From 021649494ae73912c4a0c132a9dac91a8dcc3e64 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 22 Jun 2026 18:23:48 -0700 Subject: [PATCH 01/46] docs(adr): revert failed document-ui cutover, add ADR 004 with corrected reuse plan The document-ui extraction/cutover (ADRs 002/003) was an AI-driven rewrite that broke the app; the code was reverted. Add ADR 004 as the source of truth: share @plannotator/ui as published building blocks for the Workspaces app, keep Plannotator's app unchanged, gate on human-verified parity. Banner the reverted ADRs and point AGENTS.md/CLAUDE.md at 004 so future agents don't rebuild the mess. --- AGENTS.md | 2 + ...ral-document-ui-package-20260620-083633.md | 133 ++ ...ument-ui-parity-cutover-20260621-122015.md | 89 ++ ...blished-building-blocks-20260622-180637.md | 82 ++ ...nt-ui-extraction-intent-20260620-085249.md | 287 +++++ ...i-parity-cutover-intent-20260621-122245.md | 268 +++++ ...urrent-state-and-parity-20260621-115603.md | 216 ++++ ...-ui-extraction-boundary-20260620-082002.md | 744 ++++++++++++ ...-document-ui-extraction-20260620-082343.md | 266 ++++ .../document-ui-extraction-20260620-083307.md | 1066 +++++++++++++++++ ...mpleteness-review-fixes-20260622-085528.md | 519 ++++++++ ...ument-ui-parity-cutover-20260621-121115.md | 467 ++++++++ 12 files changed, 4139 insertions(+) create mode 100644 adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md create mode 100644 adr/decisions/003-complete-document-ui-parity-cutover-20260621-122015.md create mode 100644 adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md create mode 100644 adr/implementation/document-ui-extraction-intent-20260620-085249.md create mode 100644 adr/implementation/document-ui-parity-cutover-intent-20260621-122245.md create mode 100644 adr/research/SPIKE-document-ui-current-state-and-parity-20260621-115603.md create mode 100644 adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md create mode 100644 adr/research/synthesis-document-ui-extraction-20260620-082343.md create mode 100644 adr/specs/document-ui-extraction-20260620-083307.md create mode 100644 adr/specs/document-ui-feature-completeness-review-fixes-20260622-085528.md create mode 100644 adr/specs/document-ui-parity-cutover-20260621-121115.md diff --git a/AGENTS.md b/AGENTS.md index f6b84eea4..c087d3ca7 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 / layout) in the commercial Workspaces app? Read `adr/decisions/004-reuse-document-ui-as-published-building-blocks-*.md` FIRST.** ADRs 002 and 003 (and their `document-ui-extraction` / `document-ui-parity-cutover` specs and intents) describe a reverted, failed attempt — a from-scratch reimplementation that broke the app. Do **not** implement them or recreate `packages/document-ui`. The corrected plan in 004 is: share `@plannotator/ui` as published building blocks, keep Plannotator's app unchanged, never delete working code until a human confirms parity in the browser. + ## Project Structure ``` diff --git a/adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md b/adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md new file mode 100644 index 000000000..54f3779e1 --- /dev/null +++ b/adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md @@ -0,0 +1,133 @@ +# 002. Extract a Provider-Neutral Document UI Package + +> ⚠️ **RE-SCOPED / SUPERSEDED BY ADR 004 — DO NOT IMPLEMENT AS WRITTEN.** The provider-neutral `DocumentReviewSurface` / `DocumentHostApi` approach in this ADR was attempted, broke the app, and was reverted on 2026-06-22. The corrected plan is **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`** — read it first. Kept here as history. + +Date: 2026-06-20 + +## Status + +Accepted + +## Context + +Plannotator began as a plan review UI. In Plan Mode, the Claude Code hook intercepts `ExitPlanMode`, starts the server, opens the browser, and waits for approve or deny. + +The product has since shifted. The main document workflow is now broader than plan review: users run annotate on markdown, text, HTML, URLs, folders, and last assistant messages. The same React app currently handles all of those modes. The annotate server serves document sessions through `/api/plan`, and `packages/editor/App.tsx` switches between plan review, annotate file, annotate folder, annotate message, raw HTML, archive, and goal setup. + +A sister Workspaces repo now needs the same document review experience. It should get the same Plannotator UI patterns for rendering, annotation, comments, file trees, edit state, draft restore, and feedback assembly, but with different provider mechanics: document ids instead of filesystem paths, workspace manifests instead of local directory walks, `If-Match` or version ids instead of disk hashes, and workspace APIs instead of `/api/doc` and `/api/source/save`. + +The current package split does not express this boundary. `@plannotator/ui` contains reusable primitives, but many hooks and components call hard-coded `/api/*` routes. `@plannotator/editor` contains the app shell and important document-domain behavior such as editable document state, source reconciliation, direct edits, draft restore, and agent-terminal integration. Moving only `Viewer` or renderer components would leave the hard product behavior trapped in `App.tsx` and force Workspaces to recreate it. + +The key abstraction is not local source-save. Local source-save is Plannotator's current writeback provider. The reusable concept is provider-neutral document writeback state: + +- clean +- dirty +- saving +- saved +- conflict +- missing +- error + +Plannotator local writeback uses `/api/source/save`, disk hashes, mtime, EOL metadata, file watching, and missing local files. Workspaces writeback uses workspace document APIs, `If-Match`, versions, missing document rows, and workspace restore semantics. The UI state and user experience should be shared; persistence details should belong to the host/provider. + +## Decision + +We will extract one shared package: + +```text +@plannotator/document-ui +``` + +The package will expose a product-level document review surface: + +```tsx + +``` + +The package will own the reusable document review loop: + +- markdown and raw HTML rendering +- markdown parsing and block rendering +- annotation lifecycle and highlight restoration +- comments, attachments, and annotation panel behavior +- linked document navigation +- document/file tree rendering and badges +- edit mode and edit session state +- provider-neutral writeback state +- conflict, missing, and error UI patterns +- draft save and restore behavior +- feedback and saved-change payload assembly +- code path validation UI and inline link handling +- optional Ask AI and agent-delivery integration points + +The package public contract must be provider-neutral. It must not require filesystem paths, `/api/source/save`, disk hashes, or local source-save terminology. Public types should use concepts such as `DocumentRef`, `LoadedDocument`, `DocumentReviewSession`, `DocumentHostApi`, `DocumentWritebackStatus`, `SaveDocumentRequest`, and `SaveDocumentResult`. + +Local Plannotator source-save will become the first provider implementation behind a browser-side adapter: + +```text +createPlannotatorHttpDocumentApi() +``` + +That adapter will map current routes and local source-save metadata into the provider-neutral contract: + +- `GET /api/plan` +- `GET /api/doc` +- `POST /api/doc/exists` +- `GET /api/reference/files` +- `GET /api/reference/files/stream` +- `POST /api/source/save` +- `GET/POST/DELETE /api/draft` +- `POST /api/feedback` +- `POST /api/approve` +- `POST /api/exit` + +The current routes will remain stable during extraction. We will not rename `/api/plan` as part of this work. + +Workspaces will be able to implement its own `DocumentHostApi` using workspace document ids, manifests, annotation APIs, versions, and `If-Match` behavior. That adapter does not need to live in this repository. + +The host app will continue to own runtime and environment policy: + +- CLI/plugin command interception +- server startup and browser opening +- auth +- provider implementation +- plan-mode hook stdout behavior +- plan history +- plan diff +- archive +- goal setup +- permission mode setup +- note-app settings and persistence policy +- terminal runtime, WebTUI sidecar, remote-mode security, and installer logic + +Plan review becomes one host mode that supplies a document, capabilities, and approve/deny behavior to the shared document surface. Annotate remains the reference use case because it exercises the full document experience: arbitrary files, folders, raw HTML, linked docs, writeback, drafts, and optional agent delivery. + +We will extract in phases: + +1. Create `packages/document-ui` with provider-neutral contracts. +2. Add `createPlannotatorHttpDocumentApi()` over current Plannotator routes. +3. Move document-domain state out of `packages/editor`, renaming public concepts from local source-save to provider-neutral writeback where appropriate. +4. Create `DocumentReviewSurface` around the existing viewer, HTML viewer, editor toggle, linked-doc behavior, file tree, annotation panel, drafts, and writeback state. +5. Move provider-neutral feedback assembly into the package while keeping Plannotator's agent-specific markdown wrapping in the host. +6. Shrink `packages/editor/App.tsx` into a host shell that loads the session, configures capabilities, handles plan/annotate policy, and renders `DocumentReviewSurface`. +7. Add contract and surface tests, including an in-memory provider and Bun/Pi route mapping tests. + +## Consequences + +Plannotator's document experience becomes the upstream UI for both Plannotator and Workspaces. + +The first extraction target is not just `Viewer`. The implementation must move the document-domain behavior that makes the UI useful: writeback state, draft restore, linked-doc state, comments, edit state, file tree badges, and feedback assembly. + +The package boundary will force Plannotator-local assumptions behind an adapter. Filesystem paths, disk hashes, mtime, EOL metadata, and `/api/source/save` remain valid implementation details for Plannotator local sessions, but they must not become required public fields. + +The writeback model becomes shared and provider-neutral. This gives Workspaces the same dirty/saving/saved/conflict/missing/error UI without inheriting local filesystem semantics. + +`@plannotator/ui` will likely remain a lower-level UI primitive package at first. `@plannotator/document-ui` can depend on it and gradually pull document-specific components into the new package. We will avoid a giant one-shot component move. + +Plan diff, archive, goal setup, permission mode setup, and terminal runtime stay host-owned in the first boundary. They can be exposed as optional slots or capabilities where needed, but they are not core document package responsibilities. + +Bun/Pi server parity remains required. A frontend package extraction does not remove the need to update both server implementations when route behavior changes. The first implementation should avoid route shape changes and use adapter mapping instead. + +Tests must cover both the provider-neutral package behavior and the Plannotator local adapter behavior. At minimum, we need contract tests for Bun and Pi `/api/plan` and `/api/doc` mapping, source-save-to-writeback mapping, conflict and missing writeback results, in-memory provider surface behavior, linked-doc annotation caching, draft restore, and feedback payload assembly. + +This decision increases near-term implementation work because we are extracting behavior rather than only components. It reduces long-term duplication and prevents the sister repo from reimplementing Plannotator's document state machine under a different backend. diff --git a/adr/decisions/003-complete-document-ui-parity-cutover-20260621-122015.md b/adr/decisions/003-complete-document-ui-parity-cutover-20260621-122015.md new file mode 100644 index 000000000..cb4384dd0 --- /dev/null +++ b/adr/decisions/003-complete-document-ui-parity-cutover-20260621-122015.md @@ -0,0 +1,89 @@ +# 003. Complete Document UI Parity Cutover + +> ⚠️ **REVERTED — DO NOT IMPLEMENT.** This cutover was attempted by an AI agent and failed: it produced a ~26,500-line from-scratch reimplementation, deleted the working `App.tsx`, and broke rendering (dead sidebars, wrong experience). Reverted on 2026-06-22. **This ADR is superseded by `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`** — read it before doing any document-UI work. Kept here as a post-mortem only. + +Date: 2026-06-21 + +## Status + +Accepted + +## Context + +ADR 002 established `@plannotator/document-ui` as the provider-neutral package for Plannotator's reusable document review experience. The branch has since implemented a substantial package: provider-neutral session/document types, `DocumentHostApi`, `DocumentReviewSurface`, writeback state, drafts, linked documents, document tree state, annotation persistence, feedback assembly, image handling, a Plannotator HTTP adapter, and an in-memory provider test harness. + +The package is real and green, but the app has not yet been cut over. `packages/editor/App.tsx` still owns the default Plan Review / Annotate render path. The new package surface is only mounted through an opt-in bridge behind `VITE_DOCUMENT_SURFACE=1`. + +The old shell still owns several parity-critical workflows: + +- plan diff and version browser +- richer document chrome: toolstrip, sticky controls, sidebars, panels, file/message navigation, code previews, and shortcuts +- full Ask AI panel +- agent terminal shell +- archive mode +- goal setup +- settings, share/export/import, and note integrations +- Plannotator route and environment side effects + +A sister Workspaces repo needs the same document review UI with a different provider. Keeping the shared package as an optional renderer while the hard behavior remains in `App.tsx` would recreate the coupling this extraction is meant to remove. + +## Decision + +We will finish the cutover so `@plannotator/document-ui` becomes the default production document review surface for the Plan Review / Annotate app. The feature-flagged bridge path will be removed after parity is reached. + +The package owns the reusable document review loop: + +- markdown and raw HTML document review +- annotation lifecycle, comments, global comments, image attachments, and annotation persistence hooks +- linked document navigation +- document tree/file tree UI, badges, and provider-neutral document/message navigation +- document editing and provider-neutral writeback states +- draft restore UI and state +- feedback payload assembly +- plan/document version browsing and diff UI +- generic Ask AI document-review surface when a host AI API exists +- code/link preview UI when the host can load or validate targets +- default chrome needed for parity: toolstrip, sticky controls, sidebars, panels, empty states, banners, shortcuts, and action buttons + +The host owns environment and product policy: + +- server routes, auth, browser opening, process lifetime, CLI/plugin/hook integration, and `ExitPlanMode` stdout decisions +- Plannotator settings persistence +- share/paste service policy and import/export modal policy +- Obsidian, Bear, and Octarine integrations +- agent terminal runtime, PTY/WebSocket bridge, installer, and remote security policy +- goal setup business logic +- archive storage, list loading, and archive-specific actions +- provider transport details for documents, comments, versions, and watches + +Version and diff support will move into `@plannotator/document-ui` as an optional provider-neutral capability. The host will load versions; the package will provide the default version browser, base-version selection, markdown diff computation, clean/raw diff render modes, diff annotations, edit-blocking behavior, and feedback inclusion. Plannotator will adapt `/api/plan/versions` and `/api/plan/version`; Workspaces can adapt its own document versions API without inheriting Plannotator route names. + +Archive and goal setup will not become core document-ui concepts for this cutover. Archive may be mounted through host slots or by loading read-only documents into the surface. Goal setup remains host-owned. + +Agent terminal runtime will stay host-owned. The package may provide slots and generic delivery state, but it will not own PTY, WebSocket, runtime install, remote-mode security, or terminal prompt policy. + +Ask AI will be shared only at the document-review surface level. The package may own the panel shell, document context, and in-document ask affordances. The host will own provider/model settings, auth, permission policy, and transport. + +`packages/editor/App.tsx` will be reduced to a Plannotator host shell. It should load the session through the Plannotator adapter, read settings, configure host slots and side effects, render completion/modals that remain Plannotator-owned, and render `DocumentReviewSurface`. It should no longer directly orchestrate the main document viewer, HTML viewer, plan diff viewer, annotation panel, linked-doc state machine, archive document rendering path, file/message navigation state, source-save UI state, or direct document feedback assembly. + +## Consequences + +The branch now has a concrete completion target: there should be one production document-review path, and it should go through `@plannotator/document-ui`. + +The cutover requires more work than the initial extraction because parity gaps must be closed before old code can be deleted. The biggest new package capability is provider-neutral version/diff support. + +The shared package will become larger and more product-shaped. That is intentional: the reusable value is the document review loop, not just renderer components. + +The Plannotator host shell remains necessary. It will still own routes, settings, share/export/note policy, archive storage, goal setup, terminal runtime, and hook/plugin behavior. Those are not shared document UI responsibilities. + +Workspaces gets a clear integration point: implement `DocumentHostApi` for workspace documents, manifests, annotations, writeback, and versions. It should not need to reimplement Plannotator's document state machine or inherit local source-save vocabulary. + +ADR 002 remains valid as the package boundary decision. This ADR extends it by declaring the cutover requirement and moving version/diff into the shared package as an optional capability. + +The implementation is complete only when: + +- the normal Plan Review / Annotate app renders through `@plannotator/document-ui` +- `VITE_DOCUMENT_SURFACE` is gone from production code +- the old document-review render path is removed +- plan review, annotate file, annotate folder, annotate last, raw HTML, linked docs, source-save, drafts, plan diff, Ask AI, terminal slot, archive, export/share, and note integrations still work +- Workspaces can supply a provider without depending on Plannotator local source-save terms diff --git a/adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md b/adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md new file mode 100644 index 000000000..28bad179d --- /dev/null +++ b/adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md @@ -0,0 +1,82 @@ +# 004. Reuse the Document UI as Published Building Blocks (Reverts 003, Re-scopes 002) + +Date: 2026-06-22 + +## Status + +Accepted. + +**This ADR is the single source of truth for sharing Plannotator's document UI with the commercial Workspaces app.** It **reverts ADR 003** and **re-scopes ADR 002**. + +> If you are an agent or contributor about to work on "document-ui extraction," read this ADR first. Treat ADRs 002 and 003, and their specs/intents (`adr/specs/document-ui-extraction-*`, `adr/specs/document-ui-parity-cutover-*`, `adr/implementation/document-ui-*-intent-*`), as a **post-mortem of a failed attempt** — not a plan to execute. The `packages/document-ui` package they describe was deleted. Do not recreate it. + +## Context + +### What we actually want + +The commercial app, **Workspaces**, needs to reuse Plannotator's *presentation layer*: theme, Markdown rendering, the editor, settings UI, and layout/components — including the comment/annotation *rendering*. + +Workspaces is a separate, Cloudflare-based collaborative document platform. It owns its own world: + +- documents stored as versioned blob history (Git-like), D1 metadata, a raw file-serving worker (`tot.page`) +- workspaces/folders, public/private/open sharing, raw file URLs +- document version history and restore +- anchored comments and replies, shared with teammates +- agents commenting on documents via API keys +- realtime collaboration through Durable Objects +- browser login via WorkOS (hosted) or Cloudflare Access (self-host) +- hosted at `workspaces.plannotator.ai`, raw content at `tot.page` + +The shared thing is therefore **UI building blocks, not an application.** Workspaces renders Plannotator's components and feeds them *its own* data, comments, versions, and realtime sync. Plannotator keeps feeding the same components *its* local hook/file data. Same look; different data and backend behind it. + +### What we tried before (002/003) and why it failed + +The previous attempt built a ~26,500-line, 70-file `packages/document-ui` package containing a provider-neutral `DocumentReviewSurface` + `DocumentHostApi` meant to be *the whole app* for both Plannotator and Workspaces. It then deleted Plannotator's working 4,685-line `packages/editor/App.tsx` and routed the real app through the new surface. The result did not render correctly — dead sidebars, missing chrome, a different experience. The branch was reverted on 2026-06-22 (all of it was uncommitted working-tree changes; a backup patch + archive of the dead code is in the session scratchpad). + +Root causes (these are what this ADR exists to prevent repeating): + +1. **Abstracted for a consumer that didn't exist yet.** The provider-neutral contract was designed against an imagined Workspaces backend that couldn't be run or tested. Premature/speculative generality. +2. **The method was a rewrite, not a move.** Every behavior was re-derived as a new "provider-neutral decision function" with its own unit test. ~80 such steps = a from-scratch reimplementation by construction. +3. **The acceptance bar couldn't see the failure.** Verification was `bun test` / `typecheck` / `build` only. 357 unit tests stayed green while the actual rendered app was broken. Nobody opened it. +4. **Deleted the known-good code before parity existed.** The team's own parity SPIKE measured only ~55–65% app-visible parity, yet the working shell was deleted anyway, with a demo page as the fallback. + +## Decision + +1. **Plannotator's app stays as it is.** No cutover. `packages/editor/App.tsx` and the current experience are the reference to preserve, not a thing to replace. There is no "flip the production app to a new surface" step in this plan. + +2. **Share by publishing `@plannotator/ui` (and, if a slimmer editor package is needed, a small editor package) as versioned npm packages.** Workspaces installs them as a dependency. There is no shared "whole-app surface," no `DocumentReviewSurface`, and no `DocumentHostApi`. + +3. **Shared = presentation building blocks that take their data via props/callbacks.** In scope: + - theme and color tokens (`packages/ui/theme.css`) + - Markdown parser + block renderer (`parser.ts`, `BlockRenderer`, block components) + - document viewer / editor components + - settings UI + - layout / chrome components (toolbars, sidebars, panels) + - comment / annotation **rendering** components (the visual presentation of an anchored comment and its replies) + + The real, *narrow* extraction work is this: where a shared component currently calls a hard-coded `/api/*` route or reaches into Plannotator-only globals, lift that I/O up to a prop or callback so the host supplies it. Make the components backend-agnostic. **Do not rebuild their logic.** + +4. **NOT shared — each app owns its own:** document and comment *data* and state, realtime sync, version storage, feedback/delivery, server routes, auth, and backend. Workspaces wires comments to Durable Objects and its D1/blob store; Plannotator wires them to its local hook/file model. The shared component renders what it is given and emits events; it does not know who stores the data. + +## Hard rules (these are the safeguards we lacked) + +- **Move, don't rewrite.** Relocate existing code and change import paths. If a slice produces a large amount of brand-new code, stop — that is the warning sign that you are reimplementing instead of extracting. +- **No hard-coded routes or backend assumptions in shared packages.** Data comes in via props; actions go out via callbacks. +- **Parity is the gate, and a human verifies it in the browser.** After any change, Plannotator must look and behave identically across every mode: plan review; annotate file / folder / last; raw HTML; archive; goal setup; sidebars; plan diff; keyboard shortcuts; themes; settings; editor. Passing tests are necessary but **not sufficient** — last time they were green the entire time the app was broken. +- **Never delete or replace working code until a human signs off on parity**, mode by mode. Keep the old path until the replacement is proven. +- **Small, reviewed increments.** One component family at a time, eyeballed in the running app. No day-long unattended agent runs. + +## Consequences + +- Plannotator is never at risk during this work; its app keeps running unchanged the whole time. +- Workspaces gets a real, versioned dependency (`@plannotator/ui`) it can build its own product around, without inheriting Plannotator's routes, hooks, or local-file assumptions. +- The boundary is honest and small: **share the look, own the data.** A comment renders the same in both apps; how it syncs and persists is each app's own concern. +- Publishing adds a release/version step for the shared package(s). That is the accepted cost of a clean separate-repo boundary (Workspaces is its own repo on Cloudflare). +- ADRs 002 and 003 and their specs/intents are kept as history. The 2026-06-22 review-fixes spec (`adr/specs/document-ui-feature-completeness-review-fixes-*`) remains useful as a **checklist of behaviors the UI must preserve** — but read it as an inventory, not a build plan. + +## References + +- Reverts: `adr/decisions/003-complete-document-ui-parity-cutover-20260621-122015.md` +- Re-scopes: `adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md` +- Failed-attempt parity inventory (reuse as a checklist only): `adr/specs/document-ui-feature-completeness-review-fixes-20260622-085528.md` +- Sound research on how the current system works: `adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md` diff --git a/adr/implementation/document-ui-extraction-intent-20260620-085249.md b/adr/implementation/document-ui-extraction-intent-20260620-085249.md new file mode 100644 index 000000000..e0dd7dc78 --- /dev/null +++ b/adr/implementation/document-ui-extraction-intent-20260620-085249.md @@ -0,0 +1,287 @@ +# Document UI Extraction Intent + +> ⚠️ **REVERTED — DO NOT IMPLEMENT.** Implementation log of the failed `@plannotator/document-ui` extraction (reverted 2026-06-22). The long "implemented slice" list here is a record of the from-scratch rewrite that broke the app. Corrected plan: **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`**. History only. + +Status: active + +Date: 2026-06-20 + +## Intent + +Make Plannotator's document review experience reusable by Plannotator and Workspaces without turning the shared package into either a thin renderer or a local-filesystem abstraction. + +The shared package should own the product behavior users recognize as Plannotator's document review loop: + +- render markdown and raw HTML +- annotate text and blocks +- manage comments, global comments, and image attachments +- navigate linked documents +- browse document trees +- edit documents +- show clean, dirty, saving, saved, conflict, missing, and error writeback states +- restore drafts +- assemble annotation feedback and saved-change context + +The host should own routes, auth, server calls, plan-mode hook behavior, local disk or workspace persistence, and provider-specific policy. + +## Current Read + +The strongest boundary is a single `@plannotator/document-ui` package with a provider-neutral contract: + +```tsx + +``` + +The first extraction target is not `Viewer` alone. `Viewer` is important, but the hard reusable value is the document-domain state around it: loaded document identity, linked docs, editable content, writeback state, saved-change context, draft restore, document tree badges, and feedback assembly. + +Local source-save is Plannotator's first provider. It should stay behind `createPlannotatorHttpDocumentApi()` and map into provider-neutral writeback concepts. Workspaces should be able to implement the same contract with workspace document ids, manifests, versions, `If-Match`, and its own annotation APIs. + +## Execution Path + +1. Establish provider-neutral contracts in `packages/document-ui`. +2. Add a Plannotator HTTP adapter over the current server routes. +3. Move pure document-domain state first: writeback records, draft restore shapes, saved-change tracking, and feedback edit assembly. +4. Keep existing Plannotator UI behavior working through compatibility wrappers while logic moves out of `packages/editor`. +5. Wrap the current render and annotation experience in `DocumentReviewSurface`. +6. Shrink `packages/editor/App.tsx` into a host shell that loads session data, configures capabilities, handles plan/annotate policy, and renders the shared surface. +7. Add an in-memory provider test harness so Workspaces behavior can be exercised without Plannotator-local routes. + +## Guardrails + +- Do not rename `/api/plan` or current Plannotator routes during the extraction. +- Do not expose `/api/source/save`, disk hashes, mtime, or filesystem paths as required shared-package concepts. +- Do not split the package into many small packages yet. +- Do not move plan diff, archive, goal setup, permission-mode setup, or terminal runtime into the first shared surface. +- Preserve Bun/Pi server parity when route behavior changes, but avoid route changes in the first extraction slice. +- Keep Plannotator-specific final message wrapping in the Plannotator host; move provider-neutral feedback assembly into the package. + +## Implemented Slice + +The first implementation slice is intentionally narrow but now covers the reusable document-domain contract and its first Plannotator adapter: + +- create `packages/document-ui` +- define `DocumentRef`, `LoadedDocument`, `DocumentReviewSession`, `DocumentHostApi`, and writeback result types +- add `createPlannotatorHttpDocumentApi()` over current Plannotator routes +- move writeback state into provider-neutral helpers +- move direct-edit and saved-change feedback assembly into provider-neutral helpers +- move annotation/global attachment feedback assembly into provider-neutral helpers +- add document tree state with provider-neutral row identity, expansion, aggregate counts, and writeback badges +- add linked-document cache/navigation state with linked annotation feedback entries +- add provider-neutral draft state that saves/restores annotations, linked-document annotation entries, dirty writeback documents, and saved-change context through `DocumentHostApi` +- add provider-neutral edit/writeback state that drives active edit buffers, save requests, save success, conflicts, missing documents, discard, reload-conflict, unsaved documents, and saved-change entries through `DocumentHostApi.saveDocument` +- add an initial `DocumentReviewSurface` wrapper that resolves the active document, seeds writeback state, exposes render-state, lazy-loads the existing Plannotator markdown/raw-HTML renderers, routes renderer linked-document clicks through `hostApi.resolveLinkedDocument`/`hostApi.loadDocument`, and renders provider-neutral document chrome for writeback status, draft restore, edit/save/discard, conflict overwrite, conflict reload, linked-document back/error controls, document tree/file-row navigation, and annotation/right-panel presentation for current-document comments, attachments, code annotations, linked-document feedback, unsaved writeback edits, and saved-change context +- route raw-HTML iframe local document links through the same linked-document resolver as markdown links while leaving external, anchor, unsafe, and annotation-control clicks alone +- add typed React host slots (`terminalPanel`, left/right extras, header actions, footer), Plannotator-theme layout classes, and real renderer mode options (`selection`, `comment`, `redline`, `quickLabel`, `drag`, `pinpoint`) so the default surface can carry host-specific app chrome without owning host policy +- add `createMemoryDocumentHostApi()` as a Workspaces-like in-memory provider harness with document ids, manifest trees, base-revision saves, conflict/missing results, draft round trips, linked-doc resolution, and watch events +- add provider-neutral review lifecycle actions to `DocumentReviewSurface`: assemble feedback payloads with linked annotations, unsaved direct edits, and saved-change context; call `hostApi.submitFeedback`, `hostApi.approve`, and `hostApi.exit`; clear drafts after successful terminal actions; surface action errors and default action buttons without baking in Plannotator route policy +- add provider-neutral writeback watching through `useDocumentWritebackWatch`: subscribe to `hostApi.watchDocuments`, reload changed open documents through `hostApi.loadDocument`, reconcile clean updates, preserve dirty buffers as conflicts, mark deleted/missing documents, and expose watch state from the shared surface +- add optional provider-neutral annotation persistence through `hostApi.loadAnnotations`/`hostApi.saveAnnotations`, so Workspaces can back the same comment UI with its annotations API while local Plannotator can keep relying on drafts and terminal feedback +- add Plannotator host-session normalization and editor load-plan derivation in the local HTTP adapter: `/api/plan` responses are now translated once into document-session mode flags, render mode, markdown/html payloads, annotate source, sharing settings, source-file paths, root source-save writeback, recent messages, archive/goal setup metadata, version metadata, and the concrete app-shell initialization plan before `packages/editor/App.tsx` applies React side effects +- add an explicit opt-in Plannotator app bridge for `DocumentReviewSurface`: when `VITE_DOCUMENT_SURFACE=1` or `true`, `packages/editor/App.tsx` now hands the normalized session to `PlannotatorDocumentSurfaceBridge`, which mounts `` using the Plannotator HTTP adapter while the default production path keeps the legacy Plannotator shell +- move Plannotator direct-edit feedback compatibility formatting into `@plannotator/document-ui/plannotator-feedback`, including legacy direct-edit wording, saved-file-change wording, edit badge stats, panel item builders, provider-neutral current direct-edit content resolution over live/stored edit buffers, direct-edit commit decisions for stored edits/panel reveal/remapping, direct-edit discard decisions for reset/remap behavior, direct-edit draft restore decisions with CRLF normalization and current-work-wins skipping, direct-edit feedback presence decisions, saved-change-vs-direct-edit panel precedence, and current direct-edit feedback-section gating while writeback buffers are pending; `packages/editor` now imports that behavior from the shared package +- move Plannotator source-save editable-document state and disk reconciliation into `@plannotator/document-ui` adapter exports (`plannotator-source-documents`, `plannotator-source-reconciliation`), preserving local disk hash/missing-file behavior as Plannotator compatibility while keeping it out of the provider-neutral core contract +- move the Plannotator source-document `/api/doc` probe/snapshot client into `@plannotator/document-ui/plannotator-source-client`, so source-save hash refresh, missing-file detection, and markdown snapshot loading sit with the local adapter instead of the editor shell +- move the Plannotator restored single-file draft selection helper into the source-document adapter helpers, so source-save draft restore display policy is shared rather than editor-local +- move reusable feedback text assembly into `@plannotator/document-ui/feedback-text`: current-document annotations, linked-document annotations, editor annotations, code-file annotations, multi-message feedback, empty-feedback sentinels, source-specific titles, converted-source caveats, and linked-document markdown block enrichment now live in the shared package while Plannotator delivery policy stays in the editor shell +- move reusable multi-message feedback entry assembly into `@plannotator/document-ui/feedback-text`: the shared package now converts message picker rows plus linked-session annotation state into parser-ready message feedback entries, including root markdown blocks, linked-document markdown blocks, global attachments, and code annotations; `packages/editor/App.tsx` keeps selecting/saving message state and deciding when message-mode feedback is active +- move provider-neutral feedback submission interpretation into `@plannotator/document-ui/feedback-submission`: the shared package now composes annotation text with direct-edit and saved-change sections, reports whether review content exists, distinguishes saved-change-only context from unsent feedback, produces feedback-loss wording, and decides whether approve-with-notes payloads should include feedback text +- move annotate feedback target selection into `@plannotator/document-ui/feedback-submission`: the shared package now chooses linked document, source file, active file, folder, or current-file fallback targets for annotate feedback while `packages/editor/App.tsx` keeps Plannotator's message/file feedback templates and terminal delivery side effects +- move provider-neutral annotation remapping and highlight-restore decisions into `@plannotator/document-ui/annotation-remap`: markdown edits, reloads, and draft restores can now re-anchor annotations by selected text against newly parsed blocks, preserve diff/global/checkbox annotations, clear stale positional metadata when block ids move, mark missing text with an empty block id, choose which annotations should be restored into document highlights, detect missing restored highlights through a host-provided lookup, and build missing-highlight warning copy; `packages/editor/App.tsx` keeps markdown state, edit generation, DOM lookup, highlight repaint, and toast side effects +- move Plannotator-specific route payload assembly into `@plannotator/document-ui/plannotator-delivery`: approve, deny, annotate feedback, note-integration payloads, plan-save payloads, message-scope fields, and draft-generation URL helpers now sit with the local adapter while the editor shell keeps deciding when to call each route +- add a Plannotator delivery client in `@plannotator/document-ui/plannotator-delivery` and wire `packages/editor/App.tsx` approve, deny, annotate-feedback, annotate-approve, and annotate-exit handlers through it; the editor shell still owns settings lookup, saved-change validation side effects, terminal fallback, and submitted-state UI +- move generic agent-delivery state into `@plannotator/document-ui/agent-delivery`: feedback hashing, delivery records, target matching, duplicate-send decisions, current-delivery derivation, delivered-status visibility, and feedback-to-send flags are now provider-neutral; Plannotator's terminal helper keeps only terminal prompt/target formatting and adapts to the shared record shape +- move saved-change validation decisions into `@plannotator/document-ui/saved-change-validation`: submit-time stale/unverified blocking and draft-restore kept/changed-or-missing/unverified interpretation are now shared, while Plannotator keeps toast, cleanup, and draft-scheduling side effects in the host shell +- move direct-edit begin/change state decisions into `@plannotator/document-ui/edit-feedback`: non-writeback edit sessions now normalize CRLF before seeding the edit baseline, resolve missing original baselines, and report dirty/diff state through shared direct-edit lifecycle decisions while `packages/editor/App.tsx` keeps React state, source-save branching, terminal feedback revision, and draft scheduling side effects +- move direct-edit commit/discard display decisions into `@plannotator/document-ui/edit-feedback`: stored edit content, edit-stat reset/input, edit-panel reveal, editor dirty/diff reset, and remap content now flow through shared direct-edit decisions before `packages/editor/App.tsx` applies refs and annotation repaint +- move direct-edit draft-restore display decisions into `@plannotator/document-ui/edit-feedback`: restored, skipped, and ignored draft edit outcomes now map to stored edit content, edit-stat input, editor diff reset, edit-panel reveal, and remap content before `packages/editor/App.tsx` applies refs, annotation repaint, toasts, and draft scheduling +- move document review action lifecycle state into `@plannotator/document-ui/action-controller`: submitting/exiting lanes, open-session outcomes, submitted completions, and failure recovery are now shared; `packages/editor/App.tsx` approve, deny, annotate feedback, annotate approve, annotate exit, goal-setup exit, and callback delivery paths now use the shared controller while preserving Plannotator route policy and terminal fallback behavior +- move reusable review chrome copy and surface visibility decisions into `@plannotator/document-ui/chrome`: recovered-draft messages, add-feedback prompts, saved-change awareness text, unsaved-edit warnings, unsaved writeback continuation decisions, feedback/approve/exit/primary-submit action-intent decisions, submit-shortcut routing/ignore decisions, print-shortcut routing/ignore decisions, version/diff edit-block decisions, document-navigation edit-block decisions, document layout width state, feedback-loss warnings, completion-overlay title/subtitle decisions, sticky-header visibility, annotation-toolstrip visibility, folder-empty state, normal-document visibility, inline document-control visibility, left-sidebar collapsed/expanded visibility, left-sidebar tab open/toggle/wide-exit decisions, initial/TOC sidebar preference decisions, empty-TOC auto-close decisions, document-area collapsed-sidebar offset, sidebar tab visibility, right-panel tab visibility, right-panel toggle/reveal decisions, AI-panel visibility, panel resize-handle visibility, header action visibility/control state, viewer remount identity, linked-document breadcrumb variants/back labels, document copy labels, open targets, and message-picker count state are now shared; `packages/editor/App.tsx` and `AppHeader` keep the existing dialog/components, Claude Code issue links, warning continuation callbacks, provider capability flags, agent checks, DOM event wiring, callbacks, print side effect, Plannotator-specific linked-document labels, and local storage/path wording +- move provider-neutral Ask AI context assembly into `@plannotator/document-ui/ai-context`: the shared package now derives plan vs document AI context, document targets, source metadata, raw HTML vs markdown content, thread keys/titles, general ask labels, folder-empty blocking, and readable target priority without depending on the AI provider package; `packages/editor/App.tsx` keeps `useAIChat`, provider/model settings, terminal fallback delivery, toasts, and prompt formatting +- move reusable left-sidebar tab/open state into `@plannotator/document-ui/left-sidebar`: the shared generic controller now owns active-tab/open state, raw open/close transitions, review open/toggle transitions, preference-decision application, empty-TOC auto-close application, and wide-mode exit effects; `packages/editor/App.tsx` keeps concrete Plannotator tab content, archive/file/message loading side effects, resize widths, and invokes the host-owned wide-mode exit side effect +- move reusable right-panel tab/open state into `@plannotator/document-ui/right-panel`: the shared controller now owns annotation/AI active-tab state, open/close transitions, toggle/reveal transitions, compact-viewport reveal policy, and wide-mode exit effects; `packages/editor/App.tsx` keeps resize widths, mobile layout, and invokes the host-owned wide-mode exit side effect +- move reusable annotation state, visibility, feedback-presence, and provider-mutation routing into `@plannotator/document-ui/review-state`: the shared package now owns the root annotation/code-annotation/selection/global-attachment reducer, semantic add/select/update/remove actions, opposite-selection clearing, React-style setter adapters for Plannotator compatibility hooks, local/provider annotation merging while preferring live provider copies over draft-restored duplicates, rendered-viewer vs diff annotation partitioning, message/document feedback presence/counts, and provider-vs-local edit/delete routing; `packages/editor/App.tsx` keeps external annotation route calls, DOM highlight repaint, checkbox visual overrides, file-popout opening, and linked-document cache side effects +- move linked-document annotation badge/count summaries into `@plannotator/document-ui/linked-state`: the shared package now derives per-document annotation counts from linked-document caches, scopes those counts with a host-owned containment predicate, and summarizes annotations outside the active document for right-panel badges; `packages/editor/App.tsx` keeps the legacy `useLinkedDoc` cache, Plannotator filesystem path containment predicate, file-browser highlighting timer, and `AnnotationPanel` prop shape +- move linked-document editable-load decisions into `@plannotator/document-ui/linked-state`: the shared package now decides when linked-document navigation suspends an active writeback edit, clears active editability for non-editable/HTML targets, opens a folder-linked markdown document as editable, and resets an already-open editor session from current-vs-baseline content; `packages/editor/App.tsx` keeps the Plannotator editable-document store mutations, source-save keys, and React state side effects +- move linked-document back edit-state decisions into `@plannotator/document-ui/linked-state`: returning from a linked document now gets a shared decision for whether to exit edit mode and reset active editor dirty/diff state while `packages/editor/App.tsx` keeps invoking linked-document back, file-browser active-file clearing, and archive selection clearing +- move linked-document editable snapshot decisions into `@plannotator/document-ui/linked-state`: before linked-document navigation and submission, the shared package now decides whether to snapshot live editor content, displayed content, or nothing while `packages/editor/App.tsx` keeps reading the editor handle and mutating the Plannotator editable-document store +- move reusable linked-message annotation cache helpers into `@plannotator/document-ui/linked-state`: the shared package now counts annotations across root documents, linked documents, attachments, and code comments; creates empty message annotation snapshots; normalizes immutable message root content when picker messages change; and builds per-message badge counts over a linked-session-like shape without depending on Plannotator `/api/doc` or local filesystem paths; `packages/editor/App.tsx` keeps the legacy `useLinkedDoc` hook, message picker state, code-popout side effects, and feedback-entry rendering +- move current-message annotation state and active message badge-count decisions into `@plannotator/document-ui/linked-state`: the shared package now builds the live selected-message snapshot from message rows, linked-document session snapshots, code annotations, and selected code comment ids, and overlays that live state onto cached per-message counts; `packages/editor/App.tsx` keeps storing the cache ref/state and invoking the legacy linked-doc restore side effects +- move message-state cache merge/count recomputation and annotate feedback message-scope decisions into `@plannotator/document-ui/linked-state`: the shared package now folds the live selected-message state into cached message states, produces refreshed per-message annotation counts, and decides selected-message vs multi-message feedback scope for submissions; `packages/editor/App.tsx` keeps cache refs/state setters and Plannotator route body construction +- move message selection decisions into `@plannotator/document-ui/linked-state`: the shared package now decides whether a message picker request should be ignored or should select a normalized target message state, using cached message state when present and empty message state otherwise; `packages/editor/App.tsx` keeps the actual selected-message state update, legacy linked-doc restore, and code annotation restoration side effects +- move active message annotation count summaries into `@plannotator/document-ui/linked-state`: the shared package now derives total message feedback count, annotated message ids, and has-annotation flags from active per-message counts; `packages/editor/App.tsx` keeps rendering those values and passing them to sidebar/submission policy +- move wide/focus layout mode decisions into `@plannotator/document-ui/wide-mode`: wide-mode availability, enter/toggle/forced-exit decisions, sidebar/panel snapshot capture, sidebar/panel snapshot restore, explicit sidebar reopen, explicit panel reopen, and no-restore exit behavior are now shared; `packages/editor/wideMode.ts` remains a compatibility wrapper for the old Plannotator option names +- move reusable edit/writeback chrome decisions into `@plannotator/document-ui/edit-chrome`: markdown edit availability/reason classification, save button labels/disabled/tone state, edit/done/cancel/discard labels, edit-exit click transitions, stale discard-confirmation reset decisions, dirty/failed writeback status predicates, save-shortcut document/host/ignore routing, conflict banner copy, and missing-document banner copy are now shared; `packages/editor/App.tsx` maps those neutral states to existing Tailwind classes, passes Plannotator's disk wording and surface-mode facts, and keeps host notes/export fallback behavior, while the default `DocumentReviewSurface` uses the same save-label helper +- move reusable writeback edit-session chrome state into `@plannotator/document-ui/edit-chrome`: active-buffer dirtiness, conflict overwrite availability, and cancel-mode derivation now come from provider-neutral writeback content/status inputs while `packages/editor/App.tsx` keeps Plannotator source-document field mapping and button rendering +- move Plannotator local source-save request and response mapping into `@plannotator/document-ui/plannotator-source-client`: the adapter now builds `/api/source/save` bodies, maps success metadata back to source-save capabilities, preserves conflict snapshots, and normalizes local write errors; `packages/editor/App.tsx` keeps applying those mapped results to its compatibility store, repainting annotations, and showing Plannotator toasts +- move Plannotator local source-save result application into `@plannotator/document-ui/plannotator-source-documents`: mapped save results now update the source-document compatibility store for saved, live-dirty-after-save, conflict, clean-updated, conflict-unavailable, and error outcomes; `packages/editor/App.tsx` keeps repaint, toast, panel, and draft-scheduling side effects +- move Plannotator source-save display classification into `@plannotator/document-ui/plannotator-source-documents`: saved, clean-updated, conflict, conflict-unavailable, error, and noop outcomes now map to active editor state, edit-stat inputs, repaint/reset text, panel reveal, draft-save intent, edited-buffer clearing, and notification intent before `packages/editor/App.tsx` applies React effects and toasts +- move Plannotator source-backed edit-session begin/change classification into `@plannotator/document-ui/plannotator-source-documents`: entering edit mode now normalizes displayed source text, seeds the source edit buffer, and reports disk-baseline diff state, while live editor changes update source state and report edit-session/disk-baseline dirtiness; `packages/editor/App.tsx` keeps React UI flags and draft scheduling +- move Plannotator source-backed edit-session begin/change display classification into `@plannotator/document-ui/plannotator-source-documents`: source edit begin/change outcomes now map to edit-session reset text and active editor dirty/diff state before `packages/editor/App.tsx` applies refs, React editing flags, terminal feedback revision, and draft scheduling +- move Plannotator source-backed edit-commit classification into `@plannotator/document-ui/plannotator-source-documents`: committing the editor buffer now updates the source-document compatibility store, normalizes editor line endings, and reports disk-baseline diff state; `packages/editor/App.tsx` keeps edit-stat rendering, panel opening, markdown repaint, and draft-scheduling side effects +- move Plannotator source-backed edit-commit display classification into `@plannotator/document-ui/plannotator-source-documents`: committed, clean, and ignored source-edit outcomes now map to edited-buffer clearing, edit-stat reset/input, edit-panel reveal, and normalized markdown remap content before the host applies the shared edit-display effect plan +- move Plannotator source-file discard and reload-conflict outcome/display classification into `@plannotator/document-ui/plannotator-source-documents`: source-backed discard now reports active/non-active, removed-file, and replacement-text outcomes, then maps them to active editor reset, repaint text, root empty-document reset, linked-document back-navigation intent, active-file cleanup intent, and draft-save intent; reload-conflict reports the reloaded snapshot and maps it to repaint/reset, clean edit state, draft-save intent, and notification intent; `packages/editor/App.tsx` keeps applying React state, highlights, linked-doc/file-browser effects, toasts, and draft scheduling +- move Plannotator missing source-file selection display classification into `@plannotator/document-ui/plannotator-source-documents`: selecting a missing source-backed file now maps to reopened markdown content, active source key, optional edit-session reset text, and active editor dirty/diff/stat input before `packages/editor/App.tsx` applies linked-document, file-browser, and React side effects +- move Plannotator source-backed draft restore display classification into `@plannotator/document-ui/plannotator-source-documents`: restored source drafts now decide single-file vs active-folder display, active-key selection, repaint text, edit-stat inputs, and panel reveal in the local adapter; `packages/editor/App.tsx` keeps applying React state, highlights, and draft-scheduling side effects +- move Plannotator source-backed draft restore edit-display classification into `@plannotator/document-ui/plannotator-source-documents`: restored source draft display outcomes now map to shared active editor dirty/diff/stat state and edit-panel reveal intent while `packages/editor/App.tsx` keeps remapping the newly restored annotation list before applying the shared edit-display effects +- move Plannotator source-document reconcile event classification into `@plannotator/document-ui/plannotator-source-reconciliation`: file-missing, clean-update, status-update, and conflict events now map to active-document repaint/reset, edit-state, edit-stat, and notification outcomes in the local adapter; `packages/editor/App.tsx` keeps the actual React state updates, highlight repaint, toasts, and draft scheduling +- move the default `DocumentReviewSurface` editor-session lifecycle into `@plannotator/document-ui/documentEditorSession`: begin/change/save/overwrite/discard/reload-conflict/draft restore now coordinate through the provider-neutral writeback and draft controllers instead of living inline in the renderer +- move reusable edit-display effect planning into `@plannotator/document-ui/edit-display`: repaint text, edit-session reset text, active editor dirty/diff/stat state, edit-panel reveal, draft-save intent, and edited-buffer clearing now normalize through one provider-neutral plan before the Plannotator shell applies DOM repaint, refs, toasts, and local linked-file cleanup +- move the default `DocumentReviewSurface` toolbar/sidebar chrome state into `@plannotator/document-ui/chrome`: edit/save/conflict visibility, pending action labels, document-tree sidebar visibility, and annotation-persistence badge visibility now come from a pure shared helper before the surface renders its default JSX +- make shared document-surface image upload provider-owned: `DocumentReviewSurface` now exposes and passes `hostApi.uploadImage` into markdown, raw-HTML, global, and per-comment attachment controls, while legacy `@plannotator/ui` callers still keep the `/api/upload` fallback and providers without upload support disable file upload rather than silently calling Plannotator-local routes +- make shared document-surface image display provider-owned: `DocumentReviewSurface` now passes a host-owned image URL resolver through markdown images, raw HTML blocks, attachment thumbnails, and re-edit previews; the Plannotator HTTP adapter maps local paths to `/api/image`, while generic providers can return workspace asset URLs without inheriting Plannotator-local routes +- self-review tightened the image contract: the Plannotator adapter now populates `LoadedDocument.imageBase` for root and linked documents, derives image bases from `documentRef.path` when needed, resets thumbnail load/error state when image URLs change, treats Windows absolute paths as absolute in the legacy image fallback, and treats `allowImageAttachments: false` as disabling attachment controls rather than only disabling file upload +- map Plannotator renderer linked-doc hrefs into neutral refs in the local HTTP adapter, preserving `/api/doc` base resolution without exposing local source-save as a shared-package requirement +- wire `packages/editor/App.tsx` through the Plannotator HTTP document adapter for initial session loading +- map legacy Plannotator `/api/draft` source-save records into neutral draft/writeback records in the local adapter +- keep Plannotator compatibility wrappers in `packages/editor` so current feedback wording and saved-file validation behavior stay stable + +This gives both repositories a concrete contract to evaluate before the larger React surface move. + +## Remaining Work + +- Continue visual parity work where the existing editor still owns sticky toolstrips, sidebars, plan diff, archive, goal setup, Ask AI, and other app-shell policy outside `DocumentReviewSurface`. +- Shrink `packages/editor/App.tsx` into a Plannotator host shell for plan/annotate policy, route handling, settings, and legacy plan-mode behavior. +- Decide whether threaded comment/reply history and version history live in this package contract now or stay as host-provided optional capabilities until Workspaces integration exercises them. + +## Current Verification + +Validated after the latest document-ui extraction: + +- `bun test` for the focused `packages/document-ui` suite: 101 passing tests across 17 files. +- `bun test` for the focused `packages/document-ui` suite after feedback assembly extraction: 105 passing tests across 18 files. +- `bun test` for the focused `packages/document-ui` suite after feedback submission extraction: 110 passing tests across 19 files. +- `bun test` for the focused `packages/document-ui` suite after Plannotator delivery extraction: 115 passing tests across 20 files. +- `bun test` for the focused `packages/document-ui` suite after Plannotator delivery client wiring: 117 passing tests across 20 files. +- `bun test` for the focused `packages/document-ui` suite after generic agent-delivery extraction: 120 passing tests across 21 files. +- `bun test` for the focused `packages/document-ui` suite after saved-change validation decision extraction: 123 passing tests across 21 files. +- `bun test` for the focused `packages/document-ui` suite after action-controller extraction: 128 passing tests across 22 files. +- `bun test` for the focused `packages/document-ui` suite after chrome-copy extraction: 134 passing tests across 23 files. +- `bun test` for the focused `packages/document-ui` suite after edit-chrome extraction: 138 passing tests across 24 files. +- `bun test` for the focused `packages/document-ui` suite after Plannotator source-save client extraction: 141 passing tests across 24 files. +- `bun test` for the focused `packages/document-ui` suite after Plannotator source-save result application extraction: 145 passing tests across 24 files. +- `bun test` for the focused `packages/document-ui` suite after Plannotator source-file discard/reload outcome extraction: 151 passing tests across 24 files. +- `bun test` for the focused `packages/document-ui` suite after Plannotator source-backed edit-commit extraction: 155 passing tests across 24 files. +- `bun test` for the focused `packages/document-ui` suite after Plannotator source-backed edit-session begin/change extraction: 161 passing tests across 24 files. +- `bun test` for the focused `packages/document-ui` suite after Plannotator source-document reconcile event classification extraction: 165 passing tests across 24 files. +- `bun test` for the focused `packages/document-ui` suite after annotation-remap extraction: 170 passing tests across 25 files. +- `bun test` for the focused `packages/document-ui` suite after annotation highlight-restore helper extraction: 173 passing tests across 25 files. +- `bun test` for the focused `packages/document-ui` suite after edit-availability extraction: 176 passing tests across 25 files. +- `bun test` for the focused `packages/document-ui` suite after viewport-state extraction: 179 passing tests across 25 files. +- `bun test` for the focused `packages/document-ui` suite after wide-mode extraction: 185 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after right-panel state extraction: 187 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after left-sidebar state extraction: 189 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after sidebar-tab state extraction: 191 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after unsaved writeback continuation extraction: 193 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after review action-intent extraction: 197 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after header action-state extraction: 200 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after submit-shortcut gate extraction: 203 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after save-shortcut decision extraction: 205 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after right-panel toggle extraction: 207 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after left-sidebar tab decision extraction: 209 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after sidebar preference decision extraction: 213 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after wide-mode enter/toggle decision extraction: 219 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after right-panel reveal decision extraction: 221 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after right-panel controller extraction: 226 passing tests across 27 files. +- `bun test` for the focused `packages/document-ui` suite after left-sidebar controller extraction: 231 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after annotation visibility/count extraction: 235 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after annotation mutation-routing extraction: 238 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after root annotation-state hook wiring: 240 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after annotation reducer-action wiring: 241 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after linked-message annotation cache extraction: 243 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after multi-message feedback entry extraction: 244 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after current-message state/count extraction: 247 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after message cache/scope extraction: 249 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after message selection decision extraction: 251 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after message count-summary extraction: 252 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after linked-document file badge-count extraction: 255 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after document chrome identity/label extraction: 257 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after Ask AI context extraction: 262 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after print shortcut decision extraction: 263 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after version/diff edit-block extraction: 264 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after document-navigation edit-block extraction: 265 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after document layout width extraction: 266 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after edit-exit transition extraction: 268 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after current direct-edit content resolver extraction: 269 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after direct-edit feedback/panel extraction: 271 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after current direct-edit feedback-section gate extraction: 273 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after linked-document editable-load decision extraction: 275 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after direct-edit commit decision extraction: 276 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after direct-edit discard decision extraction: 277 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after direct-edit draft restore decision extraction: 278 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after source-backed draft restore display extraction: 282 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after source-save display classification extraction: 287 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after source discard/reload display classification extraction: 293 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after shared edit-panel presentation extraction: 295 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after shared default editor-session extraction: 300 passing tests across 30 files. +- `bun test` for the focused `packages/document-ui` suite after shared edit-display effect planning extraction: 305 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after source-backed edit-commit display classification extraction: 309 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after direct-edit commit/discard display decision extraction: 312 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after direct-edit draft-restore display decision extraction: 313 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after direct-edit begin/change state decision extraction: 315 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after source-backed edit-session begin/change display classification extraction: 317 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after writeback edit-session chrome-state extraction: 318 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after missing source-file selection display classification extraction: 321 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after source-backed draft restore edit-display classification extraction: 322 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after linked-document back edit-state decision extraction: 323 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after linked-document editable snapshot decision extraction: 324 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after annotate feedback target selection extraction: 325 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after agent-delivery state derivation extraction: 326 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after Plannotator editor load-plan extraction: 326 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after default surface chrome-state extraction: 327 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after provider-owned image upload threading: 328 passing tests across 31 files. +- `bun test packages/document-ui/DocumentReviewSurface.test.tsx packages/document-ui/plannotatorHttpApi.test.ts packages/ui/components/html-viewer/bridge-script.test.ts`: 29 passing tests. +- `bun test` for the focused `packages/document-ui` suite after provider-owned image display threading: 329 passing tests across 31 files. +- `bun test packages/document-ui/DocumentReviewSurface.test.tsx packages/document-ui/plannotatorHttpApi.test.ts packages/ui/components/html-viewer/bridge-script.test.ts` after ADR self-review fixes: 29 passing tests. +- `bun test` for the focused `packages/document-ui` suite after ADR self-review fixes: 329 passing tests across 31 files. +- `bun run typecheck` after ADR self-review fixes. +- `bun build packages/document-ui/DocumentReviewSurface.tsx --target browser --outdir /tmp/plannotator-document-ui-build` after ADR self-review fixes. +- `VITE_DOCUMENT_SURFACE=1 bun run --cwd apps/hook build` after ADR self-review fixes. +- `bun run --cwd apps/hook build` after ADR self-review fixes. +- `bun run --cwd apps/review build` after ADR self-review fixes. +- `git diff --check` after ADR self-review fixes. +- `bun run typecheck` after provider-owned image display threading. +- `bun build packages/document-ui/DocumentReviewSurface.tsx --target browser --outdir /tmp/plannotator-document-ui-build`. +- `VITE_DOCUMENT_SURFACE=1 bun run --cwd apps/hook build`. +- `bun run --cwd apps/hook build`. +- `bun run --cwd apps/review build`. +- `git diff --check`. +- `bun test packages/document-ui/DocumentReviewSurface.test.tsx packages/ui/components/html-viewer/bridge-script.test.ts`: 16 passing tests. +- `bun test packages/document-ui/documentReviewChrome.test.ts packages/document-ui/DocumentReviewSurface.test.tsx`: 55 passing tests. +- `bun test packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/editor/documentSurfaceBridge.test.ts`: 3 passing tests. +- `bun test packages/editor/documentSurfaceBridge.test.ts`: 2 passing tests. +- `bun test packages/editor/documentSurfaceBridge.test.ts packages/document-ui/DocumentReviewSurface.test.tsx`: 13 passing tests. +- `bun test packages/document-ui/documentAIContext.test.ts`: 5 passing tests. +- `bun test packages/document-ui/documentFeedbackText.test.ts`: 5 passing tests. +- `bun test packages/document-ui/documentFeedbackSubmission.test.ts`: 6 passing tests. +- `bun test packages/document-ui/documentAgentDelivery.test.ts`: 4 passing tests. +- `bun test packages/document-ui/plannotatorHttpApi.test.ts`: 12 passing tests. +- `bun test packages/document-ui/documentEditDisplay.test.ts`: 6 passing tests. +- `bun test packages/document-ui/documentEditorSession.test.ts`: 5 passing tests. +- `bun test packages/document-ui/DocumentReviewSurface.test.tsx`: 11 passing tests. +- `bun test packages/document-ui/editFeedback.test.ts packages/document-ui/plannotatorFeedback.test.ts`: 29 passing tests. +- `bun test packages/document-ui/plannotatorSourceDocuments.test.ts`: 60 passing tests. +- `bun test packages/document-ui/documentLinkedState.test.ts`: 20 passing tests. +- `bun test packages/document-ui/documentReviewState.test.ts`: 14 passing tests. +- `bun test packages/document-ui/documentEditChrome.test.ts`: 12 passing tests. +- `bun test packages/document-ui/documentReviewChrome.test.ts`: 43 passing tests. +- `bun test packages/document-ui/documentReviewLeftSidebar.test.ts`: 5 passing tests. +- `bun test packages/document-ui/documentReviewRightPanel.test.ts`: 5 passing tests. +- `bun test packages/document-ui/documentWideMode.test.ts packages/editor/wideMode.test.ts`: 18 passing tests. +- `bun test packages/ui/components/html-viewer/bridge-script.test.ts`: 4 passing tests. +- `bun run --cwd apps/review build`. +- `bun run --cwd apps/hook build`. +- `VITE_DOCUMENT_SURFACE=1 bun run --cwd apps/hook build`. +- `bun build packages/document-ui/DocumentReviewSurface.tsx --target browser --outdir /tmp/plannotator-document-ui-build`. +- `bun run typecheck`. +- `git diff --check`. + +## Next Slice + +The next highest-value extraction is the remaining app-shell coupling around the review surface: sticky toolstrip components, sidebars, plan diff/archive/goal setup, source-file discard/reload side effects, and actual editor/toolbar layout still live directly in `packages/editor/App.tsx`. Text assembly, submission interpretation, annotate feedback target selection, root annotation state, annotation visibility/counting, annotation provider-mutation routing, linked-document file badge/count summaries, linked-document editable-load decisions, linked-document back edit-state decisions, linked-document editable snapshot decisions, linked-message annotation cache/counting/current-state/scope/selection/count-summary decisions, multi-message feedback entry assembly, annotation remapping/highlight-restore decisions, Ask AI context assembly, Plannotator route payload shapes, Plannotator route calls, Plannotator host-session/editor load-plan mapping, opt-in `DocumentReviewSurface` app bridge, saved-change validation decisions, action lifecycle state, review chrome copy, document chrome identity/labels, direct-edit content resolution, direct-edit feedback/panel decisions, direct-edit begin/change state decisions, direct-edit commit/discard/draft-restore decisions, direct-edit commit/discard/draft-restore display decisions, current direct-edit feedback-section gating, unsaved writeback continuation decisions, review action-intent decisions, submit/print shortcut gate decisions, version/diff edit-block decisions, document-navigation edit-block decisions, document layout width state, header action-state decisions, viewport visibility, left-sidebar state/layout/tab visibility/tab open-toggle decisions/sidebar preference decisions, right-panel state/visibility/toggle/reveal decisions, wide/focus layout mode enter/toggle/exit decisions, edit/writeback chrome decisions, writeback edit-session chrome state, edit-exit transition decisions, save-shortcut writeback routing, markdown edit availability, shared edit-panel presentation for unsaved/saved writeback edits, shared default renderer editor-session lifecycle, shared edit-display effect planning, Plannotator local source-save request/response mapping, source-save result application/display classification, source-backed edit-session begin/change/commit classification, source-backed edit-session begin/change display classification, source-backed edit-commit display classification, source-file discard/reload outcome/display classification, missing source-file selection display classification, source-backed draft restore display/edit-display classification, source-document reconcile event classification, and generic agent-delivery state now sit in `@plannotator/document-ui`; the terminal runtime, prompt formatting, plan-mode warnings, Claude Code issue-link markup, route policy, provider capability flags, agent checks, DOM event wiring, host notes/export fallback behavior, external annotation route calls, checkbox visual overrides, and Plannotator compatibility-store/toast/panel side effects remain host-owned. + +## References + +- Research: `adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md` +- Synthesis: `adr/research/synthesis-document-ui-extraction-20260620-082343.md` +- Spec: `adr/specs/document-ui-extraction-20260620-083307.md` +- Decision: `adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md` diff --git a/adr/implementation/document-ui-parity-cutover-intent-20260621-122245.md b/adr/implementation/document-ui-parity-cutover-intent-20260621-122245.md new file mode 100644 index 000000000..8d4593514 --- /dev/null +++ b/adr/implementation/document-ui-parity-cutover-intent-20260621-122245.md @@ -0,0 +1,268 @@ +# Document UI Parity Cutover Intent + +> ⚠️ **REVERTED — DO NOT IMPLEMENT.** Implementation log of the failed cutover (reverted 2026-06-22). Corrected plan: **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`**. History only. + +Date: 2026-06-21 + +## What + +We are finishing the `@plannotator/document-ui` extraction by making it the production document-review surface for Plan Review and Annotate. The package already contains much of the provider-neutral document state, rendering, writeback, draft, linked-document, annotation, feedback, image, and Plannotator adapter work. The remaining intent is to close the parity gaps, remove the `VITE_DOCUMENT_SURFACE` opt-in path, and shrink `packages/editor/App.tsx` into a Plannotator host shell instead of keeping it as a second document-review implementation. + +The shared package should own the reusable experience that Workspaces also needs: markdown and raw HTML review, annotations, attachments, linked documents, document trees, document/message navigation, edit/writeback states, drafts, feedback assembly, version/diff browsing, generic Ask AI surface behavior, code/link previews, and the default chrome around those workflows. Plannotator-specific policy should remain outside the package: routes, settings, share/export/note behavior, archive storage, goal setup, terminal runtime, plugin/hook behavior, and local source-save transport details. + +## Why + +The point of this branch is not to create an optional renderer beside the old app. The point is to make the Plannotator document review experience reusable by a sister Workspaces repo without forcing Workspaces to reimplement the hard state machine in `App.tsx`. If the package stays opt-in and the old shell remains the real product path, the extraction fails in practice: Workspaces gets components, but not the product behavior users recognize. + +The provider boundary matters because Plannotator local source-save and Workspaces document writeback are different implementations of the same user-facing states. Plannotator uses `/api/source/save`, disk hashes, mtime, file watches, missing local files, and local drafts. Workspaces will use document ids, manifests, versions, `If-Match`, annotation APIs, and workspace-specific missing/conflict behavior. The UI should share clean, dirty, saving, saved, conflict, missing, error, draft restore, feedback assembly, and version diff behavior without requiring either provider to pretend it is the other. + +## How + +The implementation should proceed by closing package parity first, then cutting over the app. The first major missing package capability is provider-neutral version and diff support: add host API methods for listing and loading document versions, map Plannotator's `/api/plan/versions` and `/api/plan/version`, and move the version browser, diff view modes, diff annotations, and edit-blocking behavior into `@plannotator/document-ui`. This should be optional capability, so Workspaces can implement it with its own versions API and hosts without versions do not see the UI. + +After version/diff, the default `DocumentReviewSurface` chrome needs to reach visible parity with the old shell for the generic document workflows: toolstrip, sticky controls, wide/focus controls, sidebars, panel resize/collapse behavior, folder empty state, file/message navigation, linked-document chrome, code/link preview, shortcuts, and raw HTML controls. File and message browsing should become provider-neutral document navigation through `DocumentRef` and `DocumentTreeNode`, while local filesystem containment, vault retry behavior, and Plannotator route details stay in the Plannotator adapter or host. + +Writeback should then become authoritative inside the package. The old app should stop owning duplicate source-save UI state, edit/save/discard/reload-conflict behavior, draft restore decisions, and direct feedback assembly. Plannotator source-save remains first-class in the Plannotator adapter, but the shared contract remains writeback-oriented rather than disk-oriented. + +Ask AI should move only as far as the reusable document-review surface. The package can own document context, an AI panel shell, and in-document ask affordances when `hostApi.askAI` exists. The host keeps provider/model settings, auth, permission handling, terminal fallback behavior, and provider-specific transport. + +Terminal, archive, goal setup, settings, export/share/import, and note integrations should be mounted around or beside the package surface through host shell code and slots. The terminal runtime, PTY bridge, installer, remote-mode security, archive storage, goal setup semantics, and note-app policy are not shared document-ui responsibilities. + +The final cutover is to remove the feature flag and delete the old document render path. At that point `packages/editor/App.tsx` should load the Plannotator session, configure the Plannotator document API and host slots, render completion/modals that remain Plannotator-owned, and mount `DocumentReviewSurface`. It should no longer directly orchestrate `Viewer`, `HtmlViewer`, `PlanDiffViewer`, `AnnotationPanel`, `usePlanDiff`, `useLinkedDoc`, `useArchive`, file/message navigation, source-save UI state, or feedback assembly for the main document path. + +Completion means the normal app renders through `@plannotator/document-ui` with no `VITE_DOCUMENT_SURFACE` flag, the old document-review path is gone, existing Plannotator workflows still work, and Workspaces can implement the same UI through `DocumentHostApi` without inheriting local source-save vocabulary. + +## Implemented Slice: Provider-Neutral Version/Diff + +The first cutover slice moved version/diff support into the package boundary instead of leaving it only in the old editor shell. + +`@plannotator/document-ui` now defines provider-neutral version types and host API methods for listing and loading document versions. `DocumentReviewSurface` owns version state through `useDocumentVersions`, computes markdown diffs through the shared diff engine, exposes version state through the render state, renders a default version navigator, and can switch the document body into a package-owned clean/classic/raw diff view. + +The Plannotator HTTP adapter maps the existing `/api/plan/versions` and `/api/plan/version` routes into the provider-neutral contract. It also seeds the session with `previousPlan` and `versionInfo`, so the package can show previous-version changes without forcing an immediate extra fetch. The memory host API now supports version seeds, version listing, and version loading so Workspaces-like behavior can be tested without local Plannotator routes. + +This slice deliberately does not move Plannotator's VS Code diff route into the shared package. That route is local host policy. The package now owns the reusable document diff surface; richer route-specific actions can be added later through host actions or slots. + +Verification: + +- `bun test packages/document-ui`: 332 passing tests. +- `bun test packages/document-ui/DocumentReviewSurface.test.tsx packages/document-ui/plannotatorHttpApi.test.ts packages/document-ui/memoryDocumentHostApi.test.ts`: 37 passing tests. +- `bun run --cwd packages/document-ui typecheck`. +- `bun build packages/document-ui/DocumentReviewSurface.tsx --target browser --outdir /tmp/plannotator-document-ui-build`. +- `git diff --check`. + +Full repo `bun run typecheck` is still blocked before `packages/document-ui` by existing `packages/ui` type errors in `AnnotationToolbar.tsx` and `config/settings.ts`; the document-ui package typecheck itself passes. + +## Implemented Slice: Default Surface Routing and Delivery Parity + +The next cutover slice removed the runtime `VITE_DOCUMENT_SURFACE` gate from the editor app. `packages/editor/App.tsx` now routes normal Plan Review and Annotate document sessions through `PlannotatorDocumentSurfaceBridge` by default when a `DocumentReviewSession` is available. The bridge eligibility is now mode-based (`plan-review`, `annotate`, `annotate-folder`, `annotate-message`) and explicitly leaves shared sessions, archive, goal setup, and demo fallback on the host shell for now. + +The package action contract now carries draft generation and approve feedback. `DocumentReviewSurface` passes those values to the host API for submit, approve, and exit actions. The Plannotator HTTP adapter maps those provider-neutral actions through the existing production delivery helpers: + +- Plan Review feedback uses `/api/deny` with plan-save settings and draft generation. +- Plan Review approval uses `/api/approve` with plan-save, permission mode, agent switch, note-app settings, optional approve-with-feedback text, document text, and draft generation. +- Annotate feedback uses `/api/feedback` with annotations, code annotations, message scope, and draft generation. +- Annotate approve/exit use the existing draft-generation query parameter routes. + +The editor bridge remains the owner of Plannotator host policy. It supplies plan-save settings, permission mode, agent-switch preference, note-app configuration, and note auto-save status through the adapter context instead of moving those settings into `@plannotator/document-ui`. + +Shared feedback rendering now includes direct-edit and saved-file-change sections in addition to annotations, linked-document feedback, and code annotations. That keeps edit feedback intact when the package surface submits through the Plannotator adapter. + +This slice also fixed the previous full-repo typecheck blockers in `packages/ui/components/AnnotationToolbar.tsx` and `packages/ui/config/settings.ts`. + +Verification: + +- `bun test packages/editor/documentSurfaceBridge.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/document-ui/DocumentReviewSurface.test.tsx packages/document-ui/plannotatorHttpApi.test.ts`: 32 passing tests. +- `bun test packages/document-ui packages/editor/documentSurfaceBridge.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx`: 337 passing tests. +- `bun test packages/editor`: 51 passing tests, 7 skipped existing hook tests. +- `bun run typecheck`. +- `bun run --cwd apps/hook build`. +- `git diff --check`. +- `rg -n "VITE_DOCUMENT_SURFACE|USE_DOCUMENT_SURFACE" packages apps --glob '!**/dist/**' --glob '!**/node_modules/**'` returned no code matches. + +Remaining cutover work: the old `packages/editor/App.tsx` document-review implementation is still present for archive, goal setup, shared sessions, and fallback/demo paths, and much of the old main-path code still exists in the file even though normal Plan Review and Annotate sessions now route through the package bridge. The final cleanup still needs host-slot parity for settings/share/export/note/archive/goal/terminal surfaces and then deletion of the duplicate old document-review orchestration. + +## Implemented Slice: Host Router Before Legacy Shell + +The default app no longer enters the legacy `App.tsx` hook graph before mounting the shared document surface. `packages/editor/App.tsx` now exports a thin router that renders `PlannotatorDocumentSurfaceHost` first, with the renamed legacy shell (`LegacyPlannotatorApp`) only as a fallback. + +`PlannotatorDocumentSurfaceHost` owns the Plannotator host bootstrap for normal document sessions: it loads `/api/plan` through the Plannotator document adapter, initializes config, chooses package-surface eligibility from the `DocumentReviewSession`, handles first-time Claude permission setup, preserves plan-arrival note auto-save behavior, computes completion copy, and mounts `PlannotatorDocumentSurfaceBridge`. Shared URL shapes (`/p/` and share-looking hash payloads) bypass package preloading so the existing legacy share loader can still restore shared documents without an API session. + +The legacy shell no longer stores a `documentSurfaceSession` or contains a second `PlannotatorDocumentSurfaceBridge` early return. That means normal Plan Review and Annotate sessions reach `@plannotator/document-ui` before legacy annotation, edit, diff, sidebar, and viewer hooks mount. Archive, goal setup, shared sessions, and demo/API-failure fallback still use the legacy shell. + +Verification: + +- `bun test packages/editor/PlannotatorDocumentSurfaceHost.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/editor/documentSurfaceBridge.test.ts`: 5 passing tests. +- `bun test packages/editor`: 53 passing tests, 7 skipped existing hook tests. +- `bun test packages/document-ui`: 334 passing tests. +- `bun run typecheck`. +- `bun run --cwd apps/hook build`. +- `git diff --check`. + +Remaining cutover work: move or slot the still-host-owned archive, goal setup, shared-session, settings/share/export/note, and terminal surfaces so the legacy shell can be deleted instead of retained as fallback. The renamed `LegacyPlannotatorApp` still contains the old document-review orchestration for those fallback paths. + +## Implemented Slice: Thin Editor Entrypoint + +The editor entrypoint is now a real host shell instead of the giant legacy implementation. The previous `packages/editor/App.tsx` body was moved to `packages/editor/LegacyPlannotatorApp.tsx`, and `packages/editor/App.tsx` is now a small wrapper that mounts `PlannotatorDocumentSurfaceHost` with `LegacyPlannotatorApp` as fallback. + +This does not delete the old implementation yet, but it makes the production entrypoint shape match ADR 003: the app entry configures a Plannotator host route and the package surface is tried first. The old document-review orchestration is isolated behind a legacy fallback module for archive, goal setup, shared URLs, and demo/API-failure cases. + +Verification: + +- `wc -l packages/editor/App.tsx packages/editor/LegacyPlannotatorApp.tsx packages/editor/PlannotatorDocumentSurfaceHost.tsx` showed `App.tsx` at 9 lines and the legacy fallback isolated in `LegacyPlannotatorApp.tsx`. +- `bun test packages/editor/PlannotatorDocumentSurfaceHost.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/editor/documentSurfaceBridge.test.ts`: 5 passing tests. +- `bun test packages/editor`: 53 passing tests, 7 skipped existing hook tests. +- `bun test packages/document-ui`: 334 passing tests. +- `bun run typecheck`. +- `bun run --cwd apps/hook build`. +- `git diff --check`. + +Remaining cutover work: delete `LegacyPlannotatorApp.tsx` by replacing its remaining fallback responsibilities with package-owned document review behavior plus small host-owned shells/slots for archive, goal setup, shared-session loading, settings/share/export/note, and terminal runtime. + +## Implemented Slice: Goal Setup Host Cutover + +Goal setup is now routed before the legacy shell. `PlannotatorDocumentSurfaceHost` recognizes `mode: "goal-setup"` from the Plannotator adapter, normalizes the goal setup bundle, initializes host config, and renders a new `PlannotatorGoalSetupHost` instead of falling through to `LegacyPlannotatorApp`. + +This keeps the ADR boundary intact: goal setup remains host-owned environment workflow, not package-owned document review behavior. The new host shell wraps the existing `GoalSetupSurface`, supplies the top-level Submit and Close actions, posts close through the existing `/api/exit` endpoint, and uses the same completion overlay copy as the document surface. + +Verification: + +- `bun test packages/editor/PlannotatorGoalSetupHost.test.tsx packages/editor/PlannotatorDocumentSurfaceHost.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/editor/documentSurfaceBridge.test.ts`: 6 passing tests. +- `bun test packages/editor`: 54 passing tests, 7 skipped existing hook tests. +- `bun test packages/document-ui`: 334 passing tests. +- `bun run typecheck`. +- `bun run --cwd apps/hook build`. + +Remaining cutover work: delete `LegacyPlannotatorApp.tsx` by replacing its remaining fallback responsibilities with package-owned document review behavior plus small host-owned shells/slots for archive, shared-session loading, settings/share/export/note, and terminal runtime. Goal setup no longer needs the legacy shell on the normal API path. + +## Implemented Slice: Archive Host Cutover + +Standalone archive mode is now routed before the legacy shell. `PlannotatorDocumentSurfaceHost` recognizes archive content from the Plannotator adapter and renders a new `PlannotatorArchiveHost` instead of entering `LegacyPlannotatorApp`. + +The archive shell keeps archive storage and lifecycle host-owned. It uses the existing archive API routes (`/api/archive/plans`, `/api/archive/plan`, `/api/done`), reuses `ArchiveBrowser` for the saved-plan list, reuses the existing markdown `Viewer` for rendering archived plans, and keeps the archive completion overlay behavior. The `Viewer` import is browser-lazy so non-browser host imports and unit tests do not load `web-highlighter` before `window` exists. + +Verification: + +- `bun test packages/editor/PlannotatorArchiveHost.test.tsx packages/editor/PlannotatorGoalSetupHost.test.tsx packages/editor/PlannotatorDocumentSurfaceHost.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/editor/documentSurfaceBridge.test.ts`: 7 passing tests. +- `bun test packages/editor`: 55 passing tests, 7 skipped existing hook tests. +- `bun run typecheck`. +- `bun run --cwd apps/hook build`. + +Remaining cutover work: delete `LegacyPlannotatorApp.tsx` by replacing its remaining fallback responsibilities with shared-session loading, demo/API-failure fallback, settings/share/export/note host actions, and terminal runtime slots. Goal setup and standalone archive no longer need the legacy shell on their normal API paths. + +## Implemented Slice: Shared Session Host Cutover + +Shared URL sessions now route before the legacy shell. `PlannotatorDocumentSurfaceHost` still bypasses `/api/plan` preloading for share-shaped URLs, but it now renders `PlannotatorSharedSessionHost` instead of falling through to `LegacyPlannotatorApp`. + +The shared host keeps share/callback policy host-owned while using the package document surface. It decodes hash payloads and short `/p/` paste-service links into a provider-neutral in-memory document session, seeds shared annotations and global attachments into `DocumentReviewSurface`, and disables provider persistence/drafts for the portable shared context. Shared sessions expose a host-owned `Copy Link` header action that assembles an updated share URL from the current package feedback payload. Bot callback links (`cb`/`ct`) are handled as host delivery: package submit/approve actions call back with an updated annotated share URL, but the package remains unaware of Plannotator's share URL format. + +To support host-owned share/export actions without coupling them into the package, `DocumentReviewSurface` header action slots can now be render functions that receive the current feedback payload and action helpers. Existing static slot nodes continue to work. + +Verification: + +- `bun test packages/editor/sharedDocumentSession.test.ts packages/editor/PlannotatorSharedSessionHost.test.tsx packages/editor/PlannotatorDocumentSurfaceHost.test.ts packages/document-ui/DocumentReviewSurface.test.tsx`: 19 passing tests. +- `bun test packages/editor`: 58 passing tests, 7 skipped existing hook tests. +- `bun test packages/document-ui`: 335 passing tests. +- `bun run typecheck`. +- `bun run --cwd apps/hook build`. + +Remaining cutover work: delete `LegacyPlannotatorApp.tsx` by replacing its remaining fallback responsibilities with demo/API-failure fallback, fuller settings/share/export/note host actions, and terminal runtime slots. Normal document review, annotate, goal setup, standalone archive, and shared URL sessions no longer need the legacy shell on their normal paths. + +## Implemented Slice: Annotate Agent Terminal Host Slot Cutover + +Annotate agent terminal delivery is now wired into the package-backed production document surface without moving the terminal runtime into `@plannotator/document-ui`. + +`PlannotatorDocumentSurfaceHost` passes the Plannotator terminal capability from the loaded `/api/plan` session into `PlannotatorDocumentSurfaceBridge`. The bridge keeps the terminal runtime host-owned: it lazy-loads `AnnotateAgentTerminalPanel`, mounts it through the existing `DocumentReviewSurface` terminal slot, and exposes host header actions for opening, hiding, stopping, and sending feedback to the agent terminal. + +The default package submit action now preserves the legacy annotate behavior when a terminal session is ready. The bridge renders the current package feedback payload, wraps it with the Plannotator annotate feedback template, sends it to the terminal, records the delivery key, clears the draft through `DocumentReviewSurface`, and keeps the review session open. Duplicate sends of the same feedback/body/target in the same terminal session are treated as already delivered. If terminal delivery fails, the bridge falls back to the original `/api/feedback` submit path. + +The package contract gained a small provider-neutral submit result flag, `keepSessionOpen`, so host-owned delivery channels can clear drafts without forcing the completion overlay. The package still does not own PTY/WebSocket setup, runtime install, remote-mode security, agent selection, or terminal prompt policy. + +Verification: + +- `bun test packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/document-ui/DocumentReviewSurface.test.tsx`: 17 passing tests. +- `bun run --cwd packages/document-ui typecheck`. +- `bun test packages/editor`: 59 passing tests, 7 skipped existing hook tests. +- `bun run typecheck`. +- `bun test packages/document-ui`: 336 passing tests. +- `bun run --cwd apps/hook build`. +- `git diff --check`. + +Remaining cutover work: delete `LegacyPlannotatorApp.tsx` by replacing its remaining fallback responsibilities with demo/API-failure fallback and fuller settings/share/export/note host actions. The annotate terminal no longer needs the legacy shell on the normal package-backed annotate file/folder paths. + +## Implemented Slice: Legacy Shell Deletion And Package Fallback + +The editor app no longer imports or mounts `LegacyPlannotatorApp`. `packages/editor/App.tsx` now mounts `PlannotatorDocumentSurfaceHost` directly, and the previous legacy fallback branch in `PlannotatorDocumentSurfaceHost` has been replaced by a package-backed fallback route. + +The fallback route is intentionally small and host-owned. If there is no active `/api/plan` session, or if a future unsupported session mode reaches the host, `PlannotatorDocumentSurfaceFallback` renders the existing demo plan through `DocumentReviewSurface` with an in-memory provider. This keeps development/no-server behavior available without preserving a second document-review implementation. + +`packages/editor/LegacyPlannotatorApp.tsx` has been deleted. Source checks now confirm there are no remaining `LegacyPlannotatorApp`, `status: 'legacy'`, `VITE_DOCUMENT_SURFACE`, `USE_DOCUMENT_SURFACE`, or `documentSurfaceSession` references in production packages/apps outside built artifacts. + +Verification: + +- `bun test packages/editor`: 60 passing tests, 7 skipped existing hook tests. +- `bun test packages/document-ui`: 336 passing tests. +- `bun run typecheck`. +- `bun run --cwd apps/hook build`. +- `git diff --check`. +- `test ! -e packages/editor/LegacyPlannotatorApp.tsx`. +- `rg -n "LegacyPlannotatorApp|status: 'legacy'|VITE_DOCUMENT_SURFACE|USE_DOCUMENT_SURFACE|documentSurfaceSession" packages apps --glob '!**/dist/**' --glob '!**/node_modules/**'` returned no matches. + +Remaining cutover work: fuller Plannotator host actions for settings, share/export/import, and quick note saves still need to be mounted around the package surface. The duplicate legacy document-review implementation is no longer present. + +## Implemented Slice: Plannotator Host Actions Around Package Surface + +Normal package-backed Plan Review and Annotate sessions now have Plannotator host actions mounted through the `DocumentReviewSurface` header slot. `PlannotatorDocumentSurfaceBridge` renders the existing host-owned `PlanHeaderMenu`, `Settings`, `ExportModal`, and `ImportModal` components around the package surface. + +The bridge prepares export/share/note data from the current package feedback payload rather than from legacy app state. It renders annotation output through the shared feedback assembler, uses the active document text or current edit/save payload for exported markdown, generates hash share URLs and paste-service short URLs through the existing Plannotator sharing utilities, downloads annotations, prints, and posts quick note saves to `/api/save-notes` for Obsidian, Bear, and Octarine. Settings remain Plannotator host policy and are conditionally mounted only when opened so server-rendered tests do not load browser-only settings stores. + +The package boundary remains intact: `@plannotator/document-ui` still receives only generic header slots, feedback payload callbacks, and provider-neutral annotation import actions. Plannotator share URLs, paste-service policy, note-app settings, and the settings UI stay in `packages/editor`/`@plannotator/ui`. + +Import review now decodes Plannotator hash links and paste-service short links in the host bridge, converts share payload annotations through the existing sharing utilities, and merges them into the package surface through the provider-neutral annotation import slot action. The host still owns Plannotator URL formats; the package owns only the annotation merge. + +Verification: + +- `bun test packages/editor`: 60 passing tests, 7 skipped existing hook tests. +- `bun test packages/document-ui`: 339 passing tests. +- `bun run typecheck`. +- `bun run --cwd apps/hook build`. +- `git diff --check`. + +Remaining cutover work at this point: package-owned generic Ask AI surface behavior still needs the package default panel and Plannotator HTTP adapter wiring. + +## Implemented Slice: Generic Ask AI And Import Parity + +`DocumentReviewSurface` now renders a provider-neutral Ask AI panel when the session exposes `canUseAskAI` and the host implements `hostApi.askAI`. The package owns document/plan context assembly, the panel shell, and streamed text rendering. The host still owns AI provider/model selection, auth, permission handling, and transport. + +The Plannotator HTTP adapter now implements `hostApi.askAI` over the existing `/api/ai/session` and `/api/ai/query` endpoints. It creates/reuses an AI server session per document review context, forwards context updates when the package context changes, and maps the server SSE stream into package-level `DocumentAskAIEvent` messages. + +Import review parity is also complete for normal package-backed sessions. `DocumentReviewSurface` exposes a provider-neutral `importAnnotations` slot action. `PlannotatorDocumentSurfaceBridge` keeps Plannotator share URL parsing host-owned, decodes hash and short links with the existing sharing utilities, and merges imported annotations/global attachments into the package annotation state without reaching into viewer DOM or highlighter internals. + +Verification: + +- `bun test packages/document-ui`: 339 passing tests. +- `bun test packages/editor`: 60 passing tests, 7 skipped existing hook tests. +- `bun run typecheck`. +- `bun run --cwd apps/hook build`. +- `git diff --check`. +- `test ! -e packages/editor/LegacyPlannotatorApp.tsx`. +- `rg -n "LegacyPlannotatorApp|status: 'legacy'|VITE_DOCUMENT_SURFACE|USE_DOCUMENT_SURFACE|documentSurfaceSession|Import review is not available" packages apps --glob '!**/dist/**' --glob '!**/node_modules/**'` returned no matches. + +Cutover status: the normal app entry no longer keeps a legacy document-review shell or feature-flag path. Plan Review, Annotate file/folder/message, shared sessions, archive, goal setup, fallback/demo, terminal delivery, settings/share/export/import/note actions, version/diff, writeback, drafts, annotation state, feedback assembly, and generic Ask AI now route through the package-backed document surface plus small Plannotator host shells/slots. + +## Self-Review Follow-Up + +The ADR self-review found and fixed two issues after the initial green run. + +First, `PlannotatorDocumentSurfaceBridge` could create a copied short share link from stale prepared export state when the current document required the paste-service short-link path. The bridge now generates short links from an explicit prepared export payload, so copy-share fallback uses the current package feedback payload. + +Second, the Plannotator HTTP adapter showed the generic Ask AI panel for every writable session. The old shell checked `/api/ai/capabilities` and hid AI when no provider was registered. `createPlannotatorHttpDocumentApi().loadSession()` now checks that capabilities route and maps `canUseAskAI` from actual provider availability. + +The self-review also cleaned tab-indented JSX in the touched surface file. + +Verification after self-review: + +- `bun test packages/document-ui`: 339 passing tests. +- `bun test packages/editor`: 60 passing tests, 7 skipped existing hook tests. +- `bun run typecheck`. +- `bun run --cwd apps/hook build`. +- `git diff --check`. diff --git a/adr/research/SPIKE-document-ui-current-state-and-parity-20260621-115603.md b/adr/research/SPIKE-document-ui-current-state-and-parity-20260621-115603.md new file mode 100644 index 000000000..eb510cf51 --- /dev/null +++ b/adr/research/SPIKE-document-ui-current-state-and-parity-20260621-115603.md @@ -0,0 +1,216 @@ +# Spike: Document UI Current State and Parity + +> ℹ️ **Context still useful; the direction it informed was reverted.** This honestly reported the failed cutover was only ~55–65% at parity. The cutover was reverted on 2026-06-22. Read **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`** before acting. + +Date: 2026-06-21 + +## Question + +What exactly is the state of the `@plannotator/document-ui` extraction now, how close is it to parity with the current Plan Review / Annotate app, and what should be finalized inside the shared package versus left to Plannotator or other hosts? + +## Scope + +This spike reads the current branch code. It does not change product code. + +Primary files inspected: + +- `packages/document-ui/package.json` +- `packages/document-ui/index.ts` +- `packages/document-ui/types.ts` +- `packages/document-ui/DocumentReviewSurface.tsx` +- `packages/document-ui/plannotatorHttpApi.ts` +- `packages/document-ui/memoryDocumentHostApi.ts` +- `packages/document-ui/documentReviewChrome.ts` +- `packages/editor/App.tsx` +- `packages/editor/PlannotatorDocumentSurfaceBridge.tsx` +- `packages/editor/documentSurfaceBridge.ts` +- `adr/implementation/document-ui-extraction-intent-20260620-085249.md` + +Verification run: + +- `bun test packages/document-ui` +- Result: 329 passing tests across 31 files. + +## Executive Read + +The extraction is real and substantial. It is accurate to say that much of Plannotator's document-review domain behavior has been moved into a new `@plannotator/document-ui` package. + +It is not accurate to say the app has been cut over to the package yet. + +The current production app still defaults to `packages/editor/App.tsx`. The new surface is mounted only when `VITE_DOCUMENT_SURFACE` is `1` or `true`, through `PlannotatorDocumentSurfaceBridge`. The old editor shell is still the main render path. + +My current read: + +- Package capability: roughly 70-80 percent of the hard reusable document-domain logic is extracted. +- Current-app parity: roughly 55-65 percent, depending on whether plan diff, archive, goal setup, Ask AI panel, and terminal are considered part of the required reusable surface. +- Cutover/no-legacy readiness: roughly 40-50 percent. The shared package is green, but the app still depends on a large legacy shell for important visible workflows. + +The branch is past "prototype contract" and into "candidate package," but it still needs a deliberate parity/cutover pass before deleting the old UI. + +## What Exists Now + +### Package Footprint + +`packages/document-ui` is a real package with explicit exports, not a single component dump. It exports the surface, provider-neutral types, memory provider, Plannotator HTTP adapter, feedback assembly, edit/writeback helpers, annotation persistence, draft state, linked state, tree state, chrome decisions, panel/sidebar state, Ask AI context, delivery helpers, and Plannotator compatibility helpers. + +The package is currently about 28.6k lines including tests. The main app shell is still about 4.8k lines: + +- `packages/document-ui/DocumentReviewSurface.tsx`: 1,437 lines. +- `packages/document-ui/*.ts/*.tsx`: 28,643 total lines including tests. +- `packages/editor/App.tsx`: 4,773 lines. + +### Provider-Neutral Contract + +The core types are now provider-neutral: + +- `DocumentRef` is provider/document identity, not local file identity (`packages/document-ui/types.ts:77`). +- `DocumentWritebackStatus` is `clean | dirty | saving | saved | conflict | missing | error` (`packages/document-ui/types.ts:90`). +- `LoadedDocument` carries content, render mode, image base, and optional writeback capability (`packages/document-ui/types.ts:129`). +- `DocumentReviewSession` carries mode, root document/ref, root tree ref, capabilities, and UI labels (`packages/document-ui/types.ts:177`). +- `SubmitDocumentFeedbackPayload` contains annotations, linked annotations, code annotations, attachments, direct edits, saved changes, and message scope (`packages/document-ui/types.ts:378`). +- `DocumentHostApi` abstracts load, linked-doc resolution, tree listing, document watching, save, drafts, annotation persistence, uploads, image URLs, feedback, approve, exit, Ask AI, and agent delivery (`packages/document-ui/types.ts:437`). + +This is the right conceptual center. Workspaces can implement the same contract with workspace document ids, manifests, versions, `If-Match`, and its annotation APIs. Plannotator implements it with `/api/plan`, `/api/doc`, `/api/source/save`, `/api/draft`, `/api/upload`, and `/api/image`. + +### Default Surface + +`DocumentReviewSurface` is no longer just a render prop wrapper. It now owns substantial product behavior: + +- Resolves the initial document from session/root/ref (`packages/document-ui/DocumentReviewSurface.tsx:144`). +- Seeds and tracks writeback state (`packages/document-ui/DocumentReviewSurface.tsx:171`). +- Owns root annotation state (`packages/document-ui/DocumentReviewSurface.tsx:195`). +- Owns linked-document state (`packages/document-ui/DocumentReviewSurface.tsx:216`). +- Owns optional annotation persistence (`packages/document-ui/DocumentReviewSurface.tsx:223`). +- Owns document tree state (`packages/document-ui/DocumentReviewSurface.tsx:242`). +- Owns edit/writeback controller state (`packages/document-ui/DocumentReviewSurface.tsx:250`). +- Owns provider watch reconciliation (`packages/document-ui/DocumentReviewSurface.tsx:259`). +- Owns draft save/restore state (`packages/document-ui/DocumentReviewSurface.tsx:269`). +- Builds feedback payloads with linked annotations, direct edits, and saved changes (`packages/document-ui/DocumentReviewSurface.tsx:384`). +- Calls host feedback, approve, and exit APIs (`packages/document-ui/DocumentReviewSurface.tsx:419`). +- Renders a default chrome with header, writeback badges, annotation-persistence badges, edit/save/discard/conflict buttons, submit/approve/close buttons, document navigator, feedback panel, draft banners, and error banners (`packages/document-ui/DocumentReviewSurface.tsx:551`, `packages/document-ui/DocumentReviewSurface.tsx:613`, `packages/document-ui/DocumentReviewSurface.tsx:693`). +- Renders markdown and raw HTML through the existing Plannotator renderer modules while routing image upload/image display and linked-doc opens through the provider API (`packages/document-ui/DocumentReviewSurface.tsx:1240`, `packages/document-ui/DocumentReviewSurface.tsx:1292`). + +This means the package already owns a meaningful document review loop. + +### Plannotator Adapter + +The Plannotator adapter is also substantial: + +- `createPlannotatorHttpDocumentApi()` maps current server routes into `DocumentHostApi`. +- `createPlannotatorHostSessionState()` normalizes `/api/plan` responses into document-session and host-session state (`packages/document-ui/plannotatorHttpApi.ts:408`). +- `createPlannotatorEditorLoadPlan()` derives the legacy editor load plan from normalized session state (`packages/document-ui/plannotatorHttpApi.ts:497`). +- Capabilities are mapped from local server data, including raw HTML, folder browsing, source-save writeback, share, Ask AI, agent terminal availability, and version support (`packages/document-ui/plannotatorHttpApi.ts:877`). + +This is good layering: local source-save details remain in Plannotator adapter exports, not in the provider-neutral `DocumentHostApi`. + +### Opt-In Bridge + +The bridge exists and is thin: + +- `packages/editor/documentSurfaceBridge.ts` decides the flag and renders feedback text through shared feedback assembly (`packages/editor/documentSurfaceBridge.ts:18`, `packages/editor/documentSurfaceBridge.ts:22`). +- `packages/editor/PlannotatorDocumentSurfaceBridge.tsx` creates the Plannotator HTTP API and mounts `` (`packages/editor/PlannotatorDocumentSurfaceBridge.tsx:44`, `packages/editor/PlannotatorDocumentSurfaceBridge.tsx:55`). +- `packages/editor/App.tsx` only uses the bridge behind `USE_DOCUMENT_SURFACE` (`packages/editor/App.tsx:130`, `packages/editor/App.tsx:3905`). + +This is the clearest evidence that the package is not yet the default app path. + +## What Still Lives In The Old App + +The old editor shell still owns major parity features and side effects: + +- Plan diff/version behavior: `usePlanDiff`, base-version selection, diff activation, and `PlanDiffViewer` render path remain in `App.tsx` (`packages/editor/App.tsx:817`, `packages/editor/App.tsx:4267`). +- Legacy linked-doc hook and Plannotator editable-source side effects remain in `App.tsx` (`packages/editor/App.tsx:888`, `packages/editor/App.tsx:938`). +- Archive browser state and archive selection remain in `App.tsx` (`packages/editor/App.tsx:968`, `packages/editor/App.tsx:4166`). +- External/editor annotation route integration remains in `App.tsx` (`packages/editor/App.tsx:1346`). +- Sticky header lane, annotation toolstrip, wide/focus inline controls, HTML tools toggle, checkbox overrides, code-file popout, message picker chrome, and Plannotator-specific viewer props remain in `App.tsx` (`packages/editor/App.tsx:4213`, `packages/editor/App.tsx:4235`, `packages/editor/App.tsx:4298`, `packages/editor/App.tsx:4411`). +- Goal setup is rendered from the old shell (`packages/editor/App.tsx:4255`). +- Agent terminal panel and resize shell remain in the old shell (`packages/editor/App.tsx:4078`). +- Ask AI panel and provider settings remain in the old shell (`packages/editor/App.tsx:4509`). +- Export/share/import modals and note integrations remain in the old shell (`packages/editor/App.tsx:4577`). +- The old `AppHeader` still controls Plannotator-specific top-level actions, settings, archive actions, callback actions, note-app actions, and AI/sidebar toggles (`packages/editor/App.tsx:3929`). + +Some of these should remain host-owned. Others are parity gaps if the package is meant to become the default document-review capability. + +## Parity Matrix + +| Area | Current State | Parity Read | +| --- | --- | --- | +| Provider-neutral document/session contract | In package | Strong | +| Markdown render and annotate | In package through existing `@plannotator/ui` renderer | Mostly there | +| Raw HTML render and annotate | In package through `HtmlViewer`; bridge-script tests exist | Mostly there | +| Image attachments and image display | Provider-owned in package | Strong | +| Linked document navigation | Package has provider-neutral state; old app still owns Plannotator filesystem side effects | Partial | +| Document tree/file browser | Package has tree state/default navigator; old app still owns richer file-browser tab and watchers | Partial | +| Writeback state | Provider-neutral core and Plannotator adapter exist | Strong | +| Local source-save compatibility | In package under Plannotator-specific exports | Strong for Plannotator, acceptable as adapter-specific | +| Draft restore | Provider-neutral core exists; old app still owns some display and side effects | Mostly there | +| Annotation persistence | Provider-neutral load/save contract exists | Mostly there | +| Feedback text/payload assembly | Shared package owns most assembly | Strong | +| Submit/approve/exit lifecycle | Package has default lifecycle; host still owns route policy in legacy path | Mostly there | +| External/editor annotations | Feedback text supports them; route/SSE integration remains old-app owned | Partial | +| Ask AI | Context helpers and host API type exist; full panel/session UI remains old-app owned | Partial | +| Plan versions/diff | Capability flag exists, but no generic host API and default surface does not render version browser/diff | Gap | +| Archive browser | Adapter carries archive metadata; default package surface does not provide archive browser parity | Gap or host-owned, depending decision | +| Goal setup | Old-app owned | Host-owned or package slot, not core document review | +| Agent terminal | Old-app owned; package has a slot and delivery helpers | Correctly host-owned runtime, partial UI slot | +| Sticky toolstrips/wide/focus polish | Decisions extracted, but package default chrome is simpler | Partial | +| Settings/share/import/export/note apps | Old-app owned | Correctly host-owned | +| Plugin/server routes/auth/browser open | Host/server owned | Correctly outside package | + +## What Should Be Finalized Inside The Package + +The package should own the reusable document-review loop end to end: + +1. `DocumentReviewSurface` as the default production surface for plan review, annotate file, annotate folder, annotate message, and workspace document review. +2. Provider-neutral document identity, loading, linked-doc navigation, tree navigation, annotation state, annotation persistence, draft restore, image upload/display, edit/writeback, conflict/missing/saving/saved chrome, feedback payload assembly, and submit/approve/exit actions. +3. A real default chrome that reaches parity with the current visible document experience: annotation toolstrip, sticky controls where applicable, feedback panel behavior, document navigation, file/tree badges, writeback badges, draft banners, and polished markdown/raw-HTML render behavior. +4. Optional document version/diff capability. This is the biggest missing reusable feature. The package already has `supportsVersions`, but it needs provider-neutral methods such as `listDocumentVersions`, `loadDocumentVersion`, and maybe `compareDocumentVersions`. Plannotator would adapt `/api/plan/versions` and `/api/plan/version`; Workspaces would adapt its versions API. The package should own the diff toggle/viewer because Workspaces explicitly needs the same review experience with a different provider. +5. Optional Ask AI surface behavior when `hostApi.askAI` exists. The package should own document target/context assembly and the in-document ask affordance. The host should still own provider/model config, auth, permission policy, and transport. +6. Optional annotation-provider watch/poll capability if Workspaces needs live comment updates. The current `loadAnnotations`/`saveAnnotations` contract is a good base, but route/SSE details should stay adapter-owned. +7. Plannotator local adapter as a first-class adapter, not as core vocabulary. Keep source-save, disk hash, mtime, missing local files, `/api/source/save`, and current draft compatibility in `plannotator-*` exports. +8. A memory/provider test harness that proves Workspaces-like behavior without local filesystem assumptions. +9. Contract tests for parity behavior. The package tests are green now, but cutover needs tests that assert the default surface can handle markdown, raw HTML, folder tree navigation, linked docs, writeback conflict/missing, drafts, version diff, and feedback assembly without the old `App.tsx` state machine. + +## What Should Stay Out Of The Package + +The package should not own host environment policy: + +1. Server route implementation, auth, process lifetime, browser launching, remote/local port behavior, and plugin command/hook handling. +2. Plan-mode `ExitPlanMode` hook behavior and stdout decision shape. +3. Plannotator note integrations: Obsidian, Bear, Octarine. +4. Share/paste service policy, short URL generation, import/export modal policy, and hosted share URLs. +5. Agent terminal runtime, PTY/WebSocket bridge, terminal installation, and terminal provider policy. The package should keep slots/state helpers, not own the terminal runtime. +6. Workspaces server calls and auth. Workspaces should provide an adapter implementing `DocumentHostApi`. +7. Local filesystem source-save internals as generic concepts. Those belong in the Plannotator adapter namespace. +8. The code-review/diff app in `packages/review-editor`; that is a different product surface. +9. Product settings UI and Plannotator-specific header menu policy. +10. External annotation transport details. The package can define optional annotation persistence/watch contracts, but SSE route names and provider mutation routes belong in adapters. + +## Cutover Work To Delete The Old UI + +If the branch goal is "the app uses the package and old/legacy code goes away," the remaining work is not another broad extraction pass. It is a focused parity and cutover pass: + +1. Make a crisp scope decision for plan diff, archive, goal setup, Ask AI, and terminal. + - My recommendation: move version/diff into the package as optional document capability. + - Keep archive as either a Plannotator host tab/slot or an optional adapter-provided document collection, not mandatory core. + - Keep goal setup host-owned or slot-based unless Workspaces needs it. + - Keep terminal runtime host-owned; use slots and delivery state. + - Move Ask AI UI only up to the provider-neutral level; host owns provider config and permissions. +2. Bring `DocumentReviewSurface` default chrome to parity for annotate/file/folder/message and plan review. +3. Add the missing generic version/diff API and render path in the package. +4. Wire Plannotator production path to the bridge without `VITE_DOCUMENT_SURFACE`. +5. Delete or collapse duplicate `App.tsx` document-domain state once the package owns it. +6. Leave `App.tsx` as a Plannotator host shell: load session, read settings, configure adapters/slots, handle route/policy side effects, render package surface. +7. Add a cutover test matrix: + - `bun test packages/document-ui` + - `bun run typecheck` + - `VITE_DOCUMENT_SURFACE=1 bun run --cwd apps/hook build` + - `bun run --cwd apps/hook build` + - targeted browser smoke for annotate markdown, annotate raw HTML, annotate folder, plan review, linked docs, source-save conflict/missing, and plan diff. + +## Bottom Line + +We have extracted much of the Plannotator document UI and domain behavior into its own package. That is a proper thing to say. + +We have not yet made the package the app. The old shell is still the default path and still owns visible parity-critical workflows. + +The clean path is to finish the package around the actual document-review loop, especially version/diff and default chrome parity, then flip the production app to the package and remove the duplicate editor state. Keeping the old shell around indefinitely would defeat the point of this branch; deleting it now would cut out real behavior users still rely on. diff --git a/adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md b/adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md new file mode 100644 index 000000000..ea5b037da --- /dev/null +++ b/adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md @@ -0,0 +1,744 @@ +# Spike: Document UI Extraction Boundary + +> ℹ️ **Research still useful; the direction it informed was reverted.** This accurately describes how the current document UI works, but the extraction approach it fed into (ADRs 002/003) was reverted on 2026-06-22. Read **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`** before acting on any recommendation here. + +Date: 2026-06-20 + +## Question + +Build a concrete understanding of how the Plan Review app now powers annotate-mode document review, and identify the boundary for extracting that document experience into a shared UI package. + +The historical center of gravity was Plan Mode: Claude Code intercepted `ExitPlanMode`, opened Plannotator, and waited for approve or deny. The current primary document workflow is broader: + +- run annotate on a markdown, text, HTML, URL, or folder target +- run last-message annotation +- optionally review-gate an artifact with approve or feedback +- sometimes keep an agent terminal running inside the annotate session + +## Scope + +This spike only reads the current branch code. It does not change product code. + +Primary files inspected: + +- `packages/editor/App.tsx` +- `packages/editor/components/AppHeader.tsx` +- `packages/editor/components/AnnotateAgentTerminalPanel.tsx` +- `packages/editor/agentTerminalIntegration.ts` +- `packages/editor/directEdits.ts` +- `packages/editor/editableDocuments.ts` +- `packages/editor/sourceDocumentClient.ts` +- `packages/editor/sourceDocumentReconciliation.ts` +- `packages/editor/savedFileChangeValidation.ts` +- `packages/ui/components/Viewer.tsx` +- `packages/ui/components/BlockRenderer.tsx` +- `packages/ui/components/InlineMarkdown.tsx` +- `packages/ui/components/MarkdownEditor.tsx` +- `packages/ui/components/html-viewer/HtmlViewer.tsx` +- `packages/ui/components/html-viewer/useHtmlAnnotation.ts` +- `packages/ui/components/AnnotationPanel.tsx` +- `packages/ui/components/sidebar/FileBrowser.tsx` +- `packages/ui/components/sidebar/SidebarContainer.tsx` +- `packages/ui/hooks/useAnnotationDraft.ts` +- `packages/ui/hooks/useAnnotationHighlighter.ts` +- `packages/ui/hooks/useFileBrowser.ts` +- `packages/ui/hooks/useLinkedDoc.ts` +- `packages/ui/hooks/usePlanDiff.ts` +- `packages/ui/hooks/useArchive.ts` +- `packages/ui/hooks/useAIChat.ts` +- `packages/ui/hooks/useExternalAnnotations.ts` +- `packages/ui/hooks/useValidatedCodePaths.ts` +- `packages/ui/utils/parser.ts` +- `packages/ui/types.ts` +- `packages/server/annotate.ts` +- `packages/server/index.ts` +- `packages/server/reference-handlers.ts` +- `packages/server/reference-watch.ts` +- `packages/server/agent-terminal.ts` +- `apps/hook/server/index.ts` +- `apps/opencode-plugin/index.ts` +- `apps/pi-extension/index.ts` +- `apps/pi-extension/server/serverAnnotate.ts` +- `apps/pi-extension/server/reference.ts` +- `apps/pi-extension/server/file-browser-watch.ts` + +## Short Answer + +There is not a separate "Annotate app" today. Annotate mode is the Plan Review app running in a different server-provided mode. + +The Bun annotate server deliberately serves document content through `/api/plan` so the existing plan editor bundle can render it. `packages/editor/App.tsx` is the real composition root for plan review, annotate, annotate-last, annotate-folder, archive, goal setup, linked docs, direct edits, AI, drafts, external annotations, file browser, raw HTML, and agent terminal. + +The reusable pieces are already partly in `@plannotator/ui`, but that package is not a clean document UI package. Many components and hooks inside it fetch hard-coded `/api/*` routes. The actual document-product state machine is split between `@plannotator/ui` and `@plannotator/editor`, with the largest orchestration still in `App.tsx`. + +A good extraction should not start by moving `App.tsx` wholesale. The safer boundary is a document-review surface with an explicit host API adapter and optional capabilities. + +## Current Runtime Shape + +### Entry points + +Claude Code, Droid, OpenCode, and Pi all route manual document review into annotate mode. + +Claude Code and Droid run the CLI-style commands: + +- `plannotator annotate ` +- `plannotator annotate-last` / `plannotator last` + +OpenCode intercepts `plannotator-annotate`, `plannotator-last`, and `plannotator-review` before the agent sees the command. This is important: OpenCode clears command prompt output so a large file path is not auto-attached to the agent context before Plannotator opens. + +Pi implements native command handlers, but converges on the same server/UI contract. + +The CLI annotate command does input detection before starting the server: + +- `https://...`: fetch with Jina Reader by default, or fetch plus Turndown with `--no-jina` +- folder: open annotate-folder mode and show the file browser +- `.html` / `.htm`: render raw HTML by default, or convert to markdown with `--markdown` +- `.md`, `.mdx`, `.txt`: read file text directly + +Annotate-last resolves recent assistant messages from each agent's transcript or session store. It can pass a picker list of recent messages to the frontend. + +### Servers + +There are two server implementations with matching API surfaces: + +- Bun server in `packages/server/*`, used by Claude Code, Droid, and OpenCode paths. +- Pi server in `apps/pi-extension/server/*`, using Node HTTP primitives and generated shared files. + +The annotate Bun server is `startAnnotateServer(options)` in `packages/server/annotate.ts`. The Pi mirror is `apps/pi-extension/server/serverAnnotate.ts`. + +The annotate server intentionally reuses `/api/plan`: + +```text +GET /api/plan -> { + plan, + origin, + mode, + filePath, + sourceInfo, + sourceConverted, + sourceSave, + gate, + renderAs, + rawHtml?, + convertHtml, + sharingEnabled, + shareBaseUrl, + pasteApiUrl, + repoInfo, + projectRoot, + isWSL, + serverConfig, + agentTerminal?, + recentMessages? +} +``` + +That endpoint is the switch that turns the plan editor bundle into annotate mode. + +Other annotate-mode endpoints used by the document UI: + +- `GET /api/doc`: open linked docs, folder files, and code-file previews. +- `POST /api/doc/exists`: validate code-file links discovered in markdown. +- `GET /api/reference/files`: build the folder file browser tree. +- `GET /api/reference/files/stream`: SSE watch for folder tree, git status, and open source file changes. +- `POST /api/source/save`: atomically save source-backed markdown, mdx, or text files. +- `GET /api/share-html`: lazily prepare portable raw HTML for sharing. +- `GET /api/html-assets//`: serve relative HTML support assets. +- `GET/POST/DELETE /api/draft`: persist annotations, attachments, and direct edits. +- `POST /api/feedback`: return annotated feedback to the invoking session. +- `POST /api/approve`: approve a review-gated annotate session. +- `POST /api/exit`: close without feedback. +- `GET/POST /api/ai/*`: Ask AI sessions. +- `GET/POST/PATCH/DELETE /api/external-annotations*`: live annotations from external tools. +- `WebSocket /api/agent-terminal/pty/`: optional annotate-mode agent terminal. + +The server also owns the security boundary for document access. `getAnnotateReferenceRootPaths()` scopes file access to the folder target, current working directory, the source file directory, and realpath equivalents. `/api/doc` and `/api/doc/exists` resolve within those roots. + +### Source-save capability + +Source save is negotiated by the server and carried to the UI as `sourceSave`. + +Enabled only for local `.md`, `.mdx`, or `.txt` documents. Disabled for: + +- message mode +- folder root before a file is selected +- raw HTML rendering +- converted HTML/URL content +- non-local URLs +- unsupported extensions +- missing or unreadable files + +The capability includes: + +- scope: `single-file` or `folder-file` +- path, basename, language +- content hash, mtime, size, and EOL style + +The UI uses these fields as optimistic concurrency metadata when calling `/api/source/save`. + +## Current UI Shape + +### Composition root + +`packages/editor/App.tsx` is 4,685 lines and owns the product state machine. + +It initializes from `/api/plan`, then branches across: + +- normal plan review +- annotate single file +- annotate last message +- annotate folder +- raw HTML annotate +- archive +- goal setup +- shared sessions + +Core state clusters in `App.tsx`: + +- document content: `markdown`, `renderAs`, `rawHtml`, `shareHtml`, `sourceInfo`, `sourceConverted`, `sourceFilePath`, `imageBaseDir`, `projectRoot` +- parsed document: `displayedMarkdown`, `frontmatter`, `blocks` +- annotations: document annotations, code annotations, external annotations, editor annotations, linked-doc annotation cache, global image attachments +- editor state: markdown edit mode, direct-edit stats, dirty flags, editable document records +- mode flags: `annotateMode`, `gate`, `annotateSource`, archive mode, goal setup, message picker +- layout: left sidebar, right annotation panel, wide mode, resizable panes, agent terminal pane +- server/session capabilities: origin, sharing URLs, repo info, AI providers, agent terminal capability + +The key render switch is: + +- `renderAs === "html"`: render `HtmlViewer` +- `isEditingMarkdown`: render `MarkdownEditor` +- otherwise: render `Viewer` + +This means markdown, editable markdown, and raw HTML are different render surfaces inside the same app shell. + +### Markdown parser and block model + +`parseMarkdownToBlocks(markdown)` in `packages/ui/utils/parser.ts` creates `Block[]`. + +The parser is intentionally simple and stable for annotation anchoring. It handles: + +- headings with deterministic ids +- paragraphs +- blockquotes and GitHub alert callouts +- list items and task checkboxes +- fenced code blocks +- tables +- horizontal rules +- raw HTML blocks +- directive containers +- inline enhancements through render components + +`Block.startLine` is part of the feedback contract. `exportAnnotations()` uses it to generate human-readable feedback with line labels. + +This creates a strong coupling: + +```text +markdown -> parseMarkdownToBlocks -> Block ids and startLine + -> Viewer highlights and annotation blockId + -> exportAnnotations feedback +``` + +Any extracted package must preserve this chain or own a replacement end to end. + +### Viewer + +`packages/ui/components/Viewer.tsx` is 970 lines. It is not just a presentational renderer. + +It owns: + +- `useAnnotationHighlighter` +- web-highlighter lifecycle +- code-block annotation path +- sticky headers and scroll behavior +- code path validation through `useValidatedCodePaths` +- heading anchors and hash navigation +- global comments and image attachments +- quick labels +- table and code popouts +- doc badges +- Ask AI hooks at comment and document level + +`Viewer` delegates block rendering to `BlockRenderer`, `CodeBlock`, `TableBlock`, `MermaidBlock`, `GraphvizBlock`, and related components. + +`InlineMarkdown` is another important coupling point. It linkifies code-file references and wiki/doc links, fetches `/api/doc` for hover previews, and relies on `CodePathValidationContext`. + +### Raw HTML viewer + +`HtmlViewer` renders raw HTML in an iframe through `srcdoc`. The server rewrites relative assets to `/api/html-assets/...`. + +Annotation inside raw HTML uses `useHtmlAnnotation` and an injected bridge script. It communicates selection, comments, deletions, and quick labels with `postMessage`. + +HTML annotations do not use markdown blocks the same way markdown annotations do. They carry text and bridge mark ids, with `blockId` effectively empty. This is a separate annotation path hidden behind the same `ViewerHandle` contract: + +- `removeHighlight` +- `clearAllHighlights` +- `applySharedAnnotations` + +### Markdown editing and direct edits + +`MarkdownEditor` in `@plannotator/ui` is a thin Plannotator-themed wrapper around `@plannotator/markdown-editor`. + +The editing state is not in that component. It lives in `App.tsx` plus `packages/editor/editableDocuments.ts`. + +For normal plan review, editing produces a Direct Edits feedback section. For source-backed annotate files, editing can save back to disk through `/api/source/save`. + +After edits, `App.tsx` calls `applyEditedDocument(next)`: + +- reparse markdown +- remap annotations by original selected text +- clear positional metadata when a block changes +- update markdown +- bump `editGeneration` +- repaint highlights + +This annotation remapping is a critical behavior. It is easy to lose if editing is extracted separately from rendering and export. + +### Source-backed folder files + +`useEditableDocuments()` tracks one record per source-backed document: + +- session-open text and hash +- disk baseline +- current text +- dirty/saving/saved/conflict/error/missing status +- saved change context for feedback +- conflict snapshots when disk changed + +The source document reconciliation loop watches directories containing open source docs through `/api/reference/files/stream`. On SSE events, it refetches snapshots through `/api/doc` and reconciles: + +- clean file changed on disk: update UI to disk +- dirty file changed on disk: mark conflict +- file disappeared: mark missing +- stale async snapshot: ignore by sequence/hash guard + +The file browser uses `useFileBrowser()` plus `FileBrowser.tsx`. It displays: + +- markdown/text/html file tree +- workspace status from git metadata +- annotation counts by file +- edit status markers from `editableDocuments` + +Folder mode selection opens files through linked-doc machinery, but source-backed folder files can become editable and saveable. + +### Linked docs + +`useLinkedDoc()` is central to the document experience. + +It handles same-surface navigation to another markdown or HTML document: + +- snapshot current root or linked document +- cache annotations and attachments per filepath +- clear and restore highlights +- switch `markdown`, `renderAs`, `rawHtml`, and `shareHtml` +- restore cached doc state on back +- keep annotation counts for file browser/sidebar + +Linked docs can be raw HTML or markdown. This means `renderAs` is not only a session-level mode; it is active-document scoped. + +### Drafts + +`useAnnotationDraft()` persists: + +- full `Annotation[]` +- code annotations +- global attachments +- direct edited markdown +- dirty source-backed edited documents +- already-saved source-backed file changes +- draft generation number + +The hook is intentionally best-effort and uses debounced `/api/draft` writes with `keepalive` flushes on page hide. Draft generation prevents a late save from resurrecting a draft after submit. + +Draft restore in `App.tsx` is complex because it has to validate saved file changes against disk, restore dirty source documents, maybe reopen a single restored file, remap annotations, and repaint highlights. + +### Feedback and approval + +Plan mode: + +- Approve posts `/api/approve`. +- Deny posts `/api/deny`. +- Feedback may include annotations, editor annotations, linked-doc annotations, code-file annotations, direct edits, saved file changes, and note-app settings. + +Annotate mode: + +- Feedback normally posts `/api/feedback`. +- Gate approval posts `/api/approve`. +- Close posts `/api/exit`. +- If the agent terminal is ready, feedback is sent directly to the terminal instead of `/api/feedback`. + +`getCurrentFeedbackPayload()` is the important document feedback seam in `App.tsx`. It composes exported annotations plus direct-edit and saved-file-change sections, then wraps them for the target agent/file/message context. + +### Agent terminal + +Agent terminal is annotate-only for `annotate` and `annotate-folder`, not `annotate-last`. + +Server side: + +- Bun uses `createBunAgentTerminalBridge()`. +- Pi mirrors it with `createNodeAgentTerminalBridge()`. +- The server advertises `agentTerminal` capability in `/api/plan`. +- A tokenized WebSocket path is generated under `/api/agent-terminal/pty/`. +- Remote sessions disable terminal by default unless `PLANNOTATOR_AGENT_TERMINAL_REMOTE=1`. +- The Bun bridge starts a Node sidecar for WebTUI and proxies browser WebSocket traffic to the sidecar. + +UI side: + +- `AnnotateAgentTerminalPanel` uses `@plannotator/webtui/browser` and `@plannotator/webtui/react`. +- It stores the preferred agent id and terminal display settings locally. +- It exposes `sendMessage()` and `stop()` through an imperative ref. +- `App.tsx` tracks whether the terminal is running, open, ready, and whether the current feedback payload was already delivered. + +When terminal delivery succeeds, the browser does not close the annotate session. It marks the feedback as delivered and keeps the terminal workflow live. + +### AI + +Ask AI is a server-backed capability exposed by `/api/ai/capabilities` and used through `useAIChat()`. + +When the annotate agent terminal is ready, `App.tsx` hides normal AI chat streaming and routes Ask AI prompts to the terminal instead. + +This is another package boundary concern: AI can be a document-surface capability, but the provider registry and terminal fallback are host/session concerns. + +## Package Boundary Today + +`@plannotator/ui` already contains a lot of reusable document primitives: + +- parser and feedback export utilities +- `Viewer` +- `HtmlViewer` +- `MarkdownEditor` +- annotation toolbar/panel pieces +- file browser UI and hook +- linked-doc hook +- draft hook +- sidebar shell +- AI chat hook and UI +- external annotation hooks +- plan diff and archive pieces + +`@plannotator/editor` contains the app shell and several document-domain state modules: + +- `App.tsx` +- source edit state and reconciliation +- direct-edit feedback sections +- source document client +- source document path helpers +- agent terminal panel and integration +- app header +- shortcuts surface + +This split is historical, not architectural. `@plannotator/ui` is broad and route-aware. `@plannotator/editor` is a product shell that imports almost every document primitive and wires them into server APIs. + +Line counts that indicate the current extraction pressure: + +- `packages/editor/App.tsx`: 4,685 +- `packages/ui/components/Viewer.tsx`: 970 +- `packages/editor/components/AnnotateAgentTerminalPanel.tsx`: 746 +- `packages/ui/components/AnnotationPanel.tsx`: 731 +- `packages/editor/editableDocuments.ts`: 666 +- `packages/ui/hooks/useLinkedDoc.ts`: 494 +- `packages/ui/hooks/useFileBrowser.ts`: 358 +- `packages/server/annotate.ts`: 661 +- `apps/pi-extension/server/serverAnnotate.ts`: 561 + +## Extraction Risks + +### 1. Direct `/api/*` fetches inside reusable UI + +Many `@plannotator/ui` hooks and components call API routes directly: + +- `useAnnotationDraft`: `/api/draft` +- `useFileBrowser`: `/api/reference/files`, `/api/reference/files/stream`, `/api/reference/obsidian/files` +- `useLinkedDoc`: default `/api/doc` +- `usePlanDiff`: `/api/plan/version`, `/api/plan/versions` +- `useAIChat`: `/api/ai/*` +- `useExternalAnnotations`: `/api/external-annotations*` +- `useEditorAnnotations`: `/api/editor-annotations`, `/api/editor-annotation` +- `useValidatedCodePaths`: `/api/doc/exists` +- `InlineMarkdown`: `/api/doc` +- `OpenInAppButton`: `/api/open-in/apps`, `/api/open-in` +- `ExportModal`: `/api/save-notes` + +That is acceptable for an app-local UI package, but not for a reusable document package unless the package declares those routes as its required host API. + +### 2. `App.tsx` mixes mode policy with document mechanics + +Examples: + +- Plan approve/deny and annotate feedback live beside editor remapping. +- Archive/goal setup branches live beside folder annotation. +- AI provider defaults live beside source-file conflict handling. +- Agent terminal delivery status affects whether feedback buttons are enabled. +- Sidebar auto-open rules depend on archive, goal, folder, HTML, and TOC state. + +Moving this all at once would preserve complexity under a new package name. + +### 3. Parser, highlight anchors, and feedback export are one contract + +The markdown renderer cannot be extracted independently from: + +- `Block` ids +- `Block.startLine` +- annotation `blockId` +- text-search restoration +- direct-edit remapping +- feedback export + +These should move or remain together. + +### 4. Source-save behavior is part of the document product + +Source save is not a small add-on. It includes optimistic concurrency, file watch reconciliation, conflict UX, missing-file UX, draft restore, saved file change feedback, and file browser edit badges. + +If source save stays outside an extracted package, the package still needs extension points for all those statuses and actions. + +### 5. Raw HTML is a parallel rendering and annotation stack + +The raw HTML path uses iframe bridge annotations, rewritten asset URLs, and share HTML preparation. It is not just another markdown block type. + +### 6. Dual server parity remains required + +Endpoint changes must be made in both: + +- `packages/server/*` +- `apps/pi-extension/server/*` + +A frontend extraction can reduce UI duplication, but it does not remove this server parity requirement. + +## Candidate Package Boundary + +The practical package is not "all of `App.tsx`." It is a document review surface. + +Working name: + +```text +@plannotator/document-ui +``` + +Primary exported component: + +```text +DocumentReviewSurface +``` + +It should own: + +- markdown/raw-HTML render switch +- annotation lifecycle +- annotation panel integration +- linked document navigation +- file browser document picking +- markdown edit mode +- source-save document state +- draft persistence integration +- feedback payload assembly + +It should not own directly: + +- Claude/OpenCode/Pi command interception +- server startup +- browser opening +- note-app integration persistence policy +- plan version history +- archive browsing +- goal setup +- agent-specific transcript lookup +- exact terminal sidecar implementation + +Those should remain host/app concerns passed as data, capabilities, callbacks, or optional slots. + +## Suggested Host API Adapter + +Instead of hard-coded route fetches in the surface, define a `DocumentHostApi` adapter. + +Shape at a high level: + +```ts +interface DocumentHostApi { + loadSession(): Promise; + loadDocument(request: LoadDocumentRequest): Promise; + validateCodePaths(request: ValidateCodePathsRequest): Promise; + listFiles(request: ListFilesRequest): Promise; + watchFiles?(request: WatchFilesRequest): EventSourceLike; + saveSource?(request: SourceSaveRequest): Promise; + loadDraft(): Promise; + saveDraft(draft: DraftPayload): Promise; + deleteDraft(generation: number): Promise; + submitFeedback(payload: SubmitFeedbackPayload): Promise; + approve?(): Promise; + exit?(): Promise; + uploadImage?(file: File): Promise; + loadShareHtml?(path?: string): Promise; +} +``` + +The current Bun/Pi HTTP routes can be one implementation of that adapter: + +```text +createPlannotatorHttpDocumentApi() +``` + +This avoids baking `/api/plan` into every reusable hook. It also makes local storybook/unit tests easier because the surface can run against an in-memory adapter. + +## Suggested Extraction Sequence + +### Step 1. Define contracts without moving UI + +Create shared document session types around the current `/api/plan` annotate shape and `/api/doc` loaded-document shape. + +Do not rename server routes yet. Keep route compatibility. + +Useful contracts: + +- `DocumentSession` +- `DocumentMode` +- `LoadedDocument` +- `DocumentSourceInfo` +- `DocumentFeedbackPayload` +- `DocumentHostApi` +- `DocumentCapabilities` + +This gives the code a vocabulary before package movement. + +### Step 2. Extract API client wrappers + +Move route fetches behind a client object used by `App.tsx`. + +Good first candidates: + +- `/api/doc` +- `/api/doc/exists` +- `/api/reference/files` +- `/api/reference/files/stream` +- `/api/source/save` +- `/api/draft` +- `/api/share-html` + +The goal is not abstraction for its own sake. The goal is to make the eventual package boundary explicit and testable. + +### Step 3. Move source document state out of `@plannotator/editor` + +The source-edit modules are already cohesive: + +- `editableDocuments.ts` +- `sourceDocumentClient.ts` +- `sourceDocumentReconciliation.ts` +- `savedFileChangeValidation.ts` +- `sourceDocumentPaths.ts` +- `directEdits.ts` +- `draftRestoreSelection.ts` + +These are document-domain modules. They are stronger candidates for `@plannotator/document-ui` than plan-specific code. + +### Step 4. Extract document surface around existing components + +Create a component that accepts: + +- initial document session +- host API adapter +- current origin/config info +- optional capability slots: AI, terminal, notes/export, sharing, archive, plan diff +- callbacks for submit/approve/exit + +At this step, `packages/editor/App.tsx` becomes a host shell that still handles plan-specific behavior, but delegates document mechanics. + +### Step 5. Split plan-only features from annotate-first features + +Plan-only or mostly plan-only: + +- `/api/approve` and `/api/deny` behavior for `ExitPlanMode` +- plan save settings +- plan version history +- plan diff browser +- archive sidebar +- permission mode setup + +Annotate/document-first: + +- render markdown/raw HTML +- annotations and feedback +- linked docs +- folder browser +- source save +- direct edits +- drafts +- external annotations +- image attachments + +The extracted package should make plan-only features optional instead of requiring them in every document session. + +## Recommended Initial Decision + +Extract a document-review package, not a plan-review package. + +The package should treat plan review as one host mode that supplies a document and approve/deny callbacks. Annotate should be the reference use case for the package because it exercises the full document surface: arbitrary files, folders, raw HTML, linked docs, source save, drafts, and optional terminal delivery. + +Do not make `@plannotator/ui` the final boundary by default. It is currently a mixed component library plus route-aware app helpers. Either: + +- create `@plannotator/document-ui` and move document-domain pieces there, or +- carve a `/document` export surface inside `@plannotator/ui` with a documented host API contract. + +The separate package is cleaner if the goal is reuse across applications. + +## Verification Map + +Existing tests that cover likely extraction-sensitive behavior: + +- `packages/ui/utils/parser.test.ts` +- `packages/ui/components/InlineMarkdown.test.ts` +- `packages/ui/markdownEditorFidelity.test.tsx` +- `packages/ui/annotationDraftPersistence.test.tsx` +- `packages/ui/hooks/useFileBrowser.test.tsx` +- `packages/ui/components/sidebar/FileBrowser.test.ts` +- `packages/editor/directEdits.test.ts` +- `packages/editor/editableDocuments.test.ts` +- `packages/editor/editableDocumentsHook.test.tsx` +- `packages/editor/sourceDocumentClient.test.ts` +- `packages/editor/sourceDocumentReconciliation.test.ts` +- `packages/editor/savedFileChangeValidation.test.ts` +- `packages/editor/agentTerminalIntegration.test.ts` +- `packages/server/annotate.test.ts` +- `packages/server/annotate-doc-url.test.ts` +- `packages/server/annotate-html-assets.test.ts` +- `packages/server/reference-handlers.test.ts` +- `packages/server/reference-watch.test.ts` +- `packages/server/agent-terminal.test.ts` +- `apps/pi-extension/server.test.ts` +- `apps/pi-extension/server/agent-terminal.test.ts` +- `apps/pi-extension/server/file-browser-watch.test.ts` + +Tests to add before or during extraction: + +- a contract test for the Bun and Pi annotate `/api/plan` shapes +- a contract test for `/api/doc` loaded markdown, raw HTML, converted HTML, code-file preview, and source-save metadata +- a component test for the document surface with an in-memory host API adapter +- an integration test that edits a folder file, saves it, restores draft state, and sends feedback with saved file change context +- an integration test that switches linked docs between markdown and raw HTML and preserves per-document annotations + +## Open Questions + +1. Should the extracted package include the right annotation panel, or only the document renderer plus hooks? + +Recommendation: include it. The panel is part of the annotation product, and feedback export depends on the same state. + +2. Should the agent terminal live in the document package? + +Recommendation: keep terminal transport and sidecar out. Include only an optional "agent delivery" capability or slot. The current panel can move later if the package is intended to ship the full annotate workspace. + +3. Should plan diff and archive move with the package? + +Recommendation: no for the first extraction. They depend on plan history endpoints and plan-specific mental models. Leave them as host-provided optional sidebar tabs. + +4. Should route names change away from `/api/plan` for annotate? + +Recommendation: not during extraction. The route name is historical but functional. Put a typed client over it first, then rename only if there is a separate compatibility plan for Bun and Pi. + +5. Should raw HTML and markdown be separate surfaces? + +Recommendation: keep one document surface with two render engines. Users experience them as one annotate workflow, and linked docs can switch between them. + +## Bottom Line + +The current architecture works because the annotate server impersonates the plan server enough for `packages/editor/App.tsx` to boot the same React app in annotate mode. + +The extraction should preserve the successful part of that design: one document review experience across plan, file, folder, HTML, URL, and last-message workflows. + +The extraction should remove the fragile part: document behavior is currently spread across a massive app shell and route-aware shared UI hooks. The next architectural boundary should be a document surface plus a typed host API adapter. diff --git a/adr/research/synthesis-document-ui-extraction-20260620-082343.md b/adr/research/synthesis-document-ui-extraction-20260620-082343.md new file mode 100644 index 000000000..69bbd4b8d --- /dev/null +++ b/adr/research/synthesis-document-ui-extraction-20260620-082343.md @@ -0,0 +1,266 @@ +# Synthesis: Document UI Extraction + +> ℹ️ **Context still useful; the direction it informed was reverted.** The extraction approach synthesized here (ADRs 002/003) was reverted on 2026-06-22. Read **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`** before acting. + +Date: 2026-06-20 + +Status: Synthesis + +## Research Reviewed + +- `adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md` +- `adr/research/SPIKE-source-edit-reliability-20260618-090850.md` +- `adr/research/SPIKE-source-edit-race-and-conflict-20260618-095558.md` +- `adr/research/SPIKE-annotate-agent-terminal-production-runtime-20260618-212101.md` +- `adr/research/synthesis-annotate-agent-terminal-production-runtime-20260618-212604.md` + +## What The Research Says + +Annotate is not a separate frontend today. It is the Plan Review app booted in another mode. The annotate server serves document sessions through `/api/plan`, and `packages/editor/App.tsx` decides whether the session is plan review, annotate file, annotate folder, annotate last message, raw HTML, archive, or goal setup. + +The desired extraction is therefore not a renderer extraction. The reusable product is the document review experience: + +- markdown and raw HTML viewing +- markdown block rendering +- annotations and comments +- annotation panel +- linked document navigation +- file browser rows and status badges +- editor toggle +- source-backed save state +- draft persistence +- feedback payload assembly + +Those pieces are currently split across `@plannotator/ui` and `@plannotator/editor`. `@plannotator/ui` has many reusable primitives, but several of them call hard-coded `/api/*` routes. `@plannotator/editor` has the main app shell plus document-domain state like editable documents, source reconciliation, direct edits, and terminal integration. + +So the current split is historical, not a clean package boundary. + +## Recommended Direction + +Create one shared document package: + +```text +@plannotator/document-ui +``` + +Do not split into renderer, tree, comments, editor, and sidebar packages now. The seams are not stable enough. The first useful boundary is one document surface with a typed host API. + +The primary export should be something like: + +```text +DocumentReviewSurface +``` + +It should own the document product mechanics: + +- render markdown or raw HTML +- manage annotation state and highlight restoration +- render the comments/annotation panel +- support linked docs +- support folder file picking +- support markdown edit mode +- manage source-save document state +- integrate draft save/restore +- assemble document feedback + +The host app should keep runtime and mode policy: + +- Claude/OpenCode/Droid/Pi command interception +- server startup and browser opening +- plan-mode approve/deny hook behavior +- plan history, plan diff, archive, and goal setup +- note app policy and settings persistence +- transcript lookup for annotate-last +- terminal runtime/sidecar implementation + +## Main Architectural Move + +Introduce a host API adapter before moving the UI. + +The document surface should not directly know that the current Plannotator server uses `/api/plan`, `/api/doc`, `/api/source/save`, or `/api/draft`. It should depend on an interface such as: + +```ts +interface DocumentHostApi { + loadSession(): Promise; + loadDocument(request: LoadDocumentRequest): Promise; + validateCodePaths(request: ValidateCodePathsRequest): Promise; + listFiles(request: ListFilesRequest): Promise; + watchFiles?(request: WatchFilesRequest): EventSourceLike; + saveSource?(request: SourceSaveRequest): Promise; + loadDraft(): Promise; + saveDraft(draft: DraftPayload): Promise; + deleteDraft(generation: number): Promise; + submitFeedback(payload: SubmitFeedbackPayload): Promise; + approve?(): Promise; + exit?(): Promise; +} +``` + +The current Bun and Pi HTTP routes can be implemented as: + +```text +createPlannotatorHttpDocumentApi() +``` + +This keeps the existing routes stable while giving the UI a real boundary. + +## Package Contents + +Move or expose these as document-domain code: + +- `Viewer` +- `HtmlViewer` +- `MarkdownEditor` +- markdown parser and block types +- annotation highlighter integration +- annotation/comment panel patterns +- linked-doc hook +- file browser hook and rows +- draft hook +- editable document state +- source document client and reconciliation +- saved file change validation +- direct edits feedback builder +- code path validation and inline link handling + +Keep these outside for the first extraction: + +- plan diff +- archive browser +- goal setup +- permission mode setup +- server implementations +- agent terminal sidecar/runtime resolver +- CLI/plugin integrations + +The annotation panel should be included. It is part of the document product, not a peripheral widget. Feedback export and annotation state depend on it. + +Raw HTML should stay in the same document surface. It is a separate render engine, but users experience it as the same annotate workflow, and linked docs can switch between markdown and raw HTML. + +## Source Save Is Core + +The source-edit research matters for extraction. + +Source save is not just "write file on Save." It includes: + +- optimistic concurrency through hash and mtime metadata +- file watch reconciliation +- stale snapshot guards +- conflict recovery with current disk snapshots +- missing-file state +- draft restore +- saved file change feedback +- file browser edit badges + +If the extracted package does not own this state, it will need so many extension points that the package will not actually own the document experience. + +Recommendation: move the source document state modules into the document package early. + +## Terminal And AI Boundary + +Agent terminal is part of the annotate workspace, but the terminal runtime is not part of document UI. + +The package can support an optional "agent delivery" capability: + +- send feedback to a running agent +- report whether the current feedback has already been delivered +- route Ask AI prompts to an agent when provided +- render an optional slot or panel if the host supplies one + +The host should keep: + +- WebTUI sidecar +- tokenized WebSocket path +- remote-mode gating +- runtime install/preflight +- agent discovery + +Normal Ask AI should also be capability-driven. Provider detection and server sessions are host concerns; document UI can consume an abstract ask/send interface. + +## Bun/Pi Constraint + +Frontend extraction does not remove server parity work. + +The Bun server and Pi server both expose the document endpoints. Any route shape change still needs both implementations updated. + +For the extraction, avoid route renames. Keep `/api/plan` for compatibility and put a typed adapter over it. Rename only later if there is a deliberate migration plan. + +## Implementation Order + +1. Define document contracts. + +Create shared types for `DocumentSession`, `LoadedDocument`, `DocumentCapabilities`, `DocumentFeedbackPayload`, and `DocumentHostApi`. Model the current annotate `/api/plan` and `/api/doc` shapes without changing routes. + +2. Add an HTTP adapter. + +Move route calls behind `createPlannotatorHttpDocumentApi()`. Start with `/api/doc`, `/api/doc/exists`, `/api/reference/files`, `/api/reference/files/stream`, `/api/source/save`, `/api/draft`, and `/api/share-html`. + +3. Move source document modules. + +Move the cohesive source-edit modules out of `@plannotator/editor` and into the document package. This reduces `App.tsx` before the main surface extraction. + +4. Create `DocumentReviewSurface`. + +Wrap the existing viewer, HTML viewer, editor toggle, linked docs, file browser, annotation panel, drafts, and source-save state behind one component. + +5. Turn `packages/editor/App.tsx` into a host shell. + +The app should load session data, configure host capabilities, handle plan-specific flows, and delegate document mechanics to `DocumentReviewSurface`. + +6. Add contract and surface tests. + +Add Bun/Pi contract tests for `/api/plan` and `/api/doc`, plus a component test using an in-memory `DocumentHostApi`. + +## What Counts As A Good First Result + +The first successful extraction should make this true: + +- annotate file/folder/HTML/last still work +- plan review still works through the same document surface +- source save and drafts still work +- `App.tsx` no longer owns document mechanics directly +- document UI can run in tests without a live Plannotator server +- no endpoint rename is required +- no extra package split is introduced + +## Keep Out Of Scope + +Do not redesign the UI. + +Do not split into multiple small UI packages yet. + +Do not move the terminal runtime into the document package. + +Do not rename `/api/plan` during extraction. + +Do not fold plan diff, archive, or goal setup into the first document package boundary. + +Do not try to solve all Pi/Bun server duplication as part of the frontend package extraction. + +## Open Decisions + +1. Should the package be a new workspace package or a documented `/document` export inside `@plannotator/ui`? + +Recommendation: new package. `@plannotator/ui` is already broad and route-aware; a new package makes the boundary visible. + +2. Should source save be mandatory or optional? + +Recommendation: optional capability, but first-class inside the package. Sessions without source save should degrade cleanly. + +3. Should plan review be implemented as a document mode or a host wrapper? + +Recommendation: host wrapper. Plan review supplies a document plus approve/deny callbacks. The document package should not know about hook stdout decisions. + +4. Should the terminal panel move later? + +Recommendation: maybe, but only after the document surface exists. The runtime stays host-owned either way. + +## Recommendation + +Proceed toward an ADR that accepts one shared document UI package with a host API adapter. + +The key decision is not "move components to another folder." The key decision is to make Plannotator's document review experience the upstream surface and make plan review one consumer of that surface. + +That preserves the best part of the current architecture: one rich review experience across plans, files, folders, HTML, URLs, and last messages. + +It fixes the weak part: the document product is currently trapped inside a very large app shell and route-aware UI helpers. diff --git a/adr/specs/document-ui-extraction-20260620-083307.md b/adr/specs/document-ui-extraction-20260620-083307.md new file mode 100644 index 000000000..be176d17f --- /dev/null +++ b/adr/specs/document-ui-extraction-20260620-083307.md @@ -0,0 +1,1066 @@ +# Spec: Shared Document UI Package + +> ⚠️ **REVERTED — DO NOT IMPLEMENT.** Spec for the failed `@plannotator/document-ui` extraction (reverted 2026-06-22). The corrected plan is **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`**. Kept here as history only. + +Date: 2026-06-20 + +Status: Draft + +## Intent + +Extract Plannotator's document review experience into one shared UI package that can be used by Plannotator and a sister Workspaces repo. + +The package should not be a thin markdown renderer. It should own the reusable document review loop: + +- render markdown and raw HTML documents +- annotate text and blocks +- show comments and attachments +- navigate linked documents +- browse document/file trees +- edit documents +- show dirty/saving/saved/conflict/missing/error states +- restore drafts +- assemble feedback and saved-change context + +The host app should own routing, auth, server calls, environment capabilities, and provider-specific persistence. + +## Package + +Create one package: + +```text +@plannotator/document-ui +``` + +Do not split into renderer/tree/comments/editor packages yet. The first stable boundary is the full document review surface. + +Primary export: + +```tsx + +``` + +The package may later expose lower-level hooks and components, but those are secondary. The product-level export is the surface. + +## Design Principle + +The core contract must be provider-neutral. + +Do not name the shared contract around Plannotator's local source-save implementation. Local source-save is one provider. The reusable concept is document writeback state: + +```ts +type DocumentWritebackStatus = + | "clean" + | "dirty" + | "saving" + | "saved" + | "conflict" + | "missing" + | "error"; +``` + +Plannotator local implements writeback with: + +- `/api/source/save` +- disk hashes +- mtime +- EOL metadata +- missing local files +- source-save draft restore + +Workspaces implements writeback with: + +- `/v1/workspaces/{workspace}/documents/{document}` +- `If-Match` +- document versions +- missing document rows +- workspace restore semantics + +The package should own the common UI and state behavior. Providers own the persistence details. + +## Goals + +1. Make Plannotator's document experience the upstream UI for both repos. + +2. Keep one shared package, not many narrowly split packages. + +3. Move document-domain behavior out of `packages/editor/App.tsx`. + +4. Preserve current Plannotator behavior for: + +- plan review +- annotate file +- annotate folder +- annotate last message +- raw HTML annotation +- linked docs +- source-backed file editing +- drafts +- feedback submission + +5. Let Workspaces provide a different backend with the same document UI: + +- workspace manifest based document tree +- document ids instead of filesystem paths +- workspace versions instead of disk hashes +- workspace annotations and replies API +- workspace auth and routes + +6. Keep current Plannotator server routes stable during extraction. + +7. Make the document surface testable with an in-memory host API. + +## Non-Goals + +Do not redesign the user interface as part of this extraction. + +Do not rename `/api/plan` during the extraction. + +Do not solve Bun/Pi server duplication as part of the frontend package boundary. + +Do not move CLI/plugin command interception into the package. + +Do not move server startup or browser opening into the package. + +Do not move the agent terminal runtime, WebTUI sidecar, or runtime installer into the package. + +Do not make plan diff, archive, goal setup, or permission mode setup first-class parts of the first document package boundary. + +Do not require a filesystem path for every document. + +## Package Responsibilities + +`@plannotator/document-ui` owns: + +- `DocumentReviewSurface` +- document render mode switch: markdown, raw HTML, editing +- markdown parsing and block model +- markdown block rendering +- raw HTML iframe annotation bridge +- annotation lifecycle +- highlight restoration +- comments and annotation panel +- image attachments +- linked document navigation +- document tree/file tree rendering and status badges +- edit toggle and edit session state +- provider-neutral writeback state +- conflict/missing/error UI patterns +- draft save and restore state +- feedback payload assembly +- saved-change section assembly +- code path validation UI +- inline link handling +- optional Ask AI integration points +- optional agent-delivery integration points + +The host app owns: + +- app routing +- server endpoints +- auth +- current user/session identity +- provider implementation +- browser opening +- plugin/CLI integration +- local filesystem access +- workspace API access +- plan-mode hook stdout behavior +- plan history +- plan diff +- archive +- goal setup +- note-app settings and persistence policy +- terminal runtime and sidecar + +## Terminology + +### Host App + +The application that embeds `DocumentReviewSurface`. + +Examples: + +- Plannotator plan/annotate app +- Workspaces web app + +### Provider + +The host-side implementation of document loading, saving, tree listing, draft persistence, annotations, versions, and feedback submission. + +Examples: + +- Plannotator local provider +- Workspaces provider +- in-memory test provider + +### DocumentRef + +Provider-neutral identity for a document. + +A document may have a filesystem path, but the package must not require one. + +### Writeback + +Provider-neutral document edit persistence. + +Local Plannotator's current `sourceSave` is one writeback implementation. Workspaces document save is another. + +### Feedback + +The review output assembled from annotations, global comments, image attachments, direct edits, saved changes, linked-document comments, and provider-specific metadata. + +## Core Types + +This is a draft interface shape. Exact names can change during implementation, but the concepts should remain. + +```ts +export type DocumentProviderId = string; + +export interface DocumentRef { + id: string; + providerId?: DocumentProviderId; + label: string; + title?: string; + path?: string; + parentId?: string; + kind?: "document" | "folder" | "message" | "url" | "html" | string; + metadata?: Record; +} + +export type DocumentRenderMode = "markdown" | "html"; + +export interface LoadedDocument { + ref: DocumentRef; + content: string; + renderMode: DocumentRenderMode; + rawHtml?: string; + shareHtml?: string; + sourceInfo?: string; + converted?: boolean; + baseForRelativeLinks?: DocumentRef | string; + imageBase?: string; + writeback?: DocumentWritebackCapability; +} + +export type DocumentWritebackStatus = + | "clean" + | "dirty" + | "saving" + | "saved" + | "conflict" + | "missing" + | "error"; + +export interface DocumentWritebackCapability { + writable: boolean; + status: DocumentWritebackStatus; + revision?: string; + language?: "markdown" | "mdx" | "text" | string; + reason?: string; + message?: string; + providerState?: unknown; +} + +export interface DocumentWritebackState { + ref: DocumentRef; + status: DocumentWritebackStatus; + dirty: boolean; + revision?: string; + sessionOpenContent?: string; + savedContent?: string; + currentContent?: string; + conflict?: DocumentConflict; + error?: string; + providerState?: unknown; +} + +export interface DocumentConflict { + latestContent: string; + latestRevision?: string; + message?: string; + providerState?: unknown; +} + +export interface SaveDocumentRequest { + ref: DocumentRef; + content: string; + baseRevision?: string; + providerState?: unknown; + overwriteConflict?: boolean; +} + +export type SaveDocumentResult = + | { + ok: true; + ref?: DocumentRef; + revision?: string; + providerState?: unknown; + } + | { + ok: false; + code: "conflict"; + message: string; + latestContent: string; + latestRevision?: string; + providerState?: unknown; + } + | { + ok: false; + code: "missing" | "not-writable" | "validation" | "network" | "unknown"; + message: string; + providerState?: unknown; + }; +``` + +## Session Types + +```ts +export type DocumentSessionMode = + | "plan-review" + | "annotate" + | "annotate-folder" + | "annotate-message" + | "workspace-review" + | string; + +export interface DocumentReviewSession { + id: string; + mode: DocumentSessionMode; + origin?: string; + rootDocument?: LoadedDocument; + initialDocumentRef?: DocumentRef; + rootTreeRef?: DocumentRef; + capabilities: DocumentCapabilities; + ui?: DocumentSessionUi; + providerState?: unknown; +} + +export interface DocumentCapabilities { + canAnnotate: boolean; + canEdit: boolean; + canWriteback: boolean; + canApprove?: boolean; + canExit?: boolean; + canShare?: boolean; + canUploadImages?: boolean; + canOpenLinkedDocuments?: boolean; + canBrowseDocuments?: boolean; + canUseAskAI?: boolean; + canDeliverToAgent?: boolean; + supportsRawHtml?: boolean; + supportsVersions?: boolean; +} + +export interface DocumentSessionUi { + title?: string; + subtitle?: string; + primaryActionLabel?: string; + approveLabel?: string; + exitLabel?: string; +} +``` + +## Host API + +The surface talks to a provider through `DocumentHostApi`. + +```ts +export interface DocumentHostApi { + loadSession?(): Promise; + + loadDocument(request: LoadDocumentRequest): Promise; + + resolveLinkedDocument?(request: ResolveLinkedDocumentRequest): Promise; + + validateCodePaths?( + request: ValidateCodePathsRequest, + ): Promise; + + listDocuments?( + request: ListDocumentsRequest, + ): Promise; + + watchDocuments?( + request: WatchDocumentsRequest, + ): DocumentWatchSubscription; + + saveDocument?( + request: SaveDocumentRequest, + ): Promise; + + loadDraft?( + request: LoadDraftRequest, + ): Promise; + + saveDraft?( + request: SaveDraftRequest, + ): Promise; + + deleteDraft?( + request: DeleteDraftRequest, + ): Promise; + + uploadImage?( + file: File, + ): Promise; + + submitFeedback?( + payload: SubmitDocumentFeedbackPayload, + ): Promise; + + approve?( + payload: ApproveDocumentPayload, + ): Promise; + + exit?( + payload: ExitDocumentPayload, + ): Promise; + + askAI?( + request: DocumentAskAIRequest, + ): Promise | AsyncIterable; + + deliverToAgent?( + payload: DocumentAgentDeliveryPayload, + ): Promise; +} +``` + +### LoadDocumentRequest + +```ts +export interface LoadDocumentRequest { + ref: DocumentRef; + baseRef?: DocumentRef; + preferredRenderMode?: DocumentRenderMode; +} +``` + +### Tree Types + +```ts +export interface DocumentTreeNode { + ref: DocumentRef; + kind: "folder" | "document"; + children?: DocumentTreeNode[]; + annotationCount?: number; + writebackStatus?: DocumentWritebackStatus; + disabled?: boolean; + metadata?: Record; +} + +export interface DocumentTreeResult { + root: DocumentTreeNode; + workspaceStatus?: unknown; +} +``` + +### Watch Types + +```ts +export interface DocumentWatchSubscription { + close(): void; + onEvent(callback: (event: DocumentWatchEvent) => void): () => void; +} + +export type DocumentWatchEvent = + | { type: "ready"; ref?: DocumentRef } + | { type: "changed"; ref?: DocumentRef; reason?: string } + | { type: "deleted"; ref: DocumentRef } + | { type: "error"; message: string }; +``` + +The Plannotator local adapter can implement this over `EventSource('/api/reference/files/stream')`. + +The Workspaces adapter can implement it over its own workspace document event system, polling, or no-op watches. + +## DocumentReviewSurface Props + +```ts +export interface DocumentReviewSurfaceProps { + session: DocumentReviewSession; + hostApi: DocumentHostApi; + initialDocument?: LoadedDocument; + initialAnnotations?: Annotation[]; + initialCodeAnnotations?: CodeAnnotation[]; + initialGlobalAttachments?: ImageAttachment[]; + className?: string; + slots?: DocumentReviewSlots; + options?: DocumentReviewOptions; + onSubmitted?: (result: SubmitFeedbackResult) => void; + onApproved?: () => void; + onExited?: () => void; + onError?: (error: DocumentReviewError) => void; +} + +export interface DocumentReviewSlots { + leftSidebarExtraTabs?: React.ReactNode; + rightPanelExtraTabs?: React.ReactNode; + terminalPanel?: React.ReactNode; + headerActions?: React.ReactNode; + footer?: React.ReactNode; +} + +export interface DocumentReviewOptions { + defaultEditorMode?: "selection" | "comment" | "redline" | "quickLabel"; + defaultInputMethod?: "drag" | "pinpoint"; + allowRawHtml?: boolean; + allowWideMode?: boolean; + allowImageAttachments?: boolean; + persistUiPreferences?: boolean; + disableDrafts?: boolean; + hideDocumentNavigator?: boolean; + hideAnnotationPanel?: boolean; +} +``` + +## State Ownership + +The package owns frontend state for: + +- active document +- linked document stack +- per-document annotation cache +- selected annotation +- global comments and attachments +- parsed blocks +- markdown edit session +- writeback status map +- dirty document set +- conflict/missing/error display +- draft payload +- saved-change records +- file/document tree badges +- feedback payload assembly + +The provider owns durable state for: + +- document content +- revisions or hashes +- versions +- saved annotations, if the host persists them +- draft storage backend +- auth and permissions +- server-side conflict detection +- route shape + +## Writeback State Machine + +The package should implement the common frontend state machine. + +### clean + +The current editor buffer matches the loaded baseline. + +Allowed actions: + +- edit +- annotate +- navigate away +- submit feedback + +### dirty + +The user changed editable content but has not saved it. + +Allowed actions: + +- save +- discard +- continue editing +- submit only if the product policy allows unsaved direct edits + +Plannotator local should continue to include direct edits in feedback where appropriate. + +Workspaces may choose to require save before submit or include unsaved edits as proposed changes. + +This policy should be configurable by the session or provider. + +### saving + +A writeback request is in flight. + +Allowed actions: + +- show saving state +- prevent duplicate save +- avoid applying stale watch snapshots + +### saved + +The user saved a change during this review session. + +Allowed actions: + +- show saved state +- include saved-change context in feedback where the session requests it +- treat current saved content as the new baseline + +### conflict + +The provider rejected save because the remote/local document changed. + +Required provider data: + +- latest content +- latest revision or provider-specific conflict state + +Allowed actions: + +- reload latest +- overwrite, when policy allows and the edit buffer is available +- keep editing +- discard + +The package should not show overwrite if the provider or current state cannot perform it. + +### missing + +The document no longer exists or cannot be resolved. + +Allowed actions: + +- show missing row/state +- preserve annotations and draft context when possible +- allow discard/close +- allow restore/recreate only if provider advertises support + +### error + +An operation failed without a recoverable conflict or missing state. + +Allowed actions: + +- retry if operation is retryable +- discard local edits +- submit only if policy allows + +## Provider Policies + +Some behavior must be provider/session configurable: + +```ts +export interface DocumentWritebackPolicy { + submitWithUnsavedEdits: + | "allow-as-direct-edits" + | "block" + | "ask"; + submitWithUnverifiedSavedChanges: + | "allow" + | "block" + | "ask"; + conflictOverwrite: + | "allowed" + | "disallowed" + | "provider"; + missingDocumentRestore: + | "none" + | "recreate" + | "provider"; +} +``` + +Plannotator local likely uses: + +- `submitWithUnsavedEdits: "allow-as-direct-edits"` for plan edits +- stricter behavior for source-backed saved-change verification +- `conflictOverwrite: "allowed"` only when live editor buffer is available +- `missingDocumentRestore: "none"` for first extraction + +Workspaces likely uses: + +- `submitWithUnsavedEdits: "block"` or `"ask"` depending on product choice +- `submitWithUnverifiedSavedChanges: "block"` if version checks fail +- `conflictOverwrite: "provider"` +- `missingDocumentRestore: "provider"` if workspace restore exists + +## Drafts + +The package owns draft shape and restore behavior, but the host owns persistence. + +Drafts should store provider-neutral data: + +```ts +export interface DocumentReviewDraft { + annotations: Annotation[]; + codeAnnotations?: CodeAnnotation[]; + globalAttachments: ImageAttachment[]; + editedDocuments?: DraftEditedDocument[]; + savedChanges?: DraftSavedDocumentChange[]; + activeDocumentRef?: DocumentRef; + selectedAnnotationId?: string; + generation: number; + timestamp: number; +} + +export interface DraftEditedDocument { + ref: DocumentRef; + baseRevision?: string; + baseContent: string; + currentContent: string; + providerState?: unknown; +} + +export interface DraftSavedDocumentChange { + ref: DocumentRef; + beforeContent: string; + afterContent: string; + beforeRevision?: string; + afterRevision?: string; + providerState?: unknown; +} +``` + +Plannotator local can map existing draft fields to this shape. + +Workspaces can persist drafts in its own storage and map workspace revisions into `baseRevision` / `afterRevision`. + +Draft generation remains important. It prevents late saves from resurrecting cleared drafts after submit. + +## Feedback Assembly + +The package should assemble a provider-neutral feedback payload: + +```ts +export interface SubmitDocumentFeedbackPayload { + sessionId: string; + mode: DocumentSessionMode; + activeDocument?: DocumentRef; + annotations: Annotation[]; + linkedDocumentAnnotations?: LinkedDocumentAnnotationEntry[]; + codeAnnotations?: CodeAnnotation[]; + globalAttachments?: ImageAttachment[]; + directEdits?: DirectEditEntry[]; + savedChanges?: SavedDocumentChangeEntry[]; + messageScope?: unknown; + providerState?: unknown; +} +``` + +The package can also expose a renderer for human-readable markdown feedback, but the host decides what to do with the payload. + +Plannotator local host behavior: + +- convert payload into current agent feedback text +- call `/api/feedback`, `/api/approve`, or `/api/deny` +- optionally route feedback to the agent terminal + +Workspaces host behavior: + +- save comments/replies through annotation APIs +- save review state through workspace APIs +- submit or share feedback according to workspace product rules + +## Linked Documents + +Linked document navigation must use `DocumentRef`, not filesystem path as the only identity. + +The package should handle: + +- opening linked docs +- preserving root document state +- caching annotations per document +- switching between markdown and raw HTML render modes +- returning to the prior document +- showing annotation counts in the tree + +The provider handles: + +- resolving link text/path/id to `DocumentRef` +- loading document content +- enforcing access and auth +- choosing whether relative links are path-based, manifest-based, or id-based + +## Document Tree + +The package should render a tree of `DocumentTreeNode`. + +Tree rows should support: + +- folders +- documents +- active document indicator +- annotation count +- writeback status badge +- workspace/git/provider status metadata +- missing/deleted rows +- disabled rows + +The package should not assume git status. It can accept provider metadata and render known generic status patterns. Plannotator local can map git workspace status into this metadata. + +## Raw HTML + +Raw HTML support remains part of the package. + +The package owns: + +- iframe rendering +- annotation bridge integration +- shared viewer handle behavior +- raw HTML annotation state + +The provider owns: + +- asset rewriting +- raw HTML sanitization/permission policy if needed +- portable/share HTML generation + +## Ask AI + +Ask AI should be an optional capability. + +The package owns: + +- where Ask AI affordances appear +- how selected document context is gathered +- how annotation context is included + +The host owns: + +- provider selection +- auth +- session creation +- streaming implementation +- terminal fallback + +## Agent Delivery + +Agent delivery is optional and host-owned. + +The package can accept: + +```ts +export interface DocumentAgentDeliveryCapability { + available: boolean; + deliveredKey?: string; + send(payload: DocumentAgentDeliveryPayload): Promise; +} +``` + +The package can use this to: + +- show delivered/current feedback state +- avoid duplicate sends +- route Ask AI prompts to an agent when configured + +The package must not own: + +- WebTUI runtime +- sidecar process +- WebSocket URL creation +- remote-mode security +- runtime installation + +## Plannotator Local Adapter + +Add a browser-side adapter around current routes: + +```ts +createPlannotatorHttpDocumentApi(options?: { + baseUrl?: string; +}): DocumentHostApi +``` + +Initial route mapping: + +- `loadSession` -> `GET /api/plan` +- `loadDocument` -> `GET /api/doc` +- `validateCodePaths` -> `POST /api/doc/exists` +- `listDocuments` -> `GET /api/reference/files` +- `watchDocuments` -> `GET /api/reference/files/stream` +- `saveDocument` -> `POST /api/source/save` +- `loadDraft` -> `GET /api/draft` +- `saveDraft` -> `POST /api/draft` +- `deleteDraft` -> `DELETE /api/draft` +- `uploadImage` -> `POST /api/upload` +- `submitFeedback` -> `POST /api/feedback` +- `approve` -> `POST /api/approve` +- `exit` -> `POST /api/exit` + +The adapter should map local `sourceSave` into provider-neutral writeback fields. + +The core package should not leak `sourceSave` into its public contract except through local adapter internals. + +## Workspaces Adapter + +The sister repo should be able to implement: + +```ts +createWorkspaceDocumentApi(options: { + workspaceId: string; + auth: WorkspaceAuth; + baseUrl: string; +}): DocumentHostApi +``` + +Expected mapping: + +- load tree from workspace manifest +- resolve linked docs from manifest/document ids +- load documents by document id +- save with `If-Match` or version id +- map versions/ETags to `revision` +- map deleted/unavailable docs to `missing` +- load comments/replies from annotations API +- persist drafts through workspace draft storage +- load versions through versions API + +This adapter does not need to live in the Plannotator repo if the package contract is stable. + +## Migration Plan + +### Phase 1. Contracts + +Create `packages/document-ui`. + +Add: + +- core types +- `DocumentHostApi` +- provider-neutral writeback types +- draft types +- feedback payload types + +No product behavior changes. + +### Phase 2. Local HTTP Adapter + +Implement `createPlannotatorHttpDocumentApi()` over current routes. + +Use it from `packages/editor/App.tsx` where possible without moving major UI yet. + +Goal: route calls begin moving behind the adapter. + +### Phase 3. Move Document Domain Modules + +Move or re-export: + +- editable document state +- source document client/reconciliation, renamed toward writeback where public +- saved file change validation, generalized to saved document change validation +- direct edits +- draft restore selection +- path helpers only where local-specific + +Rename public concepts from source-save to writeback. Keep local source-save names inside the local adapter. + +### Phase 4. Extract Surface Shell + +Create `DocumentReviewSurface` around existing: + +- `Viewer` +- `HtmlViewer` +- `MarkdownEditor` +- annotation panel +- linked doc hook +- file browser hook +- draft hook +- writeback state + +`packages/editor/App.tsx` still owns mode decisions and passes session/capabilities. + +### Phase 5. Move Feedback Assembly + +Move provider-neutral feedback assembly into document-ui. + +Keep Plannotator-specific final text wrapping in the Plannotator host. + +### Phase 6. Host Cleanup + +Shrink `packages/editor/App.tsx` into: + +- load session +- create Plannotator host API +- configure plan/annotate actions +- render `DocumentReviewSurface` +- render plan-only sidecars such as plan diff/archive when needed + +### Phase 7. Workspaces Integration + +Workspaces implements its adapter and embeds the surface. + +Any missing extension points should be added to the contract, not patched through Plannotator-local assumptions. + +## Testing + +Add contract tests: + +- Plannotator Bun `/api/plan` maps to `DocumentReviewSession` +- Pi `/api/plan` maps to the same `DocumentReviewSession` +- `/api/doc` markdown maps to `LoadedDocument` +- `/api/doc` raw HTML maps to `LoadedDocument` +- `/api/doc` converted HTML maps to non-writable document +- `/api/doc` source-save maps to writeback capability +- `/api/source/save` conflict maps to provider-neutral conflict + +Add package tests: + +- in-memory provider loads a document +- annotate and restore highlights +- edit document and transition clean -> dirty -> saving -> saved +- conflict result transitions to conflict and shows conflict controls +- missing document transition keeps row and draft context +- linked document navigation preserves annotation cache +- raw HTML document can create annotations through the common surface handle +- draft restore rehydrates edited documents and saved changes +- feedback payload includes annotations, linked doc comments, direct edits, and saved changes + +Keep existing tests active: + +- parser tests +- markdown editor fidelity tests +- annotation draft persistence tests +- file browser tests +- editable document tests +- source reconciliation tests +- server annotate/reference tests +- Pi server parity tests + +## Acceptance Criteria + +The spec is satisfied when: + +- `@plannotator/document-ui` exists as one package. +- It exports provider-neutral contracts. +- It exports `DocumentReviewSurface`. +- Plannotator uses the surface for plan review and annotate flows. +- Plannotator local behavior is preserved. +- Local source-save is represented as writeback in the package contract. +- The package public API does not require filesystem paths. +- The package public API does not expose `/api/source/save`. +- The package public API does not expose local disk hash semantics as required fields. +- Workspaces can implement a provider using document ids, manifests, `If-Match`, and versions. +- The document surface can run in tests with an in-memory provider. +- Plan diff/archive/goal setup remain host-owned. +- Agent terminal runtime remains host-owned. + +## Open Questions + +1. Should `@plannotator/document-ui` depend on `@plannotator/ui`, or should document components move out of `@plannotator/ui`? + +Working recommendation: start with `@plannotator/document-ui` depending on `@plannotator/ui` for primitives and gradually move document-specific components into the new package. Avoid a giant one-shot move. + +2. Should the Plannotator local HTTP adapter live in `@plannotator/document-ui` or `@plannotator/editor`? + +Working recommendation: put the browser-side adapter in `@plannotator/document-ui` if it only uses fetch and public routes. Keep server-only code out. + +3. Should feedback assembly produce markdown text or structured data? + +Working recommendation: produce structured data first and expose a markdown formatter. Plannotator can keep its agent-specific markdown wrapper. + +4. Should unsaved edits be allowed in Workspaces feedback? + +Working recommendation: make this a provider policy. Do not bake Plannotator's direct-edit behavior into all providers. + +5. Should comments/replies persistence be part of the host API now? + +Working recommendation: include extension points now, but do not require persistent comments for the first Plannotator extraction. Workspaces can implement persistence through the adapter. + +## Decision Draft + +Adopt one provider-neutral shared document UI package. + +The core abstraction is not local source-save. The core abstraction is document writeback state plus a host API. + +Plannotator local source-save becomes the first provider implementation. Workspaces becomes a second provider implementation. Plan review becomes one host mode that supplies a document, capabilities, and approve/deny behavior to the shared document surface. diff --git a/adr/specs/document-ui-feature-completeness-review-fixes-20260622-085528.md b/adr/specs/document-ui-feature-completeness-review-fixes-20260622-085528.md new file mode 100644 index 000000000..6f2a03b55 --- /dev/null +++ b/adr/specs/document-ui-feature-completeness-review-fixes-20260622-085528.md @@ -0,0 +1,519 @@ +# Spec: Document UI Feature Completeness Review Fixes + +> ⚠️ **FAILED ATTEMPT — USE AS A CHECKLIST ONLY, NOT A BUILD PLAN.** This catalogs the parity gaps in the reverted `@plannotator/document-ui` cutover (reverted 2026-06-22). It is a useful *inventory of behaviors the UI must preserve*, but do NOT implement it as written — it patches a reimplementation that was thrown away. Corrected plan: **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`**. + +Date: 2026-06-22 + +Status: In Progress + +## Intent + +Close the verified post-review parity gaps in the `@plannotator/document-ui` cutover so the package surface is feature-complete for Plannotator's Plan Review and Annotate app. + +The branch already performs a real architecture cutover: `packages/editor/App.tsx` mounts the new host, and `@plannotator/document-ui` owns much of the document-review state. The remaining work is not to restart the extraction. The remaining work is to wire the production surface to the behavior that was extracted, or to port the few still-missing UI entry points. + +Feature-complete means the normal Plan Review and Annotate production path can ship without the old App shell and without knowingly dropping user-facing behavior from the pre-cutover app. + +## Current Read + +The review findings are mostly valid. The package has many of the right helpers and tests, but the mounted production path does not yet call or expose several of them. + +Confirmed high-impact gaps: + +- Header actions are hidden in Plan Review because the whole `slots` object is gated on agent terminal availability. +- Submit, approve, and close call host APIs directly instead of routing through the extracted safety decisions. +- Annotate-last message data is created by the adapter but not rendered or sent back as an active message scope. +- External annotations and VS Code editor annotations still have server endpoints and shared hooks, but the document surface does not subscribe to them. +- Plan diff uses a new read-only renderer instead of the existing interactive diff viewer with block annotations and VS Code diff support. + +Confirmed secondary gaps: + +- Saved source-change validation exists but is not called before submit/approve. +- Shortcut registries exist but are not registered in the new surface. +- Code-file popout and code-file annotation entry points are not wired from `Viewer`. +- Raw HTML share does not call `/api/share-html` to build portable HTML with inlined relative assets. +- Wide/focus mode helpers exist but are unreachable. +- The new sidebar is versions plus file tree, without old TOC/archive/vault/reference parity. +- Settings are partially stubbed after the menu is restored. +- Print, checkbox task toggles, product announcements, and dead old header cleanup remain smaller follow-ups. + +## Definition Of Done + +The document UI cutover is feature-complete when: + +- Plan Review has Settings, Export, Share, Import, print, note integrations, and any host header actions visible without requiring agent terminal support. +- Approve, Send Feedback, Close, gate-mode approve, and submit shortcuts all run the same safety checks as the old app. +- Unsaved writeback edits, feedback-loss cases, stale saved source changes, missing files, and no-op saved changes are handled before delivery. +- Annotate file, annotate folder, annotate raw HTML, and annotate-last all preserve their feedback target and navigation behavior. +- External annotations and editor annotations appear in the UI and are included in exported/submitted feedback. +- Plan diff/version review supports interactive diff annotations and the VS Code diff affordance. +- The expected keyboard shortcuts work from the production surface. +- Code-file links can open the code-file popout and create code-file annotations where supported. +- Portable sharing of raw HTML sessions preserves relative assets. +- Existing package and editor tests pass, and targeted regression tests cover the formerly missing wiring. + +## P0 Required Fixes + +### 1. Always Mount Header Actions + +Problem: + +`PlannotatorDocumentSurfaceBridge` returns `undefined` slots when `terminalAvailable` or `agentTerminal` is false. This hides `PlanHeaderMenu`, Settings, Export, Import, Share, print, and note actions in normal Plan Review. Agent terminal is only available for annotate file/folder sessions, so the primary Plan Review flow loses its menu. + +Required behavior: + +- `headerActions` must always be provided for supported Plannotator sessions. +- Only `terminalPanel` and terminal-specific header buttons should be gated by terminal availability. +- Plan Review and Annotate should both receive the common Plannotator host actions. + +Implementation shape: + +- Split `hostHeaderActions` from terminal slots in `PlannotatorDocumentSurfaceBridge`. +- Return a slots object unconditionally, with `terminalPanel` set to `null` when unavailable. +- Keep terminal delivery logic unchanged. + +Acceptance: + +- Plan Review shows the options menu with Settings, Export, Share, Import, and print. +- Annotate sessions still show terminal controls only when terminal capability is available. +- Add a render test that plan-review mode includes `data-document-review-header-actions` and the menu trigger when terminal is unavailable. + +### 2. Wire Action Safety Decisions + +Problem: + +The package contains decision helpers for feedback loss, unsaved writeback edits, gate-mode primary action, print shortcut behavior, and submit shortcut behavior. The production buttons call `state.submitFeedback()`, `state.approve()`, and `state.exit()` directly. + +Required behavior: + +- Before Send Feedback, Approve, or Close, the surface must evaluate extracted chrome/action decisions. +- Unsaved writeback edits must warn before close, approve, or send feedback. +- Approve must warn when feedback would be lost. +- Close must warn when feedback would be lost. +- Gate-mode annotate should approve when there is no feedback and send feedback when feedback exists. +- Submit shortcut behavior must use the same action decision as the primary button. + +Implementation shape: + +- Add a small action coordinator inside `DocumentReviewSurface` or as a package hook. +- Render a package-owned confirmation dialog using copy from `documentReviewChrome.ts`. +- Keep host APIs simple: hosts should receive the final approved submit/approve/exit call, not own these generic decisions. +- Use existing `buildUnsavedDocumentEditContinuationDecision`, `decideDocumentReviewFeedbackAction`, `decideDocumentReviewApproveAction`, `decideDocumentReviewExitAction`, `decideDocumentReviewPrimarySubmitAction`, and `buildUnsavedDocumentEditWarningCopy`. + +Acceptance: + +- Tests prove dirty writeback documents block direct submit/approve/close until confirmed. +- Tests prove approve warns when feedback would be lost. +- Tests prove close warns when feedback would be lost. +- Tests prove gate-mode empty annotate primary action calls approve. +- Manual Plan Review: add annotation, click Approve, see feedback-loss warning where old app warned. + +### 3. Reconnect Annotate-Last Message Workflow + +Problem: + +`createPlannotatorEditorLoadPlan()` builds `messages.recentMessages` and `messages.selectedMessageId`, and the Plannotator delivery layer can submit `selectedMessageId` and `feedbackScope`. The new production host does not pass or render `loadPlan.messages`, and `createFeedbackPayload()` only includes `messageScope` when it is manually injected. + +Required behavior: + +- Annotate-last must show the recent message set or an equivalent message navigation UI. +- The selected message must be visible and changeable. +- Annotations must stay associated with their selected message. +- Feedback must include `selectedMessageId`. +- If multiple messages are annotated, feedback must include `feedbackScope: "messages"` or the provider-neutral equivalent expected by the adapter. + +Implementation shape: + +- Prefer modeling messages as provider-neutral documents, if that can be done without distorting the contract. +- Otherwise add a narrow message session controller owned by `DocumentReviewSurface` or the Plannotator bridge. +- Cache annotations per message using existing linked-document/message state helpers where possible. +- Pass the resolved `messageScope` into `createFeedbackPayload()`. + +Acceptance: + +- Annotate-last opens with the selected recent message. +- Switching messages restores that message's annotations. +- Annotating one message sends that message id. +- Annotating multiple messages sends multi-message scope. +- Existing message feedback formatting remains unchanged. + +### 4. Reconnect External And Editor Annotations + +Problem: + +`useExternalAnnotations` and `useEditorAnnotations` still exist in `@plannotator/ui`, and server endpoints still exist. The document surface does not subscribe to them, does not show them in the panel, and does not include editor annotations in feedback. + +Required behavior: + +- External annotations posted to `/api/external-annotations` appear in Plan Review and Annotate where applicable. +- VS Code editor annotations appear when running inside VS Code. +- External annotation updates and deletes are reflected in the UI. +- Editor annotations can be deleted from the UI. +- Feedback/export includes editor annotations and external annotations in the same wording as before. + +Implementation shape: + +- Add optional host/provider annotation channels to `DocumentHostApi`, or provide Plannotator host hooks through surface slots if route names must remain host-owned. +- Keep route names and SSE transport Plannotator-owned. +- Merge external annotations into surface annotation state without duplicating persisted local annotations. +- Pass editor annotations into feedback text assembly. +- Reuse existing `AnnotationPanel`/`EditorAnnotationCard` behavior where possible. + +Candidate host API: + +```ts +interface DocumentHostApi { + watchExternalAnnotations?( + request: WatchExternalAnnotationsRequest, + ): ExternalAnnotationSubscription; + deleteExternalAnnotation?(request: DeleteExternalAnnotationRequest): Promise; + updateExternalAnnotation?(request: UpdateExternalAnnotationRequest): Promise; + loadEditorAnnotations?(request: LoadEditorAnnotationsRequest): Promise; + deleteEditorAnnotation?(request: DeleteEditorAnnotationRequest): Promise; +} +``` + +Acceptance: + +- Posting a plan-review annotation through `/api/external-annotations` shows it without reload. +- Deleting an external annotation removes it. +- VS Code editor annotations appear inside the document review feedback panel. +- Submitted feedback includes editor annotations. + +### 5. Restore Interactive Plan Diff Parity + +Problem: + +The new `DocumentVersionDiffViewer` renders read-only diff blocks. The old `PlanDiffViewer` supports clean/raw modes, block-level diff annotation, selected diff annotations, and `/api/plan/vscode-diff`. + +Required behavior: + +- Version diff supports diff annotations with `diffContext`. +- Diff annotations appear in the feedback panel and exported/submitted feedback. +- The VS Code diff button works for Plannotator plan versions. +- Provider-neutral hosts can choose whether an external diff action is available. + +Implementation shape: + +- Prefer reusing `@plannotator/ui/components/plan-diff/PlanDiffViewer` in the package renderer module. +- If direct reuse is too coupled, port the interaction model into `DocumentVersionDiffViewer`. +- Replace direct `fetch("/api/plan/vscode-diff")` with an optional host API action. + +Candidate host API: + +```ts +interface DocumentHostApi { + openDocumentVersionDiff?(request: OpenDocumentVersionDiffRequest): Promise; +} +``` + +Acceptance: + +- Plan Review with prior versions shows interactive diff blocks. +- Hovering/clicking changed diff blocks can create comments/deletions/quick labels. +- Diff annotations include `[In diff content]` in submitted feedback. +- VS Code diff opens through the Plannotator host when a base version exists. +- Workspaces can omit the external diff action without breaking diff review. + +### 6. Validate Saved Source Changes Before Delivery + +Problem: + +Saved source-change validation exists, but submit/approve does not call it. Old behavior protected against stale disk state, missing files, and no-op saved edits before feedback delivery. + +Required behavior: + +- Before submit or approve, saved changes must be validated when the provider supports probing. +- Stale, missing, and no-op saved changes must be dropped or warned according to existing decisions. +- Unverified changes must be preserved when validation cannot prove they are stale. + +Implementation shape: + +- Keep generic validation in `@plannotator/document-ui`. +- Keep local source-save probe logic in the Plannotator host/adapter. +- Route Plannotator `validateSavedFileChanges()` into the surface action coordinator before delivery. + +Acceptance: + +- Tests cover valid, stale, missing, no-op, and unavailable saved-change probes. +- Submit payload only includes valid/unverified saved changes. +- UI reports dropped saved changes clearly enough for the user to understand what happened. + +## P1 Feature Completeness Fixes + +### 7. Register Keyboard Shortcuts + +Required behavior: + +- `Mod+Enter` submits the primary action. +- `Mod+P` opens print while preserving print-mode CSS behavior. +- `Escape` exits plan diff when diff is active. +- Input-method double-tap shortcuts work where supported. +- Shortcuts respect dialogs, text input focus, editing state, and submitted/exiting states. + +Implementation shape: + +- Register the existing `planReviewSurface` and `annotateSurface` shortcut scopes in the new surface/host. +- Use extracted `decideDocumentReviewSubmitShortcut` and `decideDocumentReviewPrintShortcut`. +- Wire `usePrintMode()` in the mounted app. + +Acceptance: + +- Shortcut tests cover disabled states and text input focus. +- Manual smoke confirms `Mod+Enter` and `Mod+P`. + +### 8. Restore Code-File Popout And Code Annotations + +Required behavior: + +- Markdown/PFM code-file links can open the code-file popout. +- Code-file annotations can be created and submitted. +- Code path validation continues to run through the host. + +Implementation shape: + +- Pass `onOpenCodeFile` into `Viewer`. +- Mount `CodeFilePopout` from `@plannotator/ui`. +- Use existing `useCodeFilePopout()` and host API code-file loading. +- Keep local filesystem route details in Plannotator host/adapter. + +Acceptance: + +- Clicking a code-file link opens the popout. +- Creating a code annotation adds it to the panel. +- Submitted feedback includes code-file annotations. + +### 9. Restore Portable Raw HTML Sharing + +Required behavior: + +- Raw HTML annotation sessions shared through Export/Share use portable HTML with relative assets inlined. +- The display HTML used by the review iframe should not be assumed to be the share HTML. + +Implementation shape: + +- Add `prepareShareHtml?` to the host API, or keep it as a Plannotator header action helper. +- Plannotator implementation calls `/api/share-html`. +- Cache prepared share HTML per active HTML document where sensible. + +Acceptance: + +- Sharing an HTML file with relative images/styles produces a share that renders correctly outside the local server. +- Markdown sharing remains unchanged. + +### 10. Restore Wide/Focus And Chrome Polish Needed For Parity + +Required behavior: + +- Wide/focus mode is reachable when `allowWideMode` is enabled and unavailable in archive/diff states. +- Left and right panels behave consistently with wide/focus transitions. +- Sticky controls, panel collapse, resize behavior, and visible document max-width are close enough to old Plan Review/Annotate behavior for normal use. + +Implementation shape: + +- Wire `documentWideMode.ts`, `documentReviewLeftSidebar.ts`, and `documentReviewRightPanel.ts` into `DocumentReviewSurface`. +- Keep user preference persistence host-owned or option-driven. +- Avoid making Plannotator-only settings required by core document UI. + +Acceptance: + +- Wide/focus controls exist when enabled. +- Entering wide/focus hides panels and can restore previous layout. +- Diff/archive states do not leave the layout stuck. + +### 11. Sidebar And Reference Parity + +Required behavior: + +- The left sidebar should cover the core old navigation workflows: TOC, versions, file tree, and in-session archive/reference access if those remain expected in Plan Review. +- Folder annotate should show the file tree with badges and writeback status. +- `openSidebarTab` from the load plan must be honored. + +Implementation shape: + +- Keep generic sidebar mechanics in the package. +- Keep Obsidian vault discovery and archive storage Plannotator-host owned. +- Use slots for Plannotator-only archive/vault/reference tabs if they are not generic. + +Acceptance: + +- Folder annotate opens the files tab by default. +- Archive mode or archive tab behavior matches the decided scope. +- TOC is available for long markdown documents if parity requires it. + +### 12. Finish Settings/Header Integration + +Required behavior: + +- Settings opened from the restored header should have real AI provider data. +- App version should come from package/app metadata, not `0.0.1`. +- Agent instruction copy should be enabled if that feature remains supported. +- Tater/grid/user display settings should either work or be explicitly declared out of scope. + +Implementation shape: + +- Plannotator host owns these values and passes them to the header slot. +- The package only exposes slot props and surface state needed by host actions. + +Acceptance: + +- Settings AI tab shows available providers. +- Header About/version is correct. +- Agent instructions copy works or is intentionally removed with tests/docs updated. + +## P2 Cleanup And Explicit Non-Goals + +These items should not block the feature-complete cutover unless the user/product bar says otherwise: + +- Product announcement dialogs for Plan AI and Look & Feel. These are product-owned notices, not core document review behavior. +- Moving Plannotator adapter subpath exports out of `@plannotator/document-ui`. This is boundary cleanup, not a Plannotator parity blocker. +- Deleting dead `AppHeader.tsx` and other old shell remnants once no imports remain. +- Re-adding `VITE_DIFF_DEMO` fallback behavior. This is dev/demo-only. +- Full old visual chrome parity for every ornamental detail. Preserve workflow capability first, then polish. + +## Callback Scope Decision + +Shared/hash session callback support exists in `PlannotatorSharedSessionHost`. Normal API-mode callback query support was not found in the new production host path. + +Decision needed: + +- If `?cb=&ct=` was only a shared-session workflow, no P0 work is needed. +- If API-mode sessions still need callback approval/feedback, add callback config parsing to `PlannotatorDocumentSurfaceHost` and route submit/approve through the same callback utility. + +Acceptance if in scope: + +- API-mode callback URLs preserve feedback and approval behavior. +- Shared-session callback behavior remains unchanged. + +## Package Boundary Requirements + +The package should own generic document review behavior: + +- annotation lifecycle +- feedback assembly +- writeback state and writeback warnings +- draft restore +- document tree/navigation +- version diff and diff annotations +- shortcuts and generic chrome decisions +- generic code-file preview hooks if a host can load targets + +The host should own environment behavior: + +- Plannotator route names +- note apps +- share/paste policy +- app version and settings data +- agent terminal runtime +- local source-save probing +- VS Code diff route +- external annotation transport route names +- archive/vault storage mechanics + +The Workspaces integration should be able to implement `DocumentHostApi` without importing Plannotator local source-save concepts. Any new host API should use provider-neutral names such as writeback, versions, annotations, external diff, and prepared share HTML. + +## Test Plan + +Unit and integration tests: + +- `bun test packages/document-ui` +- `bun test packages/editor` +- `bun run typecheck` +- `bun run --cwd apps/hook build` +- `git diff --check` + +New or updated tests should cover: + +- Header actions visible without terminal. +- Submit/approve/close safety warnings. +- Gate-mode primary action decision. +- Annotate-last selected and multi-message feedback scope. +- External annotation subscription/update/delete merge behavior. +- Editor annotation feedback inclusion. +- Diff annotation creation and feedback inclusion. +- Optional external version diff host action. +- Saved-change validation before delivery. +- Shortcut registration and blocking states. +- Code-file popout open path and code annotation feedback. +- Raw HTML share HTML preparation. + +Manual smoke tests: + +- Plan Review from `ExitPlanMode`: menu, approve, deny/send feedback, diff, settings, export/share/import. +- Annotate markdown file: annotations, source save, saved-change validation, close warnings. +- Annotate folder: file tree, badges, open files, writeback statuses. +- Annotate raw HTML with relative assets: render, annotate, share. +- Annotate-last: select messages and submit one-message and multi-message feedback. +- VS Code mode if available: editor annotations and VS Code diff. +- External annotation API: post, update, delete while UI is open. + +## Implementation Order + +1. Fix header slots so Plan Review has the menu again. +2. Wire the action coordinator and confirmation dialogs. +3. Add saved-change validation into the same action path. +4. Reconnect annotate-last message state and `messageScope`. +5. Reconnect external/editor annotations. +6. Restore interactive plan diff and VS Code diff host action. +7. Register shortcuts and print mode. +8. Restore code-file popout/code annotations. +9. Restore portable raw HTML sharing. +10. Wire wide/focus/sidebar parity and settings polish. +11. Remove dead old shell leftovers after the feature-complete path is verified. + +## Scope Decisions + +- Recent messages stay as provider-neutral message review state for this PR, not as regular `DocumentRef` entries. The surface owns message navigation/cache behavior and the Plannotator adapter maps it into annotate-last delivery. +- Standalone archive host is enough for this PR. In-session archive/vault/reference sidebar tabs remain P1/product-scope work. +- API-mode callback support is not required for this cutover. The old header only rendered callback actions for non-API shared sessions, and shared/hash callback support remains preserved. +- Restore workflow parity, not pixel-perfect old chrome. +- External/editor annotations are generic optional `DocumentHostApi` watch/delete capabilities, with Plannotator route names kept inside the Plannotator HTTP adapter. + +## Implementation Status: 2026-06-22 + +Completed in the current worktree: + +- Header actions are mounted in Plan Review without requiring annotate terminal support. +- Send Feedback, Approve, Close, and primary submit paths now route through package-owned action safety checks and confirmation dialogs. +- Saved source-file changes are validated before submit/approve, with stale, missing, and no-op changes filtered before delivery. +- Annotate-last message state is surfaced through a message navigator, cached per message, and submitted with `messageScope` and `messageAnnotations`. +- External annotations and VS Code editor annotations are exposed through a provider-neutral annotation watch/delete host API, rendered in the surface, and included in feedback assembly. +- Plan version diffs use the interactive plan diff viewer again, restoring block annotations and the Plannotator VS Code diff affordance. +- Basic production shortcuts and print behavior are restored for `Mod+Enter`, `Mod+P`, and diff `Escape`. +- Input-method switching is package-owned again: the surface owns mutable `drag`/`pinpoint` state and registers the existing Alt hold / Alt Alt input-method hook. +- Code-file links can open the code-file popout and create code-file annotations through the package surface. +- Raw HTML export/share preparation calls the Plannotator `/api/share-html` route before falling back to display HTML. +- Wide/focus controls are exposed by the package surface when `allowWideMode` is enabled, Plannotator enables them in the production bridge, and active wide/focus mode hides side panels until exit. +- Header settings now receive real AI provider capability data, the app version value, and enabled agent-instruction copy behavior. +- The unused old-shell `packages/editor/components/AppHeader.tsx` file and stale read-only diff renderer helpers were removed. +- Callback compatibility was audited: the old header only rendered bot callback actions for non-API shared sessions, and shared/hash callback sessions remain handled by `PlannotatorSharedSessionHost`. + +Verified: + +- `bun test packages/document-ui` -> 357 pass, 0 fail. +- `bun test packages/editor` -> 64 pass, 7 skip, 0 fail. +- `bun run typecheck` -> pass. +- `bun run --cwd apps/hook build` -> pass. +- `git diff --check` -> pass. +- Vite dev server smoke: `bun run --cwd apps/hook dev --host 127.0.0.1` served `http://127.0.0.1:3000/`, `/api/plan`, and `/api/plan/versions` successfully. +- Playwright Chromium smoke against the Vite-rendered production app path passed for: + - Plan Review header menu, Settings menu item, interactive diff view, wide-mode toggle, external annotation display, editor annotation display, print shortcut, and approve delivery. + - Plan Review share-link copy, global-comment creation, and deny/send-feedback delivery through `/api/deny`. + - Annotate markdown source-save edit/save via `/api/source/save`. + - Annotate folder document-tree expansion and `/api/doc` navigation. + - Annotate raw HTML share/export preparation via `/api/share-html`. + - Annotate-last multi-message navigator rendering. +- Playwright Chromium SSE smoke passed for browser consumption of `/api/external-annotations/stream` snapshot events. + +Additional focused checks after wide/focus, input-method, raw HTML share, code-file URL, and cleanup wiring: + +- `bun test packages/document-ui/DocumentReviewSurface.test.tsx packages/document-ui/DocumentReviewSurface.interaction.test.tsx packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx` -> 36 pass, 0 fail. +- Interaction coverage now dispatches `Mod+Enter`, `Mod+P`, `Alt`, and wide-mode clicks against a mounted DOM surface. +- Bridge coverage now verifies `/api/share-html` is called for raw HTML export/share and falls back safely when portable HTML is unavailable. +- Code-file coverage now verifies the popout `/api/doc` URL boundary uses the target path and active document base. +- `bun run typecheck` -> pass. + +Still open or requiring manual confirmation: + +- Old sidebar/reference parity is intentionally not a P0 blocker for this PR under the current scope decision: standalone archive host is enough, while in-session archive/vault/reference tabs remain P1/product-scope work. +- Host-only integrations still need manual confirmation in their native environments: the VS Code extension/editor-annotation producer and real external-annotation producers. The browser-rendered consumer paths are covered by the Playwright smoke above. diff --git a/adr/specs/document-ui-parity-cutover-20260621-121115.md b/adr/specs/document-ui-parity-cutover-20260621-121115.md new file mode 100644 index 000000000..85c96046a --- /dev/null +++ b/adr/specs/document-ui-parity-cutover-20260621-121115.md @@ -0,0 +1,467 @@ +# Spec: Document UI Parity Cutover + +> ⚠️ **REVERTED — DO NOT IMPLEMENT.** Spec for the failed cutover (reverted 2026-06-22). The corrected plan is **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`**. Kept here as history only. + +Date: 2026-06-21 + +Status: Draft + +## Intent + +Finish the `@plannotator/document-ui` extraction so the Plan Review / Annotate app uses the package as the real production document surface, with no parallel legacy document UI path left behind. + +The target is not to move every Plannotator feature into the shared package. The target is to move the reusable document-review experience into the package, then leave Plannotator-specific environment behavior in a small host shell. + +## Target State + +`packages/editor/App.tsx` should stop being the document-review product. It should become a Plannotator host shell that: + +- loads the session through the Plannotator adapter +- reads Plannotator settings and environment capabilities +- wires Plannotator-only routes and side effects +- provides host slots for settings, export/share, note integrations, archive, goal setup, and terminal +- renders `DocumentReviewSurface` + +The normal production path should not require: + +```text +VITE_DOCUMENT_SURFACE=1 +``` + +`VITE_DOCUMENT_SURFACE` should be removed once parity is reached. + +## Ownership Rule + +The package owns the document review loop. + +The host owns environment policy. + +### Package owns + +- markdown and raw HTML document review +- annotation creation, editing, deletion, selection, and persistence hooks +- global comments and image attachments +- linked document navigation +- document tree/file tree UI and badges +- message/document navigation when messages are represented as documents +- source/document editing UI +- writeback states: clean, dirty, saving, saved, conflict, missing, error +- draft restore UI and state +- feedback payload assembly +- plan/document version browsing and diff UI +- generic Ask AI panel and in-document ask affordances when a host AI API exists +- code/link preview UI when the host can load or validate targets +- generic shortcuts for document review actions +- default chrome needed for parity: toolstrip, sticky controls, sidebars, panels, empty states, banners, and action buttons + +### Host owns + +- server routes +- auth +- browser opening and process lifetime +- CLI/plugin/hook integration +- `ExitPlanMode` stdout decisions +- Plannotator settings persistence +- share/paste service policy +- import/export modal policy +- Obsidian, Bear, and Octarine integrations +- agent terminal runtime, PTY/WebSocket bridge, installer, and remote security policy +- goal setup business logic +- archive storage and list loading +- provider transport details for comments, versions, documents, and watches + +## Required Work + +### 1. Make `DocumentReviewSurface` the default app surface + +Remove the feature-flagged bridge as a separate product path. + +Current state: + +- `packages/editor/App.tsx` computes `USE_DOCUMENT_SURFACE`. +- The package surface is only rendered when the flag is enabled. +- The old app shell remains the default render path. + +Required changes: + +- Replace the default editor render path with the package surface. +- Keep a thin Plannotator host shell, but do not keep both document-review implementations. +- Delete `shouldUseDocumentSurfaceBridge()` and the `VITE_DOCUMENT_SURFACE` runtime branch after parity is green. +- Move or delete old `App.tsx` document-domain state that duplicates package state. + +Acceptance: + +- Running the normal app with no env flag renders `DocumentReviewSurface`. +- `rg VITE_DOCUMENT_SURFACE packages apps` returns no production code hits. +- `packages/editor/App.tsx` no longer imports or directly orchestrates `Viewer`, `HtmlViewer`, `PlanDiffViewer`, `AnnotationPanel`, `usePlanDiff`, `useLinkedDoc`, or `useArchive` for the main document path. + +### 2. Add provider-neutral versions and diff + +Plan diff/version browser is the biggest package gap. It should move into `@plannotator/document-ui` as optional document version capability. + +Add host API methods: + +```ts +interface DocumentHostApi { + listDocumentVersions?(request: ListDocumentVersionsRequest): Promise; + loadDocumentVersion?(request: LoadDocumentVersionRequest): Promise; +} +``` + +Draft types: + +```ts +interface DocumentVersionRef { + id: string; + label: string; + createdAt?: number; + revision?: string; + providerState?: unknown; +} + +interface DocumentVersionsResult { + versions: DocumentVersionRef[]; + currentVersionId?: string; + previousVersionId?: string; + providerState?: unknown; +} + +interface LoadedDocumentVersion { + version: DocumentVersionRef; + document: LoadedDocument; +} +``` + +Package behavior: + +- fetch and show versions when `session.capabilities.supportsVersions` is true +- select a base version +- compute markdown diffs in the package, using existing diff utilities +- render clean/raw diff modes +- support diff annotations +- block version/diff actions while document editing is dirty +- expose version state through render props for custom hosts + +Plannotator adapter: + +- map `/api/plan/versions` +- map `/api/plan/version` +- use existing `previousPlan` and `versionInfo` as initial version data when available + +Workspaces adapter expectation: + +- map workspace document versions API +- use workspace document ids and versions, not local history paths + +Acceptance: + +- Plan review with previous versions shows the same diff affordance as the old app. +- Version browser works from the package surface. +- Diff annotations are included in feedback with the current legacy wording. +- Workspaces can implement the version API without Plannotator route names. + +### 3. Bring default chrome to visible parity + +The current default `DocumentReviewSurface` chrome works, but it is simpler than the old shell. The package surface needs parity for the generic document review experience. + +Move or recreate in package: + +- annotation toolstrip +- sticky header lane behavior +- wide/focus document controls +- document max-width behavior +- raw HTML tool visibility toggle +- folder empty state +- linked document breadcrumb/back chrome +- message picker as document navigation, if message mode remains supported +- feedback panel count and delete/edit behavior +- right panel resize/collapse behavior +- left sidebar collapsed rail and tab behavior +- keyboard shortcuts for submit, print, diff exit, save, and panel/sidebar toggles +- code-file/link preview when the host can load the target +- checkbox override behavior if editable checkboxes remain part of rendered markdown review + +Keep host-owned: + +- user preference storage +- Plannotator-specific issue/help links +- product-specific header menu +- print side effect +- settings modal + +Acceptance: + +- Annotate markdown, annotate raw HTML, annotate folder, annotate last message, and plan review do not visibly regress from the old app for core review actions. +- Default package UI has no obvious missing document controls compared with the old app. +- Package surface remains usable without Plannotator-specific settings or note integrations. + +### 4. Turn file/message browsing into provider-neutral document navigation + +The package already has document tree state. It needs to become the real default file/message navigation path. + +Required changes: + +- Treat folders, files, and recent messages as `DocumentTreeNode` / `DocumentRef` data. +- Let Plannotator adapter map `/api/reference/files` and `/api/reference/files/stream` to `listDocuments` and optional watch behavior. +- Let Workspaces adapter map workspace manifest rows to the same tree. +- Preserve annotation counts and writeback status badges in the tree. +- Preserve highlighted/annotated file behavior where it is generic. +- Keep local filesystem containment and vault retry mechanics in the Plannotator adapter/host. + +Acceptance: + +- Annotate-folder can select markdown, text, and raw HTML files through the package surface. +- File annotation counts survive navigation. +- Writeback statuses show on tree rows. +- Message mode can navigate recent assistant messages without bespoke `App.tsx` state. + +### 5. Finalize writeback and local source-save cutover + +The provider-neutral writeback core is mostly done. The remaining work is to stop the old shell from applying separate source-save state. + +Required changes: + +- Route all active document edit/save/discard/reload-conflict behavior through package writeback state. +- Keep Plannotator source-save behavior inside `plannotator-*` adapter helpers. +- Ensure missing local files, disk conflicts, stale saved changes, and draft-restored edits behave the same as the old path. +- Remove duplicate editor/source-save state from `App.tsx` after package behavior is authoritative. + +Acceptance: + +- Saving source-backed markdown/text files works from the package surface. +- Dirty, saving, saved, conflict, missing, and error states match current semantics. +- Draft restore preserves dirty writeback buffers and saved-change context. +- No generic shared type requires disk hash, mtime, EOL, or filesystem path. + +### 6. Move Ask AI surface behavior into the package + +The package already has Ask AI context helpers and `hostApi.askAI`. It needs the UI path if parity requires the package surface to replace the old shell. + +Required changes: + +- Add a default AI panel when `session.capabilities.canUseAskAI` and `hostApi.askAI` are available. +- Use package-owned document context assembly. +- Support document-targeted ask from comments or selected document regions. +- Let the host provide provider/model settings and permission handling. +- Keep terminal fallback and agent-specific prompt policy host-owned. + +Possible host API extension: + +```ts +interface DocumentHostApi { + askAI?(request: DocumentAskAIRequest): Promise | AsyncIterable; + listAIProviders?(): Promise; + respondToAIPermission?(response: DocumentAIPermissionResponse): Promise; +} +``` + +Acceptance: + +- The old Ask AI panel can be replaced for document review sessions. +- Hosts without AI do not see AI UI. +- Provider/model/auth policy does not leak into core document types. + +### 7. Keep agent terminal as a host slot, but finish integration points + +Do not move the terminal runtime into `@plannotator/document-ui`. + +Required changes: + +- Keep `terminalPanel` or a refined terminal slot in `DocumentReviewSlots`. +- Let package chrome show/hide terminal entry points when the host provides a terminal slot/capability. +- Keep generic agent-delivery state in the package. +- Keep PTY, WebSocket, runtime install, remote-mode security, and shell prompt construction in Plannotator host code. + +Acceptance: + +- Annotate-mode terminal can be mounted beside the package document surface. +- Package can show delivered-to-agent status without knowing terminal transport details. +- Workspaces is not forced to implement a terminal. + +### 8. Handle archive without making it core document review + +Archive is Plannotator-specific storage, but it still needs a path after `App.tsx` is shrunk. + +Required changes: + +- Do not make archive mandatory in `DocumentHostApi`. +- Expose enough slot support for a host archive tab or collection browser. +- Plannotator host owns archive plan loading, selection, copy, and done behavior. +- Archive selection can load a read-only `LoadedDocument` into the package surface or render through a host-provided archive mode. + +Acceptance: + +- Plannotator archive mode still works after old `App.tsx` document shell is gone. +- Archive does not appear in Workspaces unless Workspaces opts into a comparable collection provider. + +### 9. Keep goal setup host-owned + +Goal setup is not document review. It should not become core package behavior. + +Required changes: + +- Render goal setup from the Plannotator host shell, not the legacy document shell. +- Keep `GoalSetupSurface` in its current package unless a later decision moves it. +- Ensure goal setup submit/exit still uses shared action-controller helpers only where useful. + +Acceptance: + +- Goal setup works without the old document-review render path. +- `@plannotator/document-ui` does not need goal setup-specific public types. + +### 10. Keep settings, share, export, and note integrations host-owned + +These are Plannotator product policies, not shared document review behavior. + +Required changes: + +- Package exposes current feedback payload/rendered feedback through callbacks or render state. +- Host uses that payload for export/share/import and note integrations. +- Host injects header/menu actions through slots. +- Package does not call paste service, Obsidian, Bear, or Octarine routes. + +Acceptance: + +- Export/share/import still work in Plannotator. +- Note-app saves still work in Plannotator. +- Workspaces can ignore these features or provide its own host actions. + +### 11. Finalize annotation provider integration + +Current package annotation persistence is a good base. Full parity needs live provider annotations to stop being old-app-specific. + +Required changes: + +- Keep `loadAnnotations` and `saveAnnotations`. +- Add optional watch/subscribe support if live updates are required: + +```ts +interface DocumentHostApi { + watchAnnotations?(request: WatchDocumentAnnotationsRequest): DocumentAnnotationSubscription; +} +``` + +- Package owns merging local draft annotations with provider-owned annotations. +- Host/provider owns external transport, SSE route names, VS Code editor annotation routes, and permission policy. + +Acceptance: + +- External/provider annotations can appear in the package surface. +- Editing or deleting provider annotations routes through the provider where appropriate. +- Hosts without live annotations still work through load/save/draft behavior. + +### 12. Cut down `packages/editor/App.tsx` + +After parity lands, remove old document-product orchestration. + +Keep in editor host shell: + +- load session +- build Plannotator host API +- read settings +- wire Plannotator host slots +- render completion overlay +- render modals owned by Plannotator +- handle plan-mode and annotate route policy + +Remove from editor host shell: + +- document annotation reducer +- linked-doc state machine +- plan diff state machine +- archive document rendering path +- file/message document navigation state +- markdown/html viewer rendering +- document edit/writeback UI state +- direct document feedback assembly +- duplicate draft restore logic + +Acceptance: + +- The old document body path is gone. +- The file is understandable as a host shell, not a product state machine. +- Any remaining Plannotator-specific code has a clear reason to stay host-owned. + +## Dependency Order + +1. Add missing package contracts: versions/diff, optional annotation watch, refined slots. +2. Move version/diff state and rendering into the package. +3. Bring package chrome to visible parity for toolstrip, sidebars, panels, file/message navigation, and code previews. +4. Wire Plannotator adapter to the new contracts. +5. Move Ask AI surface behavior into the package, keeping provider config host-owned. +6. Mount terminal/archive/settings/export/note integrations through host slots. +7. Flip default app path to `DocumentReviewSurface`. +8. Delete old duplicate editor document state. +9. Run parity verification and fix regressions. + +## Verification + +Minimum automated checks: + +```text +bun test packages/document-ui +bun run typecheck +bun build packages/document-ui/DocumentReviewSurface.tsx --target browser --outdir /tmp/plannotator-document-ui-build +bun run --cwd apps/hook build +bun run --cwd apps/review build +git diff --check +``` + +Browser smoke checks: + +- plan review approve +- plan review deny with annotations +- plan diff/version browser +- annotate markdown file +- annotate raw HTML file +- annotate folder and switch files +- annotate last message and switch messages +- linked markdown document navigation +- code-file/link preview +- image upload and image display +- source-save success +- source-save conflict +- source file missing and save/recreate behavior +- draft restore after reload +- Ask AI open/ask/permission if enabled +- agent terminal slot open/close/delivered status +- archive browse/done +- export/share/note actions + +## Non-Goals + +- Do not redesign Plannotator's visual language. +- Do not move server route implementations into the package. +- Do not rename current Plannotator routes as part of this cutover. +- Do not make Workspaces adapter code live in this repo. +- Do not make local source-save terms part of the provider-neutral core. +- Do not move terminal runtime or note-app policy into the package. +- Do not keep both old and new document UI paths after cutover. + +## Open Decisions + +1. Should archive be represented as a host-provided document collection API, or only as a Plannotator host slot? + + Recommendation: host slot for this cutover. Add a collection API later only if Workspaces has a matching need. + +2. Should Ask AI provider/model settings be shown inside the shared package panel or injected by host slot? + + Recommendation: package owns the panel shell and messages; host injects provider settings/actions. + +3. Should goal setup remain in `@plannotator/ui` or move to another host package? + + Recommendation: leave it where it is for this cutover. The important thing is that it no longer depends on the old document shell. + +4. Should package version/diff compare be host-computed or package-computed? + + Recommendation: host loads versions; package computes markdown diff by default. Add optional host-computed diff only if Workspaces needs semantic/version-specific compare results. + +## Completion Criteria + +This work is complete when: + +- The normal Plan Review / Annotate app renders through `@plannotator/document-ui`. +- There is no `VITE_DOCUMENT_SURFACE` cutover flag. +- The old document-review render path is removed. +- Plan review, annotate file, annotate folder, annotate last, raw HTML, linked docs, source-save, drafts, plan diff, Ask AI, terminal slot, archive, export/share, and note integrations all still work. +- Workspaces can implement the same UI by supplying a `DocumentHostApi` without inheriting Plannotator local source-save vocabulary. From 69e0640aec3fb26c7e84063d8e8765891bd383f6 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 22 Jun 2026 19:35:45 -0700 Subject: [PATCH 02/46] docs(adr): add verified document-ui extraction plan, supersede draft inventory 36-agent verification of the reuse inventory: confirmed the /api coupling but found the draft missed Viewer's transitive backend call, the cookie settings layer, 3 React contexts + identity singleton, SSE transports, and harder packaging blockers. Adds the verified per-subsystem extraction plan with a parity guardrail on every step; flags the draft inventory as superseded. --- ...ment-ui-reuse-inventory-20260622-183000.md | 121 ++++++++++++++ ...xtraction-plan-verified-20260622-184500.md | 158 ++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 adr/research/SPIKE-document-ui-reuse-inventory-20260622-183000.md create mode 100644 adr/specs/document-ui-extraction-plan-verified-20260622-184500.md diff --git a/adr/research/SPIKE-document-ui-reuse-inventory-20260622-183000.md b/adr/research/SPIKE-document-ui-reuse-inventory-20260622-183000.md new file mode 100644 index 000000000..c81de1aef --- /dev/null +++ b/adr/research/SPIKE-document-ui-reuse-inventory-20260622-183000.md @@ -0,0 +1,121 @@ +# Spike: Document UI Reuse Inventory (for Workspaces) + +Date: 2026-06-22 + +> ⚠️ **SUPERSEDED — first draft, materially incomplete.** A 36-agent verification found this audited only one coupling axis (literal `/api/` strings) and missed five others — most importantly: **Viewer is not actually "clean"** (fires `/api/doc/exists` on mount), an **uncounted cookie-persistence layer** (`storage.ts`, ~24 modules), **3 React contexts + a global identity singleton**, **SSE/EventSource** transports, and harder-than-stated **packaging blockers**. Use the verified version instead: **`adr/specs/document-ui-extraction-plan-verified-20260622-184500.md`**. Kept here as the first-pass record. + +> Companion to **ADR 004** (`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`). This is the concrete lay-of-the-land for sharing Plannotator's document UI with the commercial Workspaces app: what exists, what is reusable as-is, what is wired to Plannotator's backend, and a first-cut order of work. Read 004 first for the *why* and the safety rules. + +## The shape in one paragraph + +The document UI is two buckets. **Bucket 1 — `packages/ui`** (~39,400 lines, 108 components + 31 hooks + ~45 utils) is the reusable library: rendering, file browser, sidebar, editor, theme. **Bucket 2 — `packages/editor/App.tsx`** (4,685 lines, ~276 stateful hooks, 13 server fetches) is the Plannotator-specific *glue* that fetches Plannotator data and assembles it into the app. **Bucket 1 is what we share. Bucket 2 stays Plannotator's; Workspaces writes its own equivalent glue.** Of the 108 components, only **26 files** call Plannotator's server directly — those are the wires to cut. The other ~82 already take their data from the outside and are reusable as-is. + +## Bucket 1: `packages/ui` component library + +| Folder | Count | What it is | Workspaces relevance | +| --- | --- | --- | --- | +| `components/` (top level) | 65 | Viewer, MarkdownEditor, AnnotationPanel, modals, toolbars, etc. | Core | +| `components/blocks/` | 8 | Markdown block renderers (code, table, callout, alert, HTML…) | Core (doc rendering) | +| `components/sidebar/` | 7 | SidebarContainer, SidebarTabs, FileBrowser, VersionBrowser, ArchiveBrowser, MessagesBrowser, CountBadge | Core (sidebar + file tree) | +| `components/html-viewer/` | 1 | Raw HTML viewer | Core | +| `components/plan-diff/` | 6 | Plan version diff viewer | Maybe (Workspaces has version history) | +| `components/ImageAnnotator/` | 3 | Annotate images | Maybe | +| `components/ai/` | 2 | Ask-AI chat panel | Maybe (own AI) | +| `components/ui/` | 8 | Low-level primitives (buttons, dialogs…) | Core | +| `components/core/` | 2 | Shared core | Core | +| `components/icons/` | 4 | SVG icons | Core | +| `components/settings/` | 1 | Settings tab(s) | Partial | +| `components/goal-setup/` | 1 | Plannotator goal workflow | Plannotator-only | + +Plus `packages/ui/theme.css` (the theme/color tokens — pure, fully reusable), 31 hooks, ~45 utils, and `shortcuts/` (keyboard registry). + +## The 26 backend-coupled files (the wires to cut) + +Grouped by purpose. "WS" = does Workspaces need it. + +### Document rendering — WS: YES (do first) +| File | Calls | Note | +| --- | --- | --- | +| `components/blocks/HtmlBlock.tsx` | `/api/image` | image src in markdown | +| `components/ImageThumbnail.tsx` | `/api/image` | image thumbnails | +| `components/InlineMarkdown.tsx` | `/api/doc` | inline linked-doc loads | +| `hooks/useLinkedDoc.ts` | `/api/doc` | navigate doc → doc | +| `hooks/useValidatedCodePaths.ts` | `/api/doc/exists` | validate code-file links | +| `components/AttachmentsButton.tsx` | `/api/upload` | attach images to comments | + +### File tree / browser — WS: YES (core to Workspaces) +| File | Calls | Note | +| --- | --- | --- | +| `hooks/useFileBrowser.ts` | `/api/reference/files`, `/api/reference/files/stream`, `/api/reference/obsidian/*` | the file tree data source | + +### Comments / annotations / drafts — WS: YES (agents + teammates commenting) +| File | Calls | Note | +| --- | --- | --- | +| `hooks/useAnnotationDraft.ts` | `/api/draft` | autosave annotation drafts | +| `hooks/useCodeAnnotationDraft.ts` | `/api/draft` | autosave code annotations | +| `hooks/useExternalAnnotations.ts` | `/api/external-annotations`, `/api/external-annotations/stream` | **agents posting comments** — directly relevant to Workspaces | + +### Versions / diff — WS: MAYBE (Workspaces has version history) +| File | Calls | Note | +| --- | --- | --- | +| `hooks/usePlanDiff.ts` | `/api/plan/version`, `/api/plan/versions` | version list + fetch | +| `components/plan-diff/PlanDiffViewer.tsx` | `/api/plan/vscode-diff` | opens VS Code (Plannotator-local; WS would drop this one button) | + +### Settings / config — WS: PARTIAL (Workspaces feeds its own config) +| File | Calls | Note | +| --- | --- | --- | +| `config/configStore.ts` | `/api/config`, `/api/diff`, `/api/plan` | app config bootstrap | +| `config/settings.ts` | `/api/config` | settings load/save | +| `components/Settings.tsx` | `/api/ai/capabilities`, `/api/config`, `/api/obsidian/vaults` | settings panel | +| `components/settings/HooksTab.tsx` | `/api/config`, `/api/hooks/status` | Plannotator hooks tab (WS drops) | + +### Sharing / export / open-in — WS: PARTIAL (Workspaces has its own sharing) +| File | Calls | Note | +| --- | --- | --- | +| `utils/sharing.ts` | `/api/paste`, `/api/paste/` | short-URL share | +| `components/ExportModal.tsx` | `/api/save-notes` | save to Obsidian/Bear/Octarine | +| `components/OpenInAppButton.tsx` | `/api/open-in`, `/api/open-in/apps` | open in local app (local-only; WS drops) | + +### Ask AI / code-review agents — WS: NO / OWN +| File | Calls | Note | +| --- | --- | --- | +| `hooks/useAIChat.ts` | `/api/ai/*` | Ask-AI streaming (WS would wire its own AI) | +| `hooks/useAgents.ts` | `/api/agents` | agent provider detection | +| `hooks/useAgentJobs.ts` | `/api/agents/*` | code-review agent jobs (review feature, not docs) | + +### Archive / editor-annotations / plan-injection — WS: NO (Plannotator-only) +| File | Calls | Note | +| --- | --- | --- | +| `hooks/useArchive.ts` | `/api/archive/*`, `/api/done`, `/api/plan` | Plannotator plan archive | +| `hooks/useEditorAnnotations.ts` | `/api/editor-annotation(s)` | VS Code editor annotations | +| `components/goal-setup/GoalSetupSurface.tsx` | `/api/goal-setup/submit` | Plannotator goal workflow | +| `utils/planAgentInstructions.ts` | `/api/external-annotations`, `/api/plan` | plan-time prompt injection | + +### Tally +- **~10 coupled files Workspaces clearly needs** (rendering + file tree + comments). +- **~6 partial** (settings/config/sharing — Workspaces supplies its own source through the same shape). +- **~10 Plannotator-only** (archive, goal-setup, hooks, VS Code, code-review agents, open-in) — Workspaces simply won't mount these; no work needed beyond not importing them. + +## Bucket 2: the glue (`packages/editor/App.tsx`) + +4,685 lines, ~276 stateful hooks, 13 fetches. This is **not shared.** It is Plannotator's assembly layer: it bootstraps from `/api/plan`, runs the approve/deny hook flow, owns sidebar/panel/wide-mode layout state, and feeds everything into the Bucket-1 components. Workspaces writes its own (smaller) equivalent that bootstraps from its Cloudflare APIs and feeds the same components. + +**Caveat that matters:** some of "the experience" (which sidebar tab is open, file-tree expansion, panel resize, wide/focus mode) currently lives *inside this glue file*, not inside the reusable components. Part of the work is pushing that behavior **down into the components** (e.g. `SidebarContainer` owns its own open/close) so Workspaces' glue stays thin and doesn't have to re-derive layout logic. (Re-deriving that logic generically is exactly what the reverted attempt did wrong — push it into the real components instead.) + +## Packaging state + +`packages/ui/package.json` already declares `@plannotator/ui` with a fine-grained `exports` map (components, hooks, utils, config, types, theme). But it is `version: 0.0.1`, `type: module`, source-only (no build step, no publish). To be installable by an outside repo it needs: a real version, a build (or confirmed source-export consumption), peer-deps sorted (React, CodeMirror, Radix, etc.), and a publish target. Moderate, not hard. + +## First-cut order of work (the safe path from ADR 004) + +Each step: lift the server call out to a prop/callback, leave the component's logic intact, confirm Plannotator still looks identical, then move on. One item at a time. + +1. **Rendering core** — `/api/image`, `/api/doc`, `/api/doc/exists`, `/api/upload` (HtmlBlock, ImageThumbnail, InlineMarkdown, useLinkedDoc, useValidatedCodePaths, AttachmentsButton). Makes a doc render anywhere. +2. **File tree** — `useFileBrowser`. Makes the tree take a data source. +3. **Comments/drafts** — `useAnnotationDraft`, `useCodeAnnotationDraft`, `useExternalAnnotations`. Makes comments (incl. agent-posted) provider-driven. +4. **Versions** — `usePlanDiff` (keep the VS Code button as an optional prop Workspaces omits). +5. **Config/settings shape** — let `configStore`/`settings` take their source from the host instead of `/api/config`. +6. **Packaging** — turn `@plannotator/ui` into a real publishable package. +7. **Push layout state into components** — sidebar/panel/wide-mode behavior currently in `App.tsx` moves into the sidebar/layout components so Workspaces' glue stays thin. + +Steps 1–5 are independent and can be done in any order / in parallel by different people. Step 6 can start anytime. Step 7 is the largest and is best done last, informed by what Workspaces actually needs. diff --git a/adr/specs/document-ui-extraction-plan-verified-20260622-184500.md b/adr/specs/document-ui-extraction-plan-verified-20260622-184500.md new file mode 100644 index 000000000..47abf8646 --- /dev/null +++ b/adr/specs/document-ui-extraction-plan-verified-20260622-184500.md @@ -0,0 +1,158 @@ +# Spec: Document UI Extraction Plan — Verified + +Date: 2026-06-22 + +> Produced by a 36-agent verification workflow (5 coupling-sweep lenses + 15 subsystem analyses + 15 adversarial parity reviews + synthesis). It **verifies and supersedes** the draft inventory `adr/research/SPIKE-document-ui-reuse-inventory-20260622-183000.md`, which was directionally correct but materially incomplete. Governed by **ADR 004**. THE LAW: move + decouple, never rewrite; Plannotator's experience cannot change. Every seam below is "lift the URL/global to an optional prop, with **today's literal as the verbatim default**." + +**Repo:** `/Users/ramos/plannotator/feat-pkg-document-ui` · **Library:** `packages/ui` · **Glue:** `packages/editor/App.tsx` + +## Subsystem parity verdicts + +`safe` = extract via straightforward seams. `risky` = extractable, but contains timing-sensitive/stateful code that must be **moved verbatim**, not re-derived (the reverted attempt's failure mode). + +| Subsystem | Verdict | Effort | WS | +|---|---|---|---| +| Theme & tokens | safe | S | core | +| Markdown parsing + block rendering | safe | S | core | +| Document Viewer + annotation highlighting | **risky** | S–M | core | +| Raw HTML viewer | safe | S | core | +| Markdown editor | safe | S | core | +| File tree / browser | **risky** | M | core | +| Sidebar shell + tabs | safe | S | core | +| Comments / annotations / drafts | **risky** | L | core | +| Versions / plan diff | safe | M | maybe | +| Settings / config | safe | M | partial | +| Sharing / export / notes | safe | S | partial | +| Ask AI / agents | **risky** | M | maybe/own | +| Images: upload / thumbnail / annotate | safe | S | core | +| The glue (App.tsx layout) | safe | S | n/a | +| Packaging @plannotator/ui | safe | M | gate | + +## 1. What the draft inventory missed (verified corrections) + +The draft audited only one coupling axis — literal `/api/` strings at call sites — and was blind to five others. + +- **A. Viewer is NOT clean (most consequential).** `Viewer.tsx:532` calls `useValidatedCodePaths(...)` **unconditionally**, which POSTs `/api/doc/exists`. Mounting Viewer fires a Plannotator backend call. Viewer is **NEEDS_SEAM**, not one of the "clean 82." +- **B. An uncounted cookie-persistence subsystem.** `utils/storage.ts` (`document.cookie`) is the **sole** settings backend, imported by ~24 modules (theme, TOC/plan-width/sticky prefs, panel resize, editor mode, agent switch, AI provider, identity). NEEDS_SEAM (inject a `{getItem,setItem,removeItem}` adapter; it's already localStorage-shaped). +- **C. Three React contexts + one global singleton, never mentioned.** + - `ScrollViewportContext` — consumed in `packages/ui` (Viewer, StickyHeaderLane, PinpointOverlay, TableOfContents) but its **only Provider lives in the glue** (`App.tsx:3888`). Mounted elsewhere, sticky headers / pinpoint / scroll-to-anchor / TOC scrolling silently break. NEEDS_SEAM. + - `configStore` singleton — module-level, eager cookie reads, hardcoded `fetch('/api/config')` write-back (L118). Reached **transitively** via `identity.ts` by Viewer, AnnotationPanel, HtmlViewer, `useAnnotationHighlighter`, diff views, Settings. This is **annotation authorship** ("which comments are mine") — core to Workspaces commenting. NEEDS_SEAM (host-supplied `currentUser`/`isCurrentUser`). + - `CodePathValidationContext` — intra-library, null-tolerant. TRANSFER_AS_IS. +- **D. SSE (EventSource) is a distinct transport** the draft collapsed into REST siblings: `useFileBrowser.ts:297`, `useExternalAnnotations.ts:44`, `useAgentJobs.ts:66`. Workspaces' backend must speak SSE or supply a `subscribe` callback. +- **E. `useFileBrowser` is NOT transfer-as-is** — hard-codes `/api/reference/files` (L116), `/api/reference/obsidian/files` (L224), and the SSE watcher (L297). Only its expansion state is pure. +- **F. Packaging is harder than "moderate."** Hard blockers: `@plannotator/ai` + `@plannotator/shared` are `workspace:*` + `private` + `0.0.1`; a **phantom `dompurify`** dep (imported, not declared); **no `peerDependencies`** (React-duplication risk); `exports` point at raw `.tsx`/`.ts` with no `main`/`module`/`types`. +- **G. Small factual fixes** (so we don't act on bad data): theme count is a clean **51:51** (the "53/52" was a grep artifact); `useTheme()` does **not** throw without a provider (seeded default); `getImageSrc` is **one** shared seam across 5 consumers, not 3 separate wires; `utils/sharing.ts` calls the **external** paste service (base URLs parameterized), not a Plannotator `/api/paste` route. + +## 2. Master extraction plan (dependency-ordered) + +Each step: **default === today's literal**, additive optional prop/callback, logic untouched. The guardrail is how you prove Plannotator didn't change. + +### Step 0 — Packaging unblock (do first; gates external install, zero runtime effect). Effort M. +- Add `dompurify` to `packages/ui` deps at the root's exact `^3.3.3` (version mismatch could change sanitization output). +- Resolve the two `workspace:* / private` deps: publish `@plannotator/ai` + `@plannotator/shared` with real versions, **or** inline the ~11 verified browser-safe subpaths ui value-imports (all Web-API-only — Web Crypto, CompressionStream — no `node:*`). +- Add `peerDependencies` (react, react-dom, tailwindcss, tailwindcss-animate, radix set, lucide-react); keep as devDeps for in-repo typecheck. +- Fix stale `tsconfig.json:21` alias `@plannotator/shared` → nonexistent `../shared/index.ts`; align `diff` range (`^8.0.3` vs root `^8.0.4`). +- Keep the **source-only** export model (no dist build — a build could change what Plannotator ships); document required consumer bundler settings (`isolatedModules`, JSX runtime, `allowImportingTsExtensions`). +- Add a `files` allowlist incl. `assets/`, `sprite_package_*/`, themes; exclude `*.test.*` (the only upward `ui→editor` import is `shortcuts.test.ts`). +- **Guardrail:** `bun run build:hook` + `build:opencode` produce byte-identical bundles; in-repo React still resolves to one copy. + +### 1. Rendering core — images. Effort S. +- *As-is:* BlockRenderer, sanitizeHtml, inlineTransforms, parser render path. +- *Seam:* the single `getImageSrc` (ImageThumbnail.tsx:6) shared by 5 consumers (ImageThumbnail, InlineMarkdown, HtmlBlock, AttachmentsButton, Viewer). Introduce a module-level/context override whose default is the current body verbatim (http passthrough + conditional `&base`). Do **not** thread a Viewer-level prop — it can't reach InlineMarkdown/HtmlBlock. +- **Guardrail:** all 5 importers emit identical `/api/image?path=…&base=…`. Keep the default resolver **module-level (stable identity)** so HtmlBlock's `React.memo` + effect deps are untouched (otherwise `
` collapse on re-render). + +### 2. Rendering core — doc fetch + code-path validation. Effort S. +- *Seam A:* InlineMarkdown hover preview `fetch('/api/doc?…')` (L154) → optional `fetchCodeFileContents` defaulting to the literal (same `{path, base?}`, **no `convert=1`** — that's glue). `useLinkedDoc` already accepts `buildUrl`; `useCodeFilePopout` is already prop-driven. +- *Seam B:* gate Viewer's validation — add `disableCodePathValidation`/inject result; default = today (on). +- **Guardrail:** Plannotator passes nothing → hover previews + code-link rendering identical; `/api/doc/exists` still fires. + +### 3. Image upload + attachments. Effort S. +- *Seam:* `AttachmentsButton` `fetch('/api/upload')` (L140) → `onUpload(file) => Promise<{path}>`. Preserve multipart field name `'file'` and `{path}` return shape. Keep `deriveImageName` export stable. +- **Guardrail:** capture-phase paste listener + `stopPropagation` unchanged (no double-attach with App.tsx's bubble-phase paste). + +### 4. File tree. Effort M (highest-risk SSE move). +- *As-is:* `FileBrowser.tsx` helpers + CountBadge, expansion state. +- *Seam:* lift `useFileBrowser`'s three fetch URLs + the **entire** SSE watcher effect (L289-342: EventSource, 120ms debounce, ready-dedup, cleanup) into a default `loadTree`/`loadVaultTree`/`watchTrees` object — moved **verbatim**, URL literals the only relocatable part. `useFileBrowser()` must stay callable with zero args. +- **Guardrail:** existing `useFileBrowser.test.tsx` stays green **without modification**. If it needs rewriting, the default changed → regression. + +### 5. Comments / annotations / drafts. Effort L (risky). +- *As-is:* AnnotationSidebar, EditorAnnotationCard, commentContent, anchors, annotationHelpers, useExternalAnnotationHighlights. +- *Seam A — draftTransport:* wrap the 5 `/api/draft` fetches; `save` rejects on failure (preserves keepalive-retry). Keep generation bookkeeping in the hook. **Document the 3-party protocol:** `getDraftGeneration()` escapes into App.tsx and rides `/api/approve`/`/api/deny` bodies; server tombstone-gates in `shared/draft.ts`. A host swapping transport must replicate generation-gated delete-on-submit or ghost drafts resurrect. +- *Seam B — external-annotations transport:* move the **entire** effect body (EventSource + snapshot-gated fallback + `?since`/304 polling at 500ms) verbatim into a default `subscribe()`. Keep reducer + optimistic mutators. `enabled` flag already host-suppliable. +- *Seam C — identity:* `isCurrentUser(author)` + `getIdentity()` author-stamping (3 creation sites) → optional `author?`/`isCurrentUser?` props defaulting at the App.tsx call site to existing `identity.ts` functions. +- **Guardrail:** approve/deny payloads still carry `getDraftGeneration()`; SSE→polling fallback identical; `(me)` badge renders; every annotation stamped. Note: web-highlighter restoration is **renderer-coupled** — Workspaces must reuse BlockRenderer+InlineMarkdown+inlineTransforms as a unit. + +### 6. Versions / plan diff. Effort M. +- *As-is:* `planDiffEngine.ts`, Badge, ModeSwitcher, RawDiffView. +- *Seam:* inject fetchers into `usePlanDiff` (default → `/api/plan/version(s)`); optional `onOpenVscodeDiff` in `PlanDiffViewer` (default → `/api/plan/vscode-diff`). Keep error handling in the hook (asymmetric: selectBaseVersion `alert()`s, fetchVersions silent). +- *CSS gap:* block/raw-diff + `.annotation-highlight` rules live in **`packages/editor/index.css` (L119-219)**, not the package. Move into `packages/ui/theme.css` (pure move) or document as a host CSS contract. +- **Guardrail:** App.tsx calls with no opts → identical traffic + alert behavior. + +### 7. Settings / config. Effort M. +- *As-is:* `config/settings.ts` (pure cookie+default+mappers). +- *Seam A:* inject only the final `fetch('/api/config')` write-back (L118) via `setServerSync(fn)`. **Keep singleton construction, eager cookie reads, 300ms debounce, deepMerge byte-identical** (a naive per-`set()` fetch changes batching/timing). +- *Seam B:* `Settings.tsx` `fetch('/api/obsidian/vaults')` (L748) → `onDetectObsidianVaults?` default = real fetch; keep `useEffect [obsidian.enabled]` + auto-select-first-vault verbatim (a `[]` no-op default kills auto-select). +- *Seam C:* storage adapter (shared with steps 9/10). Keep literal keys (`plannotator-theme`, `plannotator-toc-enabled`, `plannotator-plan-width`, …) so existing cookies still read. +- *PLANNOTATOR_ONLY:* `HooksTab.tsx`. +- **Guardrail:** Plannotator passes nothing → identical cookie keys, merged `/api/config` POST, vault auto-select. + +### 8. Sharing / export / notes. Effort S. +- *As-is:* `sharing.ts`, `useSharing`, obsidian/bear/octarine wrappers, `callback.ts`. +- *Seam:* `ExportModal` `fetch('/api/save-notes')` (L150) → `onSaveToNotes` → `{success, error}`. Keep `showNotesTab = isApiMode && !!markdown` byte-for-byte. +- *PLANNOTATOR_ONLY:* `OpenInAppButton`. + +### 9. Theme & tokens. Effort S (safe). +- *As-is:* `theme.css` + 51 `themes/*.css` + `print.css` as **one atomic commit**. `themeRegistry` + `ThemeProvider` together. +- *Seam:* inject `storage` into `ThemeProvider` + `uiPreferences`; optional `mode?` on `MarkdownEditor`. +- **Guardrail:** do not touch synchronous `applyThemeClasses` (L96-98) or the rAF `transitions-ready` toggle (L107-111) — reordering causes FOUC. Keep `@source` globs in lockstep if files move. + +### 10. Markdown editor. Effort S (lowest-coupled). +- `MarkdownEditor.tsx` is a 41-line theme-bridge over published `@plannotator/markdown-editor@0.1.0` + `@atomic-editor/editor@0.4.3`. `editorMode.ts` is glue (App.tsx-only). +- **Guardrail:** keep `GRID_CARD_CLASSES` under a `@source`-scanned path (else grid card loses border/shadow). + +### 11. PLANNOTATOR_ONLY — never imported by Workspaces (no work). +`useAutoClose` (Glimpse), `useEditorAnnotations` (`window.__PLANNOTATOR_VSCODE`), `useUpdateCheck` (hardcoded github releases), `useArchive`/`ArchiveBrowser`, `useAgents`/`useAgentJobs`, `GoalSetupSurface`, `planAgentInstructions`, `annotateAgentTerminal` (ws:// derivation), `useSharing` `/p/` routing. They stay in the app shell. + +### 12. Ask AI. Effort M (risky — mechanical-move-only). +- *Seam:* extract **exactly** the 5 fetch literals in `useAIChat` behind a default `transport`. **Do NOT touch** the SSE reader loop (L233-304), epoch/createRequest guards, or the supersede-abort fetch position (L153-158). Capabilities fetch + provider resolution + cookie `aiConfig` init stay in the **shell** (pulling them into the lib is the forbidden re-derivation). + +## 3. Top cross-cutting parity risks + +1. **Cookie-storage swapped globally.** `storage.ts` underlies ~24 modules. Inject per-host; never change the default; keep literal `plannotator-*` keys. Otherwise Plannotator loses theme/layout/identity persistence across random-port hook invocations. +2. **`getImageSrc` resolved per-component** instead of the one shared resolver → some images break with no type error. Single override over the existing default. +3. **Over-extracting glue coordinators (the reverted-approach trap).** App.tsx's panel toggles entangle wide-mode exit + agent-terminal teardown; sidebar auto-open/close is policy keyed on `tocEnabled`/`hasTocEntries`/`isPlanDiffActive`. Keep these as opaque PLANNOTATOR_ONLY glue. +4. **Identity drift.** If `author`/`isCurrentUser` default to `undefined`/`''` instead of live `getIdentity()`/`isCurrentUser`, annotations lose author + `(me)` ownership silently. +5. **CSS that ships in the app shell, not the package** (plan-diff rules, font `@import`s, Tailwind `@source`, `GRID_CARD_CLASSES`). Move files without updating `@source` in lockstep → utilities render unstyled. Silent visual breakage in Plannotator's own build. +6. **Re-render instability from non-stable injected callbacks** (HtmlBlock memo/deps) → collapses open `
`. Keep defaults module-level. +7. **SSE→polling fallback / draft-generation protocols** are timing-sensitive state machines — move as **copies**, not re-derivations. + +## 4. Glue guidance (App.tsx) — be conservative + +**Push DOWN (default = today):** +- The seam defaults (image resolver, doc fetch, upload, draft/external transports, configStore write-back, obsidian detect, save-notes) — defaults live at the App.tsx call site wrapping the current literal. +- `packages/editor/wideMode.ts` → `packages/ui/utils/wideMode.ts`: two pure functions, no relative/circular deps — byte-identical move + one import-path edit (App.tsx:109). Effort S. +- Ship a `ScrollViewportContext` provider/wrapper with the package. + +**LEAVE in the glue (PLANNOTATOR_ONLY — do NOT genericize):** +- Bootstrap from `/api/plan`, approve/deny hook flow, `getDraftGeneration` submit-body wiring. +- Right-panel/wide-mode/agent-terminal coordinators + auto-open/close sidebar policy (risk #3). +- `fileBrowserDirs` derivation + `showFilesTab` + load-orchestration; tab-visibility `show*Tab` + archive lazy-fetch. +- AI capabilities fetch + provider resolution + cookie `aiConfig` init. +- Panel-resize CSS-var writes (`--rpanel-w`/`--toc-w`/`--agent-terminal-w`). + +**Hard rule for the draft's "step 7" (push layout into components):** keep `show*Tab`, `width`, `onTabChange` (with its archive side effect) as **opaque props/callbacks**. `useSidebar`/`useResizablePanel`/`SidebarContainer` are already prop-driven and already reused by `review-editor/App.tsx` — Workspaces writes its **own** coordinator over the same primitives. Re-deriving the coordinator generically is the forbidden path. + +## 5. Packaging blockers (verified) + +| Blocker | Severity | Fix (no logic change) | +|---|---|---| +| `@plannotator/ai` + `@plannotator/shared` are `workspace:* / private / 0.0.1` | HARD | Publish both (real version) or inline the ~11 browser-safe value-imported subpaths | +| Phantom `dompurify` dep (imported, not declared) | HARD | Add to ui deps at exact `^3.3.3` | +| No `peerDependencies` block | MED | Move react/react-dom/tailwindcss(-animate)/radix/lucide to peers; keep devDeps | +| Fonts + Tailwind `@source` live in consumer `index.css` | MED | Ship a documented CSS entry; host on Tailwind v4 | +| Source-only `exports` (no `main`/`module`/`types`) | MED | Keep source model + document bundler settings; no dist build | +| `diff` version drift (`^8.0.3` vs `^8.0.4`) | LOW | Align to `^8.0.4` | +| Stale tsconfig alias → nonexistent `../shared/index.ts` | LOW | Fix when converting shared off `workspace:*` | +| Static asset imports + no `files` allowlist | LOW | Add `files` incl. assets/sprites/themes; exclude tests | + +**Non-blockers (verified — do not "fix"):** all `@plannotator/shared` value imports are Web-API-only; `@plannotator/ai` is `import type` only; `@plannotator/shared/storage` (node:fs) is `import type` only (erased under `isolatedModules`). `theme.css` is pure. From a79e8c76d718bd08ab03965946eee3100dbb9a55 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 22 Jun 2026 19:43:29 -0700 Subject: [PATCH 03/46] docs(adr): add document-ui extraction roadmap + parity checklist Phase 0-7 execution roadmap (safety net -> packaging -> foundation seams -> rendering -> navigation -> comments -> extras -> publish) and the reusable 'did it break?' parity checklist run after every step. Both enforce the law: move + decouple, never rewrite; Plannotator's experience cannot change. --- ...document-ui-extraction-roadmap-20260622.md | 93 +++++++++++++++++++ .../document-ui-parity-checklist-20260622.md | 92 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 adr/implementation/document-ui-extraction-roadmap-20260622.md create mode 100644 adr/implementation/document-ui-parity-checklist-20260622.md diff --git a/adr/implementation/document-ui-extraction-roadmap-20260622.md b/adr/implementation/document-ui-extraction-roadmap-20260622.md new file mode 100644 index 000000000..9de0af989 --- /dev/null +++ b/adr/implementation/document-ui-extraction-roadmap-20260622.md @@ -0,0 +1,93 @@ +# Document UI Extraction — Phased Roadmap + +Date: 2026-06-22 · Baseline commit: `30cfcebb` + +> The order of operations for making `packages/ui` reusable by the commercial Workspaces app. Governed by **ADR 004**; the per-step detail (exact seams, files, line numbers) lives in the verified plan `adr/specs/document-ui-extraction-plan-verified-20260622-184500.md`; the safety net is `adr/implementation/document-ui-parity-checklist-20260622.md`. +> +> **THE LAW:** move + decouple, never rewrite. Plannotator's experience cannot change. Every step = lift a URL/global to an optional prop whose **default is today's literal**, logic untouched. +> +> **The rhythm, every step:** make one small change → run the parity checklist → confirm identical → commit → next. Small steps, a human eyeballing the result. No multi-day unattended runs. + +## Phase ordering at a glance + +| Phase | What | Risk to Plannotator | Can Workspaces start? | +|---|---|---|---| +| 0 | Safety net (parity baseline + checklist) | none (no code change) | — | +| 1 | Packaging unblock | none (invisible) | — | +| 2 | Three foundation seams (storage, image, scroll-context) | low | — | +| 3 | Rendering stack (theme, markdown, viewer, html, editor) | low | **Yes — after this** | +| 4 | Navigation (sidebar + file tree) | medium (file tree SSE) | builds on it | +| 5 | Comments / annotations / drafts | medium (move verbatim) | builds on it | +| 6 | Optional extras (versions, settings, sharing, AI) | low–medium | as needed | +| 7 | Glue cleanup + publish | low | consumes the published package | + +--- + +## Phase 0 — Safety net (do once, before any code change) +**Goal:** be able to prove Plannotator didn't change after every step. **Risk:** none. +- [ ] Capture the automated baseline (Part A of the checklist): `typecheck`, `bun test` count, all three builds, bundle fingerprint → save to `scratchpad/parity-baseline.txt`. +- [ ] Keep a baseline build (and/or screenshots of each mode) to diff against. +- [ ] Confirm the checklist covers every mode you ship. + +## Phase 1 — Packaging unblock (invisible; gates external install) +**Goal:** make `@plannotator/ui` installable by an outside repo, with zero runtime change. **Risk:** none (no pixel changes). See verified plan "Step 0." +- [ ] Add the missing `dompurify` dependency at the root's exact `^3.3.3`. +- [ ] Resolve the two internal `workspace:* / private` packages (`@plannotator/ai`, `@plannotator/shared`) — publish them, or inline the ~11 verified browser-safe subpaths the UI value-imports. +- [ ] Add a `peerDependencies` block (react, react-dom, tailwindcss, tailwindcss-animate, radix set, lucide-react); keep as devDeps for in-repo typecheck. +- [ ] Fix the stale `tsconfig.json:21` alias (points at a nonexistent file); align the `diff` version (`^8.0.3` → `^8.0.4`). +- [ ] Add a `files` allowlist (assets, sprites, themes; exclude `*.test.*`). +- [ ] Keep source-only exports (no dist build); document required consumer bundler settings. +- **Guardrail:** builds byte-identical; in-repo React still resolves to one copy. + +## Phase 2 — Three foundation seams (everything else leans on these) +**Goal:** decouple the three cross-cutting pieces first so later phases are clean. **Risk:** low. +- [ ] **Storage adapter** — inject a `{getItem,setItem,removeItem}` into the cookie layer (`utils/storage.ts`); default = current cookie impl; **keep literal `plannotator-*` keys**. (Underlies ~24 modules — theme, layout prefs, identity.) +- [ ] **Image resolver** — the single `getImageSrc` shared by 5 consumers; module-level override, default = today's `/api/image` body verbatim, stable identity. +- [ ] **Scroll/layout context** — ship a `ScrollViewportContext` provider with the package (today its only provider lives in the glue at `App.tsx:3888`). +- **Guardrail:** identical cookie keys; all images emit identical URLs; sticky headers / TOC scroll / pinpoint unchanged. + +## Phase 3 — Rendering stack (the first visible win) +**Goal:** a document renders with the Plannotator look outside the app. **Risk:** low (Viewer is the one "risky" item; gate its validation call). +- [ ] Theme & tokens (`theme.css` + 51 `themes/*.css` + `print.css` as one atomic move). +- [ ] Markdown parsing + block rendering (BlockRenderer, blocks, inline transforms) — mostly transfer-as-is. +- [ ] Document Viewer — gate the unconditional `/api/doc/exists` validation (`Viewer.tsx:532`); default on. +- [ ] Doc-fetch seam for InlineMarkdown hover preview (`/api/doc`). +- [ ] Raw HTML viewer. +- [ ] Markdown editor (41-line shim over the published editor packages). +- **Milestone:** 👉 **Workspaces can start building in parallel here** — render docs while the rest proceeds. + +## Phase 4 — Navigation (sidebar + file tree) +**Goal:** the file-tree experience Workspaces is built around. **Risk:** medium (file-tree live updates). +- [ ] Sidebar shell + tabs (`SidebarContainer`/`SidebarTabs`/`useSidebar`) — already prop-driven, transfer-as-is. +- [ ] File tree: lift `useFileBrowser`'s fetch URLs **and the entire SSE watcher effect verbatim** into a default object; `useFileBrowser()` stays callable with zero args. +- **Guardrail:** existing `useFileBrowser.test.tsx` stays green **without modification** (if it needs rewriting, the default changed). + +## Phase 5 — Comments / annotations / drafts (the big one) +**Goal:** the core collaborative piece (teammates + agents commenting). **Risk:** medium — move the timing-sensitive parts verbatim. Last among core work because it touches the most. +- [ ] Draft transport seam (5 `/api/draft` fetches) — **document the 3-party draft-generation protocol** (escapes into approve/deny bodies; server tombstone-gates). +- [ ] External-annotations transport — move the **entire** SSE + polling-fallback effect verbatim into a default `subscribe()`. +- [ ] Identity seam — `author?`/`isCurrentUser?` props defaulting to the live `identity.ts` functions at the call site. +- **Guardrail:** approve/deny still carry the draft generation; live updates + fallback identical; `(me)` badge + author stamping intact. Note: highlight restoration is renderer-coupled — Workspaces must reuse BlockRenderer+InlineMarkdown as a unit. + +## Phase 6 — Optional extras (only when Workspaces needs them) +**Risk:** low–medium. Do not build preemptively. +- [ ] Versions / plan diff (inject fetchers; optional `onOpenVscodeDiff`; resolve the diff CSS that lives in the app shell). +- [ ] Settings / config (configStore write-back seam; obsidian-detect seam; storage adapter from Phase 2). +- [ ] Sharing / export / notes (`onSaveToNotes` seam; keep notes-tab gate verbatim). +- [ ] Ask AI (extract only the 5 fetch literals behind a `transport`; **do not touch** the SSE reader loop or epoch guards; capabilities/provider-resolution stay in the shell). + +## Phase 7 — Glue cleanup + publish +**Risk:** low. +- [ ] Move `packages/editor/wideMode.ts` → `packages/ui/utils/wideMode.ts` (pure move + one import-path edit). +- [ ] **Leave the coordinators in the glue** — right-panel/wide-mode/agent-terminal teardown, auto-open/close sidebar policy, tab-visibility + archive lazy-fetch, AI capabilities/provider init, panel-resize CSS-var writes. Workspaces writes its **own** thin coordinator over the same prop-driven primitives. (Re-deriving these generically is the forbidden path.) +- [ ] Publish `@plannotator/ui`; Workspaces installs and builds its own app/glue against it. + +--- + +## Hard guardrails (never violate) +1. **Default === today's literal.** Every seam ships with the current behavior as the default; Plannotator passes nothing and is unchanged. +2. **Move verbatim, never re-derive.** Especially the SSE transports, draft-generation protocol, configStore batching, and AI reader loop — copy them; do not "simplify." +3. **Never change the storage default** — inject per host; keep `plannotator-*` keys. +4. **Keep glue coordinators opaque** — they entangle side effects; genericizing them is how the last attempt broke. +5. **Run the parity checklist after every step.** Green automated checks are not enough — eyeball the app. +6. **Never delete a working path until parity is confirmed by a human**, mode by mode. diff --git a/adr/implementation/document-ui-parity-checklist-20260622.md b/adr/implementation/document-ui-parity-checklist-20260622.md new file mode 100644 index 000000000..013ffe3d9 --- /dev/null +++ b/adr/implementation/document-ui-parity-checklist-20260622.md @@ -0,0 +1,92 @@ +# Document UI — Parity Checklist ("did it break?") + +Date: 2026-06-22 · Baseline commit: `30cfcebb` + +> The safety net for the document-ui extraction (see ADR 004 + the verified plan `adr/specs/document-ui-extraction-plan-verified-20260622-184500.md`). **THE LAW: Plannotator's experience cannot change.** Run this checklist after *every* extraction step. If anything below differs from baseline, the step changed behavior — stop and fix before continuing. Passing automated checks are necessary but NOT sufficient; the manual click-through is the part that actually catches regressions (last time, tests were green while the app was broken). + +## How to use this +1. Before starting work, capture the **baseline** (Part A) once. Save the outputs. +2. After each extraction step: re-run Part A and compare, then walk Part B for the surfaces the step could touch (when in doubt, walk all of it). +3. Green automated + identical manual = the step preserved parity. Proceed. + +--- + +## Part A — Automated baseline (fast, run every step) + +Run from repo root. Record PASS/FAIL + the bundle fingerprint. + +```bash +bun run typecheck # must pass +bun test # record pass count; must not drop vs baseline +bun run build:hook # must succeed +bun run build:review # must succeed +bun run build:opencode # must succeed +# fingerprint the shipped artifacts — compare hash before/after a step: +find apps/hook/dist apps/opencode-plugin -name '*.html' -type f -exec shasum {} \; | sort +``` + +- [ ] `typecheck` passes +- [ ] `bun test` pass count ≥ baseline (note the number) +- [ ] all three builds succeed +- [ ] **bundle fingerprint recorded** (for a pure code-move step with defaults intact, the hashes should be byte-identical; if they change, understand exactly why) + +> Baseline capture (do once, fill in): typecheck ____ · test count ____ · build ____ · hashes saved to `scratchpad/parity-baseline.txt` + +--- + +## Part B — Manual click-through (the real test) + +Launch the relevant surface and confirm each item looks and behaves **identically to baseline**. Tip: keep a baseline build/screenshots of each screen to diff against. + +### Plan Review (`ExitPlanMode` flow, or `bun run dev:hook`) +- [ ] Plan renders: headings, code blocks, tables, callouts, alerts, task lists, images, links +- [ ] Theme correct on first paint (no flash/FOUC), theme switch works +- [ ] Select text → annotation toolbar appears → add comment / deletion / global comment +- [ ] Comment shows the right author, `(me)` badge on your own +- [ ] Sidebar: Table of Contents, Version Browser, Archive tabs all open and work +- [ ] Plan diff: `+N/-M` badge → toggle diff → rendered + raw modes → annotate a diff block +- [ ] Approve, and Deny-with-feedback both deliver correctly +- [ ] Export / Share (copy link + short URL) / Import round-trips +- [ ] Settings opens; AI providers, theme, identity all present +- [ ] Keyboard shortcuts: `Mod+Enter` submit, `Mod+P` print, sidebar toggles, wide mode + +### Annotate file (`plannotator annotate `) +- [ ] File renders with full markdown/PFM support +- [ ] Edit the doc → Save → source file on disk updates; saved-change banner correct +- [ ] Draft autosave/restore survives a reload +- [ ] Code-file links open the code popout; code annotations create + submit +- [ ] Send annotations delivers feedback + +### Annotate folder (`plannotator annotate /`) +- [ ] File tree renders with badges + writeback status +- [ ] Expand/collapse folders; open files; **live updates** when a file changes on disk +- [ ] Per-file annotations stay associated; multi-file feedback assembles correctly + +### Annotate last message / raw HTML +- [ ] Annotate-last: recent message(s) show; switching messages restores their annotations; feedback carries the message id/scope +- [ ] Raw HTML: renders, annotate, share produces portable HTML with assets + +### Archive / Goal setup +- [ ] Archive view lists saved decisions with approved/denied badges; read-only render +- [ ] Goal setup surface submits and closes + +### External / editor annotations (if applicable) +- [ ] External annotations posted to the API appear live (and update/delete reflect) +- [ ] VS Code editor annotations appear and are included in feedback (VS Code mode) + +### Cross-cutting visual +- [ ] All themes render (spot-check a few); print mode CSS intact +- [ ] Wide/focus mode hides/restores panels correctly +- [ ] Panel resize + sidebar collapse behave the same +- [ ] Images load everywhere (markdown body, inline, HTML blocks, comment attachments, re-edit previews) + +--- + +## What "fail" looks like (high-risk regressions to watch — from the audit) +- Theme/layout/identity **forgets settings** → the cookie-storage default got swapped instead of injected. +- **Some images load, some don't** → a `getImageSrc` call site was missed. +- **`(me)` badge or comment author missing** → identity default became empty instead of the live function. +- **Open `
` collapse on re-render** → a non-stable callback was threaded into a memoized block. +- **Sticky headers / scroll-to-anchor / TOC scroll broken** → ScrollViewport provider not mounted. +- **Plan-diff blocks render unstyled** → CSS that lives in the app shell wasn't accounted for. +- **Live updates stop / ghost drafts reappear** → an SSE or draft-generation protocol was re-derived instead of moved verbatim. From fec125ed92bbfeba1c58f9237cfb2b241051658c Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 22 Jun 2026 19:52:55 -0700 Subject: [PATCH 04/46] =?UTF-8?q?build(ui):=20packaging=20unblock=20for=20?= =?UTF-8?q?external=20install=20(Phase=201)=20=E2=80=94=20no=20runtime=20c?= =?UTF-8?q?hange?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0: captured parity baseline (typecheck/test/build + shipped-bundle hashes). Phase 1 packaging fixes to packages/ui, metadata only: - add phantom dompurify ^3.3.3 dep (imported in sanitizeHtml/aiChatFormat, was undeclared) - align diff ^8.0.3 -> ^8.0.4 with root - add peerDependencies (react, react-dom, tailwindcss, tailwindcss-animate); keep as devDeps - add files allowlist (excludes tests); remove dead tsconfig @plannotator/shared alias Verified byte-identical: typecheck pass, 1620 tests pass/0 fail, all 3 builds OK, shipped plan+review bundle hashes unchanged from baseline. Remaining Phase 1 blocker (@plannotator/ai + @plannotator/shared workspace:* deps) deferred pending a publish-vs-inline decision; logged in worklog. --- .../document-ui-phase-0-1-worklog-20260622.md | 61 +++++++++++++++++++ bun.lock | 17 ++++-- packages/ui/package.json | 37 +++++++++-- packages/ui/tsconfig.json | 1 - 4 files changed, 106 insertions(+), 10 deletions(-) create mode 100644 adr/implementation/document-ui-phase-0-1-worklog-20260622.md diff --git a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md new file mode 100644 index 000000000..f586d159c --- /dev/null +++ b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md @@ -0,0 +1,61 @@ +# Document UI Extraction — Phase 0 & 1 Work Log + +Date: 2026-06-22 · Baseline commit: `30cfcebb` + +> Execution record for Phase 0 (safety net) and Phase 1 (packaging unblock) of the roadmap (`adr/implementation/document-ui-extraction-roadmap-20260622.md`). Law: move + decouple, never rewrite; **Plannotator's experience cannot change** — proven below by byte-identical shipped bundles. + +## Phase 0 — Safety net (DONE) + +Captured the known-good baseline at commit `30cfcebb` (saved to `scratchpad/parity-baseline.txt`): + +| Check | Baseline result | +|---|---| +| `bun run typecheck` | PASS (exit 0) | +| `bun test` | **1620 pass / 0 fail**, 1650 ran across 123 files | +| `bun run build:review` / `build:hook` / `build:opencode` | all OK | +| Shipped plan UI hash (`apps/hook/dist/index.html`, `redline.html`, `opencode-plugin/plannotator.html`) | `4ca0cbe9dd85c3674e6122f1e830704076efa129` | +| Shipped review UI hash (`apps/hook/dist/review.html`, `opencode-plugin/review-editor.html`) | `f404d00d9a47785ca925776d48b7a67b2b30b9dd` | + +The reusable click-through checklist lives at `adr/implementation/document-ui-parity-checklist-20260622.md`. The bundle-hash compare is the automated half; the manual click-through is run before any *behavioral* (non-packaging) step. + +## Phase 1 — Packaging unblock (DONE except one decision) + +All changes are package metadata only — no source/runtime change. Files touched: `packages/ui/package.json`, `packages/ui/tsconfig.json`, `bun.lock`. + +### Findings (verified against source before editing) +- **Phantom `dompurify` dependency (real latent bug).** Imported in `packages/ui/utils/sanitizeHtml.ts:1` and `utils/aiChatFormat.ts:3` but absent from `packages/ui/package.json`. Worked in-repo only via root hoisting; would break a standalone install. Root pins `dompurify ^3.3.3`. +- **`diff` version drift.** ui had `^8.0.3`; root has `^8.0.4`. +- **Stale tsconfig alias (dead).** `tsconfig.json:21` mapped bare `@plannotator/shared` → `../shared/index.ts`, which does not exist. Verified **no file in ui imports the bare specifier** — only `@plannotator/shared/*` subpaths (handled by the other, correct alias on the next line). The dead line was inert (typecheck passed with it) but removed for correctness. +- **No `peerDependencies`.** react/react-dom/tailwindcss were plain `dependencies`, risking duplicate-React when consumed by an external app. +- **No `files` allowlist.** A publish would have shipped test files and could have missed assets/themes. + +### Changes made +1. **Added `dompurify ^3.3.3`** to `dependencies` (matches root exactly — a version mismatch could change sanitization output). +2. **Aligned `diff` `^8.0.3` → `^8.0.4`** (matches root). +3. **Added a `peerDependencies` block** — `react`, `react-dom`, `tailwindcss`, `tailwindcss-animate` — and removed them from `dependencies`. Also added the same four to `devDependencies` so in-repo typecheck/build still resolve them. (Scope decision: only the singleton/build-time packages were made peers. Radix, lucide, cva, clsx, tailwind-merge, etc. stay as regular `dependencies` — they are owned by the library and have no duplicate-instance hazard. This is the conventional, lower-risk choice and diverges deliberately from the audit's broader "radix→peer" suggestion.) +4. **Added a `files` allowlist** (source dirs + assets/themes/sprites + `theme.css`/`print.css`/`types.ts`/`globals.d.ts`), excluding `**/*.test.*` and `test-setup`. Preparatory — only affects a future publish. +5. **Removed the dead `tsconfig.json` alias line.** +6. **Kept the source-only `exports` model — no dist build added** (a build could change what Plannotator ships). Consumer bundler requirements to document for Workspaces: `isolatedModules`, the automatic JSX runtime, `allowImportingTsExtensions`, and Tailwind v4 (`@theme inline` is v4-only). + +### Verification (post-change, vs Phase 0 baseline) +| Check | Result | Matches baseline? | +|---|---|---| +| `bun install` | clean, "no changes" to install tree (confirms dep moves didn't perturb resolution) | — | +| `bun run typecheck` | PASS (exit 0) | ✅ | +| `bun test` | **1620 pass / 0 fail** | ✅ identical | +| 3 builds | all OK | ✅ | +| plan UI bundle hash | `4ca0cbe9dd85c3674e6122f1e830704076efa129` | ✅ **byte-identical** | +| review UI bundle hash | `f404d00d9a47785ca925776d48b7a67b2b30b9dd` | ✅ **byte-identical** | + +**Conclusion: Plannotator's shipped app is byte-for-byte unchanged.** The packaging box is now cleaner and closer to installable, with zero impact on the open-source experience. + +## Remaining Phase 1 item — ONE decision required (not done) + +**`@plannotator/ai` and `@plannotator/shared` are `workspace:* / private / 0.0.1`.** This is the one genuine blocker to an external `@plannotator/ui` install (an outside repo cannot resolve `workspace:*` private packages). It was **deliberately not actioned** because it is a strategic fork, and one path (publishing) is outward-facing and needs explicit authorization. Two options: + +- **Option A — Publish `@plannotator/ai` + `@plannotator/shared`** (drop `private`, real versions, push to the registry). Cleanest dependency graph; lets Workspaces also reuse shared logic directly. Cost: two more published packages to maintain/version; needs registry auth. **Outward-facing — requires explicit go-ahead before any publish.** +- **Option B — Inline the browser-safe subpaths ui actually value-imports** into `@plannotator/ui` (verified Web-API-only: compress, crypto, agents, code-file, feedback-templates, project, favicon, agent-jobs, browser-paths, extract-code-paths, goal-setup). Keeps `@plannotator/ui` self-contained, no extra published packages. Cost: code duplication vs `@plannotator/shared`, and it is a real code change (must re-run the full parity verification). + +When this is decided, also revisit the `tsconfig.json` `@plannotator/shared/*` alias (currently correct for in-repo; changes if shared is published/inlined). + +> Note: the `@plannotator/ai` import is `import type` only (erased at compile). Most `@plannotator/shared` imports are also type-only or Web-API-only; verified no `node:*` value imports reach a bundle. So this blocker is about *package resolution for external install*, not about node code leaking into the browser. diff --git a/bun.lock b/bun.lock index f49daa850..ffda4a059 100644 --- a/bun.lock +++ b/bun.lock @@ -275,18 +275,15 @@ "@viz-js/viz": "^3.25.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "diff": "^8.0.3", + "diff": "^8.0.4", + "dompurify": "^3.3.3", "highlight.js": "^11.11.1", "lucide-react": "^1.14.0", "marked": "^17.0.6", "mermaid": "^11.12.2", "motion": "^12.38.0", "perfect-freehand": "^1.2.2", - "react": "^19.2.3", - "react-dom": "^19.2.3", "tailwind-merge": "^3.6.0", - "tailwindcss": "^4.1.18", - "tailwindcss-animate": "^1.0.7", "unique-username-generator": "^1.5.1", }, "devDependencies": { @@ -294,8 +291,18 @@ "@types/bun": "^1.2.0", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "tailwindcss": "^4.1.18", + "tailwindcss-animate": "^1.0.7", "typescript": "~5.8.2", }, + "peerDependencies": { + "react": "^19.2.3", + "react-dom": "^19.2.3", + "tailwindcss": "^4.1.18", + "tailwindcss-animate": "^1.0.7", + }, }, }, "packages": { diff --git a/packages/ui/package.json b/packages/ui/package.json index dfc4a9178..4d2388621 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -19,6 +19,28 @@ "./types": "./types.ts", "./theme": "./theme.css" }, + "files": [ + "components", + "config", + "hooks", + "icons", + "lib", + "shortcuts", + "utils", + "assets", + "themes", + "sprite_package_additional", + "sprite_package_new", + "sprite_package_pulluphang", + "globals.d.ts", + "types.ts", + "theme.css", + "print.css", + "plannotator.webp", + "!**/*.test.ts", + "!**/*.test.tsx", + "!test-setup" + ], "dependencies": { "@atomic-editor/editor": "^0.4.3", "@codemirror/autocomplete": "^6.20.3", @@ -52,25 +74,32 @@ "@viz-js/viz": "^3.25.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "diff": "^8.0.3", + "diff": "^8.0.4", + "dompurify": "^3.3.3", "highlight.js": "^11.11.1", "lucide-react": "^1.14.0", "marked": "^17.0.6", "mermaid": "^11.12.2", "motion": "^12.38.0", "perfect-freehand": "^1.2.2", + "tailwind-merge": "^3.6.0", + "unique-username-generator": "^1.5.1" + }, + "peerDependencies": { "react": "^19.2.3", "react-dom": "^19.2.3", - "tailwind-merge": "^3.6.0", "tailwindcss": "^4.1.18", - "tailwindcss-animate": "^1.0.7", - "unique-username-generator": "^1.5.1" + "tailwindcss-animate": "^1.0.7" }, "devDependencies": { "@happy-dom/global-registrator": "^20.10.1", "@types/bun": "^1.2.0", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "tailwindcss": "^4.1.18", + "tailwindcss-animate": "^1.0.7", "typescript": "~5.8.2" }, "scripts": { diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 683f100a6..2076dec37 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -18,7 +18,6 @@ "esModuleInterop": true, "types": ["bun"], "paths": { - "@plannotator/shared": ["../shared/index.ts"], "@plannotator/shared/*": ["../shared/*"] } }, From a0e205130206c5e496044ede9f1c2538cf2a4857 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 22 Jun 2026 20:09:16 -0700 Subject: [PATCH 05/46] feat(ui): make image URL resolution host-overridable (Phase 2, seam 1) getImageSrc now delegates to a module-level resolver defaulting to the verbatim Plannotator /api/image logic; add setImageSrcResolver/resetImageSrcResolver so a host (Workspaces) can resolve images via its own backend. All 5 consumers and the signature unchanged. Verified: default URLs byte-identical, typecheck pass, 1620 tests pass/0 fail, builds OK. No Plannotator behavior change. --- .../document-ui-phase-0-1-worklog-20260622.md | 10 +++++++ packages/ui/components/ImageThumbnail.tsx | 29 +++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md index f586d159c..0a2096f4a 100644 --- a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md +++ b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md @@ -59,3 +59,13 @@ All changes are package metadata only — no source/runtime change. Files touche When this is decided, also revisit the `tsconfig.json` `@plannotator/shared/*` alias (currently correct for in-repo; changes if shared is published/inlined). > Note: the `@plannotator/ai` import is `import type` only (erased at compile). Most `@plannotator/shared` imports are also type-only or Web-API-only; verified no `node:*` value imports reach a bundle. So this blocker is about *package resolution for external install*, not about node code leaking into the browser. + +## Phase 2 — Foundation seams (in progress) + +Three cross-cutting seams that later phases depend on. Each: lift the backend wire to an optional override, default = today's behavior. For these *code* changes the bundle hash legitimately changes; parity is proven by behavior tests (+ eyeball where there's something visual to see). + +### Seam 1 — Image resolver (DONE) +- **File:** `packages/ui/components/ImageThumbnail.tsx` (the single `getImageSrc`, shared by 5 consumers: ImageThumbnail, InlineMarkdown, HtmlBlock, AttachmentsButton, Viewer). +- **Change:** extracted the body into `defaultImageSrcResolver` and a module-level `imageSrcResolver` (stable identity); added `setImageSrcResolver(fn)` for a host to override once at startup, and `resetImageSrcResolver()` for tests. `getImageSrc(path, base?)` signature unchanged; it now delegates to the active resolver, default = the verbatim old `/api/image` logic. +- **Why no Viewer-level prop:** a prop can't reach InlineMarkdown/HtmlBlock; the module-level override is the only thing all 5 consumers share. +- **Verified:** default output byte-identical across remote-passthrough, base-append, and absolute-path cases (URL probe); override + reset work; typecheck pass; 1620 tests pass / 0 fail; all 3 builds OK. Dev-mode eyeball N/A — the mock serves no images and this change only affects the URL string (proven identical), so there is nothing visual to regress. 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'; From fa81cb80a61c1ed808516a18068e520a1998a32f Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 22 Jun 2026 20:17:23 -0700 Subject: [PATCH 06/46] feat(ui): make settings storage backend host-overridable (Phase 2, seam 2) storage.ts cookie impl is now the default 'cookieBackend'; add setStorageBackend/ resetStorageBackend so a host (Workspaces) can persist settings via its own storage. getItem/setItem/removeItem delegate to the active backend; the ~24 consumers and literal plannotator-* keys are unchanged. Verified: swap works, typecheck pass, 1620 tests pass/0 fail, builds OK, theme persists across reload. --- .../document-ui-phase-0-1-worklog-20260622.md | 6 ++ packages/ui/utils/storage.ts | 78 ++++++++++++++----- 2 files changed, 64 insertions(+), 20 deletions(-) diff --git a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md index 0a2096f4a..32ee9a7a6 100644 --- a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md +++ b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md @@ -69,3 +69,9 @@ Three cross-cutting seams that later phases depend on. Each: lift the backend wi - **Change:** extracted the body into `defaultImageSrcResolver` and a module-level `imageSrcResolver` (stable identity); added `setImageSrcResolver(fn)` for a host to override once at startup, and `resetImageSrcResolver()` for tests. `getImageSrc(path, base?)` signature unchanged; it now delegates to the active resolver, default = the verbatim old `/api/image` logic. - **Why no Viewer-level prop:** a prop can't reach InlineMarkdown/HtmlBlock; the module-level override is the only thing all 5 consumers share. - **Verified:** default output byte-identical across remote-passthrough, base-append, and absolute-path cases (URL probe); override + reset work; typecheck pass; 1620 tests pass / 0 fail; all 3 builds OK. Dev-mode eyeball N/A — the mock serves no images and this change only affects the URL string (proven identical), so there is nothing visual to regress. + +### Seam 2 — Storage backend (DONE) +- **File:** `packages/ui/utils/storage.ts` (the cookie `getItem`/`setItem`/`removeItem`, sole persistence for ~24 modules: theme, layout/TOC/width prefs, identity, auto-close, etc.). +- **Change:** moved the cookie implementation into a default `cookieBackend: StorageBackend`; added a module-level `backend` (default = cookies), `setStorageBackend(b)` for a host to swap, and `resetStorageBackend()` for tests. `getItem`/`setItem`/`removeItem` now delegate to the active backend; signatures and the `storage` object unchanged. Literal `plannotator-*` keys preserved. +- **Consumers untouched:** the ~24 modules keep calling `getItem`/`setItem` exactly as before. +- **Verified:** seam routes to an injected backend and `resetStorageBackend` restores cookies (in-memory probe); typecheck pass; 1620 tests pass / 0 fail (suite exercises storage through a real DOM); all 3 builds OK; manual eyeball — theme/settings persist across reload (cookie round-trip intact). 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); } /** From ce46899e43e3963ee832abd6eaecbe2daa81f0a0 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 22 Jun 2026 20:59:09 -0700 Subject: [PATCH 07/46] feat(ui): make MarkdownEditor theme mode host-supplyable (Phase 3) Add optional mode? prop; mode now mode ?? resolvedMode. Plannotator passes no mode (App.tsx:4261) so it keeps using ThemeProvider's resolvedMode unchanged. A host without ThemeProvider can supply mode directly. Verified: typecheck pass, 1620 tests/0 fail, builds OK, App.tsx untouched. --- packages/ui/components/MarkdownEditor.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 ( ); From 3f255d20c19d5cc672168980691f60726eb98c9b Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 22 Jun 2026 20:59:09 -0700 Subject: [PATCH 08/46] feat(ui): allow hosts to opt out of code-path validation (Phase 3) Viewer gains optional disableCodePathValidation? threaded to a new disabled? arg on useValidatedCodePaths; when set, the /api/doc/exists probe is skipped. Default undefined for Plannotator => validation stays on, /api/doc/exists fires exactly as today. Verified: typecheck pass, 1620 tests/0 fail, builds OK, App.tsx untouched. Also logs Phase 3 workflow outcome + remaining scroll/docfetch pieces. --- .../document-ui-phase-0-1-worklog-20260622.md | 17 +++++++++++++++++ packages/ui/components/Viewer.tsx | 6 +++++- packages/ui/hooks/useValidatedCodePaths.ts | 11 ++++++++++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md index 32ee9a7a6..cb264e392 100644 --- a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md +++ b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md @@ -75,3 +75,20 @@ Three cross-cutting seams that later phases depend on. Each: lift the backend wi - **Change:** moved the cookie implementation into a default `cookieBackend: StorageBackend`; added a module-level `backend` (default = cookies), `setStorageBackend(b)` for a host to swap, and `resetStorageBackend()` for tests. `getItem`/`setItem`/`removeItem` now delegate to the active backend; signatures and the `storage` object unchanged. Literal `plannotator-*` keys preserved. - **Consumers untouched:** the ~24 modules keep calling `getItem`/`setItem` exactly as before. - **Verified:** seam routes to an injected backend and `resetStorageBackend` restores cookies (in-memory probe); typecheck pass; 1620 tests pass / 0 fail (suite exercises storage through a real DOM); all 3 builds OK; manual eyeball — theme/settings persist across reload (cookie round-trip intact). + +## Phase 3 — Rendering stack (in progress) + +Teed up + adversarially reviewed by the `phase3-rendering-stack` workflow (36→22 agents; tee-up → execute-in-isolated-worktree → parity review → synthesis). Workflow verdicts: **3 noop** (theme, markdown, html-viewer — already decoupled by Phase 2 / already prop-driven, nothing to land), **3 safe** (editor, viewer, scroll), **1 "blocked"** (docfetch — false alarm: the execute worktrees were auto-removed, so the reviewer saw the clean real tree; the spec is sound, just needs real application). Note: the workflow's in-worktree `typecheck`/`tests` were unreliable (missing deps in throwaway worktrees) — landings are verified authoritatively on the real tree against the Phase 0 baseline. All landings done by hand on the real tree with the parity suite. + +### Seam — Markdown editor theme mode (DONE) +- **File:** `packages/ui/components/MarkdownEditor.tsx`. Added optional `mode?` prop; `mode={resolvedMode}` → `mode={mode ?? resolvedMode}`; destructured `mode` out of `...props`. +- **Parity:** Plannotator's only `` call (App.tsx:4261) passes no `mode` → falls to `resolvedMode` → identical. Verified: typecheck pass, 1620 tests / 0 fail, builds OK, App.tsx untouched, no `mode=` caller. + +### Seam — Viewer code-path validation gate (DONE) +- **Files:** `packages/ui/components/Viewer.tsx` + `packages/ui/hooks/useValidatedCodePaths.ts`. Added optional `disableCodePathValidation?` prop threaded to a new `disabled?` arg on the hook; when set, the `/api/doc/exists` probe is skipped (`ready: true`, empty map). Default undefined for Plannotator → validation stays on. Added `disabled` to the effect deps (always undefined for Plannotator → no behavior change). +- **Parity:** no `disableCodePathValidation` caller in editor/apps → Viewer still fires `/api/doc/exists` exactly as today. Verified: typecheck pass, 1620 tests / 0 fail, builds OK, App.tsx untouched. + +### Remaining Phase 3 +- **scroll** (safe) — extract a render-transparent `ScrollViewportProvider` into `packages/ui/hooks/useScrollViewport.ts`; rewire App.tsx's `ScrollViewportContext.Provider` (3888/4427) to use it; keep App.tsx's own `useActiveSection` consumption and the sidebar-TOC-reads-MAIN-viewport invariant. Touches App.tsx → land isolated + manual eyeball (scroll a plan, confirm TOC active-section tracks). +- **docfetch** (apply for real) — `InlineMarkdown.tsx` hover-preview `fetch('/api/doc')` → injectable `docPreviewFetcher` defaulting to today's literal (matching the getImageSrc/setStorageBackend pattern); keep the `useCallback` deps unchanged. Manual eyeball (hover a code-file link, preview popover appears). +- **noops:** theme, markdown, html-viewer — nothing to land (verified already reusable). diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index 87339bb6b..099951145 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -75,6 +75,9 @@ interface ViewerProps { * so out-of-tree relative references (e.g. `../foo.ts` in a linked doc) * resolve against the doc's own directory rather than only cwd. */ codePathBaseDir?: string; + /** Opt out of `/api/doc/exists` code-path validation (host without that + * endpoint). Default undefined for Plannotator => validation stays on. */ + disableCodePathValidation?: boolean; linkedDocInfo?: LinkedDocBadgeInfo | null; // Plan diff props planDiffStats?: { additions: number; deletions: number; modifications: number } | null; @@ -178,6 +181,7 @@ export const Viewer = forwardRef(({ linkedDocInfo, imageBaseDir, codePathBaseDir, + disableCodePathValidation, copyLabel, actionsLabelMode = 'full', archiveInfo, @@ -529,7 +533,7 @@ export const Viewer = forwardRef(({ setViewerCommentPopover(null); }, []); - const codePathValidation = useValidatedCodePaths(markdown, codePathBaseDir); + const codePathValidation = useValidatedCodePaths(markdown, codePathBaseDir, disableCodePathValidation); return ( diff --git a/packages/ui/hooks/useValidatedCodePaths.ts b/packages/ui/hooks/useValidatedCodePaths.ts index 5b6c31be6..198deace3 100644 --- a/packages/ui/hooks/useValidatedCodePaths.ts +++ b/packages/ui/hooks/useValidatedCodePaths.ts @@ -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, skip validation: code links + // render optimistically (no server probe), same as an empty candidate set. + if (disabled) { + setReady(true); + 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 From a38ba0773a6fee9e8b63bab10a4315698577b802 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 22 Jun 2026 21:20:42 -0700 Subject: [PATCH 09/46] feat(ui): make code-file hover preview fetch host-overridable (Phase 3) Add DocPreviewFetcher seam (default = verbatim /api/doc fetch) + setDocPreviewFetcher/resetDocPreviewFetcher; route handleMouseEnter through it, useCallback deps unchanged. No caller overrides it => Plannotator fetches /api/doc identically. typecheck pass, 1620 tests/0 fail, builds OK. --- packages/ui/components/InlineMarkdown.tsx | 38 ++++++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx index 77617d930..f25683789 100644 --- a/packages/ui/components/InlineMarkdown.tsx +++ b/packages/ui/components/InlineMarkdown.tsx @@ -8,6 +8,37 @@ import { useCodePathValidation, type CodePathValidationContextValue } from "./Co import type { ValidationEntry } from "../hooks/useValidatedCodePaths"; import { CodeFilePicker } from "./CodeFilePicker"; +export interface DocPreviewResult { + contents?: string; + filepath?: string; +} +export type DocPreviewFetcher = (path: string, base?: string) => Promise; + +/** + * Default code-file hover-preview fetcher — Plannotator's `/api/doc` behavior, verbatim. + */ +const defaultDocPreviewFetcher: DocPreviewFetcher = async (path, base) => { + const params = new URLSearchParams({ path }); + if (base) params.set('base', base); + const res = await fetch(`/api/doc?${params}`); + return await res.json(); +}; + +// Module-level fetcher, stable identity. Defaults to Plannotator's `/api/doc`. +// A host (e.g. Workspaces) calls setDocPreviewFetcher once at startup to load +// hover previews from its own backend. +let docPreviewFetcher: DocPreviewFetcher = defaultDocPreviewFetcher; + +/** Override how code-file hover previews are fetched. Call once at app startup. */ +export const setDocPreviewFetcher = (fetcher: DocPreviewFetcher): void => { + docPreviewFetcher = fetcher; +}; + +/** Reset to the default (Plannotator `/api/doc`) fetcher. Mainly for tests. */ +export const resetDocPreviewFetcher = (): void => { + docPreviewFetcher = defaultDocPreviewFetcher; +}; + /** * Decide how a candidate code-file path should render based on validation state: * - 'link' → clickable, opens directly via onOpenCodeFile(resolvedOrInput) @@ -149,11 +180,8 @@ const CodeFileLink: React.FC<{ if (hoverPreviewRef.current) return; showTimerRef.current = setTimeout(async () => { try { - const params = new URLSearchParams({ path: candidate }); - if (baseDir) params.set('base', baseDir); - const res = await fetch(`/api/doc?${params}`); - const data = await res.json(); - if (data.contents) setHoverPreview({ contents: data.contents, filepath: data.filepath ?? candidate }); + const data = await docPreviewFetcher(candidate, baseDir); + if (data?.contents) setHoverPreview({ contents: data.contents, filepath: data.filepath ?? candidate }); } catch {} }, 150); }, [candidate, hasLineRef, gate.render, cancelHide, baseDir]); From da9ee4e39a4cd17e674d427a29ffc72dab844b59 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 22 Jun 2026 21:20:42 -0700 Subject: [PATCH 10/46] feat(ui): ship ScrollViewportProvider with the library (Phase 3 scroll) Add render-transparent ScrollViewportProvider (createElement, keeps .ts) so the scroll-viewport context travels with @plannotator/ui instead of living only in App.tsx. Rewire App.tsx provider tags (3-line delta); identical tree/value/ position, sidebar TOC still reads the MAIN viewport. Fix stale OverlayScrollbars doc-comment. typecheck pass, 1620 tests/0 fail, builds OK, eyeball: TOC tracks. --- packages/editor/App.tsx | 6 ++--- packages/ui/hooks/useScrollViewport.ts | 34 ++++++++++++++++++++------ 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 119715010..67c72cfc9 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -53,7 +53,7 @@ import { usePrintMode } from '@plannotator/ui/hooks/usePrintMode'; import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel'; import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle'; import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea'; -import { ScrollViewportContext } from '@plannotator/ui/hooks/useScrollViewport'; +import { ScrollViewportProvider } from '@plannotator/ui/hooks/useScrollViewport'; import { useOverlayViewport } from '@plannotator/ui/hooks/useOverlayViewport'; import { useIsMobile } from '@plannotator/ui/hooks/useIsMobile'; import { @@ -3892,7 +3892,7 @@ const App: React.FC = () => { )} {/* Main Content */} - +
{/* Tater sprites — inside content wrapper so z-0 stacking context applies */} {taterMode && } @@ -4431,7 +4431,7 @@ const App: React.FC = () => { )}
-
+ {/* Code File Popout */} {codeFilePopout.popoutProps && ( diff --git a/packages/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); +} From 3d4297215cc41bf486ec220de7d0772ac9bca536 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 22 Jun 2026 21:20:42 -0700 Subject: [PATCH 11/46] fix(ui): disabled code-path validation should keep links clickable (self-review) The Phase-3 disabled branch set ready=true with an empty map, which makes gateCodePath demote every code link to plain text. Leave ready=false so the no-validation fallback renders links optimistically. No Plannotator impact (never disables). Logs Phase 3 completion + reusability note. typecheck pass, 1620 tests/0 fail, builds OK. --- .../document-ui-phase-0-1-worklog-20260622.md | 24 +++++++++++++++---- packages/ui/hooks/useValidatedCodePaths.ts | 6 ++--- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md index cb264e392..0e4fb1ad2 100644 --- a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md +++ b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md @@ -88,7 +88,23 @@ Teed up + adversarially reviewed by the `phase3-rendering-stack` workflow (36→ - **Files:** `packages/ui/components/Viewer.tsx` + `packages/ui/hooks/useValidatedCodePaths.ts`. Added optional `disableCodePathValidation?` prop threaded to a new `disabled?` arg on the hook; when set, the `/api/doc/exists` probe is skipped (`ready: true`, empty map). Default undefined for Plannotator → validation stays on. Added `disabled` to the effect deps (always undefined for Plannotator → no behavior change). - **Parity:** no `disableCodePathValidation` caller in editor/apps → Viewer still fires `/api/doc/exists` exactly as today. Verified: typecheck pass, 1620 tests / 0 fail, builds OK, App.tsx untouched. -### Remaining Phase 3 -- **scroll** (safe) — extract a render-transparent `ScrollViewportProvider` into `packages/ui/hooks/useScrollViewport.ts`; rewire App.tsx's `ScrollViewportContext.Provider` (3888/4427) to use it; keep App.tsx's own `useActiveSection` consumption and the sidebar-TOC-reads-MAIN-viewport invariant. Touches App.tsx → land isolated + manual eyeball (scroll a plan, confirm TOC active-section tracks). -- **docfetch** (apply for real) — `InlineMarkdown.tsx` hover-preview `fetch('/api/doc')` → injectable `docPreviewFetcher` defaulting to today's literal (matching the getImageSrc/setStorageBackend pattern); keep the `useCallback` deps unchanged. Manual eyeball (hover a code-file link, preview popover appears). -- **noops:** theme, markdown, html-viewer — nothing to land (verified already reusable). +### Seam — Doc-fetch (code-file hover preview) (DONE) +- **File:** `packages/ui/components/InlineMarkdown.tsx`. Added `DocPreviewResult`/`DocPreviewFetcher` + module-level `docPreviewFetcher` (default = verbatim `/api/doc` fetch) + `setDocPreviewFetcher`/`resetDocPreviewFetcher`; routed `handleMouseEnter` through it. `useCallback` deps unchanged. +- **Parity:** no `setDocPreviewFetcher` caller → Plannotator still fetches `/api/doc?path=&base=` identically. Verified: typecheck pass, 1620 tests / 0 fail, builds OK. (Hover popover not visible in dev mock — same caveat as images; call is provably identical.) + +### Seam — Scroll viewport provider (DONE) +- **Files:** `packages/ui/hooks/useScrollViewport.ts` (added render-transparent `ScrollViewportProvider` via `createElement` — kept `.ts`, no JSX; fixed the stale OverlayScrollbars doc-comment) + `packages/editor/App.tsx` (import + the two provider tags at 3888/4427: `ScrollViewportContext.Provider value=` → `ScrollViewportProvider viewport=`). +- **Parity:** `ScrollViewportProvider` renders exactly `ScrollViewportContext.Provider value={viewport}` — identical tree/value/position; App.tsx delta is 3 lines. Sidebar TOC still resolves to the MAIN viewport. Verified: typecheck pass, 1620 tests / 0 fail, builds OK; **manual eyeball — TOC active-section tracks main-content scroll, click-to-scroll works.** + +### Self-review fix — viewer `disabled` path (DONE) +- **Found:** the Phase-3 viewer seam's `disabled` branch set `ready=true` with an empty map, which makes `gateCodePath` demote every code link to **plain text** (since ready+no-entry => 'plain'). Wrong for the seam's purpose (a host disabling validation wants links to stay clickable). Did NOT affect Plannotator (never disables) but the seam was incorrect. +- **Fix:** `useValidatedCodePaths.ts` disabled branch now just `return;` (leaves `ready=false`), so `gateCodePath`'s no-validation fallback renders code links **optimistically (clickable)**. Re-verified: typecheck pass, 1620 tests / 0 fail, builds OK. + +### Noops (nothing to land — verified already reusable) +theme, markdown, html-viewer — decoupled by Phase 2 / already prop-driven. + +### Reusability note (intentional, not a defect) +Three seams now share the shape `defaultX` + module-level `x` + `setX`/`resetX` (image resolver, storage backend, doc-preview fetcher). NOT abstracted into a generic helper: they live in different files, have different call ergonomics (a bare function vs. a `{getItem,setItem,removeItem}` object vs. an async fetcher), and the duplication is ~4 trivial lines each. A shared `createOverridable()` would add indirection for little gain and churn three already-verified files. Revisit if a 4th/5th appears. + +## Phase 3 status: COMPLETE +All 7 pieces resolved — 4 landed (editor, viewer, doc-fetch, scroll), 3 noop. Plannotator byte-unchanged throughout (shipped behavior verified; App.tsx touched only by the 3-line scroll rewire). Scroll provider (the "announcer") now ships in `@plannotator/ui`, closing the Phase-2 deferred seam. diff --git a/packages/ui/hooks/useValidatedCodePaths.ts b/packages/ui/hooks/useValidatedCodePaths.ts index 198deace3..00b6462a2 100644 --- a/packages/ui/hooks/useValidatedCodePaths.ts +++ b/packages/ui/hooks/useValidatedCodePaths.ts @@ -39,10 +39,10 @@ export function useValidatedCodePaths( setReady(false); // Host opt-out (e.g. a backend with no /api/doc/exists). Default undefined - // for Plannotator => unchanged. When disabled, skip validation: code links - // render optimistically (no server probe), same as an empty candidate set. + // 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) { - setReady(true); return; } From ba21aea9b47dd6292c15e0c420c0af53f513f443 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 08:35:51 -0700 Subject: [PATCH 12/46] feat(ui): make file-tree backend host-overridable (Phase 4) Lift useFileBrowser's three backend wires (load-dir fetch, obsidian-vault fetch, and the SSE live-watch effect moved VERBATIM) into an injectable FileTreeBackend with default + setFileTreeBackend/resetFileTreeBackend, same pattern as the image /storage seams. useFileBrowser() stays zero-arg; default fetch/SSE URLs identical. Sidebar confirmed noop (zero backend wires, already reused by review-editor). Verified: useFileBrowser.test.tsx passes 6/0 UNMODIFIED (DOM_TESTS=1), typecheck pass, 1620 tests/0 fail, builds OK, App.tsx untouched, manual eyeball (annotate adr/: tree loads, file-switch works, new file appears live via SSE). Plannotator byte-unchanged. Logs two pre-existing bugs found during testing (not regressions). --- .../document-ui-phase-0-1-worklog-20260622.md | 18 ++ packages/ui/hooks/useFileBrowser.ts | 158 +++++++++++------- 2 files changed, 119 insertions(+), 57 deletions(-) diff --git a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md index 0e4fb1ad2..b2e9b3c46 100644 --- a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md +++ b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md @@ -108,3 +108,21 @@ Three seams now share the shape `defaultX` + module-level `x` + `setX`/`resetX` ## Phase 3 status: COMPLETE All 7 pieces resolved — 4 landed (editor, viewer, doc-fetch, scroll), 3 noop. Plannotator byte-unchanged throughout (shipped behavior verified; App.tsx touched only by the 3-line scroll rewire). Scroll provider (the "announcer") now ships in `@plannotator/ui`, closing the Phase-2 deferred seam. + +## Phase 4 — Navigation (sidebar + file tree) + +Teed up + multi-lens adversarially reviewed by the `phase4-navigation` workflow (tee-up → execute-in-worktree → 4 parity lenses → synthesis), then landed + verified by hand on the real tree. + +### Sidebar (NOOP — nothing to land) +Confirmed transfer-as-is: SidebarContainer/SidebarTabs/CountBadge/FileBrowser/VersionBrowser/ArchiveBrowser/MessagesBrowser and `useSidebar` have **zero** backend wires — all backend interaction arrives as injected callback props, or a pre-built `fileBrowser` prop. Already reused by `packages/review-editor/App.tsx` (`useSidebar`), a second consumer with a different tab union. No edit. + +### Seam — File tree backend (DONE) +- **File:** `packages/ui/hooks/useFileBrowser.ts` only. Lifted the three backend wires into an injectable `FileTreeBackend` (`loadTree`/`loadVaultTree`/`watchTrees`) with a `defaultFileTreeBackend` + `setFileTreeBackend`/`resetFileTreeBackend`, same module-level pattern as the image/storage seams. +- **The SSE live-watch effect moved VERBATIM** into `watchTrees` — EventSource URL, 120ms debounce timers, `readyPaths` dedup, `onmessage` ready/changed dispatch, and `clearTimeout`+`source.close()` cleanup byte-identical. The only substitution is `fetchTreeRef.current(path,{quiet:true})` → injected `onChange(path)` (the hook passes exactly that). The `typeof EventSource === "undefined"` guard relocated into `watchTrees` (returns `undefined` → no cleanup), behavior-identical. `useFileBrowser()` stays zero-arg; default fetch/SSE URLs unchanged. +- **Parity:** no `setFileTreeBackend` caller in editor/apps → Plannotator uses the default. Verified: **`useFileBrowser.test.tsx` passes 6/0 UNMODIFIED** (the strongest guardrail — it asserts the URLs, timer, and SSE behavior via fake `fetch`/`EventSource`; run with `DOM_TESTS=1`); typecheck pass; full `bun test` 1620/0; builds OK; App.tsx untouched. **Manual eyeball** (real `annotate adr/` session): tree loads, file-switching works, new file appears live via SSE without reload. + +### Phase 4 status: COMPLETE — sidebar noop, file-tree seam landed. Plannotator byte-unchanged. + +### Discovered (PRE-EXISTING, out of scope — not caused by this work) +1. **Edit/save header state leaks across file switches** in annotate-folder mode: editing+saving file A leaves the Saved/Done/wide-focus header showing when you switch to file B without editing it. Reproduced on the **baseline with the Phase 4 change reverted** (A/B confirmed) → pre-existing App.tsx bug, not a regression. Lives in the folder file-switch handler (`handleFileBrowserSelect` / edit-session reset), unrelated to `useFileBrowser`. Worth a separate fix. +2. **Annotating the repo root (`annotate ./`) bogs down** — the file walker + chokidar SSE watcher choke on 1.4GB of node_modules (16 dirs); the code already warns about this. Pre-existing scaling limit; use a bounded folder. Not a code defect introduced here. diff --git a/packages/ui/hooks/useFileBrowser.ts b/packages/ui/hooks/useFileBrowser.ts index 563b38ad4..5ce29c7ee 100644 --- a/packages/ui/hooks/useFileBrowser.ts +++ b/packages/ui/hooks/useFileBrowser.ts @@ -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 { From cfbdc37fe5d374dba946e3f11b328822eabb8d32 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 08:50:53 -0700 Subject: [PATCH 13/46] docs(adr): research + synthesis + spec for Phase 5 (comments/annotations/drafts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five-probe code research of the comment system. Key finding: most comment UI is already portable (panel/popover/toolbar/highlighter prop-driven; review-editor already reuses the hooks). Phase 5 narrows to 3 seams — draft transport (+ the 3-party generation protocol), external-annotation transport (SSE->polling, move verbatim), and identity/authorship — plus 2 non-extraction items: renderer coupling (document as a contract) and replies/threading (defer as a new feature). --- ...ment-ui-comments-system-20260623-084806.md | 73 ++++++++++++++++ ...is-document-ui-comments-20260623-084806.md | 52 ++++++++++++ ...cument-ui-comments-seam-20260623-084806.md | 85 +++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 adr/research/SPIKE-document-ui-comments-system-20260623-084806.md create mode 100644 adr/research/synthesis-document-ui-comments-20260623-084806.md create mode 100644 adr/specs/document-ui-comments-seam-20260623-084806.md diff --git a/adr/research/SPIKE-document-ui-comments-system-20260623-084806.md b/adr/research/SPIKE-document-ui-comments-system-20260623-084806.md new file mode 100644 index 000000000..0bedd2039 --- /dev/null +++ b/adr/research/SPIKE-document-ui-comments-system-20260623-084806.md @@ -0,0 +1,73 @@ +# Spike: Comments / Annotations / Drafts System (Phase 5) + +Date: 2026-06-23 + +> Code research for Phase 5 of the `@plannotator/ui` reuse effort (governed by ADR 004; roadmap `adr/implementation/document-ui-extraction-roadmap-20260622.md`). Five parallel probes mapped the comment/annotation/draft system on the real tree. Goal: know every backend wire and every timing-sensitive invariant before speccing the seams. THE LAW: move + decouple, never rewrite; Plannotator's experience cannot change. + +## Headline + +**Most of the comment UI is already portable.** The comment *components* (`AnnotationPanel`, `CommentPopover`, `AnnotationToolbar`, `AnnotationToolstrip`, `EditorAnnotationCard`) and the highlighter hook (`useAnnotationHighlighter`) are prop-driven with no backend wires. `review-editor` already reuses `useExternalAnnotations`, `useEditorAnnotations`, and `useCodeAnnotationDraft` unchanged — a second consumer proving portability. So Phase 5 is **narrower than its "big one" reputation on the UI side**; the work is concentrated in **three seams** (draft transport, external-annotation transport, identity) plus **two structural constraints** (renderer coupling, no reply model). + +## Annotation state lives in the host, not a shared reducer +- Plan: `packages/editor/App.tsx:255` `useState`; Review: `packages/review-editor/App.tsx:121` `useState`. There is **no shared annotation reducer** in `packages/ui` — each host owns its annotation array. So Workspaces will own its annotation state too; the shared package supplies the components/hooks that operate on it. (This is fine and matches review-editor.) + +## Seam 1 — Draft transport (`/api/draft`) + the generation protocol + +**Files:** `packages/ui/hooks/useAnnotationDraft.ts` (plan, full-featured), `packages/ui/hooks/useCodeAnnotationDraft.ts` (review, simpler). Server: `packages/shared/draft.ts`, `packages/server/shared-handlers.ts`, approve/deny in `packages/server/index.ts` + `annotate.ts`. + +- **Wires:** `GET/POST/DELETE /api/draft`. POST body carries `{annotations, codeAnnotations, globalAttachments, editedMarkdown, editedDocuments, savedFileChanges, draftGeneration, ts}`. DELETE uses `?generation=N`. 500ms debounce (`DEBOUNCE_MS`). +- **The 3-party generation protocol (the fragile part):** + 1. Client keeps `draftGenerationRef` (starts 0), **pre-increments before each POST** (`++draftGenerationRef.current`); `getDraftGeneration()` returns the *next* gen (`ref.current + 1`) — `useAnnotationDraft.ts:383`. + 2. That value **escapes the hook** and is threaded into submit by the host: `App.tsx:1960-1963` `withDraftGeneration(path)` appends `?draftGeneration=`; used on `/api/approve` (App.tsx:2704), `/api/exit` (2715); `/api/deny` and `/api/feedback` carry it in the **body** (2626, 2683). **Per-endpoint source differs:** plan approve/deny read from body; annotate approve/exit read from **URL** (`annotate.ts:557,550`), feedback from body (573). + 3. Server **tombstone-gates** (`shared/draft.ts`): `saveDraft` rejects if `draftGeneration <= deletedGeneration` (L98) or `< storedGeneration` (L102); `deleteDraft` writes a tombstone at the deletion generation (L150); ignores stale deletes (L146). This is what prevents a late async draft-save from **resurrecting a draft after submit** (ghost drafts). +- **Timing-sensitive, must move VERBATIM:** the `keepalive: true` POST with **retry-without-keepalive on failure** gated by generation match (L357-364); the `visibilitychange`/`pagehide` **flush** that fires a final keepalive save on tab close (L389-405); the refs (`draftGenerationRef`, `timerRef`, `latestRef` non-reactive getters, `canPersistRef`, `hasMountedRef`). `canPersist = isApiMode && !isSharedSession && !submitted`. +- **Already portable:** the hooks are pure (no host imports); `shared/draft.ts` is runtime-agnostic node:fs. The wires are the only coupling. + +## Seam 2 — External-annotation transport (the live-comment channel) + +**Files:** `packages/ui/hooks/useExternalAnnotations.ts`, `useExternalAnnotationHighlights.ts`. Server: `packages/server/external-annotations.ts` (+ Pi mirror), `packages/shared/external-annotation.ts` (store, validators, event types). + +- **This is the "teammates + agents commenting live" channel.** External tools/agents `POST /api/external-annotations`; the UI shows them live. +- **Transport state machine (move VERBATIM):** primary `EventSource('/api/external-annotations/stream')` delivers `snapshot|add|remove|clear|update` events into an internal reducer with **optimistic mutators** (delete/clear/update update local state, then call the server; SSE reconciles). On SSE error **before first snapshot**, fall back to **polling** `GET /api/external-annotations?since=` every **500ms** (`POLL_INTERVAL_MS`), honoring **304 Not Modified** when `since === store.version`. 30s SSE heartbeat (`:` comment). Version is session-scoped (`versionRef` starts 0). Fallback triggers once (`!receivedSnapshotRef && !fallbackRef`) and doesn't switch back. +- **Already generic + gated:** `useExternalAnnotations` is shape-generic and takes an `enabled` flag. Plan: `enabled: isApiMode && !goalSetupMode` (App.tsx:1135). **Review already reuses it** for `CodeAnnotation` with `enabled: !!origin` (App.tsx:284). `useExternalAnnotationHighlights` paints them via the Viewer handle (filters out global/diff, 100ms mount delay, fingerprint dedup). +- **Merge policy is host-owned:** App.tsx dedups local vs external by `source+type+originalText` (plan) / `source+type+filePath+lineStart+lineEnd+side` (review). +- **Seam = inject the transport** (a `subscribe()` + CRUD + `getSnapshot(since)` object) whose default reproduces the SSE→polling machine exactly. Server store/validators/SSE encoding (`shared/external-annotation.ts`) move wholesale. + +## Seam 3 — Identity / authorship ("which comments are mine") + +**Files:** `packages/ui/utils/identity.ts`, `generateIdentity.ts`, `config/configStore.ts`, `config/settings.ts`. + +- `getIdentity()` reads `configStore.get('displayName')`; resolution **server config > cookie (`plannotator-identity`) > generated `{adj}-{noun}-tater`**. `isCurrentUser(author)` compares `author === configStore.get('displayName')` (`identity.ts:47-50`). +- **Stamp sites (9 hardcoded `getIdentity()`):** `Viewer.tsx:456,518`, `useAnnotationHighlighter.ts:273`, `html-viewer/HtmlViewer.tsx:210`, `html-viewer/useHtmlAnnotation.ts:142,258,296,333`, `plan-diff/PlanCleanDiffView.tsx:169`. **Display sites (2 `isCurrentUser()`):** `AnnotationPanel.tsx:194,204` → renders the `(me)` badge (518, 651). +- **Partly already host-controllable:** identity persists via the **swappable storage backend** (Phase 2 `setStorageBackend`) and can be seeded from server config via `configStore.init(serverConfig)`. So a host can already set the identity *value*. The remaining seam is making the **stamp/display callable** overridable: optional `author?` / `isCurrentUser?` defaulting to the existing functions, so Workspaces (real WorkOS logins) supplies the logged-in user instead of a tater name. +- `Annotation.author` / `CodeAnnotation.author` are optional fields; `sharing.ts` preserves author across share/import (already collaborative). + +## Constraint A — Renderer coupling (structural, not a seam) + +**Files:** `useAnnotationHighlighter.ts` (`findTextInDOM` L106-235, `applyAnnotationsInternal` L293-403), `utils/inlineTransforms.ts` (`transformPlainText` = emoji + smartypants), `BlockRenderer.tsx`, `InlineMarkdown.tsx`, `@plannotator/web-highlighter@0.8.1`. + +- Highlight **restoration** re-anchors a saved annotation by searching the rendered DOM for `originalText`, with a fallback that applies `transformPlainText` (because the renderer turns `:smile:`→😄, `---`→—, straight→curly quotes). So restoration **only works if the host renders markdown to the same text** the transforms produce. +- Code blocks use **manual `` wrapping** (web-highlighter can't sit inside hljs spans); removal re-runs `hljs.highlightElement`. +- **Implication:** Workspaces must reuse `BlockRenderer` + `InlineMarkdown` + `inlineTransforms` **as a unit** for highlights to land. This is a documented integration contract, not a wire to cut. (Optional future: expose `transformPlainText` as overridable, but default stays.) + +## Constraint B — No reply / threading model (a gap, not a regression) + +- `Annotation` and `CodeAnnotation` are **flat**: a comment is one `text` field. No `parentCommentId`, `replies`, `threadId`. `CommentPopover` has a module-level draft cache but composes single comments. +- Workspaces wants **replies/threads** (teammates discussing on a doc). That is a **new feature**, not part of "make today's behavior reusable." Adding threading touches the type, the panel, and the popover — and must NOT change Plannotator's flat experience. **Out of scope for Phase 5's parity-preserving extraction**; flag as a Workspaces-side addition (build replies as a host-layer on top of, or a backward-compatible extension of, the shared components later). + +## Already-portable inventory (no Phase-5 work needed) +`AnnotationPanel.tsx`, `AnnotationToolbar.tsx`, `AnnotationToolstrip.tsx`, `CommentPopover.tsx`, `EditorAnnotationCard.tsx`, `AnnotationSidebar.tsx`, `useAnnotationHighlighter.ts`, `useExternalAnnotationHighlights.ts`, `utils/commentContent.ts`, `utils/annotationHelpers.ts`, `utils/anchors.ts`, and the `exportAnnotations`/`exportCodeFileAnnotations`/`exportEditorAnnotations` serializers in `parser.ts` (pure, no API). `AnnotationPanel` only touches identity via the display-only `isCurrentUser` (Seam 3). + +## Out of scope / host-owned (confirmed) +- `useEditorAnnotations` (`/api/editor-annotation(s)`, gated by `window.__PLANNOTATOR_VSCODE`) — VS Code IPC, host-only, not a document-UI seam. +- Feedback/submit routes (`/api/feedback`, `/api/approve`, `/api/deny`, `/api/exit`) and their payload policy — host-owned (Workspaces has its own). +- Annotation state ownership and the external-merge/dedup policy — host-owned. + +## Per-seam evidence map +| Seam | Key files | Backend wires | Move-verbatim invariants | +|---|---|---|---| +| 1 Drafts | useAnnotationDraft.ts, useCodeAnnotationDraft.ts, shared/draft.ts | `GET/POST/DELETE /api/draft`; generation in approve/deny/feedback/exit | generation pre-increment + tombstone gate; keepalive retry; visibility/pagehide flush; the 5 refs | +| 2 External | useExternalAnnotations.ts, useExternalAnnotationHighlights.ts, shared/external-annotation.ts, server/external-annotations.ts | SSE `/stream`; `GET ?since=`; `POST/PATCH/DELETE` | SSE→polling fallback machine; 500ms poll; 304 gate; 30s heartbeat; optimistic mutators; version-scoping | +| 3 Identity | identity.ts, configStore.ts, settings.ts | (none directly; via configStore→storage, swappable) | resolution order server>cookie>tater; 9 stamp sites; 2 `(me)` sites | +| A Renderer | useAnnotationHighlighter.ts, inlineTransforms.ts, BlockRenderer/InlineMarkdown | none | restoration depends on exact rendered text; manual code-block `` | +| B Replies | types.ts, AnnotationPanel, CommentPopover | none | flat model today; threading is a NEW feature, keep Plannotator flat | diff --git a/adr/research/synthesis-document-ui-comments-20260623-084806.md b/adr/research/synthesis-document-ui-comments-20260623-084806.md new file mode 100644 index 000000000..552383a41 --- /dev/null +++ b/adr/research/synthesis-document-ui-comments-20260623-084806.md @@ -0,0 +1,52 @@ +# Synthesis: Comments / Annotations / Drafts (Phase 5) + +Date: 2026-06-23 + +> Synthesizes `SPIKE-document-ui-comments-system-20260623-084806.md` against the verified plan (`adr/specs/document-ui-extraction-plan-verified-20260622-184500.md`) and ADR 004. Settles the shape of Phase 5. + +## The reframing + +Phase 5 has been called "the big one." The research **confirms it's the most interconnected subsystem but narrows the actual work.** Three facts change the picture: + +1. **The comment UI is already portable.** Panel, popover, toolbar, highlighter hook — all prop-driven, no backend wires. Nothing to extract. +2. **A second consumer already proves it.** `review-editor` reuses `useExternalAnnotations`, `useEditorAnnotations`, and `useCodeAnnotationDraft` unchanged, with `enabled` gates and shape-generics already in place. The portability pattern exists; we extend it, we don't invent it. +3. **Annotation state is host-owned already.** Each app holds its own `useState` array; there is no shared reducer to wrestle. Workspaces owns its state too. + +So Phase 5 = **three transport/identity seams** + **two things that are NOT extraction work** (a renderer constraint to document, and a replies feature to defer). + +## What we will do: three seams (same pattern as Phases 2–4) + +Each is the proven shape: a module-level default that reproduces today's literal behavior, plus an optional `setX` override; Plannotator passes nothing and is byte-unchanged. + +### Seam 1 — Draft transport +Inject a `DraftTransport` (load/save/delete) into `useAnnotationDraft` and `useCodeAnnotationDraft`, default = today's `/api/draft` fetches **verbatim**, including the `keepalive` retry and the `visibilitychange`/`pagehide` flush. **The generation protocol is the hard part and must be preserved end-to-end:** `getDraftGeneration()` still escapes the hook and the host still threads it into submit (`withDraftGeneration`), and the seam must document that a host swapping transport also has to honor generation-gated delete-on-submit (or ghost drafts return). The refs and pre-increment timing move verbatim. + +### Seam 2 — External-annotation transport +Inject an `ExternalAnnotationTransport` (`subscribe(onEvent,onError)` + optimistic CRUD + `getSnapshot(since)`) into `useExternalAnnotations`, default = the SSE→polling state machine **moved verbatim** (EventSource primary, 500ms polling fallback, 304 gate, 30s heartbeat, fallback-once semantics). The reducer and optimistic mutators stay in the hook. The `enabled` gate is already host-suppliable. The server store/validators/SSE encoding in `shared/external-annotation.ts` are already shared; a Workspaces backend implements the same event contract over Durable Objects instead of SSE. + +### Seam 3 — Identity +Make authorship overridable: optional `author?` (or an injected `getIdentity`) at the ~9 stamp sites and an optional `isCurrentUser?` at the 2 `(me)` display sites, **defaulting to the existing `identity.ts` functions**. Storage is already swappable (Phase 2) and `configStore.init(serverConfig)` already seeds identity from the server, so much of identity is host-controllable today; this seam closes the last gap so Workspaces' real logins (WorkOS) drive authorship instead of tater names. + +## What we will NOT do in Phase 5 + +### Constraint A — Renderer coupling: document it, don't fight it +Highlight restoration re-anchors against the rendered DOM and depends on `transformPlainText` (emoji + smart punctuation) matching the renderer's output. **Workspaces must reuse `BlockRenderer` + `InlineMarkdown` + `inlineTransforms` as a unit.** This is an integration contract we write down, not a wire we cut. (Optional later: expose `transformPlainText` as overridable with today's default — but not required for Phase 5.) + +### Constraint B — Replies/threading: defer as a new feature +Comments are flat today. Threading is something **Workspaces wants but Plannotator does not have** — so building it is *adding a feature*, which is explicitly outside "make today's behavior reusable without changing Plannotator." Phase 5 ships the flat model unchanged. Replies become a later, backward-compatible enhancement (a host layer over the shared components, or an optional `replies?` extension that Plannotator never populates), planned on its own once the seams land. + +## Why this ordering and risk read +- **Identity (Seam 3) is the lowest-risk** and partly done — do it first to warm up. +- **Drafts (Seam 1) is medium** — the generation protocol is fiddly but well-understood; the guardrail is that approve/deny/feedback/exit still carry the generation and ghost drafts don't return. +- **External (Seam 2) is the riskiest** — a timing-sensitive state machine that must move verbatim (the exact trap that sank the reverted attempt). Do it last, with the SSE→polling fallback proven by an eyeball (kill the stream, confirm polling takes over). +- Everything else (panel, popover, toolbar, highlighter, exporters) is already portable — **no work, just confirm noop** like the sidebar in Phase 4. + +## Open decisions for the spec/ADR +1. **Identity injection mechanism:** optional `author?` prop threaded to stamp sites vs. an injected `getIdentity`/`isCurrentUser` pair (module-level setter, like the other seams). Lean: module-level setter (`setIdentityProvider`) for consistency and to avoid threading props through 9 sites. +2. **Replies:** confirm it's deferred (recommended) vs. scoped into Phase 5. Recommend defer. +3. **Renderer constraint:** document-only (recommended) vs. also extract `transformPlainText` as overridable now. Recommend document-only. + +## References +- Spike: `adr/research/SPIKE-document-ui-comments-system-20260623-084806.md` +- Verified plan (Phase 5 / step 5): `adr/specs/document-ui-extraction-plan-verified-20260622-184500.md` +- Decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md` diff --git a/adr/specs/document-ui-comments-seam-20260623-084806.md b/adr/specs/document-ui-comments-seam-20260623-084806.md new file mode 100644 index 000000000..f64519d83 --- /dev/null +++ b/adr/specs/document-ui-comments-seam-20260623-084806.md @@ -0,0 +1,85 @@ +# Spec: Comments / Annotations / Drafts Seam (Phase 5) + +Date: 2026-06-23 · Status: Draft (iterate before implementing) + +> Implementation spec for Phase 5 of the `@plannotator/ui` reuse effort. Grounded in `SPIKE-document-ui-comments-system-20260623-084806.md` + `synthesis-document-ui-comments-20260623-084806.md`. Governed by ADR 004. THE LAW: each seam is a module-level default reproducing today's literal behavior + an optional override; Plannotator passes nothing and is byte-for-byte unchanged. Move verbatim, never rewrite — especially the SSE machine and the draft generation protocol. + +## Scope + +**In scope (3 seams):** draft transport, external-annotation transport, identity/authorship. +**Confirmed noop (already portable):** AnnotationPanel, CommentPopover, AnnotationToolbar, AnnotationToolstrip, EditorAnnotationCard, AnnotationSidebar, useAnnotationHighlighter, useExternalAnnotationHighlights, commentContent/annotationHelpers/anchors, the `export*Annotations` serializers. +**Out of scope:** replies/threading (new feature — defer), renderer coupling (document as a contract), `useEditorAnnotations` / VS Code IPC (host-only), feedback/submit routes and merge/dedup policy (host-owned). + +## Order of work (lowest risk first) + +### Step 1 — Identity / authorship seam (effort S, lowest risk; partly already done) +**Files:** `packages/ui/utils/identity.ts` and the stamp/display sites. +- Add a module-level identity provider with default = today's functions, matching the Phase 2–4 pattern: + ```ts + // identity.ts + export interface IdentityProvider { + getIdentity(): string; + isCurrentUser(author: string | undefined): boolean; + } + const defaultIdentityProvider: IdentityProvider = { getIdentity, isCurrentUser }; // existing impls + let identityProvider = defaultIdentityProvider; + export function setIdentityProvider(p: IdentityProvider): void { identityProvider = p; } + export function resetIdentityProvider(): void { identityProvider = defaultIdentityProvider; } + ``` + Then route the 9 stamp sites and 2 display sites through `identityProvider.getIdentity()` / `identityProvider.isCurrentUser()`. Keep the existing `getIdentity`/`isCurrentUser` exports working (they remain the default). +- **Alternative considered:** thread an optional `author?`/`isCurrentUser?` prop through Viewer/panel. Rejected for now — 9 stamp sites across Viewer + html-viewer + diff make a module-level provider cleaner and lower-churn. (Decide in review.) +- **Parity guardrail:** no caller sets the provider → tater identity + `(me)` badge behave exactly as today. Verify: existing identity/annotation tests green; eyeball a comment shows the tater name + `(me)`. + +### Step 2 — Draft transport seam (effort M) +**Files:** `packages/ui/hooks/useAnnotationDraft.ts`, `packages/ui/hooks/useCodeAnnotationDraft.ts`. +- Introduce a `DraftTransport` and module-level default reproducing today's fetches verbatim: + ```ts + export interface DraftTransport { + load(): Promise<{ data: unknown | null; generation: number | null }>; + save(body: object, opts: { keepalive?: boolean }): Promise; + remove(generation: number, opts?: { keepalive?: boolean }): Promise; + } + ``` + Default `save` keeps the **keepalive-true POST with retry-without-keepalive on failure gated by generation match**; default `remove` does `DELETE /api/draft?generation=N`; default `load` does `GET /api/draft` + reads `draftGeneration` from the (404) body. +- **Keep inside the hook (do not move into the transport):** the `draftGenerationRef` pre-increment, the 500ms debounce, the `latestRef` non-reactive getters, `canPersistRef`/`hasMountedRef` gates, and the `visibilitychange`/`pagehide` flush effect. These are stateful/timing-sensitive — verbatim. +- **Document the 3-party protocol in the seam's doc comment:** `getDraftGeneration()` still escapes to the host; the host still threads it into submit (`withDraftGeneration` → `/api/approve`,`/api/exit` URL; `/api/deny`,`/api/feedback` body; annotate reads approve/exit from URL). A host swapping transport **must** replicate generation-gated delete-on-submit and tombstoning, or ghost drafts resurrect. +- **Parity guardrail:** no caller overrides transport → identical `/api/draft` traffic and identical generation in approve/deny/feedback/exit. Verify: existing draft tests green (esp. `packages/shared/draft.test.ts` generation invariants — server side is untouched); typecheck; full `bun test` ≥ baseline; eyeball: type a comment, reload → draft restores; submit → draft gone, doesn't reappear. + +### Step 3 — External-annotation transport seam (effort M–L, riskiest; do last) +**Files:** `packages/ui/hooks/useExternalAnnotations.ts` (+ `useExternalAnnotationHighlights.ts` stays as-is). +- Introduce an `ExternalAnnotationTransport` and module-level default reproducing the SSE→polling machine verbatim: + ```ts + export interface ExternalAnnotationTransport { + subscribe(onEvent: (e: ExternalAnnotationEvent) => void, onError: () => void): () => void; + getSnapshot(since: number): Promise<{ annotations: T[]; version: number } | null>; // null on 304 + add(items: T[]): Promise; + remove(id: string): Promise; + update(id: string, fields: Partial): Promise; + clear(source?: string): Promise; + } + ``` + Default `subscribe` = `new EventSource('/api/external-annotations/stream')` wiring; default `getSnapshot` = `GET /api/external-annotations?since=` with 304→null; CRUD = today's optimistic-then-fetch calls. +- **Keep inside the hook (verbatim):** the reducer that applies `snapshot|add|remove|clear|update`, the **fallback-once** logic (`!receivedSnapshotRef && !fallbackRef`), the **500ms** poll interval, the version-scoped `versionRef`, and the optimistic local mutation before the network call. The default transport owns the EventSource/heartbeat/304 wire; the hook owns the state machine that drives it. +- The `enabled` flag stays host-suppliable (plan: `isApiMode && !goalSetupMode`; review: `!!origin`). Server `shared/external-annotation.ts` (store, validators, event types, SSE encoding) is already shared and unchanged. +- **Parity guardrail:** no caller overrides → identical SSE connection, identical 500ms/304 polling, identical optimistic CRUD. Verify: existing external-annotation tests green; **eyeball both paths** — (a) POST to `/api/external-annotations` shows live without reload (SSE); (b) kill/black-hole the stream → confirm polling takes over and still updates. App.tsx merge/dedup untouched. + +## Renderer-coupling contract (document, no code change) +Write a short integration note (in the package README or a `docs` doc) stating: a host consuming the annotation UI must render markdown through `@plannotator/ui` `BlockRenderer` + `InlineMarkdown` + `utils/inlineTransforms` (which applies `transformPlainText`), because highlight restoration re-anchors against that exact rendered text. Optional future work: expose `transformPlainText` as overridable with today's default. + +## Replies/threading (explicitly deferred) +Not built in Phase 5. When scoped later, do it backward-compatibly (Plannotator keeps the flat single-comment experience; threading is additive and Plannotator never populates it). Tracked as a separate spec. + +## Definition of done (Phase 5) +- Identity, draft, and external-annotation transports are host-overridable, each defaulting to today's behavior. +- Plannotator byte-unchanged: shipped bundles behave identically; full `bun test` ≥ baseline (1620/0); typecheck; builds; App.tsx changes limited to (at most) wiring the defaults at call sites if needed (ideally zero — module-level defaults mean Plannotator passes nothing). +- Eyeball confirmed: comment author/`(me)`, draft save+restore+no-ghost, live external annotations via SSE, and SSE→polling fallback. +- Renderer-coupling contract written down. Replies deferred with a note. + +## Per-step parity guardrail (run after each) +`bun run typecheck` · `bun test` must stay ≥ 1620/0 (+ the touched suite green, unmodified where a guardrail test exists) · `bun run --cwd apps/review build && bun run build:hook` · `git diff packages/editor/App.tsx` minimal/empty · manual eyeball for the step's surface. + +## Open questions (resolve before/within ADR) +1. Identity: module-level `setIdentityProvider` (recommended) vs. props. +2. Replies: deferred (recommended) vs. in-scope. +3. Renderer `transformPlainText`: document-only (recommended) vs. extract overridable now. +4. Whether to ship the three seams as one Phase-5 PR or three small verify-gated commits (recommended: three commits, identity → drafts → external, like Phase 3). From 162cf1d3ebaff6e34966a02d308ca848539d65d4 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 08:53:53 -0700 Subject: [PATCH 14/46] =?UTF-8?q?docs(adr):=20accept=20ADR=20005=20?= =?UTF-8?q?=E2=80=94=20make=20comments/annotations/drafts=20host-overridab?= =?UTF-8?q?le=20(Phase=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three seams (identity, draft transport, external-annotation transport), each defaulting to today's behavior; renderer coupling documented as a contract; replies/threading deferred as a new feature. Locks in the recommended choices from the Phase 5 spec/synthesis. --- ...mments-host-overridable-20260623-085309.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 adr/decisions/005-make-comments-host-overridable-20260623-085309.md diff --git a/adr/decisions/005-make-comments-host-overridable-20260623-085309.md b/adr/decisions/005-make-comments-host-overridable-20260623-085309.md new file mode 100644 index 000000000..bd7ff2c8d --- /dev/null +++ b/adr/decisions/005-make-comments-host-overridable-20260623-085309.md @@ -0,0 +1,52 @@ +# 005. Make Comments / Annotations / Drafts Host-Overridable (Phase 5) + +Date: 2026-06-23 + +## Status + +Accepted + +## Context + +ADR 004 set the plan: make `@plannotator/ui` reusable by the commercial Workspaces app by lifting each Plannotator-specific wire up to an optional override whose default is today's behavior, never changing Plannotator. Phases 0–4 did this for packaging, image/storage, the rendering stack, and the file tree. + +Phase 5 is comments — the core of Workspaces (teammates and AI agents commenting on documents, live). It was assumed to be the largest, most dangerous phase. Five code-research probes (`adr/research/SPIKE-document-ui-comments-system-20260623-084806.md`, synthesized in `adr/research/synthesis-document-ui-comments-20260623-084806.md`) found a narrower reality: + +- The comment **UI is already portable** — `AnnotationPanel`, `CommentPopover`, `AnnotationToolbar`, `AnnotationToolstrip`, `EditorAnnotationCard`, `useAnnotationHighlighter`, the `export*Annotations` serializers — all prop-driven, no backend wires. +- A **second consumer already proves it**: `review-editor` reuses `useExternalAnnotations`, `useEditorAnnotations`, and `useCodeAnnotationDraft` unchanged. +- Annotation **state is host-owned already** (each app's own `useState`), so there is no shared reducer to wrestle. + +The real coupling is three things: the draft transport (`/api/draft`) plus a fragile 3-party "generation" protocol that prevents ghost drafts; the external-annotation transport (an SSE→polling state machine — the live-comment channel); and identity/authorship (the local "tater" nickname behind the `(me)` badge). Two further findings are not extraction work: highlight restoration is coupled to Plannotator's exact markdown renderer, and there is no reply/threading model (which Workspaces wants but Plannotator does not have). + +## Decision + +Make the comment system host-overridable through **three seams**, each a module-level default that reproduces today's behavior plus an optional override; Plannotator passes nothing and stays byte-for-byte unchanged. Land them lowest-risk first, as three separate verify-gated commits. + +1. **Identity (first, lowest risk).** Add a module-level identity provider in `packages/ui/utils/identity.ts` (`setIdentityProvider` / `resetIdentityProvider`) defaulting to today's `getIdentity` / `isCurrentUser`. Route the ~9 author-stamp sites and 2 `(me)`-display sites through it. Workspaces supplies the logged-in user; Plannotator keeps the tater nickname. (Identity already persists via the Phase-2 swappable storage and `configStore.init(serverConfig)`; this closes the last gap.) + +2. **Draft transport (second).** Inject a `DraftTransport` (load/save/remove) into `useAnnotationDraft` and `useCodeAnnotationDraft`, default = today's `/api/draft` fetches verbatim — including the `keepalive` retry and the `visibilitychange`/`pagehide` flush. The generation protocol stays end-to-end: `getDraftGeneration()` still escapes to the host and is still threaded into approve/deny/feedback/exit; the seam's contract documents that a host swapping transport must also honor generation-gated delete-on-submit and tombstoning, or ghost drafts return. The stateful refs, debounce, and pre-increment timing stay in the hook, verbatim. + +3. **External-annotation transport (last, riskiest).** Inject an `ExternalAnnotationTransport` (`subscribe` + optimistic CRUD + `getSnapshot(since)`) into `useExternalAnnotations`, default = the SSE→polling state machine moved verbatim (EventSource primary, 500ms polling fallback, 304 gate, 30s heartbeat, fallback-once). The reducer, optimistic mutators, version-scoping, and `enabled` gate stay in the hook. A Workspaces backend implements the same event contract over Durable Objects instead of SSE; the shared store/validators/encoding in `packages/shared/external-annotation.ts` are unchanged. + +The already-portable comment components and hooks are confirmed no-ops — no work. + +**Two things are explicitly excluded from Phase 5:** + +- **Renderer coupling — document, do not change.** Highlight restoration re-anchors against the rendered DOM and depends on `transformPlainText` matching the renderer. We write down an integration contract: a host must reuse `BlockRenderer` + `InlineMarkdown` + `inlineTransforms` as a unit. (Optionally expose `transformPlainText` as overridable later; not now.) + +- **Replies / threading — defer as a new feature.** Comments are flat today. Threading is something Workspaces wants and Plannotator lacks; building it is adding a feature, not making existing behavior reusable. Phase 5 ships the flat model unchanged. Replies are a later, backward-compatible enhancement that Plannotator never populates, tracked in its own spec. + +## Consequences + +- Workspaces can power real-time, multi-person, agent-friendly commenting by implementing three transports/providers, without inheriting Plannotator's `/api/draft`, SSE routes, or tater identity. +- Plannotator is unchanged: every seam defaults to today's literal behavior; the draft generation protocol and the SSE→polling machine move verbatim (the exact failure mode of the reverted attempt is avoided by copying, not re-deriving). +- The parity bar per seam: full `bun test` stays at baseline (1620/0), typecheck and builds pass, `packages/editor/App.tsx` changes stay minimal/empty, and an eyeball confirms the surface — author/`(me)` badge, draft save+restore+no-ghost, live external annotations, and the SSE→polling fallback (kill the stream, confirm polling takes over). +- A new integration constraint is now on record: Workspaces must reuse Plannotator's markdown renderer for comment highlights to land. This narrows Workspaces' freedom on rendering but is required and cheap (it already wants the same look). +- Replies remain unbuilt; Workspaces' full collaborative-thread vision needs a follow-up once the seams land. + +## References + +- Spike: `adr/research/SPIKE-document-ui-comments-system-20260623-084806.md` +- Synthesis: `adr/research/synthesis-document-ui-comments-20260623-084806.md` +- Spec: `adr/specs/document-ui-comments-seam-20260623-084806.md` +- Governing decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md` From d31677dd31dcf517bd6e3fa7ee53630c6144e8b0 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 09:12:09 -0700 Subject: [PATCH 15/46] feat(ui): make annotation identity host-overridable (Phase 5 seam 1) Add IdentityProvider + setIdentityProvider/resetIdentityProvider in identity.ts; getIdentity/isCurrentUser now delegate to a module-level provider defaulting to today's ConfigStore tater behavior. The ~9 author-stamp sites and 2 (me)-badge sites delegate with zero call-site edits. No caller overrides => Plannotator byte-unchanged. typecheck pass, 1620 tests/0 fail, builds OK. --- packages/ui/utils/identity.ts | 51 +++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/ui/utils/identity.ts b/packages/ui/utils/identity.ts index 8b73f7504..b67bb0ba5 100644 --- a/packages/ui/utils/identity.ts +++ b/packages/ui/utils/identity.ts @@ -14,10 +14,52 @@ import { configStore } from '../config'; import { generateIdentity } from './generateIdentity'; /** - * Get current identity from ConfigStore. + * Host-overridable identity provider. + * + * Default = today's tater behavior (ConfigStore-backed nickname + cookie match). + * A host (e.g. Workspaces) calls setIdentityProvider once at startup to stamp its + * logged-in user on comments and drive the `(me)` badge instead. Mirrors the + * swappable storage backend in ./storage.ts (StorageBackend/setStorageBackend). + */ +export interface IdentityProvider { + /** Display name stamped as `author` on new annotations. */ + getIdentity(): string; + /** Whether an annotation's `author` is the current user (drives the `(me)` badge). */ + isCurrentUser(author: string | undefined): boolean; +} + +/** + * Default provider: today's literal Plannotator behavior. + * `displayName` resolution stays in ConfigStore (server config > cookie > tater). + */ +const defaultIdentityProvider: IdentityProvider = { + getIdentity(): string { + return configStore.get('displayName'); + }, + isCurrentUser(author: string | undefined): boolean { + if (!author) return false; + return author === configStore.get('displayName'); + }, +}; + +// Active provider. Defaults to the tater identity so Plannotator is unchanged. +let identityProvider: IdentityProvider = defaultIdentityProvider; + +/** Override the identity provider. Call once at app startup. */ +export function setIdentityProvider(p: IdentityProvider): void { + identityProvider = p; +} + +/** Reset to the default (tater) provider. Mainly for tests. */ +export function resetIdentityProvider(): void { + identityProvider = defaultIdentityProvider; +} + +/** + * Get current identity. Delegates to the active provider (default = ConfigStore tater). */ export function getIdentity(): string { - return configStore.get('displayName'); + return identityProvider.getIdentity(); } /** @@ -42,9 +84,8 @@ export function regenerateIdentity(): string { } /** - * Check if an identity belongs to the current user. + * Check if an identity belongs to the current user. Delegates to the active provider. */ export function isCurrentUser(author: string | undefined): boolean { - if (!author) return false; - return author === configStore.get('displayName'); + return identityProvider.isCurrentUser(author); } From 8555f15f9fed45b6b69e81fc366dcf357403642c Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 09:16:23 -0700 Subject: [PATCH 16/46] feat(ui): make draft persistence transport host-overridable (Phase 5 seam 2) Add DraftTransport (load/save/remove) + getDraftTransport/setDraftTransport/ resetDraftTransport in useAnnotationDraft.ts, default = today's /api/draft fetches verbatim. useCodeAnnotationDraft reads getDraftTransport() live. The generation pre-increment, 500ms debounce, keepalive retry-gate, and pagehide/visibilitychange flush stay in the hooks; getDraftGeneration() still escapes to the host. save rejects-on-failure so the gated retry is preserved. No caller overrides => Plannotator byte-unchanged. shared/draft.test.ts 10/0, annotationDraftPersistence 13/0, typecheck pass, 1620 tests/0 fail, builds OK. --- packages/ui/hooks/useAnnotationDraft.ts | 109 ++++++++++++++++---- packages/ui/hooks/useCodeAnnotationDraft.ts | 26 ++--- 2 files changed, 99 insertions(+), 36 deletions(-) diff --git a/packages/ui/hooks/useAnnotationDraft.ts b/packages/ui/hooks/useAnnotationDraft.ts index e744d3693..8e8bcdbc4 100644 --- a/packages/ui/hooks/useAnnotationDraft.ts +++ b/packages/ui/hooks/useAnnotationDraft.ts @@ -22,6 +22,85 @@ 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(() => {}); + draftTransport.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. + draftTransport.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(() => {}); + draftTransport.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 - }); + draftTransport.remove(deletedGeneration, { keepalive: false }).catch(() => {}); }, []); return { draftBanner, restoreDraft, scheduleDraftSave, scheduleDraftSaveAfterSubmitFailure, getDraftGeneration, dismissDraft }; diff --git a/packages/ui/hooks/useCodeAnnotationDraft.ts b/packages/ui/hooks/useCodeAnnotationDraft.ts index 0297ef9f8..a4eeb17ff 100644 --- a/packages/ui/hooks/useCodeAnnotationDraft.ts +++ b/packages/ui/hooks/useCodeAnnotationDraft.ts @@ -7,6 +7,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import type { CodeAnnotation } from '../types'; +import { getDraftTransport } from './useAnnotationDraft'; const DEBOUNCE_MS = 500; @@ -72,17 +73,12 @@ export function useCodeAnnotationDraft({ useEffect(() => { if (!isApiMode) return; - fetch('/api/draft') - .then(async res => { - const data = await res.json().catch(() => null) as DraftData | MissingDraftData | null; - if (!res.ok) { - const generation = readDraftGeneration((data as MissingDraftData | null)?.draftGeneration); - if (generation !== null) { - draftGenerationRef.current = Math.max(draftGenerationRef.current, generation); - } - return null; + getDraftTransport().load() + .then(({ data, generation }) => { + if (generation !== null) { + draftGenerationRef.current = Math.max(draftGenerationRef.current, generation); } - return data; + return data as DraftData | null; }) .then((data: DraftData | null) => { const generation = readDraftGeneration(data?.draftGeneration); @@ -149,13 +145,7 @@ export function useCodeAnnotationDraft({ ts: Date.now(), }; - fetch('/api/draft', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }).catch(() => { - // Silent failure - }); + getDraftTransport().save(payload, { keepalive: false }).catch(() => {}); }, DEBOUNCE_MS); return () => { @@ -186,7 +176,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 }; From ba0e14e043213ae7dcb371d359b328bb54a270d2 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 09:18:47 -0700 Subject: [PATCH 17/46] feat(ui): make external-annotation transport host-overridable (Phase 5 seam 3) Add ExternalAnnotationTransport (subscribe/getSnapshot/CRUD) + setters in useExternalAnnotations.ts; default = today's SSE->polling wire moved verbatim into createDefaultTransport. The reducer (applyEvent), fallback-once gate, 500ms poll, versionRef scoping, optimistic-before-await, and [enabled] gate stay in the hook. A host (Workspaces) can implement the same event contract over Durable Objects. No override caller => Plannotator byte-unchanged. external-annotations test green, typecheck pass, 1620 tests/0 fail, builds OK. Logs Phase 5 completion. --- .../document-ui-phase-0-1-worklog-20260622.md | 20 ++ packages/ui/hooks/useExternalAnnotations.ts | 222 ++++++++++++------ 2 files changed, 166 insertions(+), 76 deletions(-) diff --git a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md index b2e9b3c46..c6d651e83 100644 --- a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md +++ b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md @@ -123,6 +123,26 @@ Confirmed transfer-as-is: SidebarContainer/SidebarTabs/CountBadge/FileBrowser/Ve ### Phase 4 status: COMPLETE — sidebar noop, file-tree seam landed. Plannotator byte-unchanged. +## Phase 5 — Comments / annotations / drafts (ADR 005) + +Researched (5-probe spike), specced, ADR-005-accepted, then teed up + multi-lens adversarially reviewed by the `phase5-comments` workflow (4 tee-ups + 3 worktree executes + 12 review lenses + synthesis; all 12 lenses returned safe). Landed + verified by hand on the real tree, lowest-risk first. The already-portable comment UI (panel, popover, toolbar, highlighter, exporters) confirmed noop. + +### Seam 1 — Identity provider (DONE) +- **File:** `packages/ui/utils/identity.ts`. Added `IdentityProvider` + `setIdentityProvider`/`resetIdentityProvider`; `getIdentity`/`isCurrentUser` delegate to a module-level provider defaulting to today's ConfigStore tater behavior. The ~9 author-stamp sites and 2 `(me)`-badge sites delegate with **zero call-site edits**. +- **Parity:** no override caller → tater nickname + `(me)` badge identical. typecheck pass, 1620/0, builds OK. (+46/-5, identity.ts only.) + +### Seam 2 — Draft transport (DONE) +- **Files:** `packages/ui/hooks/useAnnotationDraft.ts` (+ `useCodeAnnotationDraft.ts` reads `getDraftTransport()` live). Added `DraftTransport` (load/save/remove) + setters, default = today's `/api/draft` fetches verbatim; `save` rejects-on-failure so the **keepalive retry-gate stays in the hook**. The generation pre-increment, 500ms debounce, and pagehide/visibilitychange flush stay verbatim; `getDraftGeneration()` still escapes to the host. +- **Landing note:** the workflow diff carried one phantom hunk (a delete-on-clear branch the real code-draft hook never had — it early-returns on empty). `patch` correctly rejected it; the real tree is correct without it. Caught by landing-on-real-tree verification. +- **Parity:** no override caller; App.tsx + `shared/draft.ts` untouched. `shared/draft.test.ts` 10/0, `annotationDraftPersistence` 13/0 (incl. pagehide-flush parity), typecheck pass, 1620/0, builds OK. + +### Seam 3 — External-annotation transport (DONE, riskiest) +- **File:** `packages/ui/hooks/useExternalAnnotations.ts`. Added `ExternalAnnotationTransport` (`subscribe`/`getSnapshot`/CRUD) + setters; default = the SSE→polling wire moved verbatim into `createDefaultTransport`. The reducer (`applyEvent`, byte-identical cases), fallback-once gate, 500ms poll, version-scoping, optimistic-before-await, and the `[enabled]` gate **stay in the hook**. Two micro-divergences (parse-then-cancelled-check; snapshot `[]`/`0` defaults) are provably unreachable for Plannotator (server always returns well-formed `{annotations, version}`; parse-then-discard is side-effect-free). +- **Parity:** no override caller; App.tsx untouched (both apps still call `useExternalAnnotations({enabled})`). external-annotation test green, typecheck pass, 1620/0, builds OK. + +### Phase 5 status: COMPLETE (pending eyeball) — 3 seams landed, comment UI noop. Plannotator byte-unchanged. +Renderer-coupling contract (Workspaces must reuse BlockRenderer+InlineMarkdown+inlineTransforms for highlights) and replies/threading deferral recorded in ADR 005. Remaining: manual eyeball — author/`(me)`, draft save+restore+no-ghost, live SSE add + kill-stream→polling-takes-over. + ### Discovered (PRE-EXISTING, out of scope — not caused by this work) 1. **Edit/save header state leaks across file switches** in annotate-folder mode: editing+saving file A leaves the Saved/Done/wide-focus header showing when you switch to file B without editing it. Reproduced on the **baseline with the Phase 4 change reverted** (A/B confirmed) → pre-existing App.tsx bug, not a regression. Lives in the folder file-switch handler (`handleFileBrowserSelect` / edit-session reset), unrelated to `useFileBrowser`. Worth a separate fix. 2. **Annotating the repo root (`annotate ./`) bogs down** — the file walker + chokidar SSE watcher choke on 1.4GB of node_modules (16 dirs); the code already warns about this. Pre-existing scaling limit; use a bounded folder. Not a code defect introduced here. diff --git a/packages/ui/hooks/useExternalAnnotations.ts b/packages/ui/hooks/useExternalAnnotations.ts index 467158a12..8f586caec 100644 --- a/packages/ui/hooks/useExternalAnnotations.ts +++ b/packages/ui/hooks/useExternalAnnotations.ts @@ -19,6 +19,98 @@ const POLL_INTERVAL_MS = 500; const STREAM_URL = '/api/external-annotations/stream'; const SNAPSHOT_URL = '/api/external-annotations'; +/** + * Wire transport for external annotations. The hook owns the state machine + * (reducer, fallback-once SSE→polling, version-scoping, optimistic mutation, + * enabled gate); the transport owns ONLY the network/event wire. + * + * Default = Plannotator's SSE→polling behavior, verbatim. A host (Workspaces) + * can implement the same contract over its own backend (e.g. Durable Objects). + */ +export interface ExternalAnnotationTransport { + /** Open the live event stream. Returns an unsubscribe fn that tears it down. */ + subscribe( + onEvent: (event: ExternalAnnotationEvent) => void, + onError: () => void, + ): () => void; + /** Fetch a version-gated snapshot. Resolves null when there are no changes (304). */ + getSnapshot(since: number): Promise<{ annotations: T[]; version: number } | null>; + add(items: T[]): Promise; + remove(id: string): Promise; + update(id: string, fields: Partial): Promise; + clear(source?: string): Promise; +} + +/** + * Default transport — Plannotator's verbatim SSE→polling wire. + * EventSource on /api/external-annotations/stream; GET snapshot honoring 304→null; + * CRUD via DELETE/PATCH fetches (optimistic local mutation stays in the hook). + */ +function createDefaultTransport(): ExternalAnnotationTransport { + return { + subscribe(onEvent, onError) { + const es = new EventSource(STREAM_URL); + es.onmessage = (event) => { + try { + const parsed: ExternalAnnotationEvent = JSON.parse(event.data); + onEvent(parsed); + } catch { + // Ignore malformed events (e.g., heartbeat comments) + } + }; + es.onerror = () => { + onError(); + }; + return () => es.close(); + }, + async getSnapshot(since) { + const url = since > 0 ? `${SNAPSHOT_URL}?since=${since}` : SNAPSHOT_URL; + const res = await fetch(url); + if (res.status === 304) return null; // No changes + if (!res.ok) return null; + const data = await res.json(); + const annotations = Array.isArray(data.annotations) ? data.annotations : []; + const version = typeof data.version === 'number' ? data.version : 0; + return { annotations, version }; + }, + async add(items) { + await fetch(SNAPSHOT_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ annotations: items }), + }); + }, + async remove(id) { + await fetch(`${SNAPSHOT_URL}?id=${encodeURIComponent(id)}`, { method: 'DELETE' }); + }, + async update(id, fields) { + await fetch(`${SNAPSHOT_URL}?id=${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fields), + }); + }, + async clear(source) { + const qs = source ? `?source=${encodeURIComponent(source)}` : ''; + await fetch(`${SNAPSHOT_URL}${qs}`, { method: 'DELETE' }); + }, + }; +} + +let externalAnnotationTransport: ExternalAnnotationTransport = createDefaultTransport(); + +/** Override the external-annotation wire transport. Call once at app startup. */ +export function setExternalAnnotationTransport( + transport: ExternalAnnotationTransport, +): void { + externalAnnotationTransport = transport; +} + +/** Reset to the default (Plannotator SSE→polling) transport. Mainly for tests. */ +export function resetExternalAnnotationTransport(): void { + externalAnnotationTransport = createDefaultTransport(); +} + interface UseExternalAnnotationsReturn { externalAnnotations: T[]; updateExternalAnnotation: (id: string, updates: Partial) => void; @@ -40,55 +132,54 @@ export function useExternalAnnotations { - if (cancelled) return; - - 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) + const transport = externalAnnotationTransport as ExternalAnnotationTransport; + + // --- 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 --- + const 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 +196,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 +207,7 @@ export function useExternalAnnotations { cancelled = true; - es.close(); + unsubscribe(); if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; @@ -141,10 +219,7 @@ export function useExternalAnnotations prev.filter((a) => a.id !== id)); try { - await fetch( - `${SNAPSHOT_URL}?id=${encodeURIComponent(id)}`, - { method: 'DELETE' }, - ); + await (externalAnnotationTransport as ExternalAnnotationTransport).remove(id); } catch { // SSE will reconcile on next event } @@ -156,8 +231,7 @@ export function useExternalAnnotations a.source !== source) : [], ); try { - const qs = source ? `?source=${encodeURIComponent(source)}` : ''; - await fetch(`${SNAPSHOT_URL}${qs}`, { method: 'DELETE' }); + await (externalAnnotationTransport as ExternalAnnotationTransport).clear(source); } catch { // SSE will reconcile on next event } @@ -166,11 +240,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 (externalAnnotationTransport as ExternalAnnotationTransport).update(id, updates); } catch { // SSE will reconcile on next event } From fe39cccc612d3879f02fe6deefc134f435e24ca9 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 10:10:30 -0700 Subject: [PATCH 18/46] docs(adr): research + synthesis + spec for Phase 6 (versions, settings, sharing, AI) Five-probe code research. Most of the four subsystems is already portable; the real work is 5 seams (version fetchers + vscode-diff, config write-back, obsidian detect, save-to-notes, AI transport) + 1 CSS move (block/raw diff classes from the app shell into the package's theme.css). Fragile do-not-touch: the AI SSE reader loop + epoch guards, and configStore debounce/deepMerge. Five Plannotator-only pieces (OpenInApp, HooksTab, useUpdateCheck, useAgents/useAgentJobs) stay home. --- ...cument-ui-extras-system-20260623-100827.md | 56 +++++++++++++++++++ ...esis-document-ui-extras-20260623-100827.md | 47 ++++++++++++++++ ...document-ui-extras-seam-20260623-100827.md | 53 ++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 adr/research/SPIKE-document-ui-extras-system-20260623-100827.md create mode 100644 adr/research/synthesis-document-ui-extras-20260623-100827.md create mode 100644 adr/specs/document-ui-extras-seam-20260623-100827.md diff --git a/adr/research/SPIKE-document-ui-extras-system-20260623-100827.md b/adr/research/SPIKE-document-ui-extras-system-20260623-100827.md new file mode 100644 index 000000000..6db4c302d --- /dev/null +++ b/adr/research/SPIKE-document-ui-extras-system-20260623-100827.md @@ -0,0 +1,56 @@ +# Spike: Phase 6 Extras — Versions/Diff, Settings, Sharing/Export, Ask AI + +Date: 2026-06-23 + +> Code research for Phase 6 of the `@plannotator/ui` reuse effort (ADR 004; roadmap `adr/implementation/document-ui-extraction-roadmap-20260622.md`). Five parallel probes mapped the four extra subsystems. THE LAW: move + decouple, never rewrite; Plannotator's experience cannot change. + +## Headline + +Phase 6 is **mostly already portable** — the same pattern as the sidebar/comment-UI noops. The actual work is a small set of seams plus **one CSS wrinkle** (block-level diff styles live in the app shell, not the package). The fragile pieces are narrow: the AI streaming reader loop (do-not-touch) and the configStore write-back batching (keep verbatim). + +## Subsystem 1 — Versions / plan diff + +**Files:** `packages/ui/hooks/usePlanDiff.ts`, `utils/planDiffEngine.ts`, `components/plan-diff/*`. CSS: `packages/editor/index.css` + `packages/ui/theme.css`. + +- **Seam A — version fetchers:** `usePlanDiff` hard-codes `fetch('/api/plan/version?v=N')` (L98) and `fetch('/api/plan/versions')` (L119). Inject optional fetchers, default = today's literals. **Keep error asymmetry verbatim:** `selectBaseVersion` `alert()`s on failure (L100, L107); `fetchVersions` is silent (L127-131). +- **Seam B — VS Code diff:** `PlanDiffViewer.tsx` POSTs `/api/plan/vscode-diff` (L65). Optional `onOpenVscodeDiff?` prop; default = today's fetch (or omit the button when not provided). +- **Already portable:** `planDiffEngine.ts` (pure `computePlanDiff`/`computeInlineDiff`), `PlanDiffBadge`, `PlanCleanDiffView`, `PlanRawDiffView`, `PlanDiffModeSwitcher` — all prop-driven. +- **THE CSS WRINKLE (confirmed):** the *word-level* classes `.plan-diff-word-*` live in the package (`theme.css` L451-480), but the *block-level* and *raw* diff classes — `.plan-diff-added/removed/modified/unchanged` (`editor/index.css` L168-211) and `.plan-diff-line-added/removed` (L213-230) — live in the **app shell**, not the package. Also `.annotation-highlight*` (L119-157) lives in the app shell (used by the regular Viewer too, so this is broader than diff). Without these, a host's diff renders unstyled (no borders/backgrounds → unreadable). Fix: move the `.plan-diff-*` block/raw classes into `packages/ui/theme.css` (co-located with `.plan-diff-word-*`); Plannotator imports `theme.css` so it stays identical. `.annotation-highlight` is a broader CSS-contract item (Viewer needs it in any host). + +## Subsystem 2 — Settings / config + +**Files:** `packages/ui/config/configStore.ts`, `config/settings.ts`, `components/Settings.tsx`, `components/settings/HooksTab.tsx`. + +- **Seam A — config write-back:** `configStore.scheduleServerSync` POSTs `/api/config` (L118) after a 300ms debounce with `deepMerge` batching. Inject **only the final fetch** via `setServerSync(fn)`; **keep singleton construction, eager cookie reads (constructor L44-59), the 300ms debounce, and `deepMerge` byte-identical** — a naive per-`set()` fetch breaks multi-setting batching. +- **Seam B — obsidian vault detect:** `Settings.tsx` `fetch('/api/obsidian/vaults')` (L745-760). Optional `onDetectObsidianVaults?`; **keep the `useEffect [obsidian.enabled]` dep and the auto-select-first-vault branch verbatim** (changing the dep re-triggers or kills auto-select). +- **Already host-controllable:** cookie storage is swappable (Phase 2 `setStorageBackend`, literal `plannotator-*` keys); identity is swappable (Phase 5 `setIdentityProvider`); `settings.ts` is pure (no fetch); server identity seeded via `configStore.init(serverConfig)`. +- **PLANNOTATOR-ONLY (out of scope):** `HooksTab.tsx` (`/api/hooks/status`, `/api/config` pfmReminder) — mounted only `mode==='plan'`, never exported to hosts. + +## Subsystem 3 — Sharing / export / notes + +**Files:** `components/ExportModal.tsx`, `utils/sharing.ts`, `hooks/useSharing.ts`, `components/ImportModal.tsx`, `components/OpenInAppButton.tsx`, `utils/{obsidian,bear,octarine,callback,defaultNotesApp}.ts`. + +- **Seam — save to notes:** `ExportModal.tsx` `fetch('/api/save-notes')` (L150). Optional `onSaveToNotes?` returning `{success, error}`; **keep `showNotesTab = isApiMode && !!markdown` (L83) byte-for-byte** — do not re-base the gate on the new prop. +- **Already portable:** `sharing.ts` is fully parameterized (`shareBaseUrl`/`pasteApiUrl` params, defaults to Plannotator URLs); `useSharing` is prop-driven; `ImportModal` is callback-driven (`onImport`); `obsidian/bear/octarine/callback/defaultNotesApp` are pure storage/format helpers. The `/p/` short-URL routing in `useSharing` is Plannotator's convention but is `pasteApiUrl`-injectable. +- **PLANNOTATOR-ONLY (out of scope):** `OpenInAppButton.tsx` (`/api/open-in`, `/api/open-in/apps`, local-CLI file opening) — host-only, stub/omit for other hosts. + +## Subsystem 4 — Ask AI (riskiest) + +**Files:** `packages/ui/hooks/useAIChat.ts`, `components/ai/*`, `utils/aiProvider.ts`, `utils/aiChatFormat.ts`. + +- **Seam — AI transport:** five `/api/ai/*` fetches in `useAIChat`: `/api/ai/session` (L134), `/api/ai/query` (L213), `/api/ai/abort` (L153 supersede + L350 standalone), `/api/ai/permission` (L365). Inject an `AITransport` (session/query/abort/permission), default = today's fetches. +- **DO NOT TOUCH (verbatim, stays in hook):** the **SSE reader loop** (L233-304 — buffers partial lines, dispatches `text_delta|text|permission_request|error|result`, mutates React state per message); the **epoch/createRequest guards** (refs L109-110; checks L152, L208; resets L376-390); the **supersede-abort fetch position** (L153-158 — must stay inside `createSession` immediately after the epoch check, or the orphaned session leaks). Only the *transport* (the fetch calls) is parametrized; the streaming consumption is not. +- **HOST-OWNED (stays in App.tsx, not the lib):** `/api/ai/capabilities` (only called by App.tsx — editor L2261, review L499); `resolveAIProviderSelection` + cookie `aiConfig` init (`aiProvider.ts`, read by App.tsx). The hook never reads cookies or calls capabilities. +- **Already portable:** `DocumentAIChatPanel`, `AIProviderBar` (fully prop-driven); `aiProvider.ts`, `aiChatFormat.ts` (pure). +- **Existing reuse:** `review-editor` already reuses `useAIChat` via a thin patch-wrapper (`review-editor/hooks/useAIChat.ts` → `context: {mode:'code-review', review:{patch}}`). + +## Explicitly OUT of Phase 6 scope (Plannotator-only / different feature) +- `OpenInAppButton` (local CLI), `HooksTab` (plan-mode hooks), `useUpdateCheck` (hardcoded github.com/backnotprop release check — no seam), `useAgents`/`useAgentJobs` (code-review agent jobs — a review-editor feature, not document-UI). These stay host-owned; a reusing host simply doesn't mount them. + +## Per-seam summary +| Subsystem | Seam | Wire | Verbatim-keep | +|---|---|---|---| +| Versions | usePlanDiff fetchers + PlanDiffViewer vscode | `/api/plan/version(s)`, `/api/plan/vscode-diff` | alert/silent error asymmetry; + move block/raw diff CSS into the package | +| Settings | configStore `setServerSync`; Settings obsidian-detect | `/api/config`, `/api/obsidian/vaults` | 300ms debounce + deepMerge + constructor; `[obsidian.enabled]` dep + auto-select | +| Sharing | ExportModal `onSaveToNotes` | `/api/save-notes` | `showNotesTab` gate (L83) | +| Ask AI | useAIChat `AITransport` | 5× `/api/ai/*` | SSE reader loop, epoch guards, supersede-abort position; capabilities/provider-resolution stay host | diff --git a/adr/research/synthesis-document-ui-extras-20260623-100827.md b/adr/research/synthesis-document-ui-extras-20260623-100827.md new file mode 100644 index 000000000..6e3454684 --- /dev/null +++ b/adr/research/synthesis-document-ui-extras-20260623-100827.md @@ -0,0 +1,47 @@ +# Synthesis: Phase 6 Extras + +Date: 2026-06-23 + +> Synthesizes `SPIKE-document-ui-extras-system-20260623-100827.md` against the verified plan and ADR 004. Settles Phase 6's shape. + +## The shape + +Phase 6 follows the now-proven pattern: a handful of module-level/prop seams, each defaulting to today's behavior. The research confirms most of these four subsystems is **already portable** (pure utils, prop-driven components, parameterized sharing). The real work is **five seams + one CSS move**, and a clear list of **Plannotator-only pieces that simply stay home**. + +## What we will do + +### 1. Versions / diff (do first — highest value, has the CSS wrinkle) +- Inject optional version fetchers into `usePlanDiff` (default → `/api/plan/version(s)`), keeping the alert/silent error asymmetry verbatim. +- Optional `onOpenVscodeDiff?` on `PlanDiffViewer` (default → `/api/plan/vscode-diff`). +- **CSS move:** relocate the block-level/raw diff classes (`.plan-diff-added/removed/modified/unchanged`, `.plan-diff-line-*`) from `packages/editor/index.css` into `packages/ui/theme.css`, co-located with the existing `.plan-diff-word-*`. Plannotator imports `theme.css`, so it stays byte-identical; the diff components become reusable without app-shell CSS. (Verify Plannotator's build doesn't double-define / drops the index.css copies.) + +### 2. Settings / config +- `configStore.setServerSync(fn)` injecting only the final `/api/config` POST; keep singleton, eager cookie reads, 300ms debounce, and `deepMerge` verbatim. +- Optional `onDetectObsidianVaults?` on `Settings`; keep the `[obsidian.enabled]` effect dep and auto-select-first-vault verbatim. +- (Storage + identity already swappable; `settings.ts` pure — nothing to do there.) + +### 3. Sharing / export / notes +- Optional `onSaveToNotes?` on `ExportModal`; keep `showNotesTab = isApiMode && !!markdown` verbatim. +- (Sharing utils + `useSharing` + `ImportModal` + the notes-app helpers are already portable — confirm noop.) + +### 4. Ask AI (last, riskiest) +- Inject an `AITransport` (session/query/abort/permission) into `useAIChat`, default = today's five fetches. **Leave the SSE reader loop, epoch/createRequest guards, and the supersede-abort position untouched in the hook.** Capabilities fetch and provider resolution stay host-owned (they already live in App.tsx). + +## What we will NOT do (Plannotator-only — they stay home) +`OpenInAppButton` (local CLI), `HooksTab` (plan-mode hooks), `useUpdateCheck` (hardcoded github release check), `useAgents`/`useAgentJobs` (code-review agent jobs). A reusing host doesn't import them. No work. + +## Risk read & ordering +- **Versions/diff** — low logic risk; the CSS move is the only non-trivial bit (verify Plannotator's diff still renders identically after relocating the classes). Do first. +- **Settings** — low; mind the debounce/deepMerge (don't move them) and the obsidian effect dep. +- **Sharing** — small (one seam); rest is noop. +- **Ask AI** — the riskiest by far: a streaming state machine. Treat exactly like the Phase-5 external transport — wrap only the wire, copy nothing, leave the reader loop and epoch guards verbatim. Do last, eyeball the AI panel end-to-end. + +## Open decisions for the spec/ADR +1. **CSS move vs. document-as-contract.** Recommend **move** (`.plan-diff-*` into `theme.css`) — it's a pure relocation that keeps Plannotator identical and makes the diff truly reusable. The broader `.annotation-highlight` CSS (used by the Viewer everywhere) is a related contract; recommend moving it into `theme.css` too in the same pass, since it's required by the already-shipped Viewer in any host (closes a latent gap from Phases 3/5). Confirm in spec. +2. **Scope confirmation:** agree the five Plannotator-only pieces are out (recommended). +3. **One PR or per-seam commits:** recommend per-seam verify-gated commits (versions → settings → sharing → AI), like Phase 5. + +## References +- Spike: `adr/research/SPIKE-document-ui-extras-system-20260623-100827.md` +- Verified plan (Phase 6 / steps 6-8,12): `adr/specs/document-ui-extraction-plan-verified-20260622-184500.md` +- Decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md` diff --git a/adr/specs/document-ui-extras-seam-20260623-100827.md b/adr/specs/document-ui-extras-seam-20260623-100827.md new file mode 100644 index 000000000..cea8c1848 --- /dev/null +++ b/adr/specs/document-ui-extras-seam-20260623-100827.md @@ -0,0 +1,53 @@ +# Spec: Phase 6 Extras Seams + +Date: 2026-06-23 · Status: Draft (iterate before implementing) + +> Implementation spec for Phase 6 of the `@plannotator/ui` reuse effort. Grounded in `SPIKE-document-ui-extras-system-20260623-100827.md` + its synthesis. Governed by ADR 004. THE LAW: each seam defaults to today's literal behavior; Plannotator passes nothing and is byte-for-byte unchanged. Move + decouple, never rewrite — especially the AI reader loop and the configStore batching. + +## Scope +**In (5 seams + 1 CSS move):** version fetchers, vscode-diff action, the block/raw diff CSS relocation, config write-back, obsidian-detect, save-to-notes, AI transport. +**Confirmed noop (already portable):** planDiffEngine + all plan-diff render components, sharing.ts/useSharing/ImportModal, obsidian/bear/octarine/callback/defaultNotesApp utils, settings.ts, DocumentAIChatPanel/AIProviderBar, aiProvider/aiChatFormat. +**Out (Plannotator-only, stay home):** OpenInAppButton, HooksTab, useUpdateCheck, useAgents, useAgentJobs. + +## Order of work (lowest risk first; AI last) + +### Step 1 — Versions / diff +**Files:** `packages/ui/hooks/usePlanDiff.ts`, `packages/ui/components/plan-diff/PlanDiffViewer.tsx`, `packages/editor/index.css` → `packages/ui/theme.css`. +- **1a. Version fetchers.** Add an optional `fetchers?: { fetchVersion?, fetchVersions? }` arg to `usePlanDiff` (or module-level setters following the seam pattern). Default = today's `fetch('/api/plan/version?v=N')` and `fetch('/api/plan/versions')` verbatim. **Keep error asymmetry:** `selectBaseVersion` still `alert()`s on failure; `fetchVersions` still silent. +- **1b. VS Code diff.** Add optional `onOpenVscodeDiff?: (baseVersion: number) => Promise<{ ok?: boolean; error?: string }>` to `PlanDiffViewer`; default = today's `fetch('/api/plan/vscode-diff')`. Plannotator passes nothing → unchanged (button still works). +- **1c. CSS move.** Cut `.plan-diff-added/removed/modified/unchanged` and `.plan-diff-line-added/removed` from `editor/index.css` (L168-230) into `packages/ui/theme.css` (next to `.plan-diff-word-*`). Also move `.annotation-highlight*` (L119-157) into `theme.css` (it's required by the shared Viewer in any host — closes a latent gap). Plannotator imports `theme.css`, so it stays identical; verify no double-definition remains in `index.css`. +- **Parity guardrail:** Plannotator's plan diff renders pixel-identical (block borders, raw +/-, word-level, annotation highlights); no caller passes fetchers/onOpenVscodeDiff. Eyeball: deny→resubmit a plan, toggle diff (clean/classic/raw), annotate a diff block, VS Code button still works. + +### Step 2 — Settings / config +**Files:** `packages/ui/config/configStore.ts`, `packages/ui/components/Settings.tsx`. +- **2a. Config write-back.** Add `setServerSync(fn)` (and a default = the current inline `fetch('/api/config', POST)`); `scheduleServerSync` calls the injected fn for the final POST only. **Keep the 300ms debounce, `pendingServerWrites` deepMerge batching, singleton construction, and eager cookie reads byte-identical.** +- **2b. Obsidian detect.** Add optional `onDetectObsidianVaults?: () => Promise` to `Settings`; default = today's `fetch('/api/obsidian/vaults')`. **Keep the `useEffect` dep `[obsidian.enabled]` and the auto-select-first-vault branch verbatim.** +- **Parity guardrail:** settings still POST `/api/config` with identical batching/timing; vault auto-select still fires on enable. No caller overrides. Eyeball: change a setting → it persists; enable Obsidian → vaults detected + first auto-selected. + +### Step 3 — Sharing / export / notes +**Files:** `packages/ui/components/ExportModal.tsx`. +- Add optional `onSaveToNotes?: (payload) => Promise<{ results?: Record }>` (match today's response shape); default = today's `fetch('/api/save-notes')`. **Keep `showNotesTab = isApiMode && !!markdown` (L83) byte-for-byte** — do not re-base on the new prop. +- (sharing.ts/useSharing/ImportModal/notes-app utils confirmed noop.) +- **Parity guardrail:** notes tab visibility unchanged; save returns identical `{success, error}`. Eyeball: Export → Notes tab shows when expected → save to Obsidian works. + +### Step 4 — Ask AI (riskiest, last) +**Files:** `packages/ui/hooks/useAIChat.ts`. +- Add an `AITransport` (session/query/abort/permission) + module-level default reproducing today's five `/api/ai/*` fetches verbatim. Route the fetch calls through it. +- **DO NOT TOUCH:** the SSE reader loop (L233-304), the epoch/createRequest guards (refs L109-110; checks L152/L208; resets L376-390), and the supersede-abort fetch **inside `createSession` immediately after the epoch check** (L153-158). Only the wire is parametrized. +- **Stays host-owned:** `/api/ai/capabilities` and `resolveAIProviderSelection`/cookie `aiConfig` (already in App.tsx — do not pull into the lib). +- **Parity guardrail:** identical AI traffic; streaming, permissions, abort, and session-supersede all behave as today. No caller overrides. Eyeball: ask the AI a question (streams), trigger a permission, switch questions mid-stream (supersede), abort. + +## Definition of done (Phase 6) +- The five seams are host-overridable, each defaulting to today's behavior; the diff CSS lives in the package. +- Plannotator byte-unchanged: full `bun test` ≥ baseline (1620/0); typecheck; builds; `App.tsx` changes minimal/empty (ideally zero — module-level/optional-prop defaults). +- Eyeball: plan diff (all modes + vscode + annotate), settings persist + obsidian detect, save-to-notes, AI chat (stream/permission/abort/supersede). +- The five Plannotator-only pieces remain host-owned (untouched). + +## Per-step parity guardrail (run after each) +`bun run typecheck` · `bun test` ≥ 1620/0 (+ touched suite green) · `bun run --cwd apps/review build && bun run build:hook` · `git diff packages/editor/App.tsx` minimal/empty · the step's manual eyeball. + +## Open questions (resolve in ADR) +1. CSS: move `.plan-diff-*` + `.annotation-highlight` into `theme.css` (recommended) vs. document-as-contract. +2. Confirm the five Plannotator-only exclusions (recommended). +3. Per-seam commits (recommended) vs. one PR. +4. usePlanDiff seam shape: extra hook arg vs. module-level setter (lean: optional arg for the fetchers since usePlanDiff already takes args; module-level setters elsewhere). From 8ddd951e37c12ee3c010d366c53e61395842f0ba Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 10:21:42 -0700 Subject: [PATCH 19/46] =?UTF-8?q?docs(adr):=20accept=20ADR=20006=20?= =?UTF-8?q?=E2=80=94=20make=20extras=20(versions/settings/sharing/AI)=20ho?= =?UTF-8?q?st-overridable=20(Phase=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five seams + one CSS move, each defaulting to today's behavior. AI reader loop + epoch guards and configStore debounce/deepMerge stay verbatim. Five Plannotator- only pieces stay home. Locks the recommended choices from the Phase 6 spec. --- ...extras-host-overridable-20260623-102104.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 adr/decisions/006-make-extras-host-overridable-20260623-102104.md diff --git a/adr/decisions/006-make-extras-host-overridable-20260623-102104.md b/adr/decisions/006-make-extras-host-overridable-20260623-102104.md new file mode 100644 index 000000000..2f9637ec1 --- /dev/null +++ b/adr/decisions/006-make-extras-host-overridable-20260623-102104.md @@ -0,0 +1,42 @@ +# 006. Make Extras (Versions, Settings, Sharing, Ask AI) Host-Overridable (Phase 6) + +Date: 2026-06-23 + +## Status + +Accepted + +## Context + +ADR 004 set the plan: make `@plannotator/ui` reusable by Workspaces by lifting each Plannotator wire up to an optional override defaulting to today's behavior, never changing Plannotator. Phases 0–5 did this for packaging, image/storage, rendering, file tree, and comments. Phase 6 is the remaining "extras": versions/plan diff, settings/config, sharing/export/notes, and Ask AI. + +Five-research probes (`adr/research/SPIKE-document-ui-extras-system-20260623-100827.md`, synthesized in `adr/research/synthesis-document-ui-extras-20260623-100827.md`) found these four subsystems are **mostly already portable** — `planDiffEngine` and all plan-diff render components, the sharing utils/`useSharing`/`ImportModal`, the notes-app helpers, `settings.ts`, the AI chat components, and `aiProvider`/`aiChatFormat` are pure or prop-driven. The actual coupling is a small set of wires, plus one CSS wrinkle: the block-level/raw plan-diff CSS lives in the app shell (`packages/editor/index.css`), not the package. + +## Decision + +Make the extras host-overridable through **five seams plus one CSS move**, each defaulting to today's behavior; Plannotator passes nothing and stays byte-for-byte unchanged. Land per-seam, verify-gated, lowest-risk first. + +1. **Versions / diff.** Optional version fetchers on `usePlanDiff` (default → `/api/plan/version(s)`, keeping the `selectBaseVersion` alert vs `fetchVersions` silent error asymmetry verbatim) and an optional `onOpenVscodeDiff?` on `PlanDiffViewer` (default → `/api/plan/vscode-diff`). **CSS move:** relocate the block-level/raw diff classes (`.plan-diff-added/removed/modified/unchanged`, `.plan-diff-line-*`) and `.annotation-highlight*` from `editor/index.css` into `packages/ui/theme.css` (co-located with `.plan-diff-word-*`); Plannotator imports `theme.css`, so its diff and highlights render identically while the components become reusable without app-shell CSS. + +2. **Settings / config.** `configStore.setServerSync(fn)` injecting only the final `/api/config` POST, keeping the singleton construction, eager cookie reads, 300ms debounce, and `deepMerge` batching byte-identical. Optional `onDetectObsidianVaults?` on `Settings`, keeping the `[obsidian.enabled]` effect dep and auto-select-first-vault verbatim. + +3. **Sharing / notes.** Optional `onSaveToNotes?` on `ExportModal` (matching today's `{results:{success,error}}` shape), keeping `showNotesTab = isApiMode && !!markdown` byte-for-byte. (Sharing utils, `useSharing`, `ImportModal`, and the notes-app helpers are confirmed noop.) + +4. **Ask AI (last, riskiest).** Inject an `AITransport` (session/query/abort/permission) into `useAIChat`, default = today's five `/api/ai/*` fetches. **The SSE reader loop, the epoch/createRequest guards, and the supersede-abort fetch position inside `createSession` stay untouched in the hook** — only the wire is parametrized. Capabilities fetch and provider resolution stay host-owned (already in App.tsx). + +**Out of Phase 6 (Plannotator-only — they stay home, no work):** `OpenInAppButton` (local CLI), `HooksTab` (plan-mode hooks), `useUpdateCheck` (hardcoded github release check), `useAgents` and `useAgentJobs` (code-review agent jobs). A reusing host simply does not import them. + +## Consequences + +- Workspaces can optionally reuse version-diff review, the settings panel, save-to-notes, and the AI chat by implementing the corresponding fetchers/callbacks/transport — but none of it is required for the already-shipped core (docs, tree, editing, comments). +- Plannotator is unchanged: every seam defaults to today's literal behavior; the AI streaming state machine and configStore batching move nowhere; the CSS relocation is a pure cut-and-paste that Plannotator still imports via `theme.css`. +- The diff components (and the Viewer's annotation highlights) become self-styling from the package — closing a latent CSS-contract gap from earlier phases. +- The parity bar per seam: full `bun test` stays at baseline (1620/0), typecheck and builds pass, `packages/editor/App.tsx` changes stay minimal/empty, and an eyeball confirms the surface — plan diff (all modes + VS Code + diff annotations), settings persistence + obsidian detect, save-to-notes, and AI chat (stream / permission / abort / mid-stream supersede). +- After Phase 6, the document UI is feature-complete for reuse; the remaining work is Phase 7 (publish) and the parked `@plannotator/ai` / `@plannotator/shared` publish-vs-inline decision. + +## References + +- Spike: `adr/research/SPIKE-document-ui-extras-system-20260623-100827.md` +- Synthesis: `adr/research/synthesis-document-ui-extras-20260623-100827.md` +- Spec: `adr/specs/document-ui-extras-seam-20260623-100827.md` +- Governing decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md` From 47e28314daa17c7f5ed7f5245fb80d4d6023e9f2 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 10:36:58 -0700 Subject: [PATCH 20/46] feat(ui): make version fetchers + vscode-diff host-overridable; move diff CSS into package (Phase 6 versions) usePlanDiff gains optional fetchers (default /api/plan/version(s), error asymmetry kept: selectBaseVersion alerts, fetchVersions silent). PlanDiffViewer gains optional onOpenVscodeDiff (default /api/plan/vscode-diff). Relocate .annotation-highlight* + .plan-diff-* block/raw CSS from editor/index.css into ui/theme.css (next to .plan-diff-word-*) so the diff/highlights are self-styling from the package. Verified: relocated CSS gone from index.css, present in shipped bundle (33x), diff renders identical; typecheck pass, 1620 tests/0 fail, builds OK, App.tsx untouched. --- packages/editor/index.css | 114 ------------------ .../components/plan-diff/PlanDiffViewer.tsx | 41 ++++--- packages/ui/hooks/usePlanDiff.ts | 61 +++++++--- packages/ui/theme.css | 114 ++++++++++++++++++ 4 files changed, 185 insertions(+), 145 deletions(-) diff --git a/packages/editor/index.css b/packages/editor/index.css index b2e900b23..95d7b544d 100644 --- a/packages/editor/index.css +++ b/packages/editor/index.css @@ -115,120 +115,6 @@ pre code.hljs .hljs-code { color: oklch(0.45 0.20 280) !important; } -/* Annotation highlights */ -.annotation-highlight { - border-radius: 2px; - padding: 0 2px; - margin: 0 -2px; -} - -.annotation-highlight.deletion { - background: oklch(from var(--destructive) l c h / 0.35); - text-decoration: line-through; - text-decoration-color: var(--destructive); - text-decoration-thickness: 2px; -} - -.annotation-highlight.comment { - background: oklch(0.70 0.18 60 / 0.3); - border-bottom: 2px solid var(--accent); -} - -/* Light mode: softer highlights */ -.light .annotation-highlight.deletion { - background: oklch(0.65 0.22 25 / 0.2); -} - -.light .annotation-highlight.comment { - background: oklch(0.70 0.20 60 / 0.15); -} - -.annotation-highlight.focused { - background: oklch(from var(--focus-highlight) l c h / 0.45) !important; - box-shadow: 0 0 8px oklch(from var(--focus-highlight) l c h / 0.4); - border-bottom: 2px solid var(--focus-highlight); - filter: none; -} - -.light .annotation-highlight.focused { - background: oklch(0.70 0.22 200 / 0.3) !important; - box-shadow: 0 0 6px oklch(0.60 0.20 200 / 0.3); -} - -.annotation-highlight:hover { - filter: brightness(1.2); - cursor: pointer; -} - -/* ======================================== - Plan Diff Styles - ======================================== */ - -/* Clean diff view - added content */ -.plan-diff-added { - border-left: 3px solid var(--success); - background: oklch(from var(--success) l c h / 0.06); - padding-left: 0.75rem; - border-radius: 0 0.25rem 0.25rem 0; - margin: 0.25rem 0; -} -.light .plan-diff-added { - background: oklch(from var(--success) l c h / 0.06); -} - -/* Clean diff view - removed content */ -.plan-diff-removed { - border-left: 3px solid var(--destructive); - background: oklch(from var(--destructive) l c h / 0.06); - padding-left: 0.75rem; - border-radius: 0 0.25rem 0.25rem 0; - margin: 0.25rem 0; -} -.light .plan-diff-removed { - background: oklch(from var(--destructive) l c h / 0.06); -} - -/* Clean diff view - modified content (mix of additions and deletions in one - block, rendered inline via word-level diff). - Deliberate asymmetry with added/removed: add/remove are BLOCK-scope events - — the whole block matters, so a loud fill is the right signal. Modify is - a WORD-scope event — the words matter, and the inline red-struck / - green-highlighted word markers already grab attention. A block-level fill - would compete with that inline work; an amber gutter on a normal - background says "look inside, the change is in the text" while staying - consistent with the green/red/yellow diff convention. */ -.plan-diff-modified { - border-left: 3px solid oklch(from var(--warning) l c h / 0.75); - background: transparent; - padding-left: 0.75rem; - border-radius: 0 0.25rem 0.25rem 0; - margin: 0.25rem 0; -} - -/* Clean diff view - unchanged (dimmed) */ -.plan-diff-unchanged { - /* handled via opacity in component */ -} - -/* Raw diff view - line styles */ -.plan-diff-line-added { - background: oklch(from var(--success) l c h / 0.15); - color: var(--success); -} -.plan-diff-line-removed { - background: oklch(from var(--destructive) l c h / 0.15); - color: var(--destructive); - opacity: 0.75; - text-decoration: line-through; - text-decoration-color: oklch(from var(--destructive) l c h / 0.4); -} -.light .plan-diff-line-added { - background: oklch(from var(--success) l c h / 0.12); -} -.light .plan-diff-line-removed { - background: oklch(from var(--destructive) l c h / 0.12); -} - /* ======================================== Sidebar ======================================== */ diff --git a/packages/ui/components/plan-diff/PlanDiffViewer.tsx b/packages/ui/components/plan-diff/PlanDiffViewer.tsx index 2830e8439..e40558e41 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,24 +79,14 @@ 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"); - } - } catch { - setVscodeDiffError("Failed to connect to server"); - } finally { - setVscodeDiffLoading(false); + const result = await (onOpenVscodeDiff ?? defaultOpenVscodeDiff)(baseVersion); + if (result.error) { + setVscodeDiffError(result.error); } + setVscodeDiffLoading(false); }; return ( 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/theme.css b/packages/ui/theme.css index d1af09cc4..f9438749b 100644 --- a/packages/ui/theme.css +++ b/packages/ui/theme.css @@ -479,6 +479,120 @@ html:not(.transitions-ready) * { background-color: color-mix(in oklab, var(--destructive) 20%, var(--muted)); } +/* Annotation highlights */ +.annotation-highlight { + border-radius: 2px; + padding: 0 2px; + margin: 0 -2px; +} + +.annotation-highlight.deletion { + background: oklch(from var(--destructive) l c h / 0.35); + text-decoration: line-through; + text-decoration-color: var(--destructive); + text-decoration-thickness: 2px; +} + +.annotation-highlight.comment { + background: oklch(0.70 0.18 60 / 0.3); + border-bottom: 2px solid var(--accent); +} + +/* Light mode: softer highlights */ +.light .annotation-highlight.deletion { + background: oklch(0.65 0.22 25 / 0.2); +} + +.light .annotation-highlight.comment { + background: oklch(0.70 0.20 60 / 0.15); +} + +.annotation-highlight.focused { + background: oklch(from var(--focus-highlight) l c h / 0.45) !important; + box-shadow: 0 0 8px oklch(from var(--focus-highlight) l c h / 0.4); + border-bottom: 2px solid var(--focus-highlight); + filter: none; +} + +.light .annotation-highlight.focused { + background: oklch(0.70 0.22 200 / 0.3) !important; + box-shadow: 0 0 6px oklch(0.60 0.20 200 / 0.3); +} + +.annotation-highlight:hover { + filter: brightness(1.2); + cursor: pointer; +} + +/* ======================================== + Plan Diff Styles + ======================================== */ + +/* Clean diff view - added content */ +.plan-diff-added { + border-left: 3px solid var(--success); + background: oklch(from var(--success) l c h / 0.06); + padding-left: 0.75rem; + border-radius: 0 0.25rem 0.25rem 0; + margin: 0.25rem 0; +} +.light .plan-diff-added { + background: oklch(from var(--success) l c h / 0.06); +} + +/* Clean diff view - removed content */ +.plan-diff-removed { + border-left: 3px solid var(--destructive); + background: oklch(from var(--destructive) l c h / 0.06); + padding-left: 0.75rem; + border-radius: 0 0.25rem 0.25rem 0; + margin: 0.25rem 0; +} +.light .plan-diff-removed { + background: oklch(from var(--destructive) l c h / 0.06); +} + +/* Clean diff view - modified content (mix of additions and deletions in one + block, rendered inline via word-level diff). + Deliberate asymmetry with added/removed: add/remove are BLOCK-scope events + — the whole block matters, so a loud fill is the right signal. Modify is + a WORD-scope event — the words matter, and the inline red-struck / + green-highlighted word markers already grab attention. A block-level fill + would compete with that inline work; an amber gutter on a normal + background says "look inside, the change is in the text" while staying + consistent with the green/red/yellow diff convention. */ +.plan-diff-modified { + border-left: 3px solid oklch(from var(--warning) l c h / 0.75); + background: transparent; + padding-left: 0.75rem; + border-radius: 0 0.25rem 0.25rem 0; + margin: 0.25rem 0; +} + +/* Clean diff view - unchanged (dimmed) */ +.plan-diff-unchanged { + /* handled via opacity in component */ +} + +/* Raw diff view - line styles */ +.plan-diff-line-added { + background: oklch(from var(--success) l c h / 0.15); + color: var(--success); +} +.plan-diff-line-removed { + background: oklch(from var(--destructive) l c h / 0.15); + color: var(--destructive); + opacity: 0.75; + text-decoration: line-through; + text-decoration-color: oklch(from var(--destructive) l c h / 0.4); +} +.light .plan-diff-line-added { + background: oklch(from var(--success) l c h / 0.12); +} +.light .plan-diff-line-removed { + background: oklch(from var(--destructive) l c h / 0.12); +} + /* Raw HTML blocks (CommonMark Type 6) rendered via dangerouslySetInnerHTML. Minimal typography so
,
, nested lists etc. inherit sensible spacing without fighting the surrounding prose styles. */ From 228aa379f2428a4e3b49d3da856b30d90df6c987 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 10:36:58 -0700 Subject: [PATCH 21/46] feat(ui): make config write-back + obsidian-detect host-overridable (Phase 6 settings) configStore.setServerSync(fn) injects only the terminal POST /api/config; the 300ms debounce, deepMerge batching, singleton, and eager cookie reads stay verbatim. Settings gains optional onDetectObsidianVaults (default /api/obsidian/vaults), with the [obsidian.enabled] effect dep + auto-select-first-vault verbatim. No override caller => Plannotator unchanged. typecheck pass, 1620 tests/0 fail, builds OK. --- packages/ui/components/Settings.tsx | 17 ++++++++++------- packages/ui/config/configStore.ts | 24 +++++++++++++++++++----- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index d4ce85670..810e9b02e 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -87,6 +87,8 @@ interface SettingsProps { aiProviders?: Array<{ id: string; name: string; capabilities: Record; models?: Array<{ id: string; label: string; default?: boolean }> }>; /** Git user name from `git config user.name`, for quick identity set */ gitUser?: string; + /** Override Obsidian vault detection (default = GET /api/obsidian/vaults). */ + onDetectObsidianVaults?: () => Promise; } // --- Review-mode Display tab (diff display options) --- @@ -610,7 +612,7 @@ const CommentsTab: React.FC = () => { ); }; -export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { +export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, onDetectObsidianVaults }) => { const [showDialog, setShowDialog] = useState(false); const [themePreview, setThemePreview] = useState(false); @@ -745,13 +747,14 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange useEffect(() => { if (obsidian.enabled && detectedVaults.length === 0 && !vaultsLoading) { setVaultsLoading(true); - fetch('/api/obsidian/vaults') - .then(res => res.json()) - .then((data: { vaults: string[] }) => { - setDetectedVaults(data.vaults || []); + const detect = onDetectObsidianVaults + ?? (() => fetch('/api/obsidian/vaults').then(res => res.json()).then((data: { vaults: string[] }) => data.vaults || [])); + detect() + .then((vaults: string[]) => { + setDetectedVaults(vaults || []); // Auto-select first vault if none set - if (data.vaults?.length > 0 && !obsidian.vaultPath) { - handleObsidianChange({ vaultPath: data.vaults[0] }); + if (vaults?.length > 0 && !obsidian.vaultPath) { + handleObsidianChange({ vaultPath: vaults[0] }); } }) .catch(() => setDetectedVaults([])) diff --git a/packages/ui/config/configStore.ts b/packages/ui/config/configStore.ts index 382afb65e..0c89e9ef4 100644 --- a/packages/ui/config/configStore.ts +++ b/packages/ui/config/configStore.ts @@ -29,6 +29,18 @@ function deepMerge(target: Record, source: Record) => void; + +/** Default = today's inline POST /api/config (best-effort). */ +const defaultServerSync: ServerSyncFn = (payload) => { + fetch('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).catch(() => {}); // best-effort +}; + /** Infer the value type from a SettingDef */ type SettingValue = SettingsMap[K] extends { defaultValue: infer D } ? D extends (...args: unknown[]) => infer R ? R : D @@ -40,6 +52,7 @@ class ConfigStore { private version = 0; private pendingServerWrites: Record = {}; private serverSyncTimer: ReturnType | null = null; + private serverSync: ServerSyncFn = defaultServerSync; constructor() { // Eagerly resolve all settings from synchronous sources (cookie > default). @@ -105,6 +118,11 @@ 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; + } + private notify(): void { this.version++; for (const fn of this.listeners) fn(); @@ -115,11 +133,7 @@ class ConfigStore { this.serverSyncTimer = setTimeout(() => { const payload = { ...this.pendingServerWrites }; this.pendingServerWrites = {}; - fetch('/api/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }).catch(() => {}); // best-effort + this.serverSync(payload); }, 300); } } From 2b5f779b33a3240857d72099e99be2586814807f Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 10:36:58 -0700 Subject: [PATCH 22/46] feat(ui): make save-to-notes host-overridable (Phase 6 sharing) ExportModal gains optional onSaveToNotes (default = verbatim POST /api/save-notes); showNotesTab = isApiMode && !!markdown kept byte-for-byte. Sharing utils already parameterized (noop). No override caller => Plannotator unchanged. typecheck pass, 1620 tests/0 fail, builds OK. --- packages/ui/components/ExportModal.tsx | 32 +++++++++++++++++++++----- 1 file changed, 26 insertions(+), 6 deletions(-) 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) { From 387b127960b0f4c6e5791d6375676926cb496070 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 10:36:58 -0700 Subject: [PATCH 23/46] feat(ui): make Ask AI transport host-overridable (Phase 6 ai) useAIChat gains a module-level AITransport (session/query/abort/permission) + setAITransport/resetAITransport, default = the five /api/ai/* fetches verbatim. The SSE reader loop, epoch/createRequest guards, and the supersede-abort position inside createSession stay untouched. Capabilities + provider-resolution stay host-owned in App.tsx. No override caller => Plannotator unchanged. ai.test.ts 97/0, typecheck pass, 1620 tests/0 fail, builds OK. --- packages/ui/hooks/useAIChat.ts | 122 ++++++++++++++++++++++----------- 1 file changed, 82 insertions(+), 40 deletions(-) diff --git a/packages/ui/hooks/useAIChat.ts b/packages/ui/hooks/useAIChat.ts index 6047743b2..d6c2ad893 100644 --- a/packages/ui/hooks/useAIChat.ts +++ b/packages/ui/hooks/useAIChat.ts @@ -92,6 +92,70 @@ function createAbortError(message: string): Error { return err; } +/** + * Transport for the AI chat wire. Each method maps to one Plannotator + * `/api/ai/*` endpoint. The default reproduces today's fetches verbatim; + * a host (e.g. Workspaces) calls `setAITransport` once at startup to route + * AI traffic through its own backend. The SSE reader loop, epoch guards, and + * supersede-abort position in the hook are unaffected — only the wire is swapped. + */ +export interface AITransport { + /** POST /api/ai/session — create or fork a session. */ + session(body: unknown, signal: AbortSignal): Promise; + /** POST /api/ai/query — send a message; returns the streaming SSE response. */ + query(body: unknown, signal: AbortSignal): Promise; + /** POST /api/ai/abort — abort a session (supersede + standalone). Fire-and-forget. */ + abort(body: unknown): void; + /** POST /api/ai/permission — respond to a permission request. Fire-and-forget. */ + permission(body: unknown): void; +} + +/** Default transport — Plannotator's local `/api/ai/*` fetches, verbatim. */ +const defaultAITransport: AITransport = { + session: (body, signal) => + fetch('/api/ai/session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal, + }), + query: (body, signal) => + fetch('/api/ai/query', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal, + }), + abort: (body) => { + fetch('/api/ai/abort', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).catch(() => {}); + }, + permission: (body) => { + fetch('/api/ai/permission', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).catch(() => {}); + }, +}; + +// Module-level transport, stable identity. Defaults to Plannotator's behavior so +// the hook and its callers are unchanged. A host overrides it once at startup. +let aiTransport: AITransport = defaultAITransport; + +/** Override the AI chat transport. Call once at app startup. */ +export const setAITransport = (transport: AITransport): void => { + aiTransport = transport; +}; + +/** Reset to the default (Plannotator local `/api/ai/*`) transport. Mainly for tests. */ +export const resetAITransport = (): void => { + aiTransport = defaultAITransport; +}; + export function useAIChat({ context, providerId, @@ -131,17 +195,12 @@ export function useAIChat({ const requestId = ++createRequestRef.current; setIsCreatingSession(true); try { - const res = await fetch('/api/ai/session', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - context, - ...(providerId && { providerId }), - ...(model && { model }), - ...(reasoningEffort && { reasoningEffort }), - }), - signal, - }); + const res = await aiTransport.session({ + context, + ...(providerId && { providerId }), + ...(model && { model }), + ...(reasoningEffort && { reasoningEffort }), + }, signal); if (!res.ok) { const data = await res.json().catch(() => ({ error: 'Failed to create AI session' })); @@ -150,11 +209,7 @@ export function useAIChat({ const data = await res.json() as { sessionId: string }; if (signal.aborted || epoch !== sessionEpochRef.current) { - fetch('/api/ai/abort', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: data.sessionId }), - }).catch(() => {}); + aiTransport.abort({ sessionId: data.sessionId }); throw createAbortError('AI session creation was superseded'); } setSessionId(data.sessionId); @@ -210,16 +265,11 @@ export function useAIChat({ } const fullPrompt = buildPrompt(params); - const res = await fetch('/api/ai/query', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sessionId: sid, - prompt: fullPrompt, - ...(params.contextUpdate && { contextUpdate: params.contextUpdate }), - }), - signal: controller.signal, - }); + const res = await aiTransport.query({ + sessionId: sid, + prompt: fullPrompt, + ...(params.contextUpdate && { contextUpdate: params.contextUpdate }), + }, controller.signal); if (!res.ok || !res.body) { const data = await res.json().catch(() => ({ error: 'Query failed' })); @@ -347,11 +397,7 @@ export function useAIChat({ } if (sessionIdRef.current) { - fetch('/api/ai/abort', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: sessionIdRef.current }), - }).catch(() => {}); + aiTransport.abort({ sessionId: sessionIdRef.current }); } }, []); @@ -362,15 +408,11 @@ export function useAIChat({ prev.map(p => p.requestId === requestId ? { ...p, decided: allow ? 'allow' : 'deny' } : p) ); - fetch('/api/ai/permission', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sessionId: sessionIdRef.current, - requestId, - allow, - }), - }).catch(() => {}); + aiTransport.permission({ + sessionId: sessionIdRef.current, + requestId, + allow, + }); }, [updatePermissions]); const resetSession = useCallback(() => { From db94b7ea99d3b53a9de06865a98e00e5fb06f399 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 10:37:35 -0700 Subject: [PATCH 24/46] docs(adr): log Phase 6 completion (4 seams + diff CSS move) --- .../document-ui-phase-0-1-worklog-20260622.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md index c6d651e83..1701e4fc5 100644 --- a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md +++ b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md @@ -141,6 +141,29 @@ Researched (5-probe spike), specced, ADR-005-accepted, then teed up + multi-lens - **Parity:** no override caller; App.tsx untouched (both apps still call `useExternalAnnotations({enabled})`). external-annotation test green, typecheck pass, 1620/0, builds OK. ### Phase 5 status: COMPLETE (pending eyeball) — 3 seams landed, comment UI noop. Plannotator byte-unchanged. + +## Phase 6 — Extras: versions/diff, settings, sharing, Ask AI (ADR 006) + +Researched (5-probe spike), specced, ADR-006-accepted, teed up + multi-lens adversarially reviewed by the `phase6-extras` workflow (5 tee-ups + 4 worktree executes + 15 review lenses + synthesis; all 15 lenses safe). Landed + verified by hand. Already-portable pieces (planDiffEngine, all diff render components, sharing utils/useSharing/ImportModal, notes-app helpers, settings.ts, AI chat components, aiProvider/aiChatFormat) confirmed noop. Five Plannotator-only pieces (OpenInAppButton, HooksTab, useUpdateCheck, useAgents, useAgentJobs) confirmed out of scope. + +### Seam — Versions/diff + CSS move (DONE) +- **Files:** `usePlanDiff.ts` (optional `fetchers?` 4th arg, default `/api/plan/version(s)`; error asymmetry kept — selectBaseVersion alerts via the existing catch, fetchVersions silent), `PlanDiffViewer.tsx` (optional `onOpenVscodeDiff?`, default `/api/plan/vscode-diff`), and the **CSS move**: `.annotation-highlight*` + `.plan-diff-added/removed/modified/unchanged/line-*` relocated **byte-identical** from `editor/index.css` (−114) into `ui/theme.css` (+114), next to `.plan-diff-word-*`. +- **Parity:** relocated CSS **gone from index.css (0), present in shipped bundle (33×)** since Plannotator imports theme.css → pixel-identical. planDiffEngine 49/0, typecheck pass, 1620/0, builds OK, App.tsx untouched. + +### Seam — Settings/config (DONE) +- **Files:** `configStore.ts` (`setServerSync(fn)` injects only the terminal `/api/config` POST; 300ms debounce + deepMerge batching + singleton + eager cookie reads verbatim), `Settings.tsx` (optional `onDetectObsidianVaults`, default `/api/obsidian/vaults`; `[obsidian.enabled]` dep + auto-select verbatim). +- **Parity:** no override caller; ui 293/0, typecheck pass, 1620/0, builds OK, App.tsx untouched. + +### Seam — Sharing/save-to-notes (DONE) +- **File:** `ExportModal.tsx` (optional `onSaveToNotes`, default = verbatim `/api/save-notes` POST; `showNotesTab = isApiMode && !!markdown` byte-for-byte). Sharing utils already parameterized (noop). +- **Parity:** no override caller; typecheck pass, 1620/0, builds OK, App.tsx untouched. + +### Seam — Ask AI transport (DONE, riskiest) +- **File:** `useAIChat.ts` (module-level `AITransport` session/query/abort/permission + setters, default = the five `/api/ai/*` fetches verbatim). The SSE reader loop, epoch/createRequest guards, and the supersede-abort position inside `createSession` stay untouched. Capabilities + provider-resolution stay host-owned (App.tsx). +- **Parity:** no override caller; `packages/ai/ai.test.ts` 97/0, typecheck pass, 1620/0, builds OK, App.tsx untouched. + +### Phase 6 status: COMPLETE (pending eyeball) — 4 seams landed + diff CSS in the package, extras noop, 5 Plannotator-only pieces out of scope. Plannotator byte-unchanged. +The document UI is now feature-complete for reuse. Remaining: Phase 7 (publish) + the parked `@plannotator/ai`/`@plannotator/shared` publish-vs-inline decision. Renderer-coupling contract (Workspaces must reuse BlockRenderer+InlineMarkdown+inlineTransforms for highlights) and replies/threading deferral recorded in ADR 005. Remaining: manual eyeball — author/`(me)`, draft save+restore+no-ghost, live SSE add + kill-stream→polling-takes-over. ### Discovered (PRE-EXISTING, out of scope — not caused by this work) From e74cc0b90a806f3f2c16a36b8ae96f65fd942979 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 12:57:45 -0700 Subject: [PATCH 25/46] docs(adr): research + synthesis + spec for Phase 7 (carve @plannotator/core + publish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carve a browser-safe @plannotator/core: move the ~15 pure shared modules in, extract types from the 3-4 node-bound ones (config/storage/workspace-status) so nothing duplicates, shim @plannotator/shared so Plannotator's 99 import sites stay unchanged, re-point @plannotator/ui to depend only on core, move wideMode.ts, then publish core+ui (source-only). shared + ai stay private. Open: registry, versions, CI job. Publish is the one outward-facing step — confirm before pushing. --- ...KE-publish-core-package-20260623-125551.md | 54 +++++++++++++++++++ ...is-publish-core-package-20260623-125551.md | 42 +++++++++++++++ .../publish-core-package-20260623-125551.md | 54 +++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 adr/research/SPIKE-publish-core-package-20260623-125551.md create mode 100644 adr/research/synthesis-publish-core-package-20260623-125551.md create mode 100644 adr/specs/publish-core-package-20260623-125551.md diff --git a/adr/research/SPIKE-publish-core-package-20260623-125551.md b/adr/research/SPIKE-publish-core-package-20260623-125551.md new file mode 100644 index 000000000..bae1b3d33 --- /dev/null +++ b/adr/research/SPIKE-publish-core-package-20260623-125551.md @@ -0,0 +1,54 @@ +# Spike: Carve `@plannotator/core` and Publish (Phase 7) + +Date: 2026-06-23 + +> Code research for Phase 7 of the `@plannotator/ui` reuse effort (ADR 004). Three probes mapped: (1) exact `@plannotator/core` membership, (2) the import blast radius + zero-churn mechanism, (3) publish toolchain. Decision context: **no copying / no duplication** — carve a single browser-safe package everyone shares. THE LAW: Plannotator stays unchanged. + +## The shape + +`@plannotator/ui` can't be published while it depends on the private `@plannotator/shared` and `@plannotator/ai`. The chosen fix: **move the browser-safe slice into a new published `@plannotator/core`; `@plannotator/shared` re-exports from it (so Plannotator's 99 import sites don't change); publish `core` + `ui`.** One copy of everything. + +## 1. What goes in `@plannotator/core` + +The UI references 21 distinct `@plannotator/shared` subpaths (value + type-only). They split three ways: + +**A. Pure modules — move wholesale into core (no node, no npm deps, no cross-deps):** +`code-file`, `agents`, `agent-jobs`, `compress`, `crypto`, `external-annotation`, `extract-code-paths` (→ imports `./code-file`, also moves), `favicon`, `feedback-templates`, `goal-setup`, `browser-paths`, `project`, `agent-terminal`, `open-in-apps`, `source-save`. (~15 files.) All verified browser-safe (the apps already bundle them for the browser today). + +**B. Node-bound modules the UI imports TYPES from — extract the types into core (the elegant no-duplication move):** +`config.ts` (node:fs/os/child_process), `storage.ts` (node:fs), `workspace-status.ts` (node:child_process). The UI imports only types from these (`DiffLineBgIntensity`, `DefaultDiffType`, `ArchivedPlan`, `WorkspaceFileChange`, `WorkspaceStatusPayload`). **Do NOT move these files** (they'd drag node into a browser package). Instead: **extract their type definitions into `core` (e.g. `core/config-types.ts`), and have `shared/config.ts` import those types back from core** + keep its node implementation. Result: the types live **once** (in core), the node code stays in shared, no duplication. + +**C. `AIContext` from `@plannotator/ai`** — a pure type union (verified node-free). Re-export it from `core` (e.g. `core/ai-context.ts`) so `ui` imports `@plannotator/core` instead of `@plannotator/ai`. + +**Nuance to verify at implementation:** `shared/types.ts` re-exports from `review-core.ts` / `review-workspace.ts`, which have value-level `node:path` imports. Confirm whether the UI's `@plannotator/shared/types` actually surfaces any of those review types to ui; if so, extract just those types (same technique as B). If not, `core/types.ts` re-exports only the browser-safe set. + +**Proposed core size:** ~15 pure files moved + ~3-4 extracted type files + an `index.ts` barrel + `ai-context.ts`. All source-only, browser-safe, **zero npm/node dependencies.** + +## 2. Blast radius + zero-churn mechanism + +- **99 import sites** across the repo reference these modules: packages/ui (36), packages/server (34), editor (11), review-editor (11), apps/hook (4), opencode (3). Heaviest: `agents` (17), `config` (10), `source-save` (9), `agent-terminal` (8), `types` (8). +- **Re-export shim = zero churn (the key finding):** for each moved module, leave a one-line shim in `shared` — `export * from '@plannotator/core/code-file'`. All 99 sites keep working unchanged; `shared`'s `exports` map stays as-is; works for both `import {}` and `import type`. **Plannotator's server/editor/review-editor/apps need no edits.** (For the type-extracted node modules, `shared/config.ts` etc. import their types from core and re-export — same effect.) +- **Pi-extension `vendor.sh`** copies ~47 shared files at build; with shims it vendors the shim files unchanged → **no vendor.sh change needed.** +- **No tsconfig/build globs** reference `packages/shared/*` — all imports are explicit subpaths. Minimal tooling impact. +- **`wideMode.ts` move** (Phase-7 leftover): `packages/editor/wideMode.ts` → `packages/ui/utils/wideMode.ts`; only 2 import sites (App.tsx + its test); ui doesn't import editor, no cycle. Ultra-low risk. + +## 3. Publish toolchain + +- **Monorepo:** Bun, `workspaces: ["apps/*","packages/*"]`, public npm. Release is tag-triggered CI (`.github/workflows/release.yml`); today it publishes only `@plannotator/opencode` + `@plannotator/pi-extension` via `bun pm pack` + `npm publish --provenance --access public`. **No ui/core/shared publish job exists yet.** +- **workspace:* resolution:** bun replaces `workspace:*` with the real version at pack time. Blocker today: `@plannotator/ai` + `@plannotator/shared` are `private:true` v0.0.1. After the carve, `ui` depends on `@plannotator/core` (published) — `shared`/`ai` stay private (ui no longer needs them directly once `core` covers its imports, **except** any remaining `import type` from shared that we must route through core). +- **Source-only model:** `ui` (and `core`) ship raw `.ts/.tsx` — no build/dist. An external TS consumer (Workspaces) must set: `moduleResolution: "bundler"`, `allowImportingTsExtensions`, `isolatedModules`, `jsx: "react-jsx"`, React 19 + Tailwind v4 (`@tailwindcss/vite`), and a Tailwind `@source` glob over `node_modules/@plannotator/ui/**/*.tsx`, plus import `@plannotator/ui/theme`. This works (no build needed) but must be documented for the consumer. +- **`ui` packaging after Phase 1:** peerDeps, dompurify, files allowlist all done. Remaining: the workspace-dep blocker (solved by `core`), a real version, and a CI publish job. + +## Open decisions (for the ADR) +1. **Registry:** public npm (matches existing opencode/pi-extension, simplest) vs private/scoped (if `ui`/`core` should be Workspaces-only). +2. **Versions:** keep 0.0.1 vs assign real (e.g. 0.1.0). `core` + `ui` likely version together. +3. **CI:** add a publish job for `core` + `ui` to `release.yml`, or publish manually the first time. +4. **`@plannotator/ai`:** since the UI only needs the `AIContext` type and `core` re-exports it, `ai` can **stay private/unpublished** — confirm the UI has no other `@plannotator/ai` value import. + +## Per-area summary +| Area | Finding | +|---|---| +| Core membership | ~15 pure files move; 3-4 node-bound modules contribute extracted types; AIContext re-exported. Zero node/npm in core. | +| Churn | Re-export shims in `shared` → 0 changes to Plannotator's 99 sites; vendor.sh unaffected. | +| Publish | Bun + public npm, tag-triggered CI; need a new publish job; source-only ships, consumer needs documented tsconfig/Tailwind. | +| Decisions | registry, versions, CI job, ai-stays-private. | diff --git a/adr/research/synthesis-publish-core-package-20260623-125551.md b/adr/research/synthesis-publish-core-package-20260623-125551.md new file mode 100644 index 000000000..2c9eb9be4 --- /dev/null +++ b/adr/research/synthesis-publish-core-package-20260623-125551.md @@ -0,0 +1,42 @@ +# Synthesis: Carve `@plannotator/core` and Publish (Phase 7) + +Date: 2026-06-23 + +> Synthesizes `SPIKE-publish-core-package-20260623-125551.md` against ADR 004 and the user decision: **no copying / single source of truth.** Settles Phase 7's shape. + +## The decision in one line + +Carve a new browser-safe **`@plannotator/core`** package, move the universal slice into it (extracting just the *types* from node-bound modules so nothing duplicates), make `@plannotator/shared` re-export from it so Plannotator is untouched, then publish `core` + `ui`. `@plannotator/shared` and `@plannotator/ai` stay private. + +## Why this shape + +- **No duplication:** every file/type has exactly one home. Pure modules live in `core`; node-bound *implementations* stay in `shared` but import their *types* from `core`. Today's clean one-copy state is preserved. +- **Plannotator unchanged:** re-export shims in `shared` mean all 99 internal import sites keep working with zero edits. Plannotator's server, editor, review-editor, apps, and the Pi vendor step are untouched. +- **Minimal published surface:** `core` is small, browser-safe, zero-dependency — not the Node/git/PR kitchen sink. Workspaces installs `ui` + `core` and nothing it doesn't run. +- **Naming is honest:** `core` = the universal foundation (runs anywhere), distinct from `ui` (components) and `shared` (the Node/server grab-bag). No `shared`/`ui` stutter. + +## The plan + +1. **Create `packages/core`** — source-only, browser-safe, zero npm/node deps. Move the ~15 pure modules in. Add extracted type files for the 3-4 node-bound modules (`config`, `storage`, `workspace-status`, and any review types `ui` surfaces). Re-export `AIContext`. Add an `index.ts` barrel and a fine-grained `exports` map (mirroring `ui`'s source-only pattern). +2. **Re-point `@plannotator/shared`** — each moved pure module becomes a one-line shim (`export * from '@plannotator/core/X'`); each node-bound module imports its types from `core` and keeps its node implementation. `shared`'s `exports` map and `private:true` stay. Plannotator's imports don't change. +3. **Re-point `@plannotator/ui`** — change `ui`'s `@plannotator/shared/X` and `@plannotator/ai` imports to `@plannotator/core/X`; replace the `workspace:* @plannotator/shared`/`@plannotator/ai` deps with `@plannotator/core` (the only published dep ui needs). Confirm no remaining `@plannotator/shared`/`@plannotator/ai` reference in ui. +4. **Move `wideMode.ts`** `editor → ui/utils` (2 import edits). +5. **Publish** `core` then `ui` (source-only) — add a CI job (or first-time manual publish), real versions, document the consumer tsconfig/Tailwind requirements in `core`/`ui` READMEs. + +## Guardrails (Plannotator stays byte-for-byte unchanged) +- After steps 1-3: full `bun test` stays 1620/0, typecheck passes, all builds byte-identical, **`git diff` touches only `packages/core` (new), `packages/shared` (shims/type-imports), `packages/ui` (import re-points + package.json), and `packages/editor` (wideMode)** — no server/app behavior change. +- The shipped bundle hashes (`apps/hook/dist`, `apps/opencode-plugin`) should stay identical (the re-exports compile to the same code). +- The publish itself is the one outward-facing, hard-to-undo step — **stop and confirm with the user before pushing anything to a registry.** + +## Open decisions to lock in the ADR +1. **Registry:** recommend **public npm** (matches the existing `@plannotator/opencode`/`pi-extension` flow, simplest, and `core`/`ui` contain nothing secret). Switch to a private scope only if there's a reason to keep them off the public registry. +2. **Versions:** recommend `0.1.0` for `core` + `ui`, versioned together. +3. **`@plannotator/ai` stays private** (ui only needs the `AIContext` type, re-exported via `core`) — confirm no value import. +4. **CI:** add `core` + `ui` to the `release.yml` npm-publish job (or publish manually first, automate later). + +## Sequencing +Do the carve + re-point + verify first (all reversible, Plannotator-unchanged), get the green parity run, **then** make the registry/version call and publish as the final, confirmed step. + +## References +- Spike: `adr/research/SPIKE-publish-core-package-20260623-125551.md` +- Governing decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180627.md` diff --git a/adr/specs/publish-core-package-20260623-125551.md b/adr/specs/publish-core-package-20260623-125551.md new file mode 100644 index 000000000..b3a611566 --- /dev/null +++ b/adr/specs/publish-core-package-20260623-125551.md @@ -0,0 +1,54 @@ +# Spec: Carve `@plannotator/core` + Publish (Phase 7) + +Date: 2026-06-23 · Status: Draft (iterate before implementing) + +> Implementation spec for Phase 7. Grounded in `SPIKE-publish-core-package-20260623-125551.md` + its synthesis. Decision: single source of truth (no copying). THE LAW: Plannotator stays byte-for-byte unchanged through the carve; the publish is the one outward-facing step — confirm with the user before pushing to any registry. + +## Scope +**In:** create `@plannotator/core`, move the universal slice + extract types from node-bound modules, shim `@plannotator/shared`, re-point `@plannotator/ui`, move `wideMode.ts`, prep + (on go-ahead) publish `core` + `ui`. +**Out / stays private:** `@plannotator/shared` (Node/git/server grab-bag) and `@plannotator/ai` (ui only needs the `AIContext` type via core). + +## Step 1 — Create `packages/core` +- `packages/core/package.json`: `name @plannotator/core`, `version 0.1.0`, `type module`, source-only `exports` map (fine-grained subpaths like `ui`), `files` allowlist (`*.ts`, exclude tests), **no dependencies** (peerDeps none — it's pure JS/Web-API). tsconfig mirroring `ui` (bundler resolution, isolatedModules, allowImportingTsExtensions). +- Add `@plannotator/core` to root `workspaces` (already covered by `packages/*`). +- **Move (git mv) the ~15 pure modules** from `packages/shared` → `packages/core`: `code-file`, `extract-code-paths`, `agents`, `agent-jobs`, `compress`, `crypto`, `external-annotation`, `favicon`, `feedback-templates`, `goal-setup`, `browser-paths`, `project`, `agent-terminal`, `open-in-apps`, `source-save`. (Confirm each is node-free at move time.) +- **Extract types** from the node-bound modules into core type files: `core/config-types.ts` (DefaultDiffType, DiffLineBgIntensity, DiffOptions, …), `core/storage-types.ts` (ArchivedPlan), `core/workspace-status-types.ts` (WorkspaceFileChange, WorkspaceStatusPayload, GitRepositoryInfo). Plus any review types `ui`'s `@plannotator/shared/types` surfaces (verify `review-core`/`review-workspace` usage). +- `core/ai-context.ts`: re-export `AIContext` (move or re-export the pure type from `packages/ai/types.ts`; confirm it's node-free). +- `core/index.ts`: barrel re-export. + +## Step 2 — Re-point `@plannotator/shared` (keeps Plannotator unchanged) +- For each **moved pure module**, replace its `packages/shared/X.ts` with a one-line shim: `export * from '@plannotator/core/X';`. Keep `shared`'s `exports` map and `private:true` as-is. +- For each **node-bound module** (`config`, `storage`, `workspace-status`), change its in-file type definitions to **import the types from `@plannotator/core/*-types`** and re-export them, keeping the node implementation. (Types now live once, in core.) +- Add `@plannotator/core: "workspace:*"` to `packages/shared` deps. +- **Verify:** all 99 existing `@plannotator/shared/X` import sites still resolve unchanged; Pi `vendor.sh` needs no edit (vendors the shims). + +## Step 3 — Re-point `@plannotator/ui` +- Change every `ui` import of `@plannotator/shared/X` → `@plannotator/core/X`, and `import type { AIContext } from '@plannotator/ai'` → `@plannotator/core`. +- In `packages/ui/package.json`: remove the `@plannotator/shared` and `@plannotator/ai` `workspace:*` deps; add `@plannotator/core: "workspace:*"`. (After publish this becomes a real version range.) +- **Verify:** `grep @plannotator/shared` and `@plannotator/ai` in `packages/ui` (non-test) returns **zero** — ui depends only on `@plannotator/core` internally. + +## Step 4 — Move `wideMode.ts` +- `git mv packages/editor/wideMode.ts packages/ui/utils/wideMode.ts` (+ its test). Update the 2 importers (`editor/App.tsx`, the test) to `@plannotator/ui/utils/wideMode`. + +## Step 5 — Publish (OUTWARD-FACING — confirm first) +- Decide registry (recommend **public npm**, matching existing flow), versions (recommend **0.1.0**, core+ui together). +- Write/READMEs documenting the consumer requirements: `moduleResolution: bundler`, `allowImportingTsExtensions`, `isolatedModules`, `jsx: react-jsx`, React 19 + Tailwind v4 (`@tailwindcss/vite`), Tailwind `@source` over `node_modules/@plannotator/ui/**/*.tsx`, import `@plannotator/ui/theme`. +- Add a publish job to `.github/workflows/release.yml` for `core` + `ui` (or publish manually the first time: `bun pm pack` each, `npm publish *.tgz --access public`). bun resolves `workspace:*` → real versions at pack time. +- **Do not run the publish until the user explicitly approves** the registry + version + go. + +## Definition of done (Phase 7) +- `@plannotator/core` exists, browser-safe, zero deps; the universal slice lives there once. +- `@plannotator/shared` re-exports from core; Plannotator byte-unchanged (full `bun test` 1620/0, typecheck, builds, shipped-bundle hashes identical; `git diff` limited to core/shared/ui/editor packaging + import re-points). +- `@plannotator/ui` depends only on `@plannotator/core` internally; installs standalone (with `core`). +- `wideMode.ts` relocated. +- Consumer requirements documented; publish job ready. +- (On explicit go) `core` + `ui` published; Workspaces can `npm install @plannotator/ui @plannotator/core`. + +## Parity guardrail (run after the carve, before publish) +`bun run typecheck` · `bun test` 1620/0 · `bun run --cwd apps/review build && bun run build:hook && bun run build:opencode` · shipped-bundle hashes vs the Phase-0 baseline (should be identical) · `git diff` confined to the expected packages · Pi `vendor.sh`/typecheck still green. + +## Open questions (resolve in ADR) +1. Registry: public npm (recommended) vs private scope. +2. Versions: 0.1.0 (recommended) vs other; core+ui together vs independent. +3. CI publish job now vs manual first publish. +4. Confirm `@plannotator/ai` stays private (no ui value import) and `review-core`/`review-workspace` type handling. From 45892810d6a9b574115bc8ea13c0b19a3823f182 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 13:32:11 -0700 Subject: [PATCH 26/46] docs(adr): fold configurePlannotatorUI() front door + precompiled CSS into Phase 7 spec Add the single typed configure() facade over the 9 global host-override setters (zero-risk, additive) and an optional precompiled CSS bundle (smooths the Tailwind-in-shared-lib wrinkle) to the Phase 7 publish scope. Both make the published surface nicer to consume; neither touches Plannotator. --- ...is-publish-core-package-20260623-125551.md | 4 +++- .../publish-core-package-20260623-125551.md | 21 ++++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/adr/research/synthesis-publish-core-package-20260623-125551.md b/adr/research/synthesis-publish-core-package-20260623-125551.md index 2c9eb9be4..4dc8981d2 100644 --- a/adr/research/synthesis-publish-core-package-20260623-125551.md +++ b/adr/research/synthesis-publish-core-package-20260623-125551.md @@ -21,7 +21,9 @@ Carve a new browser-safe **`@plannotator/core`** package, move the universal sli 2. **Re-point `@plannotator/shared`** — each moved pure module becomes a one-line shim (`export * from '@plannotator/core/X'`); each node-bound module imports its types from `core` and keeps its node implementation. `shared`'s `exports` map and `private:true` stay. Plannotator's imports don't change. 3. **Re-point `@plannotator/ui`** — change `ui`'s `@plannotator/shared/X` and `@plannotator/ai` imports to `@plannotator/core/X`; replace the `workspace:* @plannotator/shared`/`@plannotator/ai` deps with `@plannotator/core` (the only published dep ui needs). Confirm no remaining `@plannotator/shared`/`@plannotator/ai` reference in ui. 4. **Move `wideMode.ts`** `editor → ui/utils` (2 import edits). -5. **Publish** `core` then `ui` (source-only) — add a CI job (or first-time manual publish), real versions, document the consumer tsconfig/Tailwind requirements in `core`/`ui` READMEs. +5. **Single config front door** — add `configurePlannotatorUI(config)`: one typed call that fans out to the 9 global host-override setters (image, storage, doc-preview, file-tree, identity, draft, external-annotations, AI, config-sync). Fixes the "scattered switches" ergonomics wart for ~40 lines, zero risk. The render-time prop seams stay as props; a `` is the optional later upgrade. +6. **(Optional) precompiled CSS** — ship `@plannotator/ui/styles.css` so a consumer imports one stylesheet instead of wiring Tailwind `@source` to ui internals. Smooths the one genuine integration wrinkle (Tailwind-in-a-shared-lib); additive, source-ships model unchanged. +7. **Publish** `core` then `ui` (source-only) — add a CI job (or first-time manual publish), real versions, document the consumer tsconfig/Tailwind requirements in `core`/`ui` READMEs. ## Guardrails (Plannotator stays byte-for-byte unchanged) - After steps 1-3: full `bun test` stays 1620/0, typecheck passes, all builds byte-identical, **`git diff` touches only `packages/core` (new), `packages/shared` (shims/type-imports), `packages/ui` (import re-points + package.json), and `packages/editor` (wideMode)** — no server/app behavior change. diff --git a/adr/specs/publish-core-package-20260623-125551.md b/adr/specs/publish-core-package-20260623-125551.md index b3a611566..1c9ab83ea 100644 --- a/adr/specs/publish-core-package-20260623-125551.md +++ b/adr/specs/publish-core-package-20260623-125551.md @@ -5,7 +5,7 @@ Date: 2026-06-23 · Status: Draft (iterate before implementing) > Implementation spec for Phase 7. Grounded in `SPIKE-publish-core-package-20260623-125551.md` + its synthesis. Decision: single source of truth (no copying). THE LAW: Plannotator stays byte-for-byte unchanged through the carve; the publish is the one outward-facing step — confirm with the user before pushing to any registry. ## Scope -**In:** create `@plannotator/core`, move the universal slice + extract types from node-bound modules, shim `@plannotator/shared`, re-point `@plannotator/ui`, move `wideMode.ts`, prep + (on go-ahead) publish `core` + `ui`. +**In:** create `@plannotator/core`, move the universal slice + extract types from node-bound modules, shim `@plannotator/shared`, re-point `@plannotator/ui`, move `wideMode.ts`, add a single `configurePlannotatorUI()` front door, (optionally) ship a precompiled CSS bundle, prep + (on go-ahead) publish `core` + `ui`. **Out / stays private:** `@plannotator/shared` (Node/git/server grab-bag) and `@plannotator/ai` (ui only needs the `AIContext` type via core). ## Step 1 — Create `packages/core` @@ -30,7 +30,20 @@ Date: 2026-06-23 · Status: Draft (iterate before implementing) ## Step 4 — Move `wideMode.ts` - `git mv packages/editor/wideMode.ts packages/ui/utils/wideMode.ts` (+ its test). Update the 2 importers (`editor/App.tsx`, the test) to `@plannotator/ui/utils/wideMode`. -## Step 5 — Publish (OUTWARD-FACING — confirm first) +## Step 5 — Single config front door (`configurePlannotatorUI`) +The reuse surface currently has **9 global host-override switches** scattered across modules: `setImageSrcResolver`, `setStorageBackend`, `setDocPreviewFetcher`, `setFileTreeBackend`, `setIdentityProvider`, `setDraftTransport`, `setExternalAnnotationTransport`, `setAITransport`, and `configStore.setServerSync`. A consumer shouldn't have to discover and call each. +- Add **one new file** `packages/ui/configure.ts` exporting a typed `PlannotatorUIConfig` and `configurePlannotatorUI(config: PlannotatorUIConfig)` that fans out to those 9 setters (each field optional → only the provided ones are applied). Add to the `ui` `exports` map. +- **Zero risk / additive:** Plannotator never calls it, so nothing changes; the existing setters keep working individually. The per-component prop seams (vscode-diff, save-to-notes, obsidian-detect, version fetchers, editor `mode`, code-path toggle, `ScrollViewportProvider`) are intentionally NOT in the global front door — they're passed where the host renders those components. +- **Later (optional):** migrate the render-time seams to a `` (React context) if Workspaces wants per-instance config / SSR. The `configure()` facade is the 80/20 now; the Provider is the door it leaves open. +- **Verify:** typecheck; a tiny test that `configurePlannotatorUI({...})` routes to each setter; Plannotator behavior unchanged (it never calls it). + +## Step 6 — Precompiled CSS bundle (optional friction-reducer) +Sharing Tailwind-utility components forces the consumer to either scan our source (`@source`) or get a ready-made stylesheet. Ship the stylesheet to smooth the worst integration wrinkle. +- Add a build that emits a single precompiled CSS file for `@plannotator/ui` (theme tokens + the component utility classes), exported as e.g. `@plannotator/ui/styles.css`. The consumer imports one stylesheet instead of wiring Tailwind `@source` to ui internals. +- **Additive:** keep the `@source` glob path documented as the alternative; the source-ships-TS model is unchanged. This is the heavier of the two polish items (needs a small CSS build pipeline) — optional; the `@source` approach already works for the first Workspaces integration. +- **Verify:** the precompiled CSS renders Plannotator-identical visuals in a bare consumer; Plannotator's own build/styling untouched. + +## Step 7 — Publish (OUTWARD-FACING — confirm first) - Decide registry (recommend **public npm**, matching existing flow), versions (recommend **0.1.0**, core+ui together). - Write/READMEs documenting the consumer requirements: `moduleResolution: bundler`, `allowImportingTsExtensions`, `isolatedModules`, `jsx: react-jsx`, React 19 + Tailwind v4 (`@tailwindcss/vite`), Tailwind `@source` over `node_modules/@plannotator/ui/**/*.tsx`, import `@plannotator/ui/theme`. - Add a publish job to `.github/workflows/release.yml` for `core` + `ui` (or publish manually the first time: `bun pm pack` each, `npm publish *.tgz --access public`). bun resolves `workspace:*` → real versions at pack time. @@ -41,8 +54,10 @@ Date: 2026-06-23 · Status: Draft (iterate before implementing) - `@plannotator/shared` re-exports from core; Plannotator byte-unchanged (full `bun test` 1620/0, typecheck, builds, shipped-bundle hashes identical; `git diff` limited to core/shared/ui/editor packaging + import re-points). - `@plannotator/ui` depends only on `@plannotator/core` internally; installs standalone (with `core`). - `wideMode.ts` relocated. +- **`configurePlannotatorUI()` exists** as the single typed front door over the 9 global setters; Plannotator unchanged (never calls it). +- **(Optional) precompiled CSS** shipped so a consumer can import one stylesheet instead of wiring Tailwind `@source`. - Consumer requirements documented; publish job ready. -- (On explicit go) `core` + `ui` published; Workspaces can `npm install @plannotator/ui @plannotator/core`. +- (On explicit go) `core` + `ui` published; Workspaces can `npm install @plannotator/ui @plannotator/core`, call `configurePlannotatorUI({...})` once, import `@plannotator/ui/styles.css`, and build. ## Parity guardrail (run after the carve, before publish) `bun run typecheck` · `bun test` 1620/0 · `bun run --cwd apps/review build && bun run build:hook && bun run build:opencode` · shipped-bundle hashes vs the Phase-0 baseline (should be identical) · `git diff` confined to the expected packages · Pi `vendor.sh`/typecheck still green. From c312f16f9a1d05579857149a37a1d00ec93d67ff Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 13:59:19 -0700 Subject: [PATCH 27/46] docs(adr): lock Phase 7 publish decisions + carry over review fixes Decided: ship JS as source (single internal consumer on controlled stack, no build to maintain, no dist drift); precompiled CSS now REQUIRED (the @source glob is fragile under pnpm symlinks); core CI typecheck node-free; pin ui->core exact. Recorded the interrogation's carried-over Phase-5 code fixes (useExternalAnnotations split-transport + fallbackRef reset, per-seam override tests, configStore loadFromBackend) to do before publish. --- .../publish-core-package-20260623-125551.md | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/adr/specs/publish-core-package-20260623-125551.md b/adr/specs/publish-core-package-20260623-125551.md index 1c9ab83ea..710ef3af0 100644 --- a/adr/specs/publish-core-package-20260623-125551.md +++ b/adr/specs/publish-core-package-20260623-125551.md @@ -37,33 +37,54 @@ The reuse surface currently has **9 global host-override switches** scattered ac - **Later (optional):** migrate the render-time seams to a `` (React context) if Workspaces wants per-instance config / SSR. The `configure()` facade is the 80/20 now; the Provider is the door it leaves open. - **Verify:** typecheck; a tiny test that `configurePlannotatorUI({...})` routes to each setter; Plannotator behavior unchanged (it never calls it). -## Step 6 — Precompiled CSS bundle (optional friction-reducer) -Sharing Tailwind-utility components forces the consumer to either scan our source (`@source`) or get a ready-made stylesheet. Ship the stylesheet to smooth the worst integration wrinkle. -- Add a build that emits a single precompiled CSS file for `@plannotator/ui` (theme tokens + the component utility classes), exported as e.g. `@plannotator/ui/styles.css`. The consumer imports one stylesheet instead of wiring Tailwind `@source` to ui internals. -- **Additive:** keep the `@source` glob path documented as the alternative; the source-ships-TS model is unchanged. This is the heavier of the two polish items (needs a small CSS build pipeline) — optional; the `@source` approach already works for the first Workspaces integration. +## Decisions locked (post-interrogation, 2026-06-23) +- **Ship TS source for the JS, NOT a compiled build.** Rationale: the only consumer (Workspaces) is internal and on a controlled stack (Vite/Cloudflare). A `tsup`/lib build exists only to insulate unknown/arbitrary-toolchain consumers — that insulation buys ~nothing here, and shipping source avoids a build pipeline to maintain and avoids a `dist` artifact that can drift from what Plannotator actually runs. Door stays open: add a build later if/when an external consumer appears. (Contested in review — one reviewer assumed a public lib; this is the deliberate call for the internal case.) +- **Precompiled CSS is REQUIRED, not optional** (Step 6). Even internally, the `@source` glob into `node_modules/@plannotator/ui/**/*.tsx` is fragile (pnpm symlinks break it) and a per-build perf cost. Ship the stylesheet. +- **`@plannotator/core` gets a node-free CI typecheck** (Step 1) so a stray `node:*` import fails the build — turns "confirm node-free by hand" into an enforced invariant. +- **Pin `@plannotator/ui` → `@plannotator/core` to an EXACT version** (not a range) during 0.x, so a consumer can't end up with mismatched copies (and silently diverge the annotation serializers). + +## Step 6 — Precompiled CSS bundle (REQUIRED) +Tailwind-utility components force the consumer to either scan our source (`@source`) or get a ready-made stylesheet. Ship the stylesheet — the `@source` route is fragile (pnpm symlinks) and costs every consumer build time. +- Add a CSS-only build that emits a single precompiled `@plannotator/ui/styles.css` (theme tokens + the component utility classes). This is a CSS pipeline only — the JS still ships as source (per the decision above). +- Keep the `@source` glob documented as the fallback for a consumer who wants to scan source, but the stylesheet is the supported default. - **Verify:** the precompiled CSS renders Plannotator-identical visuals in a bare consumer; Plannotator's own build/styling untouched. ## Step 7 — Publish (OUTWARD-FACING — confirm first) -- Decide registry (recommend **public npm**, matching existing flow), versions (recommend **0.1.0**, core+ui together). -- Write/READMEs documenting the consumer requirements: `moduleResolution: bundler`, `allowImportingTsExtensions`, `isolatedModules`, `jsx: react-jsx`, React 19 + Tailwind v4 (`@tailwindcss/vite`), Tailwind `@source` over `node_modules/@plannotator/ui/**/*.tsx`, import `@plannotator/ui/theme`. +- JS ships as **source** (no build); CSS ships **precompiled** (Step 6). `core` + `ui` `exports` stay source-only for `.ts`/`.tsx`, plus the `styles.css` entry. +- Decide registry (recommend **public npm**, matching existing flow), versions (recommend **0.1.0**, core+ui together), with `ui`→`core` pinned **exact**. +- Write/READMEs documenting consumer requirements: `moduleResolution: bundler`, `allowImportingTsExtensions`, `isolatedModules`, `jsx: react-jsx`, React 19, and **import `@plannotator/ui/styles.css`** (the `@source` glob is the documented fallback, not the default). - Add a publish job to `.github/workflows/release.yml` for `core` + `ui` (or publish manually the first time: `bun pm pack` each, `npm publish *.tgz --access public`). bun resolves `workspace:*` → real versions at pack time. - **Do not run the publish until the user explicitly approves** the registry + version + go. +## Carried-over review fixes (do before publish; NOT Phase-7 architecture) +These are small bugs/gaps the interrogation found in already-committed Phase-5 code. None affect Plannotator (override-path only); fix before a real consumer wires the seams: +1. **`useExternalAnnotations` split-transport** — the effect captures `transport` at mount for subscribe/poll, but the CRUD callbacks read the module global live → reads and writes can hit different backends if the transport is set after mount. Read consistently in both paths. (Check `useFileBrowser` for the same shape.) +2. **`useExternalAnnotations` `fallbackRef`/`receivedSnapshotRef` not reset on effect re-run** — if `enabled` toggles false→true (Workspaces auth/loading), the hook silently stops updating. Reset both at the top of the effect. +3. **Override path untested** — add one small test per seam that calls `setX(fake)`, drives the hook/component, asserts the contract, then `resetX()`. Makes the dead `reset*()` functions live and pins the subtle contracts (draft generation, SSE fallback). +4. (consider) `configStore` only redirects setting *writes* via `setStorageBackend`; the initial *load* already ran against cookies at module-init. If Workspaces needs to load its own settings, add a `loadFromBackend()`. Skip if Workspaces owns its settings entirely. + ## Definition of done (Phase 7) -- `@plannotator/core` exists, browser-safe, zero deps; the universal slice lives there once. +- `@plannotator/core` exists, browser-safe, zero deps; the universal slice lives there once; **CI typechecks it node-free** (no `@types/node`). - `@plannotator/shared` re-exports from core; Plannotator byte-unchanged (full `bun test` 1620/0, typecheck, builds, shipped-bundle hashes identical; `git diff` limited to core/shared/ui/editor packaging + import re-points). -- `@plannotator/ui` depends only on `@plannotator/core` internally; installs standalone (with `core`). +- `@plannotator/ui` depends only on `@plannotator/core` internally, **pinned exact**; JS ships as source; installs standalone (with `core`). - `wideMode.ts` relocated. - **`configurePlannotatorUI()` exists** as the single typed front door over the 9 global setters; Plannotator unchanged (never calls it). -- **(Optional) precompiled CSS** shipped so a consumer can import one stylesheet instead of wiring Tailwind `@source`. +- **Precompiled CSS (`@plannotator/ui/styles.css`) shipped** (required). +- The carried-over review fixes (split-transport, fallbackRef reset, per-seam override tests) are done. - Consumer requirements documented; publish job ready. - (On explicit go) `core` + `ui` published; Workspaces can `npm install @plannotator/ui @plannotator/core`, call `configurePlannotatorUI({...})` once, import `@plannotator/ui/styles.css`, and build. ## Parity guardrail (run after the carve, before publish) `bun run typecheck` · `bun test` 1620/0 · `bun run --cwd apps/review build && bun run build:hook && bun run build:opencode` · shipped-bundle hashes vs the Phase-0 baseline (should be identical) · `git diff` confined to the expected packages · Pi `vendor.sh`/typecheck still green. +## Decided +- **JS ships as source, not a build** (single internal consumer on a controlled stack). — locked +- **Precompiled CSS required.** — locked +- **`core` CI typecheck node-free; `ui`→`core` pinned exact.** — locked + ## Open questions (resolve in ADR) 1. Registry: public npm (recommended) vs private scope. 2. Versions: 0.1.0 (recommended) vs other; core+ui together vs independent. 3. CI publish job now vs manual first publish. 4. Confirm `@plannotator/ai` stays private (no ui value import) and `review-core`/`review-workspace` type handling. +5. In-scope or not: `configStore.loadFromBackend()` (only if Workspaces wants its own settings persistence). From b413d051bab7d5db7313240f453c4471392ced88 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 14:06:53 -0700 Subject: [PATCH 28/46] =?UTF-8?q?docs(adr):=20ADR=20007=20=E2=80=94=20carv?= =?UTF-8?q?e=20@plannotator/core,=20complete=20settings=20provider,=20publ?= =?UTF-8?q?ish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks Phase 7 decisions: public npm; lockstep version at repo 0.21.0 (ui->core pinned exact); JS ships as source + required precompiled CSS; core CI node-free; ai stays unpublished-to-npm. Settings provider completed (loadFromBackend, prefetch +sync) is now IN SCOPE — Workspaces uses the same UI settings stored in its own backend. CI publish job wired but artifacts validated on-branch (pack + dry-run) before merge; first publish gated. Carries the 2 override-path bug fixes + per-seam override tests as pre-publish work. --- ...ore-package-and-publish-20260623-140537.md | 66 +++++++++++++++++++ .../publish-core-package-20260623-125551.md | 24 +++---- 2 files changed, 79 insertions(+), 11 deletions(-) create mode 100644 adr/decisions/007-carve-core-package-and-publish-20260623-140537.md diff --git a/adr/decisions/007-carve-core-package-and-publish-20260623-140537.md b/adr/decisions/007-carve-core-package-and-publish-20260623-140537.md new file mode 100644 index 000000000..d2fc2488a --- /dev/null +++ b/adr/decisions/007-carve-core-package-and-publish-20260623-140537.md @@ -0,0 +1,66 @@ +# 007. Carve `@plannotator/core`, complete the settings provider, and publish `core` + `ui` + +Date: 2026-06-23 + +## Status + +Accepted + +## Context + +Phases 0–6 (ADRs 004–006) made Plannotator's document UI (`packages/ui` = `@plannotator/ui`) host-overridable through optional seams that default to today's behavior, with Plannotator verified byte-for-byte unchanged. The remaining work (Phase 7) is to make `@plannotator/ui` actually installable by a separate consumer (the commercial "Workspaces"/Enterprise app) and publish it. + +Two facts force the shape of this phase: + +1. **`@plannotator/ui` can't be published as-is.** It depends on `@plannotator/shared` and `@plannotator/ai`, both unpublished workspace packages. `@plannotator/shared` is a Node/git/server kitchen sink we don't want on npm. An external installer must resolve every dependency from the registry, so the dependency tail has to be dealt with — without copying (the user's hard requirement: single source of truth, no duplication). + +2. **Workspaces will use the same UI settings, stored in its own backend.** The storage seam (`setStorageBackend`, Phase 2) already redirects setting *writes*. But the initial settings *load* runs against cookies at module-init, before a host can install its backend — so Workspaces' saved settings wouldn't load. The settings provider is half-built. + +An adversarial multi-model review (the `interrogate` pass) confirmed Phases 0–6 are sound and proportionate, found no Plannotator-affecting issues, and surfaced a small set of override-path fixes plus publish-toolchain decisions. The one contested decision (ship TS source vs. a compiled build) was resolved deliberately for the internal-consumer case. + +Supporting docs: `adr/research/SPIKE-publish-core-package-20260623-125551.md`, `adr/research/synthesis-publish-core-package-20260623-125551.md`, `adr/specs/publish-core-package-20260623-125551.md`. + +## Decision + +**1. Carve a new browser-safe `@plannotator/core` package (single source of truth).** +- Move the ~15 pure browser-safe modules the UI uses out of `@plannotator/shared` into `@plannotator/core` (`code-file`, `extract-code-paths`, `agents`, `agent-jobs`, `compress`, `crypto`, `external-annotation`, `favicon`, `feedback-templates`, `goal-setup`, `browser-paths`, `project`, `agent-terminal`, `open-in-apps`, `source-save`). +- For the node-bound modules the UI imports only *types* from (`config`, `storage`, `workspace-status`, and any review types `ui` surfaces): extract the type definitions into `core`; the Node implementation stays in `shared` and imports its types back from `core`. Types live once; nothing is duplicated. +- Re-export `AIContext` from `core` so `ui` no longer imports `@plannotator/ai`. +- `@plannotator/shared` re-exports each moved module via one-line shims, so all ~99 internal import sites and the Pi `vendor.sh` step keep working unchanged. Plannotator stays untouched. +- `core` is source-only, browser-safe, zero npm/node deps. **CI typechecks `core` with no `@types/node`** so a stray `node:*` import fails the build. + +**2. Complete the settings provider (in scope — Workspaces needs it).** +- Add a `loadFromBackend()` path so the initial settings load routes through the installed `StorageBackend`, not only cookies. +- Use the **prefetch + synchronous backend** model: a host fetches its settings, installs a sync backend that serves from that prefetched data, then calls `loadFromBackend()`. No async plumbing inside `configStore`; Plannotator's eager cookie default is unchanged (it never calls `loadFromBackend()`). + +**3. Single configuration front door.** +- Add `configurePlannotatorUI(config)`: one typed call that fans out to the 9 global host-override setters (image, storage, doc-preview, file-tree, identity, draft, external-annotations, AI, config-sync) plus the settings load. Render-time prop seams stay as props. A `` (React context) is the documented later upgrade if per-instance/SSR config is ever needed. + +**4. Ship TS source for JS; ship precompiled CSS.** +- Publish `core` + `ui` as TS source (no compiled build). Rationale: the only consumer is internal on a controlled stack (Vite/Cloudflare); a build exists to insulate unknown toolchains and buys ~nothing here, while avoiding a build pipeline and a `dist` artifact that can drift from what Plannotator runs. Revisit only if an external/arbitrary-stack consumer appears. +- Ship a **required** precompiled `@plannotator/ui/styles.css` (CSS-only build). The Tailwind `@source` glob into `node_modules` is fragile (pnpm symlinks) and a per-build cost; the stylesheet is the supported default, `@source` the documented fallback. + +**5. Publishing.** +- **Public npm** (open-source project; matches the existing `@plannotator/opencode` / `@plannotator/pi-extension` flow). +- **Lockstep versioning at the repo version (`0.21.0`)**, consistent with the other published packages; `core` + `ui` move together; `ui` → `core` pinned **exact**. +- `@plannotator/ai` stays unpublished-to-npm (npm `private: true`); the UI doesn't need it. (This is an npm-registry flag only — the code stays open on GitHub like everything else.) +- **Wire a CI publish job** for `core` + `ui` in `release.yml`. Before merging to main, **validate the artifacts on the branch**: `bun pm pack` each, inspect the tarball, and `npm publish --dry-run`. The first real publish goes out only on explicit go. + +**6. Pre-publish fixes (override-path only; none affect Plannotator), from the interrogation:** +- Fix `useExternalAnnotations` split-transport (reads/writes can hit different backends if the transport is set after mount); check `useFileBrowser` for the same shape. +- Reset `fallbackRef`/`receivedSnapshotRef` on effect re-run so a `false→true` `enabled` toggle doesn't silently stop updates. +- Add one override test per seam (`setX(fake)` → drive → assert → `resetX()`), which also makes the `reset*()` functions live. + +## Consequences + +- `@plannotator/ui` becomes installable: a consumer runs `npm install @plannotator/ui @plannotator/core`, calls `configurePlannotatorUI({...})` once, imports `@plannotator/ui/styles.css`, and builds — with the same UI settings persisted through its own backend. +- One copy of every shared module/type remains; `@plannotator/shared` and `@plannotator/ai` stay private to the monorepo. Plannotator's server, apps, editor, review-editor, and Pi build are unchanged. +- The carve + provider completion + fixes are all reversible and keep Plannotator byte-for-byte identical (parity gate: `bun test` 1620/0, typecheck, byte-identical shipped bundles, `git diff` confined to `core`/`shared`/`ui`/`editor`). The publish is the one outward-facing, hard-to-undo step and is gated on explicit approval after branch-validation. +- Shipping source couples consumers to a documented tsconfig/bundler setup; acceptable for the internal consumer, and the door to a compiled build stays open. +- New maintenance surface: a small published `@plannotator/core`, a CSS-only build, exact-version coupling between `core` and `ui`, and a node-free CI check on `core`. + +## References +- Spec: `adr/specs/publish-core-package-20260623-125551.md` +- Synthesis: `adr/research/synthesis-publish-core-package-20260623-125551.md` +- Spike: `adr/research/SPIKE-publish-core-package-20260623-125551.md` +- Governing decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md` diff --git a/adr/specs/publish-core-package-20260623-125551.md b/adr/specs/publish-core-package-20260623-125551.md index 710ef3af0..5e8dd73e0 100644 --- a/adr/specs/publish-core-package-20260623-125551.md +++ b/adr/specs/publish-core-package-20260623-125551.md @@ -61,7 +61,7 @@ These are small bugs/gaps the interrogation found in already-committed Phase-5 c 1. **`useExternalAnnotations` split-transport** — the effect captures `transport` at mount for subscribe/poll, but the CRUD callbacks read the module global live → reads and writes can hit different backends if the transport is set after mount. Read consistently in both paths. (Check `useFileBrowser` for the same shape.) 2. **`useExternalAnnotations` `fallbackRef`/`receivedSnapshotRef` not reset on effect re-run** — if `enabled` toggles false→true (Workspaces auth/loading), the hook silently stops updating. Reset both at the top of the effect. 3. **Override path untested** — add one small test per seam that calls `setX(fake)`, drives the hook/component, asserts the contract, then `resetX()`. Makes the dead `reset*()` functions live and pins the subtle contracts (draft generation, SSE fallback). -4. (consider) `configStore` only redirects setting *writes* via `setStorageBackend`; the initial *load* already ran against cookies at module-init. If Workspaces needs to load its own settings, add a `loadFromBackend()`. Skip if Workspaces owns its settings entirely. +4. **(in scope — Workspaces needs it)** Complete the settings provider: `setStorageBackend` only redirects setting *writes*; the initial *load* runs against cookies at module-init. Workspaces uses the same UI settings stored in its own backend → add `loadFromBackend()`. Model: **prefetch + synchronous backend** (host fetches settings → installs a sync backend serving from that data → calls `loadFromBackend()`); no async plumbing in `configStore`; Plannotator's eager cookie default unchanged (never calls it). ## Definition of done (Phase 7) - `@plannotator/core` exists, browser-safe, zero deps; the universal slice lives there once; **CI typechecks it node-free** (no `@types/node`). @@ -77,14 +77,16 @@ These are small bugs/gaps the interrogation found in already-committed Phase-5 c ## Parity guardrail (run after the carve, before publish) `bun run typecheck` · `bun test` 1620/0 · `bun run --cwd apps/review build && bun run build:hook && bun run build:opencode` · shipped-bundle hashes vs the Phase-0 baseline (should be identical) · `git diff` confined to the expected packages · Pi `vendor.sh`/typecheck still green. -## Decided -- **JS ships as source, not a build** (single internal consumer on a controlled stack). — locked -- **Precompiled CSS required.** — locked -- **`core` CI typecheck node-free; `ui`→`core` pinned exact.** — locked - -## Open questions (resolve in ADR) -1. Registry: public npm (recommended) vs private scope. -2. Versions: 0.1.0 (recommended) vs other; core+ui together vs independent. -3. CI publish job now vs manual first publish. -4. Confirm `@plannotator/ai` stays private (no ui value import) and `review-core`/`review-workspace` type handling. +## Decided (locked in ADR 007) +- **Registry: public npm.** +- **Versions: lockstep at repo version `0.21.0`; `ui`→`core` pinned exact.** +- **JS ships as source, not a build** (single internal consumer on a controlled stack). +- **Precompiled CSS required.** +- **`core` CI typecheck node-free.** +- **`@plannotator/ai` stays unpublished-to-npm** (`private:true`; UI doesn't need it — only `AIContext`, re-exported via `core`). +- **Settings provider completed** (`loadFromBackend()`, prefetch+sync) — in scope. +- **CI publish job wired**, but **validate artifacts on the branch first** (`bun pm pack` + inspect + `npm publish --dry-run`) before merge; first real publish gated on explicit go. + +## Still to verify at implementation +- `review-core`/`review-workspace` type handling (whether `ui`'s `@plannotator/shared/types` surfaces any node-bound review types → extract if so). 5. In-scope or not: `configStore.loadFromBackend()` (only if Workspaces wants its own settings persistence). From 64beb6ed7cc7ce41ac66ad2d89ace49e3e9b306d Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 14:16:06 -0700 Subject: [PATCH 29/46] fix(ui): make external-annotation transport reads consistent + reset fallback on re-enable Two override-path bugs found by the interrogation pass (both unreachable on Plannotator's path; harden the host-override path for a real consumer): 1. Split-transport: the effect captured the transport at mount for subscribe/poll while the CRUD callbacks read the module global live, so a host swapping the transport after mount would split reads and writes across two backends. Capture once in a ref and use it in all four spots. 2. fallbackRef/receivedSnapshotRef were not reset on effect re-run, so an enabled false->true toggle inherited a stale 'already fell back' flag and silently stopped updating. Reset both at the top of the effect. Plannotator unchanged: it never overrides the transport (same default singleton captured) and enabled never toggles (reset is a no-op). typecheck clean; full test suite shows zero delta (1605 pass / 45 pre-existing env failures, identical with and without this change). --- packages/ui/hooks/useExternalAnnotations.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/ui/hooks/useExternalAnnotations.ts b/packages/ui/hooks/useExternalAnnotations.ts index 8f586caec..da750c7b7 100644 --- a/packages/ui/hooks/useExternalAnnotations.ts +++ b/packages/ui/hooks/useExternalAnnotations.ts @@ -127,12 +127,22 @@ export function useExternalAnnotations | null>(null); const receivedSnapshotRef = useRef(false); + // Capture the transport once so subscribe/poll and CRUD always use the same + // backend instance (contract: "set once at startup"). Reading the live module + // global in CRUD while the effect captured at mount would split reads and + // writes across two backends if a host swapped the transport after mount. + const transportRef = useRef(externalAnnotationTransport as ExternalAnnotationTransport); useEffect(() => { if (!enabled) return; let cancelled = false; - const transport = externalAnnotationTransport as ExternalAnnotationTransport; + // 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; + + const transport = transportRef.current; // --- Reducer (applies snapshot|add|remove|clear|update), verbatim --- function applyEvent(parsed: ExternalAnnotationEvent) { @@ -219,7 +229,7 @@ export function useExternalAnnotations prev.filter((a) => a.id !== id)); try { - await (externalAnnotationTransport as ExternalAnnotationTransport).remove(id); + await transportRef.current.remove(id); } catch { // SSE will reconcile on next event } @@ -231,7 +241,7 @@ export function useExternalAnnotations a.source !== source) : [], ); try { - await (externalAnnotationTransport as ExternalAnnotationTransport).clear(source); + await transportRef.current.clear(source); } catch { // SSE will reconcile on next event } @@ -240,7 +250,7 @@ export function useExternalAnnotations) => { setAnnotations((prev) => prev.map((a) => (a.id === id ? { ...a, ...updates } : a))); try { - await (externalAnnotationTransport as ExternalAnnotationTransport).update(id, updates); + await transportRef.current.update(id, updates); } catch { // SSE will reconcile on next event } From ec6fc9006f2932467075d70d2f830b1bc2d3de1c Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 14:42:37 -0700 Subject: [PATCH 30/46] docs(adr): align Phase 7 spec with ADR 007 (version 0.21.0 lockstep, CSS required, scope completeness) --- adr/specs/publish-core-package-20260623-125551.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/adr/specs/publish-core-package-20260623-125551.md b/adr/specs/publish-core-package-20260623-125551.md index 5e8dd73e0..608e8dc6f 100644 --- a/adr/specs/publish-core-package-20260623-125551.md +++ b/adr/specs/publish-core-package-20260623-125551.md @@ -5,11 +5,11 @@ Date: 2026-06-23 · Status: Draft (iterate before implementing) > Implementation spec for Phase 7. Grounded in `SPIKE-publish-core-package-20260623-125551.md` + its synthesis. Decision: single source of truth (no copying). THE LAW: Plannotator stays byte-for-byte unchanged through the carve; the publish is the one outward-facing step — confirm with the user before pushing to any registry. ## Scope -**In:** create `@plannotator/core`, move the universal slice + extract types from node-bound modules, shim `@plannotator/shared`, re-point `@plannotator/ui`, move `wideMode.ts`, add a single `configurePlannotatorUI()` front door, (optionally) ship a precompiled CSS bundle, prep + (on go-ahead) publish `core` + `ui`. +**In:** create `@plannotator/core`, move the universal slice + extract types from node-bound modules, shim `@plannotator/shared`, re-point `@plannotator/ui`, move `wideMode.ts`, complete the settings provider (`loadFromBackend`, prefetch+sync), add a single `configurePlannotatorUI()` front door, ship a (required) precompiled CSS bundle, fix the 2 override-path bugs + add per-seam override tests, prep + (on go-ahead) publish `core` + `ui`. **Out / stays private:** `@plannotator/shared` (Node/git/server grab-bag) and `@plannotator/ai` (ui only needs the `AIContext` type via core). ## Step 1 — Create `packages/core` -- `packages/core/package.json`: `name @plannotator/core`, `version 0.1.0`, `type module`, source-only `exports` map (fine-grained subpaths like `ui`), `files` allowlist (`*.ts`, exclude tests), **no dependencies** (peerDeps none — it's pure JS/Web-API). tsconfig mirroring `ui` (bundler resolution, isolatedModules, allowImportingTsExtensions). +- `packages/core/package.json`: `name @plannotator/core`, `version 0.21.0` (lockstep with the repo, per ADR 007), `type module`, source-only `exports` map (fine-grained subpaths like `ui`), `files` allowlist (`*.ts`, exclude tests), **no dependencies** (peerDeps none — it's pure JS/Web-API). tsconfig mirroring `ui` (bundler resolution, isolatedModules, allowImportingTsExtensions). - Add `@plannotator/core` to root `workspaces` (already covered by `packages/*`). - **Move (git mv) the ~15 pure modules** from `packages/shared` → `packages/core`: `code-file`, `extract-code-paths`, `agents`, `agent-jobs`, `compress`, `crypto`, `external-annotation`, `favicon`, `feedback-templates`, `goal-setup`, `browser-paths`, `project`, `agent-terminal`, `open-in-apps`, `source-save`. (Confirm each is node-free at move time.) - **Extract types** from the node-bound modules into core type files: `core/config-types.ts` (DefaultDiffType, DiffLineBgIntensity, DiffOptions, …), `core/storage-types.ts` (ArchivedPlan), `core/workspace-status-types.ts` (WorkspaceFileChange, WorkspaceStatusPayload, GitRepositoryInfo). Plus any review types `ui`'s `@plannotator/shared/types` surfaces (verify `review-core`/`review-workspace` usage). From f5cf35e127c3e94268368c46c18b9fb052248a2b Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 15:29:00 -0700 Subject: [PATCH 31/46] =?UTF-8?q?feat(core):=20carve=20@plannotator/core?= =?UTF-8?q?=20=E2=80=94=20move=20pure=20modules,=20extract=20node-bound=20?= =?UTF-8?q?types,=20shim=20shared=20(Phase=207=20step=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/pi-extension/vendor.sh | 32 +- bun.lock | 13 + package.json | 2 +- packages/ai/package.json | 4 +- packages/ai/types.ts | 78 +-- packages/core/agent-jobs.ts | 149 ++++++ .../{shared => core}/agent-terminal.test.ts | 0 packages/core/agent-terminal.ts | 53 ++ packages/core/agents.ts | 53 ++ packages/core/ai-context.ts | 76 +++ packages/core/browser-paths.ts | 25 + packages/{shared => core}/code-file.test.ts | 0 packages/core/code-file.ts | 41 ++ packages/core/compress.ts | 51 ++ packages/core/config-types.ts | 18 + packages/{shared => core}/crypto.test.ts | 0 packages/core/crypto.ts | 97 ++++ packages/core/external-annotation.ts | 455 +++++++++++++++++ .../extract-code-paths.test.ts | 0 packages/core/extract-code-paths.ts | 66 +++ packages/core/favicon.ts | 5 + .../feedback-templates.test.ts | 0 packages/core/feedback-templates.ts | 45 ++ packages/{shared => core}/goal-setup.test.ts | 0 packages/core/goal-setup.ts | 336 +++++++++++++ packages/core/index.ts | 2 + packages/core/open-in-apps.ts | 189 ++++++++ packages/core/package.json | 33 ++ packages/core/project.ts | 71 +++ packages/{shared => core}/source-save.test.ts | 0 packages/core/source-save.ts | 138 ++++++ packages/core/storage-types.ts | 8 + packages/core/tsconfig.json | 21 + packages/core/types.ts | 10 + packages/core/workspace-status-types.ts | 39 ++ packages/shared/agent-jobs.ts | 150 +----- packages/shared/agent-terminal.ts | 54 +-- packages/shared/agents.ts | 54 +-- packages/shared/browser-paths.ts | 26 +- packages/shared/code-file.ts | 42 +- packages/shared/compress.ts | 52 +- packages/shared/config.ts | 20 +- packages/shared/crypto.ts | 98 +--- packages/shared/external-annotation.ts | 456 +----------------- packages/shared/extract-code-paths.ts | 67 +-- packages/shared/favicon.ts | 6 +- packages/shared/feedback-templates.ts | 46 +- packages/shared/goal-setup.ts | 337 +------------ packages/shared/open-in-apps.ts | 190 +------- packages/shared/package.json | 1 + packages/shared/project.ts | 72 +-- packages/shared/source-save.ts | 139 +----- packages/shared/storage.ts | 10 +- packages/shared/types.ts | 11 +- packages/shared/workspace-status.ts | 41 +- 55 files changed, 2053 insertions(+), 1929 deletions(-) create mode 100644 packages/core/agent-jobs.ts rename packages/{shared => core}/agent-terminal.test.ts (100%) create mode 100644 packages/core/agent-terminal.ts create mode 100644 packages/core/agents.ts create mode 100644 packages/core/ai-context.ts create mode 100644 packages/core/browser-paths.ts rename packages/{shared => core}/code-file.test.ts (100%) create mode 100644 packages/core/code-file.ts create mode 100644 packages/core/compress.ts create mode 100644 packages/core/config-types.ts rename packages/{shared => core}/crypto.test.ts (100%) create mode 100644 packages/core/crypto.ts create mode 100644 packages/core/external-annotation.ts rename packages/{shared => core}/extract-code-paths.test.ts (100%) create mode 100644 packages/core/extract-code-paths.ts create mode 100644 packages/core/favicon.ts rename packages/{shared => core}/feedback-templates.test.ts (100%) create mode 100644 packages/core/feedback-templates.ts rename packages/{shared => core}/goal-setup.test.ts (100%) create mode 100644 packages/core/goal-setup.ts create mode 100644 packages/core/index.ts create mode 100644 packages/core/open-in-apps.ts create mode 100644 packages/core/package.json create mode 100644 packages/core/project.ts rename packages/{shared => core}/source-save.test.ts (100%) create mode 100644 packages/core/source-save.ts create mode 100644 packages/core/storage-types.ts create mode 100644 packages/core/tsconfig.json create mode 100644 packages/core/types.ts create mode 100644 packages/core/workspace-status-types.ts diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh index c6f71aef2..3df7c1fb8 100755 --- a/apps/pi-extension/vendor.sh +++ b/apps/pi-extension/vendor.sh @@ -7,7 +7,29 @@ cd "$(dirname "$0")" rm -rf generated mkdir -p generated generated/ai/providers -for f in feedback-templates prompts review-core diff-paths cli-pagination jj-core vcs-core review-args storage draft project pr-types pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon code-file resolve-file annotate-reference-roots-node config external-annotation agent-jobs agent-terminal worktree worktree-pool html-to-markdown html-assets html-assets-node url-to-markdown tour annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff source-save source-save-node workspace-status open-in-apps review-profiles; do +# Modules that MOVED to @plannotator/core — vendor the real impl from core. +for f in feedback-templates project favicon code-file external-annotation agent-jobs agent-terminal source-save open-in-apps; do + src="../../packages/core/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/core/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" +done + +# Node-bound shared modules that now import types from @plannotator/core/*-types — +# vendor from shared, rewrite the bare core specifier to the flat relative path. +for f in config storage workspace-status; do + src="../../packages/shared/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" \ + | sed "s|from ['\"]@plannotator/core/\\([^'\"]*\\)-types['\"]|from './\\1-types.js'|g" \ + > "generated/$f.ts" +done + +# Extracted type files those node-bound modules now depend on — vendor from core. +for f in config-types storage-types workspace-status-types; do + src="../../packages/core/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/core/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" +done + +# Everything else in the original flat list stays sourced from packages/shared. +for f in prompts review-core diff-paths cli-pagination jj-core vcs-core review-args draft pr-types pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common resolve-file annotate-reference-roots-node worktree worktree-pool html-to-markdown html-assets html-assets-node url-to-markdown tour annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff source-save-node review-profiles; do src="../../packages/shared/$f.ts" printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" done @@ -40,9 +62,15 @@ for f in tour-review; do > "generated/$f.ts" done +# Vendor the moved AI context types from core into generated/ai/. +printf '// @generated — DO NOT EDIT. Source: packages/core/ai-context.ts\n' \ + | cat - "../../packages/core/ai-context.ts" > "generated/ai/ai-context.ts" + for f in index types provider session-manager endpoints context base-session; do src="../../packages/ai/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/ai/%s.ts\n' "$f" | cat - "$src" > "generated/ai/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/ai/%s.ts\n' "$f" | cat - "$src" \ + | sed "s|from ['\"]@plannotator/core/ai-context['\"]|from './ai-context.js'|g" \ + > "generated/ai/$f.ts" done for f in claude-agent-sdk codex-sdk opencode-sdk command-path pi-sdk pi-sdk-node pi-events; do diff --git a/bun.lock b/bun.lock index ffda4a059..2b7a1fa94 100644 --- a/bun.lock +++ b/bun.lock @@ -168,6 +168,16 @@ "packages/ai": { "name": "@plannotator/ai", "version": "0.0.1", + "dependencies": { + "@plannotator/core": "workspace:*", + }, + }, + "packages/core": { + "name": "@plannotator/core", + "version": "0.21.0", + "devDependencies": { + "typescript": "~5.8.2", + }, }, "packages/editor": { "name": "@plannotator/editor", @@ -235,6 +245,7 @@ "version": "0.0.1", "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", + "@plannotator/core": "workspace:*", "parse5": "^7.3.0", "turndown": "^7.2.4", }, @@ -804,6 +815,8 @@ "@plannotator/ai": ["@plannotator/ai@workspace:packages/ai"], + "@plannotator/core": ["@plannotator/core@workspace:packages/core"], + "@plannotator/editor": ["@plannotator/editor@workspace:packages/editor"], "@plannotator/hooks": ["@plannotator/hooks@workspace:apps/hook"], diff --git a/package.json b/package.json index e532c5da9..84fcc54e1 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "build:vscode": "bun run --cwd apps/vscode-extension build", "package:vscode": "bun run --cwd apps/vscode-extension package", "test": "bun test", - "typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json" + "typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.92", diff --git a/packages/ai/package.json b/packages/ai/package.json index 7cd03cac6..a72346f5a 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -17,5 +17,7 @@ "./providers/opencode-sdk": "./providers/opencode-sdk.ts", "./providers/pi-sdk-node": "./providers/pi-sdk-node.ts" }, - "dependencies": {} + "dependencies": { + "@plannotator/core": "workspace:*" + } } diff --git a/packages/ai/types.ts b/packages/ai/types.ts index b87e79f8f..e9c5ab424 100644 --- a/packages/ai/types.ts +++ b/packages/ai/types.ts @@ -10,82 +10,8 @@ // Context — what the AI session knows about // --------------------------------------------------------------------------- -/** The surface the user is interacting with when they invoke AI. */ -export type AIContextMode = "plan-review" | "code-review" | "annotate"; - -/** - * Describes the parent agent session that originally produced the plan or diff. - * Used to fork conversations with full history. - */ -export interface ParentSession { - /** Session ID from the host agent (e.g. Claude Code session UUID). */ - sessionId: string; - /** Working directory the parent session was running in. */ - cwd: string; -} - -/** - * Snapshot of plan-review-specific context. - * Passed when AIContextMode is "plan-review". - */ -export interface PlanContext { - /** The full plan markdown as submitted by the agent. */ - plan: string; - /** Previous plan version (if this is a resubmission). */ - previousPlan?: string; - /** The version number in the plan's history. */ - version?: number; - /** Total number of versions in the plan's history. */ - totalVersions?: number; - /** Project/repository label used for plan history. */ - project?: string; - /** Annotations the user has made so far (serialised for the prompt). */ - annotations?: string; -} - -/** - * Snapshot of code-review-specific context. - * Passed when AIContextMode is "code-review". - */ -export interface CodeReviewContext { - /** The unified diff patch. */ - patch: string; - /** The specific file being discussed (if scoped). */ - filePath?: string; - /** The line range being discussed (if scoped). */ - lineRange?: { start: number; end: number; side: "old" | "new" }; - /** The code snippet being discussed (if scoped). */ - selectedCode?: string; - /** Summary of annotations the user has made. */ - annotations?: string; -} - -/** - * Snapshot of annotate-mode context. - * Passed when AIContextMode is "annotate". - */ -export interface AnnotateContext { - /** The markdown file content being annotated. */ - content: string; - /** Path to the file on disk. */ - filePath: string; - /** Source attribution shown in the UI, such as an original URL or filename. */ - sourceInfo?: string; - /** True when the document was converted from HTML or a remote reader result. */ - sourceConverted?: boolean; - /** Render mode for the annotated content. */ - renderAs?: "markdown" | "html"; - /** Summary of annotations the user has made. */ - annotations?: string; -} - -/** - * Union of mode-specific contexts, discriminated by `mode`. - */ -export type AIContext = - | { mode: "plan-review"; plan: PlanContext; parent?: ParentSession } - | { mode: "code-review"; review: CodeReviewContext; parent?: ParentSession } - | { mode: "annotate"; annotate: AnnotateContext; parent?: ParentSession }; +import type { AIContext, AIContextMode, PlanContext, CodeReviewContext, AnnotateContext, ParentSession } from '@plannotator/core/ai-context'; +export type { AIContext, AIContextMode, PlanContext, CodeReviewContext, AnnotateContext, ParentSession }; // --------------------------------------------------------------------------- // Messages — what streams back from the AI diff --git a/packages/core/agent-jobs.ts b/packages/core/agent-jobs.ts new file mode 100644 index 000000000..a97e980f9 --- /dev/null +++ b/packages/core/agent-jobs.ts @@ -0,0 +1,149 @@ +/** + * Agent Jobs — shared types, state machine, and SSE helpers. + * + * Runtime-agnostic: no node:fs, no node:http, no Bun APIs. + * Both the Bun server handler and (future) Node handler import + * this module and wrap it with their respective HTTP transport layers. + * + * Mirrors packages/shared/external-annotation.ts in structure. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type AgentJobStatus = "starting" | "running" | "done" | "failed" | "killed"; + +/** + * Snapshot of the diff the reviewer was looking at when this job was launched. + * Carried on the job so downstream UIs (agent-result panel "Copy All") export + * the same `**Diff:** ...` header the job was actually run against — if the + * reviewer switches the UI to a different diff afterwards, the job's snapshot + * still reflects truth. Structurally compatible with the UI-side + * `FeedbackDiffContext` in `packages/review-editor/utils/exportFeedback.ts`. + */ +export interface AgentJobDiffContext { + mode: string; + base?: string; + worktreePath?: string | null; +} + +export interface AgentJobInfo { + /** Unique job identifier (UUID). */ + id: string; + /** Source identifier for external annotations — "agent-{id prefix}". */ + source: string; + /** Provider that spawned this job — "claude", "codex", "tour", "shell", etc. */ + provider: string; + /** Underlying engine used (e.g., "claude" or "codex"). Set when provider is "tour". */ + engine?: string; + /** Model used (e.g., "sonnet", "opus"). Set when provider is "tour" with Claude engine. */ + model?: string; + /** Claude --effort level (e.g., "low", "medium", "high", "xhigh", "max"). */ + effort?: string; + /** Codex reasoning effort level (e.g., "high", "medium"). */ + reasoningEffort?: string; + /** Whether Codex fast mode (service_tier=fast) was enabled. */ + fastMode?: boolean; + /** Human-readable label for the job. */ + label: string; + /** Current lifecycle status. */ + status: AgentJobStatus; + /** Timestamp when the job was created. */ + startedAt: number; + /** Timestamp when the job reached a terminal state. */ + endedAt?: number; + /** Process exit code (set on done/failed). */ + exitCode?: number; + /** Last ~500 chars of stderr on failure. */ + error?: string; + /** The actual command that was spawned (for display/debug). */ + command: string[]; + /** Working directory where the process was spawned. */ + cwd?: string; + /** The review prompt text (system + user message). Stored separately from command for providers that use stdin. */ + prompt?: string; + /** Review summary set by the agent on completion. */ + summary?: { + correctness: string; + explanation: string; + confidence: number; + }; + /** PR URL at launch time — used to attribute findings to the correct PR. */ + prUrl?: string; + /** PR diff scope at launch time — "layer" or "full-stack". */ + diffScope?: string; + /** Diff context at launch time (see AgentJobDiffContext). */ + diffContext?: AgentJobDiffContext; + /** Resolved review profile id at launch time (e.g. "builtin:default", "user:security"). */ + reviewProfileId?: string; + /** Resolved review profile label — rides on findings so the UI can show a profile tag. */ + reviewProfileLabel?: string; +} + +export interface AgentCapability { + id: string; + name: string; + available: boolean; +} + +export interface AgentCapabilities { + mode: "plan" | "review" | "annotate"; + providers: AgentCapability[]; + /** True if at least one provider is available. */ + available: boolean; +} + +// --------------------------------------------------------------------------- +// SSE event types +// --------------------------------------------------------------------------- + +export type AgentJobEvent = + | { type: "snapshot"; jobs: AgentJobInfo[] } + | { type: "job:started"; job: AgentJobInfo } + | { type: "job:updated"; job: AgentJobInfo } + | { type: "job:completed"; job: AgentJobInfo } + | { type: "job:log"; jobId: string; delta: string } + | { type: "jobs:cleared" }; + +// --------------------------------------------------------------------------- +// SSE helpers +// --------------------------------------------------------------------------- + +/** Heartbeat comment to keep SSE connections alive (sent every 30s). */ +export const AGENT_HEARTBEAT_COMMENT = ":\n\n"; + +/** Interval in ms between heartbeat comments. */ +export const AGENT_HEARTBEAT_INTERVAL_MS = 30_000; + +/** Encode an event as an SSE `data:` line. */ +export function serializeAgentSSEEvent(event: AgentJobEvent): string { + return `data: ${JSON.stringify(event)}\n\n`; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Check if a status is terminal (no further transitions). */ +export function isTerminalStatus(status: AgentJobStatus): boolean { + return status === "done" || status === "failed" || status === "killed"; +} + +/** Generate the source identifier for a job from its ID. */ +export function jobSource(id: string): string { + return "agent-" + id.slice(0, 8); +} + +// --------------------------------------------------------------------------- +// Review ingestion completion semantics +// --------------------------------------------------------------------------- + +/** Calm, provider-neutral failure reason. Never leak schema/CLI internals. */ +export const REVIEW_OUTPUT_FAILED = "Review finished but produced no usable findings."; + +/** Flip a job to failed with a calm one-liner (Code Tour precedent). */ +export function markJobReviewFailed(job: AgentJobInfo, error: string): void { + job.status = "failed"; + job.error = error; +} diff --git a/packages/shared/agent-terminal.test.ts b/packages/core/agent-terminal.test.ts similarity index 100% rename from packages/shared/agent-terminal.test.ts rename to packages/core/agent-terminal.test.ts diff --git a/packages/core/agent-terminal.ts b/packages/core/agent-terminal.ts new file mode 100644 index 000000000..42f8339a3 --- /dev/null +++ b/packages/core/agent-terminal.ts @@ -0,0 +1,53 @@ +export const AGENT_TERMINAL_WS_BASE_PATH = "/api/agent-terminal/pty"; + +export function buildAgentTerminalWsPath(token: string): string { + if (!token || token.includes("/") || token.includes("?") || token.includes("#")) { + throw new Error("Agent terminal WebSocket token must be a non-empty path segment."); + } + return `${AGENT_TERMINAL_WS_BASE_PATH}/${encodeURIComponent(token)}`; +} + +export function isAgentTerminalWsRoute(pathname: string): boolean { + return pathname === AGENT_TERMINAL_WS_BASE_PATH || + pathname.startsWith(`${AGENT_TERMINAL_WS_BASE_PATH}/`); +} + +export type AgentTerminalDisabledReason = + | "not-annotate-mode" + | "remote-disabled" + | "runtime-unavailable" + | "webtui-unavailable" + | "pty-unavailable" + | "unsupported-runtime"; + +export type AgentTerminalAgent = { + id: string; + name: string; + available: boolean; +}; + +export type AgentTerminalCapability = + | { + enabled: true; + cwd: string; + wsPath: string; + agents: AgentTerminalAgent[]; + } + | { + enabled: false; + reason: AgentTerminalDisabledReason; + message?: string; + }; + +export type AnnotateAgentTerminalMode = + | "annotate" + | "annotate-last" + | "annotate-folder" + | string + | undefined; + +export function supportsAnnotateAgentTerminalMode( + mode: AnnotateAgentTerminalMode, +): boolean { + return mode === "annotate" || mode === "annotate-folder"; +} diff --git a/packages/core/agents.ts b/packages/core/agents.ts new file mode 100644 index 000000000..cc9994135 --- /dev/null +++ b/packages/core/agents.ts @@ -0,0 +1,53 @@ +/** + * Centralized agent configuration — single source of truth for all supported agents. + * + * To add a new agent: + * 1. Add an entry to AGENT_CONFIG below (origin key, display name, badge CSS classes, + * optional AI provider types) + * 2. If detection is via environment variable, add it to the detection chain + * in apps/hook/server/index.ts (detectedOrigin constant) + * 3. That's it — all UI components read from this config automatically + */ + +type AgentConfigEntry = { + name: string; + badge: string; + /** AI provider type(s) that naturally match this origin, in preference order. */ + aiProviderTypes?: readonly string[]; +}; + +export const AGENT_CONFIG = { + 'claude-code': { name: 'Claude Code', badge: 'bg-orange-500/15 text-orange-400', aiProviderTypes: ['claude-agent-sdk'] }, + 'amp': { name: 'Amp', badge: 'bg-lime-500/15 text-lime-400' }, + 'droid': { name: 'Droid', badge: 'bg-cyan-500/15 text-cyan-400' }, + 'kiro-cli': { name: 'Kiro CLI', badge: 'bg-amber-500/15 text-amber-400' }, + 'opencode': { name: 'OpenCode', badge: 'bg-emerald-500/15 text-emerald-400', aiProviderTypes: ['opencode-sdk'] }, + 'copilot-cli': { name: 'GitHub Copilot', badge: 'bg-blue-500/15 text-blue-400' }, + 'pi': { name: 'Pi', badge: 'bg-violet-500/15 text-violet-400', aiProviderTypes: ['pi-sdk'] }, + 'codex': { name: 'Codex', badge: 'bg-purple-500/15 text-purple-400', aiProviderTypes: ['codex-sdk'] }, + 'gemini-cli': { name: 'Gemini CLI', badge: 'bg-sky-500/15 text-sky-400' }, +} as const satisfies Record; + +/** All recognized origin values. */ +export type Origin = keyof typeof AGENT_CONFIG; + +/** Resolve an origin to a human-readable agent name. */ +export function getAgentName(origin: Origin | null | undefined): string { + if (origin && origin in AGENT_CONFIG) return AGENT_CONFIG[origin as Origin].name; + return 'Coding Agent'; +} + +/** Resolve an origin to Tailwind badge classes. */ +export function getAgentBadge(origin: Origin | null | undefined): string { + if (origin && origin in AGENT_CONFIG) return AGENT_CONFIG[origin as Origin].badge; + return 'bg-zinc-500/20 text-zinc-400'; +} + +/** Resolve an origin to matching AI provider types, in preference order. */ +export function getAgentAIProviderTypes(origin: Origin | null | undefined): readonly string[] { + if (origin && origin in AGENT_CONFIG) { + const config = AGENT_CONFIG[origin as Origin]; + return 'aiProviderTypes' in config ? config.aiProviderTypes : []; + } + return []; +} diff --git a/packages/core/ai-context.ts b/packages/core/ai-context.ts new file mode 100644 index 000000000..57e2f8bb9 --- /dev/null +++ b/packages/core/ai-context.ts @@ -0,0 +1,76 @@ +/** The surface the user is interacting with when they invoke AI. */ +export type AIContextMode = "plan-review" | "code-review" | "annotate"; + +/** + * Describes the parent agent session that originally produced the plan or diff. + * Used to fork conversations with full history. + */ +export interface ParentSession { + /** Session ID from the host agent (e.g. Claude Code session UUID). */ + sessionId: string; + /** Working directory the parent session was running in. */ + cwd: string; +} + +/** + * Snapshot of plan-review-specific context. + * Passed when AIContextMode is "plan-review". + */ +export interface PlanContext { + /** The full plan markdown as submitted by the agent. */ + plan: string; + /** Previous plan version (if this is a resubmission). */ + previousPlan?: string; + /** The version number in the plan's history. */ + version?: number; + /** Total number of versions in the plan's history. */ + totalVersions?: number; + /** Project/repository label used for plan history. */ + project?: string; + /** Annotations the user has made so far (serialised for the prompt). */ + annotations?: string; +} + +/** + * Snapshot of code-review-specific context. + * Passed when AIContextMode is "code-review". + */ +export interface CodeReviewContext { + /** The unified diff patch. */ + patch: string; + /** The specific file being discussed (if scoped). */ + filePath?: string; + /** The line range being discussed (if scoped). */ + lineRange?: { start: number; end: number; side: "old" | "new" }; + /** The code snippet being discussed (if scoped). */ + selectedCode?: string; + /** Summary of annotations the user has made. */ + annotations?: string; +} + +/** + * Snapshot of annotate-mode context. + * Passed when AIContextMode is "annotate". + */ +export interface AnnotateContext { + /** The markdown file content being annotated. */ + content: string; + /** Path to the file on disk. */ + filePath: string; + /** Source attribution shown in the UI, such as an original URL or filename. */ + sourceInfo?: string; + /** True when the document was converted from HTML or a remote reader result. */ + sourceConverted?: boolean; + /** Render mode for the annotated content. */ + renderAs?: "markdown" | "html"; + /** Summary of annotations the user has made. */ + annotations?: string; +} + +/** + * Union of mode-specific contexts, discriminated by `mode`. + */ +export type AIContext = + | { mode: "plan-review"; plan: PlanContext; parent?: ParentSession } + | { mode: "code-review"; review: CodeReviewContext; parent?: ParentSession } + | { mode: "annotate"; annotate: AnnotateContext; parent?: ParentSession }; diff --git a/packages/core/browser-paths.ts b/packages/core/browser-paths.ts new file mode 100644 index 000000000..ce867a505 --- /dev/null +++ b/packages/core/browser-paths.ts @@ -0,0 +1,25 @@ +export function normalizeBrowserPath(path: string): string { + const withForwardSlashes = path.replace(/\\/g, "/"); + const prefix = withForwardSlashes.startsWith("//") ? "//" : ""; + const collapsed = prefix + withForwardSlashes.slice(prefix.length).replace(/\/+/g, "/"); + if (collapsed === "/" || /^[A-Za-z]:\/$/.test(collapsed)) return collapsed; + return collapsed.replace(/\/+$/, ""); +} + +export function dirnameBrowserPath(path: string): string { + const normalized = normalizeBrowserPath(path); + const driveRootMatch = normalized.match(/^([A-Za-z]:)\/[^/]+$/); + if (driveRootMatch) return `${driveRootMatch[1]}/`; + const index = normalized.lastIndexOf("/"); + if (index < 0) return normalized; + if (index === 0) return "/"; + return normalized.slice(0, index); +} + +export function pathIsInsideDir(path: string, dir: string): boolean { + const normalizedPath = normalizeBrowserPath(path); + const normalizedDir = normalizeBrowserPath(dir); + if (!normalizedDir) return normalizedPath === ""; + const dirPrefix = normalizedDir.endsWith("/") ? normalizedDir : `${normalizedDir}/`; + return normalizedPath === normalizedDir || normalizedPath.startsWith(dirPrefix); +} diff --git a/packages/shared/code-file.test.ts b/packages/core/code-file.test.ts similarity index 100% rename from packages/shared/code-file.test.ts rename to packages/core/code-file.test.ts diff --git a/packages/core/code-file.ts b/packages/core/code-file.ts new file mode 100644 index 000000000..43633a766 --- /dev/null +++ b/packages/core/code-file.ts @@ -0,0 +1,41 @@ +export const CODE_FILE_REGEX = /(?:\.(tsx?|jsx?|py|rb|go|rs|java|c|cpp|h|hpp|cs|swift|kt|scala|sh|bash|zsh|sql|graphql|json|ya?ml|toml|ini|css|scss|less|xml|tf|lua|r|dart|ex|exs|vue|svelte|astro|zig|proto)|(?:^|\/)(Dockerfile|Makefile|Rakefile|Gemfile|Procfile|Vagrantfile|Brewfile|Justfile))$/i; + +export const CODE_PATH_BARE_REGEX = /(?:\.{0,2}\/)?(?:[a-zA-Z0-9_@.\-\[\]]+\/)+[a-zA-Z0-9_.\-\[\]]+\.[a-zA-Z0-9]+(?::\d+(?:-\d+)?)?/g; + +const IMPLAUSIBLE_CHARS = /[{},*?\s]/; + +export function isPlausibleCodeFilePath(input: string): boolean { + return !IMPLAUSIBLE_CHARS.test(input); +} + +export interface ParsedCodePath { + filePath: string; + line?: number; + lineEnd?: number; +} + +const LINE_SUFFIX_RE = /:(\d+)(?:-(\d+))?$/; + +export function parseCodePath(input: string): ParsedCodePath { + const clean = input.replace(/#.*$/, ''); + const m = clean.match(LINE_SUFFIX_RE); + if (!m) return { filePath: clean }; + let line = Number.parseInt(m[1], 10); + let lineEnd = m[2] ? Number.parseInt(m[2], 10) : undefined; + if (lineEnd != null && lineEnd < line) { const tmp = line; line = lineEnd; lineEnd = tmp; } + return { filePath: clean.replace(LINE_SUFFIX_RE, ''), line, lineEnd }; +} + +export function stripLineRef(input: string): string { + return input.replace(/#.*$/, '').replace(LINE_SUFFIX_RE, ''); +} + +export function isCodeFilePath(input: string): boolean { + if (!isPlausibleCodeFilePath(input)) return false; + return CODE_FILE_REGEX.test(stripLineRef(input)) + && !input.startsWith('http://') && !input.startsWith('https://'); +} + +export function isCodeFilePathStrict(input: string): boolean { + return input.includes('/') && isCodeFilePath(input); +} diff --git a/packages/core/compress.ts b/packages/core/compress.ts new file mode 100644 index 000000000..70c5099ac --- /dev/null +++ b/packages/core/compress.ts @@ -0,0 +1,51 @@ +/** + * Portable deflate-raw + base64url compression. + * + * Uses only Web APIs (CompressionStream, TextEncoder, btoa) so it works + * in browsers, Bun, and edge runtimes. Both @plannotator/server and + * @plannotator/ui import from here — single source of truth. + */ + +export async function compress(data: unknown): Promise { + const json = JSON.stringify(data); + const byteArray = new TextEncoder().encode(json); + + const stream = new CompressionStream('deflate-raw'); + const writer = stream.writable.getWriter(); + writer.write(byteArray); + writer.close(); + + const buffer = await new Response(stream.readable).arrayBuffer(); + const compressed = new Uint8Array(buffer); + + // Loop instead of spread to avoid RangeError on large payloads + // (String.fromCharCode(...arr) has a ~65K argument limit) + let binary = ''; + for (let i = 0; i < compressed.length; i++) { + binary += String.fromCharCode(compressed[i]); + } + const base64 = btoa(binary); + return base64 + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +export async function decompress(b64: string): Promise { + const base64 = b64 + .replace(/-/g, '+') + .replace(/_/g, '/'); + + const binary = atob(base64); + const byteArray = Uint8Array.from(binary, c => c.charCodeAt(0)); + + const stream = new DecompressionStream('deflate-raw'); + const writer = stream.writable.getWriter(); + writer.write(byteArray); + writer.close(); + + const buffer = await new Response(stream.readable).arrayBuffer(); + const json = new TextDecoder().decode(buffer); + + return JSON.parse(json); +} diff --git a/packages/core/config-types.ts b/packages/core/config-types.ts new file mode 100644 index 000000000..7d32ec769 --- /dev/null +++ b/packages/core/config-types.ts @@ -0,0 +1,18 @@ +export type DefaultDiffType = 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all'; +export type DiffLineBgIntensity = 'subtle' | 'normal' | 'strong'; + +export interface DiffOptions { + diffStyle?: 'split' | 'unified'; + overflow?: 'scroll' | 'wrap'; + diffIndicators?: 'bars' | 'classic' | 'none'; + lineDiffType?: 'word-alt' | 'word' | 'char' | 'none'; + showLineNumbers?: boolean; + showDiffBackground?: boolean; + fontFamily?: string; + fontSize?: string; + tabSize?: number; + hideWhitespace?: boolean; + expandUnchanged?: boolean; + defaultDiffType?: DefaultDiffType; + lineBgIntensity?: DiffLineBgIntensity; +} diff --git a/packages/shared/crypto.test.ts b/packages/core/crypto.test.ts similarity index 100% rename from packages/shared/crypto.test.ts rename to packages/core/crypto.test.ts diff --git a/packages/core/crypto.ts b/packages/core/crypto.ts new file mode 100644 index 000000000..0161e6dcd --- /dev/null +++ b/packages/core/crypto.ts @@ -0,0 +1,97 @@ +/** + * AES-256-GCM encryption for zero-knowledge paste storage. + * + * Uses Web Crypto API — works in browsers, Bun, and edge runtimes. + * The key never leaves the client; it lives in the URL fragment. + */ + +/** + * Encrypt a compressed base64url string with a fresh AES-256-GCM key. + * + * Returns { ciphertext, key } where: + * - ciphertext: base64url-encoded (12-byte IV prepended to GCM output) + * - key: base64url-encoded 256-bit key for the URL fragment + */ +export async function encrypt( + compressedData: string +): Promise<{ ciphertext: string; key: string }> { + const cryptoKey = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt'] + ); + + const iv = crypto.getRandomValues(new Uint8Array(12)); + const plaintext = new TextEncoder().encode(compressedData); + + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + cryptoKey, + plaintext + ); + + // Prepend IV to ciphertext (IV || ciphertext+tag) + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(encrypted), iv.length); + + const rawKey = await crypto.subtle.exportKey('raw', cryptoKey); + + return { + ciphertext: bytesToBase64url(combined), + key: bytesToBase64url(new Uint8Array(rawKey)), + }; +} + +/** + * Decrypt a ciphertext string using a base64url-encoded AES-256-GCM key. + * + * Expects ciphertext format: base64url(IV || encrypted+tag) + * Returns the original compressed base64url string. + */ +export async function decrypt( + ciphertext: string, + key: string +): Promise { + const combined = base64urlToBytes(ciphertext); + const rawKey = base64urlToBytes(key); + + const iv = combined.slice(0, 12); + const encrypted = combined.slice(12); + + const cryptoKey = await crypto.subtle.importKey( + 'raw', + rawKey.buffer as ArrayBuffer, + { name: 'AES-GCM', length: 256 }, + false, + ['decrypt'] + ); + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + cryptoKey, + encrypted + ); + + return new TextDecoder().decode(decrypted); +} + +// --- Helpers --- + +function bytesToBase64url(bytes: Uint8Array): string { + // Loop to avoid RangeError on large payloads (same approach as compress.ts) + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +function base64urlToBytes(b64: string): Uint8Array { + const base64 = b64.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(base64); + return Uint8Array.from(binary, c => c.charCodeAt(0)); +} diff --git a/packages/core/external-annotation.ts b/packages/core/external-annotation.ts new file mode 100644 index 000000000..2260e3f87 --- /dev/null +++ b/packages/core/external-annotation.ts @@ -0,0 +1,455 @@ +/** + * External Annotations — shared types, store logic, and SSE helpers. + * + * Runtime-agnostic: no node:fs, no node:http, no Bun APIs. + * Both the Bun server handler and Pi server handler import this module + * and wrap it with their respective HTTP transport layers. + * + * The store is generic — plan servers store Annotation objects, + * review servers store CodeAnnotation objects. The mode-specific + * input transformers handle validation and field assignment. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Constraint for any annotation type the store can hold. */ +export type StorableAnnotation = { id: string; source?: string }; + +export type ExternalAnnotationEvent = + | { type: "snapshot"; annotations: T[] } + | { type: "add"; annotations: T[] } + | { type: "remove"; ids: string[] } + | { type: "clear"; source?: string } + | { type: "update"; id: string; annotation: T }; + +// --------------------------------------------------------------------------- +// SSE helpers +// --------------------------------------------------------------------------- + +/** Heartbeat comment to keep SSE connections alive (sent every 30s). */ +export const HEARTBEAT_COMMENT = ":\n\n"; + +/** Interval in ms between heartbeat comments. */ +export const HEARTBEAT_INTERVAL_MS = 30_000; + +/** Encode an event as an SSE `data:` line. */ +export function serializeSSEEvent(event: ExternalAnnotationEvent): string { + return `data: ${JSON.stringify(event)}\n\n`; +} + +// --------------------------------------------------------------------------- +// Input validation — shared helpers +// --------------------------------------------------------------------------- + +export interface ParseError { + error: string; +} + +/** + * Unwrap a POST body into an array of raw input objects. + * + * Accepts either: + * - A single annotation object: `{ source: "...", ... }` + * - A batch wrapper: `{ annotations: [{ source: "...", ... }, ...] }` + */ +function unwrapBody(body: unknown): Record[] | ParseError { + if (!body || typeof body !== "object") { + return { error: "Request body must be a JSON object" }; + } + + const obj = body as Record; + + // Batch format: { annotations: [...] } + if (Array.isArray(obj.annotations)) { + if (obj.annotations.length === 0) { + return { error: "annotations array must not be empty" }; + } + const items: Record[] = []; + for (let i = 0; i < obj.annotations.length; i++) { + const item = obj.annotations[i]; + if (!item || typeof item !== "object") { + return { error: `annotations[${i}] must be an object` }; + } + items.push(item as Record); + } + return items; + } + + // Single format: { source: "...", ... } + if (typeof obj.source === "string") { + return [obj as Record]; + } + + return { error: 'Missing required "source" field or "annotations" array' }; +} + +function requireString(obj: Record, field: string, index: number): string | ParseError { + const val = obj[field]; + if (typeof val !== "string" || val.length === 0) { + return { error: `annotations[${index}] missing required "${field}" field` }; + } + return val; +} + +// --------------------------------------------------------------------------- +// Plan mode transformer — produces Annotation objects +// --------------------------------------------------------------------------- + +/** The Annotation type shape for plan mode (mirrors packages/ui/types.ts). */ +interface PlanAnnotation { + id: string; + blockId: string; + startOffset: number; + endOffset: number; + type: string; // AnnotationType value + text?: string; + originalText: string; + createdA: number; + author?: string; + source?: string; +} + +const VALID_PLAN_TYPES = ["DELETION", "COMMENT", "GLOBAL_COMMENT"]; + +export function transformPlanInput( + body: unknown, +): { annotations: PlanAnnotation[] } | ParseError { + const items = unwrapBody(body); + if ("error" in items) return items; + + const annotations: PlanAnnotation[] = []; + for (let i = 0; i < items.length; i++) { + const obj = items[i]; + + const source = requireString(obj, "source", i); + if (typeof source !== "string") return source; + + // Must have text content + if (typeof obj.text !== "string" || obj.text.length === 0) { + return { error: `annotations[${i}] missing required "text" field` }; + } + + // Validate type if provided, default to GLOBAL_COMMENT + const type = typeof obj.type === "string" ? obj.type : "GLOBAL_COMMENT"; + if (!VALID_PLAN_TYPES.includes(type)) { + return { + error: `annotations[${i}] invalid type "${type}". Must be one of: ${VALID_PLAN_TYPES.join(", ")}`, + }; + } + + // DELETION requires originalText (the text to remove) + if (type === "DELETION" && (typeof obj.originalText !== "string" || obj.originalText.length === 0)) { + return { error: `annotations[${i}] DELETION type requires non-empty "originalText" field` }; + } + + // COMMENT requires originalText so the renderer can pin it to a phrase. + // External agents that want sidebar-only feedback should use GLOBAL_COMMENT + // instead — without a phrase to anchor to, a COMMENT renders as an empty + // quote bubble in the sidebar and exports as `Feedback on: ""`. + if (type === "COMMENT" && (typeof obj.originalText !== "string" || obj.originalText.length === 0)) { + return { + error: `annotations[${i}] COMMENT requires non-empty "originalText" field. Use GLOBAL_COMMENT for sidebar-only feedback.`, + }; + } + + annotations.push({ + id: crypto.randomUUID(), + blockId: "external", + startOffset: 0, + endOffset: 0, + type, + text: String(obj.text), + originalText: typeof obj.originalText === "string" ? obj.originalText : "", + createdA: Date.now(), + author: typeof obj.author === "string" ? obj.author : undefined, + source, + }); + } + + return { annotations }; +} + +// --------------------------------------------------------------------------- +// Review mode transformer — produces CodeAnnotation objects +// --------------------------------------------------------------------------- + +/** The CodeAnnotation type shape for review mode (mirrors packages/ui/types.ts). */ +interface ReviewAnnotation { + id: string; + type: string; // CodeAnnotationType value + scope?: string; + filePath: string; + lineStart: number; + lineEnd: number; + side: string; + text?: string; + suggestedCode?: string; + originalCode?: string; + createdAt: number; + author?: string; + source?: string; + // Agent review metadata (optional — only set by agent review findings) + severity?: string; // "important" | "nit" | "pre_existing" + reasoning?: string; // Validation chain explaining how the issue was confirmed + reviewProfileLabel?: string; // Custom review profile that produced this finding +} + +const VALID_REVIEW_TYPES = ["comment", "suggestion", "concern"]; +const VALID_SIDES = ["old", "new"]; +const VALID_SCOPES = ["line", "file", "general"]; + +/** A review finding's placement, derived from what it carries. */ +export type FindingPlacement = { + scope: "line" | "file" | "general"; + filePath: string; + lineStart: number; + lineEnd: number; +}; + +/** + * Classify an agent review finding by what it carries, so nothing is dropped: + * file + a usable line → a line comment + * file, no line → a whole-file comment + * neither → a general (review-level) comment + * + * For file and general placements the line is 0; for general the path is "". + * Consumers branch on `scope`, never on the sentinel values. + */ +export function classifyFindingPlacement( + filePath: string, + lineStart: number | null | undefined, + lineEnd: number | null | undefined, +): FindingPlacement { + const hasFile = filePath.length > 0; + const hasLine = typeof lineStart === "number"; + if (hasFile && hasLine) { + return { + scope: "line", + filePath, + lineStart, + lineEnd: typeof lineEnd === "number" ? lineEnd : lineStart, + }; + } + if (hasFile) { + return { scope: "file", filePath, lineStart: 0, lineEnd: 0 }; + } + return { scope: "general", filePath: "", lineStart: 0, lineEnd: 0 }; +} + +export function transformReviewInput( + body: unknown, +): { annotations: ReviewAnnotation[] } | ParseError { + const items = unwrapBody(body); + if ("error" in items) return items; + + const annotations: ReviewAnnotation[] = []; + for (let i = 0; i < items.length; i++) { + const obj = items[i]; + + const source = requireString(obj, "source", i); + if (typeof source !== "string") return source; + + // scope: optional, defaults to "line" + const scope = typeof obj.scope === "string" ? obj.scope : "line"; + if (!VALID_SCOPES.includes(scope)) { + return { + error: `annotations[${i}] invalid scope "${scope}". Must be one of: ${VALID_SCOPES.join(", ")}`, + }; + } + + // Location requirements depend on scope: + // line → filePath + lineStart + lineEnd required. A finding that claims + // a line must carry one, so a broken line finding is rejected + // rather than quietly passing as a vaguer comment. + // file → filePath required; line ignored (defaults to 0). + // general → no file, no line (review-level; defaults to "" / 0). + let filePath = ""; + let lineStart = 0; + let lineEnd = 0; + if (scope !== "general") { + const fp = requireString(obj, "filePath", i); + if (typeof fp !== "string") return fp; + filePath = fp; + if (scope === "line") { + if (typeof obj.lineStart !== "number") { + return { error: `annotations[${i}] missing required "lineStart" field` }; + } + if (typeof obj.lineEnd !== "number") { + return { error: `annotations[${i}] missing required "lineEnd" field` }; + } + lineStart = obj.lineStart; + lineEnd = obj.lineEnd; + } else { + lineStart = typeof obj.lineStart === "number" ? obj.lineStart : 0; + lineEnd = typeof obj.lineEnd === "number" ? obj.lineEnd : 0; + } + } + + // side: optional, defaults to "new" + const side = typeof obj.side === "string" ? obj.side : "new"; + if (!VALID_SIDES.includes(side)) { + return { + error: `annotations[${i}] invalid side "${side}". Must be one of: ${VALID_SIDES.join(", ")}`, + }; + } + + // type: optional, defaults to "comment" + const type = typeof obj.type === "string" ? obj.type : "comment"; + if (!VALID_REVIEW_TYPES.includes(type)) { + return { + error: `annotations[${i}] invalid type "${type}". Must be one of: ${VALID_REVIEW_TYPES.join(", ")}`, + }; + } + + // Must have at least text or suggestedCode + if (typeof obj.text !== "string" && typeof obj.suggestedCode !== "string") { + return { + error: `annotations[${i}] must have at least one of: text, suggestedCode`, + }; + } + + annotations.push({ + id: crypto.randomUUID(), + type, + scope, + filePath, + lineStart, + lineEnd, + side, + text: typeof obj.text === "string" ? obj.text : undefined, + suggestedCode: typeof obj.suggestedCode === "string" ? obj.suggestedCode : undefined, + originalCode: typeof obj.originalCode === "string" ? obj.originalCode : undefined, + createdAt: Date.now(), + author: typeof obj.author === "string" ? obj.author : undefined, + source, + // Agent review metadata (optional — only set by agent review findings) + ...(typeof obj.severity === "string" && { severity: obj.severity }), + ...(typeof obj.reasoning === "string" && { reasoning: obj.reasoning }), + ...(typeof obj.reviewProfileLabel === "string" && { reviewProfileLabel: obj.reviewProfileLabel }), + }); + } + + return { annotations }; +} + +// --------------------------------------------------------------------------- +// Annotation Store (generic) +// --------------------------------------------------------------------------- + +type MutationListener = (event: ExternalAnnotationEvent) => void; + +export interface AnnotationStore { + /** Add fully-formed annotations. Returns the added annotations. */ + add(items: T[]): T[]; + /** Remove an annotation by ID. Returns true if found. */ + remove(id: string): boolean; + /** Remove all annotations from a specific source. Returns count removed. */ + clearBySource(source: string): number; + /** Update an annotation by ID. Returns the updated annotation, or null if not found. */ + update(id: string, fields: Partial): T | null; + /** Remove all annotations. Returns count removed. */ + clearAll(): number; + /** Get all annotations (snapshot). */ + getAll(): T[]; + /** Monotonic version counter — incremented on every mutation. */ + readonly version: number; + /** Register a listener for mutation events. Returns unsubscribe function. */ + onMutation(listener: MutationListener): () => void; +} + +/** + * Create an in-memory annotation store. + * + * The store is runtime-agnostic — it holds data and emits events. + * HTTP transport (SSE broadcasting, request parsing) is handled by + * the server-specific adapter (Bun or Pi). + */ +export function createAnnotationStore(): AnnotationStore { + const annotations: T[] = []; + const listeners = new Set>(); + let version = 0; + + function emit(event: ExternalAnnotationEvent): void { + for (const listener of listeners) { + try { + listener(event); + } catch { + // Don't let a failing listener break the store + } + } + } + + return { + add(items) { + if (items.length > 0) { + for (const item of items) { + annotations.push(item); + } + version++; + emit({ type: "add", annotations: items }); + } + return items; + }, + + remove(id) { + const idx = annotations.findIndex((a) => a.id === id); + if (idx === -1) return false; + annotations.splice(idx, 1); + version++; + emit({ type: "remove", ids: [id] }); + return true; + }, + + update(id, fields) { + const idx = annotations.findIndex((a) => a.id === id); + if (idx === -1) return null; + const merged = { ...annotations[idx], ...fields, id } as T; + annotations[idx] = merged; + version++; + emit({ type: "update", id, annotation: merged }); + return merged; + }, + + clearBySource(source) { + const before = annotations.length; + for (let i = annotations.length - 1; i >= 0; i--) { + if (annotations[i].source === source) { + annotations.splice(i, 1); + } + } + const removed = before - annotations.length; + if (removed > 0) { + version++; + emit({ type: "clear", source }); + } + return removed; + }, + + clearAll() { + const count = annotations.length; + if (count > 0) { + annotations.length = 0; + version++; + emit({ type: "clear" }); + } + return count; + }, + + getAll() { + return [...annotations]; + }, + + get version() { + return version; + }, + + onMutation(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/packages/shared/extract-code-paths.test.ts b/packages/core/extract-code-paths.test.ts similarity index 100% rename from packages/shared/extract-code-paths.test.ts rename to packages/core/extract-code-paths.test.ts diff --git a/packages/core/extract-code-paths.ts b/packages/core/extract-code-paths.ts new file mode 100644 index 000000000..c4ef380fd --- /dev/null +++ b/packages/core/extract-code-paths.ts @@ -0,0 +1,66 @@ +import { + CODE_PATH_BARE_REGEX, + isCodeFilePath, + isCodeFilePathStrict, +} from "./code-file"; + +const FENCED_CODE_BLOCK = /(^|\n)([ \t]*)(```|~~~)[\s\S]*?\n\2\3/g; +const HTML_COMMENT = //g; +// Match InlineMarkdown.tsx's bare-URL regex exactly so URL ranges excised +// here mirror the ranges the renderer would consume. +const URL_REGEX = /https?:\/\/[^\s<>"']+/g; +const BACKTICK_SPAN = /`([^`\n]+)`/g; + +/** + * Extract candidate code-file paths from markdown text. Mirrors the renderer's + * detection precedence so the validator only sees paths the renderer would + * actually linkify: + * 1. fenced code blocks and HTML comments are stripped first; + * 2. URL ranges are excised before the bare-prose scan (URLs win); + * 3. backtick spans matching `isCodeFilePath` are collected; + * 4. bare-prose paths matching `CODE_PATH_BARE_REGEX` and + * `isCodeFilePathStrict` are collected. + * + * Hash anchors (`#L42`) are stripped from results to match the renderer's + * `cleanPath` transform. Returns deduped candidate strings. + */ +export function extractCandidateCodePaths(markdown: string): string[] { + const stripped = markdown + .replace(FENCED_CODE_BLOCK, "") + .replace(HTML_COMMENT, ""); + + const candidates = new Set(); + + let m: RegExpExecArray | null; + const backtickRe = new RegExp(BACKTICK_SPAN.source, "g"); + while ((m = backtickRe.exec(stripped)) !== null) { + const inner = m[1].trim(); + if (isCodeFilePath(inner)) { + candidates.add(inner.replace(/#.*$/, "")); + } + } + + for (const line of stripped.split("\n")) { + const urlRanges: Array<[number, number]> = []; + const urlRe = new RegExp(URL_REGEX.source, "g"); + while ((m = urlRe.exec(line)) !== null) { + urlRanges.push([m.index, m.index + m[0].length]); + } + + const pathRe = new RegExp(CODE_PATH_BARE_REGEX.source, "g"); + while ((m = pathRe.exec(line)) !== null) { + const start = m.index; + const end = start + m[0].length; + const prev = start === 0 ? "" : line[start - 1]; + if (/\w/.test(prev)) continue; + const overlapsUrl = urlRanges.some( + ([s, e]) => start < e && end > s, + ); + if (overlapsUrl) continue; + if (!isCodeFilePathStrict(m[0])) continue; + candidates.add(m[0].replace(/#.*$/, "")); + } + } + + return Array.from(candidates); +} diff --git a/packages/core/favicon.ts b/packages/core/favicon.ts new file mode 100644 index 000000000..c857b8419 --- /dev/null +++ b/packages/core/favicon.ts @@ -0,0 +1,5 @@ +export const FAVICON_SVG = ` + + + P +`; diff --git a/packages/shared/feedback-templates.test.ts b/packages/core/feedback-templates.test.ts similarity index 100% rename from packages/shared/feedback-templates.test.ts rename to packages/core/feedback-templates.test.ts diff --git a/packages/core/feedback-templates.ts b/packages/core/feedback-templates.ts new file mode 100644 index 000000000..02d9b3217 --- /dev/null +++ b/packages/core/feedback-templates.ts @@ -0,0 +1,45 @@ +/** + * Shared feedback templates for all agent integrations. + * + * The plan deny template was tuned in #224 / commit 3dca977 to use strong + * directive framing — Claude was ignoring softer phrasing. + * + * IMPORTANT: This module is imported by packages/ui/utils/parser.ts which is + * bundled into the browser SPA. It must NOT import from ./prompts or ./config + * (which depend on node:fs, node:os, node:child_process). Keep it self-contained. + * + * Server-side call sites use getPlanDeniedPrompt() from ./prompts directly. + * This module is only kept for the browser's wrapFeedbackForAgent clipboard feature. + */ + +export interface PlanDenyFeedbackOptions { + planFilePath?: string; +} + +export interface AnnotateFileFeedbackOptions { + filePath: string; + fileHeader?: "File" | "Folder" | string; +} + +export const planDenyFeedback = ( + feedback: string, + toolName: string = "ExitPlanMode", + options?: PlanDenyFeedbackOptions, +): string => { + const planFileRule = options?.planFilePath + ? `- Your plan is saved at: ${options.planFilePath}\n You can edit this file to make targeted changes, then pass its path to ${toolName}.\n` + : ""; + + return `YOUR PLAN WAS NOT APPROVED.\n\nYou MUST revise the plan to address ALL of the feedback below before calling ${toolName} again.\n\nRules:\n${planFileRule}- Do not resubmit the same plan unchanged.\n- Do NOT change the plan title (first # heading) unless the user explicitly asks you to.\n\n${feedback || "Plan changes requested"}`; +}; + +export const annotateFileFeedback = ( + feedback: string, + options: AnnotateFileFeedbackOptions, +): string => { + const fileHeader = options.fileHeader ?? "File"; + return `# Markdown Annotations\n\n${fileHeader}: ${options.filePath}\n\n${feedback}\n\nPlease address the annotation feedback above.`; +}; + +export const annotateMessageFeedback = (feedback: string): string => + `# Message Annotations\n\n${feedback}\n\nPlease address the annotation feedback above.`; diff --git a/packages/shared/goal-setup.test.ts b/packages/core/goal-setup.test.ts similarity index 100% rename from packages/shared/goal-setup.test.ts rename to packages/core/goal-setup.test.ts diff --git a/packages/core/goal-setup.ts b/packages/core/goal-setup.ts new file mode 100644 index 000000000..0b07f9c40 --- /dev/null +++ b/packages/core/goal-setup.ts @@ -0,0 +1,336 @@ +export type GoalSetupStage = "interview" | "facts"; + +export type GoalSetupAnswerMode = + | "text" + | "single" + | "multi" + | "single-custom" + | "multi-custom" + | "custom"; + +export interface GoalSetupQuestionOption { + id: string; + label: string; + description?: string; +} + +export interface GoalSetupQuestion { + id: string; + prompt: string; + description?: string; + answerMode?: GoalSetupAnswerMode; + recommendedAnswer?: string; + recommendedOptionIds?: string[]; + options?: GoalSetupQuestionOption[]; + required?: boolean; +} + +export interface GoalSetupQuestionAnswer { + questionId: string; + selectedOptionIds: string[]; + customAnswer: string; + note?: string; + answer: string; + completed: boolean; + skipped?: boolean; +} + +export interface GoalSetupInterviewBundle { + stage: "interview"; + title?: string; + goalSlug?: string; + questions: GoalSetupQuestion[]; +} + +export interface GoalSetupFact { + id: string; + text: string; + accepted: boolean; + removed: boolean; + comment?: string; + recommendedAutomatedVerification?: boolean; + automatedVerification: boolean; + previousText?: string; +} + +export interface GoalSetupFactsBundle { + stage: "facts"; + title?: string; + goalSlug?: string; + facts: GoalSetupFact[]; + showAccepted?: boolean; +} + +export type GoalSetupBundle = GoalSetupInterviewBundle | GoalSetupFactsBundle; + +export interface GoalSetupInterviewResult { + stage: "interview"; + title?: string; + goalSlug?: string; + answers: GoalSetupQuestionAnswer[]; +} + +export interface GoalSetupFactResult { + id: string; + text: string; + accepted: boolean; + removed: boolean; + comment?: string; + automatedVerification: boolean; + recommendedAutomatedVerification?: boolean; +} + +export interface GoalSetupFactsResult { + stage: "facts"; + title?: string; + goalSlug?: string; + facts: GoalSetupFactResult[]; + factsMarkdown: string; +} + +export type GoalSetupResult = GoalSetupInterviewResult | GoalSetupFactsResult; + +function asRecord(value: unknown, context: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${context} must be an object`); + } + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function normalizeId(value: unknown, fallback: string): string { + const raw = asString(value, fallback).trim(); + return raw || fallback; +} + +function normalizeAnswerMode(value: unknown): GoalSetupAnswerMode { + switch (value) { + case "single": + case "multi": + case "single-custom": + case "multi-custom": + case "custom": + case "text": + return value; + default: + return "text"; + } +} + +function normalizeOption(value: unknown, index: number): GoalSetupQuestionOption { + const item = asRecord(value, `questions[].options[${index}]`); + const label = asString(item.label).trim(); + if (!label) { + throw new Error(`questions[].options[${index}].label is required`); + } + return { + id: normalizeId(item.id, `option-${index + 1}`), + label, + ...(asString(item.description).trim() + ? { description: asString(item.description).trim() } + : {}), + }; +} + +function normalizeQuestion(value: unknown, index: number): GoalSetupQuestion { + const item = asRecord(value, `questions[${index}]`); + const prompt = asString(item.prompt).trim(); + if (!prompt) { + throw new Error(`questions[${index}].prompt is required`); + } + const options = Array.isArray(item.options) + ? item.options.map(normalizeOption) + : undefined; + + const recommendedOptionIds = Array.isArray(item.recommendedOptionIds) + ? (item.recommendedOptionIds as unknown[]).filter((id): id is string => typeof id === 'string') + : undefined; + + return { + id: normalizeId(item.id, `question-${index + 1}`), + prompt, + ...(asString(item.description).trim() + ? { description: asString(item.description).trim() } + : {}), + answerMode: normalizeAnswerMode(item.answerMode), + ...(asString(item.recommendedAnswer).trim() + ? { recommendedAnswer: asString(item.recommendedAnswer).trim() } + : {}), + ...(recommendedOptionIds && recommendedOptionIds.length > 0 + ? { recommendedOptionIds } + : {}), + ...(options && options.length > 0 ? { options } : {}), + required: asBoolean(item.required, true), + }; +} + +function normalizeFact(value: unknown, index: number): GoalSetupFact { + const item = asRecord(value, `facts[${index}]`); + const text = asString(item.text).trim(); + if (!text) { + throw new Error(`facts[${index}].text is required`); + } + const recommended = asBoolean(item.recommendedAutomatedVerification, false); + return { + id: normalizeId(item.id, `fact-${index + 1}`), + text, + accepted: asBoolean(item.accepted, false), + removed: asBoolean(item.removed, false), + ...(asString(item.comment).trim() + ? { comment: asString(item.comment).trim() } + : {}), + recommendedAutomatedVerification: recommended, + automatedVerification: asBoolean(item.automatedVerification, recommended), + ...(asString(item.previousText).trim() + ? { previousText: asString(item.previousText).trim() } + : {}), + }; +} + +export function normalizeInterviewBundle(value: unknown): GoalSetupInterviewBundle { + const raw = asRecord(value, "goal setup interview bundle"); + if (!Array.isArray(raw.questions) || raw.questions.length === 0) { + throw new Error("interview bundle requires at least one question"); + } + return { + stage: "interview", + ...(asString(raw.title).trim() ? { title: asString(raw.title).trim() } : {}), + ...(asString(raw.goalSlug).trim() + ? { goalSlug: asString(raw.goalSlug).trim() } + : {}), + questions: raw.questions.map(normalizeQuestion), + }; +} + +export function normalizeFactsBundle(value: unknown): GoalSetupFactsBundle { + const raw = asRecord(value, "goal setup facts bundle"); + if (!Array.isArray(raw.facts)) { + throw new Error("facts bundle requires a facts array"); + } + return { + stage: "facts", + ...(asString(raw.title).trim() ? { title: asString(raw.title).trim() } : {}), + ...(asString(raw.goalSlug).trim() + ? { goalSlug: asString(raw.goalSlug).trim() } + : {}), + facts: raw.facts.map(normalizeFact), + showAccepted: asBoolean(raw.showAccepted, false), + }; +} + +export function normalizeGoalSetupBundle( + value: unknown, + expectedStage?: GoalSetupStage +): GoalSetupBundle { + const raw = asRecord(value, "goal setup bundle"); + const stage = expectedStage ?? raw.stage; + if (stage === "interview") return normalizeInterviewBundle(raw); + if (stage === "facts") return normalizeFactsBundle(raw); + throw new Error("goal setup bundle stage must be interview or facts"); +} + +export function hasQuestionAnswer(answer: GoalSetupQuestionAnswer): boolean { + return ( + answer.selectedOptionIds.length > 0 || + answer.customAnswer.trim().length > 0 || + answer.answer.trim().length > 0 + ); +} + +export function createInterviewResult( + bundle: GoalSetupInterviewBundle, + answers: GoalSetupQuestionAnswer[] +): GoalSetupInterviewResult { + const byId = new Map(answers.map((answer) => [answer.questionId, answer])); + return { + stage: "interview", + title: bundle.title, + goalSlug: bundle.goalSlug, + answers: bundle.questions.map((question) => { + const answer = byId.get(question.id); + const normalized: GoalSetupQuestionAnswer = { + questionId: question.id, + selectedOptionIds: Array.isArray(answer?.selectedOptionIds) + ? answer!.selectedOptionIds + : [], + customAnswer: asString(answer?.customAnswer), + ...(asString(answer?.note).trim() + ? { note: asString(answer?.note).trim() } + : {}), + answer: asString(answer?.answer), + completed: asBoolean(answer?.completed, false), + }; + const completed = normalized.completed || hasQuestionAnswer(normalized); + const skipped = asBoolean(answer?.skipped, false) && !completed; + return { + ...normalized, + completed, + ...(skipped ? { skipped: true } : {}), + }; + }), + }; +} + +export function filterReviewableFacts(bundle: GoalSetupFactsBundle): GoalSetupFact[] { + if (bundle.showAccepted) return bundle.facts; + return bundle.facts.filter((fact) => !fact.accepted); +} + +export function createFactsResult( + bundle: GoalSetupFactsBundle, + facts: GoalSetupFactResult[] +): GoalSetupFactsResult { + const byId = new Map(facts.map((fact) => [fact.id, fact])); + const merged = bundle.facts.map((fact) => { + const next = byId.get(fact.id); + const removed = asBoolean(next?.removed, fact.removed); + const text = asString(next?.text, fact.text).trim(); + if (!removed && !text) { + throw new Error(`Fact "${fact.id}" text cannot be empty; edit it or remove the fact.`); + } + const comment = (next && Object.prototype.hasOwnProperty.call(next, "comment") + ? asString(next.comment) + : asString(fact.comment) + ).trim(); + return { + id: fact.id, + text: text || fact.text, + accepted: asBoolean(next?.accepted, fact.accepted), + removed, + ...(comment ? { comment } : {}), + automatedVerification: asBoolean( + next?.automatedVerification, + fact.automatedVerification + ), + recommendedAutomatedVerification: + next?.recommendedAutomatedVerification ?? + fact.recommendedAutomatedVerification, + }; + }); + + return { + stage: "facts", + title: bundle.title, + goalSlug: bundle.goalSlug, + facts: merged, + factsMarkdown: factsResultToMarkdown(merged), + }; +} + +export function factsResultToMarkdown(facts: GoalSetupFactResult[]): string { + const accepted = facts.filter((fact) => fact.accepted && !fact.removed); + if (accepted.length === 0) return "# Facts\n\nNo accepted facts."; + + const lines = ["# Facts", ""]; + for (const fact of accepted) { + lines.push(`- ${fact.text}`); + } + return lines.join("\n"); +} diff --git a/packages/core/index.ts b/packages/core/index.ts new file mode 100644 index 000000000..0f30b3baf --- /dev/null +++ b/packages/core/index.ts @@ -0,0 +1,2 @@ +export * from './ai-context'; +export type { EditorAnnotation } from './types'; diff --git a/packages/core/open-in-apps.ts b/packages/core/open-in-apps.ts new file mode 100644 index 000000000..122af8feb --- /dev/null +++ b/packages/core/open-in-apps.ts @@ -0,0 +1,189 @@ +/** + * Open-in-App Catalog — single source of truth. + * + * Shared between the Bun/Pi servers (which launch the app) and the UI (which + * renders the picker). Runtime-agnostic: no Bun or Node-specific APIs, pure + * data + types only. + * + * Mirrors OpenCode's "Open in" app list. Each entry declares how to launch the + * app per platform: + * - mac.appName -> `open -a "" ` + * - win.bin -> ` ` (resolved against PATH) + * - linux.bin -> ` ` + * + * `kind` drives launch semantics: + * - file-manager -> reveal the file (mac: `open -R`, win: `explorer /select,`, + * linux: open the parent dir) + * - editor -> open the file itself + * - terminal -> open the file's parent directory + * + * One special id has no platform launch fields: + * - 'reveal' (kind file-manager) — uses the OS file manager + */ + +export type OpenInKind = 'file-manager' | 'editor' | 'terminal'; + +export interface OpenInApp { + /** Stable identifier persisted in the cookie + sent to the server. */ + id: string; + /** Human-readable label. For 'reveal' this is resolved per-platform. */ + label: string; + kind: OpenInKind; + /** Icon id understood by AppIcon. For 'reveal' this is resolved per-platform. */ + icon: string; + /** macOS application bundle/display name passed to `open -a`. */ + mac?: { appName: string }; + /** Windows PATH binary. */ + win?: { bin: string }; + /** Linux PATH binary. */ + linux?: { bin: string }; +} + +/** + * The catalog, in menu order. The UI groups by `kind` + * (file-manager + default first, then editors, then terminals). + */ +export const OPEN_IN_APPS: OpenInApp[] = [ + // ── File manager (always available) ──────────────────────────────────── + { + id: 'reveal', + label: 'Finder', // resolved per-platform, see resolveRevealLabel + kind: 'file-manager', + icon: 'finder', // resolved per-platform, see resolveRevealIcon + }, + + // ── Editors ──────────────────────────────────────────────────────────── + { + id: 'vscode', + label: 'VS Code', + kind: 'editor', + icon: 'vscode', + mac: { appName: 'Visual Studio Code' }, + win: { bin: 'code' }, + linux: { bin: 'code' }, + }, + { + id: 'cursor', + label: 'Cursor', + kind: 'editor', + icon: 'cursor', + mac: { appName: 'Cursor' }, + win: { bin: 'cursor' }, + linux: { bin: 'cursor' }, + }, + { + id: 'zed', + label: 'Zed', + kind: 'editor', + icon: 'zed', + mac: { appName: 'Zed' }, + win: { bin: 'zed' }, + linux: { bin: 'zed' }, + }, + { + id: 'sublime-text', + label: 'Sublime Text', + kind: 'editor', + icon: 'sublime-text', + mac: { appName: 'Sublime Text' }, + win: { bin: 'subl' }, + linux: { bin: 'subl' }, + }, + { + id: 'textmate', + label: 'TextMate', + kind: 'editor', + icon: 'textmate', + mac: { appName: 'TextMate' }, + }, + { + id: 'antigravity', + label: 'Antigravity', + kind: 'editor', + icon: 'antigravity', + mac: { appName: 'Antigravity' }, + }, + { + id: 'xcode', + label: 'Xcode', + kind: 'editor', + icon: 'xcode', + mac: { appName: 'Xcode' }, + }, + { + id: 'android-studio', + label: 'Android Studio', + kind: 'editor', + icon: 'android-studio', + mac: { appName: 'Android Studio' }, + }, + + // ── Terminals ────────────────────────────────────────────────────────── + { + id: 'terminal', + label: 'Terminal', + kind: 'terminal', + icon: 'terminal', + mac: { appName: 'Terminal' }, + }, + { + id: 'iterm2', + label: 'iTerm2', + kind: 'terminal', + icon: 'iterm2', + mac: { appName: 'iTerm' }, // bundle name is "iTerm", not "iTerm2" + }, + { + id: 'ghostty', + label: 'Ghostty', + kind: 'terminal', + icon: 'ghostty', + mac: { appName: 'Ghostty' }, + }, + { + id: 'warp', + label: 'Warp', + kind: 'terminal', + icon: 'warp', + mac: { appName: 'Warp' }, + }, + { + id: 'powershell', + label: 'PowerShell', + kind: 'terminal', + icon: 'powershell', + win: { bin: 'powershell' }, + }, +]; + +export type OpenInPlatform = 'mac' | 'win' | 'linux'; + +/** + * Per-platform label for the 'reveal' (file-manager) entry. + */ +export function resolveRevealLabel(platform: OpenInPlatform): string { + switch (platform) { + case 'win': + return 'Explorer'; + case 'linux': + return 'Files'; + case 'mac': + default: + return 'Finder'; + } +} + +/** + * Per-platform icon for the 'reveal' (file-manager) entry: + * finder on mac/linux, file-explorer on win. + */ +export function resolveRevealIcon(platform: OpenInPlatform): string { + return platform === 'win' ? 'file-explorer' : 'finder'; +} + +/** + * Look up a catalog entry by id. + */ +export function getOpenInApp(id: string): OpenInApp | undefined { + return OPEN_IN_APPS.find((app) => app.id === id); +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 000000000..bc5331d5e --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,33 @@ +{ + "name": "@plannotator/core", + "version": "0.21.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..7d06c95d9 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "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/shared/agent-jobs.ts b/packages/shared/agent-jobs.ts index a97e980f9..f14df7ec7 100644 --- a/packages/shared/agent-jobs.ts +++ b/packages/shared/agent-jobs.ts @@ -1,149 +1 @@ -/** - * Agent Jobs — shared types, state machine, and SSE helpers. - * - * Runtime-agnostic: no node:fs, no node:http, no Bun APIs. - * Both the Bun server handler and (future) Node handler import - * this module and wrap it with their respective HTTP transport layers. - * - * Mirrors packages/shared/external-annotation.ts in structure. - */ - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type AgentJobStatus = "starting" | "running" | "done" | "failed" | "killed"; - -/** - * Snapshot of the diff the reviewer was looking at when this job was launched. - * Carried on the job so downstream UIs (agent-result panel "Copy All") export - * the same `**Diff:** ...` header the job was actually run against — if the - * reviewer switches the UI to a different diff afterwards, the job's snapshot - * still reflects truth. Structurally compatible with the UI-side - * `FeedbackDiffContext` in `packages/review-editor/utils/exportFeedback.ts`. - */ -export interface AgentJobDiffContext { - mode: string; - base?: string; - worktreePath?: string | null; -} - -export interface AgentJobInfo { - /** Unique job identifier (UUID). */ - id: string; - /** Source identifier for external annotations — "agent-{id prefix}". */ - source: string; - /** Provider that spawned this job — "claude", "codex", "tour", "shell", etc. */ - provider: string; - /** Underlying engine used (e.g., "claude" or "codex"). Set when provider is "tour". */ - engine?: string; - /** Model used (e.g., "sonnet", "opus"). Set when provider is "tour" with Claude engine. */ - model?: string; - /** Claude --effort level (e.g., "low", "medium", "high", "xhigh", "max"). */ - effort?: string; - /** Codex reasoning effort level (e.g., "high", "medium"). */ - reasoningEffort?: string; - /** Whether Codex fast mode (service_tier=fast) was enabled. */ - fastMode?: boolean; - /** Human-readable label for the job. */ - label: string; - /** Current lifecycle status. */ - status: AgentJobStatus; - /** Timestamp when the job was created. */ - startedAt: number; - /** Timestamp when the job reached a terminal state. */ - endedAt?: number; - /** Process exit code (set on done/failed). */ - exitCode?: number; - /** Last ~500 chars of stderr on failure. */ - error?: string; - /** The actual command that was spawned (for display/debug). */ - command: string[]; - /** Working directory where the process was spawned. */ - cwd?: string; - /** The review prompt text (system + user message). Stored separately from command for providers that use stdin. */ - prompt?: string; - /** Review summary set by the agent on completion. */ - summary?: { - correctness: string; - explanation: string; - confidence: number; - }; - /** PR URL at launch time — used to attribute findings to the correct PR. */ - prUrl?: string; - /** PR diff scope at launch time — "layer" or "full-stack". */ - diffScope?: string; - /** Diff context at launch time (see AgentJobDiffContext). */ - diffContext?: AgentJobDiffContext; - /** Resolved review profile id at launch time (e.g. "builtin:default", "user:security"). */ - reviewProfileId?: string; - /** Resolved review profile label — rides on findings so the UI can show a profile tag. */ - reviewProfileLabel?: string; -} - -export interface AgentCapability { - id: string; - name: string; - available: boolean; -} - -export interface AgentCapabilities { - mode: "plan" | "review" | "annotate"; - providers: AgentCapability[]; - /** True if at least one provider is available. */ - available: boolean; -} - -// --------------------------------------------------------------------------- -// SSE event types -// --------------------------------------------------------------------------- - -export type AgentJobEvent = - | { type: "snapshot"; jobs: AgentJobInfo[] } - | { type: "job:started"; job: AgentJobInfo } - | { type: "job:updated"; job: AgentJobInfo } - | { type: "job:completed"; job: AgentJobInfo } - | { type: "job:log"; jobId: string; delta: string } - | { type: "jobs:cleared" }; - -// --------------------------------------------------------------------------- -// SSE helpers -// --------------------------------------------------------------------------- - -/** Heartbeat comment to keep SSE connections alive (sent every 30s). */ -export const AGENT_HEARTBEAT_COMMENT = ":\n\n"; - -/** Interval in ms between heartbeat comments. */ -export const AGENT_HEARTBEAT_INTERVAL_MS = 30_000; - -/** Encode an event as an SSE `data:` line. */ -export function serializeAgentSSEEvent(event: AgentJobEvent): string { - return `data: ${JSON.stringify(event)}\n\n`; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Check if a status is terminal (no further transitions). */ -export function isTerminalStatus(status: AgentJobStatus): boolean { - return status === "done" || status === "failed" || status === "killed"; -} - -/** Generate the source identifier for a job from its ID. */ -export function jobSource(id: string): string { - return "agent-" + id.slice(0, 8); -} - -// --------------------------------------------------------------------------- -// Review ingestion completion semantics -// --------------------------------------------------------------------------- - -/** Calm, provider-neutral failure reason. Never leak schema/CLI internals. */ -export const REVIEW_OUTPUT_FAILED = "Review finished but produced no usable findings."; - -/** Flip a job to failed with a calm one-liner (Code Tour precedent). */ -export function markJobReviewFailed(job: AgentJobInfo, error: string): void { - job.status = "failed"; - job.error = error; -} +export * from '@plannotator/core/agent-jobs'; diff --git a/packages/shared/agent-terminal.ts b/packages/shared/agent-terminal.ts index 42f8339a3..40f8a1646 100644 --- a/packages/shared/agent-terminal.ts +++ b/packages/shared/agent-terminal.ts @@ -1,53 +1 @@ -export const AGENT_TERMINAL_WS_BASE_PATH = "/api/agent-terminal/pty"; - -export function buildAgentTerminalWsPath(token: string): string { - if (!token || token.includes("/") || token.includes("?") || token.includes("#")) { - throw new Error("Agent terminal WebSocket token must be a non-empty path segment."); - } - return `${AGENT_TERMINAL_WS_BASE_PATH}/${encodeURIComponent(token)}`; -} - -export function isAgentTerminalWsRoute(pathname: string): boolean { - return pathname === AGENT_TERMINAL_WS_BASE_PATH || - pathname.startsWith(`${AGENT_TERMINAL_WS_BASE_PATH}/`); -} - -export type AgentTerminalDisabledReason = - | "not-annotate-mode" - | "remote-disabled" - | "runtime-unavailable" - | "webtui-unavailable" - | "pty-unavailable" - | "unsupported-runtime"; - -export type AgentTerminalAgent = { - id: string; - name: string; - available: boolean; -}; - -export type AgentTerminalCapability = - | { - enabled: true; - cwd: string; - wsPath: string; - agents: AgentTerminalAgent[]; - } - | { - enabled: false; - reason: AgentTerminalDisabledReason; - message?: string; - }; - -export type AnnotateAgentTerminalMode = - | "annotate" - | "annotate-last" - | "annotate-folder" - | string - | undefined; - -export function supportsAnnotateAgentTerminalMode( - mode: AnnotateAgentTerminalMode, -): boolean { - return mode === "annotate" || mode === "annotate-folder"; -} +export * from '@plannotator/core/agent-terminal'; diff --git a/packages/shared/agents.ts b/packages/shared/agents.ts index cc9994135..2945300fb 100644 --- a/packages/shared/agents.ts +++ b/packages/shared/agents.ts @@ -1,53 +1 @@ -/** - * Centralized agent configuration — single source of truth for all supported agents. - * - * To add a new agent: - * 1. Add an entry to AGENT_CONFIG below (origin key, display name, badge CSS classes, - * optional AI provider types) - * 2. If detection is via environment variable, add it to the detection chain - * in apps/hook/server/index.ts (detectedOrigin constant) - * 3. That's it — all UI components read from this config automatically - */ - -type AgentConfigEntry = { - name: string; - badge: string; - /** AI provider type(s) that naturally match this origin, in preference order. */ - aiProviderTypes?: readonly string[]; -}; - -export const AGENT_CONFIG = { - 'claude-code': { name: 'Claude Code', badge: 'bg-orange-500/15 text-orange-400', aiProviderTypes: ['claude-agent-sdk'] }, - 'amp': { name: 'Amp', badge: 'bg-lime-500/15 text-lime-400' }, - 'droid': { name: 'Droid', badge: 'bg-cyan-500/15 text-cyan-400' }, - 'kiro-cli': { name: 'Kiro CLI', badge: 'bg-amber-500/15 text-amber-400' }, - 'opencode': { name: 'OpenCode', badge: 'bg-emerald-500/15 text-emerald-400', aiProviderTypes: ['opencode-sdk'] }, - 'copilot-cli': { name: 'GitHub Copilot', badge: 'bg-blue-500/15 text-blue-400' }, - 'pi': { name: 'Pi', badge: 'bg-violet-500/15 text-violet-400', aiProviderTypes: ['pi-sdk'] }, - 'codex': { name: 'Codex', badge: 'bg-purple-500/15 text-purple-400', aiProviderTypes: ['codex-sdk'] }, - 'gemini-cli': { name: 'Gemini CLI', badge: 'bg-sky-500/15 text-sky-400' }, -} as const satisfies Record; - -/** All recognized origin values. */ -export type Origin = keyof typeof AGENT_CONFIG; - -/** Resolve an origin to a human-readable agent name. */ -export function getAgentName(origin: Origin | null | undefined): string { - if (origin && origin in AGENT_CONFIG) return AGENT_CONFIG[origin as Origin].name; - return 'Coding Agent'; -} - -/** Resolve an origin to Tailwind badge classes. */ -export function getAgentBadge(origin: Origin | null | undefined): string { - if (origin && origin in AGENT_CONFIG) return AGENT_CONFIG[origin as Origin].badge; - return 'bg-zinc-500/20 text-zinc-400'; -} - -/** Resolve an origin to matching AI provider types, in preference order. */ -export function getAgentAIProviderTypes(origin: Origin | null | undefined): readonly string[] { - if (origin && origin in AGENT_CONFIG) { - const config = AGENT_CONFIG[origin as Origin]; - return 'aiProviderTypes' in config ? config.aiProviderTypes : []; - } - return []; -} +export * from '@plannotator/core/agents'; diff --git a/packages/shared/browser-paths.ts b/packages/shared/browser-paths.ts index ce867a505..f2f297ab6 100644 --- a/packages/shared/browser-paths.ts +++ b/packages/shared/browser-paths.ts @@ -1,25 +1 @@ -export function normalizeBrowserPath(path: string): string { - const withForwardSlashes = path.replace(/\\/g, "/"); - const prefix = withForwardSlashes.startsWith("//") ? "//" : ""; - const collapsed = prefix + withForwardSlashes.slice(prefix.length).replace(/\/+/g, "/"); - if (collapsed === "/" || /^[A-Za-z]:\/$/.test(collapsed)) return collapsed; - return collapsed.replace(/\/+$/, ""); -} - -export function dirnameBrowserPath(path: string): string { - const normalized = normalizeBrowserPath(path); - const driveRootMatch = normalized.match(/^([A-Za-z]:)\/[^/]+$/); - if (driveRootMatch) return `${driveRootMatch[1]}/`; - const index = normalized.lastIndexOf("/"); - if (index < 0) return normalized; - if (index === 0) return "/"; - return normalized.slice(0, index); -} - -export function pathIsInsideDir(path: string, dir: string): boolean { - const normalizedPath = normalizeBrowserPath(path); - const normalizedDir = normalizeBrowserPath(dir); - if (!normalizedDir) return normalizedPath === ""; - const dirPrefix = normalizedDir.endsWith("/") ? normalizedDir : `${normalizedDir}/`; - return normalizedPath === normalizedDir || normalizedPath.startsWith(dirPrefix); -} +export * from '@plannotator/core/browser-paths'; diff --git a/packages/shared/code-file.ts b/packages/shared/code-file.ts index 43633a766..dc82b5006 100644 --- a/packages/shared/code-file.ts +++ b/packages/shared/code-file.ts @@ -1,41 +1 @@ -export const CODE_FILE_REGEX = /(?:\.(tsx?|jsx?|py|rb|go|rs|java|c|cpp|h|hpp|cs|swift|kt|scala|sh|bash|zsh|sql|graphql|json|ya?ml|toml|ini|css|scss|less|xml|tf|lua|r|dart|ex|exs|vue|svelte|astro|zig|proto)|(?:^|\/)(Dockerfile|Makefile|Rakefile|Gemfile|Procfile|Vagrantfile|Brewfile|Justfile))$/i; - -export const CODE_PATH_BARE_REGEX = /(?:\.{0,2}\/)?(?:[a-zA-Z0-9_@.\-\[\]]+\/)+[a-zA-Z0-9_.\-\[\]]+\.[a-zA-Z0-9]+(?::\d+(?:-\d+)?)?/g; - -const IMPLAUSIBLE_CHARS = /[{},*?\s]/; - -export function isPlausibleCodeFilePath(input: string): boolean { - return !IMPLAUSIBLE_CHARS.test(input); -} - -export interface ParsedCodePath { - filePath: string; - line?: number; - lineEnd?: number; -} - -const LINE_SUFFIX_RE = /:(\d+)(?:-(\d+))?$/; - -export function parseCodePath(input: string): ParsedCodePath { - const clean = input.replace(/#.*$/, ''); - const m = clean.match(LINE_SUFFIX_RE); - if (!m) return { filePath: clean }; - let line = Number.parseInt(m[1], 10); - let lineEnd = m[2] ? Number.parseInt(m[2], 10) : undefined; - if (lineEnd != null && lineEnd < line) { const tmp = line; line = lineEnd; lineEnd = tmp; } - return { filePath: clean.replace(LINE_SUFFIX_RE, ''), line, lineEnd }; -} - -export function stripLineRef(input: string): string { - return input.replace(/#.*$/, '').replace(LINE_SUFFIX_RE, ''); -} - -export function isCodeFilePath(input: string): boolean { - if (!isPlausibleCodeFilePath(input)) return false; - return CODE_FILE_REGEX.test(stripLineRef(input)) - && !input.startsWith('http://') && !input.startsWith('https://'); -} - -export function isCodeFilePathStrict(input: string): boolean { - return input.includes('/') && isCodeFilePath(input); -} +export * from '@plannotator/core/code-file'; diff --git a/packages/shared/compress.ts b/packages/shared/compress.ts index 70c5099ac..723038922 100644 --- a/packages/shared/compress.ts +++ b/packages/shared/compress.ts @@ -1,51 +1 @@ -/** - * Portable deflate-raw + base64url compression. - * - * Uses only Web APIs (CompressionStream, TextEncoder, btoa) so it works - * in browsers, Bun, and edge runtimes. Both @plannotator/server and - * @plannotator/ui import from here — single source of truth. - */ - -export async function compress(data: unknown): Promise { - const json = JSON.stringify(data); - const byteArray = new TextEncoder().encode(json); - - const stream = new CompressionStream('deflate-raw'); - const writer = stream.writable.getWriter(); - writer.write(byteArray); - writer.close(); - - const buffer = await new Response(stream.readable).arrayBuffer(); - const compressed = new Uint8Array(buffer); - - // Loop instead of spread to avoid RangeError on large payloads - // (String.fromCharCode(...arr) has a ~65K argument limit) - let binary = ''; - for (let i = 0; i < compressed.length; i++) { - binary += String.fromCharCode(compressed[i]); - } - const base64 = btoa(binary); - return base64 - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, ''); -} - -export async function decompress(b64: string): Promise { - const base64 = b64 - .replace(/-/g, '+') - .replace(/_/g, '/'); - - const binary = atob(base64); - const byteArray = Uint8Array.from(binary, c => c.charCodeAt(0)); - - const stream = new DecompressionStream('deflate-raw'); - const writer = stream.writable.getWriter(); - writer.write(byteArray); - writer.close(); - - const buffer = await new Response(stream.readable).arrayBuffer(); - const json = new TextDecoder().decode(buffer); - - return JSON.parse(json); -} +export * from '@plannotator/core/compress'; diff --git a/packages/shared/config.ts b/packages/shared/config.ts index 7ab823e44..68a3cecc9 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -10,24 +10,8 @@ import { getPlannotatorDataDir } from "./data-dir"; import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; import { execSync } from "child_process"; -export type DefaultDiffType = 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all'; -export type DiffLineBgIntensity = 'subtle' | 'normal' | 'strong'; - -export interface DiffOptions { - diffStyle?: 'split' | 'unified'; - overflow?: 'scroll' | 'wrap'; - diffIndicators?: 'bars' | 'classic' | 'none'; - lineDiffType?: 'word-alt' | 'word' | 'char' | 'none'; - showLineNumbers?: boolean; - showDiffBackground?: boolean; - fontFamily?: string; - fontSize?: string; - tabSize?: number; - hideWhitespace?: boolean; - expandUnchanged?: boolean; - defaultDiffType?: DefaultDiffType; - lineBgIntensity?: DiffLineBgIntensity; -} +import type { DefaultDiffType, DiffLineBgIntensity, DiffOptions } from '@plannotator/core/config-types'; +export type { DefaultDiffType, DiffLineBgIntensity, DiffOptions }; /** Single conventional comment label entry stored in config.json */ export interface CCLabelConfig { diff --git a/packages/shared/crypto.ts b/packages/shared/crypto.ts index 0161e6dcd..663340856 100644 --- a/packages/shared/crypto.ts +++ b/packages/shared/crypto.ts @@ -1,97 +1 @@ -/** - * AES-256-GCM encryption for zero-knowledge paste storage. - * - * Uses Web Crypto API — works in browsers, Bun, and edge runtimes. - * The key never leaves the client; it lives in the URL fragment. - */ - -/** - * Encrypt a compressed base64url string with a fresh AES-256-GCM key. - * - * Returns { ciphertext, key } where: - * - ciphertext: base64url-encoded (12-byte IV prepended to GCM output) - * - key: base64url-encoded 256-bit key for the URL fragment - */ -export async function encrypt( - compressedData: string -): Promise<{ ciphertext: string; key: string }> { - const cryptoKey = await crypto.subtle.generateKey( - { name: 'AES-GCM', length: 256 }, - true, - ['encrypt'] - ); - - const iv = crypto.getRandomValues(new Uint8Array(12)); - const plaintext = new TextEncoder().encode(compressedData); - - const encrypted = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, - cryptoKey, - plaintext - ); - - // Prepend IV to ciphertext (IV || ciphertext+tag) - const combined = new Uint8Array(iv.length + encrypted.byteLength); - combined.set(iv, 0); - combined.set(new Uint8Array(encrypted), iv.length); - - const rawKey = await crypto.subtle.exportKey('raw', cryptoKey); - - return { - ciphertext: bytesToBase64url(combined), - key: bytesToBase64url(new Uint8Array(rawKey)), - }; -} - -/** - * Decrypt a ciphertext string using a base64url-encoded AES-256-GCM key. - * - * Expects ciphertext format: base64url(IV || encrypted+tag) - * Returns the original compressed base64url string. - */ -export async function decrypt( - ciphertext: string, - key: string -): Promise { - const combined = base64urlToBytes(ciphertext); - const rawKey = base64urlToBytes(key); - - const iv = combined.slice(0, 12); - const encrypted = combined.slice(12); - - const cryptoKey = await crypto.subtle.importKey( - 'raw', - rawKey.buffer as ArrayBuffer, - { name: 'AES-GCM', length: 256 }, - false, - ['decrypt'] - ); - - const decrypted = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv }, - cryptoKey, - encrypted - ); - - return new TextDecoder().decode(decrypted); -} - -// --- Helpers --- - -function bytesToBase64url(bytes: Uint8Array): string { - // Loop to avoid RangeError on large payloads (same approach as compress.ts) - let binary = ''; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, ''); -} - -function base64urlToBytes(b64: string): Uint8Array { - const base64 = b64.replace(/-/g, '+').replace(/_/g, '/'); - const binary = atob(base64); - return Uint8Array.from(binary, c => c.charCodeAt(0)); -} +export * from '@plannotator/core/crypto'; diff --git a/packages/shared/external-annotation.ts b/packages/shared/external-annotation.ts index 2260e3f87..f1d1f2838 100644 --- a/packages/shared/external-annotation.ts +++ b/packages/shared/external-annotation.ts @@ -1,455 +1 @@ -/** - * External Annotations — shared types, store logic, and SSE helpers. - * - * Runtime-agnostic: no node:fs, no node:http, no Bun APIs. - * Both the Bun server handler and Pi server handler import this module - * and wrap it with their respective HTTP transport layers. - * - * The store is generic — plan servers store Annotation objects, - * review servers store CodeAnnotation objects. The mode-specific - * input transformers handle validation and field assignment. - */ - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** Constraint for any annotation type the store can hold. */ -export type StorableAnnotation = { id: string; source?: string }; - -export type ExternalAnnotationEvent = - | { type: "snapshot"; annotations: T[] } - | { type: "add"; annotations: T[] } - | { type: "remove"; ids: string[] } - | { type: "clear"; source?: string } - | { type: "update"; id: string; annotation: T }; - -// --------------------------------------------------------------------------- -// SSE helpers -// --------------------------------------------------------------------------- - -/** Heartbeat comment to keep SSE connections alive (sent every 30s). */ -export const HEARTBEAT_COMMENT = ":\n\n"; - -/** Interval in ms between heartbeat comments. */ -export const HEARTBEAT_INTERVAL_MS = 30_000; - -/** Encode an event as an SSE `data:` line. */ -export function serializeSSEEvent(event: ExternalAnnotationEvent): string { - return `data: ${JSON.stringify(event)}\n\n`; -} - -// --------------------------------------------------------------------------- -// Input validation — shared helpers -// --------------------------------------------------------------------------- - -export interface ParseError { - error: string; -} - -/** - * Unwrap a POST body into an array of raw input objects. - * - * Accepts either: - * - A single annotation object: `{ source: "...", ... }` - * - A batch wrapper: `{ annotations: [{ source: "...", ... }, ...] }` - */ -function unwrapBody(body: unknown): Record[] | ParseError { - if (!body || typeof body !== "object") { - return { error: "Request body must be a JSON object" }; - } - - const obj = body as Record; - - // Batch format: { annotations: [...] } - if (Array.isArray(obj.annotations)) { - if (obj.annotations.length === 0) { - return { error: "annotations array must not be empty" }; - } - const items: Record[] = []; - for (let i = 0; i < obj.annotations.length; i++) { - const item = obj.annotations[i]; - if (!item || typeof item !== "object") { - return { error: `annotations[${i}] must be an object` }; - } - items.push(item as Record); - } - return items; - } - - // Single format: { source: "...", ... } - if (typeof obj.source === "string") { - return [obj as Record]; - } - - return { error: 'Missing required "source" field or "annotations" array' }; -} - -function requireString(obj: Record, field: string, index: number): string | ParseError { - const val = obj[field]; - if (typeof val !== "string" || val.length === 0) { - return { error: `annotations[${index}] missing required "${field}" field` }; - } - return val; -} - -// --------------------------------------------------------------------------- -// Plan mode transformer — produces Annotation objects -// --------------------------------------------------------------------------- - -/** The Annotation type shape for plan mode (mirrors packages/ui/types.ts). */ -interface PlanAnnotation { - id: string; - blockId: string; - startOffset: number; - endOffset: number; - type: string; // AnnotationType value - text?: string; - originalText: string; - createdA: number; - author?: string; - source?: string; -} - -const VALID_PLAN_TYPES = ["DELETION", "COMMENT", "GLOBAL_COMMENT"]; - -export function transformPlanInput( - body: unknown, -): { annotations: PlanAnnotation[] } | ParseError { - const items = unwrapBody(body); - if ("error" in items) return items; - - const annotations: PlanAnnotation[] = []; - for (let i = 0; i < items.length; i++) { - const obj = items[i]; - - const source = requireString(obj, "source", i); - if (typeof source !== "string") return source; - - // Must have text content - if (typeof obj.text !== "string" || obj.text.length === 0) { - return { error: `annotations[${i}] missing required "text" field` }; - } - - // Validate type if provided, default to GLOBAL_COMMENT - const type = typeof obj.type === "string" ? obj.type : "GLOBAL_COMMENT"; - if (!VALID_PLAN_TYPES.includes(type)) { - return { - error: `annotations[${i}] invalid type "${type}". Must be one of: ${VALID_PLAN_TYPES.join(", ")}`, - }; - } - - // DELETION requires originalText (the text to remove) - if (type === "DELETION" && (typeof obj.originalText !== "string" || obj.originalText.length === 0)) { - return { error: `annotations[${i}] DELETION type requires non-empty "originalText" field` }; - } - - // COMMENT requires originalText so the renderer can pin it to a phrase. - // External agents that want sidebar-only feedback should use GLOBAL_COMMENT - // instead — without a phrase to anchor to, a COMMENT renders as an empty - // quote bubble in the sidebar and exports as `Feedback on: ""`. - if (type === "COMMENT" && (typeof obj.originalText !== "string" || obj.originalText.length === 0)) { - return { - error: `annotations[${i}] COMMENT requires non-empty "originalText" field. Use GLOBAL_COMMENT for sidebar-only feedback.`, - }; - } - - annotations.push({ - id: crypto.randomUUID(), - blockId: "external", - startOffset: 0, - endOffset: 0, - type, - text: String(obj.text), - originalText: typeof obj.originalText === "string" ? obj.originalText : "", - createdA: Date.now(), - author: typeof obj.author === "string" ? obj.author : undefined, - source, - }); - } - - return { annotations }; -} - -// --------------------------------------------------------------------------- -// Review mode transformer — produces CodeAnnotation objects -// --------------------------------------------------------------------------- - -/** The CodeAnnotation type shape for review mode (mirrors packages/ui/types.ts). */ -interface ReviewAnnotation { - id: string; - type: string; // CodeAnnotationType value - scope?: string; - filePath: string; - lineStart: number; - lineEnd: number; - side: string; - text?: string; - suggestedCode?: string; - originalCode?: string; - createdAt: number; - author?: string; - source?: string; - // Agent review metadata (optional — only set by agent review findings) - severity?: string; // "important" | "nit" | "pre_existing" - reasoning?: string; // Validation chain explaining how the issue was confirmed - reviewProfileLabel?: string; // Custom review profile that produced this finding -} - -const VALID_REVIEW_TYPES = ["comment", "suggestion", "concern"]; -const VALID_SIDES = ["old", "new"]; -const VALID_SCOPES = ["line", "file", "general"]; - -/** A review finding's placement, derived from what it carries. */ -export type FindingPlacement = { - scope: "line" | "file" | "general"; - filePath: string; - lineStart: number; - lineEnd: number; -}; - -/** - * Classify an agent review finding by what it carries, so nothing is dropped: - * file + a usable line → a line comment - * file, no line → a whole-file comment - * neither → a general (review-level) comment - * - * For file and general placements the line is 0; for general the path is "". - * Consumers branch on `scope`, never on the sentinel values. - */ -export function classifyFindingPlacement( - filePath: string, - lineStart: number | null | undefined, - lineEnd: number | null | undefined, -): FindingPlacement { - const hasFile = filePath.length > 0; - const hasLine = typeof lineStart === "number"; - if (hasFile && hasLine) { - return { - scope: "line", - filePath, - lineStart, - lineEnd: typeof lineEnd === "number" ? lineEnd : lineStart, - }; - } - if (hasFile) { - return { scope: "file", filePath, lineStart: 0, lineEnd: 0 }; - } - return { scope: "general", filePath: "", lineStart: 0, lineEnd: 0 }; -} - -export function transformReviewInput( - body: unknown, -): { annotations: ReviewAnnotation[] } | ParseError { - const items = unwrapBody(body); - if ("error" in items) return items; - - const annotations: ReviewAnnotation[] = []; - for (let i = 0; i < items.length; i++) { - const obj = items[i]; - - const source = requireString(obj, "source", i); - if (typeof source !== "string") return source; - - // scope: optional, defaults to "line" - const scope = typeof obj.scope === "string" ? obj.scope : "line"; - if (!VALID_SCOPES.includes(scope)) { - return { - error: `annotations[${i}] invalid scope "${scope}". Must be one of: ${VALID_SCOPES.join(", ")}`, - }; - } - - // Location requirements depend on scope: - // line → filePath + lineStart + lineEnd required. A finding that claims - // a line must carry one, so a broken line finding is rejected - // rather than quietly passing as a vaguer comment. - // file → filePath required; line ignored (defaults to 0). - // general → no file, no line (review-level; defaults to "" / 0). - let filePath = ""; - let lineStart = 0; - let lineEnd = 0; - if (scope !== "general") { - const fp = requireString(obj, "filePath", i); - if (typeof fp !== "string") return fp; - filePath = fp; - if (scope === "line") { - if (typeof obj.lineStart !== "number") { - return { error: `annotations[${i}] missing required "lineStart" field` }; - } - if (typeof obj.lineEnd !== "number") { - return { error: `annotations[${i}] missing required "lineEnd" field` }; - } - lineStart = obj.lineStart; - lineEnd = obj.lineEnd; - } else { - lineStart = typeof obj.lineStart === "number" ? obj.lineStart : 0; - lineEnd = typeof obj.lineEnd === "number" ? obj.lineEnd : 0; - } - } - - // side: optional, defaults to "new" - const side = typeof obj.side === "string" ? obj.side : "new"; - if (!VALID_SIDES.includes(side)) { - return { - error: `annotations[${i}] invalid side "${side}". Must be one of: ${VALID_SIDES.join(", ")}`, - }; - } - - // type: optional, defaults to "comment" - const type = typeof obj.type === "string" ? obj.type : "comment"; - if (!VALID_REVIEW_TYPES.includes(type)) { - return { - error: `annotations[${i}] invalid type "${type}". Must be one of: ${VALID_REVIEW_TYPES.join(", ")}`, - }; - } - - // Must have at least text or suggestedCode - if (typeof obj.text !== "string" && typeof obj.suggestedCode !== "string") { - return { - error: `annotations[${i}] must have at least one of: text, suggestedCode`, - }; - } - - annotations.push({ - id: crypto.randomUUID(), - type, - scope, - filePath, - lineStart, - lineEnd, - side, - text: typeof obj.text === "string" ? obj.text : undefined, - suggestedCode: typeof obj.suggestedCode === "string" ? obj.suggestedCode : undefined, - originalCode: typeof obj.originalCode === "string" ? obj.originalCode : undefined, - createdAt: Date.now(), - author: typeof obj.author === "string" ? obj.author : undefined, - source, - // Agent review metadata (optional — only set by agent review findings) - ...(typeof obj.severity === "string" && { severity: obj.severity }), - ...(typeof obj.reasoning === "string" && { reasoning: obj.reasoning }), - ...(typeof obj.reviewProfileLabel === "string" && { reviewProfileLabel: obj.reviewProfileLabel }), - }); - } - - return { annotations }; -} - -// --------------------------------------------------------------------------- -// Annotation Store (generic) -// --------------------------------------------------------------------------- - -type MutationListener = (event: ExternalAnnotationEvent) => void; - -export interface AnnotationStore { - /** Add fully-formed annotations. Returns the added annotations. */ - add(items: T[]): T[]; - /** Remove an annotation by ID. Returns true if found. */ - remove(id: string): boolean; - /** Remove all annotations from a specific source. Returns count removed. */ - clearBySource(source: string): number; - /** Update an annotation by ID. Returns the updated annotation, or null if not found. */ - update(id: string, fields: Partial): T | null; - /** Remove all annotations. Returns count removed. */ - clearAll(): number; - /** Get all annotations (snapshot). */ - getAll(): T[]; - /** Monotonic version counter — incremented on every mutation. */ - readonly version: number; - /** Register a listener for mutation events. Returns unsubscribe function. */ - onMutation(listener: MutationListener): () => void; -} - -/** - * Create an in-memory annotation store. - * - * The store is runtime-agnostic — it holds data and emits events. - * HTTP transport (SSE broadcasting, request parsing) is handled by - * the server-specific adapter (Bun or Pi). - */ -export function createAnnotationStore(): AnnotationStore { - const annotations: T[] = []; - const listeners = new Set>(); - let version = 0; - - function emit(event: ExternalAnnotationEvent): void { - for (const listener of listeners) { - try { - listener(event); - } catch { - // Don't let a failing listener break the store - } - } - } - - return { - add(items) { - if (items.length > 0) { - for (const item of items) { - annotations.push(item); - } - version++; - emit({ type: "add", annotations: items }); - } - return items; - }, - - remove(id) { - const idx = annotations.findIndex((a) => a.id === id); - if (idx === -1) return false; - annotations.splice(idx, 1); - version++; - emit({ type: "remove", ids: [id] }); - return true; - }, - - update(id, fields) { - const idx = annotations.findIndex((a) => a.id === id); - if (idx === -1) return null; - const merged = { ...annotations[idx], ...fields, id } as T; - annotations[idx] = merged; - version++; - emit({ type: "update", id, annotation: merged }); - return merged; - }, - - clearBySource(source) { - const before = annotations.length; - for (let i = annotations.length - 1; i >= 0; i--) { - if (annotations[i].source === source) { - annotations.splice(i, 1); - } - } - const removed = before - annotations.length; - if (removed > 0) { - version++; - emit({ type: "clear", source }); - } - return removed; - }, - - clearAll() { - const count = annotations.length; - if (count > 0) { - annotations.length = 0; - version++; - emit({ type: "clear" }); - } - return count; - }, - - getAll() { - return [...annotations]; - }, - - get version() { - return version; - }, - - onMutation(listener) { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - }; -} +export * from '@plannotator/core/external-annotation'; diff --git a/packages/shared/extract-code-paths.ts b/packages/shared/extract-code-paths.ts index c4ef380fd..98aefed88 100644 --- a/packages/shared/extract-code-paths.ts +++ b/packages/shared/extract-code-paths.ts @@ -1,66 +1 @@ -import { - CODE_PATH_BARE_REGEX, - isCodeFilePath, - isCodeFilePathStrict, -} from "./code-file"; - -const FENCED_CODE_BLOCK = /(^|\n)([ \t]*)(```|~~~)[\s\S]*?\n\2\3/g; -const HTML_COMMENT = //g; -// Match InlineMarkdown.tsx's bare-URL regex exactly so URL ranges excised -// here mirror the ranges the renderer would consume. -const URL_REGEX = /https?:\/\/[^\s<>"']+/g; -const BACKTICK_SPAN = /`([^`\n]+)`/g; - -/** - * Extract candidate code-file paths from markdown text. Mirrors the renderer's - * detection precedence so the validator only sees paths the renderer would - * actually linkify: - * 1. fenced code blocks and HTML comments are stripped first; - * 2. URL ranges are excised before the bare-prose scan (URLs win); - * 3. backtick spans matching `isCodeFilePath` are collected; - * 4. bare-prose paths matching `CODE_PATH_BARE_REGEX` and - * `isCodeFilePathStrict` are collected. - * - * Hash anchors (`#L42`) are stripped from results to match the renderer's - * `cleanPath` transform. Returns deduped candidate strings. - */ -export function extractCandidateCodePaths(markdown: string): string[] { - const stripped = markdown - .replace(FENCED_CODE_BLOCK, "") - .replace(HTML_COMMENT, ""); - - const candidates = new Set(); - - let m: RegExpExecArray | null; - const backtickRe = new RegExp(BACKTICK_SPAN.source, "g"); - while ((m = backtickRe.exec(stripped)) !== null) { - const inner = m[1].trim(); - if (isCodeFilePath(inner)) { - candidates.add(inner.replace(/#.*$/, "")); - } - } - - for (const line of stripped.split("\n")) { - const urlRanges: Array<[number, number]> = []; - const urlRe = new RegExp(URL_REGEX.source, "g"); - while ((m = urlRe.exec(line)) !== null) { - urlRanges.push([m.index, m.index + m[0].length]); - } - - const pathRe = new RegExp(CODE_PATH_BARE_REGEX.source, "g"); - while ((m = pathRe.exec(line)) !== null) { - const start = m.index; - const end = start + m[0].length; - const prev = start === 0 ? "" : line[start - 1]; - if (/\w/.test(prev)) continue; - const overlapsUrl = urlRanges.some( - ([s, e]) => start < e && end > s, - ); - if (overlapsUrl) continue; - if (!isCodeFilePathStrict(m[0])) continue; - candidates.add(m[0].replace(/#.*$/, "")); - } - } - - return Array.from(candidates); -} +export * from '@plannotator/core/extract-code-paths'; diff --git a/packages/shared/favicon.ts b/packages/shared/favicon.ts index c857b8419..c5c6f94c0 100644 --- a/packages/shared/favicon.ts +++ b/packages/shared/favicon.ts @@ -1,5 +1 @@ -export const FAVICON_SVG = ` - - - P -`; +export * from '@plannotator/core/favicon'; diff --git a/packages/shared/feedback-templates.ts b/packages/shared/feedback-templates.ts index 02d9b3217..7ba12f70e 100644 --- a/packages/shared/feedback-templates.ts +++ b/packages/shared/feedback-templates.ts @@ -1,45 +1 @@ -/** - * Shared feedback templates for all agent integrations. - * - * The plan deny template was tuned in #224 / commit 3dca977 to use strong - * directive framing — Claude was ignoring softer phrasing. - * - * IMPORTANT: This module is imported by packages/ui/utils/parser.ts which is - * bundled into the browser SPA. It must NOT import from ./prompts or ./config - * (which depend on node:fs, node:os, node:child_process). Keep it self-contained. - * - * Server-side call sites use getPlanDeniedPrompt() from ./prompts directly. - * This module is only kept for the browser's wrapFeedbackForAgent clipboard feature. - */ - -export interface PlanDenyFeedbackOptions { - planFilePath?: string; -} - -export interface AnnotateFileFeedbackOptions { - filePath: string; - fileHeader?: "File" | "Folder" | string; -} - -export const planDenyFeedback = ( - feedback: string, - toolName: string = "ExitPlanMode", - options?: PlanDenyFeedbackOptions, -): string => { - const planFileRule = options?.planFilePath - ? `- Your plan is saved at: ${options.planFilePath}\n You can edit this file to make targeted changes, then pass its path to ${toolName}.\n` - : ""; - - return `YOUR PLAN WAS NOT APPROVED.\n\nYou MUST revise the plan to address ALL of the feedback below before calling ${toolName} again.\n\nRules:\n${planFileRule}- Do not resubmit the same plan unchanged.\n- Do NOT change the plan title (first # heading) unless the user explicitly asks you to.\n\n${feedback || "Plan changes requested"}`; -}; - -export const annotateFileFeedback = ( - feedback: string, - options: AnnotateFileFeedbackOptions, -): string => { - const fileHeader = options.fileHeader ?? "File"; - return `# Markdown Annotations\n\n${fileHeader}: ${options.filePath}\n\n${feedback}\n\nPlease address the annotation feedback above.`; -}; - -export const annotateMessageFeedback = (feedback: string): string => - `# Message Annotations\n\n${feedback}\n\nPlease address the annotation feedback above.`; +export * from '@plannotator/core/feedback-templates'; diff --git a/packages/shared/goal-setup.ts b/packages/shared/goal-setup.ts index 0b07f9c40..bd1dfb09d 100644 --- a/packages/shared/goal-setup.ts +++ b/packages/shared/goal-setup.ts @@ -1,336 +1 @@ -export type GoalSetupStage = "interview" | "facts"; - -export type GoalSetupAnswerMode = - | "text" - | "single" - | "multi" - | "single-custom" - | "multi-custom" - | "custom"; - -export interface GoalSetupQuestionOption { - id: string; - label: string; - description?: string; -} - -export interface GoalSetupQuestion { - id: string; - prompt: string; - description?: string; - answerMode?: GoalSetupAnswerMode; - recommendedAnswer?: string; - recommendedOptionIds?: string[]; - options?: GoalSetupQuestionOption[]; - required?: boolean; -} - -export interface GoalSetupQuestionAnswer { - questionId: string; - selectedOptionIds: string[]; - customAnswer: string; - note?: string; - answer: string; - completed: boolean; - skipped?: boolean; -} - -export interface GoalSetupInterviewBundle { - stage: "interview"; - title?: string; - goalSlug?: string; - questions: GoalSetupQuestion[]; -} - -export interface GoalSetupFact { - id: string; - text: string; - accepted: boolean; - removed: boolean; - comment?: string; - recommendedAutomatedVerification?: boolean; - automatedVerification: boolean; - previousText?: string; -} - -export interface GoalSetupFactsBundle { - stage: "facts"; - title?: string; - goalSlug?: string; - facts: GoalSetupFact[]; - showAccepted?: boolean; -} - -export type GoalSetupBundle = GoalSetupInterviewBundle | GoalSetupFactsBundle; - -export interface GoalSetupInterviewResult { - stage: "interview"; - title?: string; - goalSlug?: string; - answers: GoalSetupQuestionAnswer[]; -} - -export interface GoalSetupFactResult { - id: string; - text: string; - accepted: boolean; - removed: boolean; - comment?: string; - automatedVerification: boolean; - recommendedAutomatedVerification?: boolean; -} - -export interface GoalSetupFactsResult { - stage: "facts"; - title?: string; - goalSlug?: string; - facts: GoalSetupFactResult[]; - factsMarkdown: string; -} - -export type GoalSetupResult = GoalSetupInterviewResult | GoalSetupFactsResult; - -function asRecord(value: unknown, context: string): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error(`${context} must be an object`); - } - return value as Record; -} - -function asString(value: unknown, fallback = ""): string { - return typeof value === "string" ? value : fallback; -} - -function asBoolean(value: unknown, fallback: boolean): boolean { - return typeof value === "boolean" ? value : fallback; -} - -function normalizeId(value: unknown, fallback: string): string { - const raw = asString(value, fallback).trim(); - return raw || fallback; -} - -function normalizeAnswerMode(value: unknown): GoalSetupAnswerMode { - switch (value) { - case "single": - case "multi": - case "single-custom": - case "multi-custom": - case "custom": - case "text": - return value; - default: - return "text"; - } -} - -function normalizeOption(value: unknown, index: number): GoalSetupQuestionOption { - const item = asRecord(value, `questions[].options[${index}]`); - const label = asString(item.label).trim(); - if (!label) { - throw new Error(`questions[].options[${index}].label is required`); - } - return { - id: normalizeId(item.id, `option-${index + 1}`), - label, - ...(asString(item.description).trim() - ? { description: asString(item.description).trim() } - : {}), - }; -} - -function normalizeQuestion(value: unknown, index: number): GoalSetupQuestion { - const item = asRecord(value, `questions[${index}]`); - const prompt = asString(item.prompt).trim(); - if (!prompt) { - throw new Error(`questions[${index}].prompt is required`); - } - const options = Array.isArray(item.options) - ? item.options.map(normalizeOption) - : undefined; - - const recommendedOptionIds = Array.isArray(item.recommendedOptionIds) - ? (item.recommendedOptionIds as unknown[]).filter((id): id is string => typeof id === 'string') - : undefined; - - return { - id: normalizeId(item.id, `question-${index + 1}`), - prompt, - ...(asString(item.description).trim() - ? { description: asString(item.description).trim() } - : {}), - answerMode: normalizeAnswerMode(item.answerMode), - ...(asString(item.recommendedAnswer).trim() - ? { recommendedAnswer: asString(item.recommendedAnswer).trim() } - : {}), - ...(recommendedOptionIds && recommendedOptionIds.length > 0 - ? { recommendedOptionIds } - : {}), - ...(options && options.length > 0 ? { options } : {}), - required: asBoolean(item.required, true), - }; -} - -function normalizeFact(value: unknown, index: number): GoalSetupFact { - const item = asRecord(value, `facts[${index}]`); - const text = asString(item.text).trim(); - if (!text) { - throw new Error(`facts[${index}].text is required`); - } - const recommended = asBoolean(item.recommendedAutomatedVerification, false); - return { - id: normalizeId(item.id, `fact-${index + 1}`), - text, - accepted: asBoolean(item.accepted, false), - removed: asBoolean(item.removed, false), - ...(asString(item.comment).trim() - ? { comment: asString(item.comment).trim() } - : {}), - recommendedAutomatedVerification: recommended, - automatedVerification: asBoolean(item.automatedVerification, recommended), - ...(asString(item.previousText).trim() - ? { previousText: asString(item.previousText).trim() } - : {}), - }; -} - -export function normalizeInterviewBundle(value: unknown): GoalSetupInterviewBundle { - const raw = asRecord(value, "goal setup interview bundle"); - if (!Array.isArray(raw.questions) || raw.questions.length === 0) { - throw new Error("interview bundle requires at least one question"); - } - return { - stage: "interview", - ...(asString(raw.title).trim() ? { title: asString(raw.title).trim() } : {}), - ...(asString(raw.goalSlug).trim() - ? { goalSlug: asString(raw.goalSlug).trim() } - : {}), - questions: raw.questions.map(normalizeQuestion), - }; -} - -export function normalizeFactsBundle(value: unknown): GoalSetupFactsBundle { - const raw = asRecord(value, "goal setup facts bundle"); - if (!Array.isArray(raw.facts)) { - throw new Error("facts bundle requires a facts array"); - } - return { - stage: "facts", - ...(asString(raw.title).trim() ? { title: asString(raw.title).trim() } : {}), - ...(asString(raw.goalSlug).trim() - ? { goalSlug: asString(raw.goalSlug).trim() } - : {}), - facts: raw.facts.map(normalizeFact), - showAccepted: asBoolean(raw.showAccepted, false), - }; -} - -export function normalizeGoalSetupBundle( - value: unknown, - expectedStage?: GoalSetupStage -): GoalSetupBundle { - const raw = asRecord(value, "goal setup bundle"); - const stage = expectedStage ?? raw.stage; - if (stage === "interview") return normalizeInterviewBundle(raw); - if (stage === "facts") return normalizeFactsBundle(raw); - throw new Error("goal setup bundle stage must be interview or facts"); -} - -export function hasQuestionAnswer(answer: GoalSetupQuestionAnswer): boolean { - return ( - answer.selectedOptionIds.length > 0 || - answer.customAnswer.trim().length > 0 || - answer.answer.trim().length > 0 - ); -} - -export function createInterviewResult( - bundle: GoalSetupInterviewBundle, - answers: GoalSetupQuestionAnswer[] -): GoalSetupInterviewResult { - const byId = new Map(answers.map((answer) => [answer.questionId, answer])); - return { - stage: "interview", - title: bundle.title, - goalSlug: bundle.goalSlug, - answers: bundle.questions.map((question) => { - const answer = byId.get(question.id); - const normalized: GoalSetupQuestionAnswer = { - questionId: question.id, - selectedOptionIds: Array.isArray(answer?.selectedOptionIds) - ? answer!.selectedOptionIds - : [], - customAnswer: asString(answer?.customAnswer), - ...(asString(answer?.note).trim() - ? { note: asString(answer?.note).trim() } - : {}), - answer: asString(answer?.answer), - completed: asBoolean(answer?.completed, false), - }; - const completed = normalized.completed || hasQuestionAnswer(normalized); - const skipped = asBoolean(answer?.skipped, false) && !completed; - return { - ...normalized, - completed, - ...(skipped ? { skipped: true } : {}), - }; - }), - }; -} - -export function filterReviewableFacts(bundle: GoalSetupFactsBundle): GoalSetupFact[] { - if (bundle.showAccepted) return bundle.facts; - return bundle.facts.filter((fact) => !fact.accepted); -} - -export function createFactsResult( - bundle: GoalSetupFactsBundle, - facts: GoalSetupFactResult[] -): GoalSetupFactsResult { - const byId = new Map(facts.map((fact) => [fact.id, fact])); - const merged = bundle.facts.map((fact) => { - const next = byId.get(fact.id); - const removed = asBoolean(next?.removed, fact.removed); - const text = asString(next?.text, fact.text).trim(); - if (!removed && !text) { - throw new Error(`Fact "${fact.id}" text cannot be empty; edit it or remove the fact.`); - } - const comment = (next && Object.prototype.hasOwnProperty.call(next, "comment") - ? asString(next.comment) - : asString(fact.comment) - ).trim(); - return { - id: fact.id, - text: text || fact.text, - accepted: asBoolean(next?.accepted, fact.accepted), - removed, - ...(comment ? { comment } : {}), - automatedVerification: asBoolean( - next?.automatedVerification, - fact.automatedVerification - ), - recommendedAutomatedVerification: - next?.recommendedAutomatedVerification ?? - fact.recommendedAutomatedVerification, - }; - }); - - return { - stage: "facts", - title: bundle.title, - goalSlug: bundle.goalSlug, - facts: merged, - factsMarkdown: factsResultToMarkdown(merged), - }; -} - -export function factsResultToMarkdown(facts: GoalSetupFactResult[]): string { - const accepted = facts.filter((fact) => fact.accepted && !fact.removed); - if (accepted.length === 0) return "# Facts\n\nNo accepted facts."; - - const lines = ["# Facts", ""]; - for (const fact of accepted) { - lines.push(`- ${fact.text}`); - } - return lines.join("\n"); -} +export * from '@plannotator/core/goal-setup'; diff --git a/packages/shared/open-in-apps.ts b/packages/shared/open-in-apps.ts index 122af8feb..25d8ae092 100644 --- a/packages/shared/open-in-apps.ts +++ b/packages/shared/open-in-apps.ts @@ -1,189 +1 @@ -/** - * Open-in-App Catalog — single source of truth. - * - * Shared between the Bun/Pi servers (which launch the app) and the UI (which - * renders the picker). Runtime-agnostic: no Bun or Node-specific APIs, pure - * data + types only. - * - * Mirrors OpenCode's "Open in" app list. Each entry declares how to launch the - * app per platform: - * - mac.appName -> `open -a "" ` - * - win.bin -> ` ` (resolved against PATH) - * - linux.bin -> ` ` - * - * `kind` drives launch semantics: - * - file-manager -> reveal the file (mac: `open -R`, win: `explorer /select,`, - * linux: open the parent dir) - * - editor -> open the file itself - * - terminal -> open the file's parent directory - * - * One special id has no platform launch fields: - * - 'reveal' (kind file-manager) — uses the OS file manager - */ - -export type OpenInKind = 'file-manager' | 'editor' | 'terminal'; - -export interface OpenInApp { - /** Stable identifier persisted in the cookie + sent to the server. */ - id: string; - /** Human-readable label. For 'reveal' this is resolved per-platform. */ - label: string; - kind: OpenInKind; - /** Icon id understood by AppIcon. For 'reveal' this is resolved per-platform. */ - icon: string; - /** macOS application bundle/display name passed to `open -a`. */ - mac?: { appName: string }; - /** Windows PATH binary. */ - win?: { bin: string }; - /** Linux PATH binary. */ - linux?: { bin: string }; -} - -/** - * The catalog, in menu order. The UI groups by `kind` - * (file-manager + default first, then editors, then terminals). - */ -export const OPEN_IN_APPS: OpenInApp[] = [ - // ── File manager (always available) ──────────────────────────────────── - { - id: 'reveal', - label: 'Finder', // resolved per-platform, see resolveRevealLabel - kind: 'file-manager', - icon: 'finder', // resolved per-platform, see resolveRevealIcon - }, - - // ── Editors ──────────────────────────────────────────────────────────── - { - id: 'vscode', - label: 'VS Code', - kind: 'editor', - icon: 'vscode', - mac: { appName: 'Visual Studio Code' }, - win: { bin: 'code' }, - linux: { bin: 'code' }, - }, - { - id: 'cursor', - label: 'Cursor', - kind: 'editor', - icon: 'cursor', - mac: { appName: 'Cursor' }, - win: { bin: 'cursor' }, - linux: { bin: 'cursor' }, - }, - { - id: 'zed', - label: 'Zed', - kind: 'editor', - icon: 'zed', - mac: { appName: 'Zed' }, - win: { bin: 'zed' }, - linux: { bin: 'zed' }, - }, - { - id: 'sublime-text', - label: 'Sublime Text', - kind: 'editor', - icon: 'sublime-text', - mac: { appName: 'Sublime Text' }, - win: { bin: 'subl' }, - linux: { bin: 'subl' }, - }, - { - id: 'textmate', - label: 'TextMate', - kind: 'editor', - icon: 'textmate', - mac: { appName: 'TextMate' }, - }, - { - id: 'antigravity', - label: 'Antigravity', - kind: 'editor', - icon: 'antigravity', - mac: { appName: 'Antigravity' }, - }, - { - id: 'xcode', - label: 'Xcode', - kind: 'editor', - icon: 'xcode', - mac: { appName: 'Xcode' }, - }, - { - id: 'android-studio', - label: 'Android Studio', - kind: 'editor', - icon: 'android-studio', - mac: { appName: 'Android Studio' }, - }, - - // ── Terminals ────────────────────────────────────────────────────────── - { - id: 'terminal', - label: 'Terminal', - kind: 'terminal', - icon: 'terminal', - mac: { appName: 'Terminal' }, - }, - { - id: 'iterm2', - label: 'iTerm2', - kind: 'terminal', - icon: 'iterm2', - mac: { appName: 'iTerm' }, // bundle name is "iTerm", not "iTerm2" - }, - { - id: 'ghostty', - label: 'Ghostty', - kind: 'terminal', - icon: 'ghostty', - mac: { appName: 'Ghostty' }, - }, - { - id: 'warp', - label: 'Warp', - kind: 'terminal', - icon: 'warp', - mac: { appName: 'Warp' }, - }, - { - id: 'powershell', - label: 'PowerShell', - kind: 'terminal', - icon: 'powershell', - win: { bin: 'powershell' }, - }, -]; - -export type OpenInPlatform = 'mac' | 'win' | 'linux'; - -/** - * Per-platform label for the 'reveal' (file-manager) entry. - */ -export function resolveRevealLabel(platform: OpenInPlatform): string { - switch (platform) { - case 'win': - return 'Explorer'; - case 'linux': - return 'Files'; - case 'mac': - default: - return 'Finder'; - } -} - -/** - * Per-platform icon for the 'reveal' (file-manager) entry: - * finder on mac/linux, file-explorer on win. - */ -export function resolveRevealIcon(platform: OpenInPlatform): string { - return platform === 'win' ? 'file-explorer' : 'finder'; -} - -/** - * Look up a catalog entry by id. - */ -export function getOpenInApp(id: string): OpenInApp | undefined { - return OPEN_IN_APPS.find((app) => app.id === id); -} +export * from '@plannotator/core/open-in-apps'; diff --git a/packages/shared/package.json b/packages/shared/package.json index 76aaefb10..4501cb074 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -59,6 +59,7 @@ "./review-profiles": "./review-profiles.ts" }, "dependencies": { + "@plannotator/core": "workspace:*", "@joplin/turndown-plugin-gfm": "^1.0.64", "parse5": "^7.3.0", "turndown": "^7.2.4" diff --git a/packages/shared/project.ts b/packages/shared/project.ts index 23c130e81..ff1bbb489 100644 --- a/packages/shared/project.ts +++ b/packages/shared/project.ts @@ -1,71 +1 @@ -/** - * Project Utility — Pure Functions - * - * String sanitization and path extraction helpers. - * Runtime-agnostic: no Bun or Node-specific APIs. - */ - -/** - * Sanitize a string for use as a tag - * - lowercase - * - replace spaces/underscores with hyphens - * - remove special characters - * - trim to reasonable length - */ -export function sanitizeTag(name: string): string | null { - if (!name || typeof name !== "string") return null; - - const sanitized = name - .toLowerCase() - .trim() - .replace(/[\s_]+/g, "-") // spaces/underscores -> hyphens - .replace(/[^a-z0-9-]/g, "") // remove special chars - .replace(/-+/g, "-") // collapse multiple hyphens - .replace(/^-|-$/g, "") // trim leading/trailing hyphens - .slice(0, 30); // max 30 chars - - return sanitized.length >= 2 ? sanitized : null; -} - -/** - * Extract repo name from a git root path - */ -export function extractRepoName(gitRootPath: string): string | null { - if (!gitRootPath || typeof gitRootPath !== "string") return null; - - const trimmed = gitRootPath.trim().replace(/\/+$/, ""); // remove trailing slashes - const parts = trimmed.split("/"); - const name = parts[parts.length - 1]; - - return sanitizeTag(name); -} - -/** - * Extract directory name from a path - */ -export function extractDirName(path: string): string | null { - if (!path || typeof path !== "string") return null; - - const trimmed = path.trim().replace(/\/+$/, ""); - if (trimmed === "" || trimmed === "/") return null; - - const parts = trimmed.split("/"); - const name = parts[parts.length - 1]; - - // Skip generic names - const skipNames = new Set(["home", "users", "user", "root", "tmp", "var"]); - if (skipNames.has(name.toLowerCase())) return null; - - return sanitizeTag(name); -} - -/** - * Extract hostname from a URL string, or return the original string on failure. - */ -export function hostnameOrFallback(url: string): string { - try { - return new URL(url).hostname; - } catch { - return url; - } -} +export * from '@plannotator/core/project'; diff --git a/packages/shared/source-save.ts b/packages/shared/source-save.ts index 434f72183..d36a116c3 100644 --- a/packages/shared/source-save.ts +++ b/packages/shared/source-save.ts @@ -1,138 +1 @@ -export type SourceSaveLanguage = "markdown" | "mdx" | "text"; - -export type SourceSaveDisabledReason = - | "not-annotate-mode" - | "not-local-file" - | "unsupported-extension" - | "converted-source" - | "html-render" - | "folder-mode" - | "message-mode" - | "shared-session" - | "missing-file" - | "unreadable-file"; - -export type SourceSaveScope = "single-file" | "folder-file"; - -export type SourceFileEol = "lf" | "crlf" | "mixed" | "none"; - -export interface SourceFileSnapshot { - text: string; - hash: string; - mtimeMs: number; - size: number; - eol: SourceFileEol; -} - -export type SourceSaveCapability = - | { - enabled: true; - kind: "local-text-file"; - scope: SourceSaveScope; - path: string; - basename: string; - language: SourceSaveLanguage; - hash: string; - mtimeMs: number; - size: number; - eol: SourceFileEol; - } - | { - enabled: false; - reason: SourceSaveDisabledReason; - }; - -export interface SourceSaveRequest { - path?: string; - text: string; - baseHash: string; - baseMtimeMs?: number; - baseEol?: SourceFileEol; - allowMissingBase?: boolean; -} - -export type SourceSaveResponse = - | { - ok: true; - hash: string; - mtimeMs: number; - size: number; - eol: SourceFileEol; - } - | { - ok: false; - code: "conflict"; - message: string; - currentText: string; - currentHash: string; - currentMtimeMs: number; - currentSize: number; - currentEol: SourceFileEol; - } - | { - ok: false; - code: "not-writable" | "write-failed" | "invalid-request"; - message: string; - }; - -export type SourceSaveConflictResponse = Extract; - -export function isSourceFileEol(value: unknown): value is SourceFileEol { - return value === "lf" || value === "crlf" || value === "mixed" || value === "none"; -} - -export function hasSourceSaveConflictSnapshot(response: SourceSaveResponse): response is SourceSaveConflictResponse { - if (!("code" in response) || response.code !== "conflict") return false; - const conflict = response as SourceSaveConflictResponse; - return ( - typeof conflict.currentText === "string" && - typeof conflict.currentHash === "string" && - typeof conflict.currentMtimeMs === "number" && - typeof conflict.currentSize === "number" && - isSourceFileEol(conflict.currentEol) - ); -} - -export const SOURCE_SAVE_FILE_REGEX = /\.(md|mdx|txt)$/i; - -export function isSourceSaveFilePath(filePath: string): boolean { - return SOURCE_SAVE_FILE_REGEX.test(filePath); -} - -export function getSourceSaveLanguage(filePath: string): SourceSaveLanguage | null { - const lower = filePath.toLowerCase(); - if (lower.endsWith(".mdx")) return "mdx"; - if (lower.endsWith(".md")) return "markdown"; - if (lower.endsWith(".txt")) return "text"; - return null; -} - -export function basenameFromPath(filePath: string): string { - const normalized = filePath.replace(/\\/g, "/"); - return normalized.split("/").pop() || filePath; -} - -export function disabledSourceSave(reason: SourceSaveDisabledReason): SourceSaveCapability { - return { enabled: false, reason }; -} - -export function enabledSourceSave( - scope: SourceSaveScope, - filePath: string, - snapshot: SourceFileSnapshot, -): SourceSaveCapability { - const language = getSourceSaveLanguage(filePath); - if (!language) return disabledSourceSave("unsupported-extension"); - return { - enabled: true, - kind: "local-text-file", - scope, - path: filePath, - basename: basenameFromPath(filePath), - language, - hash: snapshot.hash, - mtimeMs: snapshot.mtimeMs, - size: snapshot.size, - eol: snapshot.eol, - }; -} +export * from '@plannotator/core/source-save'; diff --git a/packages/shared/storage.ts b/packages/shared/storage.ts index df8c958ae..431198d93 100644 --- a/packages/shared/storage.ts +++ b/packages/shared/storage.ts @@ -104,14 +104,8 @@ export function saveFinalSnapshot( // --- Plan Archive --- -export interface ArchivedPlan { - filename: string; - title: string; - date: string; - timestamp: string; // ISO string from file mtime - status: "approved" | "denied" | "unknown"; - size: number; -} +import type { ArchivedPlan } from '@plannotator/core/storage-types'; +export type { ArchivedPlan }; /** * Parse an archive filename into metadata. diff --git a/packages/shared/types.ts b/packages/shared/types.ts index dcb76c38f..eb20c5d65 100644 --- a/packages/shared/types.ts +++ b/packages/shared/types.ts @@ -1,13 +1,4 @@ -// Editor annotations from VS Code extension (ephemeral, in-memory only) -export interface EditorAnnotation { - id: string; - filePath: string; // workspace-relative (e.g., "src/auth.ts") - selectedText: string; - lineStart: number; // 1-based - lineEnd: number; // 1-based - comment?: string; - createdAt: number; -} +export type { EditorAnnotation } from '@plannotator/core/types'; // Git review types shared between server and client export type { diff --git a/packages/shared/workspace-status.ts b/packages/shared/workspace-status.ts index 6d5c9ce62..5dbc2ab4a 100644 --- a/packages/shared/workspace-status.ts +++ b/packages/shared/workspace-status.ts @@ -3,45 +3,8 @@ import { existsSync, realpathSync } from "node:fs"; import { readFile, realpath, stat } from "node:fs/promises"; import { isAbsolute, relative, resolve } from "node:path"; -export type WorkspaceFileStatus = - | "modified" - | "added" - | "deleted" - | "renamed" - | "copied" - | "typechange" - | "conflicted" - | "untracked"; - -export interface WorkspaceFileChange { - path: string; - repoRelativePath: string; - oldPath?: string; - status: WorkspaceFileStatus; - additions: number; - deletions: number; - staged: boolean; - unstaged: boolean; -} - -export interface WorkspaceStatusPayload { - available: boolean; - rootPath: string; - repoRoot?: string; - files: Record; - totals: { - files: number; - additions: number; - deletions: number; - }; - error?: string; -} - -export interface GitRepositoryInfo { - repoRoot: string; - gitDir: string; - gitCommonDir: string; -} +import type { WorkspaceFileChange, WorkspaceStatusPayload, GitRepositoryInfo, WorkspaceFileStatus } from '@plannotator/core/workspace-status-types'; +export type { WorkspaceFileChange, WorkspaceStatusPayload, GitRepositoryInfo, WorkspaceFileStatus }; const TEXT_FILE_MAX_BYTES = 2 * 1024 * 1024; const GIT_MAX_BUFFER = 20 * 1024 * 1024; From 632339203a754b311b37467087f895683395c23f Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 15:31:03 -0700 Subject: [PATCH 32/46] =?UTF-8?q?feat(ui):=20depend=20only=20on=20@plannot?= =?UTF-8?q?ator/core=20=E2=80=94=20re-point=20all=20shared/ai=20imports=20?= =?UTF-8?q?(Phase=207=20step=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bun.lock | 3 +-- packages/ui/components/AISettingsTab.tsx | 2 +- packages/ui/components/AgentsTab.tsx | 2 +- packages/ui/components/DiffTypeSetupDialog.tsx | 2 +- packages/ui/components/DocBadges.tsx | 2 +- packages/ui/components/InlineMarkdown.tsx | 2 +- packages/ui/components/MenuVersionSection.tsx | 2 +- packages/ui/components/OpenInAppButton.tsx | 2 +- packages/ui/components/PlanAIAnnouncementDialog.tsx | 4 ++-- packages/ui/components/PlanHeaderMenu.tsx | 2 +- packages/ui/components/Settings.tsx | 4 ++-- packages/ui/components/blocks/HtmlBlock.tsx | 2 +- packages/ui/components/goal-setup/GoalSetupSurface.tsx | 2 +- packages/ui/components/settings/HooksTab.tsx | 2 +- packages/ui/components/sidebar/ArchiveBrowser.tsx | 2 +- packages/ui/components/sidebar/FileBrowser.tsx | 4 ++-- packages/ui/config/settings.ts | 2 +- packages/ui/hooks/pfm/useCodeFilePopout.ts | 2 +- packages/ui/hooks/useAIChat.ts | 2 +- packages/ui/hooks/useAgents.ts | 2 +- packages/ui/hooks/useAnnotationDraft.ts | 2 +- packages/ui/hooks/useArchive.ts | 2 +- packages/ui/hooks/useFileBrowser.ts | 2 +- packages/ui/hooks/useLinkedDoc.ts | 2 +- packages/ui/hooks/useValidatedCodePaths.ts | 2 +- packages/ui/package.json | 3 +-- packages/ui/tsconfig.json | 4 +++- packages/ui/types.ts | 6 +++--- packages/ui/utils/aiProvider.ts | 2 +- packages/ui/utils/annotateAgentTerminal.ts | 2 +- packages/ui/utils/parser.ts | 2 +- packages/ui/utils/sharing.ts | 4 ++-- 32 files changed, 40 insertions(+), 40 deletions(-) diff --git a/bun.lock b/bun.lock index 2b7a1fa94..bedcf561c 100644 --- a/bun.lock +++ b/bun.lock @@ -272,9 +272,8 @@ "@lezer/common": "^1.5.2", "@lezer/highlight": "^1.2.3", "@pierre/diffs": "1.2.8", - "@plannotator/ai": "workspace:*", + "@plannotator/core": "workspace:*", "@plannotator/markdown-editor": "0.1.0", - "@plannotator/shared": "workspace:*", "@plannotator/web-highlighter": "^0.8.1", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/ui/components/AISettingsTab.tsx b/packages/ui/components/AISettingsTab.tsx index 6bd96be98..5010471a5 100644 --- a/packages/ui/components/AISettingsTab.tsx +++ b/packages/ui/components/AISettingsTab.tsx @@ -9,7 +9,7 @@ import { type AIProviderOption, } from '../utils/aiProvider'; import { useState } from 'react'; -import type { Origin } from '@plannotator/shared/agents'; +import type { Origin } from '@plannotator/core/agents'; interface AIProvider extends AIProviderOption { capabilities: Record; diff --git a/packages/ui/components/AgentsTab.tsx b/packages/ui/components/AgentsTab.tsx index 37d5729bd..6035d5528 100644 --- a/packages/ui/components/AgentsTab.tsx +++ b/packages/ui/components/AgentsTab.tsx @@ -15,7 +15,7 @@ import { Search, } from 'lucide-react'; import type { AgentJobInfo, AgentCapabilities } from '../types'; -import { isTerminalStatus } from '@plannotator/shared/agent-jobs'; +import { isTerminalStatus } from '@plannotator/core/agent-jobs'; import { cn } from '../lib/utils'; import { ReviewAgentsIcon } from './ReviewAgentsIcon'; import { ClaudeIcon, CodexIcon } from './icons/AgentIcons'; diff --git a/packages/ui/components/DiffTypeSetupDialog.tsx b/packages/ui/components/DiffTypeSetupDialog.tsx index d6669eed6..c93d22b80 100644 --- a/packages/ui/components/DiffTypeSetupDialog.tsx +++ b/packages/ui/components/DiffTypeSetupDialog.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { createPortal } from 'react-dom'; -import type { DefaultDiffType } from '@plannotator/shared/config'; +import type { DefaultDiffType } from '@plannotator/core/config-types'; import { markDiffTypeSetupDone } from '../utils/diffTypeSetup'; import { configStore } from '../config'; import diffOptionsImg from '../assets/diff-options.png'; diff --git a/packages/ui/components/DocBadges.tsx b/packages/ui/components/DocBadges.tsx index cc7ecb1e6..2ac075b2e 100644 --- a/packages/ui/components/DocBadges.tsx +++ b/packages/ui/components/DocBadges.tsx @@ -13,7 +13,7 @@ import React from 'react'; import { PlanDiffBadge } from './plan-diff/PlanDiffBadge'; import type { PlanDiffStats } from '../utils/planDiffEngine'; -import { hostnameOrFallback } from '@plannotator/shared/project'; +import { hostnameOrFallback } from '@plannotator/core/project'; import { OpenInAppButton } from './OpenInAppButton'; export interface LinkedDocBadgeInfo { diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx index f25683789..9d4b63cb4 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"; diff --git a/packages/ui/components/MenuVersionSection.tsx b/packages/ui/components/MenuVersionSection.tsx index 9b31f9be3..2caa5ff60 100644 --- a/packages/ui/components/MenuVersionSection.tsx +++ b/packages/ui/components/MenuVersionSection.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { TextShimmer } from './TextShimmer'; import type { UpdateInfo } from '../hooks/useUpdateCheck'; -import type { Origin } from '@plannotator/shared/agents'; +import type { Origin } from '@plannotator/core/agents'; import { isWindows } from '../utils/platform'; const PI_INSTALL_COMMAND = 'pi install npm:@plannotator/pi-extension'; diff --git a/packages/ui/components/OpenInAppButton.tsx b/packages/ui/components/OpenInAppButton.tsx index 88c9d8d8b..4690e8708 100644 --- a/packages/ui/components/OpenInAppButton.tsx +++ b/packages/ui/components/OpenInAppButton.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { ChevronDown, Check, Copy, MoreHorizontal } from 'lucide-react'; import { AppIcon } from './icons/AppIcon'; import { getLastOpenInApp, setLastOpenInApp } from '../utils/storage'; -import type { OpenInKind } from '@plannotator/shared/open-in-apps'; +import type { OpenInKind } from '@plannotator/core/open-in-apps'; import { DropdownMenu, DropdownMenuTrigger, diff --git a/packages/ui/components/PlanAIAnnouncementDialog.tsx b/packages/ui/components/PlanAIAnnouncementDialog.tsx index f27a8dcbd..8dc599c46 100644 --- a/packages/ui/components/PlanAIAnnouncementDialog.tsx +++ b/packages/ui/components/PlanAIAnnouncementDialog.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { createPortal } from 'react-dom'; -import type { Origin } from '@plannotator/shared/agents'; -import { AGENT_CONFIG, getAgentAIProviderTypes, getAgentName } from '@plannotator/shared/agents'; +import type { Origin } from '@plannotator/core/agents'; +import { AGENT_CONFIG, getAgentAIProviderTypes, getAgentName } from '@plannotator/core/agents'; import { SparklesIcon } from './SparklesIcon'; import { getProviderMeta } from './ProviderIcons'; diff --git a/packages/ui/components/PlanHeaderMenu.tsx b/packages/ui/components/PlanHeaderMenu.tsx index d5243b36e..2855ecfde 100644 --- a/packages/ui/components/PlanHeaderMenu.tsx +++ b/packages/ui/components/PlanHeaderMenu.tsx @@ -11,7 +11,7 @@ import { ReviewAgentsIcon } from './ReviewAgentsIcon'; import { MenuVersionSection } from './MenuVersionSection'; import { TextShimmer } from './TextShimmer'; import type { UpdateInfo } from '../hooks/useUpdateCheck'; -import type { Origin } from '@plannotator/shared/agents'; +import type { Origin } from '@plannotator/core/agents'; interface PlanHeaderMenuProps { appVersion: string; diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index 810e9b02e..78347ec6d 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useMemo } from 'react'; import { createPortal } from 'react-dom'; -import type { Origin } from '@plannotator/shared/agents'; -import type { DiffLineBgIntensity } from '@plannotator/shared/config'; +import type { Origin } from '@plannotator/core/agents'; +import type { DiffLineBgIntensity } from '@plannotator/core/config-types'; import { configStore, useConfigValue } from '../config'; import { loadDiffFont } from '../utils/diffFonts'; import { TaterSpritePullup } from './TaterSpritePullup'; 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/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.tsx b/packages/ui/components/sidebar/FileBrowser.tsx index 192cbfd1f..9b432c8f4 100644 --- a/packages/ui/components/sidebar/FileBrowser.tsx +++ b/packages/ui/components/sidebar/FileBrowser.tsx @@ -10,8 +10,8 @@ import type { VaultNode } from "../../types"; import type { DirState } from "../../hooks/useFileBrowser"; import { CountBadge } from "./CountBadge"; import { ObsidianIconRaw } from "../icons/ObsidianIcons"; -import type { WorkspaceFileChange, WorkspaceStatusPayload } from "@plannotator/shared/workspace-status"; -import { normalizeBrowserPath } from "@plannotator/shared/browser-paths"; +import type { WorkspaceFileChange, WorkspaceStatusPayload } from "@plannotator/core/workspace-status-types"; +import { normalizeBrowserPath } from "@plannotator/core/browser-paths"; interface FileBrowserProps { dirs: DirState[]; diff --git a/packages/ui/config/settings.ts b/packages/ui/config/settings.ts index 2251f86ee..2c951a9b8 100644 --- a/packages/ui/config/settings.ts +++ b/packages/ui/config/settings.ts @@ -9,7 +9,7 @@ * Add new settings here. Cookie-only settings omit serverKey. */ -import type { DiffLineBgIntensity } from '@plannotator/shared/config'; +import type { DiffLineBgIntensity } from '@plannotator/core/config-types'; import { storage } from '../utils/storage'; import { generateIdentity } from '../utils/generateIdentity'; diff --git a/packages/ui/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.ts b/packages/ui/hooks/useAIChat.ts index d6c2ad893..50017e872 100644 --- a/packages/ui/hooks/useAIChat.ts +++ b/packages/ui/hooks/useAIChat.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import type { AIContext } from '@plannotator/ai'; +import type { AIContext } from '@plannotator/core'; import type { AIQuestion, AIResponse } from '../types'; import { generateId } from '../utils/generateId'; 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.ts b/packages/ui/hooks/useAnnotationDraft.ts index 8e8bcdbc4..c563f6785 100644 --- a/packages/ui/hooks/useAnnotationDraft.ts +++ b/packages/ui/hooks/useAnnotationDraft.ts @@ -15,7 +15,7 @@ */ 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'; 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/useFileBrowser.ts b/packages/ui/hooks/useFileBrowser.ts index 5ce29c7ee..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; 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/useValidatedCodePaths.ts b/packages/ui/hooks/useValidatedCodePaths.ts index 00b6462a2..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 } diff --git a/packages/ui/package.json b/packages/ui/package.json index 4d2388621..b49beda74 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -60,9 +60,8 @@ "@pierre/diffs": "1.2.8", "@lezer/common": "^1.5.2", "@lezer/highlight": "^1.2.3", - "@plannotator/ai": "workspace:*", + "@plannotator/core": "workspace:*", "@plannotator/markdown-editor": "0.1.0", - "@plannotator/shared": "workspace:*", "@plannotator/web-highlighter": "^0.8.1", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 2076dec37..63df81fa6 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -18,7 +18,9 @@ "esModuleInterop": true, "types": ["bun"], "paths": { - "@plannotator/shared/*": ["../shared/*"] + "@plannotator/shared/*": ["../shared/*"], + "@plannotator/core": ["../core/index.ts"], + "@plannotator/core/*": ["../core/*"] } }, "include": [ diff --git a/packages/ui/types.ts b/packages/ui/types.ts index 8f8f1c269..34483ed56 100644 --- a/packages/ui/types.ts +++ b/packages/ui/types.ts @@ -210,11 +210,11 @@ export interface VaultNode { children?: VaultNode[]; } -export type { EditorAnnotation } from '@plannotator/shared/types'; +export type { EditorAnnotation } from '@plannotator/core/types'; export type { ExternalAnnotationEvent, -} from '@plannotator/shared/external-annotation'; +} from '@plannotator/core/external-annotation'; export type { AgentJobInfo, @@ -222,4 +222,4 @@ export type { AgentJobStatus, AgentCapability, AgentCapabilities, -} from '@plannotator/shared/agent-jobs'; +} from '@plannotator/core/agent-jobs'; diff --git a/packages/ui/utils/aiProvider.ts b/packages/ui/utils/aiProvider.ts index 7d707adf5..83e8edfcd 100644 --- a/packages/ui/utils/aiProvider.ts +++ b/packages/ui/utils/aiProvider.ts @@ -7,7 +7,7 @@ */ import { storage } from './storage'; -import { AGENT_CONFIG, getAgentAIProviderTypes, type Origin } from '@plannotator/shared/agents'; +import { AGENT_CONFIG, getAgentAIProviderTypes, type Origin } from '@plannotator/core/agents'; const PROVIDER_KEY = 'plannotator-ai-provider'; const MODELS_KEY = 'plannotator-ai-models'; diff --git a/packages/ui/utils/annotateAgentTerminal.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/parser.ts b/packages/ui/utils/parser.ts index c8060707a..6f579bfc1 100644 --- a/packages/ui/utils/parser.ts +++ b/packages/ui/utils/parser.ts @@ -1,5 +1,5 @@ import { Block, type Annotation, type CodeAnnotation, type EditorAnnotation, type ImageAttachment } from '../types'; -import { planDenyFeedback } from '@plannotator/shared/feedback-templates'; +import { planDenyFeedback } from '@plannotator/core/feedback-templates'; /** * Parsed YAML frontmatter as key-value pairs. diff --git a/packages/ui/utils/sharing.ts b/packages/ui/utils/sharing.ts index 8b473452d..12317bc0a 100644 --- a/packages/ui/utils/sharing.ts +++ b/packages/ui/utils/sharing.ts @@ -9,8 +9,8 @@ */ import { Annotation, AnnotationType, type ImageAttachment } from '../types'; -import { compress, decompress } from '@plannotator/shared/compress'; -import { encrypt, decrypt } from '@plannotator/shared/crypto'; +import { compress, decompress } from '@plannotator/core/compress'; +import { encrypt, decrypt } from '@plannotator/core/crypto'; // Image in shareable format: plain string (old) or [path, name] tuple (new) type ShareableImage = string | [string, string]; From d5760b3fb59ef0a55584a97e7846db9250bbcd35 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 15:32:12 -0700 Subject: [PATCH 33/46] refactor(ui): relocate wideMode helper to @plannotator/ui/utils (Phase 7 step 3) --- packages/editor/App.tsx | 2 +- packages/{editor => ui/utils}/wideMode.test.ts | 0 packages/{editor => ui/utils}/wideMode.ts | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename packages/{editor => ui/utils}/wideMode.test.ts (100%) rename packages/{editor => ui/utils}/wideMode.ts (100%) diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 67c72cfc9..84571fb30 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -106,7 +106,7 @@ import type { AgentTerminalCapability } from '@plannotator/shared/agent-terminal // same env var on the server side so V2/V3 stay paired. import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; -import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; +import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from '@plannotator/ui/utils/wideMode'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; 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 From 5c422d88e2055f398658a7edd6244b1b07ce2e3d Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 15:37:05 -0700 Subject: [PATCH 34/46] feat(ui): add loadFromBackend settings rehydration + configurePlannotatorUI front door (Phase 7 step 4) --- packages/ui/config/configStore.ts | 19 +++++ packages/ui/configure.test.ts | 130 ++++++++++++++++++++++++++++++ packages/ui/configure.ts | 46 +++++++++++ packages/ui/package.json | 2 + 4 files changed, 197 insertions(+) create mode 100644 packages/ui/configure.test.ts create mode 100644 packages/ui/configure.ts diff --git a/packages/ui/config/configStore.ts b/packages/ui/config/configStore.ts index 0c89e9ef4..fa49d983d 100644 --- a/packages/ui/config/configStore.ts +++ b/packages/ui/config/configStore.ts @@ -71,6 +71,23 @@ 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). + */ + loadFromBackend(): void { + for (const [name, def] of Object.entries(SETTINGS)) { + const fromBackend = def.fromCookie(); + if (fromBackend !== undefined) { + this.values.set(name, fromBackend); + } + } + this.notify(); + } + /** * Apply server config overrides. * Call once after fetching /api/plan or /api/diff. @@ -123,6 +140,8 @@ class ConfigStore { this.serverSync = fn; } + resetServerSync(): void { this.serverSync = defaultServerSync; } + private notify(): void { this.version++; for (const fn of this.listeners) fn(); diff --git a/packages/ui/configure.test.ts b/packages/ui/configure.test.ts new file mode 100644 index 000000000..625dc6be1 --- /dev/null +++ b/packages/ui/configure.test.ts @@ -0,0 +1,130 @@ +import { afterAll, describe, expect, it, mock, spyOn } from 'bun:test'; + +import * as ImageThumbnail from './components/ImageThumbnail'; +import * as InlineMarkdown from './components/InlineMarkdown'; +import * as storage from './utils/storage'; +import * as identity from './utils/identity'; +import * as useFileBrowser from './hooks/useFileBrowser'; +import * as useAnnotationDraft from './hooks/useAnnotationDraft'; +import * as useExternalAnnotations from './hooks/useExternalAnnotations'; +import * as useAIChat from './hooks/useAIChat'; +import { configStore } from './config'; + +import type { ImageSrcResolver } from './components/ImageThumbnail'; +import type { DocPreviewFetcher } from './components/InlineMarkdown'; +import type { StorageBackend } from './utils/storage'; +import type { IdentityProvider } from './utils/identity'; +import type { FileTreeBackend } from './hooks/useFileBrowser'; +import type { DraftTransport } from './hooks/useAnnotationDraft'; +import type { ExternalAnnotationTransport } from './hooks/useExternalAnnotations'; +import type { AITransport } from './hooks/useAIChat'; + +// Spy on each setter. We re-export the REAL module verbatim and override ONLY the +// setter with a spy, so other test files importing other exports stay intact +// (Bun's mock.module replacement is process-global — dropping exports would break +// sibling suites). The spies route to no-ops; we assert configure wired them. +const setImageSrcResolver = mock((_: ImageSrcResolver) => {}); +const setDocPreviewFetcher = mock((_: DocPreviewFetcher) => {}); +const setStorageBackend = mock((_: StorageBackend) => {}); +const setIdentityProvider = mock((_: IdentityProvider) => {}); +const setFileTreeBackend = mock((_: FileTreeBackend) => {}); +const setDraftTransport = mock((_: DraftTransport) => {}); +const setExternalAnnotationTransport = mock((_: ExternalAnnotationTransport<{ id: string; source?: string }>) => {}); +const setAITransport = mock((_: AITransport) => {}); + +mock.module('./components/ImageThumbnail', () => ({ ...ImageThumbnail, setImageSrcResolver })); +mock.module('./components/InlineMarkdown', () => ({ ...InlineMarkdown, setDocPreviewFetcher })); +mock.module('./utils/storage', () => ({ ...storage, setStorageBackend })); +mock.module('./utils/identity', () => ({ ...identity, setIdentityProvider })); +mock.module('./hooks/useFileBrowser', () => ({ ...useFileBrowser, setFileTreeBackend })); +mock.module('./hooks/useAnnotationDraft', () => ({ ...useAnnotationDraft, setDraftTransport })); +mock.module('./hooks/useExternalAnnotations', () => ({ ...useExternalAnnotations, setExternalAnnotationTransport })); +mock.module('./hooks/useAIChat', () => ({ ...useAIChat, setAITransport })); + +// configStore is shared with sibling suites — spy on the real instance methods +// (restored in afterAll) instead of replacing the ./config module. +const setServerSync = spyOn(configStore, 'setServerSync'); +const loadFromBackend = spyOn(configStore, 'loadFromBackend').mockImplementation(() => {}); + +const { configurePlannotatorUI } = await import('./configure'); + +// Shape-correct fakes (only need to satisfy the front door's optional fields). +const imageSrcResolver: ImageSrcResolver = (path) => path; +const docPreviewFetcher: DocPreviewFetcher = async () => null; +const storageBackend: StorageBackend = { getItem: () => null, setItem: () => {}, removeItem: () => {} }; +const identityProvider: IdentityProvider = { getIdentity: () => 'tater', isCurrentUser: () => false }; +const fileTreeBackend: FileTreeBackend = { + loadTree: async () => new Response('{}'), + loadVaultTree: async () => new Response('{}'), + watchTrees: () => undefined, +}; +const draftTransport: DraftTransport = { + load: async () => ({ data: null, generation: null }), + save: async () => {}, + remove: async () => {}, +}; +const externalAnnotationTransport: ExternalAnnotationTransport<{ id: string; source?: string }> = { + subscribe: () => () => {}, + getSnapshot: async () => null, + add: async () => {}, + remove: async () => {}, + update: async () => {}, + clear: async () => {}, +}; +const aiTransport: AITransport = { + session: async () => new Response(), + query: async () => new Response(), + abort: () => {}, + permission: () => {}, +}; +const serverSync = (_payload: Record) => {}; + +afterAll(() => mock.restore()); + +describe('configurePlannotatorUI routing', () => { + it('routes each provided seam to its underlying setter', () => { + configurePlannotatorUI({ + imageSrcResolver, + storageBackend, + docPreviewFetcher, + fileTreeBackend, + identityProvider, + draftTransport, + externalAnnotationTransport, + aiTransport, + serverSync, + loadSettingsFromBackend: true, + }); + + expect(setImageSrcResolver).toHaveBeenCalledWith(imageSrcResolver); + expect(setDocPreviewFetcher).toHaveBeenCalledWith(docPreviewFetcher); + expect(setStorageBackend).toHaveBeenCalledWith(storageBackend); + expect(setIdentityProvider).toHaveBeenCalledWith(identityProvider); + expect(setFileTreeBackend).toHaveBeenCalledWith(fileTreeBackend); + expect(setDraftTransport).toHaveBeenCalledWith(draftTransport); + expect(setExternalAnnotationTransport).toHaveBeenCalledWith(externalAnnotationTransport); + expect(setAITransport).toHaveBeenCalledWith(aiTransport); + expect(setServerSync).toHaveBeenCalledWith(serverSync); + expect(loadFromBackend).toHaveBeenCalledTimes(1); + + // load-bearing order: storageBackend installed before loadFromBackend re-hydrates. + expect(setStorageBackend.mock.invocationCallOrder[0]).toBeLessThan( + loadFromBackend.mock.invocationCallOrder[0], + ); + }); + + it('skips setters for omitted fields', () => { + [ + setImageSrcResolver, setDocPreviewFetcher, setStorageBackend, setIdentityProvider, + setFileTreeBackend, setDraftTransport, setExternalAnnotationTransport, setAITransport, + setServerSync, loadFromBackend, + ].forEach((m) => m.mockClear()); + + configurePlannotatorUI({ storageBackend }); + + expect(setStorageBackend).toHaveBeenCalledTimes(1); + expect(setImageSrcResolver).not.toHaveBeenCalled(); + expect(setAITransport).not.toHaveBeenCalled(); + expect(loadFromBackend).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/configure.ts b/packages/ui/configure.ts new file mode 100644 index 000000000..4d4ad3c1a --- /dev/null +++ b/packages/ui/configure.ts @@ -0,0 +1,46 @@ +import { setImageSrcResolver, type ImageSrcResolver } from './components/ImageThumbnail'; +import { setDocPreviewFetcher, type DocPreviewFetcher } from './components/InlineMarkdown'; +import { setStorageBackend, type StorageBackend } from './utils/storage'; +import { setIdentityProvider, type IdentityProvider } from './utils/identity'; +import { setFileTreeBackend, type FileTreeBackend } from './hooks/useFileBrowser'; +import { setDraftTransport, type DraftTransport } from './hooks/useAnnotationDraft'; +import { setExternalAnnotationTransport, type ExternalAnnotationTransport } from './hooks/useExternalAnnotations'; +import { setAITransport, type AITransport } from './hooks/useAIChat'; +import { configStore } from './config'; + +type ExternalAnnotationBase = { id: string; source?: string }; +type ServerSyncFn = (payload: Record) => void; + +export interface PlannotatorUIConfig { + imageSrcResolver?: ImageSrcResolver; + storageBackend?: StorageBackend; + docPreviewFetcher?: DocPreviewFetcher; + fileTreeBackend?: FileTreeBackend; + identityProvider?: IdentityProvider; + draftTransport?: DraftTransport; + /** + * Base-constraint transport. If your annotation type extends the base + * constraint ({ id: string; source?: string }) with extra fields, call + * setExternalAnnotationTransport() directly for full type safety — + * this front-door field intentionally pins the base constraint for ergonomics. + */ + externalAnnotationTransport?: ExternalAnnotationTransport; + aiTransport?: AITransport; + serverSync?: ServerSyncFn; + /** Re-hydrate settings from the installed (SYNCHRONOUS) storageBackend after install. */ + loadSettingsFromBackend?: boolean; +} + +export function configurePlannotatorUI(config: PlannotatorUIConfig): void { + if (config.imageSrcResolver) setImageSrcResolver(config.imageSrcResolver); + if (config.storageBackend) setStorageBackend(config.storageBackend); + if (config.docPreviewFetcher) setDocPreviewFetcher(config.docPreviewFetcher); + if (config.fileTreeBackend) setFileTreeBackend(config.fileTreeBackend); + if (config.identityProvider) setIdentityProvider(config.identityProvider); + if (config.draftTransport) setDraftTransport(config.draftTransport); + if (config.externalAnnotationTransport) setExternalAnnotationTransport(config.externalAnnotationTransport); + if (config.aiTransport) setAITransport(config.aiTransport); + if (config.serverSync) configStore.setServerSync(config.serverSync); + // Re-hydrate AFTER storageBackend is installed (load-bearing order — gated last). + if (config.loadSettingsFromBackend) configStore.loadFromBackend(); +} diff --git a/packages/ui/package.json b/packages/ui/package.json index b49beda74..a278ea5e0 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -16,6 +16,7 @@ "./hooks/*": "./hooks/*.ts", "./shortcuts": "./shortcuts/index.ts", "./config": "./config/index.ts", + "./configure": "./configure.ts", "./types": "./types.ts", "./theme": "./theme.css" }, @@ -33,6 +34,7 @@ "sprite_package_new", "sprite_package_pulluphang", "globals.d.ts", + "configure.ts", "types.ts", "theme.css", "print.css", From c9a0fe811c68aa0ab6df8519f24da03428426e7d Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 15:40:44 -0700 Subject: [PATCH 35/46] build(ui): precompiled styles.css CSS build + madge circular-dep check (Phase 7 step 5) --- .gitignore | 4 + .madgerc | 1 + bun.lock | 233 +++++++++++++++++++++++++++++---- package.json | 7 +- packages/ui/package.json | 12 +- packages/ui/styles-entry.css | 11 ++ packages/ui/vite.css.config.ts | 19 +++ 7 files changed, 260 insertions(+), 27 deletions(-) create mode 100644 .madgerc create mode 100644 packages/ui/styles-entry.css create mode 100644 packages/ui/vite.css.config.ts diff --git a/.gitignore b/.gitignore index 999c66367..e13177e54 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,7 @@ plannotator-local # Local Pi state/memory (not upstream) /.pi/ + +# @plannotator/ui CSS build artifacts (generated by prepublishOnly — not committed) +packages/ui/styles.css +packages/ui/styles.js diff --git a/.madgerc b/.madgerc new file mode 100644 index 000000000..b86f0df03 --- /dev/null +++ b/.madgerc @@ -0,0 +1 @@ +{ "extensions": ["ts", "tsx"], "fileExtensions": ["ts", "tsx"] } diff --git a/bun.lock b/bun.lock index bedcf561c..86f40f6a2 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "@types/node": "^25.5.2", "@types/turndown": "^5.0.6", "bun-types": "^1.3.11", + "madge": "^8.0.0", }, }, "apps/hook": { @@ -298,6 +299,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", @@ -306,6 +308,7 @@ "tailwindcss": "^4.1.18", "tailwindcss-animate": "^1.0.7", "typescript": "~5.8.2", + "vite": "^6.2.0", }, "peerDependencies": { "react": "^19.2.3", @@ -528,6 +531,10 @@ "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + "@dependents/detective-less": ["@dependents/detective-less@5.0.3", "", { "dependencies": { "gonzales-pe": "^4.3.0", "node-source-walk": "^7.0.1" } }, "sha512-v6oD9Ukp+N7V4n6p5I/+mM5fIohSfkrDSGlFm5w/pYmchvbk+sMIHsLxrFJ5Lnujewj1BzWL0K84d88lwZAMQA=="], + + "@discoveryjs/json-ext": ["@discoveryjs/json-ext@1.1.0", "", {}, "sha512-Xc3VhU02wqZ1HvHRJUwL09HkZSTvidqY5Ya0NXBSYOxAp+Ln9dcJr9fySI+CkONzP3PekQo9WdzCv0PGER/mOA=="], + "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.79.1", "", { "dependencies": { "@earendil-works/pi-ai": "^0.79.1", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-PBPjBa2YBm9jauiLtHAKaSfVJ4Dvm3/nK/bR/oHebLjwBCS2tGx3aQDX7MSGAOXi6BejlhzbB/z82BkyAyNjjQ=="], "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.79.1", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.1", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-UnORwrcsTNLm4StEvoM8iEom0u87Te7BXEWxhec3iNXygWD6eEBosUoq9ddcveqtj/QpUZBMPWUu81cCtZxzkQ=="], @@ -1100,6 +1107,14 @@ "@textlint/types": ["@textlint/types@15.7.1", "", { "dependencies": { "@textlint/ast-node-types": "15.7.1" } }, "sha512-Vye/GmFNBTgVzZFtIFJTmLB+s2A7oIADxNG6r9UhfPuY+Czv0z5G3xeyFZZudPlfxURsKUyPIU5XsjOFqVp33A=="], + "@ts-graphviz/adapter": ["@ts-graphviz/adapter@2.0.6", "", { "dependencies": { "@ts-graphviz/common": "^2.1.5" } }, "sha512-kJ10lIMSWMJkLkkCG5gt927SnGZcBuG0s0HHswGzcHTgvtUe7yk5/3zTEr0bafzsodsOq5Gi6FhQeV775nC35Q=="], + + "@ts-graphviz/ast": ["@ts-graphviz/ast@2.0.7", "", { "dependencies": { "@ts-graphviz/common": "^2.1.5" } }, "sha512-e6+2qtNV99UT6DJSoLbHfkzfyqY84aIuoV8Xlb9+hZAjgpum8iVHprGeAMQ4rF6sKUAxrmY8rfF/vgAwoPc3gw=="], + + "@ts-graphviz/common": ["@ts-graphviz/common@2.1.5", "", {}, "sha512-S6/9+T6x8j6cr/gNhp+U2olwo1n0jKj/682QVqsh7yXWV6ednHYqxFw0ZsY3LyzT0N8jaZ6jQY9YD99le3cmvg=="], + + "@ts-graphviz/core": ["@ts-graphviz/core@2.0.7", "", { "dependencies": { "@ts-graphviz/ast": "^2.0.7", "@ts-graphviz/common": "^2.1.5" } }, "sha512-w071DSzP94YfN6XiWhOxnLpYT3uqtxJBDYdh6Jdjzt+Ce6DNspJsPQgpC7rbts/B8tEkq0LHoYuIF/O5Jh5rPg=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -1216,6 +1231,16 @@ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.61.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.61.1", "@typescript-eslint/types": "^8.61.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.61.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.61.1", "", {}, "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.61.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.61.1", "@typescript-eslint/tsconfig-utils": "8.61.1", "@typescript-eslint/types": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.61.1", "", { "dependencies": { "@typescript-eslint/types": "8.61.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w=="], + "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.6", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], @@ -1248,6 +1273,16 @@ "@vscode/vsce-sign-win32-x64": ["@vscode/vsce-sign-win32-x64@2.0.6", "", { "os": "win32", "cpu": "x64" }, "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ=="], + "@vue/compiler-core": ["@vue/compiler-core@3.5.38", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.38", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ=="], + + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.38", "", { "dependencies": { "@vue/compiler-core": "3.5.38", "@vue/shared": "3.5.38" } }, "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw=="], + + "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.38", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/compiler-core": "3.5.38", "@vue/compiler-dom": "3.5.38", "@vue/compiler-ssr": "3.5.38", "@vue/shared": "3.5.38", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.15", "source-map-js": "^1.2.1" } }, "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg=="], + + "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.38", "", { "dependencies": { "@vue/compiler-dom": "3.5.38", "@vue/shared": "3.5.38" } }, "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA=="], + + "@vue/shared": ["@vue/shared@3.5.38", "", {}, "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug=="], + "@xterm/addon-fit": ["@xterm/addon-fit@0.12.0-beta.216", "", { "peerDependencies": { "@xterm/xterm": "^6.1.0-beta.216" } }, "sha512-IgKE3ngNodSnmj1O+EEYpKQZkSbAUbghPlCWd8G32RL0piIMqb3FX3BuYLnWZeLNoD9iMtublLMG1T9XjGeVvA=="], "@xterm/addon-unicode11": ["@xterm/addon-unicode11@0.10.0-beta.216", "", { "peerDependencies": { "@xterm/xterm": "^6.1.0-beta.216" } }, "sha512-i7TrEHOTzUEOClH1+6IHoHy7bR/XHVRBjHc5e0u6A1HucFkAlCU+bqUY8EfwNOh1/iUjuB06EtNh6BM1o/ZAlA=="], @@ -1276,14 +1311,18 @@ "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], "anynum": ["anynum@1.0.0", "", {}, "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA=="], + "app-module-path": ["app-module-path@2.2.0", "", {}, "sha512-gkco+qxENJV+8vFcDiiFhuoSvRXb2a/QPqpSoWhVz829VNJfOTnELbBmPmNKFxf3xdNnw4DWCkzkDaavcX/1YQ=="], + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -1296,6 +1335,8 @@ "as-table": ["as-table@1.0.55", "", { "dependencies": { "printable-characters": "^1.0.42" } }, "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ=="], + "ast-module-types": ["ast-module-types@6.0.2", "", {}, "sha512-6KuK/7nZ/2Qh7sGuVEiwxjCxzTY2Pdb5mTo5z1e6/J8BA0tvjR7G8vQJKrQMTqwmnA3UPEyKIFX4YUS1DO1Hvw=="], + "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], @@ -1368,7 +1409,7 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], @@ -1392,6 +1433,12 @@ "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + "cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], + + "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + + "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "cockatiel": ["cockatiel@3.2.1", "", {}, "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q=="], @@ -1410,10 +1457,12 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], "common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="], + "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -1536,6 +1585,8 @@ "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], @@ -1546,6 +1597,8 @@ "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dependency-tree": ["dependency-tree@11.5.0", "", { "dependencies": { "@discoveryjs/json-ext": "^1.1.0", "commander": "^12.1.0", "filing-cabinet": "^5.5.1", "precinct": "^12.3.2", "typescript": "^5.9.3" }, "bin": { "dependency-tree": "bin/cli.js" } }, "sha512-K9zBwKDZrot3RkxizugpVSdImxULAg4Ycp3+ydy2r561k96oiiw6nfsOR15fwNDQ5BF2UXe+2JFM/H5Xz4MGQg=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], @@ -1554,6 +1607,24 @@ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "detective-amd": ["detective-amd@6.1.0", "", { "dependencies": { "ast-module-types": "^6.0.1", "escodegen": "^2.1.0", "get-amd-module-type": "^6.0.2", "node-source-walk": "^7.0.1" }, "bin": { "detective-amd": "bin/cli.js" } }, "sha512-fmI6LGMvotqd49QaA3ZYw+q0aGp2yXmMjzIuY6fH9j9YFIXY/73yDhMwhX9cPbhWd+AH06NH1Di/LKOuCH0Ubg=="], + + "detective-cjs": ["detective-cjs@6.1.1", "", { "dependencies": { "ast-module-types": "^6.0.1", "node-source-walk": "^7.0.1" } }, "sha512-pSh7mkCKEtLlmANqLu3KDFS3NV8Hx41jy/JF1/gAWOgU+Uo5QTkeI1tWNP4dWGo4L0E9j18Ez9EPsTleautKqA=="], + + "detective-es6": ["detective-es6@5.0.2", "", { "dependencies": { "node-source-walk": "^7.0.1" } }, "sha512-+qHHGYhjupiVs4rnIpI9nZ5B130A4AmE35ZX1w33hb46vcZ7T3jfDbvmPw0FhWtMHn5BS5HHu7ZtnZ53bMcXZA=="], + + "detective-postcss": ["detective-postcss@8.0.4", "", { "dependencies": { "is-url-superb": "^4.0.0", "postcss-values-parser": "^6.0.2" }, "peerDependencies": { "postcss": "^8.4.47" } }, "sha512-DZ7M/hWPZyr17ZUdoQ+TVXaPj70mYr4XXrAE+GeJbca44haCvZgb191L/jLJmFYewhxRJuBd4lUtNSu986TXag=="], + + "detective-sass": ["detective-sass@6.0.2", "", { "dependencies": { "gonzales-pe": "^4.3.0", "node-source-walk": "^7.0.1" } }, "sha512-i3xpXHDKS0qI2aFW4asQ7fqlPK00ndOVZELvQapFJCaF0VxYmsNWtd0AmvXbTLMk7bfO5VdIeorhY9KfmHVoVA=="], + + "detective-scss": ["detective-scss@5.0.2", "", { "dependencies": { "gonzales-pe": "^4.3.0", "node-source-walk": "^7.0.1" } }, "sha512-9JOEMZ8pDh3ShXmftq7hoQqqJsClaGgxo1hghfCeFlmKf5TC/Twtwb0PAaK8dXwpg9Z0uCmEYSrCxO+kel2eEg=="], + + "detective-stylus": ["detective-stylus@5.0.1", "", {}, "sha512-Dgn0bUqdGbE3oZJ+WCKf8Dmu7VWLcmRJGc6RCzBgG31DLIyai9WAoEhYRgIHpt/BCRMrnXLbGWGPQuBUrnF0TA=="], + + "detective-typescript": ["detective-typescript@14.1.2", "", { "dependencies": { "@typescript-eslint/typescript-estree": "^8.58.2", "ast-module-types": "^6.0.1", "node-source-walk": "^7.0.1" }, "peerDependencies": { "typescript": "^5.4.4 || ^6.0.2" } }, "sha512-bIeEn0eVi/JRsE1YizBR2ilnMlWRAIBJJ6kXCKNFxEEWhUcEY3R6I3KYIAy48ieURbD1hcb3Ebvl8AqeoPMSzg=="], + + "detective-vue2": ["detective-vue2@2.3.0", "", { "dependencies": { "@dependents/detective-less": "^5.0.1", "@vue/compiler-sfc": "^3.5.32", "detective-es6": "^5.0.1", "detective-sass": "^6.0.1", "detective-scss": "^5.0.1", "detective-stylus": "^5.0.1", "detective-typescript": "^14.1.0" }, "peerDependencies": { "typescript": "^5.4.4 || ^6.0.2" } }, "sha512-3gwbZPqVTm9sL9XdZsgEJ7x4x99O853VVZHapQAiEkGuMJMpFPjHDrecSgfqnS5JW3FJfYXesLZGvUOibjn49g=="], + "deterministic-object-hash": ["deterministic-object-hash@2.0.2", "", { "dependencies": { "base-64": "^1.0.0" } }, "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ=="], "devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="], @@ -1632,6 +1703,14 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="], "estree-util-build-jsx": ["estree-util-build-jsx@3.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-walker": "^3.0.0" } }, "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ=="], @@ -1646,6 +1725,8 @@ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], @@ -1684,6 +1765,8 @@ "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + "filing-cabinet": ["filing-cabinet@5.5.1", "", { "dependencies": { "app-module-path": "^2.2.0", "commander": "^12.1.0", "enhanced-resolve": "^5.21.0", "module-definition": "^6.0.2", "module-lookup-amd": "^9.1.3", "resolve": "^1.22.12", "resolve-dependency-path": "^4.0.1", "sass-lookup": "^6.1.2", "stylus-lookup": "^6.1.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.9.3" }, "bin": { "filing-cabinet": "bin/cli.js" } }, "sha512-PzLBTChlVPn6LnNxF0KWs+XqPziVh3Sfmz/3TXOymHxu6a9yhrDcQn7YwgpcRM6mqhR2WHVGPR8RU4fmcF1IVA=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], @@ -1720,12 +1803,16 @@ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-amd-module-type": ["get-amd-module-type@6.0.2", "", { "dependencies": { "ast-module-types": "^6.0.1", "node-source-walk": "^7.0.1" } }, "sha512-7zShVYAYtMnj9S65CfN+hvpBCByfuB1OY8xID01nZEzXTZbx4YyysAfi+nMl95JSR6odt4q8TCj2W63KAoyVLQ=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + "get-own-enumerable-property-symbols": ["get-own-enumerable-property-symbols@3.0.2", "", {}, "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g=="], + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "get-source": ["get-source@2.0.12", "", { "dependencies": { "data-uri-to-buffer": "^2.0.0", "source-map": "^0.6.1" } }, "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w=="], @@ -1744,6 +1831,8 @@ "globby": ["globby@14.1.0", "", { "dependencies": { "@sindresorhus/merge-streams": "^2.1.0", "fast-glob": "^3.3.3", "ignore": "^7.0.3", "path-type": "^6.0.0", "slash": "^5.1.0", "unicorn-magic": "^0.3.0" } }, "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA=="], + "gonzales-pe": ["gonzales-pe@4.3.0", "", { "dependencies": { "minimist": "^1.2.5" }, "bin": { "gonzales": "bin/gonzales.js" } }, "sha512-otgSPpUmdWJ43VXyiNgEYE4luzHCL2pz4wQ0OnDluC6Eg4Ko3Vexy/SrSynglw/eR+OhkzmqFCZa/OFa/RgAOQ=="], + "google-auth-library": ["google-auth-library@10.7.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ=="], "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], @@ -1822,7 +1911,7 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], @@ -1840,6 +1929,8 @@ "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], @@ -1854,12 +1945,22 @@ "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + "is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="], + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + "is-obj": ["is-obj@1.0.1", "", {}, "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-regexp": ["is-regexp@1.0.0", "", {}, "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA=="], + + "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], + + "is-url-superb": ["is-url-superb@4.0.0", "", {}, "sha512-GI+WjezhPPcbM+tqE9LnmsY5qqjwHzTvjJ36wxYX5ujNXefSUJ/T17r5bqDV8yLhcgB59KTPNOc9O9cmHTPWsA=="], + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -1956,6 +2057,8 @@ "lodash.truncate": ["lodash.truncate@4.4.2", "", {}, "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw=="], + "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], @@ -1966,6 +2069,8 @@ "lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="], + "madge": ["madge@8.0.0", "", { "dependencies": { "chalk": "^4.1.2", "commander": "^7.2.0", "commondir": "^1.0.1", "debug": "^4.3.4", "dependency-tree": "^11.0.0", "ora": "^5.4.1", "pluralize": "^8.0.0", "pretty-ms": "^7.0.1", "rc": "^1.2.8", "stream-to-array": "^2.3.0", "ts-graphviz": "^2.1.2", "walkdir": "^0.4.1" }, "peerDependencies": { "typescript": "^5.4.4" }, "optionalPeers": ["typescript"], "bin": { "madge": "bin/cli.js" } }, "sha512-9sSsi3TBPhmkTCIpVQF0SPiChj1L7Rq9kU2KDG1o6v2XH9cCw086MopjVCD+vuoL5v8S77DTbVopTO8OUiQpIw=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], @@ -2104,6 +2209,8 @@ "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], "miniflare": ["miniflare@3.20250718.3", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "stoppable": "1.1.0", "undici": "^5.28.5", "workerd": "1.20250718.0", "ws": "8.18.0", "youch": "3.3.4", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ=="], @@ -2116,6 +2223,10 @@ "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + "module-definition": ["module-definition@6.0.2", "", { "dependencies": { "ast-module-types": "^6.0.1", "node-source-walk": "^7.0.1" }, "bin": { "module-definition": "bin/cli.js" } }, "sha512-SvAU3lB0+Yjbq55yHY3wkRZBOh+fhU1SnIF3IFbTewv6mtAh7yUT8ACHAJ2mGIJ7tCes2QuCL/cl6m0JSZ/ArA=="], + + "module-lookup-amd": ["module-lookup-amd@9.1.3", "", { "dependencies": { "commander": "^12.1.0", "requirejs": "^2.3.8", "requirejs-config-file": "^4.0.0" }, "bin": { "lookup-amd": "bin/cli.js" } }, "sha512-Jc3XmOaR9FdfMJSK8+vyLgsCkzm8z2L0NS6vrlRWi12DjS7MY7TMNE7E1yj8yXx837xtMDbKSSgcdXnFlJ2YLg=="], + "motion": ["motion@12.40.0", "", { "dependencies": { "framer-motion": "^12.40.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA=="], "motion-dom": ["motion-dom@12.40.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="], @@ -2166,6 +2277,8 @@ "node-sarif-builder": ["node-sarif-builder@3.4.0", "", { "dependencies": { "@types/sarif": "^2.1.7", "fs-extra": "^11.1.1" } }, "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg=="], + "node-source-walk": ["node-source-walk@7.0.2", "", { "dependencies": { "@babel/parser": "^7.29.0" } }, "sha512-71kFFjYaSshDTA8/a2HiTYPLdASWjLJxUyJxGE+ffxU+KhxSBtM9kiLUX+R2yooFdSFKMFpi4n3PFtDy6qXv8A=="], + "normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], @@ -2184,6 +2297,8 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], @@ -2192,6 +2307,8 @@ "openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="], + "ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], + "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], @@ -2210,6 +2327,8 @@ "parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="], + "parse-ms": ["parse-ms@2.1.0", "", {}, "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA=="], + "parse-semver": ["parse-semver@1.1.1", "", { "dependencies": { "semver": "^5.1.0" } }, "sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ=="], "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], @@ -2228,6 +2347,8 @@ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], @@ -2258,8 +2379,14 @@ "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "postcss-values-parser": ["postcss-values-parser@6.0.2", "", { "dependencies": { "color-name": "^1.1.4", "is-url-superb": "^4.0.0", "quote-unquote": "^1.0.0" }, "peerDependencies": { "postcss": "^8.2.9" } }, "sha512-YLJpK0N1brcNJrs9WatuJFtHaV9q5aAOj+S4DI5S7jgHlRfm0PIbDCAFRYMQD5SHq7Fy6xsDhyutgS0QOAs0qw=="], + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + "precinct": ["precinct@12.3.2", "", { "dependencies": { "@dependents/detective-less": "^5.0.3", "commander": "^12.1.0", "detective-amd": "^6.1.0", "detective-cjs": "^6.1.1", "detective-es6": "^5.0.2", "detective-postcss": "^8.0.3", "detective-sass": "^6.0.2", "detective-scss": "^5.0.2", "detective-stylus": "^5.0.1", "detective-typescript": "^14.1.2", "detective-vue2": "^2.3.0", "module-definition": "^6.0.2", "node-source-walk": "^7.0.2", "postcss": "^8.5.14", "typescript": "^5.9.3" }, "bin": { "precinct": "bin/cli.js" } }, "sha512-JbJevI1K80z8e/WIyDt/4vUN/4qcfBSKKqOjJA4mosPPPb7zODKRJQV7YN7apVWN3k58nZYm/vEsLgEGYmnxwg=="], + + "pretty-ms": ["pretty-ms@7.0.1", "", { "dependencies": { "parse-ms": "^2.1.0" } }, "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q=="], + "printable-characters": ["printable-characters@1.0.42", "", {}, "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ=="], "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], @@ -2284,6 +2411,8 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "quote-unquote": ["quote-unquote@1.0.0", "", {}, "sha512-twwRO/ilhlG/FIgYeKGFqyHhoEhqgnKVkcmqMKi2r524gz3ZbDTcyFt38E9xjJI2vT+KbRNHVbnJ/e0I25Azwg=="], + "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], @@ -2352,6 +2481,16 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "requirejs": ["requirejs@2.3.8", "", { "bin": { "r.js": "bin/r.js", "r_js": "bin/r.js" } }, "sha512-7/cTSLOdYkNBNJcDMWf+luFvMriVm7eYxp4BcFCsAX0wF421Vyce5SXP17c+Jd5otXKGNehIonFlyQXSowL6Mw=="], + + "requirejs-config-file": ["requirejs-config-file@4.0.0", "", { "dependencies": { "esprima": "^4.0.0", "stringify-object": "^3.2.1" } }, "sha512-jnIre8cbWOyvr8a5F2KuqBnY+SDA4NXr/hzEZJG79Mxm2WiFQz2dzhC8ibtPJS7zkmBEl1mxSwp5HhC1W4qpxw=="], + + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + + "resolve-dependency-path": ["resolve-dependency-path@4.0.1", "", {}, "sha512-YQftIIC4vzO9UMhO/sCgXukNyiwVRCVaxiWskCBy7Zpqkplm8kTAISZ8O1MoKW1ca6xzgLUBjZTcDgypXvXxiQ=="], + + "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], + "retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="], "retext-latin": ["retext-latin@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "parse-latin": "^7.0.0", "unified": "^11.0.0" } }, "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA=="], @@ -2388,6 +2527,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "sass-lookup": ["sass-lookup@6.1.2", "", { "dependencies": { "commander": "^12.1.0", "enhanced-resolve": "^5.20.0" }, "bin": { "sass-lookup": "bin/cli.js" } }, "sha512-GjmndmKQBtlPil79RK72L7yc5kDXZPCQeH97bP8R8DcxtXQJO6vECExb3WP/m6+cxaV9h4ZxrSRvCkPG2v/VSw=="], + "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -2462,13 +2603,19 @@ "stream-replace-string": ["stream-replace-string@2.0.0", "", {}, "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w=="], + "stream-to-array": ["stream-to-array@2.3.0", "", { "dependencies": { "any-promise": "^1.1.0" } }, "sha512-UsZtOYEn4tWU2RGLOXr/o/xjRBftZRlG3dEWoaHr8j4GuypJ3isitGbVyjQKAuMu+xbiop8q224TjiZWc4XTZA=="], + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], - "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "stringify-object": ["stringify-object@3.3.0", "", { "dependencies": { "get-own-enumerable-property-symbols": "^3.0.0", "is-obj": "^1.0.1", "is-regexp": "^1.0.0" } }, "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], @@ -2484,10 +2631,14 @@ "stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], + "stylus-lookup": ["stylus-lookup@6.1.2", "", { "dependencies": { "commander": "^12.1.0" }, "bin": { "stylus-lookup": "bin/cli.js" } }, "sha512-O+Q/SJ8s1X2aMLh4213fQ9X/bND9M3dhSsyTRe+O1OXPcewGLiYmAtKCrnP7FDvDBaXB2ZHPkCt3zi4cJXBlCQ=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-hyperlinks": ["supports-hyperlinks@3.2.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig=="], + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "svgo": ["svgo@4.0.1", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="], "table": ["table@6.9.0", "", { "dependencies": { "ajv": "^8.0.1", "lodash.truncate": "^4.4.2", "slice-ansi": "^4.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1" } }, "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A=="], @@ -2530,10 +2681,16 @@ "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], + "ts-graphviz": ["ts-graphviz@2.1.6", "", { "dependencies": { "@ts-graphviz/adapter": "^2.0.6", "@ts-graphviz/ast": "^2.0.7", "@ts-graphviz/common": "^2.1.5", "@ts-graphviz/core": "^2.0.7" } }, "sha512-XyLVuhBVvdJTJr2FJJV2L1pc4MwSjMhcunRVgDE9k4wbb2ee7ORYnPewxMWUav12vxyfUM686MSGsqnVRIInuw=="], + "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], + "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], @@ -2634,6 +2791,10 @@ "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "walkdir": ["walkdir@0.4.1", "", {}, "sha512-3eBwRyEln6E1MSzcxcVpQIhRG8Q1jLvEqRmCZqS3dsfXEDR/AhOF4d+jHg1qvDCpYaVRZjENPQyrVxAkQqxPgQ=="], + + "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], @@ -2716,6 +2877,8 @@ "@earendil-works/pi-ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], + "@earendil-works/pi-coding-agent/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "@earendil-works/pi-coding-agent/highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], "@earendil-works/pi-tui/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], @@ -2734,6 +2897,10 @@ "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@secretlint/formatter/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "@secretlint/formatter/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="], @@ -2746,18 +2913,20 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@textlint/linter-formatter/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "@textlint/linter-formatter/pluralize": ["pluralize@2.0.0", "", {}, "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw=="], "@textlint/linter-formatter/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "@textlint/linter-formatter/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "@vscode/vsce/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "@vscode/vsce/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], "@vscode/vsce/hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + "@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -2768,6 +2937,8 @@ "astro/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "boxen/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "cheerio/undici": ["undici@7.27.2", "", {}, "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA=="], "cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], @@ -2776,22 +2947,32 @@ "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], - "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], - "d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "dependency-tree/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "dependency-tree/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "effect/ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], + "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "filing-cabinet/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "filing-cabinet/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "get-source/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "happy-dom/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], @@ -2818,6 +2999,8 @@ "miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], + "module-lookup-amd/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "node-fetch/data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "normalize-package-data/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], @@ -2828,7 +3011,9 @@ "parse-semver/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], - "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "precinct/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "precinct/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], @@ -2840,16 +3025,20 @@ "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "sass-lookup/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], "sitemap/@types/node": ["@types/node@24.13.1", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg=="], + "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "stylus-lookup/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "table/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "table/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], @@ -2864,6 +3053,8 @@ "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "youch/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "zod-to-ts/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -2880,9 +3071,9 @@ "@plannotator/review/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "@textlint/linter-formatter/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "@secretlint/formatter/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "@textlint/linter-formatter/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@textlint/linter-formatter/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "@vscode/vsce/hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], @@ -2890,8 +3081,6 @@ "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "astro/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], "astro/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], @@ -2960,9 +3149,9 @@ "sitemap/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - "table/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "table/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "table/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], @@ -3098,6 +3287,6 @@ "wrangler/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], - "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], } } diff --git a/package.json b/package.json index 84fcc54e1..3ddbff7a7 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,9 @@ "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/core/tsconfig.json && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json" + "typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json", + "build:ui-css": "bun run --cwd packages/ui build:css", + "check:cycles": "madge --circular --extensions ts,tsx --ts-config packages/core/tsconfig.json packages/core && madge --circular --extensions ts,tsx --ts-config packages/ui/tsconfig.json packages/ui" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.92", @@ -49,6 +51,7 @@ "devDependencies": { "@types/node": "^25.5.2", "@types/turndown": "^5.0.6", - "bun-types": "^1.3.11" + "bun-types": "^1.3.11", + "madge": "^8.0.0" } } diff --git a/packages/ui/package.json b/packages/ui/package.json index a278ea5e0..e9b0f9d5a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -18,7 +18,8 @@ "./config": "./config/index.ts", "./configure": "./configure.ts", "./types": "./types.ts", - "./theme": "./theme.css" + "./theme": "./theme.css", + "./styles.css": "./styles.css" }, "files": [ "components", @@ -38,6 +39,7 @@ "types.ts", "theme.css", "print.css", + "styles.css", "plannotator.webp", "!**/*.test.ts", "!**/*.test.tsx", @@ -94,6 +96,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", @@ -101,9 +104,12 @@ "react-dom": "^19.2.3", "tailwindcss": "^4.1.18", "tailwindcss-animate": "^1.0.7", - "typescript": "~5.8.2" + "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", + "prepublishOnly": "bun run build:css" } } diff --git a/packages/ui/styles-entry.css b/packages/ui/styles-entry.css new file mode 100644 index 000000000..7cba7f178 --- /dev/null +++ b/packages/ui/styles-entry.css @@ -0,0 +1,11 @@ +@import "@fontsource-variable/inter"; +@import "@fontsource-variable/geist-mono"; +@import "tailwindcss"; + +@plugin "tailwindcss-animate"; + +@source "./components/**/*.tsx"; +@source "./hooks/**/*.ts"; +@source "./utils/**/*.ts"; + +@import "./theme.css"; diff --git a/packages/ui/vite.css.config.ts b/packages/ui/vite.css.config.ts new file mode 100644 index 000000000..80300db2b --- /dev/null +++ b/packages/ui/vite.css.config.ts @@ -0,0 +1,19 @@ +import path from 'path'; +import { defineConfig } from 'vite'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [tailwindcss()], + resolve: { alias: { '@plannotator/ui': path.resolve(__dirname, '.') } }, + build: { + lib: { + entry: path.resolve(__dirname, 'styles-entry.css'), + formats: ['es'], + fileName: () => 'styles.js', + }, + outDir: '.', + cssCodeSplit: true, + rollupOptions: { output: { assetFileNames: 'styles.css' } }, + emptyOutDir: false, + }, +}); From 6b137154df91a4afacf83e39b998c014cc2cd1ee Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 16:05:52 -0700 Subject: [PATCH 36/46] test(ui): per-seam override tests + configure routing test (Phase 7 step 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add one override test per seam (setX(fake)→drive→assert→resetX()) for all 9 seams + loadFromBackend, modeled after the existing seam test pattern. Fix configure.test.ts to defer mock.module() into beforeAll and restore with captured real function references in afterAll so sibling seam test files are not poisoned by spy replacements in the shared Bun worker module registry. --- .../components/ImageThumbnail.seam.test.tsx | 103 +++++++++++ .../components/InlineMarkdown.seam.test.tsx | 121 +++++++++++++ packages/ui/config/configStore.seam.test.ts | 110 ++++++++++++ packages/ui/configure.test.ts | 151 +++++++++++++--- packages/ui/hooks/useAIChat.seam.test.tsx | 151 ++++++++++++++++ .../ui/hooks/useAnnotationDraft.seam.test.tsx | 170 ++++++++++++++++++ .../useExternalAnnotations.seam.test.tsx | 156 ++++++++++++++++ .../ui/hooks/useFileBrowser.seam.test.tsx | 136 ++++++++++++++ packages/ui/utils/identity.seam.test.ts | 77 ++++++++ packages/ui/utils/storage.seam.test.ts | 97 ++++++++++ 10 files changed, 1251 insertions(+), 21 deletions(-) create mode 100644 packages/ui/components/ImageThumbnail.seam.test.tsx create mode 100644 packages/ui/components/InlineMarkdown.seam.test.tsx create mode 100644 packages/ui/config/configStore.seam.test.ts create mode 100644 packages/ui/hooks/useAIChat.seam.test.tsx create mode 100644 packages/ui/hooks/useAnnotationDraft.seam.test.tsx create mode 100644 packages/ui/hooks/useExternalAnnotations.seam.test.tsx create mode 100644 packages/ui/hooks/useFileBrowser.seam.test.tsx create mode 100644 packages/ui/utils/identity.seam.test.ts create mode 100644 packages/ui/utils/storage.seam.test.ts 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/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/config/configStore.seam.test.ts b/packages/ui/config/configStore.seam.test.ts new file mode 100644 index 000000000..4d2fb0b90 --- /dev/null +++ b/packages/ui/config/configStore.seam.test.ts @@ -0,0 +1,110 @@ +/** + * Seam tests: ConfigStore server-sync override + loadFromBackend re-hydration. + * + * Test 1 (serverSync seam): configStore.set on a server-synced key calls the + * installed sync fn instead of the default POST /api/config. + * resetServerSync() restores the default fn. + * + * Test 2 (loadFromBackend seam): install a fake StorageBackend with a + * prefetched setting, call loadFromBackend() — the store now returns the + * prefetched value. + * + * No DOM required. + * + * IMPORTANT: function references are captured at module-load time (top-level) + * so they remain valid even when configure.test.ts's mock.module() replaces + * the module exports later during test execution. + */ +import { afterEach, describe, expect, it } from 'bun:test'; +import { configStore } from './index'; +import * as storageModule from '../utils/storage'; + +// Capture real storage functions at import time (before configure.test.ts's +// mock.module('./utils/storage', ...) replaces them with no-op spies). +const setStorageBackend = storageModule.setStorageBackend; +const resetStorageBackend = storageModule.resetStorageBackend; + +afterEach(() => { + configStore.resetServerSync(); + resetStorageBackend(); +}); + +// --------------------------------------------------------------------------- +// 1. serverSync seam +// --------------------------------------------------------------------------- +describe('configStore.setServerSync seam', () => { + it('routes server write-back through the installed sync fn', async () => { + const synced: Array> = []; + const fakeSync = (payload: Record) => { + synced.push(payload); + }; + + configStore.setServerSync(fakeSync); + + // 'displayName' is a server-synced setting (serverKey: 'displayName') + configStore.set('displayName', 'test-tater'); + + // Server write-back is debounced at 300 ms — wait for the timer to fire. + await new Promise((resolve) => setTimeout(resolve, 350)); + + expect(synced.length).toBeGreaterThanOrEqual(1); + // The payload must contain the displayName key from toServer(). + const merged = Object.assign({}, ...synced) as Record; + expect(merged).toHaveProperty('displayName', 'test-tater'); + }); + + it('resetServerSync() restores the default fn (no longer calls the fake)', async () => { + const fakeCalled: boolean[] = []; + configStore.setServerSync((_: Record) => { fakeCalled.push(true); }); + configStore.resetServerSync(); + + configStore.set('displayName', 'another-tater'); + await new Promise((resolve) => setTimeout(resolve, 350)); + + expect(fakeCalled).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// 2. loadFromBackend seam +// --------------------------------------------------------------------------- +describe('configStore.loadFromBackend seam', () => { + it('re-hydrates settings from the installed StorageBackend', () => { + // Install a fake StorageBackend that returns a specific displayName. + // settings.ts reads 'plannotator-identity' for the displayName setting. + const prefetched = new Map([ + ['plannotator-identity', 'prefetched-workspace-user'], + ]); + const fakeBackend = { + getItem: (key: string) => prefetched.get(key) ?? null, + setItem: () => {}, + removeItem: () => {}, + }; + + setStorageBackend(fakeBackend); + configStore.loadFromBackend(); + + // The store should now reflect the prefetched value. + expect(configStore.get('displayName')).toBe('prefetched-workspace-user'); + }); + + it('keys absent from the backend are left at their prior value (not overwritten with undefined)', () => { + // First set a known value for 'displayName'. + configStore.set('displayName', 'prior-value'); + + // Install a backend that returns null for every key (simulates a backend + // that has no opinion on this setting). + const emptyBackend = { + getItem: (_key: string) => null, + setItem: () => {}, + removeItem: () => {}, + }; + + setStorageBackend(emptyBackend); + configStore.loadFromBackend(); + + // 'prior-value' must be preserved — loadFromBackend only overwrites when + // fromCookie() returns a non-undefined result. + expect(configStore.get('displayName')).toBe('prior-value'); + }); +}); diff --git a/packages/ui/configure.test.ts b/packages/ui/configure.test.ts index 625dc6be1..3423e9b6d 100644 --- a/packages/ui/configure.test.ts +++ b/packages/ui/configure.test.ts @@ -1,4 +1,4 @@ -import { afterAll, describe, expect, it, mock, spyOn } from 'bun:test'; +import { afterAll, beforeAll, describe, expect, it, mock, spyOn } from 'bun:test'; import * as ImageThumbnail from './components/ImageThumbnail'; import * as InlineMarkdown from './components/InlineMarkdown'; @@ -19,10 +19,28 @@ import type { DraftTransport } from './hooks/useAnnotationDraft'; import type { ExternalAnnotationTransport } from './hooks/useExternalAnnotations'; import type { AITransport } from './hooks/useAIChat'; -// Spy on each setter. We re-export the REAL module verbatim and override ONLY the -// setter with a spy, so other test files importing other exports stay intact -// (Bun's mock.module replacement is process-global — dropping exports would break -// sibling suites). The spies route to no-ops; we assert configure wired them. +// Capture the REAL exports at module-evaluation time (top-level, before any +// mock.module() is installed). These are used to restore the module registry +// in afterAll so that any sibling test files run in the same Bun worker see +// the real exports when THEY evaluate after this file finishes. +const realSetImageSrcResolver = ImageThumbnail.setImageSrcResolver; +const realResetImageSrcResolver = ImageThumbnail.resetImageSrcResolver; +const realSetDocPreviewFetcher = InlineMarkdown.setDocPreviewFetcher; +const realResetDocPreviewFetcher = InlineMarkdown.resetDocPreviewFetcher; +const realSetStorageBackend = storage.setStorageBackend; +const realResetStorageBackend = storage.resetStorageBackend; +const realSetIdentityProvider = identity.setIdentityProvider; +const realResetIdentityProvider = identity.resetIdentityProvider; +const realSetFileTreeBackend = useFileBrowser.setFileTreeBackend; +const realResetFileTreeBackend = useFileBrowser.resetFileTreeBackend; +const realSetDraftTransport = useAnnotationDraft.setDraftTransport; +const realResetDraftTransport = useAnnotationDraft.resetDraftTransport; +const realSetExternalAnnotationTransport = useExternalAnnotations.setExternalAnnotationTransport; +const realResetExternalAnnotationTransport = useExternalAnnotations.resetExternalAnnotationTransport; +const realSetAITransport = useAIChat.setAITransport; +const realResetAITransport = useAIChat.resetAITransport; + +// Spy mocks — will be installed into the module registry in beforeAll. const setImageSrcResolver = mock((_: ImageSrcResolver) => {}); const setDocPreviewFetcher = mock((_: DocPreviewFetcher) => {}); const setStorageBackend = mock((_: StorageBackend) => {}); @@ -32,22 +50,11 @@ const setDraftTransport = mock((_: DraftTransport) => {}); const setExternalAnnotationTransport = mock((_: ExternalAnnotationTransport<{ id: string; source?: string }>) => {}); const setAITransport = mock((_: AITransport) => {}); -mock.module('./components/ImageThumbnail', () => ({ ...ImageThumbnail, setImageSrcResolver })); -mock.module('./components/InlineMarkdown', () => ({ ...InlineMarkdown, setDocPreviewFetcher })); -mock.module('./utils/storage', () => ({ ...storage, setStorageBackend })); -mock.module('./utils/identity', () => ({ ...identity, setIdentityProvider })); -mock.module('./hooks/useFileBrowser', () => ({ ...useFileBrowser, setFileTreeBackend })); -mock.module('./hooks/useAnnotationDraft', () => ({ ...useAnnotationDraft, setDraftTransport })); -mock.module('./hooks/useExternalAnnotations', () => ({ ...useExternalAnnotations, setExternalAnnotationTransport })); -mock.module('./hooks/useAIChat', () => ({ ...useAIChat, setAITransport })); - // configStore is shared with sibling suites — spy on the real instance methods -// (restored in afterAll) instead of replacing the ./config module. +// instead of replacing the ./config module. const setServerSync = spyOn(configStore, 'setServerSync'); const loadFromBackend = spyOn(configStore, 'loadFromBackend').mockImplementation(() => {}); -const { configurePlannotatorUI } = await import('./configure'); - // Shape-correct fakes (only need to satisfy the front door's optional fields). const imageSrcResolver: ImageSrcResolver = (path) => path; const docPreviewFetcher: DocPreviewFetcher = async () => null; @@ -79,10 +86,110 @@ const aiTransport: AITransport = { }; const serverSync = (_payload: Record) => {}; -afterAll(() => mock.restore()); - describe('configurePlannotatorUI routing', () => { - it('routes each provided seam to its underlying setter', () => { + // Install mock.module() replacements HERE (in beforeAll, not at top-level) + // so that sibling seam test files' top-level captures (which happen at module + // evaluation time, BEFORE this beforeAll runs) see the real exports. + // + // Bun runs test files sequentially: file A's top-level → file A's tests + // (including beforeAll/afterAll) → file B's top-level → file B's tests. + // Because configure.test.ts runs before the seam test files (alphabetical), + // the seam files evaluate AFTER this file's afterAll — so they see whatever + // state this afterAll leaves the module registry in. We MUST restore with + // captured real functions (not `{ ...storage }`) because spreading the live + // namespace after mock.module() returns the mocked version. + beforeAll(async () => { + mock.module('./components/ImageThumbnail', () => ({ + ...ImageThumbnail, + setImageSrcResolver, + resetImageSrcResolver: realResetImageSrcResolver, + })); + mock.module('./components/InlineMarkdown', () => ({ + ...InlineMarkdown, + setDocPreviewFetcher, + resetDocPreviewFetcher: realResetDocPreviewFetcher, + })); + mock.module('./utils/storage', () => ({ + ...storage, + setStorageBackend, + resetStorageBackend: realResetStorageBackend, + })); + mock.module('./utils/identity', () => ({ + ...identity, + setIdentityProvider, + resetIdentityProvider: realResetIdentityProvider, + })); + mock.module('./hooks/useFileBrowser', () => ({ + ...useFileBrowser, + setFileTreeBackend, + resetFileTreeBackend: realResetFileTreeBackend, + })); + mock.module('./hooks/useAnnotationDraft', () => ({ + ...useAnnotationDraft, + setDraftTransport, + resetDraftTransport: realResetDraftTransport, + })); + mock.module('./hooks/useExternalAnnotations', () => ({ + ...useExternalAnnotations, + setExternalAnnotationTransport, + resetExternalAnnotationTransport: realResetExternalAnnotationTransport, + })); + mock.module('./hooks/useAIChat', () => ({ + ...useAIChat, + setAITransport, + resetAITransport: realResetAITransport, + })); + }); + + afterAll(() => { + mock.restore(); + // Restore using CAPTURED REAL FUNCTIONS (not `{ ...storage }` which would + // spread the mocked namespace and leave spies in place for sibling files). + mock.module('./components/ImageThumbnail', () => ({ + ...ImageThumbnail, + setImageSrcResolver: realSetImageSrcResolver, + resetImageSrcResolver: realResetImageSrcResolver, + })); + mock.module('./components/InlineMarkdown', () => ({ + ...InlineMarkdown, + setDocPreviewFetcher: realSetDocPreviewFetcher, + resetDocPreviewFetcher: realResetDocPreviewFetcher, + })); + mock.module('./utils/storage', () => ({ + ...storage, + setStorageBackend: realSetStorageBackend, + resetStorageBackend: realResetStorageBackend, + })); + mock.module('./utils/identity', () => ({ + ...identity, + setIdentityProvider: realSetIdentityProvider, + resetIdentityProvider: realResetIdentityProvider, + })); + mock.module('./hooks/useFileBrowser', () => ({ + ...useFileBrowser, + setFileTreeBackend: realSetFileTreeBackend, + resetFileTreeBackend: realResetFileTreeBackend, + })); + mock.module('./hooks/useAnnotationDraft', () => ({ + ...useAnnotationDraft, + setDraftTransport: realSetDraftTransport, + resetDraftTransport: realResetDraftTransport, + })); + mock.module('./hooks/useExternalAnnotations', () => ({ + ...useExternalAnnotations, + setExternalAnnotationTransport: realSetExternalAnnotationTransport, + resetExternalAnnotationTransport: realResetExternalAnnotationTransport, + })); + mock.module('./hooks/useAIChat', () => ({ + ...useAIChat, + setAITransport: realSetAITransport, + resetAITransport: realResetAITransport, + })); + }); + + it('routes each provided seam to its underlying setter', async () => { + const { configurePlannotatorUI } = await import('./configure'); + configurePlannotatorUI({ imageSrcResolver, storageBackend, @@ -113,7 +220,9 @@ describe('configurePlannotatorUI routing', () => { ); }); - it('skips setters for omitted fields', () => { + it('skips setters for omitted fields', async () => { + const { configurePlannotatorUI } = await import('./configure'); + [ setImageSrcResolver, setDocPreviewFetcher, setStorageBackend, setIdentityProvider, setFileTreeBackend, setDraftTransport, setExternalAnnotationTransport, setAITransport, diff --git a/packages/ui/hooks/useAIChat.seam.test.tsx b/packages/ui/hooks/useAIChat.seam.test.tsx new file mode 100644 index 000000000..ba1ee5864 --- /dev/null +++ b/packages/ui/hooks/useAIChat.seam.test.tsx @@ -0,0 +1,151 @@ +/** + * Seam test: AITransport override (setAITransport / resetAITransport). + * + * Contract: + * - After setAITransport(fake), useAIChat.ask() routes the session + query + * calls through the fake transport — NOT through /api/ai/*. + * - resetAITransport() restores the default. + * + * Requires DOM — runs under bun test (preloaded via bunfig.toml). + * + * IMPORTANT: function references are captured at module-load time (top-level) + * so they remain valid even when configure.test.ts's mock.module() replaces + * the module exports later during test execution. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import React from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; +import * as useAIChatModule from './useAIChat'; +import type { AITransport } from './useAIChat'; +import type { AIContext } from '@plannotator/core'; + +// Capture real function references at import time. +const setAITransport = useAIChatModule.setAITransport; +const resetAITransport = useAIChatModule.resetAITransport; +const useAIChat = useAIChatModule.useAIChat; + +const hasDom = typeof document !== 'undefined'; + +afterEach(() => { + resetAITransport(); + if (hasDom) document.body.innerHTML = ''; +}); + +type HookResult = ReturnType; + +function Harness({ resultRef, context }: { resultRef: { current: HookResult | null }; context: AIContext | null }) { + resultRef.current = useAIChat({ context }); + return null; +} + +const TEST_CONTEXT: AIContext = { + mode: 'plan-review', + plan: { plan: 'Test plan content' }, +}; + +function makeSseResponse(textDelta: string): Response { + const body = `data: {"type":"text_delta","delta":"${textDelta}"}\ndata: [DONE]\n\n`; + return new Response(body, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); +} + +async function mountHook(context: AIContext | null): Promise<{ + result: { current: HookResult | null }; + unmount: () => Promise; +}> { + const host = document.createElement('div'); + document.body.appendChild(host); + const resultRef: { current: HookResult | null } = { current: null }; + let root: Root; + await act(async () => { + root = createRoot(host); + root.render(); + }); + return { + result: resultRef, + unmount: async () => { + await act(async () => { root.unmount(); }); + host.remove(); + }, + }; +} + +describe('AITransport seam', () => { + test.skipIf(!hasDom)('fake session + query are called when useAIChat.ask() is invoked', async () => { + const sessionBodies: unknown[] = []; + const queryBodies: unknown[] = []; + + const fakeTransport: AITransport = { + session: async (body, _signal) => { + sessionBodies.push(body); + return new Response(JSON.stringify({ sessionId: 'fake-session-001' }), { status: 200 }); + }, + query: async (body, _signal) => { + queryBodies.push(body); + return makeSseResponse('hello'); + }, + abort: () => {}, + permission: () => {}, + }; + + setAITransport(fakeTransport); + + const session = await mountHook(TEST_CONTEXT); + + await act(async () => { + await session.result.current!.ask({ prompt: 'What is this plan about?' }); + }); + + // Allow SSE reader to drain + await act(async () => { await new Promise((r) => setTimeout(r, 50)); }); + + expect(sessionBodies.length).toBeGreaterThanOrEqual(1); + expect(queryBodies.length).toBeGreaterThanOrEqual(1); + const qb = queryBodies[0] as Record; + expect(qb.sessionId).toBe('fake-session-001'); + + await session.unmount(); + }); + + test.skipIf(!hasDom)('resetAITransport restores the default (does not call the fake)', async () => { + const fakeCalls: string[] = []; + const fake: AITransport = { + session: async () => { fakeCalls.push('session'); return new Response('{}', { status: 200 }); }, + query: async () => { fakeCalls.push('query'); return new Response('', { status: 200 }); }, + abort: () => { fakeCalls.push('abort'); }, + permission: () => { fakeCalls.push('permission'); }, + }; + + setAITransport(fake); + resetAITransport(); + + // After reset, the default transport issues a real fetch to /api/ai/session. + const fetchCalls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + fetchCalls.push(String(input)); + return new Response(JSON.stringify({ sessionId: 'real-session' }), { status: 200 }); + }) as typeof fetch; + + const session = await mountHook(TEST_CONTEXT); + + await act(async () => { + // Fire-and-forget: we just want to trigger the session creation path. + session.result.current!.ask({ prompt: 'test' }).catch(() => {}); + }); + + // Give the async session call a tick to fire. + await act(async () => { await new Promise((r) => setTimeout(r, 50)); }); + + globalThis.fetch = realFetch; + + // Fake was NOT called; the default made a fetch to /api/ai/session. + expect(fakeCalls).toHaveLength(0); + expect(fetchCalls.some((u) => u.includes('/api/ai/session'))).toBe(true); + + await session.unmount(); + }); +}); diff --git a/packages/ui/hooks/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/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/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/utils/identity.seam.test.ts b/packages/ui/utils/identity.seam.test.ts new file mode 100644 index 000000000..e7a6673e8 --- /dev/null +++ b/packages/ui/utils/identity.seam.test.ts @@ -0,0 +1,77 @@ +/** + * Seam test: IdentityProvider override (setIdentityProvider / resetIdentityProvider). + * + * Contract: after setIdentityProvider(fake), getIdentity() delegates to the + * fake's getIdentity() — not to ConfigStore. resetIdentityProvider() restores + * the tater (ConfigStore-backed) provider. + * + * No DOM required. + * + * IMPORTANT: function references are captured at module-load time (top-level) + * so they remain valid even when configure.test.ts's mock.module() replaces + * the module exports later during test execution. + */ +import { afterEach, describe, expect, it } from 'bun:test'; +import * as identityModule from './identity'; + +// Capture real function references at import time (before configure.test.ts's +// mock.module() runs and replaces setIdentityProvider/resetIdentityProvider exports). +const setIdentityProvider = identityModule.setIdentityProvider; +const resetIdentityProvider = identityModule.resetIdentityProvider; +const getIdentity = identityModule.getIdentity; +const isCurrentUser = identityModule.isCurrentUser; + +afterEach(() => { + resetIdentityProvider(); +}); + +describe('IdentityProvider seam', () => { + it('routes getIdentity() through the fake provider', () => { + const calls: string[] = []; + const fake = { + getIdentity: () => { calls.push('getIdentity'); return 'workspace-user@example.com'; }, + isCurrentUser: (_author: string | undefined) => false, + }; + + setIdentityProvider(fake); + + const result = getIdentity(); + + expect(calls).toEqual(['getIdentity']); + expect(result).toBe('workspace-user@example.com'); + }); + + it('routes isCurrentUser() through the fake provider', () => { + const checked: Array = []; + const fake = { + getIdentity: () => 'user@example.com', + isCurrentUser: (author: string | undefined) => { + checked.push(author); + return author === 'user@example.com'; + }, + }; + + setIdentityProvider(fake); + + expect(isCurrentUser('user@example.com')).toBe(true); + expect(isCurrentUser('other@example.com')).toBe(false); + expect(checked).toEqual(['user@example.com', 'other@example.com']); + }); + + it('resetIdentityProvider restores the default (ConfigStore-backed) provider', () => { + const fake = { + getIdentity: () => 'should-not-appear', + isCurrentUser: () => false, + }; + + setIdentityProvider(fake); + resetIdentityProvider(); + + // After reset the default provider returns a non-empty tater name from ConfigStore, + // NOT the fake's sentinel value. + const result = getIdentity(); + expect(result).not.toBe('should-not-appear'); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/ui/utils/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'); + }); +}); From 6130badbbd4e5bcc90d09ce179d7dabbdc7e03af Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 16:24:55 -0700 Subject: [PATCH 37/46] =?UTF-8?q?fix(ui):=20apply=20Phase=207=20review=20f?= =?UTF-8?q?indings=20=E2=80=94=20version=20lockstep=20+=20seam=20consisten?= =?UTF-8?q?cy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump @plannotator/ui to 0.21.0 (lockstep with @plannotator/core + repo, per ADR 007) [was the 1 critical review finding] - useAnnotationDraft: route persistNow/dismissDraft save+remove through getDraftTransport() so all paths read the transport consistently (matches the load path; makes the single-global invariant explicit) - configStore.loadFromBackend: document it must be called BEFORE init() or server values get overwritten - packages/core/tsconfig: add explicit types:[] so the node-free invariant is first-class (verified: planted node:fs still fails TS2882) --- packages/core/tsconfig.json | 1 + packages/ui/config/configStore.ts | 2 ++ packages/ui/hooks/useAnnotationDraft.ts | 8 ++++---- packages/ui/package.json | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 7d06c95d9..008df21c7 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2022", "module": "ESNext", "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": [], "moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true, diff --git a/packages/ui/config/configStore.ts b/packages/ui/config/configStore.ts index fa49d983d..5c7bfd189 100644 --- a/packages/ui/config/configStore.ts +++ b/packages/ui/config/configStore.ts @@ -77,6 +77,8 @@ class ConfigStore { * Host installs a SYNCHRONOUS StorageBackend serving prefetched settings, then calls * this to route the initial load through that backend. Precedence after a host call: * server (init) > host backend (loadFromBackend) > cookie/default (constructor). + * Call this BEFORE init(serverConfig): init() always wins, so calling loadFromBackend() + * after init() would silently overwrite server-supplied settings. */ loadFromBackend(): void { for (const [name, def] of Object.entries(SETTINGS)) { diff --git a/packages/ui/hooks/useAnnotationDraft.ts b/packages/ui/hooks/useAnnotationDraft.ts index c563f6785..f2ac27810 100644 --- a/packages/ui/hooks/useAnnotationDraft.ts +++ b/packages/ui/hooks/useAnnotationDraft.ts @@ -409,7 +409,7 @@ export function useAnnotationDraft({ // explicitly threw away. const deletedGeneration = draftGenerationRef.current + 1; draftGenerationRef.current = deletedGeneration; - draftTransport.remove(deletedGeneration, { keepalive }).catch(() => {}); + getDraftTransport().remove(deletedGeneration, { keepalive }).catch(() => {}); return; } @@ -429,11 +429,11 @@ export function useAnnotationDraft({ // 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. - draftTransport.save(payload, { keepalive }).catch(() => { + 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) { - draftTransport.save(payload, { keepalive: false }).catch(() => {}); + getDraftTransport().save(payload, { keepalive: false }).catch(() => {}); } // Otherwise silent failure — draft is best-effort. }); @@ -516,7 +516,7 @@ export function useAnnotationDraft({ setDraftBanner(null); draftDataRef.current = null; - draftTransport.remove(deletedGeneration, { keepalive: false }).catch(() => {}); + getDraftTransport().remove(deletedGeneration, { keepalive: false }).catch(() => {}); }, []); return { draftBanner, restoreDraft, scheduleDraftSave, scheduleDraftSaveAfterSubmitFailure, getDraftGeneration, dismissDraft }; diff --git a/packages/ui/package.json b/packages/ui/package.json index e9b0f9d5a..5afc01467 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@plannotator/ui", - "version": "0.0.1", + "version": "0.21.0", "type": "module", "exports": { "./components/*": "./components/*.tsx", From 23df902db570bbea29655ee4c573117d5236e744 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 16:25:34 -0700 Subject: [PATCH 38/46] docs(adr): Phase 7 implementation plan (workflow-generated, durable artifact) --- .../document-ui-phase-7-plan-20260623.md | 679 ++++++++++++++++++ 1 file changed, 679 insertions(+) create mode 100644 adr/implementation/document-ui-phase-7-plan-20260623.md diff --git a/adr/implementation/document-ui-phase-7-plan-20260623.md b/adr/implementation/document-ui-phase-7-plan-20260623.md new file mode 100644 index 000000000..8045c98bc --- /dev/null +++ b/adr/implementation/document-ui-phase-7-plan-20260623.md @@ -0,0 +1,679 @@ +# Phase 7 — Implementation Plan: Carve `@plannotator/core` + Publish Prep + +Branch: `feat/pkg-document-ui`. Authoritative: ADR 007 + spec `publish-core-package-20260623-125551.md`. + +**THE LAW (ADR 004):** Plannotator stays byte-for-byte unchanged. The carve is `git mv` + one-line +re-export shims + type extraction. Single source of truth — NO copying, NO rewriting. `packages/editor`, +`packages/server`, `packages/review-editor`, and all `apps/*` source stays untouched except the ONE +`wideMode` importer in Step 3. + +**Global rules for every step:** +- Each step leaves the tree typecheck-green **for what it touched** and ends with **one local commit** (`git commit`, NO `git push`, NO publish, NO merge). +- Work on the current branch `feat/pkg-document-ui` (already a feature branch — do not branch again). +- Repo version is `0.21.0`. `@plannotator/core` ships lockstep at `0.21.0`; `ui` → `core` pinned EXACT. +- Use `git mv` for all moves so history follows. Never delete a working file until parity is human-confirmed. +- `export *` re-exports values AND types but NOT defaults; all 15 moved modules are named-export-only (confirmed). If a default is ever found, add `export { default } from '@plannotator/core/X';`. +- **Byte-for-byte type moves:** when extracting type declarations into `packages/core/*-types.ts`, copy the source bytes **verbatim** — preserve original indentation (the `workspace-status` cluster uses TAB indentation; do NOT reflow to spaces). A pure move must produce a pure move in the diff; gratuitous reformatting is forbidden because the parity gate (item 4) scrutinizes the diff. +- **Workspace registration before typecheck:** `@plannotator/core` is a BRAND-NEW workspace package. There is no `node_modules/@plannotator` symlink dir in this repo; Bun resolves workspaces through its lockfile catalog (verified: `bun pm ls` lists workspaces, `packages/server/tsconfig.json` has no `paths` map yet resolves `@plannotator/shared/*` purely via that catalog). `packages/shared/tsconfig.json` and `packages/ai/tsconfig.json` have `moduleResolution: bundler` and NO `paths` map, so they will resolve the new `@plannotator/core/*` bare specifiers ONLY after `bun install` registers core in the catalog. Therefore `bun install` is required after Step 1a (and re-run after Step 2c) before any `tsc` verification that touches the new specifiers. + +--- + +## STEP 1 — The carve: create `@plannotator/core`, move modules, extract types, shim `shared` (CRITICAL / opus) + +Goal: `packages/core` exists with the 15 pure modules + 5 extracted type files; `packages/shared` is rewired (15 one-line shims + 4 node-bound/types modules importing types back from core); `ai/types.ts` imports the `AIContext` family (including `AIContextMode`) back from core. End state: `core` typecheck (node-free) green AND `shared` typecheck green AND `ai` typecheck green AND Pi typecheck green. + +### 1a. Create `packages/core/package.json` + register the workspace +New file `packages/core/package.json` (exact): +```json +{ + "name": "@plannotator/core", + "version": "0.21.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" + } +} +``` +Constraints: NO `private`, NO `peerDependencies`, NO `@types/node`, NO `@types/bun`. + +**Then wire deps (1h below) and run `bun install`** so the workspace catalog registers `@plannotator/core`. This is load-bearing: without it, the Step 1k `tsc` on `shared`/`ai` cannot resolve the new `@plannotator/core/*` specifiers (no `paths` map on those packages). Run order within Step 1: create core files (1a–1g) → edit deps (1h) → `bun install` → wire typecheck (1i) → fix vendor (1j) → verify (1k). + +### 1b. Create `packages/core/tsconfig.json` (node-free — DOM-only lib, no node/bun types) +New file `packages/core/tsconfig.json` (exact): +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "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"] +} +``` +Critical: NO `"types": ["bun"]`, NO `"types": ["@types/node"]`, NO `"paths"`. This is the node-free invariant — a stray `node:*` import yields `TS2307`. + +### 1c. `git mv` the 15 pure modules `packages/shared/X.ts` → `packages/core/X.ts` +Exact list (each verified node-free; the only intra-set dep is `extract-code-paths → ./code-file`, which moves together so the relative import stays valid): +``` +code-file extract-code-paths agents agent-jobs compress crypto +external-annotation favicon feedback-templates goal-setup browser-paths +project agent-terminal open-in-apps source-save +``` +Do NOT move `source-save-node.ts` (node-bound — stays in shared). `project` here is the PURE `packages/shared/project.ts`, NOT `packages/server/project.ts`. + +### 1d. Create the 5 extracted type files in core (single source — definitions move here, byte-for-byte) + +`packages/core/config-types.ts` — move the pure type decls from `shared/config.ts:13-30`: +```ts +export type DefaultDiffType = 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all'; +export type DiffLineBgIntensity = 'subtle' | 'normal' | 'strong'; +// plus DiffOptions (config.ts:16-30) — self-contained, references the two above; move for the diff-option family +``` +(UI needs only `DefaultDiffType` + `DiffLineBgIntensity`; `DiffOptions` is moved for tidiness, self-contained.) Do NOT move `CCLabelConfig`, `PromptConfig`, `PromptRuntime`, etc. + +`packages/core/storage-types.ts` — move `ArchivedPlan` (shared/storage.ts:107-114): +```ts +export interface ArchivedPlan { + filename: string; title: string; date: string; timestamp: string; + status: "approved" | "denied" | "unknown"; size: number; +} +``` + +`packages/core/workspace-status-types.ts` — move the cluster (shared/workspace-status.ts:6-44): `WorkspaceFileStatus`, `WorkspaceFileChange`, `WorkspaceStatusPayload`, `GitRepositoryInfo`. (`WorkspaceFileStatus` is required transitively by `WorkspaceFileChange`.) Do NOT move `GitResult`, `WorkspaceStatusFlight` (file-private, node-adjacent). **Copy the declarations byte-for-byte — these use TAB indentation in the source; preserve the tabs, do NOT reflow to spaces.** + +`packages/core/types.ts` — move the `EditorAnnotation` interface (shared/types.ts:2-10, pure). This file exports ONLY `EditorAnnotation`. Do NOT re-export anything from `review-core`/`review-workspace` (they import `node:path`). + +`packages/core/ai-context.ts` — MOVE (not re-export) the AI context type family from `packages/ai/types.ts:14-89`. **This family INCLUDES `AIContextMode` (at `ai/types.ts:14`).** Move ALL six names: `AIContextMode`, `ParentSession`, `PlanContext`, `CodeReviewContext`, `AnnotateContext`, `AIContext`. All verified node-free. The literal first line moved is: +```ts +export type AIContextMode = "plan-review" | "code-review" | "annotate"; +``` +Do NOT do `export … from '@plannotator/ai'` — that would give core a dep on private `ai` and break the zero-dep CI gate. + +> **Why `AIContextMode` is mandatory here:** it is consumed inside the `ai` package itself — `ai/index.ts:68` re-exports it from `./types.ts`, and `ai/session-manager.ts:16,26,65` import and use it. If it is moved out of `ai/types.ts` but not re-exported back (see 1g), the `ai` package fails to typecheck (`TS2305 'no exported member AIContextMode'`), cascading to editor/server. UI does NOT import `AIContextMode` (verified zero usage), so no Step 2 change is needed for it. + +### 1e. Create `packages/core/index.ts` (barrel) +Re-export the public surface so `import … from '@plannotator/core'` works (notably `AIContext` for UI): +```ts +export * from './ai-context'; +export type { EditorAnnotation } from './types'; +// (re-export others as convenient; AIContext is the load-bearing one for ui/useAIChat) +``` +`export * from './ai-context'` re-exports `AIContextMode`, `AIContext`, `ParentSession`, `PlanContext`, `CodeReviewContext`, `AnnotateContext` from the barrel. + +### 1f. Replace the 15 moved `shared/X.ts` files with one-line shims +After `git mv`, recreate each `packages/shared/X.ts` containing exactly: +```ts +export * from '@plannotator/core/X'; +``` +(15 files: code-file, extract-code-paths, agents, agent-jobs, compress, crypto, external-annotation, favicon, feedback-templates, goal-setup, browser-paths, project, agent-terminal, open-in-apps, source-save.) `shared`'s `exports` map and `private:true` stay unchanged — every subpath still resolves to `./X.ts`. +Note: the intra-shared relative importers (`shared/resolve-file.ts → ./code-file`, `shared/storage.ts → ./project`, `shared/source-save-node.ts → ./source-save`) resolve to the shim and forward to core — NO edits needed to those three. + +### 1g. Rewire the node-bound shared modules + `ai/types.ts` to import types back from core +`packages/shared/config.ts` — replace the inline `DefaultDiffType`/`DiffLineBgIntensity`/`DiffOptions` decls with: +```ts +export type { DefaultDiffType, DiffLineBgIntensity, DiffOptions } from '@plannotator/core/config-types'; +``` +(keep all node impl/functions unchanged.) + +`packages/shared/storage.ts` — replace the inline `export interface ArchivedPlan {…}` with: +```ts +import type { ArchivedPlan } from '@plannotator/core/storage-types'; +export type { ArchivedPlan }; +``` +(internal functions keep referencing `ArchivedPlan`; node:fs impl unchanged.) + +`packages/shared/workspace-status.ts` — replace the inline cluster with: +```ts +import type { WorkspaceFileChange, WorkspaceStatusPayload, GitRepositoryInfo, WorkspaceFileStatus } from '@plannotator/core/workspace-status-types'; +export type { WorkspaceFileChange, WorkspaceStatusPayload, GitRepositoryInfo, WorkspaceFileStatus }; +``` +(node:child_process/fs impl unchanged.) + +`packages/shared/types.ts` — replace the inline `EditorAnnotation` interface with: +```ts +export type { EditorAnnotation } from '@plannotator/core/types'; +``` +(keep its existing `review-core`/`review-workspace` re-exports as-is.) + +`packages/ai/types.ts` — replace the inline AIContext family with import-back. **This line MUST include `AIContextMode`** (it was moved in 1d and is re-exported by `ai/index.ts:68`): +```ts +export type { AIContext, AIContextMode, PlanContext, CodeReviewContext, AnnotateContext, ParentSession } from '@plannotator/core/ai-context'; +``` +This keeps `ai/index.ts`'s existing re-export (which lists `AIContextMode` at line 68) resolving, keeps `session-manager.ts`'s `import type { ..., AIContextMode } from "./types.ts"` resolving, AND keeps `editor/App.tsx:95`'s `import type { AIContext } from '@plannotator/ai'` resolving — **`ai`, `editor`, and `server` stay byte-for-byte unchanged.** + +### 1h. package.json dep edits for `shared` and `ai` +- `packages/shared/package.json` deps: add `"@plannotator/core": "workspace:*"`. Keep `private:true`, keep the full `exports` map unchanged. +- `packages/ai/package.json` deps: add `"@plannotator/core": "workspace:*"`. Keep `private:true`. + +**After 1h: run `bun install`** (registers `@plannotator/core` in the Bun workspace catalog). This is the resolution mechanism for `shared`/`ai`'s new `@plannotator/core/*` imports — those packages have no `paths` map. Without this, Step 1k's `tsc -p packages/shared/tsconfig.json` / `packages/ai/tsconfig.json` cannot resolve the new bare specifiers. + +### 1i. Wire core into the root typecheck (node-free typecheck FIRST so a node leak fails fast) +Root `package.json` `typecheck` script (line 36) — insert core's typecheck before shared's: +``` +"typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json" +``` +(Leave the vendor.sh prefix in place; vendor.sh itself is fixed in 1j.) + +### 1j. Fix Pi `vendor.sh` (the ADR/spec "no change" claim is FALSE — confirmed) + +**Why it breaks (verified against `apps/pi-extension/vendor.sh`):** +1. The main loop (vendor.sh:10) copies file CONTENT verbatim from `packages/shared/$f.ts` for a flat list that INCLUDES 9 moved-pure modules (`code-file`, `agent-jobs`, `external-annotation`, `favicon`, `feedback-templates`, `project`, `agent-terminal`, `open-in-apps`, `source-save`) AND 3 node-bound modules (`config`, `storage`, `workspace-status`). After the carve, the 9 pure files are bare shims (`export * from '@plannotator/core/X'`) and the 3 node-bound files import `@plannotator/core/-types` — all unresolvable in Pi's flat `generated/` layout (no bundler resolution to packages, no `@plannotator/core` dep, `moduleResolution: bundler` with no `paths`/`baseUrl`). +2. The **ai loop** (vendor.sh:40) copies `index types provider session-manager endpoints context base-session` VERBATIM from `packages/ai/$f.ts` with NO sed rewrites. After 1g, `packages/ai/types.ts` contains `export type { … } from '@plannotator/core/ai-context'` — vendored verbatim into `generated/ai/types.ts`, where that bare specifier cannot resolve (`TS2307`). This works today ONLY because `ai/types.ts` currently has zero `@plannotator/*` imports. + +`workspace-status` IS imported by `apps/pi-extension/server/reference.ts` and `file-browser-watch.ts` (confirmed), so a silent break here is a real runtime/typecheck failure. + +**The exact restructured `vendor.sh` (write this as runnable shell — do not infer):** + +(a) In the main loop, split the 9 moved-pure modules out of the `packages/shared/` source and source them from `packages/core/` instead. Replace the single loop (vendor.sh:10-13) so the 9 moved modules read from core, the 3 node-bound modules read from shared **and** get a sed rewrite, and everything else stays as-is: + +```bash +# Modules that MOVED to @plannotator/core — vendor the real impl from core. +for f in feedback-templates project favicon code-file external-annotation agent-jobs agent-terminal source-save open-in-apps; do + src="../../packages/core/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/core/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" +done + +# Node-bound shared modules that now import types from @plannotator/core/*-types — +# vendor from shared, rewrite the bare core specifier to the flat relative path. +for f in config storage workspace-status; do + src="../../packages/shared/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" \ + | sed 's|from "@plannotator/core/\([^"]*\)-types"|from "./\1-types.js"|g' \ + > "generated/$f.ts" +done + +# Extracted type files those node-bound modules now depend on — vendor from core. +for f in config-types storage-types workspace-status-types; do + src="../../packages/core/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/core/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" +done + +# Everything else in the original flat list stays sourced from packages/shared. +for f in prompts review-core diff-paths cli-pagination jj-core vcs-core review-args draft pr-types pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common resolve-file annotate-reference-roots-node worktree worktree-pool html-to-markdown html-assets html-assets-node url-to-markdown tour annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff source-save-node; do + src="../../packages/shared/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" +done +``` +> The relative-import chains stay valid: `resolve-file → ./code-file`, `source-save-node → ./source-save`, `storage → ./project` all resolve to the flat `generated/.ts` files, which now hold the real core impl. Confirm the original line-10 list is fully partitioned across the four loops above with no module dropped (diff the old list against the union of the four new lists). + +(b) Extend the **ai loop** (vendor.sh:40-43) to vendor `ai-context` from core and sed-rewrite the `@plannotator/core/ai-context` specifier in `generated/ai/types.ts` to `./ai-context.js`: + +```bash +# 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" \ + | sed 's|from "@plannotator/core/ai-context"|from "./ai-context.js"|g' \ + > "generated/ai/$f.ts" +done +``` +> Only `generated/ai/types.ts` actually contains the `@plannotator/core/ai-context` specifier today, but applying the sed to all 7 ai files is harmless (no-op where absent) and future-proofs the vendor. + +### 1k. Verify (run at end of Step 1) +``` +bun install # MUST run first — registers @plannotator/core in the workspace catalog +tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json +# Node-free proof: temporarily add `import 'node:fs'` to a core/*.ts → tsc on core MUST fail TS2307 → remove it. +bash apps/pi-extension/vendor.sh && tsc --noEmit -p apps/pi-extension/tsconfig.json # MUST be green — confirms generated/ai/types.ts + generated/{config,storage,workspace-status}.ts resolve their rewritten relative specifiers +git diff --name-only # confined to packages/{core,shared,ai} + apps/pi-extension/vendor.sh + root package.json + bun.lock +``` + +### Commit +``` +feat(core): carve @plannotator/core — move pure modules, extract node-bound types, shim shared (Phase 7 step 1) +``` + +--- + +## STEP 2 — Re-point `@plannotator/ui` to `@plannotator/core` (CRITICAL / opus) + +Goal: every non-test `ui` import of `@plannotator/shared/*` and `@plannotator/ai` becomes `@plannotator/core/*`; ui `package.json` drops shared+ai, adds core EXACT. End: grep returns zero; ui typecheck green. + +### 2a. Re-point the 35 import sites (31 files) +Mechanical rule: `@plannotator/shared/X` → `@plannotator/core/X` (same subpath), with these EXACT remaps for the type-extraction cases: +- `@plannotator/shared/config` → `@plannotator/core/config-types` +- `@plannotator/shared/storage` → `@plannotator/core/storage-types` +- `@plannotator/shared/workspace-status` → `@plannotator/core/workspace-status-types` +- `@plannotator/shared/types` (the `EditorAnnotation` re-export at `ui/types.ts:209`) → `@plannotator/core/types` +- `import type { AIContext } from '@plannotator/ai'` (`ui/hooks/useAIChat.ts:2`) → `from '@plannotator/core'` + +Files + lines (from scope-rewire §1, authoritative): +- `ui/types.ts:209` EditorAnnotation → `@plannotator/core/types` +- `ui/types.ts:211-213` ExternalAnnotationEvent → `@plannotator/core/external-annotation` +- `ui/types.ts:215-221` AgentJob*/AgentCapabilit* → `@plannotator/core/agent-jobs` +- `ui/config/settings.ts:12` DiffLineBgIntensity → `@plannotator/core/config-types` +- `ui/utils/parser.ts:2` planDenyFeedback → `@plannotator/core/feedback-templates` +- `ui/utils/annotateAgentTerminal.ts:1` AgentTerminalAgent → `@plannotator/core/agent-terminal` +- `ui/utils/aiProvider.ts:10` AGENT_CONFIG/getAgentAIProviderTypes/Origin → `@plannotator/core/agents` +- `ui/utils/sharing.ts:12` compress/decompress → `@plannotator/core/compress` +- `ui/utils/sharing.ts:13` encrypt/decrypt → `@plannotator/core/crypto` +- `ui/components/InlineMarkdown.tsx:4` isCodeFilePath/… → `@plannotator/core/code-file` +- `ui/components/OpenInAppButton.tsx:5` OpenInKind → `@plannotator/core/open-in-apps` +- `ui/components/Settings.tsx:3` Origin → `@plannotator/core/agents` +- `ui/components/Settings.tsx:4` DiffLineBgIntensity → `@plannotator/core/config-types` +- `ui/components/DocBadges.tsx:16` hostnameOrFallback → `@plannotator/core/project` +- `ui/components/AISettingsTab.tsx:12` Origin → `@plannotator/core/agents` +- `ui/components/MenuVersionSection.tsx:4` Origin → `@plannotator/core/agents` +- `ui/components/DiffTypeSetupDialog.tsx:3` DefaultDiffType → `@plannotator/core/config-types` +- `ui/components/PlanHeaderMenu.tsx:14` Origin → `@plannotator/core/agents` +- `ui/components/AgentsTab.tsx:16` isTerminalStatus → `@plannotator/core/agent-jobs` +- `ui/components/PlanAIAnnouncementDialog.tsx:3` Origin → `@plannotator/core/agents` +- `ui/components/PlanAIAnnouncementDialog.tsx:4` AGENT_CONFIG/getAgentAIProviderTypes/getAgentName → `@plannotator/core/agents` (all three symbols on this line; `export * from '@plannotator/core/agents'` re-exports all) +- `ui/components/blocks/HtmlBlock.tsx:2` isCodeFilePath → `@plannotator/core/code-file` +- `ui/components/sidebar/FileBrowser.tsx:13` WorkspaceFileChange/WorkspaceStatusPayload → `@plannotator/core/workspace-status-types` +- `ui/components/sidebar/FileBrowser.tsx:14` normalizeBrowserPath → `@plannotator/core/browser-paths` +- `ui/components/sidebar/ArchiveBrowser.tsx:9` ArchivedPlan → `@plannotator/core/storage-types` +- `ui/components/goal-setup/GoalSetupSurface.tsx:13-20` GoalSetup* types → `@plannotator/core/goal-setup` +- `ui/components/settings/HooksTab.tsx:2` FAVICON_SVG → `@plannotator/core/favicon` +- `ui/hooks/useAgents.ts:6` Origin → `@plannotator/core/agents` +- `ui/hooks/useAnnotationDraft.ts:18` SourceSaveCapability → `@plannotator/core/source-save` +- `ui/hooks/useArchive.ts:9` ArchivedPlan → `@plannotator/core/storage-types` +- `ui/hooks/useLinkedDoc.ts:13` SourceSaveCapability → `@plannotator/core/source-save` +- `ui/hooks/useValidatedCodePaths.ts:2` extractCandidateCodePaths → `@plannotator/core/extract-code-paths` +- `ui/hooks/useFileBrowser.ts:12` WorkspaceStatusPayload → `@plannotator/core/workspace-status-types` +- `ui/hooks/pfm/useCodeFilePopout.ts:2` parseCodePath → `@plannotator/core/code-file` +- `ui/hooks/useAIChat.ts:2` AIContext → `@plannotator/core` (bare package root → resolves via `exports['.']` → `index.ts`) + +### 2b. `packages/ui/tsconfig.json` paths +Add BOTH a `@plannotator/core/*` subpath mapping AND a bare `@plannotator/core` mapping alongside the existing shared one (line 21) so tsc resolves core during the transition. **Two entries are required:** the trailing-`/*` glob does NOT match the extensionless bare specifier `@plannotator/core` (used by `useAIChat.ts:2`): +```json +"@plannotator/core": ["../core/index.ts"], +"@plannotator/core/*": ["../core/*"] +``` +(Keep `"@plannotator/shared/*": ["../shared/*"]` — see 2d note; surviving test-file imports still use it.) The bare-specifier path map is authoritative for ui's tsc; `bun install` (already run in Step 1) is the belt-and-suspenders mechanism that also makes the bare specifier resolve via core's `exports['.']`. State both: **path map is authoritative for ui tsc; workspace catalog backs it.** + +### 2c. `packages/ui/package.json` dep edits + re-register +- REMOVE `"@plannotator/ai": "workspace:*"` +- REMOVE `"@plannotator/shared": "workspace:*"` +- ADD `"@plannotator/core": "workspace:*"` (workspace alias in source; resolves to exact `0.21.0` at pack time — ADR mandates EXACT pinning, enforce at pack in Step 5/final gate) + +**After 2c: run `bun install`** so the dependency-graph change (ui → core) is reflected in `bun.lock` before the Step 2d typecheck. + +### 2d. Verify (run at end of Step 2) +``` +bun install +grep -rn '@plannotator/shared\|@plannotator/ai' packages/ui --include='*.ts' --include='*.tsx' | grep -v '\.test\.' # MUST be empty +tsc --noEmit -p packages/ui/tsconfig.json # green +``` +> **Note (intentional, out of scope for the grep-zero gate):** exactly two ui *test* files still import `@plannotator/shared` (`annotateAgentTerminal.test.ts`, `FileBrowser.test.ts`). These are deliberately retained — the grep-zero assertion scopes to non-test files via `grep -v '\.test\.'`, and `@plannotator/shared/*` stays in the ui tsconfig `paths` to keep them resolving. A reviewer should NOT flag these. + +### Commit +``` +feat(ui): depend only on @plannotator/core — re-point all shared/ai imports (Phase 7 step 2) +``` + +--- + +## STEP 3 — Move `wideMode.ts` into `packages/ui/utils` (MECHANICAL / sonnet) + +Goal: relocate the pure `wideMode` helper from `editor` to `ui/utils`; fix the 2 importers. + +### 3a. git mv +``` +git mv packages/editor/wideMode.ts packages/ui/utils/wideMode.ts +git mv packages/editor/wideMode.test.ts packages/ui/utils/wideMode.test.ts +``` + +### 3b. Fix importer 1 — `packages/editor/App.tsx:109` +FROM: +```ts +import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; +``` +TO: +```ts +import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from '@plannotator/ui/utils/wideMode'; +``` +(This is the ONE allowed edit to `packages/editor` source — a single import-specifier change, no behavior change. `wideMode.ts` itself only imports from `@plannotator/ui/types` + `@plannotator/ui/hooks/useSidebar`, so it lands cleanly in ui.) + +### 3c. Fix importer 2 — the moved test +`packages/ui/utils/wideMode.test.ts` imports `./wideMode` (relative) — UNCHANGED after the move (it's now a sibling in `ui/utils`). Verify the line still reads `from './wideMode';`. + +### 3d. exports map +`@plannotator/ui/utils/wideMode` already resolves via the existing `"./utils/*": "./utils/*.ts"` glob in `packages/ui/package.json` — NO new exports entry needed. (Confirm the glob is present at line 14.) + +### 3e. Verify (run at end of Step 3) +``` +grep -rn 'wideMode' packages --include='*.ts' --include='*.tsx' | grep -v 'packages/ui/utils/wideMode' # only editor/App.tsx (the new specifier) shows +tsc --noEmit -p packages/ui/tsconfig.json # green (wideMode + its importer resolve) +bun test packages/ui/utils/wideMode.test.ts +``` + +### Commit +``` +refactor(ui): relocate wideMode helper to @plannotator/ui/utils (Phase 7 step 3) +``` + +--- + +## STEP 4 — Settings provider `loadFromBackend` + `configurePlannotatorUI` front door (CRITICAL / opus) + +Goal: complete the half-built settings provider (initial-load routes through installed backend) and add the single typed configuration front door over the 9 global setters. Both ADDITIVE — Plannotator never calls either, so byte-for-byte parity holds. + +### 4a. Add `loadFromBackend()` to `ConfigStore` — `packages/ui/config/configStore.ts` +Insert this method into the `ConfigStore` class (after the constructor, before `init`): +```ts + /** + * 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). + */ + loadFromBackend(): void { + for (const [name, def] of Object.entries(SETTINGS)) { + const fromBackend = def.fromCookie(); + if (fromBackend !== undefined) { + this.values.set(name, fromBackend); + } + } + this.notify(); + } +``` +Contract: use `!== undefined` (NOT `??`) so a missing key keeps the constructor default; do NOT call `def.toCookie` (no re-write). Reuses the existing per-setting `fromCookie()` reader, which under a host backend reads the host's prefetched store. + +### 4b. Add `resetServerSync()` to `ConfigStore` (needed by the Step 6 seam test; keeps the seam family symmetric) +Next to `setServerSync` (configStore.ts:122): +```ts + resetServerSync(): void { this.serverSync = defaultServerSync; } +``` +(`defaultServerSync` already exists at configStore.ts:36-42.) + +### 4c. Create `packages/ui/configure.ts` — `configurePlannotatorUI` +New file. Imports the 9 setters from their intra-`ui` relative modules and fans out (every field optional, only provided seams applied): +```ts +import { setImageSrcResolver, type ImageSrcResolver } from './components/ImageThumbnail'; +import { setDocPreviewFetcher, type DocPreviewFetcher } from './components/InlineMarkdown'; +import { setStorageBackend, type StorageBackend } from './utils/storage'; +import { setIdentityProvider, type IdentityProvider } from './utils/identity'; +import { setFileTreeBackend, type FileTreeBackend } from './hooks/useFileBrowser'; +import { setDraftTransport, type DraftTransport } from './hooks/useAnnotationDraft'; +import { setExternalAnnotationTransport, type ExternalAnnotationTransport } from './hooks/useExternalAnnotations'; +import { setAITransport, type AITransport } from './hooks/useAIChat'; +import { configStore } from './config'; + +type ExternalAnnotationBase = { id: string; source?: string }; +type ServerSyncFn = (payload: Record) => void; + +export interface PlannotatorUIConfig { + imageSrcResolver?: ImageSrcResolver; + storageBackend?: StorageBackend; + docPreviewFetcher?: DocPreviewFetcher; + fileTreeBackend?: FileTreeBackend; + identityProvider?: IdentityProvider; + draftTransport?: DraftTransport; + /** + * Base-constraint transport. If your annotation type extends the base + * constraint ({ id: string; source?: string }) with extra fields, call + * setExternalAnnotationTransport() directly for full type safety — + * this front-door field intentionally pins the base constraint for ergonomics. + */ + externalAnnotationTransport?: ExternalAnnotationTransport; + aiTransport?: AITransport; + serverSync?: ServerSyncFn; + /** Re-hydrate settings from the installed (SYNCHRONOUS) storageBackend after install. */ + loadSettingsFromBackend?: boolean; +} + +export function configurePlannotatorUI(config: PlannotatorUIConfig): void { + if (config.imageSrcResolver) setImageSrcResolver(config.imageSrcResolver); + if (config.storageBackend) setStorageBackend(config.storageBackend); + if (config.docPreviewFetcher) setDocPreviewFetcher(config.docPreviewFetcher); + if (config.fileTreeBackend) setFileTreeBackend(config.fileTreeBackend); + if (config.identityProvider) setIdentityProvider(config.identityProvider); + if (config.draftTransport) setDraftTransport(config.draftTransport); + if (config.externalAnnotationTransport) setExternalAnnotationTransport(config.externalAnnotationTransport); + if (config.aiTransport) setAITransport(config.aiTransport); + if (config.serverSync) configStore.setServerSync(config.serverSync); + // Re-hydrate AFTER storageBackend is installed (load-bearing order — gated last). + if (config.loadSettingsFromBackend) configStore.loadFromBackend(); +} +``` +Notes: inline `ServerSyncFn` (configStore's type is module-local — do NOT widen configStore's surface). The external-annotation generic is pinned to the base constraint `{ id: string; source?: string }` (the hook's default transport is ``, contract-compatible); the doc comment above the field tells consumers with extended annotation types to call `setExternalAnnotationTransport()` directly. The render-time prop seams (vscode-diff, save-to-notes, obsidian-detect, version fetchers, editor `mode`, code-path toggle, `ScrollViewportProvider`) are intentionally NOT here — they're passed where the host renders those components. + +### 4d. ui exports + files +`packages/ui/package.json`: +- exports: add `"./configure": "./configure.ts"` (alongside `./config`, `./types`). +- files: add `"configure.ts"` (sits at package root like `types.ts`). + +### 4e. Verify (run at end of Step 4) +``` +tsc --noEmit -p packages/ui/tsconfig.json # green +grep -n 'loadFromBackend\|resetServerSync' packages/ui/config/configStore.ts +grep -n 'configurePlannotatorUI' packages/ui/configure.ts +``` + +### Commit +``` +feat(ui): add loadFromBackend settings rehydration + configurePlannotatorUI front door (Phase 7 step 4) +``` + +--- + +## STEP 5 — Precompiled CSS build + madge circular-dep tooling (MECHANICAL / sonnet) + +Goal: ship a required precompiled `@plannotator/ui/styles.css` (CSS-only Vite build); add `madge` + a circular-dependency script. (Core's node-free typecheck wiring already landed in Step 1i — re-verify here.) + +### 5a. CSS entry — `packages/ui/styles-entry.css` (new) +```css +@import "@fontsource-variable/inter"; +@import "@fontsource-variable/geist-mono"; +@import "tailwindcss"; + +@plugin "tailwindcss-animate"; + +@source "./components/**/*.tsx"; +@source "./hooks/**/*.ts"; +@source "./utils/**/*.ts"; + +@import "./theme.css"; +``` +(`@source` globs are relative to this file at `packages/ui/`; they run ONCE at build time on source, baking the utility classes into the output — that's the whole point vs. the fragile consumer-side `@source` into `node_modules`.) Does NOT include `@plannotator/webtui/styles.css` (agent-terminal) or dockview CSS (review-editor) — those are runtime-specific, not exported UI. +> **`print.css` is covered (verified):** `packages/ui/theme.css:55` already does `@import "./print.css";`, and `styles-entry.css` imports `./theme.css`, so the precompiled bundle DOES include print styles transitively. No separate `@import "./print.css"` is needed here. + +### 5b. Vite CSS-only config — `packages/ui/vite.css.config.ts` (new) +```ts +import path from 'path'; +import { defineConfig } from 'vite'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [tailwindcss()], + resolve: { alias: { '@plannotator/ui': path.resolve(__dirname, '.') } }, + build: { + lib: { entry: path.resolve(__dirname, 'styles-entry.css'), formats: ['es'], fileName: () => 'styles.js' }, + outDir: '.', + cssCodeSplit: false, + rollupOptions: { output: { assetFileNames: 'styles.css' } }, + emptyOutDir: false, + }, +}); +``` + +### 5c. `packages/ui/package.json` — CSS build wiring +- scripts: add `"build:css": "vite build --config vite.css.config.ts && rm -f styles.js"` +- scripts: add `"prepublishOnly": "bun run build:css"` — mirrors `apps/pi-extension/package.json`'s `prepublishOnly` pattern. This fires automatically before `bun pm pack` / `npm publish`, guaranteeing `styles.css` is fresh in the tarball even though it is NOT committed. Without it, a publish would ship a tarball missing the (listed-in-`files`) `styles.css` — a silent consumer break. +- exports: add `"./styles.css": "./styles.css"` +- files: add `"styles.css"` +- devDependencies: add `"@tailwindcss/vite": "^4.1.18"` and `"vite": "^6.2.0"` (CSS-only build needs only these two; no react plugin). +- Add a `.gitignore` line (or repo-root ignore) for `packages/ui/styles.js`. **Do NOT commit `styles.css`** — it's a generated artifact produced by `prepublishOnly` at pack/publish time (avoids stale diffs). Also add `packages/ui/styles.css` to `.gitignore`. + +### 5d. Root scripts — `build:ui-css` +Root `package.json` scripts: add `"build:ui-css": "bun run --cwd packages/ui build:css"`. + +### 5e. madge circular-dep tooling +- Root `package.json` devDependencies: add `"madge": "^8.0.0"` (`bun add -d madge` at repo root). +- Root `.madgerc` (new) — TS support: + ```json + { "extensions": ["ts", "tsx"], "fileExtensions": ["ts", "tsx"] } + ``` +- Root `package.json` scripts: add + ```json + "check:cycles": "madge --circular --extensions ts,tsx --ts-config packages/core/tsconfig.json packages/core && madge --circular --extensions ts,tsx --ts-config packages/ui/tsconfig.json packages/ui" + ``` + (Scoped to the two published packages — the strict invariant. `--circular` reports cycles only, not unresolved imports; the `--ts-config` for ui carries the `@plannotator/core/*` + bare-`@plannotator/core` path maps added in Step 2b so madge resolves the aliases. The two surviving `@plannotator/shared` references in ui *.test.ts files are resolved by the retained shared path map and do not affect cycle detection.) + +### 5f. Re-confirm core node-free typecheck wiring (from Step 1i) +Ensure `tsc --noEmit -p packages/core/tsconfig.json` is in the root `typecheck` script (added in 1i). Sanity: a planted `import 'node:fs'` in a core file fails `TS2307`. + +### 5g. Verify (run at end of Step 5) +``` +bun install # picks up madge + vite/@tailwindcss/vite devDeps +bun run build:ui-css # emits packages/ui/styles.css, removes styles.js +test -s packages/ui/styles.css && echo "styles.css non-empty OK" +bun run check:cycles # exits 0 (no cycles in core/ui) +tsc --noEmit -p packages/core/tsconfig.json # node-free green +``` + +### Commit +``` +build(ui): precompiled styles.css CSS build + madge circular-dep check (Phase 7 step 5) +``` +(Commit the configs/scripts/devDeps + `prepublishOnly`; do NOT commit the generated `styles.css`/`styles.js`.) + +--- + +## STEP 6 — Per-seam override tests + a `configurePlannotatorUI` routing test (MECHANICAL / sonnet) + +Goal: one override test per seam (`setX(fake)` → drive → assert → `resetX()`), making the `reset*()` functions live and pinning the subtle contracts; plus one test that `configurePlannotatorUI({...})` routes to each setter. + +### 6a. Verify (DO NOT re-edit) the override-path contracts before writing tests +The two override-path fixes flagged in earlier interrogation passes are **ALREADY landed on this branch** (verified). Step 6a is VERIFICATION-ONLY — do NOT re-apply or "fix" working code (re-editing risks an unintended Plannotator behavior change, a LAW violation). + +1. **Split-transport (already fixed):** `packages/ui/hooks/useExternalAnnotations.ts:134` captures `transportRef = useRef(externalAnnotationTransport …)`; the subscribe/poll effect reads `transportRef.current` (line 145) AND every CRUD callback reads `transportRef.current` (`.remove` line 232, `.clear` line 244, `.update` line 253). Reads and writes already use the same backend instance. **Confirm via grep**: + ``` + grep -n 'transportRef.current' packages/ui/hooks/useExternalAnnotations.ts # expect lines 145, 232, 244, 253 + ``` + If (and only if) these are absent, apply the capture-once pattern; otherwise proceed. +2. **Ref reset on effect re-run (already fixed):** `fallbackRef.current = false` (line 142) and `receivedSnapshotRef.current = false` (line 143) are already reset at the TOP of the effect, so an `enabled` toggle `false→true` re-attempts SSE. **Confirm via grep**: + ``` + grep -n 'fallbackRef.current = false\|receivedSnapshotRef.current = false' packages/ui/hooks/useExternalAnnotations.ts # expect lines 142, 143 + ``` +3. **`useFileBrowser` audit (no change expected):** `useFileBrowser.ts` reads the module global `fileTreeBackend` LIVE (lines 211/316/383) rather than capturing a ref. This is a DIFFERENT but acceptable pattern (no mount-time capture, so no read/write split to fix). Confirm no change is needed; do NOT introduce a ref here. + +Then proceed straight to the seam tests in 6b/6c — they pin the already-correct behavior. + +### 6b. Per-seam override tests (10 files, `.seam.test.ts(x)` naming, colocated) +| Seam | Test file | Assert | +|------|-----------|--------| +| `setImageSrcResolver` / `resetImageSrcResolver` | `packages/ui/components/ImageThumbnail.seam.test.tsx` | render `` → fake resolver called with `"/foo/img.png"` | +| `setStorageBackend` / `resetStorageBackend` | `packages/ui/utils/storage.seam.test.ts` | `setItem`/`getItem` → fake backend's read/write called (not `document.cookie`) | +| `setDocPreviewFetcher` / `resetDocPreviewFetcher` | `packages/ui/components/InlineMarkdown.seam.test.tsx` | trigger doc preview → fake fetcher called with expected path | +| `setFileTreeBackend` / `resetFileTreeBackend` | `packages/ui/hooks/useFileBrowser.seam.test.tsx` | mount `useFileBrowser` → `fetchTree()` → `fake.loadTree` invoked with expected dirPath | +| `setIdentityProvider` / `resetIdentityProvider` | `packages/ui/utils/identity.seam.test.ts` | `getIdentity()` → fake provider invoked | +| `setDraftTransport` / `resetDraftTransport` | `packages/ui/hooks/useAnnotationDraft.seam.test.ts` | `fake.load()` on mount; `fake.save()` on scheduled save | +| `setExternalAnnotationTransport` / `resetExternalAnnotationTransport` | `packages/ui/hooks/useExternalAnnotations.seam.test.ts` | mount → `fake.subscribe` called; delete → `fake.remove` on SAME transport (pins the already-landed split-transport fix) | +| `setAITransport` / `resetAITransport` | `packages/ui/hooks/useAIChat.seam.test.ts` | mount `useAIChat` + send → `fake` session/query called | +| `configStore.setServerSync` / `resetServerSync` | `packages/ui/config/configStore.seam.test.ts` | `configStore.set('', …)` → fake sync fn called with expected payload | +| `loadFromBackend` | `packages/ui/config/configStore.seam.test.ts` (2nd describe) | `setStorageBackend(prefetched)` → `loadFromBackend()` → `configStore.get(key)` returns prefetched value | + +Pattern (template): `afterEach(() => resetXTransport())`; in the test, `setXTransport(fake)`, drive (mount hook harness via React test utils, or call the utility directly), assert recorded calls. Files auto-discovered by `bun test` — no registration. + +### 6c. `configurePlannotatorUI` routing test — `packages/ui/configure.test.ts` (new) +Call `configurePlannotatorUI({ imageSrcResolver, storageBackend, docPreviewFetcher, fileTreeBackend, identityProvider, draftTransport, externalAnnotationTransport, aiTransport, serverSync, loadSettingsFromBackend: true })` with fakes/spies, then assert each underlying setter received its fake (and that `loadFromBackend` ran after `setStorageBackend`). Reset every seam in `afterEach`. + +### 6d. Verify (run at end of Step 6) +``` +bun test packages/ui # all ui tests incl. new .seam.test + configure.test green +tsc --noEmit -p packages/ui/tsconfig.json +``` + +### Commit +``` +test(ui): per-seam override tests + configure routing test (Phase 7 step 6) +``` + +--- + +## FINAL PARITY GATE (run after Step 6, before any publish/push — DO NOT push or publish) + +Run from repo root. ALL must pass; investigate any failure before proceeding. + +1. **Full typecheck (incl. core node-free + Pi vendor):** + ``` + bun install # ensure catalog is current (core registered, ui→core) + bun run typecheck + ``` + Expect green for core, shared, ai, server, ui, pi-extension. Confirm a planted `import 'node:fs'` in a `packages/core/*.ts` fails `TS2307` (node-free invariant), then remove it. + +2. **Test suite — delta vs. main must be ADDITIONS only:** + ``` + bun test + ``` + Expect the Phase-0 baseline pass count PLUS the new Step-6 seam/configure tests — zero regressions, zero failures. The delta should be exactly the new `.seam.test`/`configure.test` files plus the moved `wideMode.test` file. No pre-existing test changed behavior. + +3. **madge clean (no circular deps in published packages):** + ``` + bun run check:cycles + ``` + Exit 0. + +4. **`git diff` confined to expected packages:** + ``` + git diff --name-only main...HEAD + ``` + Must be limited to: `packages/core/**`, `packages/shared/**`, `packages/ai/**`, `packages/ui/**`, the single `packages/editor/App.tsx` import line + the moved `wideMode` files, `apps/pi-extension/vendor.sh`, root `package.json` / `.madgerc` / `.gitignore` / `bun.lock`, and (if added) `.github/workflows/*` CI. NOTHING in `packages/server`, `packages/review-editor`, `apps/hook`, `apps/opencode-plugin`, or any other Plannotator app source. + +5. **ui depends ONLY on core internally (non-test):** + ``` + grep -rn '@plannotator/shared\|@plannotator/ai' packages/ui --include='*.ts' --include='*.tsx' | grep -v '\.test\.' + ``` + Empty. (The two surviving `@plannotator/shared` imports in ui *.test.ts files are intentional — see Step 2d note — and are excluded by `grep -v '\.test\.'`.) + +6. **Apps build green + functional/visual parity (the REAL gate):** + ``` + bun run --cwd apps/review build && bun run build:hook && bun run build:opencode + bun run build:pi # Pi vendors from core now + ``` + All builds MUST succeed. **Parity is confirmed by a human running the plan review and code review UIs in the browser** (ADR 004: human browser verification is the real gate). The shipped bundle should be **functionally identical** to the Phase-0 baseline — the carve is move + re-export only, no runtime logic change. + + **Bundle-hash guidance (NOT a hard gate):** compare shipped bundle hashes against the Phase-0 baseline as a proxy signal, but do NOT treat any hash delta as an automatic STOP. The carve changes the import-resolution graph (e.g. `@plannotator/ui` now resolves `compress` directly from `core/compress.ts` instead of through the `shared/compress.ts` shim; `editor/App.tsx` now imports `wideMode` from `@plannotator/ui/utils/wideMode`). A bundler may emit different hashes purely from changed module ordering, import-path string literals, or source-map metadata while the executed JS logic is byte-identical. + - **Acceptable (proceed):** hash differs only in source-map metadata or import-path string literals, with no change to the JS logic bytes — confirm by diffing the de-minified/normalized bundle output. + - **STOP and investigate:** any difference in the actual JS logic bytes, OR any visible/functional difference in the browser. That is a regression and must be root-caused before proceeding. + +7. **CSS artifact builds:** + ``` + bun run build:ui-css && test -s packages/ui/styles.css + ``` + +**Publish/registry steps are OUT OF SCOPE for these 6 steps** — branch-validation (`bun pm pack` each, inspect tarball, `npm publish --dry-run`), the `release.yml` publish job, and the EXACT-pin substitution of `ui → core@0.21.0` happen only after a human confirms parity in the browser and gives explicit go (ADR 007 §5, THE LAW). Do NOT push these commits. From 2f673b1b64763a03a4ce4f266411648d42a936b9 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 18:37:35 -0700 Subject: [PATCH 39/46] fix(ui): reconcile #948 with the draft-transport seam + lockstep 0.21.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto origin/main (picks up #948 draft-deletion fix, the 0.21.1 bump, and the #949/#950 editor fix). The rebase auto-merged #948's code-draft logic (hasHadAnnotationsRef, empty-state tombstone, clearTimeout in restore/dismiss) with the Phase-5 transport refactor cleanly — except the empty-state tombstone delete was left as a raw fetch('/api/draft', DELETE). Route it through getDraftTransport().remove() so a host backend tombstones its own stored draft on clear (the #948 guarantee, for hosts). Plannotator unchanged (default transport hits the same endpoint). Bump @plannotator/core + @plannotator/ui 0.21.0 -> 0.21.1 to match main's version (lockstep per ADR 007). Verified: typecheck clean, madge no-cycles, plain suite 1637 pass / 0 fail, #948 draft-clear test 3/0. (The 45 DOM_TESTS failures are the known server/network integration tests that need a real OS env — same set on main, not regressions.) --- packages/core/package.json | 2 +- packages/ui/hooks/useCodeAnnotationDraft.ts | 7 +++---- packages/ui/package.json | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index bc5331d5e..e729364a8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@plannotator/core", - "version": "0.21.0", + "version": "0.21.1", "type": "module", "exports": { "./agents": "./agents.ts", diff --git a/packages/ui/hooks/useCodeAnnotationDraft.ts b/packages/ui/hooks/useCodeAnnotationDraft.ts index a4eeb17ff..6a01ec615 100644 --- a/packages/ui/hooks/useCodeAnnotationDraft.ts +++ b/packages/ui/hooks/useCodeAnnotationDraft.ts @@ -131,10 +131,9 @@ export function useCodeAnnotationDraft({ if (isEmpty) { // The user cleared everything (#948). Delete the draft with a generation // tombstone so it can't resurface on refresh and a late save can't revive - // it. Mirrors useAnnotationDraft.persistNow. - fetch(`/api/draft?generation=${draftGeneration}`, { method: 'DELETE' }).catch(() => { - // Silent failure - }); + // it. Mirrors useAnnotationDraft.persistNow — routed through the draft + // transport seam so a host backend tombstones its own stored draft too. + getDraftTransport().remove(draftGeneration, { keepalive: false }).catch(() => {}); return; } diff --git a/packages/ui/package.json b/packages/ui/package.json index 5afc01467..e9b658830 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@plannotator/ui", - "version": "0.21.0", + "version": "0.21.1", "type": "module", "exports": { "./components/*": "./components/*.tsx", From c7cc0738e4e04507828121e96c0408337a61794a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 18:44:50 -0700 Subject: [PATCH 40/46] =?UTF-8?q?fix(ui):=20address=20review=20nits=20?= =?UTF-8?q?=E2=80=94=20host-path=20robustness=20+=20cleanups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PlanDiffViewer: wrap onOpenVscodeDiff in try/finally so a host opener that throws can't wedge the VS Code button in a permanent loading state (default unaffected) - useExternalAnnotations: (re-)capture the transport inside the effect on enable so a host that installs a transport before enabling annotations is honored, not the stale default — keeps the split-transport fix (effect + CRUD share one ref) - configure.ts: import ServerSyncFn from configStore instead of duplicating the type - repoint the 2 remaining @plannotator/shared test imports to @plannotator/core - AGENTS.md/CLAUDE.md: document the new packages/core package All host-path only — Plannotator behavior unchanged. typecheck clean, no cycles, full suite green. Skipped (not simple/over-engineering): usePlanDiff prop->module-level (design change), Obsidian late-bind, getSnapshot guard (inert), transport (variance). --- AGENTS.md | 3 ++- .../ui/components/plan-diff/PlanDiffViewer.tsx | 15 +++++++++++---- .../ui/components/sidebar/FileBrowser.test.ts | 2 +- packages/ui/config/configStore.ts | 2 +- packages/ui/configure.ts | 2 +- packages/ui/hooks/useExternalAnnotations.ts | 10 ++++++---- packages/ui/utils/annotateAgentTerminal.test.ts | 2 +- 7 files changed, 23 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c087d3ca7..7771494db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,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) diff --git a/packages/ui/components/plan-diff/PlanDiffViewer.tsx b/packages/ui/components/plan-diff/PlanDiffViewer.tsx index e40558e41..ae456297b 100644 --- a/packages/ui/components/plan-diff/PlanDiffViewer.tsx +++ b/packages/ui/components/plan-diff/PlanDiffViewer.tsx @@ -82,11 +82,18 @@ export const PlanDiffViewer: React.FC = ({ if (!canOpenVscodeDiff || baseVersion == null) return; setVscodeDiffLoading(true); setVscodeDiffError(null); - const result = await (onOpenVscodeDiff ?? defaultOpenVscodeDiff)(baseVersion); - if (result.error) { - setVscodeDiffError(result.error); + try { + const result = await (onOpenVscodeDiff ?? defaultOpenVscodeDiff)(baseVersion); + if (result.error) { + setVscodeDiffError(result.error); + } + } catch { + // 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); } - setVscodeDiffLoading(false); }; return ( 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/config/configStore.ts b/packages/ui/config/configStore.ts index 5c7bfd189..655c6f828 100644 --- a/packages/ui/config/configStore.ts +++ b/packages/ui/config/configStore.ts @@ -30,7 +30,7 @@ function deepMerge(target: Record, source: Record) => void; +export type ServerSyncFn = (payload: Record) => void; /** Default = today's inline POST /api/config (best-effort). */ const defaultServerSync: ServerSyncFn = (payload) => { diff --git a/packages/ui/configure.ts b/packages/ui/configure.ts index 4d4ad3c1a..0f786e689 100644 --- a/packages/ui/configure.ts +++ b/packages/ui/configure.ts @@ -7,9 +7,9 @@ import { setDraftTransport, type DraftTransport } from './hooks/useAnnotationDra import { setExternalAnnotationTransport, type ExternalAnnotationTransport } from './hooks/useExternalAnnotations'; import { setAITransport, type AITransport } from './hooks/useAIChat'; import { configStore } from './config'; +import type { ServerSyncFn } from './config/configStore'; type ExternalAnnotationBase = { id: string; source?: string }; -type ServerSyncFn = (payload: Record) => void; export interface PlannotatorUIConfig { imageSrcResolver?: ImageSrcResolver; diff --git a/packages/ui/hooks/useExternalAnnotations.ts b/packages/ui/hooks/useExternalAnnotations.ts index da750c7b7..1b54d90a2 100644 --- a/packages/ui/hooks/useExternalAnnotations.ts +++ b/packages/ui/hooks/useExternalAnnotations.ts @@ -127,10 +127,10 @@ export function useExternalAnnotations | null>(null); const receivedSnapshotRef = useRef(false); - // Capture the transport once so subscribe/poll and CRUD always use the same - // backend instance (contract: "set once at startup"). Reading the live module - // global in CRUD while the effect captured at mount would split reads and - // writes across two backends if a host swapped the transport after mount. + // 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(() => { @@ -142,6 +142,8 @@ export function useExternalAnnotations; const transport = transportRef.current; // --- Reducer (applies snapshot|add|remove|clear|update), verbatim --- 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[] = [ From de00839dfa0bc264be038a67ed9b77e1565b5fe3 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 18:57:16 -0700 Subject: [PATCH 41/46] docs: collapse 29 ADR process docs into one packages/ui/README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch had accumulated ~6,200 lines of ADR scaffolding (6 decisions, 7 specs, 10 research spikes/synthesis, 6 worklogs/roadmaps/plans) for this one effort. Replace all of it with a single concise README that ships with the published package: what @plannotator/ui + @plannotator/core are, why they exist (commercial reuse), how the host-override seams work (configurePlannotatorUI), how a consumer installs/builds, and the one rule (don't reimplement from scratch — add a seam). Repoint the CLAUDE.md banner at the README. No code references the deleted docs; main's pre-existing adr/ docs untouched. --- AGENTS.md | 2 +- ...ral-document-ui-package-20260620-083633.md | 133 -- ...ument-ui-parity-cutover-20260621-122015.md | 89 -- ...blished-building-blocks-20260622-180637.md | 82 -- ...mments-host-overridable-20260623-085309.md | 52 - ...extras-host-overridable-20260623-102104.md | 42 - ...ore-package-and-publish-20260623-140537.md | 66 - ...nt-ui-extraction-intent-20260620-085249.md | 287 ----- ...document-ui-extraction-roadmap-20260622.md | 93 -- .../document-ui-parity-checklist-20260622.md | 92 -- ...i-parity-cutover-intent-20260621-122245.md | 268 ----- .../document-ui-phase-0-1-worklog-20260622.md | 171 --- .../document-ui-phase-7-plan-20260623.md | 679 ----------- ...ment-ui-comments-system-20260623-084806.md | 73 -- ...urrent-state-and-parity-20260621-115603.md | 216 ---- ...-ui-extraction-boundary-20260620-082002.md | 744 ------------ ...cument-ui-extras-system-20260623-100827.md | 56 - ...ment-ui-reuse-inventory-20260622-183000.md | 121 -- ...KE-publish-core-package-20260623-125551.md | 54 - ...is-document-ui-comments-20260623-084806.md | 52 - ...-document-ui-extraction-20260620-082343.md | 266 ---- ...esis-document-ui-extras-20260623-100827.md | 47 - ...is-publish-core-package-20260623-125551.md | 44 - ...cument-ui-comments-seam-20260623-084806.md | 85 -- .../document-ui-extraction-20260620-083307.md | 1066 ----------------- ...xtraction-plan-verified-20260622-184500.md | 158 --- ...document-ui-extras-seam-20260623-100827.md | 53 - ...mpleteness-review-fixes-20260622-085528.md | 519 -------- ...ument-ui-parity-cutover-20260621-121115.md | 467 -------- .../publish-core-package-20260623-125551.md | 92 -- packages/ui/README.md | 53 + 31 files changed, 54 insertions(+), 6168 deletions(-) delete mode 100644 adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md delete mode 100644 adr/decisions/003-complete-document-ui-parity-cutover-20260621-122015.md delete mode 100644 adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md delete mode 100644 adr/decisions/005-make-comments-host-overridable-20260623-085309.md delete mode 100644 adr/decisions/006-make-extras-host-overridable-20260623-102104.md delete mode 100644 adr/decisions/007-carve-core-package-and-publish-20260623-140537.md delete mode 100644 adr/implementation/document-ui-extraction-intent-20260620-085249.md delete mode 100644 adr/implementation/document-ui-extraction-roadmap-20260622.md delete mode 100644 adr/implementation/document-ui-parity-checklist-20260622.md delete mode 100644 adr/implementation/document-ui-parity-cutover-intent-20260621-122245.md delete mode 100644 adr/implementation/document-ui-phase-0-1-worklog-20260622.md delete mode 100644 adr/implementation/document-ui-phase-7-plan-20260623.md delete mode 100644 adr/research/SPIKE-document-ui-comments-system-20260623-084806.md delete mode 100644 adr/research/SPIKE-document-ui-current-state-and-parity-20260621-115603.md delete mode 100644 adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md delete mode 100644 adr/research/SPIKE-document-ui-extras-system-20260623-100827.md delete mode 100644 adr/research/SPIKE-document-ui-reuse-inventory-20260622-183000.md delete mode 100644 adr/research/SPIKE-publish-core-package-20260623-125551.md delete mode 100644 adr/research/synthesis-document-ui-comments-20260623-084806.md delete mode 100644 adr/research/synthesis-document-ui-extraction-20260620-082343.md delete mode 100644 adr/research/synthesis-document-ui-extras-20260623-100827.md delete mode 100644 adr/research/synthesis-publish-core-package-20260623-125551.md delete mode 100644 adr/specs/document-ui-comments-seam-20260623-084806.md delete mode 100644 adr/specs/document-ui-extraction-20260620-083307.md delete mode 100644 adr/specs/document-ui-extraction-plan-verified-20260622-184500.md delete mode 100644 adr/specs/document-ui-extras-seam-20260623-100827.md delete mode 100644 adr/specs/document-ui-feature-completeness-review-fixes-20260622-085528.md delete mode 100644 adr/specs/document-ui-parity-cutover-20260621-121115.md delete mode 100644 adr/specs/publish-core-package-20260623-125551.md create mode 100644 packages/ui/README.md diff --git a/AGENTS.md b/AGENTS.md index 7771494db..5bb30b2bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ 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 / layout) in the commercial Workspaces app? Read `adr/decisions/004-reuse-document-ui-as-published-building-blocks-*.md` FIRST.** ADRs 002 and 003 (and their `document-ui-extraction` / `document-ui-parity-cutover` specs and intents) describe a reverted, failed attempt — a from-scratch reimplementation that broke the app. Do **not** implement them or recreate `packages/document-ui`. The corrected plan in 004 is: share `@plannotator/ui` as published building blocks, keep Plannotator's app unchanged, never delete working code until a human confirms parity in the browser. +> **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 diff --git a/adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md b/adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md deleted file mode 100644 index 54f3779e1..000000000 --- a/adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md +++ /dev/null @@ -1,133 +0,0 @@ -# 002. Extract a Provider-Neutral Document UI Package - -> ⚠️ **RE-SCOPED / SUPERSEDED BY ADR 004 — DO NOT IMPLEMENT AS WRITTEN.** The provider-neutral `DocumentReviewSurface` / `DocumentHostApi` approach in this ADR was attempted, broke the app, and was reverted on 2026-06-22. The corrected plan is **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`** — read it first. Kept here as history. - -Date: 2026-06-20 - -## Status - -Accepted - -## Context - -Plannotator began as a plan review UI. In Plan Mode, the Claude Code hook intercepts `ExitPlanMode`, starts the server, opens the browser, and waits for approve or deny. - -The product has since shifted. The main document workflow is now broader than plan review: users run annotate on markdown, text, HTML, URLs, folders, and last assistant messages. The same React app currently handles all of those modes. The annotate server serves document sessions through `/api/plan`, and `packages/editor/App.tsx` switches between plan review, annotate file, annotate folder, annotate message, raw HTML, archive, and goal setup. - -A sister Workspaces repo now needs the same document review experience. It should get the same Plannotator UI patterns for rendering, annotation, comments, file trees, edit state, draft restore, and feedback assembly, but with different provider mechanics: document ids instead of filesystem paths, workspace manifests instead of local directory walks, `If-Match` or version ids instead of disk hashes, and workspace APIs instead of `/api/doc` and `/api/source/save`. - -The current package split does not express this boundary. `@plannotator/ui` contains reusable primitives, but many hooks and components call hard-coded `/api/*` routes. `@plannotator/editor` contains the app shell and important document-domain behavior such as editable document state, source reconciliation, direct edits, draft restore, and agent-terminal integration. Moving only `Viewer` or renderer components would leave the hard product behavior trapped in `App.tsx` and force Workspaces to recreate it. - -The key abstraction is not local source-save. Local source-save is Plannotator's current writeback provider. The reusable concept is provider-neutral document writeback state: - -- clean -- dirty -- saving -- saved -- conflict -- missing -- error - -Plannotator local writeback uses `/api/source/save`, disk hashes, mtime, EOL metadata, file watching, and missing local files. Workspaces writeback uses workspace document APIs, `If-Match`, versions, missing document rows, and workspace restore semantics. The UI state and user experience should be shared; persistence details should belong to the host/provider. - -## Decision - -We will extract one shared package: - -```text -@plannotator/document-ui -``` - -The package will expose a product-level document review surface: - -```tsx - -``` - -The package will own the reusable document review loop: - -- markdown and raw HTML rendering -- markdown parsing and block rendering -- annotation lifecycle and highlight restoration -- comments, attachments, and annotation panel behavior -- linked document navigation -- document/file tree rendering and badges -- edit mode and edit session state -- provider-neutral writeback state -- conflict, missing, and error UI patterns -- draft save and restore behavior -- feedback and saved-change payload assembly -- code path validation UI and inline link handling -- optional Ask AI and agent-delivery integration points - -The package public contract must be provider-neutral. It must not require filesystem paths, `/api/source/save`, disk hashes, or local source-save terminology. Public types should use concepts such as `DocumentRef`, `LoadedDocument`, `DocumentReviewSession`, `DocumentHostApi`, `DocumentWritebackStatus`, `SaveDocumentRequest`, and `SaveDocumentResult`. - -Local Plannotator source-save will become the first provider implementation behind a browser-side adapter: - -```text -createPlannotatorHttpDocumentApi() -``` - -That adapter will map current routes and local source-save metadata into the provider-neutral contract: - -- `GET /api/plan` -- `GET /api/doc` -- `POST /api/doc/exists` -- `GET /api/reference/files` -- `GET /api/reference/files/stream` -- `POST /api/source/save` -- `GET/POST/DELETE /api/draft` -- `POST /api/feedback` -- `POST /api/approve` -- `POST /api/exit` - -The current routes will remain stable during extraction. We will not rename `/api/plan` as part of this work. - -Workspaces will be able to implement its own `DocumentHostApi` using workspace document ids, manifests, annotation APIs, versions, and `If-Match` behavior. That adapter does not need to live in this repository. - -The host app will continue to own runtime and environment policy: - -- CLI/plugin command interception -- server startup and browser opening -- auth -- provider implementation -- plan-mode hook stdout behavior -- plan history -- plan diff -- archive -- goal setup -- permission mode setup -- note-app settings and persistence policy -- terminal runtime, WebTUI sidecar, remote-mode security, and installer logic - -Plan review becomes one host mode that supplies a document, capabilities, and approve/deny behavior to the shared document surface. Annotate remains the reference use case because it exercises the full document experience: arbitrary files, folders, raw HTML, linked docs, writeback, drafts, and optional agent delivery. - -We will extract in phases: - -1. Create `packages/document-ui` with provider-neutral contracts. -2. Add `createPlannotatorHttpDocumentApi()` over current Plannotator routes. -3. Move document-domain state out of `packages/editor`, renaming public concepts from local source-save to provider-neutral writeback where appropriate. -4. Create `DocumentReviewSurface` around the existing viewer, HTML viewer, editor toggle, linked-doc behavior, file tree, annotation panel, drafts, and writeback state. -5. Move provider-neutral feedback assembly into the package while keeping Plannotator's agent-specific markdown wrapping in the host. -6. Shrink `packages/editor/App.tsx` into a host shell that loads the session, configures capabilities, handles plan/annotate policy, and renders `DocumentReviewSurface`. -7. Add contract and surface tests, including an in-memory provider and Bun/Pi route mapping tests. - -## Consequences - -Plannotator's document experience becomes the upstream UI for both Plannotator and Workspaces. - -The first extraction target is not just `Viewer`. The implementation must move the document-domain behavior that makes the UI useful: writeback state, draft restore, linked-doc state, comments, edit state, file tree badges, and feedback assembly. - -The package boundary will force Plannotator-local assumptions behind an adapter. Filesystem paths, disk hashes, mtime, EOL metadata, and `/api/source/save` remain valid implementation details for Plannotator local sessions, but they must not become required public fields. - -The writeback model becomes shared and provider-neutral. This gives Workspaces the same dirty/saving/saved/conflict/missing/error UI without inheriting local filesystem semantics. - -`@plannotator/ui` will likely remain a lower-level UI primitive package at first. `@plannotator/document-ui` can depend on it and gradually pull document-specific components into the new package. We will avoid a giant one-shot component move. - -Plan diff, archive, goal setup, permission mode setup, and terminal runtime stay host-owned in the first boundary. They can be exposed as optional slots or capabilities where needed, but they are not core document package responsibilities. - -Bun/Pi server parity remains required. A frontend package extraction does not remove the need to update both server implementations when route behavior changes. The first implementation should avoid route shape changes and use adapter mapping instead. - -Tests must cover both the provider-neutral package behavior and the Plannotator local adapter behavior. At minimum, we need contract tests for Bun and Pi `/api/plan` and `/api/doc` mapping, source-save-to-writeback mapping, conflict and missing writeback results, in-memory provider surface behavior, linked-doc annotation caching, draft restore, and feedback payload assembly. - -This decision increases near-term implementation work because we are extracting behavior rather than only components. It reduces long-term duplication and prevents the sister repo from reimplementing Plannotator's document state machine under a different backend. diff --git a/adr/decisions/003-complete-document-ui-parity-cutover-20260621-122015.md b/adr/decisions/003-complete-document-ui-parity-cutover-20260621-122015.md deleted file mode 100644 index cb4384dd0..000000000 --- a/adr/decisions/003-complete-document-ui-parity-cutover-20260621-122015.md +++ /dev/null @@ -1,89 +0,0 @@ -# 003. Complete Document UI Parity Cutover - -> ⚠️ **REVERTED — DO NOT IMPLEMENT.** This cutover was attempted by an AI agent and failed: it produced a ~26,500-line from-scratch reimplementation, deleted the working `App.tsx`, and broke rendering (dead sidebars, wrong experience). Reverted on 2026-06-22. **This ADR is superseded by `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`** — read it before doing any document-UI work. Kept here as a post-mortem only. - -Date: 2026-06-21 - -## Status - -Accepted - -## Context - -ADR 002 established `@plannotator/document-ui` as the provider-neutral package for Plannotator's reusable document review experience. The branch has since implemented a substantial package: provider-neutral session/document types, `DocumentHostApi`, `DocumentReviewSurface`, writeback state, drafts, linked documents, document tree state, annotation persistence, feedback assembly, image handling, a Plannotator HTTP adapter, and an in-memory provider test harness. - -The package is real and green, but the app has not yet been cut over. `packages/editor/App.tsx` still owns the default Plan Review / Annotate render path. The new package surface is only mounted through an opt-in bridge behind `VITE_DOCUMENT_SURFACE=1`. - -The old shell still owns several parity-critical workflows: - -- plan diff and version browser -- richer document chrome: toolstrip, sticky controls, sidebars, panels, file/message navigation, code previews, and shortcuts -- full Ask AI panel -- agent terminal shell -- archive mode -- goal setup -- settings, share/export/import, and note integrations -- Plannotator route and environment side effects - -A sister Workspaces repo needs the same document review UI with a different provider. Keeping the shared package as an optional renderer while the hard behavior remains in `App.tsx` would recreate the coupling this extraction is meant to remove. - -## Decision - -We will finish the cutover so `@plannotator/document-ui` becomes the default production document review surface for the Plan Review / Annotate app. The feature-flagged bridge path will be removed after parity is reached. - -The package owns the reusable document review loop: - -- markdown and raw HTML document review -- annotation lifecycle, comments, global comments, image attachments, and annotation persistence hooks -- linked document navigation -- document tree/file tree UI, badges, and provider-neutral document/message navigation -- document editing and provider-neutral writeback states -- draft restore UI and state -- feedback payload assembly -- plan/document version browsing and diff UI -- generic Ask AI document-review surface when a host AI API exists -- code/link preview UI when the host can load or validate targets -- default chrome needed for parity: toolstrip, sticky controls, sidebars, panels, empty states, banners, shortcuts, and action buttons - -The host owns environment and product policy: - -- server routes, auth, browser opening, process lifetime, CLI/plugin/hook integration, and `ExitPlanMode` stdout decisions -- Plannotator settings persistence -- share/paste service policy and import/export modal policy -- Obsidian, Bear, and Octarine integrations -- agent terminal runtime, PTY/WebSocket bridge, installer, and remote security policy -- goal setup business logic -- archive storage, list loading, and archive-specific actions -- provider transport details for documents, comments, versions, and watches - -Version and diff support will move into `@plannotator/document-ui` as an optional provider-neutral capability. The host will load versions; the package will provide the default version browser, base-version selection, markdown diff computation, clean/raw diff render modes, diff annotations, edit-blocking behavior, and feedback inclusion. Plannotator will adapt `/api/plan/versions` and `/api/plan/version`; Workspaces can adapt its own document versions API without inheriting Plannotator route names. - -Archive and goal setup will not become core document-ui concepts for this cutover. Archive may be mounted through host slots or by loading read-only documents into the surface. Goal setup remains host-owned. - -Agent terminal runtime will stay host-owned. The package may provide slots and generic delivery state, but it will not own PTY, WebSocket, runtime install, remote-mode security, or terminal prompt policy. - -Ask AI will be shared only at the document-review surface level. The package may own the panel shell, document context, and in-document ask affordances. The host will own provider/model settings, auth, permission policy, and transport. - -`packages/editor/App.tsx` will be reduced to a Plannotator host shell. It should load the session through the Plannotator adapter, read settings, configure host slots and side effects, render completion/modals that remain Plannotator-owned, and render `DocumentReviewSurface`. It should no longer directly orchestrate the main document viewer, HTML viewer, plan diff viewer, annotation panel, linked-doc state machine, archive document rendering path, file/message navigation state, source-save UI state, or direct document feedback assembly. - -## Consequences - -The branch now has a concrete completion target: there should be one production document-review path, and it should go through `@plannotator/document-ui`. - -The cutover requires more work than the initial extraction because parity gaps must be closed before old code can be deleted. The biggest new package capability is provider-neutral version/diff support. - -The shared package will become larger and more product-shaped. That is intentional: the reusable value is the document review loop, not just renderer components. - -The Plannotator host shell remains necessary. It will still own routes, settings, share/export/note policy, archive storage, goal setup, terminal runtime, and hook/plugin behavior. Those are not shared document UI responsibilities. - -Workspaces gets a clear integration point: implement `DocumentHostApi` for workspace documents, manifests, annotations, writeback, and versions. It should not need to reimplement Plannotator's document state machine or inherit local source-save vocabulary. - -ADR 002 remains valid as the package boundary decision. This ADR extends it by declaring the cutover requirement and moving version/diff into the shared package as an optional capability. - -The implementation is complete only when: - -- the normal Plan Review / Annotate app renders through `@plannotator/document-ui` -- `VITE_DOCUMENT_SURFACE` is gone from production code -- the old document-review render path is removed -- plan review, annotate file, annotate folder, annotate last, raw HTML, linked docs, source-save, drafts, plan diff, Ask AI, terminal slot, archive, export/share, and note integrations still work -- Workspaces can supply a provider without depending on Plannotator local source-save terms diff --git a/adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md b/adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md deleted file mode 100644 index 28bad179d..000000000 --- a/adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md +++ /dev/null @@ -1,82 +0,0 @@ -# 004. Reuse the Document UI as Published Building Blocks (Reverts 003, Re-scopes 002) - -Date: 2026-06-22 - -## Status - -Accepted. - -**This ADR is the single source of truth for sharing Plannotator's document UI with the commercial Workspaces app.** It **reverts ADR 003** and **re-scopes ADR 002**. - -> If you are an agent or contributor about to work on "document-ui extraction," read this ADR first. Treat ADRs 002 and 003, and their specs/intents (`adr/specs/document-ui-extraction-*`, `adr/specs/document-ui-parity-cutover-*`, `adr/implementation/document-ui-*-intent-*`), as a **post-mortem of a failed attempt** — not a plan to execute. The `packages/document-ui` package they describe was deleted. Do not recreate it. - -## Context - -### What we actually want - -The commercial app, **Workspaces**, needs to reuse Plannotator's *presentation layer*: theme, Markdown rendering, the editor, settings UI, and layout/components — including the comment/annotation *rendering*. - -Workspaces is a separate, Cloudflare-based collaborative document platform. It owns its own world: - -- documents stored as versioned blob history (Git-like), D1 metadata, a raw file-serving worker (`tot.page`) -- workspaces/folders, public/private/open sharing, raw file URLs -- document version history and restore -- anchored comments and replies, shared with teammates -- agents commenting on documents via API keys -- realtime collaboration through Durable Objects -- browser login via WorkOS (hosted) or Cloudflare Access (self-host) -- hosted at `workspaces.plannotator.ai`, raw content at `tot.page` - -The shared thing is therefore **UI building blocks, not an application.** Workspaces renders Plannotator's components and feeds them *its own* data, comments, versions, and realtime sync. Plannotator keeps feeding the same components *its* local hook/file data. Same look; different data and backend behind it. - -### What we tried before (002/003) and why it failed - -The previous attempt built a ~26,500-line, 70-file `packages/document-ui` package containing a provider-neutral `DocumentReviewSurface` + `DocumentHostApi` meant to be *the whole app* for both Plannotator and Workspaces. It then deleted Plannotator's working 4,685-line `packages/editor/App.tsx` and routed the real app through the new surface. The result did not render correctly — dead sidebars, missing chrome, a different experience. The branch was reverted on 2026-06-22 (all of it was uncommitted working-tree changes; a backup patch + archive of the dead code is in the session scratchpad). - -Root causes (these are what this ADR exists to prevent repeating): - -1. **Abstracted for a consumer that didn't exist yet.** The provider-neutral contract was designed against an imagined Workspaces backend that couldn't be run or tested. Premature/speculative generality. -2. **The method was a rewrite, not a move.** Every behavior was re-derived as a new "provider-neutral decision function" with its own unit test. ~80 such steps = a from-scratch reimplementation by construction. -3. **The acceptance bar couldn't see the failure.** Verification was `bun test` / `typecheck` / `build` only. 357 unit tests stayed green while the actual rendered app was broken. Nobody opened it. -4. **Deleted the known-good code before parity existed.** The team's own parity SPIKE measured only ~55–65% app-visible parity, yet the working shell was deleted anyway, with a demo page as the fallback. - -## Decision - -1. **Plannotator's app stays as it is.** No cutover. `packages/editor/App.tsx` and the current experience are the reference to preserve, not a thing to replace. There is no "flip the production app to a new surface" step in this plan. - -2. **Share by publishing `@plannotator/ui` (and, if a slimmer editor package is needed, a small editor package) as versioned npm packages.** Workspaces installs them as a dependency. There is no shared "whole-app surface," no `DocumentReviewSurface`, and no `DocumentHostApi`. - -3. **Shared = presentation building blocks that take their data via props/callbacks.** In scope: - - theme and color tokens (`packages/ui/theme.css`) - - Markdown parser + block renderer (`parser.ts`, `BlockRenderer`, block components) - - document viewer / editor components - - settings UI - - layout / chrome components (toolbars, sidebars, panels) - - comment / annotation **rendering** components (the visual presentation of an anchored comment and its replies) - - The real, *narrow* extraction work is this: where a shared component currently calls a hard-coded `/api/*` route or reaches into Plannotator-only globals, lift that I/O up to a prop or callback so the host supplies it. Make the components backend-agnostic. **Do not rebuild their logic.** - -4. **NOT shared — each app owns its own:** document and comment *data* and state, realtime sync, version storage, feedback/delivery, server routes, auth, and backend. Workspaces wires comments to Durable Objects and its D1/blob store; Plannotator wires them to its local hook/file model. The shared component renders what it is given and emits events; it does not know who stores the data. - -## Hard rules (these are the safeguards we lacked) - -- **Move, don't rewrite.** Relocate existing code and change import paths. If a slice produces a large amount of brand-new code, stop — that is the warning sign that you are reimplementing instead of extracting. -- **No hard-coded routes or backend assumptions in shared packages.** Data comes in via props; actions go out via callbacks. -- **Parity is the gate, and a human verifies it in the browser.** After any change, Plannotator must look and behave identically across every mode: plan review; annotate file / folder / last; raw HTML; archive; goal setup; sidebars; plan diff; keyboard shortcuts; themes; settings; editor. Passing tests are necessary but **not sufficient** — last time they were green the entire time the app was broken. -- **Never delete or replace working code until a human signs off on parity**, mode by mode. Keep the old path until the replacement is proven. -- **Small, reviewed increments.** One component family at a time, eyeballed in the running app. No day-long unattended agent runs. - -## Consequences - -- Plannotator is never at risk during this work; its app keeps running unchanged the whole time. -- Workspaces gets a real, versioned dependency (`@plannotator/ui`) it can build its own product around, without inheriting Plannotator's routes, hooks, or local-file assumptions. -- The boundary is honest and small: **share the look, own the data.** A comment renders the same in both apps; how it syncs and persists is each app's own concern. -- Publishing adds a release/version step for the shared package(s). That is the accepted cost of a clean separate-repo boundary (Workspaces is its own repo on Cloudflare). -- ADRs 002 and 003 and their specs/intents are kept as history. The 2026-06-22 review-fixes spec (`adr/specs/document-ui-feature-completeness-review-fixes-*`) remains useful as a **checklist of behaviors the UI must preserve** — but read it as an inventory, not a build plan. - -## References - -- Reverts: `adr/decisions/003-complete-document-ui-parity-cutover-20260621-122015.md` -- Re-scopes: `adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md` -- Failed-attempt parity inventory (reuse as a checklist only): `adr/specs/document-ui-feature-completeness-review-fixes-20260622-085528.md` -- Sound research on how the current system works: `adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md` diff --git a/adr/decisions/005-make-comments-host-overridable-20260623-085309.md b/adr/decisions/005-make-comments-host-overridable-20260623-085309.md deleted file mode 100644 index bd7ff2c8d..000000000 --- a/adr/decisions/005-make-comments-host-overridable-20260623-085309.md +++ /dev/null @@ -1,52 +0,0 @@ -# 005. Make Comments / Annotations / Drafts Host-Overridable (Phase 5) - -Date: 2026-06-23 - -## Status - -Accepted - -## Context - -ADR 004 set the plan: make `@plannotator/ui` reusable by the commercial Workspaces app by lifting each Plannotator-specific wire up to an optional override whose default is today's behavior, never changing Plannotator. Phases 0–4 did this for packaging, image/storage, the rendering stack, and the file tree. - -Phase 5 is comments — the core of Workspaces (teammates and AI agents commenting on documents, live). It was assumed to be the largest, most dangerous phase. Five code-research probes (`adr/research/SPIKE-document-ui-comments-system-20260623-084806.md`, synthesized in `adr/research/synthesis-document-ui-comments-20260623-084806.md`) found a narrower reality: - -- The comment **UI is already portable** — `AnnotationPanel`, `CommentPopover`, `AnnotationToolbar`, `AnnotationToolstrip`, `EditorAnnotationCard`, `useAnnotationHighlighter`, the `export*Annotations` serializers — all prop-driven, no backend wires. -- A **second consumer already proves it**: `review-editor` reuses `useExternalAnnotations`, `useEditorAnnotations`, and `useCodeAnnotationDraft` unchanged. -- Annotation **state is host-owned already** (each app's own `useState`), so there is no shared reducer to wrestle. - -The real coupling is three things: the draft transport (`/api/draft`) plus a fragile 3-party "generation" protocol that prevents ghost drafts; the external-annotation transport (an SSE→polling state machine — the live-comment channel); and identity/authorship (the local "tater" nickname behind the `(me)` badge). Two further findings are not extraction work: highlight restoration is coupled to Plannotator's exact markdown renderer, and there is no reply/threading model (which Workspaces wants but Plannotator does not have). - -## Decision - -Make the comment system host-overridable through **three seams**, each a module-level default that reproduces today's behavior plus an optional override; Plannotator passes nothing and stays byte-for-byte unchanged. Land them lowest-risk first, as three separate verify-gated commits. - -1. **Identity (first, lowest risk).** Add a module-level identity provider in `packages/ui/utils/identity.ts` (`setIdentityProvider` / `resetIdentityProvider`) defaulting to today's `getIdentity` / `isCurrentUser`. Route the ~9 author-stamp sites and 2 `(me)`-display sites through it. Workspaces supplies the logged-in user; Plannotator keeps the tater nickname. (Identity already persists via the Phase-2 swappable storage and `configStore.init(serverConfig)`; this closes the last gap.) - -2. **Draft transport (second).** Inject a `DraftTransport` (load/save/remove) into `useAnnotationDraft` and `useCodeAnnotationDraft`, default = today's `/api/draft` fetches verbatim — including the `keepalive` retry and the `visibilitychange`/`pagehide` flush. The generation protocol stays end-to-end: `getDraftGeneration()` still escapes to the host and is still threaded into approve/deny/feedback/exit; the seam's contract documents that a host swapping transport must also honor generation-gated delete-on-submit and tombstoning, or ghost drafts return. The stateful refs, debounce, and pre-increment timing stay in the hook, verbatim. - -3. **External-annotation transport (last, riskiest).** Inject an `ExternalAnnotationTransport` (`subscribe` + optimistic CRUD + `getSnapshot(since)`) into `useExternalAnnotations`, default = the SSE→polling state machine moved verbatim (EventSource primary, 500ms polling fallback, 304 gate, 30s heartbeat, fallback-once). The reducer, optimistic mutators, version-scoping, and `enabled` gate stay in the hook. A Workspaces backend implements the same event contract over Durable Objects instead of SSE; the shared store/validators/encoding in `packages/shared/external-annotation.ts` are unchanged. - -The already-portable comment components and hooks are confirmed no-ops — no work. - -**Two things are explicitly excluded from Phase 5:** - -- **Renderer coupling — document, do not change.** Highlight restoration re-anchors against the rendered DOM and depends on `transformPlainText` matching the renderer. We write down an integration contract: a host must reuse `BlockRenderer` + `InlineMarkdown` + `inlineTransforms` as a unit. (Optionally expose `transformPlainText` as overridable later; not now.) - -- **Replies / threading — defer as a new feature.** Comments are flat today. Threading is something Workspaces wants and Plannotator lacks; building it is adding a feature, not making existing behavior reusable. Phase 5 ships the flat model unchanged. Replies are a later, backward-compatible enhancement that Plannotator never populates, tracked in its own spec. - -## Consequences - -- Workspaces can power real-time, multi-person, agent-friendly commenting by implementing three transports/providers, without inheriting Plannotator's `/api/draft`, SSE routes, or tater identity. -- Plannotator is unchanged: every seam defaults to today's literal behavior; the draft generation protocol and the SSE→polling machine move verbatim (the exact failure mode of the reverted attempt is avoided by copying, not re-deriving). -- The parity bar per seam: full `bun test` stays at baseline (1620/0), typecheck and builds pass, `packages/editor/App.tsx` changes stay minimal/empty, and an eyeball confirms the surface — author/`(me)` badge, draft save+restore+no-ghost, live external annotations, and the SSE→polling fallback (kill the stream, confirm polling takes over). -- A new integration constraint is now on record: Workspaces must reuse Plannotator's markdown renderer for comment highlights to land. This narrows Workspaces' freedom on rendering but is required and cheap (it already wants the same look). -- Replies remain unbuilt; Workspaces' full collaborative-thread vision needs a follow-up once the seams land. - -## References - -- Spike: `adr/research/SPIKE-document-ui-comments-system-20260623-084806.md` -- Synthesis: `adr/research/synthesis-document-ui-comments-20260623-084806.md` -- Spec: `adr/specs/document-ui-comments-seam-20260623-084806.md` -- Governing decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md` diff --git a/adr/decisions/006-make-extras-host-overridable-20260623-102104.md b/adr/decisions/006-make-extras-host-overridable-20260623-102104.md deleted file mode 100644 index 2f9637ec1..000000000 --- a/adr/decisions/006-make-extras-host-overridable-20260623-102104.md +++ /dev/null @@ -1,42 +0,0 @@ -# 006. Make Extras (Versions, Settings, Sharing, Ask AI) Host-Overridable (Phase 6) - -Date: 2026-06-23 - -## Status - -Accepted - -## Context - -ADR 004 set the plan: make `@plannotator/ui` reusable by Workspaces by lifting each Plannotator wire up to an optional override defaulting to today's behavior, never changing Plannotator. Phases 0–5 did this for packaging, image/storage, rendering, file tree, and comments. Phase 6 is the remaining "extras": versions/plan diff, settings/config, sharing/export/notes, and Ask AI. - -Five-research probes (`adr/research/SPIKE-document-ui-extras-system-20260623-100827.md`, synthesized in `adr/research/synthesis-document-ui-extras-20260623-100827.md`) found these four subsystems are **mostly already portable** — `planDiffEngine` and all plan-diff render components, the sharing utils/`useSharing`/`ImportModal`, the notes-app helpers, `settings.ts`, the AI chat components, and `aiProvider`/`aiChatFormat` are pure or prop-driven. The actual coupling is a small set of wires, plus one CSS wrinkle: the block-level/raw plan-diff CSS lives in the app shell (`packages/editor/index.css`), not the package. - -## Decision - -Make the extras host-overridable through **five seams plus one CSS move**, each defaulting to today's behavior; Plannotator passes nothing and stays byte-for-byte unchanged. Land per-seam, verify-gated, lowest-risk first. - -1. **Versions / diff.** Optional version fetchers on `usePlanDiff` (default → `/api/plan/version(s)`, keeping the `selectBaseVersion` alert vs `fetchVersions` silent error asymmetry verbatim) and an optional `onOpenVscodeDiff?` on `PlanDiffViewer` (default → `/api/plan/vscode-diff`). **CSS move:** relocate the block-level/raw diff classes (`.plan-diff-added/removed/modified/unchanged`, `.plan-diff-line-*`) and `.annotation-highlight*` from `editor/index.css` into `packages/ui/theme.css` (co-located with `.plan-diff-word-*`); Plannotator imports `theme.css`, so its diff and highlights render identically while the components become reusable without app-shell CSS. - -2. **Settings / config.** `configStore.setServerSync(fn)` injecting only the final `/api/config` POST, keeping the singleton construction, eager cookie reads, 300ms debounce, and `deepMerge` batching byte-identical. Optional `onDetectObsidianVaults?` on `Settings`, keeping the `[obsidian.enabled]` effect dep and auto-select-first-vault verbatim. - -3. **Sharing / notes.** Optional `onSaveToNotes?` on `ExportModal` (matching today's `{results:{success,error}}` shape), keeping `showNotesTab = isApiMode && !!markdown` byte-for-byte. (Sharing utils, `useSharing`, `ImportModal`, and the notes-app helpers are confirmed noop.) - -4. **Ask AI (last, riskiest).** Inject an `AITransport` (session/query/abort/permission) into `useAIChat`, default = today's five `/api/ai/*` fetches. **The SSE reader loop, the epoch/createRequest guards, and the supersede-abort fetch position inside `createSession` stay untouched in the hook** — only the wire is parametrized. Capabilities fetch and provider resolution stay host-owned (already in App.tsx). - -**Out of Phase 6 (Plannotator-only — they stay home, no work):** `OpenInAppButton` (local CLI), `HooksTab` (plan-mode hooks), `useUpdateCheck` (hardcoded github release check), `useAgents` and `useAgentJobs` (code-review agent jobs). A reusing host simply does not import them. - -## Consequences - -- Workspaces can optionally reuse version-diff review, the settings panel, save-to-notes, and the AI chat by implementing the corresponding fetchers/callbacks/transport — but none of it is required for the already-shipped core (docs, tree, editing, comments). -- Plannotator is unchanged: every seam defaults to today's literal behavior; the AI streaming state machine and configStore batching move nowhere; the CSS relocation is a pure cut-and-paste that Plannotator still imports via `theme.css`. -- The diff components (and the Viewer's annotation highlights) become self-styling from the package — closing a latent CSS-contract gap from earlier phases. -- The parity bar per seam: full `bun test` stays at baseline (1620/0), typecheck and builds pass, `packages/editor/App.tsx` changes stay minimal/empty, and an eyeball confirms the surface — plan diff (all modes + VS Code + diff annotations), settings persistence + obsidian detect, save-to-notes, and AI chat (stream / permission / abort / mid-stream supersede). -- After Phase 6, the document UI is feature-complete for reuse; the remaining work is Phase 7 (publish) and the parked `@plannotator/ai` / `@plannotator/shared` publish-vs-inline decision. - -## References - -- Spike: `adr/research/SPIKE-document-ui-extras-system-20260623-100827.md` -- Synthesis: `adr/research/synthesis-document-ui-extras-20260623-100827.md` -- Spec: `adr/specs/document-ui-extras-seam-20260623-100827.md` -- Governing decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md` diff --git a/adr/decisions/007-carve-core-package-and-publish-20260623-140537.md b/adr/decisions/007-carve-core-package-and-publish-20260623-140537.md deleted file mode 100644 index d2fc2488a..000000000 --- a/adr/decisions/007-carve-core-package-and-publish-20260623-140537.md +++ /dev/null @@ -1,66 +0,0 @@ -# 007. Carve `@plannotator/core`, complete the settings provider, and publish `core` + `ui` - -Date: 2026-06-23 - -## Status - -Accepted - -## Context - -Phases 0–6 (ADRs 004–006) made Plannotator's document UI (`packages/ui` = `@plannotator/ui`) host-overridable through optional seams that default to today's behavior, with Plannotator verified byte-for-byte unchanged. The remaining work (Phase 7) is to make `@plannotator/ui` actually installable by a separate consumer (the commercial "Workspaces"/Enterprise app) and publish it. - -Two facts force the shape of this phase: - -1. **`@plannotator/ui` can't be published as-is.** It depends on `@plannotator/shared` and `@plannotator/ai`, both unpublished workspace packages. `@plannotator/shared` is a Node/git/server kitchen sink we don't want on npm. An external installer must resolve every dependency from the registry, so the dependency tail has to be dealt with — without copying (the user's hard requirement: single source of truth, no duplication). - -2. **Workspaces will use the same UI settings, stored in its own backend.** The storage seam (`setStorageBackend`, Phase 2) already redirects setting *writes*. But the initial settings *load* runs against cookies at module-init, before a host can install its backend — so Workspaces' saved settings wouldn't load. The settings provider is half-built. - -An adversarial multi-model review (the `interrogate` pass) confirmed Phases 0–6 are sound and proportionate, found no Plannotator-affecting issues, and surfaced a small set of override-path fixes plus publish-toolchain decisions. The one contested decision (ship TS source vs. a compiled build) was resolved deliberately for the internal-consumer case. - -Supporting docs: `adr/research/SPIKE-publish-core-package-20260623-125551.md`, `adr/research/synthesis-publish-core-package-20260623-125551.md`, `adr/specs/publish-core-package-20260623-125551.md`. - -## Decision - -**1. Carve a new browser-safe `@plannotator/core` package (single source of truth).** -- Move the ~15 pure browser-safe modules the UI uses out of `@plannotator/shared` into `@plannotator/core` (`code-file`, `extract-code-paths`, `agents`, `agent-jobs`, `compress`, `crypto`, `external-annotation`, `favicon`, `feedback-templates`, `goal-setup`, `browser-paths`, `project`, `agent-terminal`, `open-in-apps`, `source-save`). -- For the node-bound modules the UI imports only *types* from (`config`, `storage`, `workspace-status`, and any review types `ui` surfaces): extract the type definitions into `core`; the Node implementation stays in `shared` and imports its types back from `core`. Types live once; nothing is duplicated. -- Re-export `AIContext` from `core` so `ui` no longer imports `@plannotator/ai`. -- `@plannotator/shared` re-exports each moved module via one-line shims, so all ~99 internal import sites and the Pi `vendor.sh` step keep working unchanged. Plannotator stays untouched. -- `core` is source-only, browser-safe, zero npm/node deps. **CI typechecks `core` with no `@types/node`** so a stray `node:*` import fails the build. - -**2. Complete the settings provider (in scope — Workspaces needs it).** -- Add a `loadFromBackend()` path so the initial settings load routes through the installed `StorageBackend`, not only cookies. -- Use the **prefetch + synchronous backend** model: a host fetches its settings, installs a sync backend that serves from that prefetched data, then calls `loadFromBackend()`. No async plumbing inside `configStore`; Plannotator's eager cookie default is unchanged (it never calls `loadFromBackend()`). - -**3. Single configuration front door.** -- Add `configurePlannotatorUI(config)`: one typed call that fans out to the 9 global host-override setters (image, storage, doc-preview, file-tree, identity, draft, external-annotations, AI, config-sync) plus the settings load. Render-time prop seams stay as props. A `` (React context) is the documented later upgrade if per-instance/SSR config is ever needed. - -**4. Ship TS source for JS; ship precompiled CSS.** -- Publish `core` + `ui` as TS source (no compiled build). Rationale: the only consumer is internal on a controlled stack (Vite/Cloudflare); a build exists to insulate unknown toolchains and buys ~nothing here, while avoiding a build pipeline and a `dist` artifact that can drift from what Plannotator runs. Revisit only if an external/arbitrary-stack consumer appears. -- Ship a **required** precompiled `@plannotator/ui/styles.css` (CSS-only build). The Tailwind `@source` glob into `node_modules` is fragile (pnpm symlinks) and a per-build cost; the stylesheet is the supported default, `@source` the documented fallback. - -**5. Publishing.** -- **Public npm** (open-source project; matches the existing `@plannotator/opencode` / `@plannotator/pi-extension` flow). -- **Lockstep versioning at the repo version (`0.21.0`)**, consistent with the other published packages; `core` + `ui` move together; `ui` → `core` pinned **exact**. -- `@plannotator/ai` stays unpublished-to-npm (npm `private: true`); the UI doesn't need it. (This is an npm-registry flag only — the code stays open on GitHub like everything else.) -- **Wire a CI publish job** for `core` + `ui` in `release.yml`. Before merging to main, **validate the artifacts on the branch**: `bun pm pack` each, inspect the tarball, and `npm publish --dry-run`. The first real publish goes out only on explicit go. - -**6. Pre-publish fixes (override-path only; none affect Plannotator), from the interrogation:** -- Fix `useExternalAnnotations` split-transport (reads/writes can hit different backends if the transport is set after mount); check `useFileBrowser` for the same shape. -- Reset `fallbackRef`/`receivedSnapshotRef` on effect re-run so a `false→true` `enabled` toggle doesn't silently stop updates. -- Add one override test per seam (`setX(fake)` → drive → assert → `resetX()`), which also makes the `reset*()` functions live. - -## Consequences - -- `@plannotator/ui` becomes installable: a consumer runs `npm install @plannotator/ui @plannotator/core`, calls `configurePlannotatorUI({...})` once, imports `@plannotator/ui/styles.css`, and builds — with the same UI settings persisted through its own backend. -- One copy of every shared module/type remains; `@plannotator/shared` and `@plannotator/ai` stay private to the monorepo. Plannotator's server, apps, editor, review-editor, and Pi build are unchanged. -- The carve + provider completion + fixes are all reversible and keep Plannotator byte-for-byte identical (parity gate: `bun test` 1620/0, typecheck, byte-identical shipped bundles, `git diff` confined to `core`/`shared`/`ui`/`editor`). The publish is the one outward-facing, hard-to-undo step and is gated on explicit approval after branch-validation. -- Shipping source couples consumers to a documented tsconfig/bundler setup; acceptable for the internal consumer, and the door to a compiled build stays open. -- New maintenance surface: a small published `@plannotator/core`, a CSS-only build, exact-version coupling between `core` and `ui`, and a node-free CI check on `core`. - -## References -- Spec: `adr/specs/publish-core-package-20260623-125551.md` -- Synthesis: `adr/research/synthesis-publish-core-package-20260623-125551.md` -- Spike: `adr/research/SPIKE-publish-core-package-20260623-125551.md` -- Governing decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md` diff --git a/adr/implementation/document-ui-extraction-intent-20260620-085249.md b/adr/implementation/document-ui-extraction-intent-20260620-085249.md deleted file mode 100644 index e0dd7dc78..000000000 --- a/adr/implementation/document-ui-extraction-intent-20260620-085249.md +++ /dev/null @@ -1,287 +0,0 @@ -# Document UI Extraction Intent - -> ⚠️ **REVERTED — DO NOT IMPLEMENT.** Implementation log of the failed `@plannotator/document-ui` extraction (reverted 2026-06-22). The long "implemented slice" list here is a record of the from-scratch rewrite that broke the app. Corrected plan: **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`**. History only. - -Status: active - -Date: 2026-06-20 - -## Intent - -Make Plannotator's document review experience reusable by Plannotator and Workspaces without turning the shared package into either a thin renderer or a local-filesystem abstraction. - -The shared package should own the product behavior users recognize as Plannotator's document review loop: - -- render markdown and raw HTML -- annotate text and blocks -- manage comments, global comments, and image attachments -- navigate linked documents -- browse document trees -- edit documents -- show clean, dirty, saving, saved, conflict, missing, and error writeback states -- restore drafts -- assemble annotation feedback and saved-change context - -The host should own routes, auth, server calls, plan-mode hook behavior, local disk or workspace persistence, and provider-specific policy. - -## Current Read - -The strongest boundary is a single `@plannotator/document-ui` package with a provider-neutral contract: - -```tsx - -``` - -The first extraction target is not `Viewer` alone. `Viewer` is important, but the hard reusable value is the document-domain state around it: loaded document identity, linked docs, editable content, writeback state, saved-change context, draft restore, document tree badges, and feedback assembly. - -Local source-save is Plannotator's first provider. It should stay behind `createPlannotatorHttpDocumentApi()` and map into provider-neutral writeback concepts. Workspaces should be able to implement the same contract with workspace document ids, manifests, versions, `If-Match`, and its own annotation APIs. - -## Execution Path - -1. Establish provider-neutral contracts in `packages/document-ui`. -2. Add a Plannotator HTTP adapter over the current server routes. -3. Move pure document-domain state first: writeback records, draft restore shapes, saved-change tracking, and feedback edit assembly. -4. Keep existing Plannotator UI behavior working through compatibility wrappers while logic moves out of `packages/editor`. -5. Wrap the current render and annotation experience in `DocumentReviewSurface`. -6. Shrink `packages/editor/App.tsx` into a host shell that loads session data, configures capabilities, handles plan/annotate policy, and renders the shared surface. -7. Add an in-memory provider test harness so Workspaces behavior can be exercised without Plannotator-local routes. - -## Guardrails - -- Do not rename `/api/plan` or current Plannotator routes during the extraction. -- Do not expose `/api/source/save`, disk hashes, mtime, or filesystem paths as required shared-package concepts. -- Do not split the package into many small packages yet. -- Do not move plan diff, archive, goal setup, permission-mode setup, or terminal runtime into the first shared surface. -- Preserve Bun/Pi server parity when route behavior changes, but avoid route changes in the first extraction slice. -- Keep Plannotator-specific final message wrapping in the Plannotator host; move provider-neutral feedback assembly into the package. - -## Implemented Slice - -The first implementation slice is intentionally narrow but now covers the reusable document-domain contract and its first Plannotator adapter: - -- create `packages/document-ui` -- define `DocumentRef`, `LoadedDocument`, `DocumentReviewSession`, `DocumentHostApi`, and writeback result types -- add `createPlannotatorHttpDocumentApi()` over current Plannotator routes -- move writeback state into provider-neutral helpers -- move direct-edit and saved-change feedback assembly into provider-neutral helpers -- move annotation/global attachment feedback assembly into provider-neutral helpers -- add document tree state with provider-neutral row identity, expansion, aggregate counts, and writeback badges -- add linked-document cache/navigation state with linked annotation feedback entries -- add provider-neutral draft state that saves/restores annotations, linked-document annotation entries, dirty writeback documents, and saved-change context through `DocumentHostApi` -- add provider-neutral edit/writeback state that drives active edit buffers, save requests, save success, conflicts, missing documents, discard, reload-conflict, unsaved documents, and saved-change entries through `DocumentHostApi.saveDocument` -- add an initial `DocumentReviewSurface` wrapper that resolves the active document, seeds writeback state, exposes render-state, lazy-loads the existing Plannotator markdown/raw-HTML renderers, routes renderer linked-document clicks through `hostApi.resolveLinkedDocument`/`hostApi.loadDocument`, and renders provider-neutral document chrome for writeback status, draft restore, edit/save/discard, conflict overwrite, conflict reload, linked-document back/error controls, document tree/file-row navigation, and annotation/right-panel presentation for current-document comments, attachments, code annotations, linked-document feedback, unsaved writeback edits, and saved-change context -- route raw-HTML iframe local document links through the same linked-document resolver as markdown links while leaving external, anchor, unsafe, and annotation-control clicks alone -- add typed React host slots (`terminalPanel`, left/right extras, header actions, footer), Plannotator-theme layout classes, and real renderer mode options (`selection`, `comment`, `redline`, `quickLabel`, `drag`, `pinpoint`) so the default surface can carry host-specific app chrome without owning host policy -- add `createMemoryDocumentHostApi()` as a Workspaces-like in-memory provider harness with document ids, manifest trees, base-revision saves, conflict/missing results, draft round trips, linked-doc resolution, and watch events -- add provider-neutral review lifecycle actions to `DocumentReviewSurface`: assemble feedback payloads with linked annotations, unsaved direct edits, and saved-change context; call `hostApi.submitFeedback`, `hostApi.approve`, and `hostApi.exit`; clear drafts after successful terminal actions; surface action errors and default action buttons without baking in Plannotator route policy -- add provider-neutral writeback watching through `useDocumentWritebackWatch`: subscribe to `hostApi.watchDocuments`, reload changed open documents through `hostApi.loadDocument`, reconcile clean updates, preserve dirty buffers as conflicts, mark deleted/missing documents, and expose watch state from the shared surface -- add optional provider-neutral annotation persistence through `hostApi.loadAnnotations`/`hostApi.saveAnnotations`, so Workspaces can back the same comment UI with its annotations API while local Plannotator can keep relying on drafts and terminal feedback -- add Plannotator host-session normalization and editor load-plan derivation in the local HTTP adapter: `/api/plan` responses are now translated once into document-session mode flags, render mode, markdown/html payloads, annotate source, sharing settings, source-file paths, root source-save writeback, recent messages, archive/goal setup metadata, version metadata, and the concrete app-shell initialization plan before `packages/editor/App.tsx` applies React side effects -- add an explicit opt-in Plannotator app bridge for `DocumentReviewSurface`: when `VITE_DOCUMENT_SURFACE=1` or `true`, `packages/editor/App.tsx` now hands the normalized session to `PlannotatorDocumentSurfaceBridge`, which mounts `` using the Plannotator HTTP adapter while the default production path keeps the legacy Plannotator shell -- move Plannotator direct-edit feedback compatibility formatting into `@plannotator/document-ui/plannotator-feedback`, including legacy direct-edit wording, saved-file-change wording, edit badge stats, panel item builders, provider-neutral current direct-edit content resolution over live/stored edit buffers, direct-edit commit decisions for stored edits/panel reveal/remapping, direct-edit discard decisions for reset/remap behavior, direct-edit draft restore decisions with CRLF normalization and current-work-wins skipping, direct-edit feedback presence decisions, saved-change-vs-direct-edit panel precedence, and current direct-edit feedback-section gating while writeback buffers are pending; `packages/editor` now imports that behavior from the shared package -- move Plannotator source-save editable-document state and disk reconciliation into `@plannotator/document-ui` adapter exports (`plannotator-source-documents`, `plannotator-source-reconciliation`), preserving local disk hash/missing-file behavior as Plannotator compatibility while keeping it out of the provider-neutral core contract -- move the Plannotator source-document `/api/doc` probe/snapshot client into `@plannotator/document-ui/plannotator-source-client`, so source-save hash refresh, missing-file detection, and markdown snapshot loading sit with the local adapter instead of the editor shell -- move the Plannotator restored single-file draft selection helper into the source-document adapter helpers, so source-save draft restore display policy is shared rather than editor-local -- move reusable feedback text assembly into `@plannotator/document-ui/feedback-text`: current-document annotations, linked-document annotations, editor annotations, code-file annotations, multi-message feedback, empty-feedback sentinels, source-specific titles, converted-source caveats, and linked-document markdown block enrichment now live in the shared package while Plannotator delivery policy stays in the editor shell -- move reusable multi-message feedback entry assembly into `@plannotator/document-ui/feedback-text`: the shared package now converts message picker rows plus linked-session annotation state into parser-ready message feedback entries, including root markdown blocks, linked-document markdown blocks, global attachments, and code annotations; `packages/editor/App.tsx` keeps selecting/saving message state and deciding when message-mode feedback is active -- move provider-neutral feedback submission interpretation into `@plannotator/document-ui/feedback-submission`: the shared package now composes annotation text with direct-edit and saved-change sections, reports whether review content exists, distinguishes saved-change-only context from unsent feedback, produces feedback-loss wording, and decides whether approve-with-notes payloads should include feedback text -- move annotate feedback target selection into `@plannotator/document-ui/feedback-submission`: the shared package now chooses linked document, source file, active file, folder, or current-file fallback targets for annotate feedback while `packages/editor/App.tsx` keeps Plannotator's message/file feedback templates and terminal delivery side effects -- move provider-neutral annotation remapping and highlight-restore decisions into `@plannotator/document-ui/annotation-remap`: markdown edits, reloads, and draft restores can now re-anchor annotations by selected text against newly parsed blocks, preserve diff/global/checkbox annotations, clear stale positional metadata when block ids move, mark missing text with an empty block id, choose which annotations should be restored into document highlights, detect missing restored highlights through a host-provided lookup, and build missing-highlight warning copy; `packages/editor/App.tsx` keeps markdown state, edit generation, DOM lookup, highlight repaint, and toast side effects -- move Plannotator-specific route payload assembly into `@plannotator/document-ui/plannotator-delivery`: approve, deny, annotate feedback, note-integration payloads, plan-save payloads, message-scope fields, and draft-generation URL helpers now sit with the local adapter while the editor shell keeps deciding when to call each route -- add a Plannotator delivery client in `@plannotator/document-ui/plannotator-delivery` and wire `packages/editor/App.tsx` approve, deny, annotate-feedback, annotate-approve, and annotate-exit handlers through it; the editor shell still owns settings lookup, saved-change validation side effects, terminal fallback, and submitted-state UI -- move generic agent-delivery state into `@plannotator/document-ui/agent-delivery`: feedback hashing, delivery records, target matching, duplicate-send decisions, current-delivery derivation, delivered-status visibility, and feedback-to-send flags are now provider-neutral; Plannotator's terminal helper keeps only terminal prompt/target formatting and adapts to the shared record shape -- move saved-change validation decisions into `@plannotator/document-ui/saved-change-validation`: submit-time stale/unverified blocking and draft-restore kept/changed-or-missing/unverified interpretation are now shared, while Plannotator keeps toast, cleanup, and draft-scheduling side effects in the host shell -- move direct-edit begin/change state decisions into `@plannotator/document-ui/edit-feedback`: non-writeback edit sessions now normalize CRLF before seeding the edit baseline, resolve missing original baselines, and report dirty/diff state through shared direct-edit lifecycle decisions while `packages/editor/App.tsx` keeps React state, source-save branching, terminal feedback revision, and draft scheduling side effects -- move direct-edit commit/discard display decisions into `@plannotator/document-ui/edit-feedback`: stored edit content, edit-stat reset/input, edit-panel reveal, editor dirty/diff reset, and remap content now flow through shared direct-edit decisions before `packages/editor/App.tsx` applies refs and annotation repaint -- move direct-edit draft-restore display decisions into `@plannotator/document-ui/edit-feedback`: restored, skipped, and ignored draft edit outcomes now map to stored edit content, edit-stat input, editor diff reset, edit-panel reveal, and remap content before `packages/editor/App.tsx` applies refs, annotation repaint, toasts, and draft scheduling -- move document review action lifecycle state into `@plannotator/document-ui/action-controller`: submitting/exiting lanes, open-session outcomes, submitted completions, and failure recovery are now shared; `packages/editor/App.tsx` approve, deny, annotate feedback, annotate approve, annotate exit, goal-setup exit, and callback delivery paths now use the shared controller while preserving Plannotator route policy and terminal fallback behavior -- move reusable review chrome copy and surface visibility decisions into `@plannotator/document-ui/chrome`: recovered-draft messages, add-feedback prompts, saved-change awareness text, unsaved-edit warnings, unsaved writeback continuation decisions, feedback/approve/exit/primary-submit action-intent decisions, submit-shortcut routing/ignore decisions, print-shortcut routing/ignore decisions, version/diff edit-block decisions, document-navigation edit-block decisions, document layout width state, feedback-loss warnings, completion-overlay title/subtitle decisions, sticky-header visibility, annotation-toolstrip visibility, folder-empty state, normal-document visibility, inline document-control visibility, left-sidebar collapsed/expanded visibility, left-sidebar tab open/toggle/wide-exit decisions, initial/TOC sidebar preference decisions, empty-TOC auto-close decisions, document-area collapsed-sidebar offset, sidebar tab visibility, right-panel tab visibility, right-panel toggle/reveal decisions, AI-panel visibility, panel resize-handle visibility, header action visibility/control state, viewer remount identity, linked-document breadcrumb variants/back labels, document copy labels, open targets, and message-picker count state are now shared; `packages/editor/App.tsx` and `AppHeader` keep the existing dialog/components, Claude Code issue links, warning continuation callbacks, provider capability flags, agent checks, DOM event wiring, callbacks, print side effect, Plannotator-specific linked-document labels, and local storage/path wording -- move provider-neutral Ask AI context assembly into `@plannotator/document-ui/ai-context`: the shared package now derives plan vs document AI context, document targets, source metadata, raw HTML vs markdown content, thread keys/titles, general ask labels, folder-empty blocking, and readable target priority without depending on the AI provider package; `packages/editor/App.tsx` keeps `useAIChat`, provider/model settings, terminal fallback delivery, toasts, and prompt formatting -- move reusable left-sidebar tab/open state into `@plannotator/document-ui/left-sidebar`: the shared generic controller now owns active-tab/open state, raw open/close transitions, review open/toggle transitions, preference-decision application, empty-TOC auto-close application, and wide-mode exit effects; `packages/editor/App.tsx` keeps concrete Plannotator tab content, archive/file/message loading side effects, resize widths, and invokes the host-owned wide-mode exit side effect -- move reusable right-panel tab/open state into `@plannotator/document-ui/right-panel`: the shared controller now owns annotation/AI active-tab state, open/close transitions, toggle/reveal transitions, compact-viewport reveal policy, and wide-mode exit effects; `packages/editor/App.tsx` keeps resize widths, mobile layout, and invokes the host-owned wide-mode exit side effect -- move reusable annotation state, visibility, feedback-presence, and provider-mutation routing into `@plannotator/document-ui/review-state`: the shared package now owns the root annotation/code-annotation/selection/global-attachment reducer, semantic add/select/update/remove actions, opposite-selection clearing, React-style setter adapters for Plannotator compatibility hooks, local/provider annotation merging while preferring live provider copies over draft-restored duplicates, rendered-viewer vs diff annotation partitioning, message/document feedback presence/counts, and provider-vs-local edit/delete routing; `packages/editor/App.tsx` keeps external annotation route calls, DOM highlight repaint, checkbox visual overrides, file-popout opening, and linked-document cache side effects -- move linked-document annotation badge/count summaries into `@plannotator/document-ui/linked-state`: the shared package now derives per-document annotation counts from linked-document caches, scopes those counts with a host-owned containment predicate, and summarizes annotations outside the active document for right-panel badges; `packages/editor/App.tsx` keeps the legacy `useLinkedDoc` cache, Plannotator filesystem path containment predicate, file-browser highlighting timer, and `AnnotationPanel` prop shape -- move linked-document editable-load decisions into `@plannotator/document-ui/linked-state`: the shared package now decides when linked-document navigation suspends an active writeback edit, clears active editability for non-editable/HTML targets, opens a folder-linked markdown document as editable, and resets an already-open editor session from current-vs-baseline content; `packages/editor/App.tsx` keeps the Plannotator editable-document store mutations, source-save keys, and React state side effects -- move linked-document back edit-state decisions into `@plannotator/document-ui/linked-state`: returning from a linked document now gets a shared decision for whether to exit edit mode and reset active editor dirty/diff state while `packages/editor/App.tsx` keeps invoking linked-document back, file-browser active-file clearing, and archive selection clearing -- move linked-document editable snapshot decisions into `@plannotator/document-ui/linked-state`: before linked-document navigation and submission, the shared package now decides whether to snapshot live editor content, displayed content, or nothing while `packages/editor/App.tsx` keeps reading the editor handle and mutating the Plannotator editable-document store -- move reusable linked-message annotation cache helpers into `@plannotator/document-ui/linked-state`: the shared package now counts annotations across root documents, linked documents, attachments, and code comments; creates empty message annotation snapshots; normalizes immutable message root content when picker messages change; and builds per-message badge counts over a linked-session-like shape without depending on Plannotator `/api/doc` or local filesystem paths; `packages/editor/App.tsx` keeps the legacy `useLinkedDoc` hook, message picker state, code-popout side effects, and feedback-entry rendering -- move current-message annotation state and active message badge-count decisions into `@plannotator/document-ui/linked-state`: the shared package now builds the live selected-message snapshot from message rows, linked-document session snapshots, code annotations, and selected code comment ids, and overlays that live state onto cached per-message counts; `packages/editor/App.tsx` keeps storing the cache ref/state and invoking the legacy linked-doc restore side effects -- move message-state cache merge/count recomputation and annotate feedback message-scope decisions into `@plannotator/document-ui/linked-state`: the shared package now folds the live selected-message state into cached message states, produces refreshed per-message annotation counts, and decides selected-message vs multi-message feedback scope for submissions; `packages/editor/App.tsx` keeps cache refs/state setters and Plannotator route body construction -- move message selection decisions into `@plannotator/document-ui/linked-state`: the shared package now decides whether a message picker request should be ignored or should select a normalized target message state, using cached message state when present and empty message state otherwise; `packages/editor/App.tsx` keeps the actual selected-message state update, legacy linked-doc restore, and code annotation restoration side effects -- move active message annotation count summaries into `@plannotator/document-ui/linked-state`: the shared package now derives total message feedback count, annotated message ids, and has-annotation flags from active per-message counts; `packages/editor/App.tsx` keeps rendering those values and passing them to sidebar/submission policy -- move wide/focus layout mode decisions into `@plannotator/document-ui/wide-mode`: wide-mode availability, enter/toggle/forced-exit decisions, sidebar/panel snapshot capture, sidebar/panel snapshot restore, explicit sidebar reopen, explicit panel reopen, and no-restore exit behavior are now shared; `packages/editor/wideMode.ts` remains a compatibility wrapper for the old Plannotator option names -- move reusable edit/writeback chrome decisions into `@plannotator/document-ui/edit-chrome`: markdown edit availability/reason classification, save button labels/disabled/tone state, edit/done/cancel/discard labels, edit-exit click transitions, stale discard-confirmation reset decisions, dirty/failed writeback status predicates, save-shortcut document/host/ignore routing, conflict banner copy, and missing-document banner copy are now shared; `packages/editor/App.tsx` maps those neutral states to existing Tailwind classes, passes Plannotator's disk wording and surface-mode facts, and keeps host notes/export fallback behavior, while the default `DocumentReviewSurface` uses the same save-label helper -- move reusable writeback edit-session chrome state into `@plannotator/document-ui/edit-chrome`: active-buffer dirtiness, conflict overwrite availability, and cancel-mode derivation now come from provider-neutral writeback content/status inputs while `packages/editor/App.tsx` keeps Plannotator source-document field mapping and button rendering -- move Plannotator local source-save request and response mapping into `@plannotator/document-ui/plannotator-source-client`: the adapter now builds `/api/source/save` bodies, maps success metadata back to source-save capabilities, preserves conflict snapshots, and normalizes local write errors; `packages/editor/App.tsx` keeps applying those mapped results to its compatibility store, repainting annotations, and showing Plannotator toasts -- move Plannotator local source-save result application into `@plannotator/document-ui/plannotator-source-documents`: mapped save results now update the source-document compatibility store for saved, live-dirty-after-save, conflict, clean-updated, conflict-unavailable, and error outcomes; `packages/editor/App.tsx` keeps repaint, toast, panel, and draft-scheduling side effects -- move Plannotator source-save display classification into `@plannotator/document-ui/plannotator-source-documents`: saved, clean-updated, conflict, conflict-unavailable, error, and noop outcomes now map to active editor state, edit-stat inputs, repaint/reset text, panel reveal, draft-save intent, edited-buffer clearing, and notification intent before `packages/editor/App.tsx` applies React effects and toasts -- move Plannotator source-backed edit-session begin/change classification into `@plannotator/document-ui/plannotator-source-documents`: entering edit mode now normalizes displayed source text, seeds the source edit buffer, and reports disk-baseline diff state, while live editor changes update source state and report edit-session/disk-baseline dirtiness; `packages/editor/App.tsx` keeps React UI flags and draft scheduling -- move Plannotator source-backed edit-session begin/change display classification into `@plannotator/document-ui/plannotator-source-documents`: source edit begin/change outcomes now map to edit-session reset text and active editor dirty/diff state before `packages/editor/App.tsx` applies refs, React editing flags, terminal feedback revision, and draft scheduling -- move Plannotator source-backed edit-commit classification into `@plannotator/document-ui/plannotator-source-documents`: committing the editor buffer now updates the source-document compatibility store, normalizes editor line endings, and reports disk-baseline diff state; `packages/editor/App.tsx` keeps edit-stat rendering, panel opening, markdown repaint, and draft-scheduling side effects -- move Plannotator source-backed edit-commit display classification into `@plannotator/document-ui/plannotator-source-documents`: committed, clean, and ignored source-edit outcomes now map to edited-buffer clearing, edit-stat reset/input, edit-panel reveal, and normalized markdown remap content before the host applies the shared edit-display effect plan -- move Plannotator source-file discard and reload-conflict outcome/display classification into `@plannotator/document-ui/plannotator-source-documents`: source-backed discard now reports active/non-active, removed-file, and replacement-text outcomes, then maps them to active editor reset, repaint text, root empty-document reset, linked-document back-navigation intent, active-file cleanup intent, and draft-save intent; reload-conflict reports the reloaded snapshot and maps it to repaint/reset, clean edit state, draft-save intent, and notification intent; `packages/editor/App.tsx` keeps applying React state, highlights, linked-doc/file-browser effects, toasts, and draft scheduling -- move Plannotator missing source-file selection display classification into `@plannotator/document-ui/plannotator-source-documents`: selecting a missing source-backed file now maps to reopened markdown content, active source key, optional edit-session reset text, and active editor dirty/diff/stat input before `packages/editor/App.tsx` applies linked-document, file-browser, and React side effects -- move Plannotator source-backed draft restore display classification into `@plannotator/document-ui/plannotator-source-documents`: restored source drafts now decide single-file vs active-folder display, active-key selection, repaint text, edit-stat inputs, and panel reveal in the local adapter; `packages/editor/App.tsx` keeps applying React state, highlights, and draft-scheduling side effects -- move Plannotator source-backed draft restore edit-display classification into `@plannotator/document-ui/plannotator-source-documents`: restored source draft display outcomes now map to shared active editor dirty/diff/stat state and edit-panel reveal intent while `packages/editor/App.tsx` keeps remapping the newly restored annotation list before applying the shared edit-display effects -- move Plannotator source-document reconcile event classification into `@plannotator/document-ui/plannotator-source-reconciliation`: file-missing, clean-update, status-update, and conflict events now map to active-document repaint/reset, edit-state, edit-stat, and notification outcomes in the local adapter; `packages/editor/App.tsx` keeps the actual React state updates, highlight repaint, toasts, and draft scheduling -- move the default `DocumentReviewSurface` editor-session lifecycle into `@plannotator/document-ui/documentEditorSession`: begin/change/save/overwrite/discard/reload-conflict/draft restore now coordinate through the provider-neutral writeback and draft controllers instead of living inline in the renderer -- move reusable edit-display effect planning into `@plannotator/document-ui/edit-display`: repaint text, edit-session reset text, active editor dirty/diff/stat state, edit-panel reveal, draft-save intent, and edited-buffer clearing now normalize through one provider-neutral plan before the Plannotator shell applies DOM repaint, refs, toasts, and local linked-file cleanup -- move the default `DocumentReviewSurface` toolbar/sidebar chrome state into `@plannotator/document-ui/chrome`: edit/save/conflict visibility, pending action labels, document-tree sidebar visibility, and annotation-persistence badge visibility now come from a pure shared helper before the surface renders its default JSX -- make shared document-surface image upload provider-owned: `DocumentReviewSurface` now exposes and passes `hostApi.uploadImage` into markdown, raw-HTML, global, and per-comment attachment controls, while legacy `@plannotator/ui` callers still keep the `/api/upload` fallback and providers without upload support disable file upload rather than silently calling Plannotator-local routes -- make shared document-surface image display provider-owned: `DocumentReviewSurface` now passes a host-owned image URL resolver through markdown images, raw HTML blocks, attachment thumbnails, and re-edit previews; the Plannotator HTTP adapter maps local paths to `/api/image`, while generic providers can return workspace asset URLs without inheriting Plannotator-local routes -- self-review tightened the image contract: the Plannotator adapter now populates `LoadedDocument.imageBase` for root and linked documents, derives image bases from `documentRef.path` when needed, resets thumbnail load/error state when image URLs change, treats Windows absolute paths as absolute in the legacy image fallback, and treats `allowImageAttachments: false` as disabling attachment controls rather than only disabling file upload -- map Plannotator renderer linked-doc hrefs into neutral refs in the local HTTP adapter, preserving `/api/doc` base resolution without exposing local source-save as a shared-package requirement -- wire `packages/editor/App.tsx` through the Plannotator HTTP document adapter for initial session loading -- map legacy Plannotator `/api/draft` source-save records into neutral draft/writeback records in the local adapter -- keep Plannotator compatibility wrappers in `packages/editor` so current feedback wording and saved-file validation behavior stay stable - -This gives both repositories a concrete contract to evaluate before the larger React surface move. - -## Remaining Work - -- Continue visual parity work where the existing editor still owns sticky toolstrips, sidebars, plan diff, archive, goal setup, Ask AI, and other app-shell policy outside `DocumentReviewSurface`. -- Shrink `packages/editor/App.tsx` into a Plannotator host shell for plan/annotate policy, route handling, settings, and legacy plan-mode behavior. -- Decide whether threaded comment/reply history and version history live in this package contract now or stay as host-provided optional capabilities until Workspaces integration exercises them. - -## Current Verification - -Validated after the latest document-ui extraction: - -- `bun test` for the focused `packages/document-ui` suite: 101 passing tests across 17 files. -- `bun test` for the focused `packages/document-ui` suite after feedback assembly extraction: 105 passing tests across 18 files. -- `bun test` for the focused `packages/document-ui` suite after feedback submission extraction: 110 passing tests across 19 files. -- `bun test` for the focused `packages/document-ui` suite after Plannotator delivery extraction: 115 passing tests across 20 files. -- `bun test` for the focused `packages/document-ui` suite after Plannotator delivery client wiring: 117 passing tests across 20 files. -- `bun test` for the focused `packages/document-ui` suite after generic agent-delivery extraction: 120 passing tests across 21 files. -- `bun test` for the focused `packages/document-ui` suite after saved-change validation decision extraction: 123 passing tests across 21 files. -- `bun test` for the focused `packages/document-ui` suite after action-controller extraction: 128 passing tests across 22 files. -- `bun test` for the focused `packages/document-ui` suite after chrome-copy extraction: 134 passing tests across 23 files. -- `bun test` for the focused `packages/document-ui` suite after edit-chrome extraction: 138 passing tests across 24 files. -- `bun test` for the focused `packages/document-ui` suite after Plannotator source-save client extraction: 141 passing tests across 24 files. -- `bun test` for the focused `packages/document-ui` suite after Plannotator source-save result application extraction: 145 passing tests across 24 files. -- `bun test` for the focused `packages/document-ui` suite after Plannotator source-file discard/reload outcome extraction: 151 passing tests across 24 files. -- `bun test` for the focused `packages/document-ui` suite after Plannotator source-backed edit-commit extraction: 155 passing tests across 24 files. -- `bun test` for the focused `packages/document-ui` suite after Plannotator source-backed edit-session begin/change extraction: 161 passing tests across 24 files. -- `bun test` for the focused `packages/document-ui` suite after Plannotator source-document reconcile event classification extraction: 165 passing tests across 24 files. -- `bun test` for the focused `packages/document-ui` suite after annotation-remap extraction: 170 passing tests across 25 files. -- `bun test` for the focused `packages/document-ui` suite after annotation highlight-restore helper extraction: 173 passing tests across 25 files. -- `bun test` for the focused `packages/document-ui` suite after edit-availability extraction: 176 passing tests across 25 files. -- `bun test` for the focused `packages/document-ui` suite after viewport-state extraction: 179 passing tests across 25 files. -- `bun test` for the focused `packages/document-ui` suite after wide-mode extraction: 185 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after right-panel state extraction: 187 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after left-sidebar state extraction: 189 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after sidebar-tab state extraction: 191 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after unsaved writeback continuation extraction: 193 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after review action-intent extraction: 197 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after header action-state extraction: 200 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after submit-shortcut gate extraction: 203 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after save-shortcut decision extraction: 205 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after right-panel toggle extraction: 207 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after left-sidebar tab decision extraction: 209 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after sidebar preference decision extraction: 213 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after wide-mode enter/toggle decision extraction: 219 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after right-panel reveal decision extraction: 221 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after right-panel controller extraction: 226 passing tests across 27 files. -- `bun test` for the focused `packages/document-ui` suite after left-sidebar controller extraction: 231 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after annotation visibility/count extraction: 235 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after annotation mutation-routing extraction: 238 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after root annotation-state hook wiring: 240 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after annotation reducer-action wiring: 241 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after linked-message annotation cache extraction: 243 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after multi-message feedback entry extraction: 244 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after current-message state/count extraction: 247 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after message cache/scope extraction: 249 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after message selection decision extraction: 251 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after message count-summary extraction: 252 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after linked-document file badge-count extraction: 255 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after document chrome identity/label extraction: 257 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after Ask AI context extraction: 262 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after print shortcut decision extraction: 263 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after version/diff edit-block extraction: 264 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after document-navigation edit-block extraction: 265 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after document layout width extraction: 266 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after edit-exit transition extraction: 268 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after current direct-edit content resolver extraction: 269 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after direct-edit feedback/panel extraction: 271 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after current direct-edit feedback-section gate extraction: 273 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after linked-document editable-load decision extraction: 275 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after direct-edit commit decision extraction: 276 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after direct-edit discard decision extraction: 277 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after direct-edit draft restore decision extraction: 278 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after source-backed draft restore display extraction: 282 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after source-save display classification extraction: 287 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after source discard/reload display classification extraction: 293 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after shared edit-panel presentation extraction: 295 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after shared default editor-session extraction: 300 passing tests across 30 files. -- `bun test` for the focused `packages/document-ui` suite after shared edit-display effect planning extraction: 305 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after source-backed edit-commit display classification extraction: 309 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after direct-edit commit/discard display decision extraction: 312 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after direct-edit draft-restore display decision extraction: 313 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after direct-edit begin/change state decision extraction: 315 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after source-backed edit-session begin/change display classification extraction: 317 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after writeback edit-session chrome-state extraction: 318 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after missing source-file selection display classification extraction: 321 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after source-backed draft restore edit-display classification extraction: 322 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after linked-document back edit-state decision extraction: 323 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after linked-document editable snapshot decision extraction: 324 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after annotate feedback target selection extraction: 325 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after agent-delivery state derivation extraction: 326 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after Plannotator editor load-plan extraction: 326 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after default surface chrome-state extraction: 327 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after provider-owned image upload threading: 328 passing tests across 31 files. -- `bun test packages/document-ui/DocumentReviewSurface.test.tsx packages/document-ui/plannotatorHttpApi.test.ts packages/ui/components/html-viewer/bridge-script.test.ts`: 29 passing tests. -- `bun test` for the focused `packages/document-ui` suite after provider-owned image display threading: 329 passing tests across 31 files. -- `bun test packages/document-ui/DocumentReviewSurface.test.tsx packages/document-ui/plannotatorHttpApi.test.ts packages/ui/components/html-viewer/bridge-script.test.ts` after ADR self-review fixes: 29 passing tests. -- `bun test` for the focused `packages/document-ui` suite after ADR self-review fixes: 329 passing tests across 31 files. -- `bun run typecheck` after ADR self-review fixes. -- `bun build packages/document-ui/DocumentReviewSurface.tsx --target browser --outdir /tmp/plannotator-document-ui-build` after ADR self-review fixes. -- `VITE_DOCUMENT_SURFACE=1 bun run --cwd apps/hook build` after ADR self-review fixes. -- `bun run --cwd apps/hook build` after ADR self-review fixes. -- `bun run --cwd apps/review build` after ADR self-review fixes. -- `git diff --check` after ADR self-review fixes. -- `bun run typecheck` after provider-owned image display threading. -- `bun build packages/document-ui/DocumentReviewSurface.tsx --target browser --outdir /tmp/plannotator-document-ui-build`. -- `VITE_DOCUMENT_SURFACE=1 bun run --cwd apps/hook build`. -- `bun run --cwd apps/hook build`. -- `bun run --cwd apps/review build`. -- `git diff --check`. -- `bun test packages/document-ui/DocumentReviewSurface.test.tsx packages/ui/components/html-viewer/bridge-script.test.ts`: 16 passing tests. -- `bun test packages/document-ui/documentReviewChrome.test.ts packages/document-ui/DocumentReviewSurface.test.tsx`: 55 passing tests. -- `bun test packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/editor/documentSurfaceBridge.test.ts`: 3 passing tests. -- `bun test packages/editor/documentSurfaceBridge.test.ts`: 2 passing tests. -- `bun test packages/editor/documentSurfaceBridge.test.ts packages/document-ui/DocumentReviewSurface.test.tsx`: 13 passing tests. -- `bun test packages/document-ui/documentAIContext.test.ts`: 5 passing tests. -- `bun test packages/document-ui/documentFeedbackText.test.ts`: 5 passing tests. -- `bun test packages/document-ui/documentFeedbackSubmission.test.ts`: 6 passing tests. -- `bun test packages/document-ui/documentAgentDelivery.test.ts`: 4 passing tests. -- `bun test packages/document-ui/plannotatorHttpApi.test.ts`: 12 passing tests. -- `bun test packages/document-ui/documentEditDisplay.test.ts`: 6 passing tests. -- `bun test packages/document-ui/documentEditorSession.test.ts`: 5 passing tests. -- `bun test packages/document-ui/DocumentReviewSurface.test.tsx`: 11 passing tests. -- `bun test packages/document-ui/editFeedback.test.ts packages/document-ui/plannotatorFeedback.test.ts`: 29 passing tests. -- `bun test packages/document-ui/plannotatorSourceDocuments.test.ts`: 60 passing tests. -- `bun test packages/document-ui/documentLinkedState.test.ts`: 20 passing tests. -- `bun test packages/document-ui/documentReviewState.test.ts`: 14 passing tests. -- `bun test packages/document-ui/documentEditChrome.test.ts`: 12 passing tests. -- `bun test packages/document-ui/documentReviewChrome.test.ts`: 43 passing tests. -- `bun test packages/document-ui/documentReviewLeftSidebar.test.ts`: 5 passing tests. -- `bun test packages/document-ui/documentReviewRightPanel.test.ts`: 5 passing tests. -- `bun test packages/document-ui/documentWideMode.test.ts packages/editor/wideMode.test.ts`: 18 passing tests. -- `bun test packages/ui/components/html-viewer/bridge-script.test.ts`: 4 passing tests. -- `bun run --cwd apps/review build`. -- `bun run --cwd apps/hook build`. -- `VITE_DOCUMENT_SURFACE=1 bun run --cwd apps/hook build`. -- `bun build packages/document-ui/DocumentReviewSurface.tsx --target browser --outdir /tmp/plannotator-document-ui-build`. -- `bun run typecheck`. -- `git diff --check`. - -## Next Slice - -The next highest-value extraction is the remaining app-shell coupling around the review surface: sticky toolstrip components, sidebars, plan diff/archive/goal setup, source-file discard/reload side effects, and actual editor/toolbar layout still live directly in `packages/editor/App.tsx`. Text assembly, submission interpretation, annotate feedback target selection, root annotation state, annotation visibility/counting, annotation provider-mutation routing, linked-document file badge/count summaries, linked-document editable-load decisions, linked-document back edit-state decisions, linked-document editable snapshot decisions, linked-message annotation cache/counting/current-state/scope/selection/count-summary decisions, multi-message feedback entry assembly, annotation remapping/highlight-restore decisions, Ask AI context assembly, Plannotator route payload shapes, Plannotator route calls, Plannotator host-session/editor load-plan mapping, opt-in `DocumentReviewSurface` app bridge, saved-change validation decisions, action lifecycle state, review chrome copy, document chrome identity/labels, direct-edit content resolution, direct-edit feedback/panel decisions, direct-edit begin/change state decisions, direct-edit commit/discard/draft-restore decisions, direct-edit commit/discard/draft-restore display decisions, current direct-edit feedback-section gating, unsaved writeback continuation decisions, review action-intent decisions, submit/print shortcut gate decisions, version/diff edit-block decisions, document-navigation edit-block decisions, document layout width state, header action-state decisions, viewport visibility, left-sidebar state/layout/tab visibility/tab open-toggle decisions/sidebar preference decisions, right-panel state/visibility/toggle/reveal decisions, wide/focus layout mode enter/toggle/exit decisions, edit/writeback chrome decisions, writeback edit-session chrome state, edit-exit transition decisions, save-shortcut writeback routing, markdown edit availability, shared edit-panel presentation for unsaved/saved writeback edits, shared default renderer editor-session lifecycle, shared edit-display effect planning, Plannotator local source-save request/response mapping, source-save result application/display classification, source-backed edit-session begin/change/commit classification, source-backed edit-session begin/change display classification, source-backed edit-commit display classification, source-file discard/reload outcome/display classification, missing source-file selection display classification, source-backed draft restore display/edit-display classification, source-document reconcile event classification, and generic agent-delivery state now sit in `@plannotator/document-ui`; the terminal runtime, prompt formatting, plan-mode warnings, Claude Code issue-link markup, route policy, provider capability flags, agent checks, DOM event wiring, host notes/export fallback behavior, external annotation route calls, checkbox visual overrides, and Plannotator compatibility-store/toast/panel side effects remain host-owned. - -## References - -- Research: `adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md` -- Synthesis: `adr/research/synthesis-document-ui-extraction-20260620-082343.md` -- Spec: `adr/specs/document-ui-extraction-20260620-083307.md` -- Decision: `adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md` diff --git a/adr/implementation/document-ui-extraction-roadmap-20260622.md b/adr/implementation/document-ui-extraction-roadmap-20260622.md deleted file mode 100644 index 9de0af989..000000000 --- a/adr/implementation/document-ui-extraction-roadmap-20260622.md +++ /dev/null @@ -1,93 +0,0 @@ -# Document UI Extraction — Phased Roadmap - -Date: 2026-06-22 · Baseline commit: `30cfcebb` - -> The order of operations for making `packages/ui` reusable by the commercial Workspaces app. Governed by **ADR 004**; the per-step detail (exact seams, files, line numbers) lives in the verified plan `adr/specs/document-ui-extraction-plan-verified-20260622-184500.md`; the safety net is `adr/implementation/document-ui-parity-checklist-20260622.md`. -> -> **THE LAW:** move + decouple, never rewrite. Plannotator's experience cannot change. Every step = lift a URL/global to an optional prop whose **default is today's literal**, logic untouched. -> -> **The rhythm, every step:** make one small change → run the parity checklist → confirm identical → commit → next. Small steps, a human eyeballing the result. No multi-day unattended runs. - -## Phase ordering at a glance - -| Phase | What | Risk to Plannotator | Can Workspaces start? | -|---|---|---|---| -| 0 | Safety net (parity baseline + checklist) | none (no code change) | — | -| 1 | Packaging unblock | none (invisible) | — | -| 2 | Three foundation seams (storage, image, scroll-context) | low | — | -| 3 | Rendering stack (theme, markdown, viewer, html, editor) | low | **Yes — after this** | -| 4 | Navigation (sidebar + file tree) | medium (file tree SSE) | builds on it | -| 5 | Comments / annotations / drafts | medium (move verbatim) | builds on it | -| 6 | Optional extras (versions, settings, sharing, AI) | low–medium | as needed | -| 7 | Glue cleanup + publish | low | consumes the published package | - ---- - -## Phase 0 — Safety net (do once, before any code change) -**Goal:** be able to prove Plannotator didn't change after every step. **Risk:** none. -- [ ] Capture the automated baseline (Part A of the checklist): `typecheck`, `bun test` count, all three builds, bundle fingerprint → save to `scratchpad/parity-baseline.txt`. -- [ ] Keep a baseline build (and/or screenshots of each mode) to diff against. -- [ ] Confirm the checklist covers every mode you ship. - -## Phase 1 — Packaging unblock (invisible; gates external install) -**Goal:** make `@plannotator/ui` installable by an outside repo, with zero runtime change. **Risk:** none (no pixel changes). See verified plan "Step 0." -- [ ] Add the missing `dompurify` dependency at the root's exact `^3.3.3`. -- [ ] Resolve the two internal `workspace:* / private` packages (`@plannotator/ai`, `@plannotator/shared`) — publish them, or inline the ~11 verified browser-safe subpaths the UI value-imports. -- [ ] Add a `peerDependencies` block (react, react-dom, tailwindcss, tailwindcss-animate, radix set, lucide-react); keep as devDeps for in-repo typecheck. -- [ ] Fix the stale `tsconfig.json:21` alias (points at a nonexistent file); align the `diff` version (`^8.0.3` → `^8.0.4`). -- [ ] Add a `files` allowlist (assets, sprites, themes; exclude `*.test.*`). -- [ ] Keep source-only exports (no dist build); document required consumer bundler settings. -- **Guardrail:** builds byte-identical; in-repo React still resolves to one copy. - -## Phase 2 — Three foundation seams (everything else leans on these) -**Goal:** decouple the three cross-cutting pieces first so later phases are clean. **Risk:** low. -- [ ] **Storage adapter** — inject a `{getItem,setItem,removeItem}` into the cookie layer (`utils/storage.ts`); default = current cookie impl; **keep literal `plannotator-*` keys**. (Underlies ~24 modules — theme, layout prefs, identity.) -- [ ] **Image resolver** — the single `getImageSrc` shared by 5 consumers; module-level override, default = today's `/api/image` body verbatim, stable identity. -- [ ] **Scroll/layout context** — ship a `ScrollViewportContext` provider with the package (today its only provider lives in the glue at `App.tsx:3888`). -- **Guardrail:** identical cookie keys; all images emit identical URLs; sticky headers / TOC scroll / pinpoint unchanged. - -## Phase 3 — Rendering stack (the first visible win) -**Goal:** a document renders with the Plannotator look outside the app. **Risk:** low (Viewer is the one "risky" item; gate its validation call). -- [ ] Theme & tokens (`theme.css` + 51 `themes/*.css` + `print.css` as one atomic move). -- [ ] Markdown parsing + block rendering (BlockRenderer, blocks, inline transforms) — mostly transfer-as-is. -- [ ] Document Viewer — gate the unconditional `/api/doc/exists` validation (`Viewer.tsx:532`); default on. -- [ ] Doc-fetch seam for InlineMarkdown hover preview (`/api/doc`). -- [ ] Raw HTML viewer. -- [ ] Markdown editor (41-line shim over the published editor packages). -- **Milestone:** 👉 **Workspaces can start building in parallel here** — render docs while the rest proceeds. - -## Phase 4 — Navigation (sidebar + file tree) -**Goal:** the file-tree experience Workspaces is built around. **Risk:** medium (file-tree live updates). -- [ ] Sidebar shell + tabs (`SidebarContainer`/`SidebarTabs`/`useSidebar`) — already prop-driven, transfer-as-is. -- [ ] File tree: lift `useFileBrowser`'s fetch URLs **and the entire SSE watcher effect verbatim** into a default object; `useFileBrowser()` stays callable with zero args. -- **Guardrail:** existing `useFileBrowser.test.tsx` stays green **without modification** (if it needs rewriting, the default changed). - -## Phase 5 — Comments / annotations / drafts (the big one) -**Goal:** the core collaborative piece (teammates + agents commenting). **Risk:** medium — move the timing-sensitive parts verbatim. Last among core work because it touches the most. -- [ ] Draft transport seam (5 `/api/draft` fetches) — **document the 3-party draft-generation protocol** (escapes into approve/deny bodies; server tombstone-gates). -- [ ] External-annotations transport — move the **entire** SSE + polling-fallback effect verbatim into a default `subscribe()`. -- [ ] Identity seam — `author?`/`isCurrentUser?` props defaulting to the live `identity.ts` functions at the call site. -- **Guardrail:** approve/deny still carry the draft generation; live updates + fallback identical; `(me)` badge + author stamping intact. Note: highlight restoration is renderer-coupled — Workspaces must reuse BlockRenderer+InlineMarkdown as a unit. - -## Phase 6 — Optional extras (only when Workspaces needs them) -**Risk:** low–medium. Do not build preemptively. -- [ ] Versions / plan diff (inject fetchers; optional `onOpenVscodeDiff`; resolve the diff CSS that lives in the app shell). -- [ ] Settings / config (configStore write-back seam; obsidian-detect seam; storage adapter from Phase 2). -- [ ] Sharing / export / notes (`onSaveToNotes` seam; keep notes-tab gate verbatim). -- [ ] Ask AI (extract only the 5 fetch literals behind a `transport`; **do not touch** the SSE reader loop or epoch guards; capabilities/provider-resolution stay in the shell). - -## Phase 7 — Glue cleanup + publish -**Risk:** low. -- [ ] Move `packages/editor/wideMode.ts` → `packages/ui/utils/wideMode.ts` (pure move + one import-path edit). -- [ ] **Leave the coordinators in the glue** — right-panel/wide-mode/agent-terminal teardown, auto-open/close sidebar policy, tab-visibility + archive lazy-fetch, AI capabilities/provider init, panel-resize CSS-var writes. Workspaces writes its **own** thin coordinator over the same prop-driven primitives. (Re-deriving these generically is the forbidden path.) -- [ ] Publish `@plannotator/ui`; Workspaces installs and builds its own app/glue against it. - ---- - -## Hard guardrails (never violate) -1. **Default === today's literal.** Every seam ships with the current behavior as the default; Plannotator passes nothing and is unchanged. -2. **Move verbatim, never re-derive.** Especially the SSE transports, draft-generation protocol, configStore batching, and AI reader loop — copy them; do not "simplify." -3. **Never change the storage default** — inject per host; keep `plannotator-*` keys. -4. **Keep glue coordinators opaque** — they entangle side effects; genericizing them is how the last attempt broke. -5. **Run the parity checklist after every step.** Green automated checks are not enough — eyeball the app. -6. **Never delete a working path until parity is confirmed by a human**, mode by mode. diff --git a/adr/implementation/document-ui-parity-checklist-20260622.md b/adr/implementation/document-ui-parity-checklist-20260622.md deleted file mode 100644 index 013ffe3d9..000000000 --- a/adr/implementation/document-ui-parity-checklist-20260622.md +++ /dev/null @@ -1,92 +0,0 @@ -# Document UI — Parity Checklist ("did it break?") - -Date: 2026-06-22 · Baseline commit: `30cfcebb` - -> The safety net for the document-ui extraction (see ADR 004 + the verified plan `adr/specs/document-ui-extraction-plan-verified-20260622-184500.md`). **THE LAW: Plannotator's experience cannot change.** Run this checklist after *every* extraction step. If anything below differs from baseline, the step changed behavior — stop and fix before continuing. Passing automated checks are necessary but NOT sufficient; the manual click-through is the part that actually catches regressions (last time, tests were green while the app was broken). - -## How to use this -1. Before starting work, capture the **baseline** (Part A) once. Save the outputs. -2. After each extraction step: re-run Part A and compare, then walk Part B for the surfaces the step could touch (when in doubt, walk all of it). -3. Green automated + identical manual = the step preserved parity. Proceed. - ---- - -## Part A — Automated baseline (fast, run every step) - -Run from repo root. Record PASS/FAIL + the bundle fingerprint. - -```bash -bun run typecheck # must pass -bun test # record pass count; must not drop vs baseline -bun run build:hook # must succeed -bun run build:review # must succeed -bun run build:opencode # must succeed -# fingerprint the shipped artifacts — compare hash before/after a step: -find apps/hook/dist apps/opencode-plugin -name '*.html' -type f -exec shasum {} \; | sort -``` - -- [ ] `typecheck` passes -- [ ] `bun test` pass count ≥ baseline (note the number) -- [ ] all three builds succeed -- [ ] **bundle fingerprint recorded** (for a pure code-move step with defaults intact, the hashes should be byte-identical; if they change, understand exactly why) - -> Baseline capture (do once, fill in): typecheck ____ · test count ____ · build ____ · hashes saved to `scratchpad/parity-baseline.txt` - ---- - -## Part B — Manual click-through (the real test) - -Launch the relevant surface and confirm each item looks and behaves **identically to baseline**. Tip: keep a baseline build/screenshots of each screen to diff against. - -### Plan Review (`ExitPlanMode` flow, or `bun run dev:hook`) -- [ ] Plan renders: headings, code blocks, tables, callouts, alerts, task lists, images, links -- [ ] Theme correct on first paint (no flash/FOUC), theme switch works -- [ ] Select text → annotation toolbar appears → add comment / deletion / global comment -- [ ] Comment shows the right author, `(me)` badge on your own -- [ ] Sidebar: Table of Contents, Version Browser, Archive tabs all open and work -- [ ] Plan diff: `+N/-M` badge → toggle diff → rendered + raw modes → annotate a diff block -- [ ] Approve, and Deny-with-feedback both deliver correctly -- [ ] Export / Share (copy link + short URL) / Import round-trips -- [ ] Settings opens; AI providers, theme, identity all present -- [ ] Keyboard shortcuts: `Mod+Enter` submit, `Mod+P` print, sidebar toggles, wide mode - -### Annotate file (`plannotator annotate `) -- [ ] File renders with full markdown/PFM support -- [ ] Edit the doc → Save → source file on disk updates; saved-change banner correct -- [ ] Draft autosave/restore survives a reload -- [ ] Code-file links open the code popout; code annotations create + submit -- [ ] Send annotations delivers feedback - -### Annotate folder (`plannotator annotate /`) -- [ ] File tree renders with badges + writeback status -- [ ] Expand/collapse folders; open files; **live updates** when a file changes on disk -- [ ] Per-file annotations stay associated; multi-file feedback assembles correctly - -### Annotate last message / raw HTML -- [ ] Annotate-last: recent message(s) show; switching messages restores their annotations; feedback carries the message id/scope -- [ ] Raw HTML: renders, annotate, share produces portable HTML with assets - -### Archive / Goal setup -- [ ] Archive view lists saved decisions with approved/denied badges; read-only render -- [ ] Goal setup surface submits and closes - -### External / editor annotations (if applicable) -- [ ] External annotations posted to the API appear live (and update/delete reflect) -- [ ] VS Code editor annotations appear and are included in feedback (VS Code mode) - -### Cross-cutting visual -- [ ] All themes render (spot-check a few); print mode CSS intact -- [ ] Wide/focus mode hides/restores panels correctly -- [ ] Panel resize + sidebar collapse behave the same -- [ ] Images load everywhere (markdown body, inline, HTML blocks, comment attachments, re-edit previews) - ---- - -## What "fail" looks like (high-risk regressions to watch — from the audit) -- Theme/layout/identity **forgets settings** → the cookie-storage default got swapped instead of injected. -- **Some images load, some don't** → a `getImageSrc` call site was missed. -- **`(me)` badge or comment author missing** → identity default became empty instead of the live function. -- **Open `
` collapse on re-render** → a non-stable callback was threaded into a memoized block. -- **Sticky headers / scroll-to-anchor / TOC scroll broken** → ScrollViewport provider not mounted. -- **Plan-diff blocks render unstyled** → CSS that lives in the app shell wasn't accounted for. -- **Live updates stop / ghost drafts reappear** → an SSE or draft-generation protocol was re-derived instead of moved verbatim. diff --git a/adr/implementation/document-ui-parity-cutover-intent-20260621-122245.md b/adr/implementation/document-ui-parity-cutover-intent-20260621-122245.md deleted file mode 100644 index 8d4593514..000000000 --- a/adr/implementation/document-ui-parity-cutover-intent-20260621-122245.md +++ /dev/null @@ -1,268 +0,0 @@ -# Document UI Parity Cutover Intent - -> ⚠️ **REVERTED — DO NOT IMPLEMENT.** Implementation log of the failed cutover (reverted 2026-06-22). Corrected plan: **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`**. History only. - -Date: 2026-06-21 - -## What - -We are finishing the `@plannotator/document-ui` extraction by making it the production document-review surface for Plan Review and Annotate. The package already contains much of the provider-neutral document state, rendering, writeback, draft, linked-document, annotation, feedback, image, and Plannotator adapter work. The remaining intent is to close the parity gaps, remove the `VITE_DOCUMENT_SURFACE` opt-in path, and shrink `packages/editor/App.tsx` into a Plannotator host shell instead of keeping it as a second document-review implementation. - -The shared package should own the reusable experience that Workspaces also needs: markdown and raw HTML review, annotations, attachments, linked documents, document trees, document/message navigation, edit/writeback states, drafts, feedback assembly, version/diff browsing, generic Ask AI surface behavior, code/link previews, and the default chrome around those workflows. Plannotator-specific policy should remain outside the package: routes, settings, share/export/note behavior, archive storage, goal setup, terminal runtime, plugin/hook behavior, and local source-save transport details. - -## Why - -The point of this branch is not to create an optional renderer beside the old app. The point is to make the Plannotator document review experience reusable by a sister Workspaces repo without forcing Workspaces to reimplement the hard state machine in `App.tsx`. If the package stays opt-in and the old shell remains the real product path, the extraction fails in practice: Workspaces gets components, but not the product behavior users recognize. - -The provider boundary matters because Plannotator local source-save and Workspaces document writeback are different implementations of the same user-facing states. Plannotator uses `/api/source/save`, disk hashes, mtime, file watches, missing local files, and local drafts. Workspaces will use document ids, manifests, versions, `If-Match`, annotation APIs, and workspace-specific missing/conflict behavior. The UI should share clean, dirty, saving, saved, conflict, missing, error, draft restore, feedback assembly, and version diff behavior without requiring either provider to pretend it is the other. - -## How - -The implementation should proceed by closing package parity first, then cutting over the app. The first major missing package capability is provider-neutral version and diff support: add host API methods for listing and loading document versions, map Plannotator's `/api/plan/versions` and `/api/plan/version`, and move the version browser, diff view modes, diff annotations, and edit-blocking behavior into `@plannotator/document-ui`. This should be optional capability, so Workspaces can implement it with its own versions API and hosts without versions do not see the UI. - -After version/diff, the default `DocumentReviewSurface` chrome needs to reach visible parity with the old shell for the generic document workflows: toolstrip, sticky controls, wide/focus controls, sidebars, panel resize/collapse behavior, folder empty state, file/message navigation, linked-document chrome, code/link preview, shortcuts, and raw HTML controls. File and message browsing should become provider-neutral document navigation through `DocumentRef` and `DocumentTreeNode`, while local filesystem containment, vault retry behavior, and Plannotator route details stay in the Plannotator adapter or host. - -Writeback should then become authoritative inside the package. The old app should stop owning duplicate source-save UI state, edit/save/discard/reload-conflict behavior, draft restore decisions, and direct feedback assembly. Plannotator source-save remains first-class in the Plannotator adapter, but the shared contract remains writeback-oriented rather than disk-oriented. - -Ask AI should move only as far as the reusable document-review surface. The package can own document context, an AI panel shell, and in-document ask affordances when `hostApi.askAI` exists. The host keeps provider/model settings, auth, permission handling, terminal fallback behavior, and provider-specific transport. - -Terminal, archive, goal setup, settings, export/share/import, and note integrations should be mounted around or beside the package surface through host shell code and slots. The terminal runtime, PTY bridge, installer, remote-mode security, archive storage, goal setup semantics, and note-app policy are not shared document-ui responsibilities. - -The final cutover is to remove the feature flag and delete the old document render path. At that point `packages/editor/App.tsx` should load the Plannotator session, configure the Plannotator document API and host slots, render completion/modals that remain Plannotator-owned, and mount `DocumentReviewSurface`. It should no longer directly orchestrate `Viewer`, `HtmlViewer`, `PlanDiffViewer`, `AnnotationPanel`, `usePlanDiff`, `useLinkedDoc`, `useArchive`, file/message navigation, source-save UI state, or feedback assembly for the main document path. - -Completion means the normal app renders through `@plannotator/document-ui` with no `VITE_DOCUMENT_SURFACE` flag, the old document-review path is gone, existing Plannotator workflows still work, and Workspaces can implement the same UI through `DocumentHostApi` without inheriting local source-save vocabulary. - -## Implemented Slice: Provider-Neutral Version/Diff - -The first cutover slice moved version/diff support into the package boundary instead of leaving it only in the old editor shell. - -`@plannotator/document-ui` now defines provider-neutral version types and host API methods for listing and loading document versions. `DocumentReviewSurface` owns version state through `useDocumentVersions`, computes markdown diffs through the shared diff engine, exposes version state through the render state, renders a default version navigator, and can switch the document body into a package-owned clean/classic/raw diff view. - -The Plannotator HTTP adapter maps the existing `/api/plan/versions` and `/api/plan/version` routes into the provider-neutral contract. It also seeds the session with `previousPlan` and `versionInfo`, so the package can show previous-version changes without forcing an immediate extra fetch. The memory host API now supports version seeds, version listing, and version loading so Workspaces-like behavior can be tested without local Plannotator routes. - -This slice deliberately does not move Plannotator's VS Code diff route into the shared package. That route is local host policy. The package now owns the reusable document diff surface; richer route-specific actions can be added later through host actions or slots. - -Verification: - -- `bun test packages/document-ui`: 332 passing tests. -- `bun test packages/document-ui/DocumentReviewSurface.test.tsx packages/document-ui/plannotatorHttpApi.test.ts packages/document-ui/memoryDocumentHostApi.test.ts`: 37 passing tests. -- `bun run --cwd packages/document-ui typecheck`. -- `bun build packages/document-ui/DocumentReviewSurface.tsx --target browser --outdir /tmp/plannotator-document-ui-build`. -- `git diff --check`. - -Full repo `bun run typecheck` is still blocked before `packages/document-ui` by existing `packages/ui` type errors in `AnnotationToolbar.tsx` and `config/settings.ts`; the document-ui package typecheck itself passes. - -## Implemented Slice: Default Surface Routing and Delivery Parity - -The next cutover slice removed the runtime `VITE_DOCUMENT_SURFACE` gate from the editor app. `packages/editor/App.tsx` now routes normal Plan Review and Annotate document sessions through `PlannotatorDocumentSurfaceBridge` by default when a `DocumentReviewSession` is available. The bridge eligibility is now mode-based (`plan-review`, `annotate`, `annotate-folder`, `annotate-message`) and explicitly leaves shared sessions, archive, goal setup, and demo fallback on the host shell for now. - -The package action contract now carries draft generation and approve feedback. `DocumentReviewSurface` passes those values to the host API for submit, approve, and exit actions. The Plannotator HTTP adapter maps those provider-neutral actions through the existing production delivery helpers: - -- Plan Review feedback uses `/api/deny` with plan-save settings and draft generation. -- Plan Review approval uses `/api/approve` with plan-save, permission mode, agent switch, note-app settings, optional approve-with-feedback text, document text, and draft generation. -- Annotate feedback uses `/api/feedback` with annotations, code annotations, message scope, and draft generation. -- Annotate approve/exit use the existing draft-generation query parameter routes. - -The editor bridge remains the owner of Plannotator host policy. It supplies plan-save settings, permission mode, agent-switch preference, note-app configuration, and note auto-save status through the adapter context instead of moving those settings into `@plannotator/document-ui`. - -Shared feedback rendering now includes direct-edit and saved-file-change sections in addition to annotations, linked-document feedback, and code annotations. That keeps edit feedback intact when the package surface submits through the Plannotator adapter. - -This slice also fixed the previous full-repo typecheck blockers in `packages/ui/components/AnnotationToolbar.tsx` and `packages/ui/config/settings.ts`. - -Verification: - -- `bun test packages/editor/documentSurfaceBridge.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/document-ui/DocumentReviewSurface.test.tsx packages/document-ui/plannotatorHttpApi.test.ts`: 32 passing tests. -- `bun test packages/document-ui packages/editor/documentSurfaceBridge.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx`: 337 passing tests. -- `bun test packages/editor`: 51 passing tests, 7 skipped existing hook tests. -- `bun run typecheck`. -- `bun run --cwd apps/hook build`. -- `git diff --check`. -- `rg -n "VITE_DOCUMENT_SURFACE|USE_DOCUMENT_SURFACE" packages apps --glob '!**/dist/**' --glob '!**/node_modules/**'` returned no code matches. - -Remaining cutover work: the old `packages/editor/App.tsx` document-review implementation is still present for archive, goal setup, shared sessions, and fallback/demo paths, and much of the old main-path code still exists in the file even though normal Plan Review and Annotate sessions now route through the package bridge. The final cleanup still needs host-slot parity for settings/share/export/note/archive/goal/terminal surfaces and then deletion of the duplicate old document-review orchestration. - -## Implemented Slice: Host Router Before Legacy Shell - -The default app no longer enters the legacy `App.tsx` hook graph before mounting the shared document surface. `packages/editor/App.tsx` now exports a thin router that renders `PlannotatorDocumentSurfaceHost` first, with the renamed legacy shell (`LegacyPlannotatorApp`) only as a fallback. - -`PlannotatorDocumentSurfaceHost` owns the Plannotator host bootstrap for normal document sessions: it loads `/api/plan` through the Plannotator document adapter, initializes config, chooses package-surface eligibility from the `DocumentReviewSession`, handles first-time Claude permission setup, preserves plan-arrival note auto-save behavior, computes completion copy, and mounts `PlannotatorDocumentSurfaceBridge`. Shared URL shapes (`/p/` and share-looking hash payloads) bypass package preloading so the existing legacy share loader can still restore shared documents without an API session. - -The legacy shell no longer stores a `documentSurfaceSession` or contains a second `PlannotatorDocumentSurfaceBridge` early return. That means normal Plan Review and Annotate sessions reach `@plannotator/document-ui` before legacy annotation, edit, diff, sidebar, and viewer hooks mount. Archive, goal setup, shared sessions, and demo/API-failure fallback still use the legacy shell. - -Verification: - -- `bun test packages/editor/PlannotatorDocumentSurfaceHost.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/editor/documentSurfaceBridge.test.ts`: 5 passing tests. -- `bun test packages/editor`: 53 passing tests, 7 skipped existing hook tests. -- `bun test packages/document-ui`: 334 passing tests. -- `bun run typecheck`. -- `bun run --cwd apps/hook build`. -- `git diff --check`. - -Remaining cutover work: move or slot the still-host-owned archive, goal setup, shared-session, settings/share/export/note, and terminal surfaces so the legacy shell can be deleted instead of retained as fallback. The renamed `LegacyPlannotatorApp` still contains the old document-review orchestration for those fallback paths. - -## Implemented Slice: Thin Editor Entrypoint - -The editor entrypoint is now a real host shell instead of the giant legacy implementation. The previous `packages/editor/App.tsx` body was moved to `packages/editor/LegacyPlannotatorApp.tsx`, and `packages/editor/App.tsx` is now a small wrapper that mounts `PlannotatorDocumentSurfaceHost` with `LegacyPlannotatorApp` as fallback. - -This does not delete the old implementation yet, but it makes the production entrypoint shape match ADR 003: the app entry configures a Plannotator host route and the package surface is tried first. The old document-review orchestration is isolated behind a legacy fallback module for archive, goal setup, shared URLs, and demo/API-failure cases. - -Verification: - -- `wc -l packages/editor/App.tsx packages/editor/LegacyPlannotatorApp.tsx packages/editor/PlannotatorDocumentSurfaceHost.tsx` showed `App.tsx` at 9 lines and the legacy fallback isolated in `LegacyPlannotatorApp.tsx`. -- `bun test packages/editor/PlannotatorDocumentSurfaceHost.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/editor/documentSurfaceBridge.test.ts`: 5 passing tests. -- `bun test packages/editor`: 53 passing tests, 7 skipped existing hook tests. -- `bun test packages/document-ui`: 334 passing tests. -- `bun run typecheck`. -- `bun run --cwd apps/hook build`. -- `git diff --check`. - -Remaining cutover work: delete `LegacyPlannotatorApp.tsx` by replacing its remaining fallback responsibilities with package-owned document review behavior plus small host-owned shells/slots for archive, goal setup, shared-session loading, settings/share/export/note, and terminal runtime. - -## Implemented Slice: Goal Setup Host Cutover - -Goal setup is now routed before the legacy shell. `PlannotatorDocumentSurfaceHost` recognizes `mode: "goal-setup"` from the Plannotator adapter, normalizes the goal setup bundle, initializes host config, and renders a new `PlannotatorGoalSetupHost` instead of falling through to `LegacyPlannotatorApp`. - -This keeps the ADR boundary intact: goal setup remains host-owned environment workflow, not package-owned document review behavior. The new host shell wraps the existing `GoalSetupSurface`, supplies the top-level Submit and Close actions, posts close through the existing `/api/exit` endpoint, and uses the same completion overlay copy as the document surface. - -Verification: - -- `bun test packages/editor/PlannotatorGoalSetupHost.test.tsx packages/editor/PlannotatorDocumentSurfaceHost.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/editor/documentSurfaceBridge.test.ts`: 6 passing tests. -- `bun test packages/editor`: 54 passing tests, 7 skipped existing hook tests. -- `bun test packages/document-ui`: 334 passing tests. -- `bun run typecheck`. -- `bun run --cwd apps/hook build`. - -Remaining cutover work: delete `LegacyPlannotatorApp.tsx` by replacing its remaining fallback responsibilities with package-owned document review behavior plus small host-owned shells/slots for archive, shared-session loading, settings/share/export/note, and terminal runtime. Goal setup no longer needs the legacy shell on the normal API path. - -## Implemented Slice: Archive Host Cutover - -Standalone archive mode is now routed before the legacy shell. `PlannotatorDocumentSurfaceHost` recognizes archive content from the Plannotator adapter and renders a new `PlannotatorArchiveHost` instead of entering `LegacyPlannotatorApp`. - -The archive shell keeps archive storage and lifecycle host-owned. It uses the existing archive API routes (`/api/archive/plans`, `/api/archive/plan`, `/api/done`), reuses `ArchiveBrowser` for the saved-plan list, reuses the existing markdown `Viewer` for rendering archived plans, and keeps the archive completion overlay behavior. The `Viewer` import is browser-lazy so non-browser host imports and unit tests do not load `web-highlighter` before `window` exists. - -Verification: - -- `bun test packages/editor/PlannotatorArchiveHost.test.tsx packages/editor/PlannotatorGoalSetupHost.test.tsx packages/editor/PlannotatorDocumentSurfaceHost.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/editor/documentSurfaceBridge.test.ts`: 7 passing tests. -- `bun test packages/editor`: 55 passing tests, 7 skipped existing hook tests. -- `bun run typecheck`. -- `bun run --cwd apps/hook build`. - -Remaining cutover work: delete `LegacyPlannotatorApp.tsx` by replacing its remaining fallback responsibilities with shared-session loading, demo/API-failure fallback, settings/share/export/note host actions, and terminal runtime slots. Goal setup and standalone archive no longer need the legacy shell on their normal API paths. - -## Implemented Slice: Shared Session Host Cutover - -Shared URL sessions now route before the legacy shell. `PlannotatorDocumentSurfaceHost` still bypasses `/api/plan` preloading for share-shaped URLs, but it now renders `PlannotatorSharedSessionHost` instead of falling through to `LegacyPlannotatorApp`. - -The shared host keeps share/callback policy host-owned while using the package document surface. It decodes hash payloads and short `/p/` paste-service links into a provider-neutral in-memory document session, seeds shared annotations and global attachments into `DocumentReviewSurface`, and disables provider persistence/drafts for the portable shared context. Shared sessions expose a host-owned `Copy Link` header action that assembles an updated share URL from the current package feedback payload. Bot callback links (`cb`/`ct`) are handled as host delivery: package submit/approve actions call back with an updated annotated share URL, but the package remains unaware of Plannotator's share URL format. - -To support host-owned share/export actions without coupling them into the package, `DocumentReviewSurface` header action slots can now be render functions that receive the current feedback payload and action helpers. Existing static slot nodes continue to work. - -Verification: - -- `bun test packages/editor/sharedDocumentSession.test.ts packages/editor/PlannotatorSharedSessionHost.test.tsx packages/editor/PlannotatorDocumentSurfaceHost.test.ts packages/document-ui/DocumentReviewSurface.test.tsx`: 19 passing tests. -- `bun test packages/editor`: 58 passing tests, 7 skipped existing hook tests. -- `bun test packages/document-ui`: 335 passing tests. -- `bun run typecheck`. -- `bun run --cwd apps/hook build`. - -Remaining cutover work: delete `LegacyPlannotatorApp.tsx` by replacing its remaining fallback responsibilities with demo/API-failure fallback, fuller settings/share/export/note host actions, and terminal runtime slots. Normal document review, annotate, goal setup, standalone archive, and shared URL sessions no longer need the legacy shell on their normal paths. - -## Implemented Slice: Annotate Agent Terminal Host Slot Cutover - -Annotate agent terminal delivery is now wired into the package-backed production document surface without moving the terminal runtime into `@plannotator/document-ui`. - -`PlannotatorDocumentSurfaceHost` passes the Plannotator terminal capability from the loaded `/api/plan` session into `PlannotatorDocumentSurfaceBridge`. The bridge keeps the terminal runtime host-owned: it lazy-loads `AnnotateAgentTerminalPanel`, mounts it through the existing `DocumentReviewSurface` terminal slot, and exposes host header actions for opening, hiding, stopping, and sending feedback to the agent terminal. - -The default package submit action now preserves the legacy annotate behavior when a terminal session is ready. The bridge renders the current package feedback payload, wraps it with the Plannotator annotate feedback template, sends it to the terminal, records the delivery key, clears the draft through `DocumentReviewSurface`, and keeps the review session open. Duplicate sends of the same feedback/body/target in the same terminal session are treated as already delivered. If terminal delivery fails, the bridge falls back to the original `/api/feedback` submit path. - -The package contract gained a small provider-neutral submit result flag, `keepSessionOpen`, so host-owned delivery channels can clear drafts without forcing the completion overlay. The package still does not own PTY/WebSocket setup, runtime install, remote-mode security, agent selection, or terminal prompt policy. - -Verification: - -- `bun test packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/document-ui/DocumentReviewSurface.test.tsx`: 17 passing tests. -- `bun run --cwd packages/document-ui typecheck`. -- `bun test packages/editor`: 59 passing tests, 7 skipped existing hook tests. -- `bun run typecheck`. -- `bun test packages/document-ui`: 336 passing tests. -- `bun run --cwd apps/hook build`. -- `git diff --check`. - -Remaining cutover work: delete `LegacyPlannotatorApp.tsx` by replacing its remaining fallback responsibilities with demo/API-failure fallback and fuller settings/share/export/note host actions. The annotate terminal no longer needs the legacy shell on the normal package-backed annotate file/folder paths. - -## Implemented Slice: Legacy Shell Deletion And Package Fallback - -The editor app no longer imports or mounts `LegacyPlannotatorApp`. `packages/editor/App.tsx` now mounts `PlannotatorDocumentSurfaceHost` directly, and the previous legacy fallback branch in `PlannotatorDocumentSurfaceHost` has been replaced by a package-backed fallback route. - -The fallback route is intentionally small and host-owned. If there is no active `/api/plan` session, or if a future unsupported session mode reaches the host, `PlannotatorDocumentSurfaceFallback` renders the existing demo plan through `DocumentReviewSurface` with an in-memory provider. This keeps development/no-server behavior available without preserving a second document-review implementation. - -`packages/editor/LegacyPlannotatorApp.tsx` has been deleted. Source checks now confirm there are no remaining `LegacyPlannotatorApp`, `status: 'legacy'`, `VITE_DOCUMENT_SURFACE`, `USE_DOCUMENT_SURFACE`, or `documentSurfaceSession` references in production packages/apps outside built artifacts. - -Verification: - -- `bun test packages/editor`: 60 passing tests, 7 skipped existing hook tests. -- `bun test packages/document-ui`: 336 passing tests. -- `bun run typecheck`. -- `bun run --cwd apps/hook build`. -- `git diff --check`. -- `test ! -e packages/editor/LegacyPlannotatorApp.tsx`. -- `rg -n "LegacyPlannotatorApp|status: 'legacy'|VITE_DOCUMENT_SURFACE|USE_DOCUMENT_SURFACE|documentSurfaceSession" packages apps --glob '!**/dist/**' --glob '!**/node_modules/**'` returned no matches. - -Remaining cutover work: fuller Plannotator host actions for settings, share/export/import, and quick note saves still need to be mounted around the package surface. The duplicate legacy document-review implementation is no longer present. - -## Implemented Slice: Plannotator Host Actions Around Package Surface - -Normal package-backed Plan Review and Annotate sessions now have Plannotator host actions mounted through the `DocumentReviewSurface` header slot. `PlannotatorDocumentSurfaceBridge` renders the existing host-owned `PlanHeaderMenu`, `Settings`, `ExportModal`, and `ImportModal` components around the package surface. - -The bridge prepares export/share/note data from the current package feedback payload rather than from legacy app state. It renders annotation output through the shared feedback assembler, uses the active document text or current edit/save payload for exported markdown, generates hash share URLs and paste-service short URLs through the existing Plannotator sharing utilities, downloads annotations, prints, and posts quick note saves to `/api/save-notes` for Obsidian, Bear, and Octarine. Settings remain Plannotator host policy and are conditionally mounted only when opened so server-rendered tests do not load browser-only settings stores. - -The package boundary remains intact: `@plannotator/document-ui` still receives only generic header slots, feedback payload callbacks, and provider-neutral annotation import actions. Plannotator share URLs, paste-service policy, note-app settings, and the settings UI stay in `packages/editor`/`@plannotator/ui`. - -Import review now decodes Plannotator hash links and paste-service short links in the host bridge, converts share payload annotations through the existing sharing utilities, and merges them into the package surface through the provider-neutral annotation import slot action. The host still owns Plannotator URL formats; the package owns only the annotation merge. - -Verification: - -- `bun test packages/editor`: 60 passing tests, 7 skipped existing hook tests. -- `bun test packages/document-ui`: 339 passing tests. -- `bun run typecheck`. -- `bun run --cwd apps/hook build`. -- `git diff --check`. - -Remaining cutover work at this point: package-owned generic Ask AI surface behavior still needs the package default panel and Plannotator HTTP adapter wiring. - -## Implemented Slice: Generic Ask AI And Import Parity - -`DocumentReviewSurface` now renders a provider-neutral Ask AI panel when the session exposes `canUseAskAI` and the host implements `hostApi.askAI`. The package owns document/plan context assembly, the panel shell, and streamed text rendering. The host still owns AI provider/model selection, auth, permission handling, and transport. - -The Plannotator HTTP adapter now implements `hostApi.askAI` over the existing `/api/ai/session` and `/api/ai/query` endpoints. It creates/reuses an AI server session per document review context, forwards context updates when the package context changes, and maps the server SSE stream into package-level `DocumentAskAIEvent` messages. - -Import review parity is also complete for normal package-backed sessions. `DocumentReviewSurface` exposes a provider-neutral `importAnnotations` slot action. `PlannotatorDocumentSurfaceBridge` keeps Plannotator share URL parsing host-owned, decodes hash and short links with the existing sharing utilities, and merges imported annotations/global attachments into the package annotation state without reaching into viewer DOM or highlighter internals. - -Verification: - -- `bun test packages/document-ui`: 339 passing tests. -- `bun test packages/editor`: 60 passing tests, 7 skipped existing hook tests. -- `bun run typecheck`. -- `bun run --cwd apps/hook build`. -- `git diff --check`. -- `test ! -e packages/editor/LegacyPlannotatorApp.tsx`. -- `rg -n "LegacyPlannotatorApp|status: 'legacy'|VITE_DOCUMENT_SURFACE|USE_DOCUMENT_SURFACE|documentSurfaceSession|Import review is not available" packages apps --glob '!**/dist/**' --glob '!**/node_modules/**'` returned no matches. - -Cutover status: the normal app entry no longer keeps a legacy document-review shell or feature-flag path. Plan Review, Annotate file/folder/message, shared sessions, archive, goal setup, fallback/demo, terminal delivery, settings/share/export/import/note actions, version/diff, writeback, drafts, annotation state, feedback assembly, and generic Ask AI now route through the package-backed document surface plus small Plannotator host shells/slots. - -## Self-Review Follow-Up - -The ADR self-review found and fixed two issues after the initial green run. - -First, `PlannotatorDocumentSurfaceBridge` could create a copied short share link from stale prepared export state when the current document required the paste-service short-link path. The bridge now generates short links from an explicit prepared export payload, so copy-share fallback uses the current package feedback payload. - -Second, the Plannotator HTTP adapter showed the generic Ask AI panel for every writable session. The old shell checked `/api/ai/capabilities` and hid AI when no provider was registered. `createPlannotatorHttpDocumentApi().loadSession()` now checks that capabilities route and maps `canUseAskAI` from actual provider availability. - -The self-review also cleaned tab-indented JSX in the touched surface file. - -Verification after self-review: - -- `bun test packages/document-ui`: 339 passing tests. -- `bun test packages/editor`: 60 passing tests, 7 skipped existing hook tests. -- `bun run typecheck`. -- `bun run --cwd apps/hook build`. -- `git diff --check`. diff --git a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md deleted file mode 100644 index 1701e4fc5..000000000 --- a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md +++ /dev/null @@ -1,171 +0,0 @@ -# Document UI Extraction — Phase 0 & 1 Work Log - -Date: 2026-06-22 · Baseline commit: `30cfcebb` - -> Execution record for Phase 0 (safety net) and Phase 1 (packaging unblock) of the roadmap (`adr/implementation/document-ui-extraction-roadmap-20260622.md`). Law: move + decouple, never rewrite; **Plannotator's experience cannot change** — proven below by byte-identical shipped bundles. - -## Phase 0 — Safety net (DONE) - -Captured the known-good baseline at commit `30cfcebb` (saved to `scratchpad/parity-baseline.txt`): - -| Check | Baseline result | -|---|---| -| `bun run typecheck` | PASS (exit 0) | -| `bun test` | **1620 pass / 0 fail**, 1650 ran across 123 files | -| `bun run build:review` / `build:hook` / `build:opencode` | all OK | -| Shipped plan UI hash (`apps/hook/dist/index.html`, `redline.html`, `opencode-plugin/plannotator.html`) | `4ca0cbe9dd85c3674e6122f1e830704076efa129` | -| Shipped review UI hash (`apps/hook/dist/review.html`, `opencode-plugin/review-editor.html`) | `f404d00d9a47785ca925776d48b7a67b2b30b9dd` | - -The reusable click-through checklist lives at `adr/implementation/document-ui-parity-checklist-20260622.md`. The bundle-hash compare is the automated half; the manual click-through is run before any *behavioral* (non-packaging) step. - -## Phase 1 — Packaging unblock (DONE except one decision) - -All changes are package metadata only — no source/runtime change. Files touched: `packages/ui/package.json`, `packages/ui/tsconfig.json`, `bun.lock`. - -### Findings (verified against source before editing) -- **Phantom `dompurify` dependency (real latent bug).** Imported in `packages/ui/utils/sanitizeHtml.ts:1` and `utils/aiChatFormat.ts:3` but absent from `packages/ui/package.json`. Worked in-repo only via root hoisting; would break a standalone install. Root pins `dompurify ^3.3.3`. -- **`diff` version drift.** ui had `^8.0.3`; root has `^8.0.4`. -- **Stale tsconfig alias (dead).** `tsconfig.json:21` mapped bare `@plannotator/shared` → `../shared/index.ts`, which does not exist. Verified **no file in ui imports the bare specifier** — only `@plannotator/shared/*` subpaths (handled by the other, correct alias on the next line). The dead line was inert (typecheck passed with it) but removed for correctness. -- **No `peerDependencies`.** react/react-dom/tailwindcss were plain `dependencies`, risking duplicate-React when consumed by an external app. -- **No `files` allowlist.** A publish would have shipped test files and could have missed assets/themes. - -### Changes made -1. **Added `dompurify ^3.3.3`** to `dependencies` (matches root exactly — a version mismatch could change sanitization output). -2. **Aligned `diff` `^8.0.3` → `^8.0.4`** (matches root). -3. **Added a `peerDependencies` block** — `react`, `react-dom`, `tailwindcss`, `tailwindcss-animate` — and removed them from `dependencies`. Also added the same four to `devDependencies` so in-repo typecheck/build still resolve them. (Scope decision: only the singleton/build-time packages were made peers. Radix, lucide, cva, clsx, tailwind-merge, etc. stay as regular `dependencies` — they are owned by the library and have no duplicate-instance hazard. This is the conventional, lower-risk choice and diverges deliberately from the audit's broader "radix→peer" suggestion.) -4. **Added a `files` allowlist** (source dirs + assets/themes/sprites + `theme.css`/`print.css`/`types.ts`/`globals.d.ts`), excluding `**/*.test.*` and `test-setup`. Preparatory — only affects a future publish. -5. **Removed the dead `tsconfig.json` alias line.** -6. **Kept the source-only `exports` model — no dist build added** (a build could change what Plannotator ships). Consumer bundler requirements to document for Workspaces: `isolatedModules`, the automatic JSX runtime, `allowImportingTsExtensions`, and Tailwind v4 (`@theme inline` is v4-only). - -### Verification (post-change, vs Phase 0 baseline) -| Check | Result | Matches baseline? | -|---|---|---| -| `bun install` | clean, "no changes" to install tree (confirms dep moves didn't perturb resolution) | — | -| `bun run typecheck` | PASS (exit 0) | ✅ | -| `bun test` | **1620 pass / 0 fail** | ✅ identical | -| 3 builds | all OK | ✅ | -| plan UI bundle hash | `4ca0cbe9dd85c3674e6122f1e830704076efa129` | ✅ **byte-identical** | -| review UI bundle hash | `f404d00d9a47785ca925776d48b7a67b2b30b9dd` | ✅ **byte-identical** | - -**Conclusion: Plannotator's shipped app is byte-for-byte unchanged.** The packaging box is now cleaner and closer to installable, with zero impact on the open-source experience. - -## Remaining Phase 1 item — ONE decision required (not done) - -**`@plannotator/ai` and `@plannotator/shared` are `workspace:* / private / 0.0.1`.** This is the one genuine blocker to an external `@plannotator/ui` install (an outside repo cannot resolve `workspace:*` private packages). It was **deliberately not actioned** because it is a strategic fork, and one path (publishing) is outward-facing and needs explicit authorization. Two options: - -- **Option A — Publish `@plannotator/ai` + `@plannotator/shared`** (drop `private`, real versions, push to the registry). Cleanest dependency graph; lets Workspaces also reuse shared logic directly. Cost: two more published packages to maintain/version; needs registry auth. **Outward-facing — requires explicit go-ahead before any publish.** -- **Option B — Inline the browser-safe subpaths ui actually value-imports** into `@plannotator/ui` (verified Web-API-only: compress, crypto, agents, code-file, feedback-templates, project, favicon, agent-jobs, browser-paths, extract-code-paths, goal-setup). Keeps `@plannotator/ui` self-contained, no extra published packages. Cost: code duplication vs `@plannotator/shared`, and it is a real code change (must re-run the full parity verification). - -When this is decided, also revisit the `tsconfig.json` `@plannotator/shared/*` alias (currently correct for in-repo; changes if shared is published/inlined). - -> Note: the `@plannotator/ai` import is `import type` only (erased at compile). Most `@plannotator/shared` imports are also type-only or Web-API-only; verified no `node:*` value imports reach a bundle. So this blocker is about *package resolution for external install*, not about node code leaking into the browser. - -## Phase 2 — Foundation seams (in progress) - -Three cross-cutting seams that later phases depend on. Each: lift the backend wire to an optional override, default = today's behavior. For these *code* changes the bundle hash legitimately changes; parity is proven by behavior tests (+ eyeball where there's something visual to see). - -### Seam 1 — Image resolver (DONE) -- **File:** `packages/ui/components/ImageThumbnail.tsx` (the single `getImageSrc`, shared by 5 consumers: ImageThumbnail, InlineMarkdown, HtmlBlock, AttachmentsButton, Viewer). -- **Change:** extracted the body into `defaultImageSrcResolver` and a module-level `imageSrcResolver` (stable identity); added `setImageSrcResolver(fn)` for a host to override once at startup, and `resetImageSrcResolver()` for tests. `getImageSrc(path, base?)` signature unchanged; it now delegates to the active resolver, default = the verbatim old `/api/image` logic. -- **Why no Viewer-level prop:** a prop can't reach InlineMarkdown/HtmlBlock; the module-level override is the only thing all 5 consumers share. -- **Verified:** default output byte-identical across remote-passthrough, base-append, and absolute-path cases (URL probe); override + reset work; typecheck pass; 1620 tests pass / 0 fail; all 3 builds OK. Dev-mode eyeball N/A — the mock serves no images and this change only affects the URL string (proven identical), so there is nothing visual to regress. - -### Seam 2 — Storage backend (DONE) -- **File:** `packages/ui/utils/storage.ts` (the cookie `getItem`/`setItem`/`removeItem`, sole persistence for ~24 modules: theme, layout/TOC/width prefs, identity, auto-close, etc.). -- **Change:** moved the cookie implementation into a default `cookieBackend: StorageBackend`; added a module-level `backend` (default = cookies), `setStorageBackend(b)` for a host to swap, and `resetStorageBackend()` for tests. `getItem`/`setItem`/`removeItem` now delegate to the active backend; signatures and the `storage` object unchanged. Literal `plannotator-*` keys preserved. -- **Consumers untouched:** the ~24 modules keep calling `getItem`/`setItem` exactly as before. -- **Verified:** seam routes to an injected backend and `resetStorageBackend` restores cookies (in-memory probe); typecheck pass; 1620 tests pass / 0 fail (suite exercises storage through a real DOM); all 3 builds OK; manual eyeball — theme/settings persist across reload (cookie round-trip intact). - -## Phase 3 — Rendering stack (in progress) - -Teed up + adversarially reviewed by the `phase3-rendering-stack` workflow (36→22 agents; tee-up → execute-in-isolated-worktree → parity review → synthesis). Workflow verdicts: **3 noop** (theme, markdown, html-viewer — already decoupled by Phase 2 / already prop-driven, nothing to land), **3 safe** (editor, viewer, scroll), **1 "blocked"** (docfetch — false alarm: the execute worktrees were auto-removed, so the reviewer saw the clean real tree; the spec is sound, just needs real application). Note: the workflow's in-worktree `typecheck`/`tests` were unreliable (missing deps in throwaway worktrees) — landings are verified authoritatively on the real tree against the Phase 0 baseline. All landings done by hand on the real tree with the parity suite. - -### Seam — Markdown editor theme mode (DONE) -- **File:** `packages/ui/components/MarkdownEditor.tsx`. Added optional `mode?` prop; `mode={resolvedMode}` → `mode={mode ?? resolvedMode}`; destructured `mode` out of `...props`. -- **Parity:** Plannotator's only `` call (App.tsx:4261) passes no `mode` → falls to `resolvedMode` → identical. Verified: typecheck pass, 1620 tests / 0 fail, builds OK, App.tsx untouched, no `mode=` caller. - -### Seam — Viewer code-path validation gate (DONE) -- **Files:** `packages/ui/components/Viewer.tsx` + `packages/ui/hooks/useValidatedCodePaths.ts`. Added optional `disableCodePathValidation?` prop threaded to a new `disabled?` arg on the hook; when set, the `/api/doc/exists` probe is skipped (`ready: true`, empty map). Default undefined for Plannotator → validation stays on. Added `disabled` to the effect deps (always undefined for Plannotator → no behavior change). -- **Parity:** no `disableCodePathValidation` caller in editor/apps → Viewer still fires `/api/doc/exists` exactly as today. Verified: typecheck pass, 1620 tests / 0 fail, builds OK, App.tsx untouched. - -### Seam — Doc-fetch (code-file hover preview) (DONE) -- **File:** `packages/ui/components/InlineMarkdown.tsx`. Added `DocPreviewResult`/`DocPreviewFetcher` + module-level `docPreviewFetcher` (default = verbatim `/api/doc` fetch) + `setDocPreviewFetcher`/`resetDocPreviewFetcher`; routed `handleMouseEnter` through it. `useCallback` deps unchanged. -- **Parity:** no `setDocPreviewFetcher` caller → Plannotator still fetches `/api/doc?path=&base=` identically. Verified: typecheck pass, 1620 tests / 0 fail, builds OK. (Hover popover not visible in dev mock — same caveat as images; call is provably identical.) - -### Seam — Scroll viewport provider (DONE) -- **Files:** `packages/ui/hooks/useScrollViewport.ts` (added render-transparent `ScrollViewportProvider` via `createElement` — kept `.ts`, no JSX; fixed the stale OverlayScrollbars doc-comment) + `packages/editor/App.tsx` (import + the two provider tags at 3888/4427: `ScrollViewportContext.Provider value=` → `ScrollViewportProvider viewport=`). -- **Parity:** `ScrollViewportProvider` renders exactly `ScrollViewportContext.Provider value={viewport}` — identical tree/value/position; App.tsx delta is 3 lines. Sidebar TOC still resolves to the MAIN viewport. Verified: typecheck pass, 1620 tests / 0 fail, builds OK; **manual eyeball — TOC active-section tracks main-content scroll, click-to-scroll works.** - -### Self-review fix — viewer `disabled` path (DONE) -- **Found:** the Phase-3 viewer seam's `disabled` branch set `ready=true` with an empty map, which makes `gateCodePath` demote every code link to **plain text** (since ready+no-entry => 'plain'). Wrong for the seam's purpose (a host disabling validation wants links to stay clickable). Did NOT affect Plannotator (never disables) but the seam was incorrect. -- **Fix:** `useValidatedCodePaths.ts` disabled branch now just `return;` (leaves `ready=false`), so `gateCodePath`'s no-validation fallback renders code links **optimistically (clickable)**. Re-verified: typecheck pass, 1620 tests / 0 fail, builds OK. - -### Noops (nothing to land — verified already reusable) -theme, markdown, html-viewer — decoupled by Phase 2 / already prop-driven. - -### Reusability note (intentional, not a defect) -Three seams now share the shape `defaultX` + module-level `x` + `setX`/`resetX` (image resolver, storage backend, doc-preview fetcher). NOT abstracted into a generic helper: they live in different files, have different call ergonomics (a bare function vs. a `{getItem,setItem,removeItem}` object vs. an async fetcher), and the duplication is ~4 trivial lines each. A shared `createOverridable()` would add indirection for little gain and churn three already-verified files. Revisit if a 4th/5th appears. - -## Phase 3 status: COMPLETE -All 7 pieces resolved — 4 landed (editor, viewer, doc-fetch, scroll), 3 noop. Plannotator byte-unchanged throughout (shipped behavior verified; App.tsx touched only by the 3-line scroll rewire). Scroll provider (the "announcer") now ships in `@plannotator/ui`, closing the Phase-2 deferred seam. - -## Phase 4 — Navigation (sidebar + file tree) - -Teed up + multi-lens adversarially reviewed by the `phase4-navigation` workflow (tee-up → execute-in-worktree → 4 parity lenses → synthesis), then landed + verified by hand on the real tree. - -### Sidebar (NOOP — nothing to land) -Confirmed transfer-as-is: SidebarContainer/SidebarTabs/CountBadge/FileBrowser/VersionBrowser/ArchiveBrowser/MessagesBrowser and `useSidebar` have **zero** backend wires — all backend interaction arrives as injected callback props, or a pre-built `fileBrowser` prop. Already reused by `packages/review-editor/App.tsx` (`useSidebar`), a second consumer with a different tab union. No edit. - -### Seam — File tree backend (DONE) -- **File:** `packages/ui/hooks/useFileBrowser.ts` only. Lifted the three backend wires into an injectable `FileTreeBackend` (`loadTree`/`loadVaultTree`/`watchTrees`) with a `defaultFileTreeBackend` + `setFileTreeBackend`/`resetFileTreeBackend`, same module-level pattern as the image/storage seams. -- **The SSE live-watch effect moved VERBATIM** into `watchTrees` — EventSource URL, 120ms debounce timers, `readyPaths` dedup, `onmessage` ready/changed dispatch, and `clearTimeout`+`source.close()` cleanup byte-identical. The only substitution is `fetchTreeRef.current(path,{quiet:true})` → injected `onChange(path)` (the hook passes exactly that). The `typeof EventSource === "undefined"` guard relocated into `watchTrees` (returns `undefined` → no cleanup), behavior-identical. `useFileBrowser()` stays zero-arg; default fetch/SSE URLs unchanged. -- **Parity:** no `setFileTreeBackend` caller in editor/apps → Plannotator uses the default. Verified: **`useFileBrowser.test.tsx` passes 6/0 UNMODIFIED** (the strongest guardrail — it asserts the URLs, timer, and SSE behavior via fake `fetch`/`EventSource`; run with `DOM_TESTS=1`); typecheck pass; full `bun test` 1620/0; builds OK; App.tsx untouched. **Manual eyeball** (real `annotate adr/` session): tree loads, file-switching works, new file appears live via SSE without reload. - -### Phase 4 status: COMPLETE — sidebar noop, file-tree seam landed. Plannotator byte-unchanged. - -## Phase 5 — Comments / annotations / drafts (ADR 005) - -Researched (5-probe spike), specced, ADR-005-accepted, then teed up + multi-lens adversarially reviewed by the `phase5-comments` workflow (4 tee-ups + 3 worktree executes + 12 review lenses + synthesis; all 12 lenses returned safe). Landed + verified by hand on the real tree, lowest-risk first. The already-portable comment UI (panel, popover, toolbar, highlighter, exporters) confirmed noop. - -### Seam 1 — Identity provider (DONE) -- **File:** `packages/ui/utils/identity.ts`. Added `IdentityProvider` + `setIdentityProvider`/`resetIdentityProvider`; `getIdentity`/`isCurrentUser` delegate to a module-level provider defaulting to today's ConfigStore tater behavior. The ~9 author-stamp sites and 2 `(me)`-badge sites delegate with **zero call-site edits**. -- **Parity:** no override caller → tater nickname + `(me)` badge identical. typecheck pass, 1620/0, builds OK. (+46/-5, identity.ts only.) - -### Seam 2 — Draft transport (DONE) -- **Files:** `packages/ui/hooks/useAnnotationDraft.ts` (+ `useCodeAnnotationDraft.ts` reads `getDraftTransport()` live). Added `DraftTransport` (load/save/remove) + setters, default = today's `/api/draft` fetches verbatim; `save` rejects-on-failure so the **keepalive retry-gate stays in the hook**. The generation pre-increment, 500ms debounce, and pagehide/visibilitychange flush stay verbatim; `getDraftGeneration()` still escapes to the host. -- **Landing note:** the workflow diff carried one phantom hunk (a delete-on-clear branch the real code-draft hook never had — it early-returns on empty). `patch` correctly rejected it; the real tree is correct without it. Caught by landing-on-real-tree verification. -- **Parity:** no override caller; App.tsx + `shared/draft.ts` untouched. `shared/draft.test.ts` 10/0, `annotationDraftPersistence` 13/0 (incl. pagehide-flush parity), typecheck pass, 1620/0, builds OK. - -### Seam 3 — External-annotation transport (DONE, riskiest) -- **File:** `packages/ui/hooks/useExternalAnnotations.ts`. Added `ExternalAnnotationTransport` (`subscribe`/`getSnapshot`/CRUD) + setters; default = the SSE→polling wire moved verbatim into `createDefaultTransport`. The reducer (`applyEvent`, byte-identical cases), fallback-once gate, 500ms poll, version-scoping, optimistic-before-await, and the `[enabled]` gate **stay in the hook**. Two micro-divergences (parse-then-cancelled-check; snapshot `[]`/`0` defaults) are provably unreachable for Plannotator (server always returns well-formed `{annotations, version}`; parse-then-discard is side-effect-free). -- **Parity:** no override caller; App.tsx untouched (both apps still call `useExternalAnnotations({enabled})`). external-annotation test green, typecheck pass, 1620/0, builds OK. - -### Phase 5 status: COMPLETE (pending eyeball) — 3 seams landed, comment UI noop. Plannotator byte-unchanged. - -## Phase 6 — Extras: versions/diff, settings, sharing, Ask AI (ADR 006) - -Researched (5-probe spike), specced, ADR-006-accepted, teed up + multi-lens adversarially reviewed by the `phase6-extras` workflow (5 tee-ups + 4 worktree executes + 15 review lenses + synthesis; all 15 lenses safe). Landed + verified by hand. Already-portable pieces (planDiffEngine, all diff render components, sharing utils/useSharing/ImportModal, notes-app helpers, settings.ts, AI chat components, aiProvider/aiChatFormat) confirmed noop. Five Plannotator-only pieces (OpenInAppButton, HooksTab, useUpdateCheck, useAgents, useAgentJobs) confirmed out of scope. - -### Seam — Versions/diff + CSS move (DONE) -- **Files:** `usePlanDiff.ts` (optional `fetchers?` 4th arg, default `/api/plan/version(s)`; error asymmetry kept — selectBaseVersion alerts via the existing catch, fetchVersions silent), `PlanDiffViewer.tsx` (optional `onOpenVscodeDiff?`, default `/api/plan/vscode-diff`), and the **CSS move**: `.annotation-highlight*` + `.plan-diff-added/removed/modified/unchanged/line-*` relocated **byte-identical** from `editor/index.css` (−114) into `ui/theme.css` (+114), next to `.plan-diff-word-*`. -- **Parity:** relocated CSS **gone from index.css (0), present in shipped bundle (33×)** since Plannotator imports theme.css → pixel-identical. planDiffEngine 49/0, typecheck pass, 1620/0, builds OK, App.tsx untouched. - -### Seam — Settings/config (DONE) -- **Files:** `configStore.ts` (`setServerSync(fn)` injects only the terminal `/api/config` POST; 300ms debounce + deepMerge batching + singleton + eager cookie reads verbatim), `Settings.tsx` (optional `onDetectObsidianVaults`, default `/api/obsidian/vaults`; `[obsidian.enabled]` dep + auto-select verbatim). -- **Parity:** no override caller; ui 293/0, typecheck pass, 1620/0, builds OK, App.tsx untouched. - -### Seam — Sharing/save-to-notes (DONE) -- **File:** `ExportModal.tsx` (optional `onSaveToNotes`, default = verbatim `/api/save-notes` POST; `showNotesTab = isApiMode && !!markdown` byte-for-byte). Sharing utils already parameterized (noop). -- **Parity:** no override caller; typecheck pass, 1620/0, builds OK, App.tsx untouched. - -### Seam — Ask AI transport (DONE, riskiest) -- **File:** `useAIChat.ts` (module-level `AITransport` session/query/abort/permission + setters, default = the five `/api/ai/*` fetches verbatim). The SSE reader loop, epoch/createRequest guards, and the supersede-abort position inside `createSession` stay untouched. Capabilities + provider-resolution stay host-owned (App.tsx). -- **Parity:** no override caller; `packages/ai/ai.test.ts` 97/0, typecheck pass, 1620/0, builds OK, App.tsx untouched. - -### Phase 6 status: COMPLETE (pending eyeball) — 4 seams landed + diff CSS in the package, extras noop, 5 Plannotator-only pieces out of scope. Plannotator byte-unchanged. -The document UI is now feature-complete for reuse. Remaining: Phase 7 (publish) + the parked `@plannotator/ai`/`@plannotator/shared` publish-vs-inline decision. -Renderer-coupling contract (Workspaces must reuse BlockRenderer+InlineMarkdown+inlineTransforms for highlights) and replies/threading deferral recorded in ADR 005. Remaining: manual eyeball — author/`(me)`, draft save+restore+no-ghost, live SSE add + kill-stream→polling-takes-over. - -### Discovered (PRE-EXISTING, out of scope — not caused by this work) -1. **Edit/save header state leaks across file switches** in annotate-folder mode: editing+saving file A leaves the Saved/Done/wide-focus header showing when you switch to file B without editing it. Reproduced on the **baseline with the Phase 4 change reverted** (A/B confirmed) → pre-existing App.tsx bug, not a regression. Lives in the folder file-switch handler (`handleFileBrowserSelect` / edit-session reset), unrelated to `useFileBrowser`. Worth a separate fix. -2. **Annotating the repo root (`annotate ./`) bogs down** — the file walker + chokidar SSE watcher choke on 1.4GB of node_modules (16 dirs); the code already warns about this. Pre-existing scaling limit; use a bounded folder. Not a code defect introduced here. diff --git a/adr/implementation/document-ui-phase-7-plan-20260623.md b/adr/implementation/document-ui-phase-7-plan-20260623.md deleted file mode 100644 index 8045c98bc..000000000 --- a/adr/implementation/document-ui-phase-7-plan-20260623.md +++ /dev/null @@ -1,679 +0,0 @@ -# Phase 7 — Implementation Plan: Carve `@plannotator/core` + Publish Prep - -Branch: `feat/pkg-document-ui`. Authoritative: ADR 007 + spec `publish-core-package-20260623-125551.md`. - -**THE LAW (ADR 004):** Plannotator stays byte-for-byte unchanged. The carve is `git mv` + one-line -re-export shims + type extraction. Single source of truth — NO copying, NO rewriting. `packages/editor`, -`packages/server`, `packages/review-editor`, and all `apps/*` source stays untouched except the ONE -`wideMode` importer in Step 3. - -**Global rules for every step:** -- Each step leaves the tree typecheck-green **for what it touched** and ends with **one local commit** (`git commit`, NO `git push`, NO publish, NO merge). -- Work on the current branch `feat/pkg-document-ui` (already a feature branch — do not branch again). -- Repo version is `0.21.0`. `@plannotator/core` ships lockstep at `0.21.0`; `ui` → `core` pinned EXACT. -- Use `git mv` for all moves so history follows. Never delete a working file until parity is human-confirmed. -- `export *` re-exports values AND types but NOT defaults; all 15 moved modules are named-export-only (confirmed). If a default is ever found, add `export { default } from '@plannotator/core/X';`. -- **Byte-for-byte type moves:** when extracting type declarations into `packages/core/*-types.ts`, copy the source bytes **verbatim** — preserve original indentation (the `workspace-status` cluster uses TAB indentation; do NOT reflow to spaces). A pure move must produce a pure move in the diff; gratuitous reformatting is forbidden because the parity gate (item 4) scrutinizes the diff. -- **Workspace registration before typecheck:** `@plannotator/core` is a BRAND-NEW workspace package. There is no `node_modules/@plannotator` symlink dir in this repo; Bun resolves workspaces through its lockfile catalog (verified: `bun pm ls` lists workspaces, `packages/server/tsconfig.json` has no `paths` map yet resolves `@plannotator/shared/*` purely via that catalog). `packages/shared/tsconfig.json` and `packages/ai/tsconfig.json` have `moduleResolution: bundler` and NO `paths` map, so they will resolve the new `@plannotator/core/*` bare specifiers ONLY after `bun install` registers core in the catalog. Therefore `bun install` is required after Step 1a (and re-run after Step 2c) before any `tsc` verification that touches the new specifiers. - ---- - -## STEP 1 — The carve: create `@plannotator/core`, move modules, extract types, shim `shared` (CRITICAL / opus) - -Goal: `packages/core` exists with the 15 pure modules + 5 extracted type files; `packages/shared` is rewired (15 one-line shims + 4 node-bound/types modules importing types back from core); `ai/types.ts` imports the `AIContext` family (including `AIContextMode`) back from core. End state: `core` typecheck (node-free) green AND `shared` typecheck green AND `ai` typecheck green AND Pi typecheck green. - -### 1a. Create `packages/core/package.json` + register the workspace -New file `packages/core/package.json` (exact): -```json -{ - "name": "@plannotator/core", - "version": "0.21.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" - } -} -``` -Constraints: NO `private`, NO `peerDependencies`, NO `@types/node`, NO `@types/bun`. - -**Then wire deps (1h below) and run `bun install`** so the workspace catalog registers `@plannotator/core`. This is load-bearing: without it, the Step 1k `tsc` on `shared`/`ai` cannot resolve the new `@plannotator/core/*` specifiers (no `paths` map on those packages). Run order within Step 1: create core files (1a–1g) → edit deps (1h) → `bun install` → wire typecheck (1i) → fix vendor (1j) → verify (1k). - -### 1b. Create `packages/core/tsconfig.json` (node-free — DOM-only lib, no node/bun types) -New file `packages/core/tsconfig.json` (exact): -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "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"] -} -``` -Critical: NO `"types": ["bun"]`, NO `"types": ["@types/node"]`, NO `"paths"`. This is the node-free invariant — a stray `node:*` import yields `TS2307`. - -### 1c. `git mv` the 15 pure modules `packages/shared/X.ts` → `packages/core/X.ts` -Exact list (each verified node-free; the only intra-set dep is `extract-code-paths → ./code-file`, which moves together so the relative import stays valid): -``` -code-file extract-code-paths agents agent-jobs compress crypto -external-annotation favicon feedback-templates goal-setup browser-paths -project agent-terminal open-in-apps source-save -``` -Do NOT move `source-save-node.ts` (node-bound — stays in shared). `project` here is the PURE `packages/shared/project.ts`, NOT `packages/server/project.ts`. - -### 1d. Create the 5 extracted type files in core (single source — definitions move here, byte-for-byte) - -`packages/core/config-types.ts` — move the pure type decls from `shared/config.ts:13-30`: -```ts -export type DefaultDiffType = 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all'; -export type DiffLineBgIntensity = 'subtle' | 'normal' | 'strong'; -// plus DiffOptions (config.ts:16-30) — self-contained, references the two above; move for the diff-option family -``` -(UI needs only `DefaultDiffType` + `DiffLineBgIntensity`; `DiffOptions` is moved for tidiness, self-contained.) Do NOT move `CCLabelConfig`, `PromptConfig`, `PromptRuntime`, etc. - -`packages/core/storage-types.ts` — move `ArchivedPlan` (shared/storage.ts:107-114): -```ts -export interface ArchivedPlan { - filename: string; title: string; date: string; timestamp: string; - status: "approved" | "denied" | "unknown"; size: number; -} -``` - -`packages/core/workspace-status-types.ts` — move the cluster (shared/workspace-status.ts:6-44): `WorkspaceFileStatus`, `WorkspaceFileChange`, `WorkspaceStatusPayload`, `GitRepositoryInfo`. (`WorkspaceFileStatus` is required transitively by `WorkspaceFileChange`.) Do NOT move `GitResult`, `WorkspaceStatusFlight` (file-private, node-adjacent). **Copy the declarations byte-for-byte — these use TAB indentation in the source; preserve the tabs, do NOT reflow to spaces.** - -`packages/core/types.ts` — move the `EditorAnnotation` interface (shared/types.ts:2-10, pure). This file exports ONLY `EditorAnnotation`. Do NOT re-export anything from `review-core`/`review-workspace` (they import `node:path`). - -`packages/core/ai-context.ts` — MOVE (not re-export) the AI context type family from `packages/ai/types.ts:14-89`. **This family INCLUDES `AIContextMode` (at `ai/types.ts:14`).** Move ALL six names: `AIContextMode`, `ParentSession`, `PlanContext`, `CodeReviewContext`, `AnnotateContext`, `AIContext`. All verified node-free. The literal first line moved is: -```ts -export type AIContextMode = "plan-review" | "code-review" | "annotate"; -``` -Do NOT do `export … from '@plannotator/ai'` — that would give core a dep on private `ai` and break the zero-dep CI gate. - -> **Why `AIContextMode` is mandatory here:** it is consumed inside the `ai` package itself — `ai/index.ts:68` re-exports it from `./types.ts`, and `ai/session-manager.ts:16,26,65` import and use it. If it is moved out of `ai/types.ts` but not re-exported back (see 1g), the `ai` package fails to typecheck (`TS2305 'no exported member AIContextMode'`), cascading to editor/server. UI does NOT import `AIContextMode` (verified zero usage), so no Step 2 change is needed for it. - -### 1e. Create `packages/core/index.ts` (barrel) -Re-export the public surface so `import … from '@plannotator/core'` works (notably `AIContext` for UI): -```ts -export * from './ai-context'; -export type { EditorAnnotation } from './types'; -// (re-export others as convenient; AIContext is the load-bearing one for ui/useAIChat) -``` -`export * from './ai-context'` re-exports `AIContextMode`, `AIContext`, `ParentSession`, `PlanContext`, `CodeReviewContext`, `AnnotateContext` from the barrel. - -### 1f. Replace the 15 moved `shared/X.ts` files with one-line shims -After `git mv`, recreate each `packages/shared/X.ts` containing exactly: -```ts -export * from '@plannotator/core/X'; -``` -(15 files: code-file, extract-code-paths, agents, agent-jobs, compress, crypto, external-annotation, favicon, feedback-templates, goal-setup, browser-paths, project, agent-terminal, open-in-apps, source-save.) `shared`'s `exports` map and `private:true` stay unchanged — every subpath still resolves to `./X.ts`. -Note: the intra-shared relative importers (`shared/resolve-file.ts → ./code-file`, `shared/storage.ts → ./project`, `shared/source-save-node.ts → ./source-save`) resolve to the shim and forward to core — NO edits needed to those three. - -### 1g. Rewire the node-bound shared modules + `ai/types.ts` to import types back from core -`packages/shared/config.ts` — replace the inline `DefaultDiffType`/`DiffLineBgIntensity`/`DiffOptions` decls with: -```ts -export type { DefaultDiffType, DiffLineBgIntensity, DiffOptions } from '@plannotator/core/config-types'; -``` -(keep all node impl/functions unchanged.) - -`packages/shared/storage.ts` — replace the inline `export interface ArchivedPlan {…}` with: -```ts -import type { ArchivedPlan } from '@plannotator/core/storage-types'; -export type { ArchivedPlan }; -``` -(internal functions keep referencing `ArchivedPlan`; node:fs impl unchanged.) - -`packages/shared/workspace-status.ts` — replace the inline cluster with: -```ts -import type { WorkspaceFileChange, WorkspaceStatusPayload, GitRepositoryInfo, WorkspaceFileStatus } from '@plannotator/core/workspace-status-types'; -export type { WorkspaceFileChange, WorkspaceStatusPayload, GitRepositoryInfo, WorkspaceFileStatus }; -``` -(node:child_process/fs impl unchanged.) - -`packages/shared/types.ts` — replace the inline `EditorAnnotation` interface with: -```ts -export type { EditorAnnotation } from '@plannotator/core/types'; -``` -(keep its existing `review-core`/`review-workspace` re-exports as-is.) - -`packages/ai/types.ts` — replace the inline AIContext family with import-back. **This line MUST include `AIContextMode`** (it was moved in 1d and is re-exported by `ai/index.ts:68`): -```ts -export type { AIContext, AIContextMode, PlanContext, CodeReviewContext, AnnotateContext, ParentSession } from '@plannotator/core/ai-context'; -``` -This keeps `ai/index.ts`'s existing re-export (which lists `AIContextMode` at line 68) resolving, keeps `session-manager.ts`'s `import type { ..., AIContextMode } from "./types.ts"` resolving, AND keeps `editor/App.tsx:95`'s `import type { AIContext } from '@plannotator/ai'` resolving — **`ai`, `editor`, and `server` stay byte-for-byte unchanged.** - -### 1h. package.json dep edits for `shared` and `ai` -- `packages/shared/package.json` deps: add `"@plannotator/core": "workspace:*"`. Keep `private:true`, keep the full `exports` map unchanged. -- `packages/ai/package.json` deps: add `"@plannotator/core": "workspace:*"`. Keep `private:true`. - -**After 1h: run `bun install`** (registers `@plannotator/core` in the Bun workspace catalog). This is the resolution mechanism for `shared`/`ai`'s new `@plannotator/core/*` imports — those packages have no `paths` map. Without this, Step 1k's `tsc -p packages/shared/tsconfig.json` / `packages/ai/tsconfig.json` cannot resolve the new bare specifiers. - -### 1i. Wire core into the root typecheck (node-free typecheck FIRST so a node leak fails fast) -Root `package.json` `typecheck` script (line 36) — insert core's typecheck before shared's: -``` -"typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json" -``` -(Leave the vendor.sh prefix in place; vendor.sh itself is fixed in 1j.) - -### 1j. Fix Pi `vendor.sh` (the ADR/spec "no change" claim is FALSE — confirmed) - -**Why it breaks (verified against `apps/pi-extension/vendor.sh`):** -1. The main loop (vendor.sh:10) copies file CONTENT verbatim from `packages/shared/$f.ts` for a flat list that INCLUDES 9 moved-pure modules (`code-file`, `agent-jobs`, `external-annotation`, `favicon`, `feedback-templates`, `project`, `agent-terminal`, `open-in-apps`, `source-save`) AND 3 node-bound modules (`config`, `storage`, `workspace-status`). After the carve, the 9 pure files are bare shims (`export * from '@plannotator/core/X'`) and the 3 node-bound files import `@plannotator/core/-types` — all unresolvable in Pi's flat `generated/` layout (no bundler resolution to packages, no `@plannotator/core` dep, `moduleResolution: bundler` with no `paths`/`baseUrl`). -2. The **ai loop** (vendor.sh:40) copies `index types provider session-manager endpoints context base-session` VERBATIM from `packages/ai/$f.ts` with NO sed rewrites. After 1g, `packages/ai/types.ts` contains `export type { … } from '@plannotator/core/ai-context'` — vendored verbatim into `generated/ai/types.ts`, where that bare specifier cannot resolve (`TS2307`). This works today ONLY because `ai/types.ts` currently has zero `@plannotator/*` imports. - -`workspace-status` IS imported by `apps/pi-extension/server/reference.ts` and `file-browser-watch.ts` (confirmed), so a silent break here is a real runtime/typecheck failure. - -**The exact restructured `vendor.sh` (write this as runnable shell — do not infer):** - -(a) In the main loop, split the 9 moved-pure modules out of the `packages/shared/` source and source them from `packages/core/` instead. Replace the single loop (vendor.sh:10-13) so the 9 moved modules read from core, the 3 node-bound modules read from shared **and** get a sed rewrite, and everything else stays as-is: - -```bash -# Modules that MOVED to @plannotator/core — vendor the real impl from core. -for f in feedback-templates project favicon code-file external-annotation agent-jobs agent-terminal source-save open-in-apps; do - src="../../packages/core/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/core/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" -done - -# Node-bound shared modules that now import types from @plannotator/core/*-types — -# vendor from shared, rewrite the bare core specifier to the flat relative path. -for f in config storage workspace-status; do - src="../../packages/shared/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" \ - | sed 's|from "@plannotator/core/\([^"]*\)-types"|from "./\1-types.js"|g' \ - > "generated/$f.ts" -done - -# Extracted type files those node-bound modules now depend on — vendor from core. -for f in config-types storage-types workspace-status-types; do - src="../../packages/core/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/core/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" -done - -# Everything else in the original flat list stays sourced from packages/shared. -for f in prompts review-core diff-paths cli-pagination jj-core vcs-core review-args draft pr-types pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common resolve-file annotate-reference-roots-node worktree worktree-pool html-to-markdown html-assets html-assets-node url-to-markdown tour annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff source-save-node; do - src="../../packages/shared/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" -done -``` -> The relative-import chains stay valid: `resolve-file → ./code-file`, `source-save-node → ./source-save`, `storage → ./project` all resolve to the flat `generated/.ts` files, which now hold the real core impl. Confirm the original line-10 list is fully partitioned across the four loops above with no module dropped (diff the old list against the union of the four new lists). - -(b) Extend the **ai loop** (vendor.sh:40-43) to vendor `ai-context` from core and sed-rewrite the `@plannotator/core/ai-context` specifier in `generated/ai/types.ts` to `./ai-context.js`: - -```bash -# 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" \ - | sed 's|from "@plannotator/core/ai-context"|from "./ai-context.js"|g' \ - > "generated/ai/$f.ts" -done -``` -> Only `generated/ai/types.ts` actually contains the `@plannotator/core/ai-context` specifier today, but applying the sed to all 7 ai files is harmless (no-op where absent) and future-proofs the vendor. - -### 1k. Verify (run at end of Step 1) -``` -bun install # MUST run first — registers @plannotator/core in the workspace catalog -tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json -# Node-free proof: temporarily add `import 'node:fs'` to a core/*.ts → tsc on core MUST fail TS2307 → remove it. -bash apps/pi-extension/vendor.sh && tsc --noEmit -p apps/pi-extension/tsconfig.json # MUST be green — confirms generated/ai/types.ts + generated/{config,storage,workspace-status}.ts resolve their rewritten relative specifiers -git diff --name-only # confined to packages/{core,shared,ai} + apps/pi-extension/vendor.sh + root package.json + bun.lock -``` - -### Commit -``` -feat(core): carve @plannotator/core — move pure modules, extract node-bound types, shim shared (Phase 7 step 1) -``` - ---- - -## STEP 2 — Re-point `@plannotator/ui` to `@plannotator/core` (CRITICAL / opus) - -Goal: every non-test `ui` import of `@plannotator/shared/*` and `@plannotator/ai` becomes `@plannotator/core/*`; ui `package.json` drops shared+ai, adds core EXACT. End: grep returns zero; ui typecheck green. - -### 2a. Re-point the 35 import sites (31 files) -Mechanical rule: `@plannotator/shared/X` → `@plannotator/core/X` (same subpath), with these EXACT remaps for the type-extraction cases: -- `@plannotator/shared/config` → `@plannotator/core/config-types` -- `@plannotator/shared/storage` → `@plannotator/core/storage-types` -- `@plannotator/shared/workspace-status` → `@plannotator/core/workspace-status-types` -- `@plannotator/shared/types` (the `EditorAnnotation` re-export at `ui/types.ts:209`) → `@plannotator/core/types` -- `import type { AIContext } from '@plannotator/ai'` (`ui/hooks/useAIChat.ts:2`) → `from '@plannotator/core'` - -Files + lines (from scope-rewire §1, authoritative): -- `ui/types.ts:209` EditorAnnotation → `@plannotator/core/types` -- `ui/types.ts:211-213` ExternalAnnotationEvent → `@plannotator/core/external-annotation` -- `ui/types.ts:215-221` AgentJob*/AgentCapabilit* → `@plannotator/core/agent-jobs` -- `ui/config/settings.ts:12` DiffLineBgIntensity → `@plannotator/core/config-types` -- `ui/utils/parser.ts:2` planDenyFeedback → `@plannotator/core/feedback-templates` -- `ui/utils/annotateAgentTerminal.ts:1` AgentTerminalAgent → `@plannotator/core/agent-terminal` -- `ui/utils/aiProvider.ts:10` AGENT_CONFIG/getAgentAIProviderTypes/Origin → `@plannotator/core/agents` -- `ui/utils/sharing.ts:12` compress/decompress → `@plannotator/core/compress` -- `ui/utils/sharing.ts:13` encrypt/decrypt → `@plannotator/core/crypto` -- `ui/components/InlineMarkdown.tsx:4` isCodeFilePath/… → `@plannotator/core/code-file` -- `ui/components/OpenInAppButton.tsx:5` OpenInKind → `@plannotator/core/open-in-apps` -- `ui/components/Settings.tsx:3` Origin → `@plannotator/core/agents` -- `ui/components/Settings.tsx:4` DiffLineBgIntensity → `@plannotator/core/config-types` -- `ui/components/DocBadges.tsx:16` hostnameOrFallback → `@plannotator/core/project` -- `ui/components/AISettingsTab.tsx:12` Origin → `@plannotator/core/agents` -- `ui/components/MenuVersionSection.tsx:4` Origin → `@plannotator/core/agents` -- `ui/components/DiffTypeSetupDialog.tsx:3` DefaultDiffType → `@plannotator/core/config-types` -- `ui/components/PlanHeaderMenu.tsx:14` Origin → `@plannotator/core/agents` -- `ui/components/AgentsTab.tsx:16` isTerminalStatus → `@plannotator/core/agent-jobs` -- `ui/components/PlanAIAnnouncementDialog.tsx:3` Origin → `@plannotator/core/agents` -- `ui/components/PlanAIAnnouncementDialog.tsx:4` AGENT_CONFIG/getAgentAIProviderTypes/getAgentName → `@plannotator/core/agents` (all three symbols on this line; `export * from '@plannotator/core/agents'` re-exports all) -- `ui/components/blocks/HtmlBlock.tsx:2` isCodeFilePath → `@plannotator/core/code-file` -- `ui/components/sidebar/FileBrowser.tsx:13` WorkspaceFileChange/WorkspaceStatusPayload → `@plannotator/core/workspace-status-types` -- `ui/components/sidebar/FileBrowser.tsx:14` normalizeBrowserPath → `@plannotator/core/browser-paths` -- `ui/components/sidebar/ArchiveBrowser.tsx:9` ArchivedPlan → `@plannotator/core/storage-types` -- `ui/components/goal-setup/GoalSetupSurface.tsx:13-20` GoalSetup* types → `@plannotator/core/goal-setup` -- `ui/components/settings/HooksTab.tsx:2` FAVICON_SVG → `@plannotator/core/favicon` -- `ui/hooks/useAgents.ts:6` Origin → `@plannotator/core/agents` -- `ui/hooks/useAnnotationDraft.ts:18` SourceSaveCapability → `@plannotator/core/source-save` -- `ui/hooks/useArchive.ts:9` ArchivedPlan → `@plannotator/core/storage-types` -- `ui/hooks/useLinkedDoc.ts:13` SourceSaveCapability → `@plannotator/core/source-save` -- `ui/hooks/useValidatedCodePaths.ts:2` extractCandidateCodePaths → `@plannotator/core/extract-code-paths` -- `ui/hooks/useFileBrowser.ts:12` WorkspaceStatusPayload → `@plannotator/core/workspace-status-types` -- `ui/hooks/pfm/useCodeFilePopout.ts:2` parseCodePath → `@plannotator/core/code-file` -- `ui/hooks/useAIChat.ts:2` AIContext → `@plannotator/core` (bare package root → resolves via `exports['.']` → `index.ts`) - -### 2b. `packages/ui/tsconfig.json` paths -Add BOTH a `@plannotator/core/*` subpath mapping AND a bare `@plannotator/core` mapping alongside the existing shared one (line 21) so tsc resolves core during the transition. **Two entries are required:** the trailing-`/*` glob does NOT match the extensionless bare specifier `@plannotator/core` (used by `useAIChat.ts:2`): -```json -"@plannotator/core": ["../core/index.ts"], -"@plannotator/core/*": ["../core/*"] -``` -(Keep `"@plannotator/shared/*": ["../shared/*"]` — see 2d note; surviving test-file imports still use it.) The bare-specifier path map is authoritative for ui's tsc; `bun install` (already run in Step 1) is the belt-and-suspenders mechanism that also makes the bare specifier resolve via core's `exports['.']`. State both: **path map is authoritative for ui tsc; workspace catalog backs it.** - -### 2c. `packages/ui/package.json` dep edits + re-register -- REMOVE `"@plannotator/ai": "workspace:*"` -- REMOVE `"@plannotator/shared": "workspace:*"` -- ADD `"@plannotator/core": "workspace:*"` (workspace alias in source; resolves to exact `0.21.0` at pack time — ADR mandates EXACT pinning, enforce at pack in Step 5/final gate) - -**After 2c: run `bun install`** so the dependency-graph change (ui → core) is reflected in `bun.lock` before the Step 2d typecheck. - -### 2d. Verify (run at end of Step 2) -``` -bun install -grep -rn '@plannotator/shared\|@plannotator/ai' packages/ui --include='*.ts' --include='*.tsx' | grep -v '\.test\.' # MUST be empty -tsc --noEmit -p packages/ui/tsconfig.json # green -``` -> **Note (intentional, out of scope for the grep-zero gate):** exactly two ui *test* files still import `@plannotator/shared` (`annotateAgentTerminal.test.ts`, `FileBrowser.test.ts`). These are deliberately retained — the grep-zero assertion scopes to non-test files via `grep -v '\.test\.'`, and `@plannotator/shared/*` stays in the ui tsconfig `paths` to keep them resolving. A reviewer should NOT flag these. - -### Commit -``` -feat(ui): depend only on @plannotator/core — re-point all shared/ai imports (Phase 7 step 2) -``` - ---- - -## STEP 3 — Move `wideMode.ts` into `packages/ui/utils` (MECHANICAL / sonnet) - -Goal: relocate the pure `wideMode` helper from `editor` to `ui/utils`; fix the 2 importers. - -### 3a. git mv -``` -git mv packages/editor/wideMode.ts packages/ui/utils/wideMode.ts -git mv packages/editor/wideMode.test.ts packages/ui/utils/wideMode.test.ts -``` - -### 3b. Fix importer 1 — `packages/editor/App.tsx:109` -FROM: -```ts -import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; -``` -TO: -```ts -import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from '@plannotator/ui/utils/wideMode'; -``` -(This is the ONE allowed edit to `packages/editor` source — a single import-specifier change, no behavior change. `wideMode.ts` itself only imports from `@plannotator/ui/types` + `@plannotator/ui/hooks/useSidebar`, so it lands cleanly in ui.) - -### 3c. Fix importer 2 — the moved test -`packages/ui/utils/wideMode.test.ts` imports `./wideMode` (relative) — UNCHANGED after the move (it's now a sibling in `ui/utils`). Verify the line still reads `from './wideMode';`. - -### 3d. exports map -`@plannotator/ui/utils/wideMode` already resolves via the existing `"./utils/*": "./utils/*.ts"` glob in `packages/ui/package.json` — NO new exports entry needed. (Confirm the glob is present at line 14.) - -### 3e. Verify (run at end of Step 3) -``` -grep -rn 'wideMode' packages --include='*.ts' --include='*.tsx' | grep -v 'packages/ui/utils/wideMode' # only editor/App.tsx (the new specifier) shows -tsc --noEmit -p packages/ui/tsconfig.json # green (wideMode + its importer resolve) -bun test packages/ui/utils/wideMode.test.ts -``` - -### Commit -``` -refactor(ui): relocate wideMode helper to @plannotator/ui/utils (Phase 7 step 3) -``` - ---- - -## STEP 4 — Settings provider `loadFromBackend` + `configurePlannotatorUI` front door (CRITICAL / opus) - -Goal: complete the half-built settings provider (initial-load routes through installed backend) and add the single typed configuration front door over the 9 global setters. Both ADDITIVE — Plannotator never calls either, so byte-for-byte parity holds. - -### 4a. Add `loadFromBackend()` to `ConfigStore` — `packages/ui/config/configStore.ts` -Insert this method into the `ConfigStore` class (after the constructor, before `init`): -```ts - /** - * 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). - */ - loadFromBackend(): void { - for (const [name, def] of Object.entries(SETTINGS)) { - const fromBackend = def.fromCookie(); - if (fromBackend !== undefined) { - this.values.set(name, fromBackend); - } - } - this.notify(); - } -``` -Contract: use `!== undefined` (NOT `??`) so a missing key keeps the constructor default; do NOT call `def.toCookie` (no re-write). Reuses the existing per-setting `fromCookie()` reader, which under a host backend reads the host's prefetched store. - -### 4b. Add `resetServerSync()` to `ConfigStore` (needed by the Step 6 seam test; keeps the seam family symmetric) -Next to `setServerSync` (configStore.ts:122): -```ts - resetServerSync(): void { this.serverSync = defaultServerSync; } -``` -(`defaultServerSync` already exists at configStore.ts:36-42.) - -### 4c. Create `packages/ui/configure.ts` — `configurePlannotatorUI` -New file. Imports the 9 setters from their intra-`ui` relative modules and fans out (every field optional, only provided seams applied): -```ts -import { setImageSrcResolver, type ImageSrcResolver } from './components/ImageThumbnail'; -import { setDocPreviewFetcher, type DocPreviewFetcher } from './components/InlineMarkdown'; -import { setStorageBackend, type StorageBackend } from './utils/storage'; -import { setIdentityProvider, type IdentityProvider } from './utils/identity'; -import { setFileTreeBackend, type FileTreeBackend } from './hooks/useFileBrowser'; -import { setDraftTransport, type DraftTransport } from './hooks/useAnnotationDraft'; -import { setExternalAnnotationTransport, type ExternalAnnotationTransport } from './hooks/useExternalAnnotations'; -import { setAITransport, type AITransport } from './hooks/useAIChat'; -import { configStore } from './config'; - -type ExternalAnnotationBase = { id: string; source?: string }; -type ServerSyncFn = (payload: Record) => void; - -export interface PlannotatorUIConfig { - imageSrcResolver?: ImageSrcResolver; - storageBackend?: StorageBackend; - docPreviewFetcher?: DocPreviewFetcher; - fileTreeBackend?: FileTreeBackend; - identityProvider?: IdentityProvider; - draftTransport?: DraftTransport; - /** - * Base-constraint transport. If your annotation type extends the base - * constraint ({ id: string; source?: string }) with extra fields, call - * setExternalAnnotationTransport() directly for full type safety — - * this front-door field intentionally pins the base constraint for ergonomics. - */ - externalAnnotationTransport?: ExternalAnnotationTransport; - aiTransport?: AITransport; - serverSync?: ServerSyncFn; - /** Re-hydrate settings from the installed (SYNCHRONOUS) storageBackend after install. */ - loadSettingsFromBackend?: boolean; -} - -export function configurePlannotatorUI(config: PlannotatorUIConfig): void { - if (config.imageSrcResolver) setImageSrcResolver(config.imageSrcResolver); - if (config.storageBackend) setStorageBackend(config.storageBackend); - if (config.docPreviewFetcher) setDocPreviewFetcher(config.docPreviewFetcher); - if (config.fileTreeBackend) setFileTreeBackend(config.fileTreeBackend); - if (config.identityProvider) setIdentityProvider(config.identityProvider); - if (config.draftTransport) setDraftTransport(config.draftTransport); - if (config.externalAnnotationTransport) setExternalAnnotationTransport(config.externalAnnotationTransport); - if (config.aiTransport) setAITransport(config.aiTransport); - if (config.serverSync) configStore.setServerSync(config.serverSync); - // Re-hydrate AFTER storageBackend is installed (load-bearing order — gated last). - if (config.loadSettingsFromBackend) configStore.loadFromBackend(); -} -``` -Notes: inline `ServerSyncFn` (configStore's type is module-local — do NOT widen configStore's surface). The external-annotation generic is pinned to the base constraint `{ id: string; source?: string }` (the hook's default transport is ``, contract-compatible); the doc comment above the field tells consumers with extended annotation types to call `setExternalAnnotationTransport()` directly. The render-time prop seams (vscode-diff, save-to-notes, obsidian-detect, version fetchers, editor `mode`, code-path toggle, `ScrollViewportProvider`) are intentionally NOT here — they're passed where the host renders those components. - -### 4d. ui exports + files -`packages/ui/package.json`: -- exports: add `"./configure": "./configure.ts"` (alongside `./config`, `./types`). -- files: add `"configure.ts"` (sits at package root like `types.ts`). - -### 4e. Verify (run at end of Step 4) -``` -tsc --noEmit -p packages/ui/tsconfig.json # green -grep -n 'loadFromBackend\|resetServerSync' packages/ui/config/configStore.ts -grep -n 'configurePlannotatorUI' packages/ui/configure.ts -``` - -### Commit -``` -feat(ui): add loadFromBackend settings rehydration + configurePlannotatorUI front door (Phase 7 step 4) -``` - ---- - -## STEP 5 — Precompiled CSS build + madge circular-dep tooling (MECHANICAL / sonnet) - -Goal: ship a required precompiled `@plannotator/ui/styles.css` (CSS-only Vite build); add `madge` + a circular-dependency script. (Core's node-free typecheck wiring already landed in Step 1i — re-verify here.) - -### 5a. CSS entry — `packages/ui/styles-entry.css` (new) -```css -@import "@fontsource-variable/inter"; -@import "@fontsource-variable/geist-mono"; -@import "tailwindcss"; - -@plugin "tailwindcss-animate"; - -@source "./components/**/*.tsx"; -@source "./hooks/**/*.ts"; -@source "./utils/**/*.ts"; - -@import "./theme.css"; -``` -(`@source` globs are relative to this file at `packages/ui/`; they run ONCE at build time on source, baking the utility classes into the output — that's the whole point vs. the fragile consumer-side `@source` into `node_modules`.) Does NOT include `@plannotator/webtui/styles.css` (agent-terminal) or dockview CSS (review-editor) — those are runtime-specific, not exported UI. -> **`print.css` is covered (verified):** `packages/ui/theme.css:55` already does `@import "./print.css";`, and `styles-entry.css` imports `./theme.css`, so the precompiled bundle DOES include print styles transitively. No separate `@import "./print.css"` is needed here. - -### 5b. Vite CSS-only config — `packages/ui/vite.css.config.ts` (new) -```ts -import path from 'path'; -import { defineConfig } from 'vite'; -import tailwindcss from '@tailwindcss/vite'; - -export default defineConfig({ - plugins: [tailwindcss()], - resolve: { alias: { '@plannotator/ui': path.resolve(__dirname, '.') } }, - build: { - lib: { entry: path.resolve(__dirname, 'styles-entry.css'), formats: ['es'], fileName: () => 'styles.js' }, - outDir: '.', - cssCodeSplit: false, - rollupOptions: { output: { assetFileNames: 'styles.css' } }, - emptyOutDir: false, - }, -}); -``` - -### 5c. `packages/ui/package.json` — CSS build wiring -- scripts: add `"build:css": "vite build --config vite.css.config.ts && rm -f styles.js"` -- scripts: add `"prepublishOnly": "bun run build:css"` — mirrors `apps/pi-extension/package.json`'s `prepublishOnly` pattern. This fires automatically before `bun pm pack` / `npm publish`, guaranteeing `styles.css` is fresh in the tarball even though it is NOT committed. Without it, a publish would ship a tarball missing the (listed-in-`files`) `styles.css` — a silent consumer break. -- exports: add `"./styles.css": "./styles.css"` -- files: add `"styles.css"` -- devDependencies: add `"@tailwindcss/vite": "^4.1.18"` and `"vite": "^6.2.0"` (CSS-only build needs only these two; no react plugin). -- Add a `.gitignore` line (or repo-root ignore) for `packages/ui/styles.js`. **Do NOT commit `styles.css`** — it's a generated artifact produced by `prepublishOnly` at pack/publish time (avoids stale diffs). Also add `packages/ui/styles.css` to `.gitignore`. - -### 5d. Root scripts — `build:ui-css` -Root `package.json` scripts: add `"build:ui-css": "bun run --cwd packages/ui build:css"`. - -### 5e. madge circular-dep tooling -- Root `package.json` devDependencies: add `"madge": "^8.0.0"` (`bun add -d madge` at repo root). -- Root `.madgerc` (new) — TS support: - ```json - { "extensions": ["ts", "tsx"], "fileExtensions": ["ts", "tsx"] } - ``` -- Root `package.json` scripts: add - ```json - "check:cycles": "madge --circular --extensions ts,tsx --ts-config packages/core/tsconfig.json packages/core && madge --circular --extensions ts,tsx --ts-config packages/ui/tsconfig.json packages/ui" - ``` - (Scoped to the two published packages — the strict invariant. `--circular` reports cycles only, not unresolved imports; the `--ts-config` for ui carries the `@plannotator/core/*` + bare-`@plannotator/core` path maps added in Step 2b so madge resolves the aliases. The two surviving `@plannotator/shared` references in ui *.test.ts files are resolved by the retained shared path map and do not affect cycle detection.) - -### 5f. Re-confirm core node-free typecheck wiring (from Step 1i) -Ensure `tsc --noEmit -p packages/core/tsconfig.json` is in the root `typecheck` script (added in 1i). Sanity: a planted `import 'node:fs'` in a core file fails `TS2307`. - -### 5g. Verify (run at end of Step 5) -``` -bun install # picks up madge + vite/@tailwindcss/vite devDeps -bun run build:ui-css # emits packages/ui/styles.css, removes styles.js -test -s packages/ui/styles.css && echo "styles.css non-empty OK" -bun run check:cycles # exits 0 (no cycles in core/ui) -tsc --noEmit -p packages/core/tsconfig.json # node-free green -``` - -### Commit -``` -build(ui): precompiled styles.css CSS build + madge circular-dep check (Phase 7 step 5) -``` -(Commit the configs/scripts/devDeps + `prepublishOnly`; do NOT commit the generated `styles.css`/`styles.js`.) - ---- - -## STEP 6 — Per-seam override tests + a `configurePlannotatorUI` routing test (MECHANICAL / sonnet) - -Goal: one override test per seam (`setX(fake)` → drive → assert → `resetX()`), making the `reset*()` functions live and pinning the subtle contracts; plus one test that `configurePlannotatorUI({...})` routes to each setter. - -### 6a. Verify (DO NOT re-edit) the override-path contracts before writing tests -The two override-path fixes flagged in earlier interrogation passes are **ALREADY landed on this branch** (verified). Step 6a is VERIFICATION-ONLY — do NOT re-apply or "fix" working code (re-editing risks an unintended Plannotator behavior change, a LAW violation). - -1. **Split-transport (already fixed):** `packages/ui/hooks/useExternalAnnotations.ts:134` captures `transportRef = useRef(externalAnnotationTransport …)`; the subscribe/poll effect reads `transportRef.current` (line 145) AND every CRUD callback reads `transportRef.current` (`.remove` line 232, `.clear` line 244, `.update` line 253). Reads and writes already use the same backend instance. **Confirm via grep**: - ``` - grep -n 'transportRef.current' packages/ui/hooks/useExternalAnnotations.ts # expect lines 145, 232, 244, 253 - ``` - If (and only if) these are absent, apply the capture-once pattern; otherwise proceed. -2. **Ref reset on effect re-run (already fixed):** `fallbackRef.current = false` (line 142) and `receivedSnapshotRef.current = false` (line 143) are already reset at the TOP of the effect, so an `enabled` toggle `false→true` re-attempts SSE. **Confirm via grep**: - ``` - grep -n 'fallbackRef.current = false\|receivedSnapshotRef.current = false' packages/ui/hooks/useExternalAnnotations.ts # expect lines 142, 143 - ``` -3. **`useFileBrowser` audit (no change expected):** `useFileBrowser.ts` reads the module global `fileTreeBackend` LIVE (lines 211/316/383) rather than capturing a ref. This is a DIFFERENT but acceptable pattern (no mount-time capture, so no read/write split to fix). Confirm no change is needed; do NOT introduce a ref here. - -Then proceed straight to the seam tests in 6b/6c — they pin the already-correct behavior. - -### 6b. Per-seam override tests (10 files, `.seam.test.ts(x)` naming, colocated) -| Seam | Test file | Assert | -|------|-----------|--------| -| `setImageSrcResolver` / `resetImageSrcResolver` | `packages/ui/components/ImageThumbnail.seam.test.tsx` | render `` → fake resolver called with `"/foo/img.png"` | -| `setStorageBackend` / `resetStorageBackend` | `packages/ui/utils/storage.seam.test.ts` | `setItem`/`getItem` → fake backend's read/write called (not `document.cookie`) | -| `setDocPreviewFetcher` / `resetDocPreviewFetcher` | `packages/ui/components/InlineMarkdown.seam.test.tsx` | trigger doc preview → fake fetcher called with expected path | -| `setFileTreeBackend` / `resetFileTreeBackend` | `packages/ui/hooks/useFileBrowser.seam.test.tsx` | mount `useFileBrowser` → `fetchTree()` → `fake.loadTree` invoked with expected dirPath | -| `setIdentityProvider` / `resetIdentityProvider` | `packages/ui/utils/identity.seam.test.ts` | `getIdentity()` → fake provider invoked | -| `setDraftTransport` / `resetDraftTransport` | `packages/ui/hooks/useAnnotationDraft.seam.test.ts` | `fake.load()` on mount; `fake.save()` on scheduled save | -| `setExternalAnnotationTransport` / `resetExternalAnnotationTransport` | `packages/ui/hooks/useExternalAnnotations.seam.test.ts` | mount → `fake.subscribe` called; delete → `fake.remove` on SAME transport (pins the already-landed split-transport fix) | -| `setAITransport` / `resetAITransport` | `packages/ui/hooks/useAIChat.seam.test.ts` | mount `useAIChat` + send → `fake` session/query called | -| `configStore.setServerSync` / `resetServerSync` | `packages/ui/config/configStore.seam.test.ts` | `configStore.set('', …)` → fake sync fn called with expected payload | -| `loadFromBackend` | `packages/ui/config/configStore.seam.test.ts` (2nd describe) | `setStorageBackend(prefetched)` → `loadFromBackend()` → `configStore.get(key)` returns prefetched value | - -Pattern (template): `afterEach(() => resetXTransport())`; in the test, `setXTransport(fake)`, drive (mount hook harness via React test utils, or call the utility directly), assert recorded calls. Files auto-discovered by `bun test` — no registration. - -### 6c. `configurePlannotatorUI` routing test — `packages/ui/configure.test.ts` (new) -Call `configurePlannotatorUI({ imageSrcResolver, storageBackend, docPreviewFetcher, fileTreeBackend, identityProvider, draftTransport, externalAnnotationTransport, aiTransport, serverSync, loadSettingsFromBackend: true })` with fakes/spies, then assert each underlying setter received its fake (and that `loadFromBackend` ran after `setStorageBackend`). Reset every seam in `afterEach`. - -### 6d. Verify (run at end of Step 6) -``` -bun test packages/ui # all ui tests incl. new .seam.test + configure.test green -tsc --noEmit -p packages/ui/tsconfig.json -``` - -### Commit -``` -test(ui): per-seam override tests + configure routing test (Phase 7 step 6) -``` - ---- - -## FINAL PARITY GATE (run after Step 6, before any publish/push — DO NOT push or publish) - -Run from repo root. ALL must pass; investigate any failure before proceeding. - -1. **Full typecheck (incl. core node-free + Pi vendor):** - ``` - bun install # ensure catalog is current (core registered, ui→core) - bun run typecheck - ``` - Expect green for core, shared, ai, server, ui, pi-extension. Confirm a planted `import 'node:fs'` in a `packages/core/*.ts` fails `TS2307` (node-free invariant), then remove it. - -2. **Test suite — delta vs. main must be ADDITIONS only:** - ``` - bun test - ``` - Expect the Phase-0 baseline pass count PLUS the new Step-6 seam/configure tests — zero regressions, zero failures. The delta should be exactly the new `.seam.test`/`configure.test` files plus the moved `wideMode.test` file. No pre-existing test changed behavior. - -3. **madge clean (no circular deps in published packages):** - ``` - bun run check:cycles - ``` - Exit 0. - -4. **`git diff` confined to expected packages:** - ``` - git diff --name-only main...HEAD - ``` - Must be limited to: `packages/core/**`, `packages/shared/**`, `packages/ai/**`, `packages/ui/**`, the single `packages/editor/App.tsx` import line + the moved `wideMode` files, `apps/pi-extension/vendor.sh`, root `package.json` / `.madgerc` / `.gitignore` / `bun.lock`, and (if added) `.github/workflows/*` CI. NOTHING in `packages/server`, `packages/review-editor`, `apps/hook`, `apps/opencode-plugin`, or any other Plannotator app source. - -5. **ui depends ONLY on core internally (non-test):** - ``` - grep -rn '@plannotator/shared\|@plannotator/ai' packages/ui --include='*.ts' --include='*.tsx' | grep -v '\.test\.' - ``` - Empty. (The two surviving `@plannotator/shared` imports in ui *.test.ts files are intentional — see Step 2d note — and are excluded by `grep -v '\.test\.'`.) - -6. **Apps build green + functional/visual parity (the REAL gate):** - ``` - bun run --cwd apps/review build && bun run build:hook && bun run build:opencode - bun run build:pi # Pi vendors from core now - ``` - All builds MUST succeed. **Parity is confirmed by a human running the plan review and code review UIs in the browser** (ADR 004: human browser verification is the real gate). The shipped bundle should be **functionally identical** to the Phase-0 baseline — the carve is move + re-export only, no runtime logic change. - - **Bundle-hash guidance (NOT a hard gate):** compare shipped bundle hashes against the Phase-0 baseline as a proxy signal, but do NOT treat any hash delta as an automatic STOP. The carve changes the import-resolution graph (e.g. `@plannotator/ui` now resolves `compress` directly from `core/compress.ts` instead of through the `shared/compress.ts` shim; `editor/App.tsx` now imports `wideMode` from `@plannotator/ui/utils/wideMode`). A bundler may emit different hashes purely from changed module ordering, import-path string literals, or source-map metadata while the executed JS logic is byte-identical. - - **Acceptable (proceed):** hash differs only in source-map metadata or import-path string literals, with no change to the JS logic bytes — confirm by diffing the de-minified/normalized bundle output. - - **STOP and investigate:** any difference in the actual JS logic bytes, OR any visible/functional difference in the browser. That is a regression and must be root-caused before proceeding. - -7. **CSS artifact builds:** - ``` - bun run build:ui-css && test -s packages/ui/styles.css - ``` - -**Publish/registry steps are OUT OF SCOPE for these 6 steps** — branch-validation (`bun pm pack` each, inspect tarball, `npm publish --dry-run`), the `release.yml` publish job, and the EXACT-pin substitution of `ui → core@0.21.0` happen only after a human confirms parity in the browser and gives explicit go (ADR 007 §5, THE LAW). Do NOT push these commits. diff --git a/adr/research/SPIKE-document-ui-comments-system-20260623-084806.md b/adr/research/SPIKE-document-ui-comments-system-20260623-084806.md deleted file mode 100644 index 0bedd2039..000000000 --- a/adr/research/SPIKE-document-ui-comments-system-20260623-084806.md +++ /dev/null @@ -1,73 +0,0 @@ -# Spike: Comments / Annotations / Drafts System (Phase 5) - -Date: 2026-06-23 - -> Code research for Phase 5 of the `@plannotator/ui` reuse effort (governed by ADR 004; roadmap `adr/implementation/document-ui-extraction-roadmap-20260622.md`). Five parallel probes mapped the comment/annotation/draft system on the real tree. Goal: know every backend wire and every timing-sensitive invariant before speccing the seams. THE LAW: move + decouple, never rewrite; Plannotator's experience cannot change. - -## Headline - -**Most of the comment UI is already portable.** The comment *components* (`AnnotationPanel`, `CommentPopover`, `AnnotationToolbar`, `AnnotationToolstrip`, `EditorAnnotationCard`) and the highlighter hook (`useAnnotationHighlighter`) are prop-driven with no backend wires. `review-editor` already reuses `useExternalAnnotations`, `useEditorAnnotations`, and `useCodeAnnotationDraft` unchanged — a second consumer proving portability. So Phase 5 is **narrower than its "big one" reputation on the UI side**; the work is concentrated in **three seams** (draft transport, external-annotation transport, identity) plus **two structural constraints** (renderer coupling, no reply model). - -## Annotation state lives in the host, not a shared reducer -- Plan: `packages/editor/App.tsx:255` `useState`; Review: `packages/review-editor/App.tsx:121` `useState`. There is **no shared annotation reducer** in `packages/ui` — each host owns its annotation array. So Workspaces will own its annotation state too; the shared package supplies the components/hooks that operate on it. (This is fine and matches review-editor.) - -## Seam 1 — Draft transport (`/api/draft`) + the generation protocol - -**Files:** `packages/ui/hooks/useAnnotationDraft.ts` (plan, full-featured), `packages/ui/hooks/useCodeAnnotationDraft.ts` (review, simpler). Server: `packages/shared/draft.ts`, `packages/server/shared-handlers.ts`, approve/deny in `packages/server/index.ts` + `annotate.ts`. - -- **Wires:** `GET/POST/DELETE /api/draft`. POST body carries `{annotations, codeAnnotations, globalAttachments, editedMarkdown, editedDocuments, savedFileChanges, draftGeneration, ts}`. DELETE uses `?generation=N`. 500ms debounce (`DEBOUNCE_MS`). -- **The 3-party generation protocol (the fragile part):** - 1. Client keeps `draftGenerationRef` (starts 0), **pre-increments before each POST** (`++draftGenerationRef.current`); `getDraftGeneration()` returns the *next* gen (`ref.current + 1`) — `useAnnotationDraft.ts:383`. - 2. That value **escapes the hook** and is threaded into submit by the host: `App.tsx:1960-1963` `withDraftGeneration(path)` appends `?draftGeneration=`; used on `/api/approve` (App.tsx:2704), `/api/exit` (2715); `/api/deny` and `/api/feedback` carry it in the **body** (2626, 2683). **Per-endpoint source differs:** plan approve/deny read from body; annotate approve/exit read from **URL** (`annotate.ts:557,550`), feedback from body (573). - 3. Server **tombstone-gates** (`shared/draft.ts`): `saveDraft` rejects if `draftGeneration <= deletedGeneration` (L98) or `< storedGeneration` (L102); `deleteDraft` writes a tombstone at the deletion generation (L150); ignores stale deletes (L146). This is what prevents a late async draft-save from **resurrecting a draft after submit** (ghost drafts). -- **Timing-sensitive, must move VERBATIM:** the `keepalive: true` POST with **retry-without-keepalive on failure** gated by generation match (L357-364); the `visibilitychange`/`pagehide` **flush** that fires a final keepalive save on tab close (L389-405); the refs (`draftGenerationRef`, `timerRef`, `latestRef` non-reactive getters, `canPersistRef`, `hasMountedRef`). `canPersist = isApiMode && !isSharedSession && !submitted`. -- **Already portable:** the hooks are pure (no host imports); `shared/draft.ts` is runtime-agnostic node:fs. The wires are the only coupling. - -## Seam 2 — External-annotation transport (the live-comment channel) - -**Files:** `packages/ui/hooks/useExternalAnnotations.ts`, `useExternalAnnotationHighlights.ts`. Server: `packages/server/external-annotations.ts` (+ Pi mirror), `packages/shared/external-annotation.ts` (store, validators, event types). - -- **This is the "teammates + agents commenting live" channel.** External tools/agents `POST /api/external-annotations`; the UI shows them live. -- **Transport state machine (move VERBATIM):** primary `EventSource('/api/external-annotations/stream')` delivers `snapshot|add|remove|clear|update` events into an internal reducer with **optimistic mutators** (delete/clear/update update local state, then call the server; SSE reconciles). On SSE error **before first snapshot**, fall back to **polling** `GET /api/external-annotations?since=` every **500ms** (`POLL_INTERVAL_MS`), honoring **304 Not Modified** when `since === store.version`. 30s SSE heartbeat (`:` comment). Version is session-scoped (`versionRef` starts 0). Fallback triggers once (`!receivedSnapshotRef && !fallbackRef`) and doesn't switch back. -- **Already generic + gated:** `useExternalAnnotations` is shape-generic and takes an `enabled` flag. Plan: `enabled: isApiMode && !goalSetupMode` (App.tsx:1135). **Review already reuses it** for `CodeAnnotation` with `enabled: !!origin` (App.tsx:284). `useExternalAnnotationHighlights` paints them via the Viewer handle (filters out global/diff, 100ms mount delay, fingerprint dedup). -- **Merge policy is host-owned:** App.tsx dedups local vs external by `source+type+originalText` (plan) / `source+type+filePath+lineStart+lineEnd+side` (review). -- **Seam = inject the transport** (a `subscribe()` + CRUD + `getSnapshot(since)` object) whose default reproduces the SSE→polling machine exactly. Server store/validators/SSE encoding (`shared/external-annotation.ts`) move wholesale. - -## Seam 3 — Identity / authorship ("which comments are mine") - -**Files:** `packages/ui/utils/identity.ts`, `generateIdentity.ts`, `config/configStore.ts`, `config/settings.ts`. - -- `getIdentity()` reads `configStore.get('displayName')`; resolution **server config > cookie (`plannotator-identity`) > generated `{adj}-{noun}-tater`**. `isCurrentUser(author)` compares `author === configStore.get('displayName')` (`identity.ts:47-50`). -- **Stamp sites (9 hardcoded `getIdentity()`):** `Viewer.tsx:456,518`, `useAnnotationHighlighter.ts:273`, `html-viewer/HtmlViewer.tsx:210`, `html-viewer/useHtmlAnnotation.ts:142,258,296,333`, `plan-diff/PlanCleanDiffView.tsx:169`. **Display sites (2 `isCurrentUser()`):** `AnnotationPanel.tsx:194,204` → renders the `(me)` badge (518, 651). -- **Partly already host-controllable:** identity persists via the **swappable storage backend** (Phase 2 `setStorageBackend`) and can be seeded from server config via `configStore.init(serverConfig)`. So a host can already set the identity *value*. The remaining seam is making the **stamp/display callable** overridable: optional `author?` / `isCurrentUser?` defaulting to the existing functions, so Workspaces (real WorkOS logins) supplies the logged-in user instead of a tater name. -- `Annotation.author` / `CodeAnnotation.author` are optional fields; `sharing.ts` preserves author across share/import (already collaborative). - -## Constraint A — Renderer coupling (structural, not a seam) - -**Files:** `useAnnotationHighlighter.ts` (`findTextInDOM` L106-235, `applyAnnotationsInternal` L293-403), `utils/inlineTransforms.ts` (`transformPlainText` = emoji + smartypants), `BlockRenderer.tsx`, `InlineMarkdown.tsx`, `@plannotator/web-highlighter@0.8.1`. - -- Highlight **restoration** re-anchors a saved annotation by searching the rendered DOM for `originalText`, with a fallback that applies `transformPlainText` (because the renderer turns `:smile:`→😄, `---`→—, straight→curly quotes). So restoration **only works if the host renders markdown to the same text** the transforms produce. -- Code blocks use **manual `` wrapping** (web-highlighter can't sit inside hljs spans); removal re-runs `hljs.highlightElement`. -- **Implication:** Workspaces must reuse `BlockRenderer` + `InlineMarkdown` + `inlineTransforms` **as a unit** for highlights to land. This is a documented integration contract, not a wire to cut. (Optional future: expose `transformPlainText` as overridable, but default stays.) - -## Constraint B — No reply / threading model (a gap, not a regression) - -- `Annotation` and `CodeAnnotation` are **flat**: a comment is one `text` field. No `parentCommentId`, `replies`, `threadId`. `CommentPopover` has a module-level draft cache but composes single comments. -- Workspaces wants **replies/threads** (teammates discussing on a doc). That is a **new feature**, not part of "make today's behavior reusable." Adding threading touches the type, the panel, and the popover — and must NOT change Plannotator's flat experience. **Out of scope for Phase 5's parity-preserving extraction**; flag as a Workspaces-side addition (build replies as a host-layer on top of, or a backward-compatible extension of, the shared components later). - -## Already-portable inventory (no Phase-5 work needed) -`AnnotationPanel.tsx`, `AnnotationToolbar.tsx`, `AnnotationToolstrip.tsx`, `CommentPopover.tsx`, `EditorAnnotationCard.tsx`, `AnnotationSidebar.tsx`, `useAnnotationHighlighter.ts`, `useExternalAnnotationHighlights.ts`, `utils/commentContent.ts`, `utils/annotationHelpers.ts`, `utils/anchors.ts`, and the `exportAnnotations`/`exportCodeFileAnnotations`/`exportEditorAnnotations` serializers in `parser.ts` (pure, no API). `AnnotationPanel` only touches identity via the display-only `isCurrentUser` (Seam 3). - -## Out of scope / host-owned (confirmed) -- `useEditorAnnotations` (`/api/editor-annotation(s)`, gated by `window.__PLANNOTATOR_VSCODE`) — VS Code IPC, host-only, not a document-UI seam. -- Feedback/submit routes (`/api/feedback`, `/api/approve`, `/api/deny`, `/api/exit`) and their payload policy — host-owned (Workspaces has its own). -- Annotation state ownership and the external-merge/dedup policy — host-owned. - -## Per-seam evidence map -| Seam | Key files | Backend wires | Move-verbatim invariants | -|---|---|---|---| -| 1 Drafts | useAnnotationDraft.ts, useCodeAnnotationDraft.ts, shared/draft.ts | `GET/POST/DELETE /api/draft`; generation in approve/deny/feedback/exit | generation pre-increment + tombstone gate; keepalive retry; visibility/pagehide flush; the 5 refs | -| 2 External | useExternalAnnotations.ts, useExternalAnnotationHighlights.ts, shared/external-annotation.ts, server/external-annotations.ts | SSE `/stream`; `GET ?since=`; `POST/PATCH/DELETE` | SSE→polling fallback machine; 500ms poll; 304 gate; 30s heartbeat; optimistic mutators; version-scoping | -| 3 Identity | identity.ts, configStore.ts, settings.ts | (none directly; via configStore→storage, swappable) | resolution order server>cookie>tater; 9 stamp sites; 2 `(me)` sites | -| A Renderer | useAnnotationHighlighter.ts, inlineTransforms.ts, BlockRenderer/InlineMarkdown | none | restoration depends on exact rendered text; manual code-block `` | -| B Replies | types.ts, AnnotationPanel, CommentPopover | none | flat model today; threading is a NEW feature, keep Plannotator flat | diff --git a/adr/research/SPIKE-document-ui-current-state-and-parity-20260621-115603.md b/adr/research/SPIKE-document-ui-current-state-and-parity-20260621-115603.md deleted file mode 100644 index eb510cf51..000000000 --- a/adr/research/SPIKE-document-ui-current-state-and-parity-20260621-115603.md +++ /dev/null @@ -1,216 +0,0 @@ -# Spike: Document UI Current State and Parity - -> ℹ️ **Context still useful; the direction it informed was reverted.** This honestly reported the failed cutover was only ~55–65% at parity. The cutover was reverted on 2026-06-22. Read **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`** before acting. - -Date: 2026-06-21 - -## Question - -What exactly is the state of the `@plannotator/document-ui` extraction now, how close is it to parity with the current Plan Review / Annotate app, and what should be finalized inside the shared package versus left to Plannotator or other hosts? - -## Scope - -This spike reads the current branch code. It does not change product code. - -Primary files inspected: - -- `packages/document-ui/package.json` -- `packages/document-ui/index.ts` -- `packages/document-ui/types.ts` -- `packages/document-ui/DocumentReviewSurface.tsx` -- `packages/document-ui/plannotatorHttpApi.ts` -- `packages/document-ui/memoryDocumentHostApi.ts` -- `packages/document-ui/documentReviewChrome.ts` -- `packages/editor/App.tsx` -- `packages/editor/PlannotatorDocumentSurfaceBridge.tsx` -- `packages/editor/documentSurfaceBridge.ts` -- `adr/implementation/document-ui-extraction-intent-20260620-085249.md` - -Verification run: - -- `bun test packages/document-ui` -- Result: 329 passing tests across 31 files. - -## Executive Read - -The extraction is real and substantial. It is accurate to say that much of Plannotator's document-review domain behavior has been moved into a new `@plannotator/document-ui` package. - -It is not accurate to say the app has been cut over to the package yet. - -The current production app still defaults to `packages/editor/App.tsx`. The new surface is mounted only when `VITE_DOCUMENT_SURFACE` is `1` or `true`, through `PlannotatorDocumentSurfaceBridge`. The old editor shell is still the main render path. - -My current read: - -- Package capability: roughly 70-80 percent of the hard reusable document-domain logic is extracted. -- Current-app parity: roughly 55-65 percent, depending on whether plan diff, archive, goal setup, Ask AI panel, and terminal are considered part of the required reusable surface. -- Cutover/no-legacy readiness: roughly 40-50 percent. The shared package is green, but the app still depends on a large legacy shell for important visible workflows. - -The branch is past "prototype contract" and into "candidate package," but it still needs a deliberate parity/cutover pass before deleting the old UI. - -## What Exists Now - -### Package Footprint - -`packages/document-ui` is a real package with explicit exports, not a single component dump. It exports the surface, provider-neutral types, memory provider, Plannotator HTTP adapter, feedback assembly, edit/writeback helpers, annotation persistence, draft state, linked state, tree state, chrome decisions, panel/sidebar state, Ask AI context, delivery helpers, and Plannotator compatibility helpers. - -The package is currently about 28.6k lines including tests. The main app shell is still about 4.8k lines: - -- `packages/document-ui/DocumentReviewSurface.tsx`: 1,437 lines. -- `packages/document-ui/*.ts/*.tsx`: 28,643 total lines including tests. -- `packages/editor/App.tsx`: 4,773 lines. - -### Provider-Neutral Contract - -The core types are now provider-neutral: - -- `DocumentRef` is provider/document identity, not local file identity (`packages/document-ui/types.ts:77`). -- `DocumentWritebackStatus` is `clean | dirty | saving | saved | conflict | missing | error` (`packages/document-ui/types.ts:90`). -- `LoadedDocument` carries content, render mode, image base, and optional writeback capability (`packages/document-ui/types.ts:129`). -- `DocumentReviewSession` carries mode, root document/ref, root tree ref, capabilities, and UI labels (`packages/document-ui/types.ts:177`). -- `SubmitDocumentFeedbackPayload` contains annotations, linked annotations, code annotations, attachments, direct edits, saved changes, and message scope (`packages/document-ui/types.ts:378`). -- `DocumentHostApi` abstracts load, linked-doc resolution, tree listing, document watching, save, drafts, annotation persistence, uploads, image URLs, feedback, approve, exit, Ask AI, and agent delivery (`packages/document-ui/types.ts:437`). - -This is the right conceptual center. Workspaces can implement the same contract with workspace document ids, manifests, versions, `If-Match`, and its annotation APIs. Plannotator implements it with `/api/plan`, `/api/doc`, `/api/source/save`, `/api/draft`, `/api/upload`, and `/api/image`. - -### Default Surface - -`DocumentReviewSurface` is no longer just a render prop wrapper. It now owns substantial product behavior: - -- Resolves the initial document from session/root/ref (`packages/document-ui/DocumentReviewSurface.tsx:144`). -- Seeds and tracks writeback state (`packages/document-ui/DocumentReviewSurface.tsx:171`). -- Owns root annotation state (`packages/document-ui/DocumentReviewSurface.tsx:195`). -- Owns linked-document state (`packages/document-ui/DocumentReviewSurface.tsx:216`). -- Owns optional annotation persistence (`packages/document-ui/DocumentReviewSurface.tsx:223`). -- Owns document tree state (`packages/document-ui/DocumentReviewSurface.tsx:242`). -- Owns edit/writeback controller state (`packages/document-ui/DocumentReviewSurface.tsx:250`). -- Owns provider watch reconciliation (`packages/document-ui/DocumentReviewSurface.tsx:259`). -- Owns draft save/restore state (`packages/document-ui/DocumentReviewSurface.tsx:269`). -- Builds feedback payloads with linked annotations, direct edits, and saved changes (`packages/document-ui/DocumentReviewSurface.tsx:384`). -- Calls host feedback, approve, and exit APIs (`packages/document-ui/DocumentReviewSurface.tsx:419`). -- Renders a default chrome with header, writeback badges, annotation-persistence badges, edit/save/discard/conflict buttons, submit/approve/close buttons, document navigator, feedback panel, draft banners, and error banners (`packages/document-ui/DocumentReviewSurface.tsx:551`, `packages/document-ui/DocumentReviewSurface.tsx:613`, `packages/document-ui/DocumentReviewSurface.tsx:693`). -- Renders markdown and raw HTML through the existing Plannotator renderer modules while routing image upload/image display and linked-doc opens through the provider API (`packages/document-ui/DocumentReviewSurface.tsx:1240`, `packages/document-ui/DocumentReviewSurface.tsx:1292`). - -This means the package already owns a meaningful document review loop. - -### Plannotator Adapter - -The Plannotator adapter is also substantial: - -- `createPlannotatorHttpDocumentApi()` maps current server routes into `DocumentHostApi`. -- `createPlannotatorHostSessionState()` normalizes `/api/plan` responses into document-session and host-session state (`packages/document-ui/plannotatorHttpApi.ts:408`). -- `createPlannotatorEditorLoadPlan()` derives the legacy editor load plan from normalized session state (`packages/document-ui/plannotatorHttpApi.ts:497`). -- Capabilities are mapped from local server data, including raw HTML, folder browsing, source-save writeback, share, Ask AI, agent terminal availability, and version support (`packages/document-ui/plannotatorHttpApi.ts:877`). - -This is good layering: local source-save details remain in Plannotator adapter exports, not in the provider-neutral `DocumentHostApi`. - -### Opt-In Bridge - -The bridge exists and is thin: - -- `packages/editor/documentSurfaceBridge.ts` decides the flag and renders feedback text through shared feedback assembly (`packages/editor/documentSurfaceBridge.ts:18`, `packages/editor/documentSurfaceBridge.ts:22`). -- `packages/editor/PlannotatorDocumentSurfaceBridge.tsx` creates the Plannotator HTTP API and mounts `` (`packages/editor/PlannotatorDocumentSurfaceBridge.tsx:44`, `packages/editor/PlannotatorDocumentSurfaceBridge.tsx:55`). -- `packages/editor/App.tsx` only uses the bridge behind `USE_DOCUMENT_SURFACE` (`packages/editor/App.tsx:130`, `packages/editor/App.tsx:3905`). - -This is the clearest evidence that the package is not yet the default app path. - -## What Still Lives In The Old App - -The old editor shell still owns major parity features and side effects: - -- Plan diff/version behavior: `usePlanDiff`, base-version selection, diff activation, and `PlanDiffViewer` render path remain in `App.tsx` (`packages/editor/App.tsx:817`, `packages/editor/App.tsx:4267`). -- Legacy linked-doc hook and Plannotator editable-source side effects remain in `App.tsx` (`packages/editor/App.tsx:888`, `packages/editor/App.tsx:938`). -- Archive browser state and archive selection remain in `App.tsx` (`packages/editor/App.tsx:968`, `packages/editor/App.tsx:4166`). -- External/editor annotation route integration remains in `App.tsx` (`packages/editor/App.tsx:1346`). -- Sticky header lane, annotation toolstrip, wide/focus inline controls, HTML tools toggle, checkbox overrides, code-file popout, message picker chrome, and Plannotator-specific viewer props remain in `App.tsx` (`packages/editor/App.tsx:4213`, `packages/editor/App.tsx:4235`, `packages/editor/App.tsx:4298`, `packages/editor/App.tsx:4411`). -- Goal setup is rendered from the old shell (`packages/editor/App.tsx:4255`). -- Agent terminal panel and resize shell remain in the old shell (`packages/editor/App.tsx:4078`). -- Ask AI panel and provider settings remain in the old shell (`packages/editor/App.tsx:4509`). -- Export/share/import modals and note integrations remain in the old shell (`packages/editor/App.tsx:4577`). -- The old `AppHeader` still controls Plannotator-specific top-level actions, settings, archive actions, callback actions, note-app actions, and AI/sidebar toggles (`packages/editor/App.tsx:3929`). - -Some of these should remain host-owned. Others are parity gaps if the package is meant to become the default document-review capability. - -## Parity Matrix - -| Area | Current State | Parity Read | -| --- | --- | --- | -| Provider-neutral document/session contract | In package | Strong | -| Markdown render and annotate | In package through existing `@plannotator/ui` renderer | Mostly there | -| Raw HTML render and annotate | In package through `HtmlViewer`; bridge-script tests exist | Mostly there | -| Image attachments and image display | Provider-owned in package | Strong | -| Linked document navigation | Package has provider-neutral state; old app still owns Plannotator filesystem side effects | Partial | -| Document tree/file browser | Package has tree state/default navigator; old app still owns richer file-browser tab and watchers | Partial | -| Writeback state | Provider-neutral core and Plannotator adapter exist | Strong | -| Local source-save compatibility | In package under Plannotator-specific exports | Strong for Plannotator, acceptable as adapter-specific | -| Draft restore | Provider-neutral core exists; old app still owns some display and side effects | Mostly there | -| Annotation persistence | Provider-neutral load/save contract exists | Mostly there | -| Feedback text/payload assembly | Shared package owns most assembly | Strong | -| Submit/approve/exit lifecycle | Package has default lifecycle; host still owns route policy in legacy path | Mostly there | -| External/editor annotations | Feedback text supports them; route/SSE integration remains old-app owned | Partial | -| Ask AI | Context helpers and host API type exist; full panel/session UI remains old-app owned | Partial | -| Plan versions/diff | Capability flag exists, but no generic host API and default surface does not render version browser/diff | Gap | -| Archive browser | Adapter carries archive metadata; default package surface does not provide archive browser parity | Gap or host-owned, depending decision | -| Goal setup | Old-app owned | Host-owned or package slot, not core document review | -| Agent terminal | Old-app owned; package has a slot and delivery helpers | Correctly host-owned runtime, partial UI slot | -| Sticky toolstrips/wide/focus polish | Decisions extracted, but package default chrome is simpler | Partial | -| Settings/share/import/export/note apps | Old-app owned | Correctly host-owned | -| Plugin/server routes/auth/browser open | Host/server owned | Correctly outside package | - -## What Should Be Finalized Inside The Package - -The package should own the reusable document-review loop end to end: - -1. `DocumentReviewSurface` as the default production surface for plan review, annotate file, annotate folder, annotate message, and workspace document review. -2. Provider-neutral document identity, loading, linked-doc navigation, tree navigation, annotation state, annotation persistence, draft restore, image upload/display, edit/writeback, conflict/missing/saving/saved chrome, feedback payload assembly, and submit/approve/exit actions. -3. A real default chrome that reaches parity with the current visible document experience: annotation toolstrip, sticky controls where applicable, feedback panel behavior, document navigation, file/tree badges, writeback badges, draft banners, and polished markdown/raw-HTML render behavior. -4. Optional document version/diff capability. This is the biggest missing reusable feature. The package already has `supportsVersions`, but it needs provider-neutral methods such as `listDocumentVersions`, `loadDocumentVersion`, and maybe `compareDocumentVersions`. Plannotator would adapt `/api/plan/versions` and `/api/plan/version`; Workspaces would adapt its versions API. The package should own the diff toggle/viewer because Workspaces explicitly needs the same review experience with a different provider. -5. Optional Ask AI surface behavior when `hostApi.askAI` exists. The package should own document target/context assembly and the in-document ask affordance. The host should still own provider/model config, auth, permission policy, and transport. -6. Optional annotation-provider watch/poll capability if Workspaces needs live comment updates. The current `loadAnnotations`/`saveAnnotations` contract is a good base, but route/SSE details should stay adapter-owned. -7. Plannotator local adapter as a first-class adapter, not as core vocabulary. Keep source-save, disk hash, mtime, missing local files, `/api/source/save`, and current draft compatibility in `plannotator-*` exports. -8. A memory/provider test harness that proves Workspaces-like behavior without local filesystem assumptions. -9. Contract tests for parity behavior. The package tests are green now, but cutover needs tests that assert the default surface can handle markdown, raw HTML, folder tree navigation, linked docs, writeback conflict/missing, drafts, version diff, and feedback assembly without the old `App.tsx` state machine. - -## What Should Stay Out Of The Package - -The package should not own host environment policy: - -1. Server route implementation, auth, process lifetime, browser launching, remote/local port behavior, and plugin command/hook handling. -2. Plan-mode `ExitPlanMode` hook behavior and stdout decision shape. -3. Plannotator note integrations: Obsidian, Bear, Octarine. -4. Share/paste service policy, short URL generation, import/export modal policy, and hosted share URLs. -5. Agent terminal runtime, PTY/WebSocket bridge, terminal installation, and terminal provider policy. The package should keep slots/state helpers, not own the terminal runtime. -6. Workspaces server calls and auth. Workspaces should provide an adapter implementing `DocumentHostApi`. -7. Local filesystem source-save internals as generic concepts. Those belong in the Plannotator adapter namespace. -8. The code-review/diff app in `packages/review-editor`; that is a different product surface. -9. Product settings UI and Plannotator-specific header menu policy. -10. External annotation transport details. The package can define optional annotation persistence/watch contracts, but SSE route names and provider mutation routes belong in adapters. - -## Cutover Work To Delete The Old UI - -If the branch goal is "the app uses the package and old/legacy code goes away," the remaining work is not another broad extraction pass. It is a focused parity and cutover pass: - -1. Make a crisp scope decision for plan diff, archive, goal setup, Ask AI, and terminal. - - My recommendation: move version/diff into the package as optional document capability. - - Keep archive as either a Plannotator host tab/slot or an optional adapter-provided document collection, not mandatory core. - - Keep goal setup host-owned or slot-based unless Workspaces needs it. - - Keep terminal runtime host-owned; use slots and delivery state. - - Move Ask AI UI only up to the provider-neutral level; host owns provider config and permissions. -2. Bring `DocumentReviewSurface` default chrome to parity for annotate/file/folder/message and plan review. -3. Add the missing generic version/diff API and render path in the package. -4. Wire Plannotator production path to the bridge without `VITE_DOCUMENT_SURFACE`. -5. Delete or collapse duplicate `App.tsx` document-domain state once the package owns it. -6. Leave `App.tsx` as a Plannotator host shell: load session, read settings, configure adapters/slots, handle route/policy side effects, render package surface. -7. Add a cutover test matrix: - - `bun test packages/document-ui` - - `bun run typecheck` - - `VITE_DOCUMENT_SURFACE=1 bun run --cwd apps/hook build` - - `bun run --cwd apps/hook build` - - targeted browser smoke for annotate markdown, annotate raw HTML, annotate folder, plan review, linked docs, source-save conflict/missing, and plan diff. - -## Bottom Line - -We have extracted much of the Plannotator document UI and domain behavior into its own package. That is a proper thing to say. - -We have not yet made the package the app. The old shell is still the default path and still owns visible parity-critical workflows. - -The clean path is to finish the package around the actual document-review loop, especially version/diff and default chrome parity, then flip the production app to the package and remove the duplicate editor state. Keeping the old shell around indefinitely would defeat the point of this branch; deleting it now would cut out real behavior users still rely on. diff --git a/adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md b/adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md deleted file mode 100644 index ea5b037da..000000000 --- a/adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md +++ /dev/null @@ -1,744 +0,0 @@ -# Spike: Document UI Extraction Boundary - -> ℹ️ **Research still useful; the direction it informed was reverted.** This accurately describes how the current document UI works, but the extraction approach it fed into (ADRs 002/003) was reverted on 2026-06-22. Read **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`** before acting on any recommendation here. - -Date: 2026-06-20 - -## Question - -Build a concrete understanding of how the Plan Review app now powers annotate-mode document review, and identify the boundary for extracting that document experience into a shared UI package. - -The historical center of gravity was Plan Mode: Claude Code intercepted `ExitPlanMode`, opened Plannotator, and waited for approve or deny. The current primary document workflow is broader: - -- run annotate on a markdown, text, HTML, URL, or folder target -- run last-message annotation -- optionally review-gate an artifact with approve or feedback -- sometimes keep an agent terminal running inside the annotate session - -## Scope - -This spike only reads the current branch code. It does not change product code. - -Primary files inspected: - -- `packages/editor/App.tsx` -- `packages/editor/components/AppHeader.tsx` -- `packages/editor/components/AnnotateAgentTerminalPanel.tsx` -- `packages/editor/agentTerminalIntegration.ts` -- `packages/editor/directEdits.ts` -- `packages/editor/editableDocuments.ts` -- `packages/editor/sourceDocumentClient.ts` -- `packages/editor/sourceDocumentReconciliation.ts` -- `packages/editor/savedFileChangeValidation.ts` -- `packages/ui/components/Viewer.tsx` -- `packages/ui/components/BlockRenderer.tsx` -- `packages/ui/components/InlineMarkdown.tsx` -- `packages/ui/components/MarkdownEditor.tsx` -- `packages/ui/components/html-viewer/HtmlViewer.tsx` -- `packages/ui/components/html-viewer/useHtmlAnnotation.ts` -- `packages/ui/components/AnnotationPanel.tsx` -- `packages/ui/components/sidebar/FileBrowser.tsx` -- `packages/ui/components/sidebar/SidebarContainer.tsx` -- `packages/ui/hooks/useAnnotationDraft.ts` -- `packages/ui/hooks/useAnnotationHighlighter.ts` -- `packages/ui/hooks/useFileBrowser.ts` -- `packages/ui/hooks/useLinkedDoc.ts` -- `packages/ui/hooks/usePlanDiff.ts` -- `packages/ui/hooks/useArchive.ts` -- `packages/ui/hooks/useAIChat.ts` -- `packages/ui/hooks/useExternalAnnotations.ts` -- `packages/ui/hooks/useValidatedCodePaths.ts` -- `packages/ui/utils/parser.ts` -- `packages/ui/types.ts` -- `packages/server/annotate.ts` -- `packages/server/index.ts` -- `packages/server/reference-handlers.ts` -- `packages/server/reference-watch.ts` -- `packages/server/agent-terminal.ts` -- `apps/hook/server/index.ts` -- `apps/opencode-plugin/index.ts` -- `apps/pi-extension/index.ts` -- `apps/pi-extension/server/serverAnnotate.ts` -- `apps/pi-extension/server/reference.ts` -- `apps/pi-extension/server/file-browser-watch.ts` - -## Short Answer - -There is not a separate "Annotate app" today. Annotate mode is the Plan Review app running in a different server-provided mode. - -The Bun annotate server deliberately serves document content through `/api/plan` so the existing plan editor bundle can render it. `packages/editor/App.tsx` is the real composition root for plan review, annotate, annotate-last, annotate-folder, archive, goal setup, linked docs, direct edits, AI, drafts, external annotations, file browser, raw HTML, and agent terminal. - -The reusable pieces are already partly in `@plannotator/ui`, but that package is not a clean document UI package. Many components and hooks inside it fetch hard-coded `/api/*` routes. The actual document-product state machine is split between `@plannotator/ui` and `@plannotator/editor`, with the largest orchestration still in `App.tsx`. - -A good extraction should not start by moving `App.tsx` wholesale. The safer boundary is a document-review surface with an explicit host API adapter and optional capabilities. - -## Current Runtime Shape - -### Entry points - -Claude Code, Droid, OpenCode, and Pi all route manual document review into annotate mode. - -Claude Code and Droid run the CLI-style commands: - -- `plannotator annotate ` -- `plannotator annotate-last` / `plannotator last` - -OpenCode intercepts `plannotator-annotate`, `plannotator-last`, and `plannotator-review` before the agent sees the command. This is important: OpenCode clears command prompt output so a large file path is not auto-attached to the agent context before Plannotator opens. - -Pi implements native command handlers, but converges on the same server/UI contract. - -The CLI annotate command does input detection before starting the server: - -- `https://...`: fetch with Jina Reader by default, or fetch plus Turndown with `--no-jina` -- folder: open annotate-folder mode and show the file browser -- `.html` / `.htm`: render raw HTML by default, or convert to markdown with `--markdown` -- `.md`, `.mdx`, `.txt`: read file text directly - -Annotate-last resolves recent assistant messages from each agent's transcript or session store. It can pass a picker list of recent messages to the frontend. - -### Servers - -There are two server implementations with matching API surfaces: - -- Bun server in `packages/server/*`, used by Claude Code, Droid, and OpenCode paths. -- Pi server in `apps/pi-extension/server/*`, using Node HTTP primitives and generated shared files. - -The annotate Bun server is `startAnnotateServer(options)` in `packages/server/annotate.ts`. The Pi mirror is `apps/pi-extension/server/serverAnnotate.ts`. - -The annotate server intentionally reuses `/api/plan`: - -```text -GET /api/plan -> { - plan, - origin, - mode, - filePath, - sourceInfo, - sourceConverted, - sourceSave, - gate, - renderAs, - rawHtml?, - convertHtml, - sharingEnabled, - shareBaseUrl, - pasteApiUrl, - repoInfo, - projectRoot, - isWSL, - serverConfig, - agentTerminal?, - recentMessages? -} -``` - -That endpoint is the switch that turns the plan editor bundle into annotate mode. - -Other annotate-mode endpoints used by the document UI: - -- `GET /api/doc`: open linked docs, folder files, and code-file previews. -- `POST /api/doc/exists`: validate code-file links discovered in markdown. -- `GET /api/reference/files`: build the folder file browser tree. -- `GET /api/reference/files/stream`: SSE watch for folder tree, git status, and open source file changes. -- `POST /api/source/save`: atomically save source-backed markdown, mdx, or text files. -- `GET /api/share-html`: lazily prepare portable raw HTML for sharing. -- `GET /api/html-assets//`: serve relative HTML support assets. -- `GET/POST/DELETE /api/draft`: persist annotations, attachments, and direct edits. -- `POST /api/feedback`: return annotated feedback to the invoking session. -- `POST /api/approve`: approve a review-gated annotate session. -- `POST /api/exit`: close without feedback. -- `GET/POST /api/ai/*`: Ask AI sessions. -- `GET/POST/PATCH/DELETE /api/external-annotations*`: live annotations from external tools. -- `WebSocket /api/agent-terminal/pty/`: optional annotate-mode agent terminal. - -The server also owns the security boundary for document access. `getAnnotateReferenceRootPaths()` scopes file access to the folder target, current working directory, the source file directory, and realpath equivalents. `/api/doc` and `/api/doc/exists` resolve within those roots. - -### Source-save capability - -Source save is negotiated by the server and carried to the UI as `sourceSave`. - -Enabled only for local `.md`, `.mdx`, or `.txt` documents. Disabled for: - -- message mode -- folder root before a file is selected -- raw HTML rendering -- converted HTML/URL content -- non-local URLs -- unsupported extensions -- missing or unreadable files - -The capability includes: - -- scope: `single-file` or `folder-file` -- path, basename, language -- content hash, mtime, size, and EOL style - -The UI uses these fields as optimistic concurrency metadata when calling `/api/source/save`. - -## Current UI Shape - -### Composition root - -`packages/editor/App.tsx` is 4,685 lines and owns the product state machine. - -It initializes from `/api/plan`, then branches across: - -- normal plan review -- annotate single file -- annotate last message -- annotate folder -- raw HTML annotate -- archive -- goal setup -- shared sessions - -Core state clusters in `App.tsx`: - -- document content: `markdown`, `renderAs`, `rawHtml`, `shareHtml`, `sourceInfo`, `sourceConverted`, `sourceFilePath`, `imageBaseDir`, `projectRoot` -- parsed document: `displayedMarkdown`, `frontmatter`, `blocks` -- annotations: document annotations, code annotations, external annotations, editor annotations, linked-doc annotation cache, global image attachments -- editor state: markdown edit mode, direct-edit stats, dirty flags, editable document records -- mode flags: `annotateMode`, `gate`, `annotateSource`, archive mode, goal setup, message picker -- layout: left sidebar, right annotation panel, wide mode, resizable panes, agent terminal pane -- server/session capabilities: origin, sharing URLs, repo info, AI providers, agent terminal capability - -The key render switch is: - -- `renderAs === "html"`: render `HtmlViewer` -- `isEditingMarkdown`: render `MarkdownEditor` -- otherwise: render `Viewer` - -This means markdown, editable markdown, and raw HTML are different render surfaces inside the same app shell. - -### Markdown parser and block model - -`parseMarkdownToBlocks(markdown)` in `packages/ui/utils/parser.ts` creates `Block[]`. - -The parser is intentionally simple and stable for annotation anchoring. It handles: - -- headings with deterministic ids -- paragraphs -- blockquotes and GitHub alert callouts -- list items and task checkboxes -- fenced code blocks -- tables -- horizontal rules -- raw HTML blocks -- directive containers -- inline enhancements through render components - -`Block.startLine` is part of the feedback contract. `exportAnnotations()` uses it to generate human-readable feedback with line labels. - -This creates a strong coupling: - -```text -markdown -> parseMarkdownToBlocks -> Block ids and startLine - -> Viewer highlights and annotation blockId - -> exportAnnotations feedback -``` - -Any extracted package must preserve this chain or own a replacement end to end. - -### Viewer - -`packages/ui/components/Viewer.tsx` is 970 lines. It is not just a presentational renderer. - -It owns: - -- `useAnnotationHighlighter` -- web-highlighter lifecycle -- code-block annotation path -- sticky headers and scroll behavior -- code path validation through `useValidatedCodePaths` -- heading anchors and hash navigation -- global comments and image attachments -- quick labels -- table and code popouts -- doc badges -- Ask AI hooks at comment and document level - -`Viewer` delegates block rendering to `BlockRenderer`, `CodeBlock`, `TableBlock`, `MermaidBlock`, `GraphvizBlock`, and related components. - -`InlineMarkdown` is another important coupling point. It linkifies code-file references and wiki/doc links, fetches `/api/doc` for hover previews, and relies on `CodePathValidationContext`. - -### Raw HTML viewer - -`HtmlViewer` renders raw HTML in an iframe through `srcdoc`. The server rewrites relative assets to `/api/html-assets/...`. - -Annotation inside raw HTML uses `useHtmlAnnotation` and an injected bridge script. It communicates selection, comments, deletions, and quick labels with `postMessage`. - -HTML annotations do not use markdown blocks the same way markdown annotations do. They carry text and bridge mark ids, with `blockId` effectively empty. This is a separate annotation path hidden behind the same `ViewerHandle` contract: - -- `removeHighlight` -- `clearAllHighlights` -- `applySharedAnnotations` - -### Markdown editing and direct edits - -`MarkdownEditor` in `@plannotator/ui` is a thin Plannotator-themed wrapper around `@plannotator/markdown-editor`. - -The editing state is not in that component. It lives in `App.tsx` plus `packages/editor/editableDocuments.ts`. - -For normal plan review, editing produces a Direct Edits feedback section. For source-backed annotate files, editing can save back to disk through `/api/source/save`. - -After edits, `App.tsx` calls `applyEditedDocument(next)`: - -- reparse markdown -- remap annotations by original selected text -- clear positional metadata when a block changes -- update markdown -- bump `editGeneration` -- repaint highlights - -This annotation remapping is a critical behavior. It is easy to lose if editing is extracted separately from rendering and export. - -### Source-backed folder files - -`useEditableDocuments()` tracks one record per source-backed document: - -- session-open text and hash -- disk baseline -- current text -- dirty/saving/saved/conflict/error/missing status -- saved change context for feedback -- conflict snapshots when disk changed - -The source document reconciliation loop watches directories containing open source docs through `/api/reference/files/stream`. On SSE events, it refetches snapshots through `/api/doc` and reconciles: - -- clean file changed on disk: update UI to disk -- dirty file changed on disk: mark conflict -- file disappeared: mark missing -- stale async snapshot: ignore by sequence/hash guard - -The file browser uses `useFileBrowser()` plus `FileBrowser.tsx`. It displays: - -- markdown/text/html file tree -- workspace status from git metadata -- annotation counts by file -- edit status markers from `editableDocuments` - -Folder mode selection opens files through linked-doc machinery, but source-backed folder files can become editable and saveable. - -### Linked docs - -`useLinkedDoc()` is central to the document experience. - -It handles same-surface navigation to another markdown or HTML document: - -- snapshot current root or linked document -- cache annotations and attachments per filepath -- clear and restore highlights -- switch `markdown`, `renderAs`, `rawHtml`, and `shareHtml` -- restore cached doc state on back -- keep annotation counts for file browser/sidebar - -Linked docs can be raw HTML or markdown. This means `renderAs` is not only a session-level mode; it is active-document scoped. - -### Drafts - -`useAnnotationDraft()` persists: - -- full `Annotation[]` -- code annotations -- global attachments -- direct edited markdown -- dirty source-backed edited documents -- already-saved source-backed file changes -- draft generation number - -The hook is intentionally best-effort and uses debounced `/api/draft` writes with `keepalive` flushes on page hide. Draft generation prevents a late save from resurrecting a draft after submit. - -Draft restore in `App.tsx` is complex because it has to validate saved file changes against disk, restore dirty source documents, maybe reopen a single restored file, remap annotations, and repaint highlights. - -### Feedback and approval - -Plan mode: - -- Approve posts `/api/approve`. -- Deny posts `/api/deny`. -- Feedback may include annotations, editor annotations, linked-doc annotations, code-file annotations, direct edits, saved file changes, and note-app settings. - -Annotate mode: - -- Feedback normally posts `/api/feedback`. -- Gate approval posts `/api/approve`. -- Close posts `/api/exit`. -- If the agent terminal is ready, feedback is sent directly to the terminal instead of `/api/feedback`. - -`getCurrentFeedbackPayload()` is the important document feedback seam in `App.tsx`. It composes exported annotations plus direct-edit and saved-file-change sections, then wraps them for the target agent/file/message context. - -### Agent terminal - -Agent terminal is annotate-only for `annotate` and `annotate-folder`, not `annotate-last`. - -Server side: - -- Bun uses `createBunAgentTerminalBridge()`. -- Pi mirrors it with `createNodeAgentTerminalBridge()`. -- The server advertises `agentTerminal` capability in `/api/plan`. -- A tokenized WebSocket path is generated under `/api/agent-terminal/pty/`. -- Remote sessions disable terminal by default unless `PLANNOTATOR_AGENT_TERMINAL_REMOTE=1`. -- The Bun bridge starts a Node sidecar for WebTUI and proxies browser WebSocket traffic to the sidecar. - -UI side: - -- `AnnotateAgentTerminalPanel` uses `@plannotator/webtui/browser` and `@plannotator/webtui/react`. -- It stores the preferred agent id and terminal display settings locally. -- It exposes `sendMessage()` and `stop()` through an imperative ref. -- `App.tsx` tracks whether the terminal is running, open, ready, and whether the current feedback payload was already delivered. - -When terminal delivery succeeds, the browser does not close the annotate session. It marks the feedback as delivered and keeps the terminal workflow live. - -### AI - -Ask AI is a server-backed capability exposed by `/api/ai/capabilities` and used through `useAIChat()`. - -When the annotate agent terminal is ready, `App.tsx` hides normal AI chat streaming and routes Ask AI prompts to the terminal instead. - -This is another package boundary concern: AI can be a document-surface capability, but the provider registry and terminal fallback are host/session concerns. - -## Package Boundary Today - -`@plannotator/ui` already contains a lot of reusable document primitives: - -- parser and feedback export utilities -- `Viewer` -- `HtmlViewer` -- `MarkdownEditor` -- annotation toolbar/panel pieces -- file browser UI and hook -- linked-doc hook -- draft hook -- sidebar shell -- AI chat hook and UI -- external annotation hooks -- plan diff and archive pieces - -`@plannotator/editor` contains the app shell and several document-domain state modules: - -- `App.tsx` -- source edit state and reconciliation -- direct-edit feedback sections -- source document client -- source document path helpers -- agent terminal panel and integration -- app header -- shortcuts surface - -This split is historical, not architectural. `@plannotator/ui` is broad and route-aware. `@plannotator/editor` is a product shell that imports almost every document primitive and wires them into server APIs. - -Line counts that indicate the current extraction pressure: - -- `packages/editor/App.tsx`: 4,685 -- `packages/ui/components/Viewer.tsx`: 970 -- `packages/editor/components/AnnotateAgentTerminalPanel.tsx`: 746 -- `packages/ui/components/AnnotationPanel.tsx`: 731 -- `packages/editor/editableDocuments.ts`: 666 -- `packages/ui/hooks/useLinkedDoc.ts`: 494 -- `packages/ui/hooks/useFileBrowser.ts`: 358 -- `packages/server/annotate.ts`: 661 -- `apps/pi-extension/server/serverAnnotate.ts`: 561 - -## Extraction Risks - -### 1. Direct `/api/*` fetches inside reusable UI - -Many `@plannotator/ui` hooks and components call API routes directly: - -- `useAnnotationDraft`: `/api/draft` -- `useFileBrowser`: `/api/reference/files`, `/api/reference/files/stream`, `/api/reference/obsidian/files` -- `useLinkedDoc`: default `/api/doc` -- `usePlanDiff`: `/api/plan/version`, `/api/plan/versions` -- `useAIChat`: `/api/ai/*` -- `useExternalAnnotations`: `/api/external-annotations*` -- `useEditorAnnotations`: `/api/editor-annotations`, `/api/editor-annotation` -- `useValidatedCodePaths`: `/api/doc/exists` -- `InlineMarkdown`: `/api/doc` -- `OpenInAppButton`: `/api/open-in/apps`, `/api/open-in` -- `ExportModal`: `/api/save-notes` - -That is acceptable for an app-local UI package, but not for a reusable document package unless the package declares those routes as its required host API. - -### 2. `App.tsx` mixes mode policy with document mechanics - -Examples: - -- Plan approve/deny and annotate feedback live beside editor remapping. -- Archive/goal setup branches live beside folder annotation. -- AI provider defaults live beside source-file conflict handling. -- Agent terminal delivery status affects whether feedback buttons are enabled. -- Sidebar auto-open rules depend on archive, goal, folder, HTML, and TOC state. - -Moving this all at once would preserve complexity under a new package name. - -### 3. Parser, highlight anchors, and feedback export are one contract - -The markdown renderer cannot be extracted independently from: - -- `Block` ids -- `Block.startLine` -- annotation `blockId` -- text-search restoration -- direct-edit remapping -- feedback export - -These should move or remain together. - -### 4. Source-save behavior is part of the document product - -Source save is not a small add-on. It includes optimistic concurrency, file watch reconciliation, conflict UX, missing-file UX, draft restore, saved file change feedback, and file browser edit badges. - -If source save stays outside an extracted package, the package still needs extension points for all those statuses and actions. - -### 5. Raw HTML is a parallel rendering and annotation stack - -The raw HTML path uses iframe bridge annotations, rewritten asset URLs, and share HTML preparation. It is not just another markdown block type. - -### 6. Dual server parity remains required - -Endpoint changes must be made in both: - -- `packages/server/*` -- `apps/pi-extension/server/*` - -A frontend extraction can reduce UI duplication, but it does not remove this server parity requirement. - -## Candidate Package Boundary - -The practical package is not "all of `App.tsx`." It is a document review surface. - -Working name: - -```text -@plannotator/document-ui -``` - -Primary exported component: - -```text -DocumentReviewSurface -``` - -It should own: - -- markdown/raw-HTML render switch -- annotation lifecycle -- annotation panel integration -- linked document navigation -- file browser document picking -- markdown edit mode -- source-save document state -- draft persistence integration -- feedback payload assembly - -It should not own directly: - -- Claude/OpenCode/Pi command interception -- server startup -- browser opening -- note-app integration persistence policy -- plan version history -- archive browsing -- goal setup -- agent-specific transcript lookup -- exact terminal sidecar implementation - -Those should remain host/app concerns passed as data, capabilities, callbacks, or optional slots. - -## Suggested Host API Adapter - -Instead of hard-coded route fetches in the surface, define a `DocumentHostApi` adapter. - -Shape at a high level: - -```ts -interface DocumentHostApi { - loadSession(): Promise; - loadDocument(request: LoadDocumentRequest): Promise; - validateCodePaths(request: ValidateCodePathsRequest): Promise; - listFiles(request: ListFilesRequest): Promise; - watchFiles?(request: WatchFilesRequest): EventSourceLike; - saveSource?(request: SourceSaveRequest): Promise; - loadDraft(): Promise; - saveDraft(draft: DraftPayload): Promise; - deleteDraft(generation: number): Promise; - submitFeedback(payload: SubmitFeedbackPayload): Promise; - approve?(): Promise; - exit?(): Promise; - uploadImage?(file: File): Promise; - loadShareHtml?(path?: string): Promise; -} -``` - -The current Bun/Pi HTTP routes can be one implementation of that adapter: - -```text -createPlannotatorHttpDocumentApi() -``` - -This avoids baking `/api/plan` into every reusable hook. It also makes local storybook/unit tests easier because the surface can run against an in-memory adapter. - -## Suggested Extraction Sequence - -### Step 1. Define contracts without moving UI - -Create shared document session types around the current `/api/plan` annotate shape and `/api/doc` loaded-document shape. - -Do not rename server routes yet. Keep route compatibility. - -Useful contracts: - -- `DocumentSession` -- `DocumentMode` -- `LoadedDocument` -- `DocumentSourceInfo` -- `DocumentFeedbackPayload` -- `DocumentHostApi` -- `DocumentCapabilities` - -This gives the code a vocabulary before package movement. - -### Step 2. Extract API client wrappers - -Move route fetches behind a client object used by `App.tsx`. - -Good first candidates: - -- `/api/doc` -- `/api/doc/exists` -- `/api/reference/files` -- `/api/reference/files/stream` -- `/api/source/save` -- `/api/draft` -- `/api/share-html` - -The goal is not abstraction for its own sake. The goal is to make the eventual package boundary explicit and testable. - -### Step 3. Move source document state out of `@plannotator/editor` - -The source-edit modules are already cohesive: - -- `editableDocuments.ts` -- `sourceDocumentClient.ts` -- `sourceDocumentReconciliation.ts` -- `savedFileChangeValidation.ts` -- `sourceDocumentPaths.ts` -- `directEdits.ts` -- `draftRestoreSelection.ts` - -These are document-domain modules. They are stronger candidates for `@plannotator/document-ui` than plan-specific code. - -### Step 4. Extract document surface around existing components - -Create a component that accepts: - -- initial document session -- host API adapter -- current origin/config info -- optional capability slots: AI, terminal, notes/export, sharing, archive, plan diff -- callbacks for submit/approve/exit - -At this step, `packages/editor/App.tsx` becomes a host shell that still handles plan-specific behavior, but delegates document mechanics. - -### Step 5. Split plan-only features from annotate-first features - -Plan-only or mostly plan-only: - -- `/api/approve` and `/api/deny` behavior for `ExitPlanMode` -- plan save settings -- plan version history -- plan diff browser -- archive sidebar -- permission mode setup - -Annotate/document-first: - -- render markdown/raw HTML -- annotations and feedback -- linked docs -- folder browser -- source save -- direct edits -- drafts -- external annotations -- image attachments - -The extracted package should make plan-only features optional instead of requiring them in every document session. - -## Recommended Initial Decision - -Extract a document-review package, not a plan-review package. - -The package should treat plan review as one host mode that supplies a document and approve/deny callbacks. Annotate should be the reference use case for the package because it exercises the full document surface: arbitrary files, folders, raw HTML, linked docs, source save, drafts, and optional terminal delivery. - -Do not make `@plannotator/ui` the final boundary by default. It is currently a mixed component library plus route-aware app helpers. Either: - -- create `@plannotator/document-ui` and move document-domain pieces there, or -- carve a `/document` export surface inside `@plannotator/ui` with a documented host API contract. - -The separate package is cleaner if the goal is reuse across applications. - -## Verification Map - -Existing tests that cover likely extraction-sensitive behavior: - -- `packages/ui/utils/parser.test.ts` -- `packages/ui/components/InlineMarkdown.test.ts` -- `packages/ui/markdownEditorFidelity.test.tsx` -- `packages/ui/annotationDraftPersistence.test.tsx` -- `packages/ui/hooks/useFileBrowser.test.tsx` -- `packages/ui/components/sidebar/FileBrowser.test.ts` -- `packages/editor/directEdits.test.ts` -- `packages/editor/editableDocuments.test.ts` -- `packages/editor/editableDocumentsHook.test.tsx` -- `packages/editor/sourceDocumentClient.test.ts` -- `packages/editor/sourceDocumentReconciliation.test.ts` -- `packages/editor/savedFileChangeValidation.test.ts` -- `packages/editor/agentTerminalIntegration.test.ts` -- `packages/server/annotate.test.ts` -- `packages/server/annotate-doc-url.test.ts` -- `packages/server/annotate-html-assets.test.ts` -- `packages/server/reference-handlers.test.ts` -- `packages/server/reference-watch.test.ts` -- `packages/server/agent-terminal.test.ts` -- `apps/pi-extension/server.test.ts` -- `apps/pi-extension/server/agent-terminal.test.ts` -- `apps/pi-extension/server/file-browser-watch.test.ts` - -Tests to add before or during extraction: - -- a contract test for the Bun and Pi annotate `/api/plan` shapes -- a contract test for `/api/doc` loaded markdown, raw HTML, converted HTML, code-file preview, and source-save metadata -- a component test for the document surface with an in-memory host API adapter -- an integration test that edits a folder file, saves it, restores draft state, and sends feedback with saved file change context -- an integration test that switches linked docs between markdown and raw HTML and preserves per-document annotations - -## Open Questions - -1. Should the extracted package include the right annotation panel, or only the document renderer plus hooks? - -Recommendation: include it. The panel is part of the annotation product, and feedback export depends on the same state. - -2. Should the agent terminal live in the document package? - -Recommendation: keep terminal transport and sidecar out. Include only an optional "agent delivery" capability or slot. The current panel can move later if the package is intended to ship the full annotate workspace. - -3. Should plan diff and archive move with the package? - -Recommendation: no for the first extraction. They depend on plan history endpoints and plan-specific mental models. Leave them as host-provided optional sidebar tabs. - -4. Should route names change away from `/api/plan` for annotate? - -Recommendation: not during extraction. The route name is historical but functional. Put a typed client over it first, then rename only if there is a separate compatibility plan for Bun and Pi. - -5. Should raw HTML and markdown be separate surfaces? - -Recommendation: keep one document surface with two render engines. Users experience them as one annotate workflow, and linked docs can switch between them. - -## Bottom Line - -The current architecture works because the annotate server impersonates the plan server enough for `packages/editor/App.tsx` to boot the same React app in annotate mode. - -The extraction should preserve the successful part of that design: one document review experience across plan, file, folder, HTML, URL, and last-message workflows. - -The extraction should remove the fragile part: document behavior is currently spread across a massive app shell and route-aware shared UI hooks. The next architectural boundary should be a document surface plus a typed host API adapter. diff --git a/adr/research/SPIKE-document-ui-extras-system-20260623-100827.md b/adr/research/SPIKE-document-ui-extras-system-20260623-100827.md deleted file mode 100644 index 6db4c302d..000000000 --- a/adr/research/SPIKE-document-ui-extras-system-20260623-100827.md +++ /dev/null @@ -1,56 +0,0 @@ -# Spike: Phase 6 Extras — Versions/Diff, Settings, Sharing/Export, Ask AI - -Date: 2026-06-23 - -> Code research for Phase 6 of the `@plannotator/ui` reuse effort (ADR 004; roadmap `adr/implementation/document-ui-extraction-roadmap-20260622.md`). Five parallel probes mapped the four extra subsystems. THE LAW: move + decouple, never rewrite; Plannotator's experience cannot change. - -## Headline - -Phase 6 is **mostly already portable** — the same pattern as the sidebar/comment-UI noops. The actual work is a small set of seams plus **one CSS wrinkle** (block-level diff styles live in the app shell, not the package). The fragile pieces are narrow: the AI streaming reader loop (do-not-touch) and the configStore write-back batching (keep verbatim). - -## Subsystem 1 — Versions / plan diff - -**Files:** `packages/ui/hooks/usePlanDiff.ts`, `utils/planDiffEngine.ts`, `components/plan-diff/*`. CSS: `packages/editor/index.css` + `packages/ui/theme.css`. - -- **Seam A — version fetchers:** `usePlanDiff` hard-codes `fetch('/api/plan/version?v=N')` (L98) and `fetch('/api/plan/versions')` (L119). Inject optional fetchers, default = today's literals. **Keep error asymmetry verbatim:** `selectBaseVersion` `alert()`s on failure (L100, L107); `fetchVersions` is silent (L127-131). -- **Seam B — VS Code diff:** `PlanDiffViewer.tsx` POSTs `/api/plan/vscode-diff` (L65). Optional `onOpenVscodeDiff?` prop; default = today's fetch (or omit the button when not provided). -- **Already portable:** `planDiffEngine.ts` (pure `computePlanDiff`/`computeInlineDiff`), `PlanDiffBadge`, `PlanCleanDiffView`, `PlanRawDiffView`, `PlanDiffModeSwitcher` — all prop-driven. -- **THE CSS WRINKLE (confirmed):** the *word-level* classes `.plan-diff-word-*` live in the package (`theme.css` L451-480), but the *block-level* and *raw* diff classes — `.plan-diff-added/removed/modified/unchanged` (`editor/index.css` L168-211) and `.plan-diff-line-added/removed` (L213-230) — live in the **app shell**, not the package. Also `.annotation-highlight*` (L119-157) lives in the app shell (used by the regular Viewer too, so this is broader than diff). Without these, a host's diff renders unstyled (no borders/backgrounds → unreadable). Fix: move the `.plan-diff-*` block/raw classes into `packages/ui/theme.css` (co-located with `.plan-diff-word-*`); Plannotator imports `theme.css` so it stays identical. `.annotation-highlight` is a broader CSS-contract item (Viewer needs it in any host). - -## Subsystem 2 — Settings / config - -**Files:** `packages/ui/config/configStore.ts`, `config/settings.ts`, `components/Settings.tsx`, `components/settings/HooksTab.tsx`. - -- **Seam A — config write-back:** `configStore.scheduleServerSync` POSTs `/api/config` (L118) after a 300ms debounce with `deepMerge` batching. Inject **only the final fetch** via `setServerSync(fn)`; **keep singleton construction, eager cookie reads (constructor L44-59), the 300ms debounce, and `deepMerge` byte-identical** — a naive per-`set()` fetch breaks multi-setting batching. -- **Seam B — obsidian vault detect:** `Settings.tsx` `fetch('/api/obsidian/vaults')` (L745-760). Optional `onDetectObsidianVaults?`; **keep the `useEffect [obsidian.enabled]` dep and the auto-select-first-vault branch verbatim** (changing the dep re-triggers or kills auto-select). -- **Already host-controllable:** cookie storage is swappable (Phase 2 `setStorageBackend`, literal `plannotator-*` keys); identity is swappable (Phase 5 `setIdentityProvider`); `settings.ts` is pure (no fetch); server identity seeded via `configStore.init(serverConfig)`. -- **PLANNOTATOR-ONLY (out of scope):** `HooksTab.tsx` (`/api/hooks/status`, `/api/config` pfmReminder) — mounted only `mode==='plan'`, never exported to hosts. - -## Subsystem 3 — Sharing / export / notes - -**Files:** `components/ExportModal.tsx`, `utils/sharing.ts`, `hooks/useSharing.ts`, `components/ImportModal.tsx`, `components/OpenInAppButton.tsx`, `utils/{obsidian,bear,octarine,callback,defaultNotesApp}.ts`. - -- **Seam — save to notes:** `ExportModal.tsx` `fetch('/api/save-notes')` (L150). Optional `onSaveToNotes?` returning `{success, error}`; **keep `showNotesTab = isApiMode && !!markdown` (L83) byte-for-byte** — do not re-base the gate on the new prop. -- **Already portable:** `sharing.ts` is fully parameterized (`shareBaseUrl`/`pasteApiUrl` params, defaults to Plannotator URLs); `useSharing` is prop-driven; `ImportModal` is callback-driven (`onImport`); `obsidian/bear/octarine/callback/defaultNotesApp` are pure storage/format helpers. The `/p/` short-URL routing in `useSharing` is Plannotator's convention but is `pasteApiUrl`-injectable. -- **PLANNOTATOR-ONLY (out of scope):** `OpenInAppButton.tsx` (`/api/open-in`, `/api/open-in/apps`, local-CLI file opening) — host-only, stub/omit for other hosts. - -## Subsystem 4 — Ask AI (riskiest) - -**Files:** `packages/ui/hooks/useAIChat.ts`, `components/ai/*`, `utils/aiProvider.ts`, `utils/aiChatFormat.ts`. - -- **Seam — AI transport:** five `/api/ai/*` fetches in `useAIChat`: `/api/ai/session` (L134), `/api/ai/query` (L213), `/api/ai/abort` (L153 supersede + L350 standalone), `/api/ai/permission` (L365). Inject an `AITransport` (session/query/abort/permission), default = today's fetches. -- **DO NOT TOUCH (verbatim, stays in hook):** the **SSE reader loop** (L233-304 — buffers partial lines, dispatches `text_delta|text|permission_request|error|result`, mutates React state per message); the **epoch/createRequest guards** (refs L109-110; checks L152, L208; resets L376-390); the **supersede-abort fetch position** (L153-158 — must stay inside `createSession` immediately after the epoch check, or the orphaned session leaks). Only the *transport* (the fetch calls) is parametrized; the streaming consumption is not. -- **HOST-OWNED (stays in App.tsx, not the lib):** `/api/ai/capabilities` (only called by App.tsx — editor L2261, review L499); `resolveAIProviderSelection` + cookie `aiConfig` init (`aiProvider.ts`, read by App.tsx). The hook never reads cookies or calls capabilities. -- **Already portable:** `DocumentAIChatPanel`, `AIProviderBar` (fully prop-driven); `aiProvider.ts`, `aiChatFormat.ts` (pure). -- **Existing reuse:** `review-editor` already reuses `useAIChat` via a thin patch-wrapper (`review-editor/hooks/useAIChat.ts` → `context: {mode:'code-review', review:{patch}}`). - -## Explicitly OUT of Phase 6 scope (Plannotator-only / different feature) -- `OpenInAppButton` (local CLI), `HooksTab` (plan-mode hooks), `useUpdateCheck` (hardcoded github.com/backnotprop release check — no seam), `useAgents`/`useAgentJobs` (code-review agent jobs — a review-editor feature, not document-UI). These stay host-owned; a reusing host simply doesn't mount them. - -## Per-seam summary -| Subsystem | Seam | Wire | Verbatim-keep | -|---|---|---|---| -| Versions | usePlanDiff fetchers + PlanDiffViewer vscode | `/api/plan/version(s)`, `/api/plan/vscode-diff` | alert/silent error asymmetry; + move block/raw diff CSS into the package | -| Settings | configStore `setServerSync`; Settings obsidian-detect | `/api/config`, `/api/obsidian/vaults` | 300ms debounce + deepMerge + constructor; `[obsidian.enabled]` dep + auto-select | -| Sharing | ExportModal `onSaveToNotes` | `/api/save-notes` | `showNotesTab` gate (L83) | -| Ask AI | useAIChat `AITransport` | 5× `/api/ai/*` | SSE reader loop, epoch guards, supersede-abort position; capabilities/provider-resolution stay host | diff --git a/adr/research/SPIKE-document-ui-reuse-inventory-20260622-183000.md b/adr/research/SPIKE-document-ui-reuse-inventory-20260622-183000.md deleted file mode 100644 index c81de1aef..000000000 --- a/adr/research/SPIKE-document-ui-reuse-inventory-20260622-183000.md +++ /dev/null @@ -1,121 +0,0 @@ -# Spike: Document UI Reuse Inventory (for Workspaces) - -Date: 2026-06-22 - -> ⚠️ **SUPERSEDED — first draft, materially incomplete.** A 36-agent verification found this audited only one coupling axis (literal `/api/` strings) and missed five others — most importantly: **Viewer is not actually "clean"** (fires `/api/doc/exists` on mount), an **uncounted cookie-persistence layer** (`storage.ts`, ~24 modules), **3 React contexts + a global identity singleton**, **SSE/EventSource** transports, and harder-than-stated **packaging blockers**. Use the verified version instead: **`adr/specs/document-ui-extraction-plan-verified-20260622-184500.md`**. Kept here as the first-pass record. - -> Companion to **ADR 004** (`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`). This is the concrete lay-of-the-land for sharing Plannotator's document UI with the commercial Workspaces app: what exists, what is reusable as-is, what is wired to Plannotator's backend, and a first-cut order of work. Read 004 first for the *why* and the safety rules. - -## The shape in one paragraph - -The document UI is two buckets. **Bucket 1 — `packages/ui`** (~39,400 lines, 108 components + 31 hooks + ~45 utils) is the reusable library: rendering, file browser, sidebar, editor, theme. **Bucket 2 — `packages/editor/App.tsx`** (4,685 lines, ~276 stateful hooks, 13 server fetches) is the Plannotator-specific *glue* that fetches Plannotator data and assembles it into the app. **Bucket 1 is what we share. Bucket 2 stays Plannotator's; Workspaces writes its own equivalent glue.** Of the 108 components, only **26 files** call Plannotator's server directly — those are the wires to cut. The other ~82 already take their data from the outside and are reusable as-is. - -## Bucket 1: `packages/ui` component library - -| Folder | Count | What it is | Workspaces relevance | -| --- | --- | --- | --- | -| `components/` (top level) | 65 | Viewer, MarkdownEditor, AnnotationPanel, modals, toolbars, etc. | Core | -| `components/blocks/` | 8 | Markdown block renderers (code, table, callout, alert, HTML…) | Core (doc rendering) | -| `components/sidebar/` | 7 | SidebarContainer, SidebarTabs, FileBrowser, VersionBrowser, ArchiveBrowser, MessagesBrowser, CountBadge | Core (sidebar + file tree) | -| `components/html-viewer/` | 1 | Raw HTML viewer | Core | -| `components/plan-diff/` | 6 | Plan version diff viewer | Maybe (Workspaces has version history) | -| `components/ImageAnnotator/` | 3 | Annotate images | Maybe | -| `components/ai/` | 2 | Ask-AI chat panel | Maybe (own AI) | -| `components/ui/` | 8 | Low-level primitives (buttons, dialogs…) | Core | -| `components/core/` | 2 | Shared core | Core | -| `components/icons/` | 4 | SVG icons | Core | -| `components/settings/` | 1 | Settings tab(s) | Partial | -| `components/goal-setup/` | 1 | Plannotator goal workflow | Plannotator-only | - -Plus `packages/ui/theme.css` (the theme/color tokens — pure, fully reusable), 31 hooks, ~45 utils, and `shortcuts/` (keyboard registry). - -## The 26 backend-coupled files (the wires to cut) - -Grouped by purpose. "WS" = does Workspaces need it. - -### Document rendering — WS: YES (do first) -| File | Calls | Note | -| --- | --- | --- | -| `components/blocks/HtmlBlock.tsx` | `/api/image` | image src in markdown | -| `components/ImageThumbnail.tsx` | `/api/image` | image thumbnails | -| `components/InlineMarkdown.tsx` | `/api/doc` | inline linked-doc loads | -| `hooks/useLinkedDoc.ts` | `/api/doc` | navigate doc → doc | -| `hooks/useValidatedCodePaths.ts` | `/api/doc/exists` | validate code-file links | -| `components/AttachmentsButton.tsx` | `/api/upload` | attach images to comments | - -### File tree / browser — WS: YES (core to Workspaces) -| File | Calls | Note | -| --- | --- | --- | -| `hooks/useFileBrowser.ts` | `/api/reference/files`, `/api/reference/files/stream`, `/api/reference/obsidian/*` | the file tree data source | - -### Comments / annotations / drafts — WS: YES (agents + teammates commenting) -| File | Calls | Note | -| --- | --- | --- | -| `hooks/useAnnotationDraft.ts` | `/api/draft` | autosave annotation drafts | -| `hooks/useCodeAnnotationDraft.ts` | `/api/draft` | autosave code annotations | -| `hooks/useExternalAnnotations.ts` | `/api/external-annotations`, `/api/external-annotations/stream` | **agents posting comments** — directly relevant to Workspaces | - -### Versions / diff — WS: MAYBE (Workspaces has version history) -| File | Calls | Note | -| --- | --- | --- | -| `hooks/usePlanDiff.ts` | `/api/plan/version`, `/api/plan/versions` | version list + fetch | -| `components/plan-diff/PlanDiffViewer.tsx` | `/api/plan/vscode-diff` | opens VS Code (Plannotator-local; WS would drop this one button) | - -### Settings / config — WS: PARTIAL (Workspaces feeds its own config) -| File | Calls | Note | -| --- | --- | --- | -| `config/configStore.ts` | `/api/config`, `/api/diff`, `/api/plan` | app config bootstrap | -| `config/settings.ts` | `/api/config` | settings load/save | -| `components/Settings.tsx` | `/api/ai/capabilities`, `/api/config`, `/api/obsidian/vaults` | settings panel | -| `components/settings/HooksTab.tsx` | `/api/config`, `/api/hooks/status` | Plannotator hooks tab (WS drops) | - -### Sharing / export / open-in — WS: PARTIAL (Workspaces has its own sharing) -| File | Calls | Note | -| --- | --- | --- | -| `utils/sharing.ts` | `/api/paste`, `/api/paste/` | short-URL share | -| `components/ExportModal.tsx` | `/api/save-notes` | save to Obsidian/Bear/Octarine | -| `components/OpenInAppButton.tsx` | `/api/open-in`, `/api/open-in/apps` | open in local app (local-only; WS drops) | - -### Ask AI / code-review agents — WS: NO / OWN -| File | Calls | Note | -| --- | --- | --- | -| `hooks/useAIChat.ts` | `/api/ai/*` | Ask-AI streaming (WS would wire its own AI) | -| `hooks/useAgents.ts` | `/api/agents` | agent provider detection | -| `hooks/useAgentJobs.ts` | `/api/agents/*` | code-review agent jobs (review feature, not docs) | - -### Archive / editor-annotations / plan-injection — WS: NO (Plannotator-only) -| File | Calls | Note | -| --- | --- | --- | -| `hooks/useArchive.ts` | `/api/archive/*`, `/api/done`, `/api/plan` | Plannotator plan archive | -| `hooks/useEditorAnnotations.ts` | `/api/editor-annotation(s)` | VS Code editor annotations | -| `components/goal-setup/GoalSetupSurface.tsx` | `/api/goal-setup/submit` | Plannotator goal workflow | -| `utils/planAgentInstructions.ts` | `/api/external-annotations`, `/api/plan` | plan-time prompt injection | - -### Tally -- **~10 coupled files Workspaces clearly needs** (rendering + file tree + comments). -- **~6 partial** (settings/config/sharing — Workspaces supplies its own source through the same shape). -- **~10 Plannotator-only** (archive, goal-setup, hooks, VS Code, code-review agents, open-in) — Workspaces simply won't mount these; no work needed beyond not importing them. - -## Bucket 2: the glue (`packages/editor/App.tsx`) - -4,685 lines, ~276 stateful hooks, 13 fetches. This is **not shared.** It is Plannotator's assembly layer: it bootstraps from `/api/plan`, runs the approve/deny hook flow, owns sidebar/panel/wide-mode layout state, and feeds everything into the Bucket-1 components. Workspaces writes its own (smaller) equivalent that bootstraps from its Cloudflare APIs and feeds the same components. - -**Caveat that matters:** some of "the experience" (which sidebar tab is open, file-tree expansion, panel resize, wide/focus mode) currently lives *inside this glue file*, not inside the reusable components. Part of the work is pushing that behavior **down into the components** (e.g. `SidebarContainer` owns its own open/close) so Workspaces' glue stays thin and doesn't have to re-derive layout logic. (Re-deriving that logic generically is exactly what the reverted attempt did wrong — push it into the real components instead.) - -## Packaging state - -`packages/ui/package.json` already declares `@plannotator/ui` with a fine-grained `exports` map (components, hooks, utils, config, types, theme). But it is `version: 0.0.1`, `type: module`, source-only (no build step, no publish). To be installable by an outside repo it needs: a real version, a build (or confirmed source-export consumption), peer-deps sorted (React, CodeMirror, Radix, etc.), and a publish target. Moderate, not hard. - -## First-cut order of work (the safe path from ADR 004) - -Each step: lift the server call out to a prop/callback, leave the component's logic intact, confirm Plannotator still looks identical, then move on. One item at a time. - -1. **Rendering core** — `/api/image`, `/api/doc`, `/api/doc/exists`, `/api/upload` (HtmlBlock, ImageThumbnail, InlineMarkdown, useLinkedDoc, useValidatedCodePaths, AttachmentsButton). Makes a doc render anywhere. -2. **File tree** — `useFileBrowser`. Makes the tree take a data source. -3. **Comments/drafts** — `useAnnotationDraft`, `useCodeAnnotationDraft`, `useExternalAnnotations`. Makes comments (incl. agent-posted) provider-driven. -4. **Versions** — `usePlanDiff` (keep the VS Code button as an optional prop Workspaces omits). -5. **Config/settings shape** — let `configStore`/`settings` take their source from the host instead of `/api/config`. -6. **Packaging** — turn `@plannotator/ui` into a real publishable package. -7. **Push layout state into components** — sidebar/panel/wide-mode behavior currently in `App.tsx` moves into the sidebar/layout components so Workspaces' glue stays thin. - -Steps 1–5 are independent and can be done in any order / in parallel by different people. Step 6 can start anytime. Step 7 is the largest and is best done last, informed by what Workspaces actually needs. diff --git a/adr/research/SPIKE-publish-core-package-20260623-125551.md b/adr/research/SPIKE-publish-core-package-20260623-125551.md deleted file mode 100644 index bae1b3d33..000000000 --- a/adr/research/SPIKE-publish-core-package-20260623-125551.md +++ /dev/null @@ -1,54 +0,0 @@ -# Spike: Carve `@plannotator/core` and Publish (Phase 7) - -Date: 2026-06-23 - -> Code research for Phase 7 of the `@plannotator/ui` reuse effort (ADR 004). Three probes mapped: (1) exact `@plannotator/core` membership, (2) the import blast radius + zero-churn mechanism, (3) publish toolchain. Decision context: **no copying / no duplication** — carve a single browser-safe package everyone shares. THE LAW: Plannotator stays unchanged. - -## The shape - -`@plannotator/ui` can't be published while it depends on the private `@plannotator/shared` and `@plannotator/ai`. The chosen fix: **move the browser-safe slice into a new published `@plannotator/core`; `@plannotator/shared` re-exports from it (so Plannotator's 99 import sites don't change); publish `core` + `ui`.** One copy of everything. - -## 1. What goes in `@plannotator/core` - -The UI references 21 distinct `@plannotator/shared` subpaths (value + type-only). They split three ways: - -**A. Pure modules — move wholesale into core (no node, no npm deps, no cross-deps):** -`code-file`, `agents`, `agent-jobs`, `compress`, `crypto`, `external-annotation`, `extract-code-paths` (→ imports `./code-file`, also moves), `favicon`, `feedback-templates`, `goal-setup`, `browser-paths`, `project`, `agent-terminal`, `open-in-apps`, `source-save`. (~15 files.) All verified browser-safe (the apps already bundle them for the browser today). - -**B. Node-bound modules the UI imports TYPES from — extract the types into core (the elegant no-duplication move):** -`config.ts` (node:fs/os/child_process), `storage.ts` (node:fs), `workspace-status.ts` (node:child_process). The UI imports only types from these (`DiffLineBgIntensity`, `DefaultDiffType`, `ArchivedPlan`, `WorkspaceFileChange`, `WorkspaceStatusPayload`). **Do NOT move these files** (they'd drag node into a browser package). Instead: **extract their type definitions into `core` (e.g. `core/config-types.ts`), and have `shared/config.ts` import those types back from core** + keep its node implementation. Result: the types live **once** (in core), the node code stays in shared, no duplication. - -**C. `AIContext` from `@plannotator/ai`** — a pure type union (verified node-free). Re-export it from `core` (e.g. `core/ai-context.ts`) so `ui` imports `@plannotator/core` instead of `@plannotator/ai`. - -**Nuance to verify at implementation:** `shared/types.ts` re-exports from `review-core.ts` / `review-workspace.ts`, which have value-level `node:path` imports. Confirm whether the UI's `@plannotator/shared/types` actually surfaces any of those review types to ui; if so, extract just those types (same technique as B). If not, `core/types.ts` re-exports only the browser-safe set. - -**Proposed core size:** ~15 pure files moved + ~3-4 extracted type files + an `index.ts` barrel + `ai-context.ts`. All source-only, browser-safe, **zero npm/node dependencies.** - -## 2. Blast radius + zero-churn mechanism - -- **99 import sites** across the repo reference these modules: packages/ui (36), packages/server (34), editor (11), review-editor (11), apps/hook (4), opencode (3). Heaviest: `agents` (17), `config` (10), `source-save` (9), `agent-terminal` (8), `types` (8). -- **Re-export shim = zero churn (the key finding):** for each moved module, leave a one-line shim in `shared` — `export * from '@plannotator/core/code-file'`. All 99 sites keep working unchanged; `shared`'s `exports` map stays as-is; works for both `import {}` and `import type`. **Plannotator's server/editor/review-editor/apps need no edits.** (For the type-extracted node modules, `shared/config.ts` etc. import their types from core and re-export — same effect.) -- **Pi-extension `vendor.sh`** copies ~47 shared files at build; with shims it vendors the shim files unchanged → **no vendor.sh change needed.** -- **No tsconfig/build globs** reference `packages/shared/*` — all imports are explicit subpaths. Minimal tooling impact. -- **`wideMode.ts` move** (Phase-7 leftover): `packages/editor/wideMode.ts` → `packages/ui/utils/wideMode.ts`; only 2 import sites (App.tsx + its test); ui doesn't import editor, no cycle. Ultra-low risk. - -## 3. Publish toolchain - -- **Monorepo:** Bun, `workspaces: ["apps/*","packages/*"]`, public npm. Release is tag-triggered CI (`.github/workflows/release.yml`); today it publishes only `@plannotator/opencode` + `@plannotator/pi-extension` via `bun pm pack` + `npm publish --provenance --access public`. **No ui/core/shared publish job exists yet.** -- **workspace:* resolution:** bun replaces `workspace:*` with the real version at pack time. Blocker today: `@plannotator/ai` + `@plannotator/shared` are `private:true` v0.0.1. After the carve, `ui` depends on `@plannotator/core` (published) — `shared`/`ai` stay private (ui no longer needs them directly once `core` covers its imports, **except** any remaining `import type` from shared that we must route through core). -- **Source-only model:** `ui` (and `core`) ship raw `.ts/.tsx` — no build/dist. An external TS consumer (Workspaces) must set: `moduleResolution: "bundler"`, `allowImportingTsExtensions`, `isolatedModules`, `jsx: "react-jsx"`, React 19 + Tailwind v4 (`@tailwindcss/vite`), and a Tailwind `@source` glob over `node_modules/@plannotator/ui/**/*.tsx`, plus import `@plannotator/ui/theme`. This works (no build needed) but must be documented for the consumer. -- **`ui` packaging after Phase 1:** peerDeps, dompurify, files allowlist all done. Remaining: the workspace-dep blocker (solved by `core`), a real version, and a CI publish job. - -## Open decisions (for the ADR) -1. **Registry:** public npm (matches existing opencode/pi-extension, simplest) vs private/scoped (if `ui`/`core` should be Workspaces-only). -2. **Versions:** keep 0.0.1 vs assign real (e.g. 0.1.0). `core` + `ui` likely version together. -3. **CI:** add a publish job for `core` + `ui` to `release.yml`, or publish manually the first time. -4. **`@plannotator/ai`:** since the UI only needs the `AIContext` type and `core` re-exports it, `ai` can **stay private/unpublished** — confirm the UI has no other `@plannotator/ai` value import. - -## Per-area summary -| Area | Finding | -|---|---| -| Core membership | ~15 pure files move; 3-4 node-bound modules contribute extracted types; AIContext re-exported. Zero node/npm in core. | -| Churn | Re-export shims in `shared` → 0 changes to Plannotator's 99 sites; vendor.sh unaffected. | -| Publish | Bun + public npm, tag-triggered CI; need a new publish job; source-only ships, consumer needs documented tsconfig/Tailwind. | -| Decisions | registry, versions, CI job, ai-stays-private. | diff --git a/adr/research/synthesis-document-ui-comments-20260623-084806.md b/adr/research/synthesis-document-ui-comments-20260623-084806.md deleted file mode 100644 index 552383a41..000000000 --- a/adr/research/synthesis-document-ui-comments-20260623-084806.md +++ /dev/null @@ -1,52 +0,0 @@ -# Synthesis: Comments / Annotations / Drafts (Phase 5) - -Date: 2026-06-23 - -> Synthesizes `SPIKE-document-ui-comments-system-20260623-084806.md` against the verified plan (`adr/specs/document-ui-extraction-plan-verified-20260622-184500.md`) and ADR 004. Settles the shape of Phase 5. - -## The reframing - -Phase 5 has been called "the big one." The research **confirms it's the most interconnected subsystem but narrows the actual work.** Three facts change the picture: - -1. **The comment UI is already portable.** Panel, popover, toolbar, highlighter hook — all prop-driven, no backend wires. Nothing to extract. -2. **A second consumer already proves it.** `review-editor` reuses `useExternalAnnotations`, `useEditorAnnotations`, and `useCodeAnnotationDraft` unchanged, with `enabled` gates and shape-generics already in place. The portability pattern exists; we extend it, we don't invent it. -3. **Annotation state is host-owned already.** Each app holds its own `useState` array; there is no shared reducer to wrestle. Workspaces owns its state too. - -So Phase 5 = **three transport/identity seams** + **two things that are NOT extraction work** (a renderer constraint to document, and a replies feature to defer). - -## What we will do: three seams (same pattern as Phases 2–4) - -Each is the proven shape: a module-level default that reproduces today's literal behavior, plus an optional `setX` override; Plannotator passes nothing and is byte-unchanged. - -### Seam 1 — Draft transport -Inject a `DraftTransport` (load/save/delete) into `useAnnotationDraft` and `useCodeAnnotationDraft`, default = today's `/api/draft` fetches **verbatim**, including the `keepalive` retry and the `visibilitychange`/`pagehide` flush. **The generation protocol is the hard part and must be preserved end-to-end:** `getDraftGeneration()` still escapes the hook and the host still threads it into submit (`withDraftGeneration`), and the seam must document that a host swapping transport also has to honor generation-gated delete-on-submit (or ghost drafts return). The refs and pre-increment timing move verbatim. - -### Seam 2 — External-annotation transport -Inject an `ExternalAnnotationTransport` (`subscribe(onEvent,onError)` + optimistic CRUD + `getSnapshot(since)`) into `useExternalAnnotations`, default = the SSE→polling state machine **moved verbatim** (EventSource primary, 500ms polling fallback, 304 gate, 30s heartbeat, fallback-once semantics). The reducer and optimistic mutators stay in the hook. The `enabled` gate is already host-suppliable. The server store/validators/SSE encoding in `shared/external-annotation.ts` are already shared; a Workspaces backend implements the same event contract over Durable Objects instead of SSE. - -### Seam 3 — Identity -Make authorship overridable: optional `author?` (or an injected `getIdentity`) at the ~9 stamp sites and an optional `isCurrentUser?` at the 2 `(me)` display sites, **defaulting to the existing `identity.ts` functions**. Storage is already swappable (Phase 2) and `configStore.init(serverConfig)` already seeds identity from the server, so much of identity is host-controllable today; this seam closes the last gap so Workspaces' real logins (WorkOS) drive authorship instead of tater names. - -## What we will NOT do in Phase 5 - -### Constraint A — Renderer coupling: document it, don't fight it -Highlight restoration re-anchors against the rendered DOM and depends on `transformPlainText` (emoji + smart punctuation) matching the renderer's output. **Workspaces must reuse `BlockRenderer` + `InlineMarkdown` + `inlineTransforms` as a unit.** This is an integration contract we write down, not a wire we cut. (Optional later: expose `transformPlainText` as overridable with today's default — but not required for Phase 5.) - -### Constraint B — Replies/threading: defer as a new feature -Comments are flat today. Threading is something **Workspaces wants but Plannotator does not have** — so building it is *adding a feature*, which is explicitly outside "make today's behavior reusable without changing Plannotator." Phase 5 ships the flat model unchanged. Replies become a later, backward-compatible enhancement (a host layer over the shared components, or an optional `replies?` extension that Plannotator never populates), planned on its own once the seams land. - -## Why this ordering and risk read -- **Identity (Seam 3) is the lowest-risk** and partly done — do it first to warm up. -- **Drafts (Seam 1) is medium** — the generation protocol is fiddly but well-understood; the guardrail is that approve/deny/feedback/exit still carry the generation and ghost drafts don't return. -- **External (Seam 2) is the riskiest** — a timing-sensitive state machine that must move verbatim (the exact trap that sank the reverted attempt). Do it last, with the SSE→polling fallback proven by an eyeball (kill the stream, confirm polling takes over). -- Everything else (panel, popover, toolbar, highlighter, exporters) is already portable — **no work, just confirm noop** like the sidebar in Phase 4. - -## Open decisions for the spec/ADR -1. **Identity injection mechanism:** optional `author?` prop threaded to stamp sites vs. an injected `getIdentity`/`isCurrentUser` pair (module-level setter, like the other seams). Lean: module-level setter (`setIdentityProvider`) for consistency and to avoid threading props through 9 sites. -2. **Replies:** confirm it's deferred (recommended) vs. scoped into Phase 5. Recommend defer. -3. **Renderer constraint:** document-only (recommended) vs. also extract `transformPlainText` as overridable now. Recommend document-only. - -## References -- Spike: `adr/research/SPIKE-document-ui-comments-system-20260623-084806.md` -- Verified plan (Phase 5 / step 5): `adr/specs/document-ui-extraction-plan-verified-20260622-184500.md` -- Decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md` diff --git a/adr/research/synthesis-document-ui-extraction-20260620-082343.md b/adr/research/synthesis-document-ui-extraction-20260620-082343.md deleted file mode 100644 index 69bbd4b8d..000000000 --- a/adr/research/synthesis-document-ui-extraction-20260620-082343.md +++ /dev/null @@ -1,266 +0,0 @@ -# Synthesis: Document UI Extraction - -> ℹ️ **Context still useful; the direction it informed was reverted.** The extraction approach synthesized here (ADRs 002/003) was reverted on 2026-06-22. Read **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`** before acting. - -Date: 2026-06-20 - -Status: Synthesis - -## Research Reviewed - -- `adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md` -- `adr/research/SPIKE-source-edit-reliability-20260618-090850.md` -- `adr/research/SPIKE-source-edit-race-and-conflict-20260618-095558.md` -- `adr/research/SPIKE-annotate-agent-terminal-production-runtime-20260618-212101.md` -- `adr/research/synthesis-annotate-agent-terminal-production-runtime-20260618-212604.md` - -## What The Research Says - -Annotate is not a separate frontend today. It is the Plan Review app booted in another mode. The annotate server serves document sessions through `/api/plan`, and `packages/editor/App.tsx` decides whether the session is plan review, annotate file, annotate folder, annotate last message, raw HTML, archive, or goal setup. - -The desired extraction is therefore not a renderer extraction. The reusable product is the document review experience: - -- markdown and raw HTML viewing -- markdown block rendering -- annotations and comments -- annotation panel -- linked document navigation -- file browser rows and status badges -- editor toggle -- source-backed save state -- draft persistence -- feedback payload assembly - -Those pieces are currently split across `@plannotator/ui` and `@plannotator/editor`. `@plannotator/ui` has many reusable primitives, but several of them call hard-coded `/api/*` routes. `@plannotator/editor` has the main app shell plus document-domain state like editable documents, source reconciliation, direct edits, and terminal integration. - -So the current split is historical, not a clean package boundary. - -## Recommended Direction - -Create one shared document package: - -```text -@plannotator/document-ui -``` - -Do not split into renderer, tree, comments, editor, and sidebar packages now. The seams are not stable enough. The first useful boundary is one document surface with a typed host API. - -The primary export should be something like: - -```text -DocumentReviewSurface -``` - -It should own the document product mechanics: - -- render markdown or raw HTML -- manage annotation state and highlight restoration -- render the comments/annotation panel -- support linked docs -- support folder file picking -- support markdown edit mode -- manage source-save document state -- integrate draft save/restore -- assemble document feedback - -The host app should keep runtime and mode policy: - -- Claude/OpenCode/Droid/Pi command interception -- server startup and browser opening -- plan-mode approve/deny hook behavior -- plan history, plan diff, archive, and goal setup -- note app policy and settings persistence -- transcript lookup for annotate-last -- terminal runtime/sidecar implementation - -## Main Architectural Move - -Introduce a host API adapter before moving the UI. - -The document surface should not directly know that the current Plannotator server uses `/api/plan`, `/api/doc`, `/api/source/save`, or `/api/draft`. It should depend on an interface such as: - -```ts -interface DocumentHostApi { - loadSession(): Promise; - loadDocument(request: LoadDocumentRequest): Promise; - validateCodePaths(request: ValidateCodePathsRequest): Promise; - listFiles(request: ListFilesRequest): Promise; - watchFiles?(request: WatchFilesRequest): EventSourceLike; - saveSource?(request: SourceSaveRequest): Promise; - loadDraft(): Promise; - saveDraft(draft: DraftPayload): Promise; - deleteDraft(generation: number): Promise; - submitFeedback(payload: SubmitFeedbackPayload): Promise; - approve?(): Promise; - exit?(): Promise; -} -``` - -The current Bun and Pi HTTP routes can be implemented as: - -```text -createPlannotatorHttpDocumentApi() -``` - -This keeps the existing routes stable while giving the UI a real boundary. - -## Package Contents - -Move or expose these as document-domain code: - -- `Viewer` -- `HtmlViewer` -- `MarkdownEditor` -- markdown parser and block types -- annotation highlighter integration -- annotation/comment panel patterns -- linked-doc hook -- file browser hook and rows -- draft hook -- editable document state -- source document client and reconciliation -- saved file change validation -- direct edits feedback builder -- code path validation and inline link handling - -Keep these outside for the first extraction: - -- plan diff -- archive browser -- goal setup -- permission mode setup -- server implementations -- agent terminal sidecar/runtime resolver -- CLI/plugin integrations - -The annotation panel should be included. It is part of the document product, not a peripheral widget. Feedback export and annotation state depend on it. - -Raw HTML should stay in the same document surface. It is a separate render engine, but users experience it as the same annotate workflow, and linked docs can switch between markdown and raw HTML. - -## Source Save Is Core - -The source-edit research matters for extraction. - -Source save is not just "write file on Save." It includes: - -- optimistic concurrency through hash and mtime metadata -- file watch reconciliation -- stale snapshot guards -- conflict recovery with current disk snapshots -- missing-file state -- draft restore -- saved file change feedback -- file browser edit badges - -If the extracted package does not own this state, it will need so many extension points that the package will not actually own the document experience. - -Recommendation: move the source document state modules into the document package early. - -## Terminal And AI Boundary - -Agent terminal is part of the annotate workspace, but the terminal runtime is not part of document UI. - -The package can support an optional "agent delivery" capability: - -- send feedback to a running agent -- report whether the current feedback has already been delivered -- route Ask AI prompts to an agent when provided -- render an optional slot or panel if the host supplies one - -The host should keep: - -- WebTUI sidecar -- tokenized WebSocket path -- remote-mode gating -- runtime install/preflight -- agent discovery - -Normal Ask AI should also be capability-driven. Provider detection and server sessions are host concerns; document UI can consume an abstract ask/send interface. - -## Bun/Pi Constraint - -Frontend extraction does not remove server parity work. - -The Bun server and Pi server both expose the document endpoints. Any route shape change still needs both implementations updated. - -For the extraction, avoid route renames. Keep `/api/plan` for compatibility and put a typed adapter over it. Rename only later if there is a deliberate migration plan. - -## Implementation Order - -1. Define document contracts. - -Create shared types for `DocumentSession`, `LoadedDocument`, `DocumentCapabilities`, `DocumentFeedbackPayload`, and `DocumentHostApi`. Model the current annotate `/api/plan` and `/api/doc` shapes without changing routes. - -2. Add an HTTP adapter. - -Move route calls behind `createPlannotatorHttpDocumentApi()`. Start with `/api/doc`, `/api/doc/exists`, `/api/reference/files`, `/api/reference/files/stream`, `/api/source/save`, `/api/draft`, and `/api/share-html`. - -3. Move source document modules. - -Move the cohesive source-edit modules out of `@plannotator/editor` and into the document package. This reduces `App.tsx` before the main surface extraction. - -4. Create `DocumentReviewSurface`. - -Wrap the existing viewer, HTML viewer, editor toggle, linked docs, file browser, annotation panel, drafts, and source-save state behind one component. - -5. Turn `packages/editor/App.tsx` into a host shell. - -The app should load session data, configure host capabilities, handle plan-specific flows, and delegate document mechanics to `DocumentReviewSurface`. - -6. Add contract and surface tests. - -Add Bun/Pi contract tests for `/api/plan` and `/api/doc`, plus a component test using an in-memory `DocumentHostApi`. - -## What Counts As A Good First Result - -The first successful extraction should make this true: - -- annotate file/folder/HTML/last still work -- plan review still works through the same document surface -- source save and drafts still work -- `App.tsx` no longer owns document mechanics directly -- document UI can run in tests without a live Plannotator server -- no endpoint rename is required -- no extra package split is introduced - -## Keep Out Of Scope - -Do not redesign the UI. - -Do not split into multiple small UI packages yet. - -Do not move the terminal runtime into the document package. - -Do not rename `/api/plan` during extraction. - -Do not fold plan diff, archive, or goal setup into the first document package boundary. - -Do not try to solve all Pi/Bun server duplication as part of the frontend package extraction. - -## Open Decisions - -1. Should the package be a new workspace package or a documented `/document` export inside `@plannotator/ui`? - -Recommendation: new package. `@plannotator/ui` is already broad and route-aware; a new package makes the boundary visible. - -2. Should source save be mandatory or optional? - -Recommendation: optional capability, but first-class inside the package. Sessions without source save should degrade cleanly. - -3. Should plan review be implemented as a document mode or a host wrapper? - -Recommendation: host wrapper. Plan review supplies a document plus approve/deny callbacks. The document package should not know about hook stdout decisions. - -4. Should the terminal panel move later? - -Recommendation: maybe, but only after the document surface exists. The runtime stays host-owned either way. - -## Recommendation - -Proceed toward an ADR that accepts one shared document UI package with a host API adapter. - -The key decision is not "move components to another folder." The key decision is to make Plannotator's document review experience the upstream surface and make plan review one consumer of that surface. - -That preserves the best part of the current architecture: one rich review experience across plans, files, folders, HTML, URLs, and last messages. - -It fixes the weak part: the document product is currently trapped inside a very large app shell and route-aware UI helpers. diff --git a/adr/research/synthesis-document-ui-extras-20260623-100827.md b/adr/research/synthesis-document-ui-extras-20260623-100827.md deleted file mode 100644 index 6e3454684..000000000 --- a/adr/research/synthesis-document-ui-extras-20260623-100827.md +++ /dev/null @@ -1,47 +0,0 @@ -# Synthesis: Phase 6 Extras - -Date: 2026-06-23 - -> Synthesizes `SPIKE-document-ui-extras-system-20260623-100827.md` against the verified plan and ADR 004. Settles Phase 6's shape. - -## The shape - -Phase 6 follows the now-proven pattern: a handful of module-level/prop seams, each defaulting to today's behavior. The research confirms most of these four subsystems is **already portable** (pure utils, prop-driven components, parameterized sharing). The real work is **five seams + one CSS move**, and a clear list of **Plannotator-only pieces that simply stay home**. - -## What we will do - -### 1. Versions / diff (do first — highest value, has the CSS wrinkle) -- Inject optional version fetchers into `usePlanDiff` (default → `/api/plan/version(s)`), keeping the alert/silent error asymmetry verbatim. -- Optional `onOpenVscodeDiff?` on `PlanDiffViewer` (default → `/api/plan/vscode-diff`). -- **CSS move:** relocate the block-level/raw diff classes (`.plan-diff-added/removed/modified/unchanged`, `.plan-diff-line-*`) from `packages/editor/index.css` into `packages/ui/theme.css`, co-located with the existing `.plan-diff-word-*`. Plannotator imports `theme.css`, so it stays byte-identical; the diff components become reusable without app-shell CSS. (Verify Plannotator's build doesn't double-define / drops the index.css copies.) - -### 2. Settings / config -- `configStore.setServerSync(fn)` injecting only the final `/api/config` POST; keep singleton, eager cookie reads, 300ms debounce, and `deepMerge` verbatim. -- Optional `onDetectObsidianVaults?` on `Settings`; keep the `[obsidian.enabled]` effect dep and auto-select-first-vault verbatim. -- (Storage + identity already swappable; `settings.ts` pure — nothing to do there.) - -### 3. Sharing / export / notes -- Optional `onSaveToNotes?` on `ExportModal`; keep `showNotesTab = isApiMode && !!markdown` verbatim. -- (Sharing utils + `useSharing` + `ImportModal` + the notes-app helpers are already portable — confirm noop.) - -### 4. Ask AI (last, riskiest) -- Inject an `AITransport` (session/query/abort/permission) into `useAIChat`, default = today's five fetches. **Leave the SSE reader loop, epoch/createRequest guards, and the supersede-abort position untouched in the hook.** Capabilities fetch and provider resolution stay host-owned (they already live in App.tsx). - -## What we will NOT do (Plannotator-only — they stay home) -`OpenInAppButton` (local CLI), `HooksTab` (plan-mode hooks), `useUpdateCheck` (hardcoded github release check), `useAgents`/`useAgentJobs` (code-review agent jobs). A reusing host doesn't import them. No work. - -## Risk read & ordering -- **Versions/diff** — low logic risk; the CSS move is the only non-trivial bit (verify Plannotator's diff still renders identically after relocating the classes). Do first. -- **Settings** — low; mind the debounce/deepMerge (don't move them) and the obsidian effect dep. -- **Sharing** — small (one seam); rest is noop. -- **Ask AI** — the riskiest by far: a streaming state machine. Treat exactly like the Phase-5 external transport — wrap only the wire, copy nothing, leave the reader loop and epoch guards verbatim. Do last, eyeball the AI panel end-to-end. - -## Open decisions for the spec/ADR -1. **CSS move vs. document-as-contract.** Recommend **move** (`.plan-diff-*` into `theme.css`) — it's a pure relocation that keeps Plannotator identical and makes the diff truly reusable. The broader `.annotation-highlight` CSS (used by the Viewer everywhere) is a related contract; recommend moving it into `theme.css` too in the same pass, since it's required by the already-shipped Viewer in any host (closes a latent gap from Phases 3/5). Confirm in spec. -2. **Scope confirmation:** agree the five Plannotator-only pieces are out (recommended). -3. **One PR or per-seam commits:** recommend per-seam verify-gated commits (versions → settings → sharing → AI), like Phase 5. - -## References -- Spike: `adr/research/SPIKE-document-ui-extras-system-20260623-100827.md` -- Verified plan (Phase 6 / steps 6-8,12): `adr/specs/document-ui-extraction-plan-verified-20260622-184500.md` -- Decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md` diff --git a/adr/research/synthesis-publish-core-package-20260623-125551.md b/adr/research/synthesis-publish-core-package-20260623-125551.md deleted file mode 100644 index 4dc8981d2..000000000 --- a/adr/research/synthesis-publish-core-package-20260623-125551.md +++ /dev/null @@ -1,44 +0,0 @@ -# Synthesis: Carve `@plannotator/core` and Publish (Phase 7) - -Date: 2026-06-23 - -> Synthesizes `SPIKE-publish-core-package-20260623-125551.md` against ADR 004 and the user decision: **no copying / single source of truth.** Settles Phase 7's shape. - -## The decision in one line - -Carve a new browser-safe **`@plannotator/core`** package, move the universal slice into it (extracting just the *types* from node-bound modules so nothing duplicates), make `@plannotator/shared` re-export from it so Plannotator is untouched, then publish `core` + `ui`. `@plannotator/shared` and `@plannotator/ai` stay private. - -## Why this shape - -- **No duplication:** every file/type has exactly one home. Pure modules live in `core`; node-bound *implementations* stay in `shared` but import their *types* from `core`. Today's clean one-copy state is preserved. -- **Plannotator unchanged:** re-export shims in `shared` mean all 99 internal import sites keep working with zero edits. Plannotator's server, editor, review-editor, apps, and the Pi vendor step are untouched. -- **Minimal published surface:** `core` is small, browser-safe, zero-dependency — not the Node/git/PR kitchen sink. Workspaces installs `ui` + `core` and nothing it doesn't run. -- **Naming is honest:** `core` = the universal foundation (runs anywhere), distinct from `ui` (components) and `shared` (the Node/server grab-bag). No `shared`/`ui` stutter. - -## The plan - -1. **Create `packages/core`** — source-only, browser-safe, zero npm/node deps. Move the ~15 pure modules in. Add extracted type files for the 3-4 node-bound modules (`config`, `storage`, `workspace-status`, and any review types `ui` surfaces). Re-export `AIContext`. Add an `index.ts` barrel and a fine-grained `exports` map (mirroring `ui`'s source-only pattern). -2. **Re-point `@plannotator/shared`** — each moved pure module becomes a one-line shim (`export * from '@plannotator/core/X'`); each node-bound module imports its types from `core` and keeps its node implementation. `shared`'s `exports` map and `private:true` stay. Plannotator's imports don't change. -3. **Re-point `@plannotator/ui`** — change `ui`'s `@plannotator/shared/X` and `@plannotator/ai` imports to `@plannotator/core/X`; replace the `workspace:* @plannotator/shared`/`@plannotator/ai` deps with `@plannotator/core` (the only published dep ui needs). Confirm no remaining `@plannotator/shared`/`@plannotator/ai` reference in ui. -4. **Move `wideMode.ts`** `editor → ui/utils` (2 import edits). -5. **Single config front door** — add `configurePlannotatorUI(config)`: one typed call that fans out to the 9 global host-override setters (image, storage, doc-preview, file-tree, identity, draft, external-annotations, AI, config-sync). Fixes the "scattered switches" ergonomics wart for ~40 lines, zero risk. The render-time prop seams stay as props; a `` is the optional later upgrade. -6. **(Optional) precompiled CSS** — ship `@plannotator/ui/styles.css` so a consumer imports one stylesheet instead of wiring Tailwind `@source` to ui internals. Smooths the one genuine integration wrinkle (Tailwind-in-a-shared-lib); additive, source-ships model unchanged. -7. **Publish** `core` then `ui` (source-only) — add a CI job (or first-time manual publish), real versions, document the consumer tsconfig/Tailwind requirements in `core`/`ui` READMEs. - -## Guardrails (Plannotator stays byte-for-byte unchanged) -- After steps 1-3: full `bun test` stays 1620/0, typecheck passes, all builds byte-identical, **`git diff` touches only `packages/core` (new), `packages/shared` (shims/type-imports), `packages/ui` (import re-points + package.json), and `packages/editor` (wideMode)** — no server/app behavior change. -- The shipped bundle hashes (`apps/hook/dist`, `apps/opencode-plugin`) should stay identical (the re-exports compile to the same code). -- The publish itself is the one outward-facing, hard-to-undo step — **stop and confirm with the user before pushing anything to a registry.** - -## Open decisions to lock in the ADR -1. **Registry:** recommend **public npm** (matches the existing `@plannotator/opencode`/`pi-extension` flow, simplest, and `core`/`ui` contain nothing secret). Switch to a private scope only if there's a reason to keep them off the public registry. -2. **Versions:** recommend `0.1.0` for `core` + `ui`, versioned together. -3. **`@plannotator/ai` stays private** (ui only needs the `AIContext` type, re-exported via `core`) — confirm no value import. -4. **CI:** add `core` + `ui` to the `release.yml` npm-publish job (or publish manually first, automate later). - -## Sequencing -Do the carve + re-point + verify first (all reversible, Plannotator-unchanged), get the green parity run, **then** make the registry/version call and publish as the final, confirmed step. - -## References -- Spike: `adr/research/SPIKE-publish-core-package-20260623-125551.md` -- Governing decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180627.md` diff --git a/adr/specs/document-ui-comments-seam-20260623-084806.md b/adr/specs/document-ui-comments-seam-20260623-084806.md deleted file mode 100644 index f64519d83..000000000 --- a/adr/specs/document-ui-comments-seam-20260623-084806.md +++ /dev/null @@ -1,85 +0,0 @@ -# Spec: Comments / Annotations / Drafts Seam (Phase 5) - -Date: 2026-06-23 · Status: Draft (iterate before implementing) - -> Implementation spec for Phase 5 of the `@plannotator/ui` reuse effort. Grounded in `SPIKE-document-ui-comments-system-20260623-084806.md` + `synthesis-document-ui-comments-20260623-084806.md`. Governed by ADR 004. THE LAW: each seam is a module-level default reproducing today's literal behavior + an optional override; Plannotator passes nothing and is byte-for-byte unchanged. Move verbatim, never rewrite — especially the SSE machine and the draft generation protocol. - -## Scope - -**In scope (3 seams):** draft transport, external-annotation transport, identity/authorship. -**Confirmed noop (already portable):** AnnotationPanel, CommentPopover, AnnotationToolbar, AnnotationToolstrip, EditorAnnotationCard, AnnotationSidebar, useAnnotationHighlighter, useExternalAnnotationHighlights, commentContent/annotationHelpers/anchors, the `export*Annotations` serializers. -**Out of scope:** replies/threading (new feature — defer), renderer coupling (document as a contract), `useEditorAnnotations` / VS Code IPC (host-only), feedback/submit routes and merge/dedup policy (host-owned). - -## Order of work (lowest risk first) - -### Step 1 — Identity / authorship seam (effort S, lowest risk; partly already done) -**Files:** `packages/ui/utils/identity.ts` and the stamp/display sites. -- Add a module-level identity provider with default = today's functions, matching the Phase 2–4 pattern: - ```ts - // identity.ts - export interface IdentityProvider { - getIdentity(): string; - isCurrentUser(author: string | undefined): boolean; - } - const defaultIdentityProvider: IdentityProvider = { getIdentity, isCurrentUser }; // existing impls - let identityProvider = defaultIdentityProvider; - export function setIdentityProvider(p: IdentityProvider): void { identityProvider = p; } - export function resetIdentityProvider(): void { identityProvider = defaultIdentityProvider; } - ``` - Then route the 9 stamp sites and 2 display sites through `identityProvider.getIdentity()` / `identityProvider.isCurrentUser()`. Keep the existing `getIdentity`/`isCurrentUser` exports working (they remain the default). -- **Alternative considered:** thread an optional `author?`/`isCurrentUser?` prop through Viewer/panel. Rejected for now — 9 stamp sites across Viewer + html-viewer + diff make a module-level provider cleaner and lower-churn. (Decide in review.) -- **Parity guardrail:** no caller sets the provider → tater identity + `(me)` badge behave exactly as today. Verify: existing identity/annotation tests green; eyeball a comment shows the tater name + `(me)`. - -### Step 2 — Draft transport seam (effort M) -**Files:** `packages/ui/hooks/useAnnotationDraft.ts`, `packages/ui/hooks/useCodeAnnotationDraft.ts`. -- Introduce a `DraftTransport` and module-level default reproducing today's fetches verbatim: - ```ts - export interface DraftTransport { - load(): Promise<{ data: unknown | null; generation: number | null }>; - save(body: object, opts: { keepalive?: boolean }): Promise; - remove(generation: number, opts?: { keepalive?: boolean }): Promise; - } - ``` - Default `save` keeps the **keepalive-true POST with retry-without-keepalive on failure gated by generation match**; default `remove` does `DELETE /api/draft?generation=N`; default `load` does `GET /api/draft` + reads `draftGeneration` from the (404) body. -- **Keep inside the hook (do not move into the transport):** the `draftGenerationRef` pre-increment, the 500ms debounce, the `latestRef` non-reactive getters, `canPersistRef`/`hasMountedRef` gates, and the `visibilitychange`/`pagehide` flush effect. These are stateful/timing-sensitive — verbatim. -- **Document the 3-party protocol in the seam's doc comment:** `getDraftGeneration()` still escapes to the host; the host still threads it into submit (`withDraftGeneration` → `/api/approve`,`/api/exit` URL; `/api/deny`,`/api/feedback` body; annotate reads approve/exit from URL). A host swapping transport **must** replicate generation-gated delete-on-submit and tombstoning, or ghost drafts resurrect. -- **Parity guardrail:** no caller overrides transport → identical `/api/draft` traffic and identical generation in approve/deny/feedback/exit. Verify: existing draft tests green (esp. `packages/shared/draft.test.ts` generation invariants — server side is untouched); typecheck; full `bun test` ≥ baseline; eyeball: type a comment, reload → draft restores; submit → draft gone, doesn't reappear. - -### Step 3 — External-annotation transport seam (effort M–L, riskiest; do last) -**Files:** `packages/ui/hooks/useExternalAnnotations.ts` (+ `useExternalAnnotationHighlights.ts` stays as-is). -- Introduce an `ExternalAnnotationTransport` and module-level default reproducing the SSE→polling machine verbatim: - ```ts - export interface ExternalAnnotationTransport { - subscribe(onEvent: (e: ExternalAnnotationEvent) => void, onError: () => void): () => void; - getSnapshot(since: number): Promise<{ annotations: T[]; version: number } | null>; // null on 304 - add(items: T[]): Promise; - remove(id: string): Promise; - update(id: string, fields: Partial): Promise; - clear(source?: string): Promise; - } - ``` - Default `subscribe` = `new EventSource('/api/external-annotations/stream')` wiring; default `getSnapshot` = `GET /api/external-annotations?since=` with 304→null; CRUD = today's optimistic-then-fetch calls. -- **Keep inside the hook (verbatim):** the reducer that applies `snapshot|add|remove|clear|update`, the **fallback-once** logic (`!receivedSnapshotRef && !fallbackRef`), the **500ms** poll interval, the version-scoped `versionRef`, and the optimistic local mutation before the network call. The default transport owns the EventSource/heartbeat/304 wire; the hook owns the state machine that drives it. -- The `enabled` flag stays host-suppliable (plan: `isApiMode && !goalSetupMode`; review: `!!origin`). Server `shared/external-annotation.ts` (store, validators, event types, SSE encoding) is already shared and unchanged. -- **Parity guardrail:** no caller overrides → identical SSE connection, identical 500ms/304 polling, identical optimistic CRUD. Verify: existing external-annotation tests green; **eyeball both paths** — (a) POST to `/api/external-annotations` shows live without reload (SSE); (b) kill/black-hole the stream → confirm polling takes over and still updates. App.tsx merge/dedup untouched. - -## Renderer-coupling contract (document, no code change) -Write a short integration note (in the package README or a `docs` doc) stating: a host consuming the annotation UI must render markdown through `@plannotator/ui` `BlockRenderer` + `InlineMarkdown` + `utils/inlineTransforms` (which applies `transformPlainText`), because highlight restoration re-anchors against that exact rendered text. Optional future work: expose `transformPlainText` as overridable with today's default. - -## Replies/threading (explicitly deferred) -Not built in Phase 5. When scoped later, do it backward-compatibly (Plannotator keeps the flat single-comment experience; threading is additive and Plannotator never populates it). Tracked as a separate spec. - -## Definition of done (Phase 5) -- Identity, draft, and external-annotation transports are host-overridable, each defaulting to today's behavior. -- Plannotator byte-unchanged: shipped bundles behave identically; full `bun test` ≥ baseline (1620/0); typecheck; builds; App.tsx changes limited to (at most) wiring the defaults at call sites if needed (ideally zero — module-level defaults mean Plannotator passes nothing). -- Eyeball confirmed: comment author/`(me)`, draft save+restore+no-ghost, live external annotations via SSE, and SSE→polling fallback. -- Renderer-coupling contract written down. Replies deferred with a note. - -## Per-step parity guardrail (run after each) -`bun run typecheck` · `bun test` must stay ≥ 1620/0 (+ the touched suite green, unmodified where a guardrail test exists) · `bun run --cwd apps/review build && bun run build:hook` · `git diff packages/editor/App.tsx` minimal/empty · manual eyeball for the step's surface. - -## Open questions (resolve before/within ADR) -1. Identity: module-level `setIdentityProvider` (recommended) vs. props. -2. Replies: deferred (recommended) vs. in-scope. -3. Renderer `transformPlainText`: document-only (recommended) vs. extract overridable now. -4. Whether to ship the three seams as one Phase-5 PR or three small verify-gated commits (recommended: three commits, identity → drafts → external, like Phase 3). diff --git a/adr/specs/document-ui-extraction-20260620-083307.md b/adr/specs/document-ui-extraction-20260620-083307.md deleted file mode 100644 index be176d17f..000000000 --- a/adr/specs/document-ui-extraction-20260620-083307.md +++ /dev/null @@ -1,1066 +0,0 @@ -# Spec: Shared Document UI Package - -> ⚠️ **REVERTED — DO NOT IMPLEMENT.** Spec for the failed `@plannotator/document-ui` extraction (reverted 2026-06-22). The corrected plan is **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`**. Kept here as history only. - -Date: 2026-06-20 - -Status: Draft - -## Intent - -Extract Plannotator's document review experience into one shared UI package that can be used by Plannotator and a sister Workspaces repo. - -The package should not be a thin markdown renderer. It should own the reusable document review loop: - -- render markdown and raw HTML documents -- annotate text and blocks -- show comments and attachments -- navigate linked documents -- browse document/file trees -- edit documents -- show dirty/saving/saved/conflict/missing/error states -- restore drafts -- assemble feedback and saved-change context - -The host app should own routing, auth, server calls, environment capabilities, and provider-specific persistence. - -## Package - -Create one package: - -```text -@plannotator/document-ui -``` - -Do not split into renderer/tree/comments/editor packages yet. The first stable boundary is the full document review surface. - -Primary export: - -```tsx - -``` - -The package may later expose lower-level hooks and components, but those are secondary. The product-level export is the surface. - -## Design Principle - -The core contract must be provider-neutral. - -Do not name the shared contract around Plannotator's local source-save implementation. Local source-save is one provider. The reusable concept is document writeback state: - -```ts -type DocumentWritebackStatus = - | "clean" - | "dirty" - | "saving" - | "saved" - | "conflict" - | "missing" - | "error"; -``` - -Plannotator local implements writeback with: - -- `/api/source/save` -- disk hashes -- mtime -- EOL metadata -- missing local files -- source-save draft restore - -Workspaces implements writeback with: - -- `/v1/workspaces/{workspace}/documents/{document}` -- `If-Match` -- document versions -- missing document rows -- workspace restore semantics - -The package should own the common UI and state behavior. Providers own the persistence details. - -## Goals - -1. Make Plannotator's document experience the upstream UI for both repos. - -2. Keep one shared package, not many narrowly split packages. - -3. Move document-domain behavior out of `packages/editor/App.tsx`. - -4. Preserve current Plannotator behavior for: - -- plan review -- annotate file -- annotate folder -- annotate last message -- raw HTML annotation -- linked docs -- source-backed file editing -- drafts -- feedback submission - -5. Let Workspaces provide a different backend with the same document UI: - -- workspace manifest based document tree -- document ids instead of filesystem paths -- workspace versions instead of disk hashes -- workspace annotations and replies API -- workspace auth and routes - -6. Keep current Plannotator server routes stable during extraction. - -7. Make the document surface testable with an in-memory host API. - -## Non-Goals - -Do not redesign the user interface as part of this extraction. - -Do not rename `/api/plan` during the extraction. - -Do not solve Bun/Pi server duplication as part of the frontend package boundary. - -Do not move CLI/plugin command interception into the package. - -Do not move server startup or browser opening into the package. - -Do not move the agent terminal runtime, WebTUI sidecar, or runtime installer into the package. - -Do not make plan diff, archive, goal setup, or permission mode setup first-class parts of the first document package boundary. - -Do not require a filesystem path for every document. - -## Package Responsibilities - -`@plannotator/document-ui` owns: - -- `DocumentReviewSurface` -- document render mode switch: markdown, raw HTML, editing -- markdown parsing and block model -- markdown block rendering -- raw HTML iframe annotation bridge -- annotation lifecycle -- highlight restoration -- comments and annotation panel -- image attachments -- linked document navigation -- document tree/file tree rendering and status badges -- edit toggle and edit session state -- provider-neutral writeback state -- conflict/missing/error UI patterns -- draft save and restore state -- feedback payload assembly -- saved-change section assembly -- code path validation UI -- inline link handling -- optional Ask AI integration points -- optional agent-delivery integration points - -The host app owns: - -- app routing -- server endpoints -- auth -- current user/session identity -- provider implementation -- browser opening -- plugin/CLI integration -- local filesystem access -- workspace API access -- plan-mode hook stdout behavior -- plan history -- plan diff -- archive -- goal setup -- note-app settings and persistence policy -- terminal runtime and sidecar - -## Terminology - -### Host App - -The application that embeds `DocumentReviewSurface`. - -Examples: - -- Plannotator plan/annotate app -- Workspaces web app - -### Provider - -The host-side implementation of document loading, saving, tree listing, draft persistence, annotations, versions, and feedback submission. - -Examples: - -- Plannotator local provider -- Workspaces provider -- in-memory test provider - -### DocumentRef - -Provider-neutral identity for a document. - -A document may have a filesystem path, but the package must not require one. - -### Writeback - -Provider-neutral document edit persistence. - -Local Plannotator's current `sourceSave` is one writeback implementation. Workspaces document save is another. - -### Feedback - -The review output assembled from annotations, global comments, image attachments, direct edits, saved changes, linked-document comments, and provider-specific metadata. - -## Core Types - -This is a draft interface shape. Exact names can change during implementation, but the concepts should remain. - -```ts -export type DocumentProviderId = string; - -export interface DocumentRef { - id: string; - providerId?: DocumentProviderId; - label: string; - title?: string; - path?: string; - parentId?: string; - kind?: "document" | "folder" | "message" | "url" | "html" | string; - metadata?: Record; -} - -export type DocumentRenderMode = "markdown" | "html"; - -export interface LoadedDocument { - ref: DocumentRef; - content: string; - renderMode: DocumentRenderMode; - rawHtml?: string; - shareHtml?: string; - sourceInfo?: string; - converted?: boolean; - baseForRelativeLinks?: DocumentRef | string; - imageBase?: string; - writeback?: DocumentWritebackCapability; -} - -export type DocumentWritebackStatus = - | "clean" - | "dirty" - | "saving" - | "saved" - | "conflict" - | "missing" - | "error"; - -export interface DocumentWritebackCapability { - writable: boolean; - status: DocumentWritebackStatus; - revision?: string; - language?: "markdown" | "mdx" | "text" | string; - reason?: string; - message?: string; - providerState?: unknown; -} - -export interface DocumentWritebackState { - ref: DocumentRef; - status: DocumentWritebackStatus; - dirty: boolean; - revision?: string; - sessionOpenContent?: string; - savedContent?: string; - currentContent?: string; - conflict?: DocumentConflict; - error?: string; - providerState?: unknown; -} - -export interface DocumentConflict { - latestContent: string; - latestRevision?: string; - message?: string; - providerState?: unknown; -} - -export interface SaveDocumentRequest { - ref: DocumentRef; - content: string; - baseRevision?: string; - providerState?: unknown; - overwriteConflict?: boolean; -} - -export type SaveDocumentResult = - | { - ok: true; - ref?: DocumentRef; - revision?: string; - providerState?: unknown; - } - | { - ok: false; - code: "conflict"; - message: string; - latestContent: string; - latestRevision?: string; - providerState?: unknown; - } - | { - ok: false; - code: "missing" | "not-writable" | "validation" | "network" | "unknown"; - message: string; - providerState?: unknown; - }; -``` - -## Session Types - -```ts -export type DocumentSessionMode = - | "plan-review" - | "annotate" - | "annotate-folder" - | "annotate-message" - | "workspace-review" - | string; - -export interface DocumentReviewSession { - id: string; - mode: DocumentSessionMode; - origin?: string; - rootDocument?: LoadedDocument; - initialDocumentRef?: DocumentRef; - rootTreeRef?: DocumentRef; - capabilities: DocumentCapabilities; - ui?: DocumentSessionUi; - providerState?: unknown; -} - -export interface DocumentCapabilities { - canAnnotate: boolean; - canEdit: boolean; - canWriteback: boolean; - canApprove?: boolean; - canExit?: boolean; - canShare?: boolean; - canUploadImages?: boolean; - canOpenLinkedDocuments?: boolean; - canBrowseDocuments?: boolean; - canUseAskAI?: boolean; - canDeliverToAgent?: boolean; - supportsRawHtml?: boolean; - supportsVersions?: boolean; -} - -export interface DocumentSessionUi { - title?: string; - subtitle?: string; - primaryActionLabel?: string; - approveLabel?: string; - exitLabel?: string; -} -``` - -## Host API - -The surface talks to a provider through `DocumentHostApi`. - -```ts -export interface DocumentHostApi { - loadSession?(): Promise; - - loadDocument(request: LoadDocumentRequest): Promise; - - resolveLinkedDocument?(request: ResolveLinkedDocumentRequest): Promise; - - validateCodePaths?( - request: ValidateCodePathsRequest, - ): Promise; - - listDocuments?( - request: ListDocumentsRequest, - ): Promise; - - watchDocuments?( - request: WatchDocumentsRequest, - ): DocumentWatchSubscription; - - saveDocument?( - request: SaveDocumentRequest, - ): Promise; - - loadDraft?( - request: LoadDraftRequest, - ): Promise; - - saveDraft?( - request: SaveDraftRequest, - ): Promise; - - deleteDraft?( - request: DeleteDraftRequest, - ): Promise; - - uploadImage?( - file: File, - ): Promise; - - submitFeedback?( - payload: SubmitDocumentFeedbackPayload, - ): Promise; - - approve?( - payload: ApproveDocumentPayload, - ): Promise; - - exit?( - payload: ExitDocumentPayload, - ): Promise; - - askAI?( - request: DocumentAskAIRequest, - ): Promise | AsyncIterable; - - deliverToAgent?( - payload: DocumentAgentDeliveryPayload, - ): Promise; -} -``` - -### LoadDocumentRequest - -```ts -export interface LoadDocumentRequest { - ref: DocumentRef; - baseRef?: DocumentRef; - preferredRenderMode?: DocumentRenderMode; -} -``` - -### Tree Types - -```ts -export interface DocumentTreeNode { - ref: DocumentRef; - kind: "folder" | "document"; - children?: DocumentTreeNode[]; - annotationCount?: number; - writebackStatus?: DocumentWritebackStatus; - disabled?: boolean; - metadata?: Record; -} - -export interface DocumentTreeResult { - root: DocumentTreeNode; - workspaceStatus?: unknown; -} -``` - -### Watch Types - -```ts -export interface DocumentWatchSubscription { - close(): void; - onEvent(callback: (event: DocumentWatchEvent) => void): () => void; -} - -export type DocumentWatchEvent = - | { type: "ready"; ref?: DocumentRef } - | { type: "changed"; ref?: DocumentRef; reason?: string } - | { type: "deleted"; ref: DocumentRef } - | { type: "error"; message: string }; -``` - -The Plannotator local adapter can implement this over `EventSource('/api/reference/files/stream')`. - -The Workspaces adapter can implement it over its own workspace document event system, polling, or no-op watches. - -## DocumentReviewSurface Props - -```ts -export interface DocumentReviewSurfaceProps { - session: DocumentReviewSession; - hostApi: DocumentHostApi; - initialDocument?: LoadedDocument; - initialAnnotations?: Annotation[]; - initialCodeAnnotations?: CodeAnnotation[]; - initialGlobalAttachments?: ImageAttachment[]; - className?: string; - slots?: DocumentReviewSlots; - options?: DocumentReviewOptions; - onSubmitted?: (result: SubmitFeedbackResult) => void; - onApproved?: () => void; - onExited?: () => void; - onError?: (error: DocumentReviewError) => void; -} - -export interface DocumentReviewSlots { - leftSidebarExtraTabs?: React.ReactNode; - rightPanelExtraTabs?: React.ReactNode; - terminalPanel?: React.ReactNode; - headerActions?: React.ReactNode; - footer?: React.ReactNode; -} - -export interface DocumentReviewOptions { - defaultEditorMode?: "selection" | "comment" | "redline" | "quickLabel"; - defaultInputMethod?: "drag" | "pinpoint"; - allowRawHtml?: boolean; - allowWideMode?: boolean; - allowImageAttachments?: boolean; - persistUiPreferences?: boolean; - disableDrafts?: boolean; - hideDocumentNavigator?: boolean; - hideAnnotationPanel?: boolean; -} -``` - -## State Ownership - -The package owns frontend state for: - -- active document -- linked document stack -- per-document annotation cache -- selected annotation -- global comments and attachments -- parsed blocks -- markdown edit session -- writeback status map -- dirty document set -- conflict/missing/error display -- draft payload -- saved-change records -- file/document tree badges -- feedback payload assembly - -The provider owns durable state for: - -- document content -- revisions or hashes -- versions -- saved annotations, if the host persists them -- draft storage backend -- auth and permissions -- server-side conflict detection -- route shape - -## Writeback State Machine - -The package should implement the common frontend state machine. - -### clean - -The current editor buffer matches the loaded baseline. - -Allowed actions: - -- edit -- annotate -- navigate away -- submit feedback - -### dirty - -The user changed editable content but has not saved it. - -Allowed actions: - -- save -- discard -- continue editing -- submit only if the product policy allows unsaved direct edits - -Plannotator local should continue to include direct edits in feedback where appropriate. - -Workspaces may choose to require save before submit or include unsaved edits as proposed changes. - -This policy should be configurable by the session or provider. - -### saving - -A writeback request is in flight. - -Allowed actions: - -- show saving state -- prevent duplicate save -- avoid applying stale watch snapshots - -### saved - -The user saved a change during this review session. - -Allowed actions: - -- show saved state -- include saved-change context in feedback where the session requests it -- treat current saved content as the new baseline - -### conflict - -The provider rejected save because the remote/local document changed. - -Required provider data: - -- latest content -- latest revision or provider-specific conflict state - -Allowed actions: - -- reload latest -- overwrite, when policy allows and the edit buffer is available -- keep editing -- discard - -The package should not show overwrite if the provider or current state cannot perform it. - -### missing - -The document no longer exists or cannot be resolved. - -Allowed actions: - -- show missing row/state -- preserve annotations and draft context when possible -- allow discard/close -- allow restore/recreate only if provider advertises support - -### error - -An operation failed without a recoverable conflict or missing state. - -Allowed actions: - -- retry if operation is retryable -- discard local edits -- submit only if policy allows - -## Provider Policies - -Some behavior must be provider/session configurable: - -```ts -export interface DocumentWritebackPolicy { - submitWithUnsavedEdits: - | "allow-as-direct-edits" - | "block" - | "ask"; - submitWithUnverifiedSavedChanges: - | "allow" - | "block" - | "ask"; - conflictOverwrite: - | "allowed" - | "disallowed" - | "provider"; - missingDocumentRestore: - | "none" - | "recreate" - | "provider"; -} -``` - -Plannotator local likely uses: - -- `submitWithUnsavedEdits: "allow-as-direct-edits"` for plan edits -- stricter behavior for source-backed saved-change verification -- `conflictOverwrite: "allowed"` only when live editor buffer is available -- `missingDocumentRestore: "none"` for first extraction - -Workspaces likely uses: - -- `submitWithUnsavedEdits: "block"` or `"ask"` depending on product choice -- `submitWithUnverifiedSavedChanges: "block"` if version checks fail -- `conflictOverwrite: "provider"` -- `missingDocumentRestore: "provider"` if workspace restore exists - -## Drafts - -The package owns draft shape and restore behavior, but the host owns persistence. - -Drafts should store provider-neutral data: - -```ts -export interface DocumentReviewDraft { - annotations: Annotation[]; - codeAnnotations?: CodeAnnotation[]; - globalAttachments: ImageAttachment[]; - editedDocuments?: DraftEditedDocument[]; - savedChanges?: DraftSavedDocumentChange[]; - activeDocumentRef?: DocumentRef; - selectedAnnotationId?: string; - generation: number; - timestamp: number; -} - -export interface DraftEditedDocument { - ref: DocumentRef; - baseRevision?: string; - baseContent: string; - currentContent: string; - providerState?: unknown; -} - -export interface DraftSavedDocumentChange { - ref: DocumentRef; - beforeContent: string; - afterContent: string; - beforeRevision?: string; - afterRevision?: string; - providerState?: unknown; -} -``` - -Plannotator local can map existing draft fields to this shape. - -Workspaces can persist drafts in its own storage and map workspace revisions into `baseRevision` / `afterRevision`. - -Draft generation remains important. It prevents late saves from resurrecting cleared drafts after submit. - -## Feedback Assembly - -The package should assemble a provider-neutral feedback payload: - -```ts -export interface SubmitDocumentFeedbackPayload { - sessionId: string; - mode: DocumentSessionMode; - activeDocument?: DocumentRef; - annotations: Annotation[]; - linkedDocumentAnnotations?: LinkedDocumentAnnotationEntry[]; - codeAnnotations?: CodeAnnotation[]; - globalAttachments?: ImageAttachment[]; - directEdits?: DirectEditEntry[]; - savedChanges?: SavedDocumentChangeEntry[]; - messageScope?: unknown; - providerState?: unknown; -} -``` - -The package can also expose a renderer for human-readable markdown feedback, but the host decides what to do with the payload. - -Plannotator local host behavior: - -- convert payload into current agent feedback text -- call `/api/feedback`, `/api/approve`, or `/api/deny` -- optionally route feedback to the agent terminal - -Workspaces host behavior: - -- save comments/replies through annotation APIs -- save review state through workspace APIs -- submit or share feedback according to workspace product rules - -## Linked Documents - -Linked document navigation must use `DocumentRef`, not filesystem path as the only identity. - -The package should handle: - -- opening linked docs -- preserving root document state -- caching annotations per document -- switching between markdown and raw HTML render modes -- returning to the prior document -- showing annotation counts in the tree - -The provider handles: - -- resolving link text/path/id to `DocumentRef` -- loading document content -- enforcing access and auth -- choosing whether relative links are path-based, manifest-based, or id-based - -## Document Tree - -The package should render a tree of `DocumentTreeNode`. - -Tree rows should support: - -- folders -- documents -- active document indicator -- annotation count -- writeback status badge -- workspace/git/provider status metadata -- missing/deleted rows -- disabled rows - -The package should not assume git status. It can accept provider metadata and render known generic status patterns. Plannotator local can map git workspace status into this metadata. - -## Raw HTML - -Raw HTML support remains part of the package. - -The package owns: - -- iframe rendering -- annotation bridge integration -- shared viewer handle behavior -- raw HTML annotation state - -The provider owns: - -- asset rewriting -- raw HTML sanitization/permission policy if needed -- portable/share HTML generation - -## Ask AI - -Ask AI should be an optional capability. - -The package owns: - -- where Ask AI affordances appear -- how selected document context is gathered -- how annotation context is included - -The host owns: - -- provider selection -- auth -- session creation -- streaming implementation -- terminal fallback - -## Agent Delivery - -Agent delivery is optional and host-owned. - -The package can accept: - -```ts -export interface DocumentAgentDeliveryCapability { - available: boolean; - deliveredKey?: string; - send(payload: DocumentAgentDeliveryPayload): Promise; -} -``` - -The package can use this to: - -- show delivered/current feedback state -- avoid duplicate sends -- route Ask AI prompts to an agent when configured - -The package must not own: - -- WebTUI runtime -- sidecar process -- WebSocket URL creation -- remote-mode security -- runtime installation - -## Plannotator Local Adapter - -Add a browser-side adapter around current routes: - -```ts -createPlannotatorHttpDocumentApi(options?: { - baseUrl?: string; -}): DocumentHostApi -``` - -Initial route mapping: - -- `loadSession` -> `GET /api/plan` -- `loadDocument` -> `GET /api/doc` -- `validateCodePaths` -> `POST /api/doc/exists` -- `listDocuments` -> `GET /api/reference/files` -- `watchDocuments` -> `GET /api/reference/files/stream` -- `saveDocument` -> `POST /api/source/save` -- `loadDraft` -> `GET /api/draft` -- `saveDraft` -> `POST /api/draft` -- `deleteDraft` -> `DELETE /api/draft` -- `uploadImage` -> `POST /api/upload` -- `submitFeedback` -> `POST /api/feedback` -- `approve` -> `POST /api/approve` -- `exit` -> `POST /api/exit` - -The adapter should map local `sourceSave` into provider-neutral writeback fields. - -The core package should not leak `sourceSave` into its public contract except through local adapter internals. - -## Workspaces Adapter - -The sister repo should be able to implement: - -```ts -createWorkspaceDocumentApi(options: { - workspaceId: string; - auth: WorkspaceAuth; - baseUrl: string; -}): DocumentHostApi -``` - -Expected mapping: - -- load tree from workspace manifest -- resolve linked docs from manifest/document ids -- load documents by document id -- save with `If-Match` or version id -- map versions/ETags to `revision` -- map deleted/unavailable docs to `missing` -- load comments/replies from annotations API -- persist drafts through workspace draft storage -- load versions through versions API - -This adapter does not need to live in the Plannotator repo if the package contract is stable. - -## Migration Plan - -### Phase 1. Contracts - -Create `packages/document-ui`. - -Add: - -- core types -- `DocumentHostApi` -- provider-neutral writeback types -- draft types -- feedback payload types - -No product behavior changes. - -### Phase 2. Local HTTP Adapter - -Implement `createPlannotatorHttpDocumentApi()` over current routes. - -Use it from `packages/editor/App.tsx` where possible without moving major UI yet. - -Goal: route calls begin moving behind the adapter. - -### Phase 3. Move Document Domain Modules - -Move or re-export: - -- editable document state -- source document client/reconciliation, renamed toward writeback where public -- saved file change validation, generalized to saved document change validation -- direct edits -- draft restore selection -- path helpers only where local-specific - -Rename public concepts from source-save to writeback. Keep local source-save names inside the local adapter. - -### Phase 4. Extract Surface Shell - -Create `DocumentReviewSurface` around existing: - -- `Viewer` -- `HtmlViewer` -- `MarkdownEditor` -- annotation panel -- linked doc hook -- file browser hook -- draft hook -- writeback state - -`packages/editor/App.tsx` still owns mode decisions and passes session/capabilities. - -### Phase 5. Move Feedback Assembly - -Move provider-neutral feedback assembly into document-ui. - -Keep Plannotator-specific final text wrapping in the Plannotator host. - -### Phase 6. Host Cleanup - -Shrink `packages/editor/App.tsx` into: - -- load session -- create Plannotator host API -- configure plan/annotate actions -- render `DocumentReviewSurface` -- render plan-only sidecars such as plan diff/archive when needed - -### Phase 7. Workspaces Integration - -Workspaces implements its adapter and embeds the surface. - -Any missing extension points should be added to the contract, not patched through Plannotator-local assumptions. - -## Testing - -Add contract tests: - -- Plannotator Bun `/api/plan` maps to `DocumentReviewSession` -- Pi `/api/plan` maps to the same `DocumentReviewSession` -- `/api/doc` markdown maps to `LoadedDocument` -- `/api/doc` raw HTML maps to `LoadedDocument` -- `/api/doc` converted HTML maps to non-writable document -- `/api/doc` source-save maps to writeback capability -- `/api/source/save` conflict maps to provider-neutral conflict - -Add package tests: - -- in-memory provider loads a document -- annotate and restore highlights -- edit document and transition clean -> dirty -> saving -> saved -- conflict result transitions to conflict and shows conflict controls -- missing document transition keeps row and draft context -- linked document navigation preserves annotation cache -- raw HTML document can create annotations through the common surface handle -- draft restore rehydrates edited documents and saved changes -- feedback payload includes annotations, linked doc comments, direct edits, and saved changes - -Keep existing tests active: - -- parser tests -- markdown editor fidelity tests -- annotation draft persistence tests -- file browser tests -- editable document tests -- source reconciliation tests -- server annotate/reference tests -- Pi server parity tests - -## Acceptance Criteria - -The spec is satisfied when: - -- `@plannotator/document-ui` exists as one package. -- It exports provider-neutral contracts. -- It exports `DocumentReviewSurface`. -- Plannotator uses the surface for plan review and annotate flows. -- Plannotator local behavior is preserved. -- Local source-save is represented as writeback in the package contract. -- The package public API does not require filesystem paths. -- The package public API does not expose `/api/source/save`. -- The package public API does not expose local disk hash semantics as required fields. -- Workspaces can implement a provider using document ids, manifests, `If-Match`, and versions. -- The document surface can run in tests with an in-memory provider. -- Plan diff/archive/goal setup remain host-owned. -- Agent terminal runtime remains host-owned. - -## Open Questions - -1. Should `@plannotator/document-ui` depend on `@plannotator/ui`, or should document components move out of `@plannotator/ui`? - -Working recommendation: start with `@plannotator/document-ui` depending on `@plannotator/ui` for primitives and gradually move document-specific components into the new package. Avoid a giant one-shot move. - -2. Should the Plannotator local HTTP adapter live in `@plannotator/document-ui` or `@plannotator/editor`? - -Working recommendation: put the browser-side adapter in `@plannotator/document-ui` if it only uses fetch and public routes. Keep server-only code out. - -3. Should feedback assembly produce markdown text or structured data? - -Working recommendation: produce structured data first and expose a markdown formatter. Plannotator can keep its agent-specific markdown wrapper. - -4. Should unsaved edits be allowed in Workspaces feedback? - -Working recommendation: make this a provider policy. Do not bake Plannotator's direct-edit behavior into all providers. - -5. Should comments/replies persistence be part of the host API now? - -Working recommendation: include extension points now, but do not require persistent comments for the first Plannotator extraction. Workspaces can implement persistence through the adapter. - -## Decision Draft - -Adopt one provider-neutral shared document UI package. - -The core abstraction is not local source-save. The core abstraction is document writeback state plus a host API. - -Plannotator local source-save becomes the first provider implementation. Workspaces becomes a second provider implementation. Plan review becomes one host mode that supplies a document, capabilities, and approve/deny behavior to the shared document surface. diff --git a/adr/specs/document-ui-extraction-plan-verified-20260622-184500.md b/adr/specs/document-ui-extraction-plan-verified-20260622-184500.md deleted file mode 100644 index 47abf8646..000000000 --- a/adr/specs/document-ui-extraction-plan-verified-20260622-184500.md +++ /dev/null @@ -1,158 +0,0 @@ -# Spec: Document UI Extraction Plan — Verified - -Date: 2026-06-22 - -> Produced by a 36-agent verification workflow (5 coupling-sweep lenses + 15 subsystem analyses + 15 adversarial parity reviews + synthesis). It **verifies and supersedes** the draft inventory `adr/research/SPIKE-document-ui-reuse-inventory-20260622-183000.md`, which was directionally correct but materially incomplete. Governed by **ADR 004**. THE LAW: move + decouple, never rewrite; Plannotator's experience cannot change. Every seam below is "lift the URL/global to an optional prop, with **today's literal as the verbatim default**." - -**Repo:** `/Users/ramos/plannotator/feat-pkg-document-ui` · **Library:** `packages/ui` · **Glue:** `packages/editor/App.tsx` - -## Subsystem parity verdicts - -`safe` = extract via straightforward seams. `risky` = extractable, but contains timing-sensitive/stateful code that must be **moved verbatim**, not re-derived (the reverted attempt's failure mode). - -| Subsystem | Verdict | Effort | WS | -|---|---|---|---| -| Theme & tokens | safe | S | core | -| Markdown parsing + block rendering | safe | S | core | -| Document Viewer + annotation highlighting | **risky** | S–M | core | -| Raw HTML viewer | safe | S | core | -| Markdown editor | safe | S | core | -| File tree / browser | **risky** | M | core | -| Sidebar shell + tabs | safe | S | core | -| Comments / annotations / drafts | **risky** | L | core | -| Versions / plan diff | safe | M | maybe | -| Settings / config | safe | M | partial | -| Sharing / export / notes | safe | S | partial | -| Ask AI / agents | **risky** | M | maybe/own | -| Images: upload / thumbnail / annotate | safe | S | core | -| The glue (App.tsx layout) | safe | S | n/a | -| Packaging @plannotator/ui | safe | M | gate | - -## 1. What the draft inventory missed (verified corrections) - -The draft audited only one coupling axis — literal `/api/` strings at call sites — and was blind to five others. - -- **A. Viewer is NOT clean (most consequential).** `Viewer.tsx:532` calls `useValidatedCodePaths(...)` **unconditionally**, which POSTs `/api/doc/exists`. Mounting Viewer fires a Plannotator backend call. Viewer is **NEEDS_SEAM**, not one of the "clean 82." -- **B. An uncounted cookie-persistence subsystem.** `utils/storage.ts` (`document.cookie`) is the **sole** settings backend, imported by ~24 modules (theme, TOC/plan-width/sticky prefs, panel resize, editor mode, agent switch, AI provider, identity). NEEDS_SEAM (inject a `{getItem,setItem,removeItem}` adapter; it's already localStorage-shaped). -- **C. Three React contexts + one global singleton, never mentioned.** - - `ScrollViewportContext` — consumed in `packages/ui` (Viewer, StickyHeaderLane, PinpointOverlay, TableOfContents) but its **only Provider lives in the glue** (`App.tsx:3888`). Mounted elsewhere, sticky headers / pinpoint / scroll-to-anchor / TOC scrolling silently break. NEEDS_SEAM. - - `configStore` singleton — module-level, eager cookie reads, hardcoded `fetch('/api/config')` write-back (L118). Reached **transitively** via `identity.ts` by Viewer, AnnotationPanel, HtmlViewer, `useAnnotationHighlighter`, diff views, Settings. This is **annotation authorship** ("which comments are mine") — core to Workspaces commenting. NEEDS_SEAM (host-supplied `currentUser`/`isCurrentUser`). - - `CodePathValidationContext` — intra-library, null-tolerant. TRANSFER_AS_IS. -- **D. SSE (EventSource) is a distinct transport** the draft collapsed into REST siblings: `useFileBrowser.ts:297`, `useExternalAnnotations.ts:44`, `useAgentJobs.ts:66`. Workspaces' backend must speak SSE or supply a `subscribe` callback. -- **E. `useFileBrowser` is NOT transfer-as-is** — hard-codes `/api/reference/files` (L116), `/api/reference/obsidian/files` (L224), and the SSE watcher (L297). Only its expansion state is pure. -- **F. Packaging is harder than "moderate."** Hard blockers: `@plannotator/ai` + `@plannotator/shared` are `workspace:*` + `private` + `0.0.1`; a **phantom `dompurify`** dep (imported, not declared); **no `peerDependencies`** (React-duplication risk); `exports` point at raw `.tsx`/`.ts` with no `main`/`module`/`types`. -- **G. Small factual fixes** (so we don't act on bad data): theme count is a clean **51:51** (the "53/52" was a grep artifact); `useTheme()` does **not** throw without a provider (seeded default); `getImageSrc` is **one** shared seam across 5 consumers, not 3 separate wires; `utils/sharing.ts` calls the **external** paste service (base URLs parameterized), not a Plannotator `/api/paste` route. - -## 2. Master extraction plan (dependency-ordered) - -Each step: **default === today's literal**, additive optional prop/callback, logic untouched. The guardrail is how you prove Plannotator didn't change. - -### Step 0 — Packaging unblock (do first; gates external install, zero runtime effect). Effort M. -- Add `dompurify` to `packages/ui` deps at the root's exact `^3.3.3` (version mismatch could change sanitization output). -- Resolve the two `workspace:* / private` deps: publish `@plannotator/ai` + `@plannotator/shared` with real versions, **or** inline the ~11 verified browser-safe subpaths ui value-imports (all Web-API-only — Web Crypto, CompressionStream — no `node:*`). -- Add `peerDependencies` (react, react-dom, tailwindcss, tailwindcss-animate, radix set, lucide-react); keep as devDeps for in-repo typecheck. -- Fix stale `tsconfig.json:21` alias `@plannotator/shared` → nonexistent `../shared/index.ts`; align `diff` range (`^8.0.3` vs root `^8.0.4`). -- Keep the **source-only** export model (no dist build — a build could change what Plannotator ships); document required consumer bundler settings (`isolatedModules`, JSX runtime, `allowImportingTsExtensions`). -- Add a `files` allowlist incl. `assets/`, `sprite_package_*/`, themes; exclude `*.test.*` (the only upward `ui→editor` import is `shortcuts.test.ts`). -- **Guardrail:** `bun run build:hook` + `build:opencode` produce byte-identical bundles; in-repo React still resolves to one copy. - -### 1. Rendering core — images. Effort S. -- *As-is:* BlockRenderer, sanitizeHtml, inlineTransforms, parser render path. -- *Seam:* the single `getImageSrc` (ImageThumbnail.tsx:6) shared by 5 consumers (ImageThumbnail, InlineMarkdown, HtmlBlock, AttachmentsButton, Viewer). Introduce a module-level/context override whose default is the current body verbatim (http passthrough + conditional `&base`). Do **not** thread a Viewer-level prop — it can't reach InlineMarkdown/HtmlBlock. -- **Guardrail:** all 5 importers emit identical `/api/image?path=…&base=…`. Keep the default resolver **module-level (stable identity)** so HtmlBlock's `React.memo` + effect deps are untouched (otherwise `
` collapse on re-render). - -### 2. Rendering core — doc fetch + code-path validation. Effort S. -- *Seam A:* InlineMarkdown hover preview `fetch('/api/doc?…')` (L154) → optional `fetchCodeFileContents` defaulting to the literal (same `{path, base?}`, **no `convert=1`** — that's glue). `useLinkedDoc` already accepts `buildUrl`; `useCodeFilePopout` is already prop-driven. -- *Seam B:* gate Viewer's validation — add `disableCodePathValidation`/inject result; default = today (on). -- **Guardrail:** Plannotator passes nothing → hover previews + code-link rendering identical; `/api/doc/exists` still fires. - -### 3. Image upload + attachments. Effort S. -- *Seam:* `AttachmentsButton` `fetch('/api/upload')` (L140) → `onUpload(file) => Promise<{path}>`. Preserve multipart field name `'file'` and `{path}` return shape. Keep `deriveImageName` export stable. -- **Guardrail:** capture-phase paste listener + `stopPropagation` unchanged (no double-attach with App.tsx's bubble-phase paste). - -### 4. File tree. Effort M (highest-risk SSE move). -- *As-is:* `FileBrowser.tsx` helpers + CountBadge, expansion state. -- *Seam:* lift `useFileBrowser`'s three fetch URLs + the **entire** SSE watcher effect (L289-342: EventSource, 120ms debounce, ready-dedup, cleanup) into a default `loadTree`/`loadVaultTree`/`watchTrees` object — moved **verbatim**, URL literals the only relocatable part. `useFileBrowser()` must stay callable with zero args. -- **Guardrail:** existing `useFileBrowser.test.tsx` stays green **without modification**. If it needs rewriting, the default changed → regression. - -### 5. Comments / annotations / drafts. Effort L (risky). -- *As-is:* AnnotationSidebar, EditorAnnotationCard, commentContent, anchors, annotationHelpers, useExternalAnnotationHighlights. -- *Seam A — draftTransport:* wrap the 5 `/api/draft` fetches; `save` rejects on failure (preserves keepalive-retry). Keep generation bookkeeping in the hook. **Document the 3-party protocol:** `getDraftGeneration()` escapes into App.tsx and rides `/api/approve`/`/api/deny` bodies; server tombstone-gates in `shared/draft.ts`. A host swapping transport must replicate generation-gated delete-on-submit or ghost drafts resurrect. -- *Seam B — external-annotations transport:* move the **entire** effect body (EventSource + snapshot-gated fallback + `?since`/304 polling at 500ms) verbatim into a default `subscribe()`. Keep reducer + optimistic mutators. `enabled` flag already host-suppliable. -- *Seam C — identity:* `isCurrentUser(author)` + `getIdentity()` author-stamping (3 creation sites) → optional `author?`/`isCurrentUser?` props defaulting at the App.tsx call site to existing `identity.ts` functions. -- **Guardrail:** approve/deny payloads still carry `getDraftGeneration()`; SSE→polling fallback identical; `(me)` badge renders; every annotation stamped. Note: web-highlighter restoration is **renderer-coupled** — Workspaces must reuse BlockRenderer+InlineMarkdown+inlineTransforms as a unit. - -### 6. Versions / plan diff. Effort M. -- *As-is:* `planDiffEngine.ts`, Badge, ModeSwitcher, RawDiffView. -- *Seam:* inject fetchers into `usePlanDiff` (default → `/api/plan/version(s)`); optional `onOpenVscodeDiff` in `PlanDiffViewer` (default → `/api/plan/vscode-diff`). Keep error handling in the hook (asymmetric: selectBaseVersion `alert()`s, fetchVersions silent). -- *CSS gap:* block/raw-diff + `.annotation-highlight` rules live in **`packages/editor/index.css` (L119-219)**, not the package. Move into `packages/ui/theme.css` (pure move) or document as a host CSS contract. -- **Guardrail:** App.tsx calls with no opts → identical traffic + alert behavior. - -### 7. Settings / config. Effort M. -- *As-is:* `config/settings.ts` (pure cookie+default+mappers). -- *Seam A:* inject only the final `fetch('/api/config')` write-back (L118) via `setServerSync(fn)`. **Keep singleton construction, eager cookie reads, 300ms debounce, deepMerge byte-identical** (a naive per-`set()` fetch changes batching/timing). -- *Seam B:* `Settings.tsx` `fetch('/api/obsidian/vaults')` (L748) → `onDetectObsidianVaults?` default = real fetch; keep `useEffect [obsidian.enabled]` + auto-select-first-vault verbatim (a `[]` no-op default kills auto-select). -- *Seam C:* storage adapter (shared with steps 9/10). Keep literal keys (`plannotator-theme`, `plannotator-toc-enabled`, `plannotator-plan-width`, …) so existing cookies still read. -- *PLANNOTATOR_ONLY:* `HooksTab.tsx`. -- **Guardrail:** Plannotator passes nothing → identical cookie keys, merged `/api/config` POST, vault auto-select. - -### 8. Sharing / export / notes. Effort S. -- *As-is:* `sharing.ts`, `useSharing`, obsidian/bear/octarine wrappers, `callback.ts`. -- *Seam:* `ExportModal` `fetch('/api/save-notes')` (L150) → `onSaveToNotes` → `{success, error}`. Keep `showNotesTab = isApiMode && !!markdown` byte-for-byte. -- *PLANNOTATOR_ONLY:* `OpenInAppButton`. - -### 9. Theme & tokens. Effort S (safe). -- *As-is:* `theme.css` + 51 `themes/*.css` + `print.css` as **one atomic commit**. `themeRegistry` + `ThemeProvider` together. -- *Seam:* inject `storage` into `ThemeProvider` + `uiPreferences`; optional `mode?` on `MarkdownEditor`. -- **Guardrail:** do not touch synchronous `applyThemeClasses` (L96-98) or the rAF `transitions-ready` toggle (L107-111) — reordering causes FOUC. Keep `@source` globs in lockstep if files move. - -### 10. Markdown editor. Effort S (lowest-coupled). -- `MarkdownEditor.tsx` is a 41-line theme-bridge over published `@plannotator/markdown-editor@0.1.0` + `@atomic-editor/editor@0.4.3`. `editorMode.ts` is glue (App.tsx-only). -- **Guardrail:** keep `GRID_CARD_CLASSES` under a `@source`-scanned path (else grid card loses border/shadow). - -### 11. PLANNOTATOR_ONLY — never imported by Workspaces (no work). -`useAutoClose` (Glimpse), `useEditorAnnotations` (`window.__PLANNOTATOR_VSCODE`), `useUpdateCheck` (hardcoded github releases), `useArchive`/`ArchiveBrowser`, `useAgents`/`useAgentJobs`, `GoalSetupSurface`, `planAgentInstructions`, `annotateAgentTerminal` (ws:// derivation), `useSharing` `/p/` routing. They stay in the app shell. - -### 12. Ask AI. Effort M (risky — mechanical-move-only). -- *Seam:* extract **exactly** the 5 fetch literals in `useAIChat` behind a default `transport`. **Do NOT touch** the SSE reader loop (L233-304), epoch/createRequest guards, or the supersede-abort fetch position (L153-158). Capabilities fetch + provider resolution + cookie `aiConfig` init stay in the **shell** (pulling them into the lib is the forbidden re-derivation). - -## 3. Top cross-cutting parity risks - -1. **Cookie-storage swapped globally.** `storage.ts` underlies ~24 modules. Inject per-host; never change the default; keep literal `plannotator-*` keys. Otherwise Plannotator loses theme/layout/identity persistence across random-port hook invocations. -2. **`getImageSrc` resolved per-component** instead of the one shared resolver → some images break with no type error. Single override over the existing default. -3. **Over-extracting glue coordinators (the reverted-approach trap).** App.tsx's panel toggles entangle wide-mode exit + agent-terminal teardown; sidebar auto-open/close is policy keyed on `tocEnabled`/`hasTocEntries`/`isPlanDiffActive`. Keep these as opaque PLANNOTATOR_ONLY glue. -4. **Identity drift.** If `author`/`isCurrentUser` default to `undefined`/`''` instead of live `getIdentity()`/`isCurrentUser`, annotations lose author + `(me)` ownership silently. -5. **CSS that ships in the app shell, not the package** (plan-diff rules, font `@import`s, Tailwind `@source`, `GRID_CARD_CLASSES`). Move files without updating `@source` in lockstep → utilities render unstyled. Silent visual breakage in Plannotator's own build. -6. **Re-render instability from non-stable injected callbacks** (HtmlBlock memo/deps) → collapses open `
`. Keep defaults module-level. -7. **SSE→polling fallback / draft-generation protocols** are timing-sensitive state machines — move as **copies**, not re-derivations. - -## 4. Glue guidance (App.tsx) — be conservative - -**Push DOWN (default = today):** -- The seam defaults (image resolver, doc fetch, upload, draft/external transports, configStore write-back, obsidian detect, save-notes) — defaults live at the App.tsx call site wrapping the current literal. -- `packages/editor/wideMode.ts` → `packages/ui/utils/wideMode.ts`: two pure functions, no relative/circular deps — byte-identical move + one import-path edit (App.tsx:109). Effort S. -- Ship a `ScrollViewportContext` provider/wrapper with the package. - -**LEAVE in the glue (PLANNOTATOR_ONLY — do NOT genericize):** -- Bootstrap from `/api/plan`, approve/deny hook flow, `getDraftGeneration` submit-body wiring. -- Right-panel/wide-mode/agent-terminal coordinators + auto-open/close sidebar policy (risk #3). -- `fileBrowserDirs` derivation + `showFilesTab` + load-orchestration; tab-visibility `show*Tab` + archive lazy-fetch. -- AI capabilities fetch + provider resolution + cookie `aiConfig` init. -- Panel-resize CSS-var writes (`--rpanel-w`/`--toc-w`/`--agent-terminal-w`). - -**Hard rule for the draft's "step 7" (push layout into components):** keep `show*Tab`, `width`, `onTabChange` (with its archive side effect) as **opaque props/callbacks**. `useSidebar`/`useResizablePanel`/`SidebarContainer` are already prop-driven and already reused by `review-editor/App.tsx` — Workspaces writes its **own** coordinator over the same primitives. Re-deriving the coordinator generically is the forbidden path. - -## 5. Packaging blockers (verified) - -| Blocker | Severity | Fix (no logic change) | -|---|---|---| -| `@plannotator/ai` + `@plannotator/shared` are `workspace:* / private / 0.0.1` | HARD | Publish both (real version) or inline the ~11 browser-safe value-imported subpaths | -| Phantom `dompurify` dep (imported, not declared) | HARD | Add to ui deps at exact `^3.3.3` | -| No `peerDependencies` block | MED | Move react/react-dom/tailwindcss(-animate)/radix/lucide to peers; keep devDeps | -| Fonts + Tailwind `@source` live in consumer `index.css` | MED | Ship a documented CSS entry; host on Tailwind v4 | -| Source-only `exports` (no `main`/`module`/`types`) | MED | Keep source model + document bundler settings; no dist build | -| `diff` version drift (`^8.0.3` vs `^8.0.4`) | LOW | Align to `^8.0.4` | -| Stale tsconfig alias → nonexistent `../shared/index.ts` | LOW | Fix when converting shared off `workspace:*` | -| Static asset imports + no `files` allowlist | LOW | Add `files` incl. assets/sprites/themes; exclude tests | - -**Non-blockers (verified — do not "fix"):** all `@plannotator/shared` value imports are Web-API-only; `@plannotator/ai` is `import type` only; `@plannotator/shared/storage` (node:fs) is `import type` only (erased under `isolatedModules`). `theme.css` is pure. diff --git a/adr/specs/document-ui-extras-seam-20260623-100827.md b/adr/specs/document-ui-extras-seam-20260623-100827.md deleted file mode 100644 index cea8c1848..000000000 --- a/adr/specs/document-ui-extras-seam-20260623-100827.md +++ /dev/null @@ -1,53 +0,0 @@ -# Spec: Phase 6 Extras Seams - -Date: 2026-06-23 · Status: Draft (iterate before implementing) - -> Implementation spec for Phase 6 of the `@plannotator/ui` reuse effort. Grounded in `SPIKE-document-ui-extras-system-20260623-100827.md` + its synthesis. Governed by ADR 004. THE LAW: each seam defaults to today's literal behavior; Plannotator passes nothing and is byte-for-byte unchanged. Move + decouple, never rewrite — especially the AI reader loop and the configStore batching. - -## Scope -**In (5 seams + 1 CSS move):** version fetchers, vscode-diff action, the block/raw diff CSS relocation, config write-back, obsidian-detect, save-to-notes, AI transport. -**Confirmed noop (already portable):** planDiffEngine + all plan-diff render components, sharing.ts/useSharing/ImportModal, obsidian/bear/octarine/callback/defaultNotesApp utils, settings.ts, DocumentAIChatPanel/AIProviderBar, aiProvider/aiChatFormat. -**Out (Plannotator-only, stay home):** OpenInAppButton, HooksTab, useUpdateCheck, useAgents, useAgentJobs. - -## Order of work (lowest risk first; AI last) - -### Step 1 — Versions / diff -**Files:** `packages/ui/hooks/usePlanDiff.ts`, `packages/ui/components/plan-diff/PlanDiffViewer.tsx`, `packages/editor/index.css` → `packages/ui/theme.css`. -- **1a. Version fetchers.** Add an optional `fetchers?: { fetchVersion?, fetchVersions? }` arg to `usePlanDiff` (or module-level setters following the seam pattern). Default = today's `fetch('/api/plan/version?v=N')` and `fetch('/api/plan/versions')` verbatim. **Keep error asymmetry:** `selectBaseVersion` still `alert()`s on failure; `fetchVersions` still silent. -- **1b. VS Code diff.** Add optional `onOpenVscodeDiff?: (baseVersion: number) => Promise<{ ok?: boolean; error?: string }>` to `PlanDiffViewer`; default = today's `fetch('/api/plan/vscode-diff')`. Plannotator passes nothing → unchanged (button still works). -- **1c. CSS move.** Cut `.plan-diff-added/removed/modified/unchanged` and `.plan-diff-line-added/removed` from `editor/index.css` (L168-230) into `packages/ui/theme.css` (next to `.plan-diff-word-*`). Also move `.annotation-highlight*` (L119-157) into `theme.css` (it's required by the shared Viewer in any host — closes a latent gap). Plannotator imports `theme.css`, so it stays identical; verify no double-definition remains in `index.css`. -- **Parity guardrail:** Plannotator's plan diff renders pixel-identical (block borders, raw +/-, word-level, annotation highlights); no caller passes fetchers/onOpenVscodeDiff. Eyeball: deny→resubmit a plan, toggle diff (clean/classic/raw), annotate a diff block, VS Code button still works. - -### Step 2 — Settings / config -**Files:** `packages/ui/config/configStore.ts`, `packages/ui/components/Settings.tsx`. -- **2a. Config write-back.** Add `setServerSync(fn)` (and a default = the current inline `fetch('/api/config', POST)`); `scheduleServerSync` calls the injected fn for the final POST only. **Keep the 300ms debounce, `pendingServerWrites` deepMerge batching, singleton construction, and eager cookie reads byte-identical.** -- **2b. Obsidian detect.** Add optional `onDetectObsidianVaults?: () => Promise` to `Settings`; default = today's `fetch('/api/obsidian/vaults')`. **Keep the `useEffect` dep `[obsidian.enabled]` and the auto-select-first-vault branch verbatim.** -- **Parity guardrail:** settings still POST `/api/config` with identical batching/timing; vault auto-select still fires on enable. No caller overrides. Eyeball: change a setting → it persists; enable Obsidian → vaults detected + first auto-selected. - -### Step 3 — Sharing / export / notes -**Files:** `packages/ui/components/ExportModal.tsx`. -- Add optional `onSaveToNotes?: (payload) => Promise<{ results?: Record }>` (match today's response shape); default = today's `fetch('/api/save-notes')`. **Keep `showNotesTab = isApiMode && !!markdown` (L83) byte-for-byte** — do not re-base on the new prop. -- (sharing.ts/useSharing/ImportModal/notes-app utils confirmed noop.) -- **Parity guardrail:** notes tab visibility unchanged; save returns identical `{success, error}`. Eyeball: Export → Notes tab shows when expected → save to Obsidian works. - -### Step 4 — Ask AI (riskiest, last) -**Files:** `packages/ui/hooks/useAIChat.ts`. -- Add an `AITransport` (session/query/abort/permission) + module-level default reproducing today's five `/api/ai/*` fetches verbatim. Route the fetch calls through it. -- **DO NOT TOUCH:** the SSE reader loop (L233-304), the epoch/createRequest guards (refs L109-110; checks L152/L208; resets L376-390), and the supersede-abort fetch **inside `createSession` immediately after the epoch check** (L153-158). Only the wire is parametrized. -- **Stays host-owned:** `/api/ai/capabilities` and `resolveAIProviderSelection`/cookie `aiConfig` (already in App.tsx — do not pull into the lib). -- **Parity guardrail:** identical AI traffic; streaming, permissions, abort, and session-supersede all behave as today. No caller overrides. Eyeball: ask the AI a question (streams), trigger a permission, switch questions mid-stream (supersede), abort. - -## Definition of done (Phase 6) -- The five seams are host-overridable, each defaulting to today's behavior; the diff CSS lives in the package. -- Plannotator byte-unchanged: full `bun test` ≥ baseline (1620/0); typecheck; builds; `App.tsx` changes minimal/empty (ideally zero — module-level/optional-prop defaults). -- Eyeball: plan diff (all modes + vscode + annotate), settings persist + obsidian detect, save-to-notes, AI chat (stream/permission/abort/supersede). -- The five Plannotator-only pieces remain host-owned (untouched). - -## Per-step parity guardrail (run after each) -`bun run typecheck` · `bun test` ≥ 1620/0 (+ touched suite green) · `bun run --cwd apps/review build && bun run build:hook` · `git diff packages/editor/App.tsx` minimal/empty · the step's manual eyeball. - -## Open questions (resolve in ADR) -1. CSS: move `.plan-diff-*` + `.annotation-highlight` into `theme.css` (recommended) vs. document-as-contract. -2. Confirm the five Plannotator-only exclusions (recommended). -3. Per-seam commits (recommended) vs. one PR. -4. usePlanDiff seam shape: extra hook arg vs. module-level setter (lean: optional arg for the fetchers since usePlanDiff already takes args; module-level setters elsewhere). diff --git a/adr/specs/document-ui-feature-completeness-review-fixes-20260622-085528.md b/adr/specs/document-ui-feature-completeness-review-fixes-20260622-085528.md deleted file mode 100644 index 6f2a03b55..000000000 --- a/adr/specs/document-ui-feature-completeness-review-fixes-20260622-085528.md +++ /dev/null @@ -1,519 +0,0 @@ -# Spec: Document UI Feature Completeness Review Fixes - -> ⚠️ **FAILED ATTEMPT — USE AS A CHECKLIST ONLY, NOT A BUILD PLAN.** This catalogs the parity gaps in the reverted `@plannotator/document-ui` cutover (reverted 2026-06-22). It is a useful *inventory of behaviors the UI must preserve*, but do NOT implement it as written — it patches a reimplementation that was thrown away. Corrected plan: **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`**. - -Date: 2026-06-22 - -Status: In Progress - -## Intent - -Close the verified post-review parity gaps in the `@plannotator/document-ui` cutover so the package surface is feature-complete for Plannotator's Plan Review and Annotate app. - -The branch already performs a real architecture cutover: `packages/editor/App.tsx` mounts the new host, and `@plannotator/document-ui` owns much of the document-review state. The remaining work is not to restart the extraction. The remaining work is to wire the production surface to the behavior that was extracted, or to port the few still-missing UI entry points. - -Feature-complete means the normal Plan Review and Annotate production path can ship without the old App shell and without knowingly dropping user-facing behavior from the pre-cutover app. - -## Current Read - -The review findings are mostly valid. The package has many of the right helpers and tests, but the mounted production path does not yet call or expose several of them. - -Confirmed high-impact gaps: - -- Header actions are hidden in Plan Review because the whole `slots` object is gated on agent terminal availability. -- Submit, approve, and close call host APIs directly instead of routing through the extracted safety decisions. -- Annotate-last message data is created by the adapter but not rendered or sent back as an active message scope. -- External annotations and VS Code editor annotations still have server endpoints and shared hooks, but the document surface does not subscribe to them. -- Plan diff uses a new read-only renderer instead of the existing interactive diff viewer with block annotations and VS Code diff support. - -Confirmed secondary gaps: - -- Saved source-change validation exists but is not called before submit/approve. -- Shortcut registries exist but are not registered in the new surface. -- Code-file popout and code-file annotation entry points are not wired from `Viewer`. -- Raw HTML share does not call `/api/share-html` to build portable HTML with inlined relative assets. -- Wide/focus mode helpers exist but are unreachable. -- The new sidebar is versions plus file tree, without old TOC/archive/vault/reference parity. -- Settings are partially stubbed after the menu is restored. -- Print, checkbox task toggles, product announcements, and dead old header cleanup remain smaller follow-ups. - -## Definition Of Done - -The document UI cutover is feature-complete when: - -- Plan Review has Settings, Export, Share, Import, print, note integrations, and any host header actions visible without requiring agent terminal support. -- Approve, Send Feedback, Close, gate-mode approve, and submit shortcuts all run the same safety checks as the old app. -- Unsaved writeback edits, feedback-loss cases, stale saved source changes, missing files, and no-op saved changes are handled before delivery. -- Annotate file, annotate folder, annotate raw HTML, and annotate-last all preserve their feedback target and navigation behavior. -- External annotations and editor annotations appear in the UI and are included in exported/submitted feedback. -- Plan diff/version review supports interactive diff annotations and the VS Code diff affordance. -- The expected keyboard shortcuts work from the production surface. -- Code-file links can open the code-file popout and create code-file annotations where supported. -- Portable sharing of raw HTML sessions preserves relative assets. -- Existing package and editor tests pass, and targeted regression tests cover the formerly missing wiring. - -## P0 Required Fixes - -### 1. Always Mount Header Actions - -Problem: - -`PlannotatorDocumentSurfaceBridge` returns `undefined` slots when `terminalAvailable` or `agentTerminal` is false. This hides `PlanHeaderMenu`, Settings, Export, Import, Share, print, and note actions in normal Plan Review. Agent terminal is only available for annotate file/folder sessions, so the primary Plan Review flow loses its menu. - -Required behavior: - -- `headerActions` must always be provided for supported Plannotator sessions. -- Only `terminalPanel` and terminal-specific header buttons should be gated by terminal availability. -- Plan Review and Annotate should both receive the common Plannotator host actions. - -Implementation shape: - -- Split `hostHeaderActions` from terminal slots in `PlannotatorDocumentSurfaceBridge`. -- Return a slots object unconditionally, with `terminalPanel` set to `null` when unavailable. -- Keep terminal delivery logic unchanged. - -Acceptance: - -- Plan Review shows the options menu with Settings, Export, Share, Import, and print. -- Annotate sessions still show terminal controls only when terminal capability is available. -- Add a render test that plan-review mode includes `data-document-review-header-actions` and the menu trigger when terminal is unavailable. - -### 2. Wire Action Safety Decisions - -Problem: - -The package contains decision helpers for feedback loss, unsaved writeback edits, gate-mode primary action, print shortcut behavior, and submit shortcut behavior. The production buttons call `state.submitFeedback()`, `state.approve()`, and `state.exit()` directly. - -Required behavior: - -- Before Send Feedback, Approve, or Close, the surface must evaluate extracted chrome/action decisions. -- Unsaved writeback edits must warn before close, approve, or send feedback. -- Approve must warn when feedback would be lost. -- Close must warn when feedback would be lost. -- Gate-mode annotate should approve when there is no feedback and send feedback when feedback exists. -- Submit shortcut behavior must use the same action decision as the primary button. - -Implementation shape: - -- Add a small action coordinator inside `DocumentReviewSurface` or as a package hook. -- Render a package-owned confirmation dialog using copy from `documentReviewChrome.ts`. -- Keep host APIs simple: hosts should receive the final approved submit/approve/exit call, not own these generic decisions. -- Use existing `buildUnsavedDocumentEditContinuationDecision`, `decideDocumentReviewFeedbackAction`, `decideDocumentReviewApproveAction`, `decideDocumentReviewExitAction`, `decideDocumentReviewPrimarySubmitAction`, and `buildUnsavedDocumentEditWarningCopy`. - -Acceptance: - -- Tests prove dirty writeback documents block direct submit/approve/close until confirmed. -- Tests prove approve warns when feedback would be lost. -- Tests prove close warns when feedback would be lost. -- Tests prove gate-mode empty annotate primary action calls approve. -- Manual Plan Review: add annotation, click Approve, see feedback-loss warning where old app warned. - -### 3. Reconnect Annotate-Last Message Workflow - -Problem: - -`createPlannotatorEditorLoadPlan()` builds `messages.recentMessages` and `messages.selectedMessageId`, and the Plannotator delivery layer can submit `selectedMessageId` and `feedbackScope`. The new production host does not pass or render `loadPlan.messages`, and `createFeedbackPayload()` only includes `messageScope` when it is manually injected. - -Required behavior: - -- Annotate-last must show the recent message set or an equivalent message navigation UI. -- The selected message must be visible and changeable. -- Annotations must stay associated with their selected message. -- Feedback must include `selectedMessageId`. -- If multiple messages are annotated, feedback must include `feedbackScope: "messages"` or the provider-neutral equivalent expected by the adapter. - -Implementation shape: - -- Prefer modeling messages as provider-neutral documents, if that can be done without distorting the contract. -- Otherwise add a narrow message session controller owned by `DocumentReviewSurface` or the Plannotator bridge. -- Cache annotations per message using existing linked-document/message state helpers where possible. -- Pass the resolved `messageScope` into `createFeedbackPayload()`. - -Acceptance: - -- Annotate-last opens with the selected recent message. -- Switching messages restores that message's annotations. -- Annotating one message sends that message id. -- Annotating multiple messages sends multi-message scope. -- Existing message feedback formatting remains unchanged. - -### 4. Reconnect External And Editor Annotations - -Problem: - -`useExternalAnnotations` and `useEditorAnnotations` still exist in `@plannotator/ui`, and server endpoints still exist. The document surface does not subscribe to them, does not show them in the panel, and does not include editor annotations in feedback. - -Required behavior: - -- External annotations posted to `/api/external-annotations` appear in Plan Review and Annotate where applicable. -- VS Code editor annotations appear when running inside VS Code. -- External annotation updates and deletes are reflected in the UI. -- Editor annotations can be deleted from the UI. -- Feedback/export includes editor annotations and external annotations in the same wording as before. - -Implementation shape: - -- Add optional host/provider annotation channels to `DocumentHostApi`, or provide Plannotator host hooks through surface slots if route names must remain host-owned. -- Keep route names and SSE transport Plannotator-owned. -- Merge external annotations into surface annotation state without duplicating persisted local annotations. -- Pass editor annotations into feedback text assembly. -- Reuse existing `AnnotationPanel`/`EditorAnnotationCard` behavior where possible. - -Candidate host API: - -```ts -interface DocumentHostApi { - watchExternalAnnotations?( - request: WatchExternalAnnotationsRequest, - ): ExternalAnnotationSubscription; - deleteExternalAnnotation?(request: DeleteExternalAnnotationRequest): Promise; - updateExternalAnnotation?(request: UpdateExternalAnnotationRequest): Promise; - loadEditorAnnotations?(request: LoadEditorAnnotationsRequest): Promise; - deleteEditorAnnotation?(request: DeleteEditorAnnotationRequest): Promise; -} -``` - -Acceptance: - -- Posting a plan-review annotation through `/api/external-annotations` shows it without reload. -- Deleting an external annotation removes it. -- VS Code editor annotations appear inside the document review feedback panel. -- Submitted feedback includes editor annotations. - -### 5. Restore Interactive Plan Diff Parity - -Problem: - -The new `DocumentVersionDiffViewer` renders read-only diff blocks. The old `PlanDiffViewer` supports clean/raw modes, block-level diff annotation, selected diff annotations, and `/api/plan/vscode-diff`. - -Required behavior: - -- Version diff supports diff annotations with `diffContext`. -- Diff annotations appear in the feedback panel and exported/submitted feedback. -- The VS Code diff button works for Plannotator plan versions. -- Provider-neutral hosts can choose whether an external diff action is available. - -Implementation shape: - -- Prefer reusing `@plannotator/ui/components/plan-diff/PlanDiffViewer` in the package renderer module. -- If direct reuse is too coupled, port the interaction model into `DocumentVersionDiffViewer`. -- Replace direct `fetch("/api/plan/vscode-diff")` with an optional host API action. - -Candidate host API: - -```ts -interface DocumentHostApi { - openDocumentVersionDiff?(request: OpenDocumentVersionDiffRequest): Promise; -} -``` - -Acceptance: - -- Plan Review with prior versions shows interactive diff blocks. -- Hovering/clicking changed diff blocks can create comments/deletions/quick labels. -- Diff annotations include `[In diff content]` in submitted feedback. -- VS Code diff opens through the Plannotator host when a base version exists. -- Workspaces can omit the external diff action without breaking diff review. - -### 6. Validate Saved Source Changes Before Delivery - -Problem: - -Saved source-change validation exists, but submit/approve does not call it. Old behavior protected against stale disk state, missing files, and no-op saved edits before feedback delivery. - -Required behavior: - -- Before submit or approve, saved changes must be validated when the provider supports probing. -- Stale, missing, and no-op saved changes must be dropped or warned according to existing decisions. -- Unverified changes must be preserved when validation cannot prove they are stale. - -Implementation shape: - -- Keep generic validation in `@plannotator/document-ui`. -- Keep local source-save probe logic in the Plannotator host/adapter. -- Route Plannotator `validateSavedFileChanges()` into the surface action coordinator before delivery. - -Acceptance: - -- Tests cover valid, stale, missing, no-op, and unavailable saved-change probes. -- Submit payload only includes valid/unverified saved changes. -- UI reports dropped saved changes clearly enough for the user to understand what happened. - -## P1 Feature Completeness Fixes - -### 7. Register Keyboard Shortcuts - -Required behavior: - -- `Mod+Enter` submits the primary action. -- `Mod+P` opens print while preserving print-mode CSS behavior. -- `Escape` exits plan diff when diff is active. -- Input-method double-tap shortcuts work where supported. -- Shortcuts respect dialogs, text input focus, editing state, and submitted/exiting states. - -Implementation shape: - -- Register the existing `planReviewSurface` and `annotateSurface` shortcut scopes in the new surface/host. -- Use extracted `decideDocumentReviewSubmitShortcut` and `decideDocumentReviewPrintShortcut`. -- Wire `usePrintMode()` in the mounted app. - -Acceptance: - -- Shortcut tests cover disabled states and text input focus. -- Manual smoke confirms `Mod+Enter` and `Mod+P`. - -### 8. Restore Code-File Popout And Code Annotations - -Required behavior: - -- Markdown/PFM code-file links can open the code-file popout. -- Code-file annotations can be created and submitted. -- Code path validation continues to run through the host. - -Implementation shape: - -- Pass `onOpenCodeFile` into `Viewer`. -- Mount `CodeFilePopout` from `@plannotator/ui`. -- Use existing `useCodeFilePopout()` and host API code-file loading. -- Keep local filesystem route details in Plannotator host/adapter. - -Acceptance: - -- Clicking a code-file link opens the popout. -- Creating a code annotation adds it to the panel. -- Submitted feedback includes code-file annotations. - -### 9. Restore Portable Raw HTML Sharing - -Required behavior: - -- Raw HTML annotation sessions shared through Export/Share use portable HTML with relative assets inlined. -- The display HTML used by the review iframe should not be assumed to be the share HTML. - -Implementation shape: - -- Add `prepareShareHtml?` to the host API, or keep it as a Plannotator header action helper. -- Plannotator implementation calls `/api/share-html`. -- Cache prepared share HTML per active HTML document where sensible. - -Acceptance: - -- Sharing an HTML file with relative images/styles produces a share that renders correctly outside the local server. -- Markdown sharing remains unchanged. - -### 10. Restore Wide/Focus And Chrome Polish Needed For Parity - -Required behavior: - -- Wide/focus mode is reachable when `allowWideMode` is enabled and unavailable in archive/diff states. -- Left and right panels behave consistently with wide/focus transitions. -- Sticky controls, panel collapse, resize behavior, and visible document max-width are close enough to old Plan Review/Annotate behavior for normal use. - -Implementation shape: - -- Wire `documentWideMode.ts`, `documentReviewLeftSidebar.ts`, and `documentReviewRightPanel.ts` into `DocumentReviewSurface`. -- Keep user preference persistence host-owned or option-driven. -- Avoid making Plannotator-only settings required by core document UI. - -Acceptance: - -- Wide/focus controls exist when enabled. -- Entering wide/focus hides panels and can restore previous layout. -- Diff/archive states do not leave the layout stuck. - -### 11. Sidebar And Reference Parity - -Required behavior: - -- The left sidebar should cover the core old navigation workflows: TOC, versions, file tree, and in-session archive/reference access if those remain expected in Plan Review. -- Folder annotate should show the file tree with badges and writeback status. -- `openSidebarTab` from the load plan must be honored. - -Implementation shape: - -- Keep generic sidebar mechanics in the package. -- Keep Obsidian vault discovery and archive storage Plannotator-host owned. -- Use slots for Plannotator-only archive/vault/reference tabs if they are not generic. - -Acceptance: - -- Folder annotate opens the files tab by default. -- Archive mode or archive tab behavior matches the decided scope. -- TOC is available for long markdown documents if parity requires it. - -### 12. Finish Settings/Header Integration - -Required behavior: - -- Settings opened from the restored header should have real AI provider data. -- App version should come from package/app metadata, not `0.0.1`. -- Agent instruction copy should be enabled if that feature remains supported. -- Tater/grid/user display settings should either work or be explicitly declared out of scope. - -Implementation shape: - -- Plannotator host owns these values and passes them to the header slot. -- The package only exposes slot props and surface state needed by host actions. - -Acceptance: - -- Settings AI tab shows available providers. -- Header About/version is correct. -- Agent instructions copy works or is intentionally removed with tests/docs updated. - -## P2 Cleanup And Explicit Non-Goals - -These items should not block the feature-complete cutover unless the user/product bar says otherwise: - -- Product announcement dialogs for Plan AI and Look & Feel. These are product-owned notices, not core document review behavior. -- Moving Plannotator adapter subpath exports out of `@plannotator/document-ui`. This is boundary cleanup, not a Plannotator parity blocker. -- Deleting dead `AppHeader.tsx` and other old shell remnants once no imports remain. -- Re-adding `VITE_DIFF_DEMO` fallback behavior. This is dev/demo-only. -- Full old visual chrome parity for every ornamental detail. Preserve workflow capability first, then polish. - -## Callback Scope Decision - -Shared/hash session callback support exists in `PlannotatorSharedSessionHost`. Normal API-mode callback query support was not found in the new production host path. - -Decision needed: - -- If `?cb=&ct=` was only a shared-session workflow, no P0 work is needed. -- If API-mode sessions still need callback approval/feedback, add callback config parsing to `PlannotatorDocumentSurfaceHost` and route submit/approve through the same callback utility. - -Acceptance if in scope: - -- API-mode callback URLs preserve feedback and approval behavior. -- Shared-session callback behavior remains unchanged. - -## Package Boundary Requirements - -The package should own generic document review behavior: - -- annotation lifecycle -- feedback assembly -- writeback state and writeback warnings -- draft restore -- document tree/navigation -- version diff and diff annotations -- shortcuts and generic chrome decisions -- generic code-file preview hooks if a host can load targets - -The host should own environment behavior: - -- Plannotator route names -- note apps -- share/paste policy -- app version and settings data -- agent terminal runtime -- local source-save probing -- VS Code diff route -- external annotation transport route names -- archive/vault storage mechanics - -The Workspaces integration should be able to implement `DocumentHostApi` without importing Plannotator local source-save concepts. Any new host API should use provider-neutral names such as writeback, versions, annotations, external diff, and prepared share HTML. - -## Test Plan - -Unit and integration tests: - -- `bun test packages/document-ui` -- `bun test packages/editor` -- `bun run typecheck` -- `bun run --cwd apps/hook build` -- `git diff --check` - -New or updated tests should cover: - -- Header actions visible without terminal. -- Submit/approve/close safety warnings. -- Gate-mode primary action decision. -- Annotate-last selected and multi-message feedback scope. -- External annotation subscription/update/delete merge behavior. -- Editor annotation feedback inclusion. -- Diff annotation creation and feedback inclusion. -- Optional external version diff host action. -- Saved-change validation before delivery. -- Shortcut registration and blocking states. -- Code-file popout open path and code annotation feedback. -- Raw HTML share HTML preparation. - -Manual smoke tests: - -- Plan Review from `ExitPlanMode`: menu, approve, deny/send feedback, diff, settings, export/share/import. -- Annotate markdown file: annotations, source save, saved-change validation, close warnings. -- Annotate folder: file tree, badges, open files, writeback statuses. -- Annotate raw HTML with relative assets: render, annotate, share. -- Annotate-last: select messages and submit one-message and multi-message feedback. -- VS Code mode if available: editor annotations and VS Code diff. -- External annotation API: post, update, delete while UI is open. - -## Implementation Order - -1. Fix header slots so Plan Review has the menu again. -2. Wire the action coordinator and confirmation dialogs. -3. Add saved-change validation into the same action path. -4. Reconnect annotate-last message state and `messageScope`. -5. Reconnect external/editor annotations. -6. Restore interactive plan diff and VS Code diff host action. -7. Register shortcuts and print mode. -8. Restore code-file popout/code annotations. -9. Restore portable raw HTML sharing. -10. Wire wide/focus/sidebar parity and settings polish. -11. Remove dead old shell leftovers after the feature-complete path is verified. - -## Scope Decisions - -- Recent messages stay as provider-neutral message review state for this PR, not as regular `DocumentRef` entries. The surface owns message navigation/cache behavior and the Plannotator adapter maps it into annotate-last delivery. -- Standalone archive host is enough for this PR. In-session archive/vault/reference sidebar tabs remain P1/product-scope work. -- API-mode callback support is not required for this cutover. The old header only rendered callback actions for non-API shared sessions, and shared/hash callback support remains preserved. -- Restore workflow parity, not pixel-perfect old chrome. -- External/editor annotations are generic optional `DocumentHostApi` watch/delete capabilities, with Plannotator route names kept inside the Plannotator HTTP adapter. - -## Implementation Status: 2026-06-22 - -Completed in the current worktree: - -- Header actions are mounted in Plan Review without requiring annotate terminal support. -- Send Feedback, Approve, Close, and primary submit paths now route through package-owned action safety checks and confirmation dialogs. -- Saved source-file changes are validated before submit/approve, with stale, missing, and no-op changes filtered before delivery. -- Annotate-last message state is surfaced through a message navigator, cached per message, and submitted with `messageScope` and `messageAnnotations`. -- External annotations and VS Code editor annotations are exposed through a provider-neutral annotation watch/delete host API, rendered in the surface, and included in feedback assembly. -- Plan version diffs use the interactive plan diff viewer again, restoring block annotations and the Plannotator VS Code diff affordance. -- Basic production shortcuts and print behavior are restored for `Mod+Enter`, `Mod+P`, and diff `Escape`. -- Input-method switching is package-owned again: the surface owns mutable `drag`/`pinpoint` state and registers the existing Alt hold / Alt Alt input-method hook. -- Code-file links can open the code-file popout and create code-file annotations through the package surface. -- Raw HTML export/share preparation calls the Plannotator `/api/share-html` route before falling back to display HTML. -- Wide/focus controls are exposed by the package surface when `allowWideMode` is enabled, Plannotator enables them in the production bridge, and active wide/focus mode hides side panels until exit. -- Header settings now receive real AI provider capability data, the app version value, and enabled agent-instruction copy behavior. -- The unused old-shell `packages/editor/components/AppHeader.tsx` file and stale read-only diff renderer helpers were removed. -- Callback compatibility was audited: the old header only rendered bot callback actions for non-API shared sessions, and shared/hash callback sessions remain handled by `PlannotatorSharedSessionHost`. - -Verified: - -- `bun test packages/document-ui` -> 357 pass, 0 fail. -- `bun test packages/editor` -> 64 pass, 7 skip, 0 fail. -- `bun run typecheck` -> pass. -- `bun run --cwd apps/hook build` -> pass. -- `git diff --check` -> pass. -- Vite dev server smoke: `bun run --cwd apps/hook dev --host 127.0.0.1` served `http://127.0.0.1:3000/`, `/api/plan`, and `/api/plan/versions` successfully. -- Playwright Chromium smoke against the Vite-rendered production app path passed for: - - Plan Review header menu, Settings menu item, interactive diff view, wide-mode toggle, external annotation display, editor annotation display, print shortcut, and approve delivery. - - Plan Review share-link copy, global-comment creation, and deny/send-feedback delivery through `/api/deny`. - - Annotate markdown source-save edit/save via `/api/source/save`. - - Annotate folder document-tree expansion and `/api/doc` navigation. - - Annotate raw HTML share/export preparation via `/api/share-html`. - - Annotate-last multi-message navigator rendering. -- Playwright Chromium SSE smoke passed for browser consumption of `/api/external-annotations/stream` snapshot events. - -Additional focused checks after wide/focus, input-method, raw HTML share, code-file URL, and cleanup wiring: - -- `bun test packages/document-ui/DocumentReviewSurface.test.tsx packages/document-ui/DocumentReviewSurface.interaction.test.tsx packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx` -> 36 pass, 0 fail. -- Interaction coverage now dispatches `Mod+Enter`, `Mod+P`, `Alt`, and wide-mode clicks against a mounted DOM surface. -- Bridge coverage now verifies `/api/share-html` is called for raw HTML export/share and falls back safely when portable HTML is unavailable. -- Code-file coverage now verifies the popout `/api/doc` URL boundary uses the target path and active document base. -- `bun run typecheck` -> pass. - -Still open or requiring manual confirmation: - -- Old sidebar/reference parity is intentionally not a P0 blocker for this PR under the current scope decision: standalone archive host is enough, while in-session archive/vault/reference tabs remain P1/product-scope work. -- Host-only integrations still need manual confirmation in their native environments: the VS Code extension/editor-annotation producer and real external-annotation producers. The browser-rendered consumer paths are covered by the Playwright smoke above. diff --git a/adr/specs/document-ui-parity-cutover-20260621-121115.md b/adr/specs/document-ui-parity-cutover-20260621-121115.md deleted file mode 100644 index 85c96046a..000000000 --- a/adr/specs/document-ui-parity-cutover-20260621-121115.md +++ /dev/null @@ -1,467 +0,0 @@ -# Spec: Document UI Parity Cutover - -> ⚠️ **REVERTED — DO NOT IMPLEMENT.** Spec for the failed cutover (reverted 2026-06-22). The corrected plan is **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`**. Kept here as history only. - -Date: 2026-06-21 - -Status: Draft - -## Intent - -Finish the `@plannotator/document-ui` extraction so the Plan Review / Annotate app uses the package as the real production document surface, with no parallel legacy document UI path left behind. - -The target is not to move every Plannotator feature into the shared package. The target is to move the reusable document-review experience into the package, then leave Plannotator-specific environment behavior in a small host shell. - -## Target State - -`packages/editor/App.tsx` should stop being the document-review product. It should become a Plannotator host shell that: - -- loads the session through the Plannotator adapter -- reads Plannotator settings and environment capabilities -- wires Plannotator-only routes and side effects -- provides host slots for settings, export/share, note integrations, archive, goal setup, and terminal -- renders `DocumentReviewSurface` - -The normal production path should not require: - -```text -VITE_DOCUMENT_SURFACE=1 -``` - -`VITE_DOCUMENT_SURFACE` should be removed once parity is reached. - -## Ownership Rule - -The package owns the document review loop. - -The host owns environment policy. - -### Package owns - -- markdown and raw HTML document review -- annotation creation, editing, deletion, selection, and persistence hooks -- global comments and image attachments -- linked document navigation -- document tree/file tree UI and badges -- message/document navigation when messages are represented as documents -- source/document editing UI -- writeback states: clean, dirty, saving, saved, conflict, missing, error -- draft restore UI and state -- feedback payload assembly -- plan/document version browsing and diff UI -- generic Ask AI panel and in-document ask affordances when a host AI API exists -- code/link preview UI when the host can load or validate targets -- generic shortcuts for document review actions -- default chrome needed for parity: toolstrip, sticky controls, sidebars, panels, empty states, banners, and action buttons - -### Host owns - -- server routes -- auth -- browser opening and process lifetime -- CLI/plugin/hook integration -- `ExitPlanMode` stdout decisions -- Plannotator settings persistence -- share/paste service policy -- import/export modal policy -- Obsidian, Bear, and Octarine integrations -- agent terminal runtime, PTY/WebSocket bridge, installer, and remote security policy -- goal setup business logic -- archive storage and list loading -- provider transport details for comments, versions, documents, and watches - -## Required Work - -### 1. Make `DocumentReviewSurface` the default app surface - -Remove the feature-flagged bridge as a separate product path. - -Current state: - -- `packages/editor/App.tsx` computes `USE_DOCUMENT_SURFACE`. -- The package surface is only rendered when the flag is enabled. -- The old app shell remains the default render path. - -Required changes: - -- Replace the default editor render path with the package surface. -- Keep a thin Plannotator host shell, but do not keep both document-review implementations. -- Delete `shouldUseDocumentSurfaceBridge()` and the `VITE_DOCUMENT_SURFACE` runtime branch after parity is green. -- Move or delete old `App.tsx` document-domain state that duplicates package state. - -Acceptance: - -- Running the normal app with no env flag renders `DocumentReviewSurface`. -- `rg VITE_DOCUMENT_SURFACE packages apps` returns no production code hits. -- `packages/editor/App.tsx` no longer imports or directly orchestrates `Viewer`, `HtmlViewer`, `PlanDiffViewer`, `AnnotationPanel`, `usePlanDiff`, `useLinkedDoc`, or `useArchive` for the main document path. - -### 2. Add provider-neutral versions and diff - -Plan diff/version browser is the biggest package gap. It should move into `@plannotator/document-ui` as optional document version capability. - -Add host API methods: - -```ts -interface DocumentHostApi { - listDocumentVersions?(request: ListDocumentVersionsRequest): Promise; - loadDocumentVersion?(request: LoadDocumentVersionRequest): Promise; -} -``` - -Draft types: - -```ts -interface DocumentVersionRef { - id: string; - label: string; - createdAt?: number; - revision?: string; - providerState?: unknown; -} - -interface DocumentVersionsResult { - versions: DocumentVersionRef[]; - currentVersionId?: string; - previousVersionId?: string; - providerState?: unknown; -} - -interface LoadedDocumentVersion { - version: DocumentVersionRef; - document: LoadedDocument; -} -``` - -Package behavior: - -- fetch and show versions when `session.capabilities.supportsVersions` is true -- select a base version -- compute markdown diffs in the package, using existing diff utilities -- render clean/raw diff modes -- support diff annotations -- block version/diff actions while document editing is dirty -- expose version state through render props for custom hosts - -Plannotator adapter: - -- map `/api/plan/versions` -- map `/api/plan/version` -- use existing `previousPlan` and `versionInfo` as initial version data when available - -Workspaces adapter expectation: - -- map workspace document versions API -- use workspace document ids and versions, not local history paths - -Acceptance: - -- Plan review with previous versions shows the same diff affordance as the old app. -- Version browser works from the package surface. -- Diff annotations are included in feedback with the current legacy wording. -- Workspaces can implement the version API without Plannotator route names. - -### 3. Bring default chrome to visible parity - -The current default `DocumentReviewSurface` chrome works, but it is simpler than the old shell. The package surface needs parity for the generic document review experience. - -Move or recreate in package: - -- annotation toolstrip -- sticky header lane behavior -- wide/focus document controls -- document max-width behavior -- raw HTML tool visibility toggle -- folder empty state -- linked document breadcrumb/back chrome -- message picker as document navigation, if message mode remains supported -- feedback panel count and delete/edit behavior -- right panel resize/collapse behavior -- left sidebar collapsed rail and tab behavior -- keyboard shortcuts for submit, print, diff exit, save, and panel/sidebar toggles -- code-file/link preview when the host can load the target -- checkbox override behavior if editable checkboxes remain part of rendered markdown review - -Keep host-owned: - -- user preference storage -- Plannotator-specific issue/help links -- product-specific header menu -- print side effect -- settings modal - -Acceptance: - -- Annotate markdown, annotate raw HTML, annotate folder, annotate last message, and plan review do not visibly regress from the old app for core review actions. -- Default package UI has no obvious missing document controls compared with the old app. -- Package surface remains usable without Plannotator-specific settings or note integrations. - -### 4. Turn file/message browsing into provider-neutral document navigation - -The package already has document tree state. It needs to become the real default file/message navigation path. - -Required changes: - -- Treat folders, files, and recent messages as `DocumentTreeNode` / `DocumentRef` data. -- Let Plannotator adapter map `/api/reference/files` and `/api/reference/files/stream` to `listDocuments` and optional watch behavior. -- Let Workspaces adapter map workspace manifest rows to the same tree. -- Preserve annotation counts and writeback status badges in the tree. -- Preserve highlighted/annotated file behavior where it is generic. -- Keep local filesystem containment and vault retry mechanics in the Plannotator adapter/host. - -Acceptance: - -- Annotate-folder can select markdown, text, and raw HTML files through the package surface. -- File annotation counts survive navigation. -- Writeback statuses show on tree rows. -- Message mode can navigate recent assistant messages without bespoke `App.tsx` state. - -### 5. Finalize writeback and local source-save cutover - -The provider-neutral writeback core is mostly done. The remaining work is to stop the old shell from applying separate source-save state. - -Required changes: - -- Route all active document edit/save/discard/reload-conflict behavior through package writeback state. -- Keep Plannotator source-save behavior inside `plannotator-*` adapter helpers. -- Ensure missing local files, disk conflicts, stale saved changes, and draft-restored edits behave the same as the old path. -- Remove duplicate editor/source-save state from `App.tsx` after package behavior is authoritative. - -Acceptance: - -- Saving source-backed markdown/text files works from the package surface. -- Dirty, saving, saved, conflict, missing, and error states match current semantics. -- Draft restore preserves dirty writeback buffers and saved-change context. -- No generic shared type requires disk hash, mtime, EOL, or filesystem path. - -### 6. Move Ask AI surface behavior into the package - -The package already has Ask AI context helpers and `hostApi.askAI`. It needs the UI path if parity requires the package surface to replace the old shell. - -Required changes: - -- Add a default AI panel when `session.capabilities.canUseAskAI` and `hostApi.askAI` are available. -- Use package-owned document context assembly. -- Support document-targeted ask from comments or selected document regions. -- Let the host provide provider/model settings and permission handling. -- Keep terminal fallback and agent-specific prompt policy host-owned. - -Possible host API extension: - -```ts -interface DocumentHostApi { - askAI?(request: DocumentAskAIRequest): Promise | AsyncIterable; - listAIProviders?(): Promise; - respondToAIPermission?(response: DocumentAIPermissionResponse): Promise; -} -``` - -Acceptance: - -- The old Ask AI panel can be replaced for document review sessions. -- Hosts without AI do not see AI UI. -- Provider/model/auth policy does not leak into core document types. - -### 7. Keep agent terminal as a host slot, but finish integration points - -Do not move the terminal runtime into `@plannotator/document-ui`. - -Required changes: - -- Keep `terminalPanel` or a refined terminal slot in `DocumentReviewSlots`. -- Let package chrome show/hide terminal entry points when the host provides a terminal slot/capability. -- Keep generic agent-delivery state in the package. -- Keep PTY, WebSocket, runtime install, remote-mode security, and shell prompt construction in Plannotator host code. - -Acceptance: - -- Annotate-mode terminal can be mounted beside the package document surface. -- Package can show delivered-to-agent status without knowing terminal transport details. -- Workspaces is not forced to implement a terminal. - -### 8. Handle archive without making it core document review - -Archive is Plannotator-specific storage, but it still needs a path after `App.tsx` is shrunk. - -Required changes: - -- Do not make archive mandatory in `DocumentHostApi`. -- Expose enough slot support for a host archive tab or collection browser. -- Plannotator host owns archive plan loading, selection, copy, and done behavior. -- Archive selection can load a read-only `LoadedDocument` into the package surface or render through a host-provided archive mode. - -Acceptance: - -- Plannotator archive mode still works after old `App.tsx` document shell is gone. -- Archive does not appear in Workspaces unless Workspaces opts into a comparable collection provider. - -### 9. Keep goal setup host-owned - -Goal setup is not document review. It should not become core package behavior. - -Required changes: - -- Render goal setup from the Plannotator host shell, not the legacy document shell. -- Keep `GoalSetupSurface` in its current package unless a later decision moves it. -- Ensure goal setup submit/exit still uses shared action-controller helpers only where useful. - -Acceptance: - -- Goal setup works without the old document-review render path. -- `@plannotator/document-ui` does not need goal setup-specific public types. - -### 10. Keep settings, share, export, and note integrations host-owned - -These are Plannotator product policies, not shared document review behavior. - -Required changes: - -- Package exposes current feedback payload/rendered feedback through callbacks or render state. -- Host uses that payload for export/share/import and note integrations. -- Host injects header/menu actions through slots. -- Package does not call paste service, Obsidian, Bear, or Octarine routes. - -Acceptance: - -- Export/share/import still work in Plannotator. -- Note-app saves still work in Plannotator. -- Workspaces can ignore these features or provide its own host actions. - -### 11. Finalize annotation provider integration - -Current package annotation persistence is a good base. Full parity needs live provider annotations to stop being old-app-specific. - -Required changes: - -- Keep `loadAnnotations` and `saveAnnotations`. -- Add optional watch/subscribe support if live updates are required: - -```ts -interface DocumentHostApi { - watchAnnotations?(request: WatchDocumentAnnotationsRequest): DocumentAnnotationSubscription; -} -``` - -- Package owns merging local draft annotations with provider-owned annotations. -- Host/provider owns external transport, SSE route names, VS Code editor annotation routes, and permission policy. - -Acceptance: - -- External/provider annotations can appear in the package surface. -- Editing or deleting provider annotations routes through the provider where appropriate. -- Hosts without live annotations still work through load/save/draft behavior. - -### 12. Cut down `packages/editor/App.tsx` - -After parity lands, remove old document-product orchestration. - -Keep in editor host shell: - -- load session -- build Plannotator host API -- read settings -- wire Plannotator host slots -- render completion overlay -- render modals owned by Plannotator -- handle plan-mode and annotate route policy - -Remove from editor host shell: - -- document annotation reducer -- linked-doc state machine -- plan diff state machine -- archive document rendering path -- file/message document navigation state -- markdown/html viewer rendering -- document edit/writeback UI state -- direct document feedback assembly -- duplicate draft restore logic - -Acceptance: - -- The old document body path is gone. -- The file is understandable as a host shell, not a product state machine. -- Any remaining Plannotator-specific code has a clear reason to stay host-owned. - -## Dependency Order - -1. Add missing package contracts: versions/diff, optional annotation watch, refined slots. -2. Move version/diff state and rendering into the package. -3. Bring package chrome to visible parity for toolstrip, sidebars, panels, file/message navigation, and code previews. -4. Wire Plannotator adapter to the new contracts. -5. Move Ask AI surface behavior into the package, keeping provider config host-owned. -6. Mount terminal/archive/settings/export/note integrations through host slots. -7. Flip default app path to `DocumentReviewSurface`. -8. Delete old duplicate editor document state. -9. Run parity verification and fix regressions. - -## Verification - -Minimum automated checks: - -```text -bun test packages/document-ui -bun run typecheck -bun build packages/document-ui/DocumentReviewSurface.tsx --target browser --outdir /tmp/plannotator-document-ui-build -bun run --cwd apps/hook build -bun run --cwd apps/review build -git diff --check -``` - -Browser smoke checks: - -- plan review approve -- plan review deny with annotations -- plan diff/version browser -- annotate markdown file -- annotate raw HTML file -- annotate folder and switch files -- annotate last message and switch messages -- linked markdown document navigation -- code-file/link preview -- image upload and image display -- source-save success -- source-save conflict -- source file missing and save/recreate behavior -- draft restore after reload -- Ask AI open/ask/permission if enabled -- agent terminal slot open/close/delivered status -- archive browse/done -- export/share/note actions - -## Non-Goals - -- Do not redesign Plannotator's visual language. -- Do not move server route implementations into the package. -- Do not rename current Plannotator routes as part of this cutover. -- Do not make Workspaces adapter code live in this repo. -- Do not make local source-save terms part of the provider-neutral core. -- Do not move terminal runtime or note-app policy into the package. -- Do not keep both old and new document UI paths after cutover. - -## Open Decisions - -1. Should archive be represented as a host-provided document collection API, or only as a Plannotator host slot? - - Recommendation: host slot for this cutover. Add a collection API later only if Workspaces has a matching need. - -2. Should Ask AI provider/model settings be shown inside the shared package panel or injected by host slot? - - Recommendation: package owns the panel shell and messages; host injects provider settings/actions. - -3. Should goal setup remain in `@plannotator/ui` or move to another host package? - - Recommendation: leave it where it is for this cutover. The important thing is that it no longer depends on the old document shell. - -4. Should package version/diff compare be host-computed or package-computed? - - Recommendation: host loads versions; package computes markdown diff by default. Add optional host-computed diff only if Workspaces needs semantic/version-specific compare results. - -## Completion Criteria - -This work is complete when: - -- The normal Plan Review / Annotate app renders through `@plannotator/document-ui`. -- There is no `VITE_DOCUMENT_SURFACE` cutover flag. -- The old document-review render path is removed. -- Plan review, annotate file, annotate folder, annotate last, raw HTML, linked docs, source-save, drafts, plan diff, Ask AI, terminal slot, archive, export/share, and note integrations all still work. -- Workspaces can implement the same UI by supplying a `DocumentHostApi` without inheriting Plannotator local source-save vocabulary. diff --git a/adr/specs/publish-core-package-20260623-125551.md b/adr/specs/publish-core-package-20260623-125551.md deleted file mode 100644 index 608e8dc6f..000000000 --- a/adr/specs/publish-core-package-20260623-125551.md +++ /dev/null @@ -1,92 +0,0 @@ -# Spec: Carve `@plannotator/core` + Publish (Phase 7) - -Date: 2026-06-23 · Status: Draft (iterate before implementing) - -> Implementation spec for Phase 7. Grounded in `SPIKE-publish-core-package-20260623-125551.md` + its synthesis. Decision: single source of truth (no copying). THE LAW: Plannotator stays byte-for-byte unchanged through the carve; the publish is the one outward-facing step — confirm with the user before pushing to any registry. - -## Scope -**In:** create `@plannotator/core`, move the universal slice + extract types from node-bound modules, shim `@plannotator/shared`, re-point `@plannotator/ui`, move `wideMode.ts`, complete the settings provider (`loadFromBackend`, prefetch+sync), add a single `configurePlannotatorUI()` front door, ship a (required) precompiled CSS bundle, fix the 2 override-path bugs + add per-seam override tests, prep + (on go-ahead) publish `core` + `ui`. -**Out / stays private:** `@plannotator/shared` (Node/git/server grab-bag) and `@plannotator/ai` (ui only needs the `AIContext` type via core). - -## Step 1 — Create `packages/core` -- `packages/core/package.json`: `name @plannotator/core`, `version 0.21.0` (lockstep with the repo, per ADR 007), `type module`, source-only `exports` map (fine-grained subpaths like `ui`), `files` allowlist (`*.ts`, exclude tests), **no dependencies** (peerDeps none — it's pure JS/Web-API). tsconfig mirroring `ui` (bundler resolution, isolatedModules, allowImportingTsExtensions). -- Add `@plannotator/core` to root `workspaces` (already covered by `packages/*`). -- **Move (git mv) the ~15 pure modules** from `packages/shared` → `packages/core`: `code-file`, `extract-code-paths`, `agents`, `agent-jobs`, `compress`, `crypto`, `external-annotation`, `favicon`, `feedback-templates`, `goal-setup`, `browser-paths`, `project`, `agent-terminal`, `open-in-apps`, `source-save`. (Confirm each is node-free at move time.) -- **Extract types** from the node-bound modules into core type files: `core/config-types.ts` (DefaultDiffType, DiffLineBgIntensity, DiffOptions, …), `core/storage-types.ts` (ArchivedPlan), `core/workspace-status-types.ts` (WorkspaceFileChange, WorkspaceStatusPayload, GitRepositoryInfo). Plus any review types `ui`'s `@plannotator/shared/types` surfaces (verify `review-core`/`review-workspace` usage). -- `core/ai-context.ts`: re-export `AIContext` (move or re-export the pure type from `packages/ai/types.ts`; confirm it's node-free). -- `core/index.ts`: barrel re-export. - -## Step 2 — Re-point `@plannotator/shared` (keeps Plannotator unchanged) -- For each **moved pure module**, replace its `packages/shared/X.ts` with a one-line shim: `export * from '@plannotator/core/X';`. Keep `shared`'s `exports` map and `private:true` as-is. -- For each **node-bound module** (`config`, `storage`, `workspace-status`), change its in-file type definitions to **import the types from `@plannotator/core/*-types`** and re-export them, keeping the node implementation. (Types now live once, in core.) -- Add `@plannotator/core: "workspace:*"` to `packages/shared` deps. -- **Verify:** all 99 existing `@plannotator/shared/X` import sites still resolve unchanged; Pi `vendor.sh` needs no edit (vendors the shims). - -## Step 3 — Re-point `@plannotator/ui` -- Change every `ui` import of `@plannotator/shared/X` → `@plannotator/core/X`, and `import type { AIContext } from '@plannotator/ai'` → `@plannotator/core`. -- In `packages/ui/package.json`: remove the `@plannotator/shared` and `@plannotator/ai` `workspace:*` deps; add `@plannotator/core: "workspace:*"`. (After publish this becomes a real version range.) -- **Verify:** `grep @plannotator/shared` and `@plannotator/ai` in `packages/ui` (non-test) returns **zero** — ui depends only on `@plannotator/core` internally. - -## Step 4 — Move `wideMode.ts` -- `git mv packages/editor/wideMode.ts packages/ui/utils/wideMode.ts` (+ its test). Update the 2 importers (`editor/App.tsx`, the test) to `@plannotator/ui/utils/wideMode`. - -## Step 5 — Single config front door (`configurePlannotatorUI`) -The reuse surface currently has **9 global host-override switches** scattered across modules: `setImageSrcResolver`, `setStorageBackend`, `setDocPreviewFetcher`, `setFileTreeBackend`, `setIdentityProvider`, `setDraftTransport`, `setExternalAnnotationTransport`, `setAITransport`, and `configStore.setServerSync`. A consumer shouldn't have to discover and call each. -- Add **one new file** `packages/ui/configure.ts` exporting a typed `PlannotatorUIConfig` and `configurePlannotatorUI(config: PlannotatorUIConfig)` that fans out to those 9 setters (each field optional → only the provided ones are applied). Add to the `ui` `exports` map. -- **Zero risk / additive:** Plannotator never calls it, so nothing changes; the existing setters keep working individually. The per-component prop seams (vscode-diff, save-to-notes, obsidian-detect, version fetchers, editor `mode`, code-path toggle, `ScrollViewportProvider`) are intentionally NOT in the global front door — they're passed where the host renders those components. -- **Later (optional):** migrate the render-time seams to a `` (React context) if Workspaces wants per-instance config / SSR. The `configure()` facade is the 80/20 now; the Provider is the door it leaves open. -- **Verify:** typecheck; a tiny test that `configurePlannotatorUI({...})` routes to each setter; Plannotator behavior unchanged (it never calls it). - -## Decisions locked (post-interrogation, 2026-06-23) -- **Ship TS source for the JS, NOT a compiled build.** Rationale: the only consumer (Workspaces) is internal and on a controlled stack (Vite/Cloudflare). A `tsup`/lib build exists only to insulate unknown/arbitrary-toolchain consumers — that insulation buys ~nothing here, and shipping source avoids a build pipeline to maintain and avoids a `dist` artifact that can drift from what Plannotator actually runs. Door stays open: add a build later if/when an external consumer appears. (Contested in review — one reviewer assumed a public lib; this is the deliberate call for the internal case.) -- **Precompiled CSS is REQUIRED, not optional** (Step 6). Even internally, the `@source` glob into `node_modules/@plannotator/ui/**/*.tsx` is fragile (pnpm symlinks break it) and a per-build perf cost. Ship the stylesheet. -- **`@plannotator/core` gets a node-free CI typecheck** (Step 1) so a stray `node:*` import fails the build — turns "confirm node-free by hand" into an enforced invariant. -- **Pin `@plannotator/ui` → `@plannotator/core` to an EXACT version** (not a range) during 0.x, so a consumer can't end up with mismatched copies (and silently diverge the annotation serializers). - -## Step 6 — Precompiled CSS bundle (REQUIRED) -Tailwind-utility components force the consumer to either scan our source (`@source`) or get a ready-made stylesheet. Ship the stylesheet — the `@source` route is fragile (pnpm symlinks) and costs every consumer build time. -- Add a CSS-only build that emits a single precompiled `@plannotator/ui/styles.css` (theme tokens + the component utility classes). This is a CSS pipeline only — the JS still ships as source (per the decision above). -- Keep the `@source` glob documented as the fallback for a consumer who wants to scan source, but the stylesheet is the supported default. -- **Verify:** the precompiled CSS renders Plannotator-identical visuals in a bare consumer; Plannotator's own build/styling untouched. - -## Step 7 — Publish (OUTWARD-FACING — confirm first) -- JS ships as **source** (no build); CSS ships **precompiled** (Step 6). `core` + `ui` `exports` stay source-only for `.ts`/`.tsx`, plus the `styles.css` entry. -- Decide registry (recommend **public npm**, matching existing flow), versions (recommend **0.1.0**, core+ui together), with `ui`→`core` pinned **exact**. -- Write/READMEs documenting consumer requirements: `moduleResolution: bundler`, `allowImportingTsExtensions`, `isolatedModules`, `jsx: react-jsx`, React 19, and **import `@plannotator/ui/styles.css`** (the `@source` glob is the documented fallback, not the default). -- Add a publish job to `.github/workflows/release.yml` for `core` + `ui` (or publish manually the first time: `bun pm pack` each, `npm publish *.tgz --access public`). bun resolves `workspace:*` → real versions at pack time. -- **Do not run the publish until the user explicitly approves** the registry + version + go. - -## Carried-over review fixes (do before publish; NOT Phase-7 architecture) -These are small bugs/gaps the interrogation found in already-committed Phase-5 code. None affect Plannotator (override-path only); fix before a real consumer wires the seams: -1. **`useExternalAnnotations` split-transport** — the effect captures `transport` at mount for subscribe/poll, but the CRUD callbacks read the module global live → reads and writes can hit different backends if the transport is set after mount. Read consistently in both paths. (Check `useFileBrowser` for the same shape.) -2. **`useExternalAnnotations` `fallbackRef`/`receivedSnapshotRef` not reset on effect re-run** — if `enabled` toggles false→true (Workspaces auth/loading), the hook silently stops updating. Reset both at the top of the effect. -3. **Override path untested** — add one small test per seam that calls `setX(fake)`, drives the hook/component, asserts the contract, then `resetX()`. Makes the dead `reset*()` functions live and pins the subtle contracts (draft generation, SSE fallback). -4. **(in scope — Workspaces needs it)** Complete the settings provider: `setStorageBackend` only redirects setting *writes*; the initial *load* runs against cookies at module-init. Workspaces uses the same UI settings stored in its own backend → add `loadFromBackend()`. Model: **prefetch + synchronous backend** (host fetches settings → installs a sync backend serving from that data → calls `loadFromBackend()`); no async plumbing in `configStore`; Plannotator's eager cookie default unchanged (never calls it). - -## Definition of done (Phase 7) -- `@plannotator/core` exists, browser-safe, zero deps; the universal slice lives there once; **CI typechecks it node-free** (no `@types/node`). -- `@plannotator/shared` re-exports from core; Plannotator byte-unchanged (full `bun test` 1620/0, typecheck, builds, shipped-bundle hashes identical; `git diff` limited to core/shared/ui/editor packaging + import re-points). -- `@plannotator/ui` depends only on `@plannotator/core` internally, **pinned exact**; JS ships as source; installs standalone (with `core`). -- `wideMode.ts` relocated. -- **`configurePlannotatorUI()` exists** as the single typed front door over the 9 global setters; Plannotator unchanged (never calls it). -- **Precompiled CSS (`@plannotator/ui/styles.css`) shipped** (required). -- The carried-over review fixes (split-transport, fallbackRef reset, per-seam override tests) are done. -- Consumer requirements documented; publish job ready. -- (On explicit go) `core` + `ui` published; Workspaces can `npm install @plannotator/ui @plannotator/core`, call `configurePlannotatorUI({...})` once, import `@plannotator/ui/styles.css`, and build. - -## Parity guardrail (run after the carve, before publish) -`bun run typecheck` · `bun test` 1620/0 · `bun run --cwd apps/review build && bun run build:hook && bun run build:opencode` · shipped-bundle hashes vs the Phase-0 baseline (should be identical) · `git diff` confined to the expected packages · Pi `vendor.sh`/typecheck still green. - -## Decided (locked in ADR 007) -- **Registry: public npm.** -- **Versions: lockstep at repo version `0.21.0`; `ui`→`core` pinned exact.** -- **JS ships as source, not a build** (single internal consumer on a controlled stack). -- **Precompiled CSS required.** -- **`core` CI typecheck node-free.** -- **`@plannotator/ai` stays unpublished-to-npm** (`private:true`; UI doesn't need it — only `AIContext`, re-exported via `core`). -- **Settings provider completed** (`loadFromBackend()`, prefetch+sync) — in scope. -- **CI publish job wired**, but **validate artifacts on the branch first** (`bun pm pack` + inspect + `npm publish --dry-run`) before merge; first real publish gated on explicit go. - -## Still to verify at implementation -- `review-core`/`review-workspace` type handling (whether `ui`'s `@plannotator/shared/types` surfaces any node-bound review types → extract if so). -5. In-scope or not: `configStore.loadFromBackend()` (only if Workspaces wants its own settings persistence). diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 000000000..13af7c39f --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1,53 @@ +# @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({ + storage, // where settings persist + identity, // who the current user is + imageResolver, // how image paths resolve to URLs + docPreviewFetcher, + fileTreeBackend, + draftTransport, + externalAnnotations, // live/agent comments + aiTransport, + serverSync, +}); +``` + +Anything you don't pass keeps Plannotator's default. A few component-specific overrides (e.g. an "open in editor" diff action) are passed as props where you render that component. + +## Consuming it (e.g. from Workspaces) + +```bash +npm install @plannotator/ui @plannotator/core +``` + +1. Call `configurePlannotatorUI({ ... })` once at startup with your backend. +2. Import the stylesheet: `import "@plannotator/ui/styles.css";` (precompiled — no Tailwind wiring needed; the `@source` glob is the fallback if you'd rather scan source). +3. Import components: `import { Viewer } from "@plannotator/ui/components/Viewer";` +4. Build with a bundler that compiles TS/TSX (Vite + React 19 + Tailwind v4). The packages ship **source**, so your bundler compiles them — set `moduleResolution: "bundler"`, `allowImportingTsExtensions`, `jsx: "react-jsx"`. + +## Packages & publishing + +- `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published. +- `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on `@plannotator/core` (exact-version lockstep). Published. +- `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched. +- Versioned in lockstep with the repo. Publish `core` then `ui` together with **`bun publish`** (not `npm` — bun resolves `workspace:*` to the exact version at pack time). + +## The one rule + +**Do not reimplement the document UI from scratch.** A prior from-scratch rewrite broke the app and was reverted. The supported path is always: keep these components as-is and add a seam where a host needs different backend behavior. Never delete working Plannotator code until a human has confirmed parity in the browser. From 9359b5c01472fcb6d3e4234d44b5c81b9ac1c470 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 19:03:31 -0700 Subject: [PATCH 42/46] docs(ui): add packages/ui/AGENTS.md guardrail + CLAUDE.md symlink Directory-scoped agent guidance for anyone editing @plannotator/ui: don't rewrite from scratch, add a seam (default = today's behavior, Plannotator byte-for-byte unchanged), core stays node-free, never delete working code until human parity. Points to README.md for the architecture. CLAUDE.md -> AGENTS.md symlink mirrors the repo root convention. --- packages/ui/AGENTS.md | 10 ++++++++++ packages/ui/CLAUDE.md | 1 + 2 files changed, 11 insertions(+) create mode 100644 packages/ui/AGENTS.md create mode 120000 packages/ui/CLAUDE.md 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 From 0fc3fcd006c30fd1946746c3e3bfdda98c9a72ff Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 19:20:19 -0700 Subject: [PATCH 43/46] build: remove madge circular-dep check (unmaintained) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit madge is unmaintained (~3 years stale) and the check was never wired into CI, so it was a dormant script + devDependency on a load-bearing path. Drop it: remove the check:cycles script, the madge devDependency, and .madgerc. The no-cycle invariant still holds by construction — @plannotator/core imports nothing (zero @plannotator deps in its package.json), so any accidental core->shared/ui import fails at publish-time bun pm pack (and review). No automated tripwire, but no stale unmaintained tooling either. --- .madgerc | 1 - bun.lock | 235 ++++++--------------------------------------------- package.json | 6 +- 3 files changed, 26 insertions(+), 216 deletions(-) delete mode 100644 .madgerc diff --git a/.madgerc b/.madgerc deleted file mode 100644 index b86f0df03..000000000 --- a/.madgerc +++ /dev/null @@ -1 +0,0 @@ -{ "extensions": ["ts", "tsx"], "fileExtensions": ["ts", "tsx"] } diff --git a/bun.lock b/bun.lock index 86f40f6a2..fab1801fa 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,6 @@ "@types/node": "^25.5.2", "@types/turndown": "^5.0.6", "bun-types": "^1.3.11", - "madge": "^8.0.0", }, }, "apps/hook": { @@ -175,7 +174,7 @@ }, "packages/core": { "name": "@plannotator/core", - "version": "0.21.0", + "version": "0.21.1", "devDependencies": { "typescript": "~5.8.2", }, @@ -253,7 +252,7 @@ }, "packages/ui": { "name": "@plannotator/ui", - "version": "0.0.1", + "version": "0.21.1", "dependencies": { "@atomic-editor/editor": "^0.4.3", "@codemirror/autocomplete": "^6.20.3", @@ -531,10 +530,6 @@ "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], - "@dependents/detective-less": ["@dependents/detective-less@5.0.3", "", { "dependencies": { "gonzales-pe": "^4.3.0", "node-source-walk": "^7.0.1" } }, "sha512-v6oD9Ukp+N7V4n6p5I/+mM5fIohSfkrDSGlFm5w/pYmchvbk+sMIHsLxrFJ5Lnujewj1BzWL0K84d88lwZAMQA=="], - - "@discoveryjs/json-ext": ["@discoveryjs/json-ext@1.1.0", "", {}, "sha512-Xc3VhU02wqZ1HvHRJUwL09HkZSTvidqY5Ya0NXBSYOxAp+Ln9dcJr9fySI+CkONzP3PekQo9WdzCv0PGER/mOA=="], - "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.79.1", "", { "dependencies": { "@earendil-works/pi-ai": "^0.79.1", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-PBPjBa2YBm9jauiLtHAKaSfVJ4Dvm3/nK/bR/oHebLjwBCS2tGx3aQDX7MSGAOXi6BejlhzbB/z82BkyAyNjjQ=="], "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.79.1", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.1", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-UnORwrcsTNLm4StEvoM8iEom0u87Te7BXEWxhec3iNXygWD6eEBosUoq9ddcveqtj/QpUZBMPWUu81cCtZxzkQ=="], @@ -1107,14 +1102,6 @@ "@textlint/types": ["@textlint/types@15.7.1", "", { "dependencies": { "@textlint/ast-node-types": "15.7.1" } }, "sha512-Vye/GmFNBTgVzZFtIFJTmLB+s2A7oIADxNG6r9UhfPuY+Czv0z5G3xeyFZZudPlfxURsKUyPIU5XsjOFqVp33A=="], - "@ts-graphviz/adapter": ["@ts-graphviz/adapter@2.0.6", "", { "dependencies": { "@ts-graphviz/common": "^2.1.5" } }, "sha512-kJ10lIMSWMJkLkkCG5gt927SnGZcBuG0s0HHswGzcHTgvtUe7yk5/3zTEr0bafzsodsOq5Gi6FhQeV775nC35Q=="], - - "@ts-graphviz/ast": ["@ts-graphviz/ast@2.0.7", "", { "dependencies": { "@ts-graphviz/common": "^2.1.5" } }, "sha512-e6+2qtNV99UT6DJSoLbHfkzfyqY84aIuoV8Xlb9+hZAjgpum8iVHprGeAMQ4rF6sKUAxrmY8rfF/vgAwoPc3gw=="], - - "@ts-graphviz/common": ["@ts-graphviz/common@2.1.5", "", {}, "sha512-S6/9+T6x8j6cr/gNhp+U2olwo1n0jKj/682QVqsh7yXWV6ednHYqxFw0ZsY3LyzT0N8jaZ6jQY9YD99le3cmvg=="], - - "@ts-graphviz/core": ["@ts-graphviz/core@2.0.7", "", { "dependencies": { "@ts-graphviz/ast": "^2.0.7", "@ts-graphviz/common": "^2.1.5" } }, "sha512-w071DSzP94YfN6XiWhOxnLpYT3uqtxJBDYdh6Jdjzt+Ce6DNspJsPQgpC7rbts/B8tEkq0LHoYuIF/O5Jh5rPg=="], - "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -1231,16 +1218,6 @@ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.61.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.61.1", "@typescript-eslint/types": "^8.61.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA=="], - - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.61.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg=="], - - "@typescript-eslint/types": ["@typescript-eslint/types@8.61.1", "", {}, "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA=="], - - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.61.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.61.1", "@typescript-eslint/tsconfig-utils": "8.61.1", "@typescript-eslint/types": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg=="], - - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.61.1", "", { "dependencies": { "@typescript-eslint/types": "8.61.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w=="], - "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.6", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], @@ -1273,16 +1250,6 @@ "@vscode/vsce-sign-win32-x64": ["@vscode/vsce-sign-win32-x64@2.0.6", "", { "os": "win32", "cpu": "x64" }, "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ=="], - "@vue/compiler-core": ["@vue/compiler-core@3.5.38", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.38", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ=="], - - "@vue/compiler-dom": ["@vue/compiler-dom@3.5.38", "", { "dependencies": { "@vue/compiler-core": "3.5.38", "@vue/shared": "3.5.38" } }, "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw=="], - - "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.38", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/compiler-core": "3.5.38", "@vue/compiler-dom": "3.5.38", "@vue/compiler-ssr": "3.5.38", "@vue/shared": "3.5.38", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.15", "source-map-js": "^1.2.1" } }, "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg=="], - - "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.38", "", { "dependencies": { "@vue/compiler-dom": "3.5.38", "@vue/shared": "3.5.38" } }, "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA=="], - - "@vue/shared": ["@vue/shared@3.5.38", "", {}, "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug=="], - "@xterm/addon-fit": ["@xterm/addon-fit@0.12.0-beta.216", "", { "peerDependencies": { "@xterm/xterm": "^6.1.0-beta.216" } }, "sha512-IgKE3ngNodSnmj1O+EEYpKQZkSbAUbghPlCWd8G32RL0piIMqb3FX3BuYLnWZeLNoD9iMtublLMG1T9XjGeVvA=="], "@xterm/addon-unicode11": ["@xterm/addon-unicode11@0.10.0-beta.216", "", { "peerDependencies": { "@xterm/xterm": "^6.1.0-beta.216" } }, "sha512-i7TrEHOTzUEOClH1+6IHoHy7bR/XHVRBjHc5e0u6A1HucFkAlCU+bqUY8EfwNOh1/iUjuB06EtNh6BM1o/ZAlA=="], @@ -1311,18 +1278,14 @@ "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], - "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], - "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], "anynum": ["anynum@1.0.0", "", {}, "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA=="], - "app-module-path": ["app-module-path@2.2.0", "", {}, "sha512-gkco+qxENJV+8vFcDiiFhuoSvRXb2a/QPqpSoWhVz829VNJfOTnELbBmPmNKFxf3xdNnw4DWCkzkDaavcX/1YQ=="], - "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -1335,8 +1298,6 @@ "as-table": ["as-table@1.0.55", "", { "dependencies": { "printable-characters": "^1.0.42" } }, "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ=="], - "ast-module-types": ["ast-module-types@6.0.2", "", {}, "sha512-6KuK/7nZ/2Qh7sGuVEiwxjCxzTY2Pdb5mTo5z1e6/J8BA0tvjR7G8vQJKrQMTqwmnA3UPEyKIFX4YUS1DO1Hvw=="], - "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], @@ -1409,7 +1370,7 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], @@ -1433,12 +1394,6 @@ "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], - "cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], - - "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], - - "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], - "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "cockatiel": ["cockatiel@3.2.1", "", {}, "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q=="], @@ -1457,12 +1412,10 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - "commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], "common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="], - "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], - "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -1585,8 +1538,6 @@ "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], - "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], - "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], @@ -1597,8 +1548,6 @@ "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], - "dependency-tree": ["dependency-tree@11.5.0", "", { "dependencies": { "@discoveryjs/json-ext": "^1.1.0", "commander": "^12.1.0", "filing-cabinet": "^5.5.1", "precinct": "^12.3.2", "typescript": "^5.9.3" }, "bin": { "dependency-tree": "bin/cli.js" } }, "sha512-K9zBwKDZrot3RkxizugpVSdImxULAg4Ycp3+ydy2r561k96oiiw6nfsOR15fwNDQ5BF2UXe+2JFM/H5Xz4MGQg=="], - "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], @@ -1607,24 +1556,6 @@ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], - "detective-amd": ["detective-amd@6.1.0", "", { "dependencies": { "ast-module-types": "^6.0.1", "escodegen": "^2.1.0", "get-amd-module-type": "^6.0.2", "node-source-walk": "^7.0.1" }, "bin": { "detective-amd": "bin/cli.js" } }, "sha512-fmI6LGMvotqd49QaA3ZYw+q0aGp2yXmMjzIuY6fH9j9YFIXY/73yDhMwhX9cPbhWd+AH06NH1Di/LKOuCH0Ubg=="], - - "detective-cjs": ["detective-cjs@6.1.1", "", { "dependencies": { "ast-module-types": "^6.0.1", "node-source-walk": "^7.0.1" } }, "sha512-pSh7mkCKEtLlmANqLu3KDFS3NV8Hx41jy/JF1/gAWOgU+Uo5QTkeI1tWNP4dWGo4L0E9j18Ez9EPsTleautKqA=="], - - "detective-es6": ["detective-es6@5.0.2", "", { "dependencies": { "node-source-walk": "^7.0.1" } }, "sha512-+qHHGYhjupiVs4rnIpI9nZ5B130A4AmE35ZX1w33hb46vcZ7T3jfDbvmPw0FhWtMHn5BS5HHu7ZtnZ53bMcXZA=="], - - "detective-postcss": ["detective-postcss@8.0.4", "", { "dependencies": { "is-url-superb": "^4.0.0", "postcss-values-parser": "^6.0.2" }, "peerDependencies": { "postcss": "^8.4.47" } }, "sha512-DZ7M/hWPZyr17ZUdoQ+TVXaPj70mYr4XXrAE+GeJbca44haCvZgb191L/jLJmFYewhxRJuBd4lUtNSu986TXag=="], - - "detective-sass": ["detective-sass@6.0.2", "", { "dependencies": { "gonzales-pe": "^4.3.0", "node-source-walk": "^7.0.1" } }, "sha512-i3xpXHDKS0qI2aFW4asQ7fqlPK00ndOVZELvQapFJCaF0VxYmsNWtd0AmvXbTLMk7bfO5VdIeorhY9KfmHVoVA=="], - - "detective-scss": ["detective-scss@5.0.2", "", { "dependencies": { "gonzales-pe": "^4.3.0", "node-source-walk": "^7.0.1" } }, "sha512-9JOEMZ8pDh3ShXmftq7hoQqqJsClaGgxo1hghfCeFlmKf5TC/Twtwb0PAaK8dXwpg9Z0uCmEYSrCxO+kel2eEg=="], - - "detective-stylus": ["detective-stylus@5.0.1", "", {}, "sha512-Dgn0bUqdGbE3oZJ+WCKf8Dmu7VWLcmRJGc6RCzBgG31DLIyai9WAoEhYRgIHpt/BCRMrnXLbGWGPQuBUrnF0TA=="], - - "detective-typescript": ["detective-typescript@14.1.2", "", { "dependencies": { "@typescript-eslint/typescript-estree": "^8.58.2", "ast-module-types": "^6.0.1", "node-source-walk": "^7.0.1" }, "peerDependencies": { "typescript": "^5.4.4 || ^6.0.2" } }, "sha512-bIeEn0eVi/JRsE1YizBR2ilnMlWRAIBJJ6kXCKNFxEEWhUcEY3R6I3KYIAy48ieURbD1hcb3Ebvl8AqeoPMSzg=="], - - "detective-vue2": ["detective-vue2@2.3.0", "", { "dependencies": { "@dependents/detective-less": "^5.0.1", "@vue/compiler-sfc": "^3.5.32", "detective-es6": "^5.0.1", "detective-sass": "^6.0.1", "detective-scss": "^5.0.1", "detective-stylus": "^5.0.1", "detective-typescript": "^14.1.0" }, "peerDependencies": { "typescript": "^5.4.4 || ^6.0.2" } }, "sha512-3gwbZPqVTm9sL9XdZsgEJ7x4x99O853VVZHapQAiEkGuMJMpFPjHDrecSgfqnS5JW3FJfYXesLZGvUOibjn49g=="], - "deterministic-object-hash": ["deterministic-object-hash@2.0.2", "", { "dependencies": { "base-64": "^1.0.0" } }, "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ=="], "devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="], @@ -1703,14 +1634,6 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], - - "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - - "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], - - "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], - "estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="], "estree-util-build-jsx": ["estree-util-build-jsx@3.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-walker": "^3.0.0" } }, "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ=="], @@ -1725,8 +1648,6 @@ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], @@ -1765,8 +1686,6 @@ "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], - "filing-cabinet": ["filing-cabinet@5.5.1", "", { "dependencies": { "app-module-path": "^2.2.0", "commander": "^12.1.0", "enhanced-resolve": "^5.21.0", "module-definition": "^6.0.2", "module-lookup-amd": "^9.1.3", "resolve": "^1.22.12", "resolve-dependency-path": "^4.0.1", "sass-lookup": "^6.1.2", "stylus-lookup": "^6.1.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.9.3" }, "bin": { "filing-cabinet": "bin/cli.js" } }, "sha512-PzLBTChlVPn6LnNxF0KWs+XqPziVh3Sfmz/3TXOymHxu6a9yhrDcQn7YwgpcRM6mqhR2WHVGPR8RU4fmcF1IVA=="], - "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], @@ -1803,16 +1722,12 @@ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - "get-amd-module-type": ["get-amd-module-type@6.0.2", "", { "dependencies": { "ast-module-types": "^6.0.1", "node-source-walk": "^7.0.1" } }, "sha512-7zShVYAYtMnj9S65CfN+hvpBCByfuB1OY8xID01nZEzXTZbx4YyysAfi+nMl95JSR6odt4q8TCj2W63KAoyVLQ=="], - "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], - "get-own-enumerable-property-symbols": ["get-own-enumerable-property-symbols@3.0.2", "", {}, "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g=="], - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "get-source": ["get-source@2.0.12", "", { "dependencies": { "data-uri-to-buffer": "^2.0.0", "source-map": "^0.6.1" } }, "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w=="], @@ -1831,8 +1746,6 @@ "globby": ["globby@14.1.0", "", { "dependencies": { "@sindresorhus/merge-streams": "^2.1.0", "fast-glob": "^3.3.3", "ignore": "^7.0.3", "path-type": "^6.0.0", "slash": "^5.1.0", "unicorn-magic": "^0.3.0" } }, "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA=="], - "gonzales-pe": ["gonzales-pe@4.3.0", "", { "dependencies": { "minimist": "^1.2.5" }, "bin": { "gonzales": "bin/gonzales.js" } }, "sha512-otgSPpUmdWJ43VXyiNgEYE4luzHCL2pz4wQ0OnDluC6Eg4Ko3Vexy/SrSynglw/eR+OhkzmqFCZa/OFa/RgAOQ=="], - "google-auth-library": ["google-auth-library@10.7.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ=="], "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], @@ -1911,7 +1824,7 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], @@ -1929,8 +1842,6 @@ "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], - "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], - "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], @@ -1945,22 +1856,12 @@ "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], - "is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="], - "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], - "is-obj": ["is-obj@1.0.1", "", {}, "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg=="], - "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - "is-regexp": ["is-regexp@1.0.0", "", {}, "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA=="], - - "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], - - "is-url-superb": ["is-url-superb@4.0.0", "", {}, "sha512-GI+WjezhPPcbM+tqE9LnmsY5qqjwHzTvjJ36wxYX5ujNXefSUJ/T17r5bqDV8yLhcgB59KTPNOc9O9cmHTPWsA=="], - "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -2057,8 +1958,6 @@ "lodash.truncate": ["lodash.truncate@4.4.2", "", {}, "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw=="], - "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], - "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], @@ -2069,8 +1968,6 @@ "lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="], - "madge": ["madge@8.0.0", "", { "dependencies": { "chalk": "^4.1.2", "commander": "^7.2.0", "commondir": "^1.0.1", "debug": "^4.3.4", "dependency-tree": "^11.0.0", "ora": "^5.4.1", "pluralize": "^8.0.0", "pretty-ms": "^7.0.1", "rc": "^1.2.8", "stream-to-array": "^2.3.0", "ts-graphviz": "^2.1.2", "walkdir": "^0.4.1" }, "peerDependencies": { "typescript": "^5.4.4" }, "optionalPeers": ["typescript"], "bin": { "madge": "bin/cli.js" } }, "sha512-9sSsi3TBPhmkTCIpVQF0SPiChj1L7Rq9kU2KDG1o6v2XH9cCw086MopjVCD+vuoL5v8S77DTbVopTO8OUiQpIw=="], - "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], @@ -2209,8 +2106,6 @@ "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], "miniflare": ["miniflare@3.20250718.3", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "stoppable": "1.1.0", "undici": "^5.28.5", "workerd": "1.20250718.0", "ws": "8.18.0", "youch": "3.3.4", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ=="], @@ -2223,10 +2118,6 @@ "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], - "module-definition": ["module-definition@6.0.2", "", { "dependencies": { "ast-module-types": "^6.0.1", "node-source-walk": "^7.0.1" }, "bin": { "module-definition": "bin/cli.js" } }, "sha512-SvAU3lB0+Yjbq55yHY3wkRZBOh+fhU1SnIF3IFbTewv6mtAh7yUT8ACHAJ2mGIJ7tCes2QuCL/cl6m0JSZ/ArA=="], - - "module-lookup-amd": ["module-lookup-amd@9.1.3", "", { "dependencies": { "commander": "^12.1.0", "requirejs": "^2.3.8", "requirejs-config-file": "^4.0.0" }, "bin": { "lookup-amd": "bin/cli.js" } }, "sha512-Jc3XmOaR9FdfMJSK8+vyLgsCkzm8z2L0NS6vrlRWi12DjS7MY7TMNE7E1yj8yXx837xtMDbKSSgcdXnFlJ2YLg=="], - "motion": ["motion@12.40.0", "", { "dependencies": { "framer-motion": "^12.40.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA=="], "motion-dom": ["motion-dom@12.40.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="], @@ -2277,8 +2168,6 @@ "node-sarif-builder": ["node-sarif-builder@3.4.0", "", { "dependencies": { "@types/sarif": "^2.1.7", "fs-extra": "^11.1.1" } }, "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg=="], - "node-source-walk": ["node-source-walk@7.0.2", "", { "dependencies": { "@babel/parser": "^7.29.0" } }, "sha512-71kFFjYaSshDTA8/a2HiTYPLdASWjLJxUyJxGE+ffxU+KhxSBtM9kiLUX+R2yooFdSFKMFpi4n3PFtDy6qXv8A=="], - "normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], @@ -2297,8 +2186,6 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], @@ -2307,8 +2194,6 @@ "openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="], - "ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], - "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], @@ -2327,8 +2212,6 @@ "parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="], - "parse-ms": ["parse-ms@2.1.0", "", {}, "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA=="], - "parse-semver": ["parse-semver@1.1.1", "", { "dependencies": { "semver": "^5.1.0" } }, "sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ=="], "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], @@ -2347,8 +2230,6 @@ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], - "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], @@ -2379,14 +2260,8 @@ "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], - "postcss-values-parser": ["postcss-values-parser@6.0.2", "", { "dependencies": { "color-name": "^1.1.4", "is-url-superb": "^4.0.0", "quote-unquote": "^1.0.0" }, "peerDependencies": { "postcss": "^8.2.9" } }, "sha512-YLJpK0N1brcNJrs9WatuJFtHaV9q5aAOj+S4DI5S7jgHlRfm0PIbDCAFRYMQD5SHq7Fy6xsDhyutgS0QOAs0qw=="], - "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], - "precinct": ["precinct@12.3.2", "", { "dependencies": { "@dependents/detective-less": "^5.0.3", "commander": "^12.1.0", "detective-amd": "^6.1.0", "detective-cjs": "^6.1.1", "detective-es6": "^5.0.2", "detective-postcss": "^8.0.3", "detective-sass": "^6.0.2", "detective-scss": "^5.0.2", "detective-stylus": "^5.0.1", "detective-typescript": "^14.1.2", "detective-vue2": "^2.3.0", "module-definition": "^6.0.2", "node-source-walk": "^7.0.2", "postcss": "^8.5.14", "typescript": "^5.9.3" }, "bin": { "precinct": "bin/cli.js" } }, "sha512-JbJevI1K80z8e/WIyDt/4vUN/4qcfBSKKqOjJA4mosPPPb7zODKRJQV7YN7apVWN3k58nZYm/vEsLgEGYmnxwg=="], - - "pretty-ms": ["pretty-ms@7.0.1", "", { "dependencies": { "parse-ms": "^2.1.0" } }, "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q=="], - "printable-characters": ["printable-characters@1.0.42", "", {}, "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ=="], "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], @@ -2411,8 +2286,6 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - "quote-unquote": ["quote-unquote@1.0.0", "", {}, "sha512-twwRO/ilhlG/FIgYeKGFqyHhoEhqgnKVkcmqMKi2r524gz3ZbDTcyFt38E9xjJI2vT+KbRNHVbnJ/e0I25Azwg=="], - "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], @@ -2481,16 +2354,6 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - "requirejs": ["requirejs@2.3.8", "", { "bin": { "r.js": "bin/r.js", "r_js": "bin/r.js" } }, "sha512-7/cTSLOdYkNBNJcDMWf+luFvMriVm7eYxp4BcFCsAX0wF421Vyce5SXP17c+Jd5otXKGNehIonFlyQXSowL6Mw=="], - - "requirejs-config-file": ["requirejs-config-file@4.0.0", "", { "dependencies": { "esprima": "^4.0.0", "stringify-object": "^3.2.1" } }, "sha512-jnIre8cbWOyvr8a5F2KuqBnY+SDA4NXr/hzEZJG79Mxm2WiFQz2dzhC8ibtPJS7zkmBEl1mxSwp5HhC1W4qpxw=="], - - "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], - - "resolve-dependency-path": ["resolve-dependency-path@4.0.1", "", {}, "sha512-YQftIIC4vzO9UMhO/sCgXukNyiwVRCVaxiWskCBy7Zpqkplm8kTAISZ8O1MoKW1ca6xzgLUBjZTcDgypXvXxiQ=="], - - "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], - "retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="], "retext-latin": ["retext-latin@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "parse-latin": "^7.0.0", "unified": "^11.0.0" } }, "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA=="], @@ -2527,8 +2390,6 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "sass-lookup": ["sass-lookup@6.1.2", "", { "dependencies": { "commander": "^12.1.0", "enhanced-resolve": "^5.20.0" }, "bin": { "sass-lookup": "bin/cli.js" } }, "sha512-GjmndmKQBtlPil79RK72L7yc5kDXZPCQeH97bP8R8DcxtXQJO6vECExb3WP/m6+cxaV9h4ZxrSRvCkPG2v/VSw=="], - "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -2603,19 +2464,13 @@ "stream-replace-string": ["stream-replace-string@2.0.0", "", {}, "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w=="], - "stream-to-array": ["stream-to-array@2.3.0", "", { "dependencies": { "any-promise": "^1.1.0" } }, "sha512-UsZtOYEn4tWU2RGLOXr/o/xjRBftZRlG3dEWoaHr8j4GuypJ3isitGbVyjQKAuMu+xbiop8q224TjiZWc4XTZA=="], - "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], - "stringify-object": ["stringify-object@3.3.0", "", { "dependencies": { "get-own-enumerable-property-symbols": "^3.0.0", "is-obj": "^1.0.1", "is-regexp": "^1.0.0" } }, "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw=="], - - "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], @@ -2631,14 +2486,10 @@ "stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], - "stylus-lookup": ["stylus-lookup@6.1.2", "", { "dependencies": { "commander": "^12.1.0" }, "bin": { "stylus-lookup": "bin/cli.js" } }, "sha512-O+Q/SJ8s1X2aMLh4213fQ9X/bND9M3dhSsyTRe+O1OXPcewGLiYmAtKCrnP7FDvDBaXB2ZHPkCt3zi4cJXBlCQ=="], - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-hyperlinks": ["supports-hyperlinks@3.2.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig=="], - "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - "svgo": ["svgo@4.0.1", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="], "table": ["table@6.9.0", "", { "dependencies": { "ajv": "^8.0.1", "lodash.truncate": "^4.4.2", "slice-ansi": "^4.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1" } }, "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A=="], @@ -2681,16 +2532,10 @@ "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], - "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], - "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], - "ts-graphviz": ["ts-graphviz@2.1.6", "", { "dependencies": { "@ts-graphviz/adapter": "^2.0.6", "@ts-graphviz/ast": "^2.0.7", "@ts-graphviz/common": "^2.1.5", "@ts-graphviz/core": "^2.0.7" } }, "sha512-XyLVuhBVvdJTJr2FJJV2L1pc4MwSjMhcunRVgDE9k4wbb2ee7ORYnPewxMWUav12vxyfUM686MSGsqnVRIInuw=="], - "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], - "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], @@ -2791,10 +2636,6 @@ "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], - "walkdir": ["walkdir@0.4.1", "", {}, "sha512-3eBwRyEln6E1MSzcxcVpQIhRG8Q1jLvEqRmCZqS3dsfXEDR/AhOF4d+jHg1qvDCpYaVRZjENPQyrVxAkQqxPgQ=="], - - "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], - "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], @@ -2877,8 +2718,6 @@ "@earendil-works/pi-ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], - "@earendil-works/pi-coding-agent/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "@earendil-works/pi-coding-agent/highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], "@earendil-works/pi-tui/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], @@ -2897,10 +2736,6 @@ "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "@secretlint/formatter/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - - "@secretlint/formatter/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="], @@ -2913,19 +2748,17 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@textlint/linter-formatter/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "@textlint/linter-formatter/pluralize": ["pluralize@2.0.0", "", {}, "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw=="], "@textlint/linter-formatter/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "@vscode/vsce/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "@textlint/linter-formatter/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@vscode/vsce/hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + "@vscode/vsce/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - - "@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - - "@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@vscode/vsce/hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], "accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], @@ -2937,8 +2770,6 @@ "astro/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "boxen/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "cheerio/undici": ["undici@7.27.2", "", {}, "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA=="], "cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], @@ -2947,32 +2778,22 @@ "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], + "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + "d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], - "dependency-tree/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], - - "dependency-tree/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "effect/ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], - "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - "filing-cabinet/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], - - "filing-cabinet/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "get-source/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "happy-dom/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], @@ -2999,8 +2820,6 @@ "miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], - "module-lookup-amd/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], - "node-fetch/data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "normalize-package-data/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], @@ -3011,9 +2830,7 @@ "parse-semver/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], - "precinct/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], - - "precinct/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], "read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], @@ -3025,20 +2842,16 @@ "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], - "sass-lookup/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], - "send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], "sitemap/@types/node": ["@types/node@24.13.1", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg=="], - "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - - "stylus-lookup/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], - "svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "table/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "table/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], @@ -3053,8 +2866,6 @@ "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "youch/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "zod-to-ts/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -3071,16 +2882,18 @@ "@plannotator/review/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "@secretlint/formatter/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "@textlint/linter-formatter/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "@textlint/linter-formatter/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@vscode/vsce/hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "accepts/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "astro/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], "astro/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], @@ -3149,10 +2962,10 @@ "sitemap/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "table/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "table/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], @@ -3287,6 +3100,6 @@ "wrangler/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], - "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } } diff --git a/package.json b/package.json index 3ddbff7a7..01ce0824a 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,7 @@ "package:vscode": "bun run --cwd apps/vscode-extension package", "test": "bun test", "typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json", - "build:ui-css": "bun run --cwd packages/ui build:css", - "check:cycles": "madge --circular --extensions ts,tsx --ts-config packages/core/tsconfig.json packages/core && madge --circular --extensions ts,tsx --ts-config packages/ui/tsconfig.json packages/ui" + "build:ui-css": "bun run --cwd packages/ui build:css" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.92", @@ -51,7 +50,6 @@ "devDependencies": { "@types/node": "^25.5.2", "@types/turndown": "^5.0.6", - "bun-types": "^1.3.11", - "madge": "^8.0.0" + "bun-types": "^1.3.11" } } From 98fdf09e823baa585bc55a055fd005bbf5bda116 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 20:37:48 -0700 Subject: [PATCH 44/46] =?UTF-8?q?fix(ui):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20TDZ=20guard,=20html-viewer=20export,=20doc=20corrections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useExternalAnnotations: declare unsubscribe as let (not const) + guard calls, so a host transport that fires onError synchronously during subscribe falls back to polling instead of throwing a TDZ ReferenceError (Plannotator's EventSource fires async, never hit) - package.json: add explicit ./components/html-viewer export (dir has index.ts; the ./components/* -> *.tsx wildcard can't resolve it, so external installers would fail) - README: fix configurePlannotatorUI sample keys to the real option names (storageBackend/identityProvider/imageSrcResolver/externalAnnotationTransport) - AGENTS.md: point the Ask-AI mapping at packages/core/agents.ts (shared/agents.ts is a shim now) All publish/host-path/doc only — Plannotator unchanged. (#1 CSS-build font collision deferred to publish-prep — it needs the asset pipeline + files allowlist, not a one-liner.) --- AGENTS.md | 2 +- packages/ui/README.md | 8 ++++---- packages/ui/hooks/useExternalAnnotations.ts | 10 +++++++--- packages/ui/package.json | 1 + 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5bb30b2bd..b564799fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -197,7 +197,7 @@ Approve → "LGTM" sent to agent session ## Ask AI Provider Defaults -Ask AI providers are detected independently from installed/authenticated local CLIs, then the UI picks a default from the detected Plannotator origin. The mapping lives in `packages/shared/agents.ts` and is applied by `packages/ui/utils/aiProvider.ts`: +Ask AI providers are detected independently from installed/authenticated local CLIs, then the UI picks a default from the detected Plannotator origin. The mapping lives in `packages/core/agents.ts` (re-exported via the `packages/shared/agents.ts` shim) and is applied by `packages/ui/utils/aiProvider.ts`: | Origin | Preferred Ask AI provider | |--------|---------------------------| diff --git a/packages/ui/README.md b/packages/ui/README.md index 13af7c39f..6758b7654 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -16,13 +16,13 @@ Every place the UI talks to a backend (loading a doc preview, saving settings, p import { configurePlannotatorUI } from "@plannotator/ui/configure"; configurePlannotatorUI({ - storage, // where settings persist - identity, // who the current user is - imageResolver, // how image paths resolve to URLs + storageBackend, // where settings persist + identityProvider, // who the current user is + imageSrcResolver, // how image paths resolve to URLs docPreviewFetcher, fileTreeBackend, draftTransport, - externalAnnotations, // live/agent comments + externalAnnotationTransport, // live/agent comments aiTransport, serverSync, }); diff --git a/packages/ui/hooks/useExternalAnnotations.ts b/packages/ui/hooks/useExternalAnnotations.ts index 1b54d90a2..8d3d98385 100644 --- a/packages/ui/hooks/useExternalAnnotations.ts +++ b/packages/ui/hooks/useExternalAnnotations.ts @@ -177,7 +177,11 @@ export function useExternalAnnotations void) | undefined; + unsubscribe = transport.subscribe( (parsed) => { if (cancelled) return; applyEvent(parsed); @@ -186,7 +190,7 @@ export function useExternalAnnotations { cancelled = true; - unsubscribe(); + unsubscribe?.(); if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; diff --git a/packages/ui/package.json b/packages/ui/package.json index e9b658830..9e6d5376b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", From ea01b34312030046271005ef3b7062a6c029e2ce Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 24 Jun 2026 04:58:13 -0700 Subject: [PATCH 45/46] =?UTF-8?q?build(ui):=20don't=20bundle=20fonts=20in?= =?UTF-8?q?=20published=20styles.css=20=E2=80=94=20app=20loads=20fonts=20(?= =?UTF-8?q?review=20#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Industry standard for a shared UI package: ship theme + component CSS, let the consuming app load fonts. Drop the @fontsource imports from styles-entry.css (the publish CSS entry); the theme still defines --font-sans/--font-mono, and the app provides those families. Fixes the asset-name collision (every emitted .woff2 was renamed styles.css) and shrinks the published stylesheet 555kB -> 185kB. README documents the two-line @fontsource install. Plannotator unaffected: its apps (editor/review-editor index.css) load fonts via their own entry CSS — styles-entry.css is consumed ONLY by the publish CSS build. --- packages/ui/README.md | 10 ++++++++-- packages/ui/styles-entry.css | 6 ++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/ui/README.md b/packages/ui/README.md index 6758b7654..240172ac2 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -38,8 +38,14 @@ npm install @plannotator/ui @plannotator/core 1. Call `configurePlannotatorUI({ ... })` once at startup with your backend. 2. Import the stylesheet: `import "@plannotator/ui/styles.css";` (precompiled — no Tailwind wiring needed; the `@source` glob is the fallback if you'd rather scan source). -3. Import components: `import { Viewer } from "@plannotator/ui/components/Viewer";` -4. 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"`. +3. **Load the fonts in your app entry** — the stylesheet references `--font-sans` / `--font-mono` but does not ship font binaries (standard for a shared UI package; your app owns font loading). Plannotator uses Inter + Geist Mono: + ```ts + import "@fontsource-variable/inter"; + import "@fontsource-variable/geist-mono"; + ``` + Or provide your own fonts and set `--font-sans` / `--font-mono` to match. +4. Import components: `import { Viewer } from "@plannotator/ui/components/Viewer";` +5. Build with a bundler that compiles TS/TSX (Vite + React 19 + Tailwind v4). The packages ship **source**, so your bundler compiles them — set `moduleResolution: "bundler"`, `allowImportingTsExtensions`, `jsx: "react-jsx"`. ## Packages & publishing diff --git a/packages/ui/styles-entry.css b/packages/ui/styles-entry.css index 7cba7f178..143bb1034 100644 --- a/packages/ui/styles-entry.css +++ b/packages/ui/styles-entry.css @@ -1,5 +1,7 @@ -@import "@fontsource-variable/inter"; -@import "@fontsource-variable/geist-mono"; +/* Fonts are NOT bundled here. The published styles.css ships only theme + component + styles; the consuming app loads the fonts (see README). The theme defines the + font-family tokens (--font-sans / --font-mono); the app makes those families + available. This keeps styles.css small and avoids shipping font binaries. */ @import "tailwindcss"; @plugin "tailwindcss-animate"; From 701c0fdaa9a83aebca3b567ddf2e3b9912c5924a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 24 Jun 2026 07:00:45 -0700 Subject: [PATCH 46/46] fix(ui): build styles.css on prepack, not prepublishOnly (review #4) prepublishOnly doesn't run for npm pack / bun pm pack / git / file: installs, so the package exported ./styles.css without shipping it. prepack runs on any pack, so the stylesheet is always present. Verified: bun pm pack now emits styles.css. --- packages/ui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index 9e6d5376b..9f2668149 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -111,6 +111,6 @@ "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json", "build:css": "vite build --config vite.css.config.ts && rm -f styles.js", - "prepublishOnly": "bun run build:css" + "prepack": "bun run build:css" } }