Commit Graph

20 Commits

Author SHA1 Message Date
Michael Ramos 8e88dcec8c fix: v0.27.2 pre-release QA batch (mobile TOC, dialog bounds, seed guard) (#1311)
* fix(plan): make the compact TOC scroll the document again

The compact navigator overlay rendered outside App's ScrollViewportProvider,
so the TableOfContents it hosts resolved a null viewport and every "jump to
heading" tap was a silent no-op on phones. The provider is context-only, so
hoisting it above the overlay fixes the lookup without touching desktop DOM
structure or order.

* fix(plan): scope the permission-mode chooser to plan review and bound its card

The one-time chooser fired in every non-goal-setup Claude Code session, so
annotate, annotate-last, annotate-folder and archive reviewers got a blocking
dialog about what happens after plan approval. Gate it on plan review, which
is the absence of a mode field in the /api/plan payload.

The card itself was hand-rolled with no height cap and no internal scroll, so
on a short landscape phone it overflowed both edges of a modal that has no
dismiss control. Give it the same bounded shell the sibling one-time dialogs
use: safe-area padding, a visible-viewport max height, and the option list as
the only scrolling region. Content and cookie behavior are unchanged.

* fix(review): never seed Tree over a persisted panel view

The first-run initializer gated only on the setup-seen cookie, but sessions
that never reach it (non-git, workspace, PR, no since-base) still let Settings
persist a panel view. A reviewer could hold an explicit Git status choice with
"seen" unset, and the next plain git session seeded Tree over it. Treat a
persisted view as the decision: consume the one-time setup and write nothing.

* fix(comments): give the geometry-forced composer a working Escape

When the anchor has no room the position tracker forces dialog mode. On a
fine-pointer viewport Escape took the collapse branch, the tracker instantly
re-forced the dialog, and the keystroke was eaten; the Collapse button bounced
the same way. Track forced expansion separately from the preferred kind: in
that state Escape closes (draft-preserving) and Collapse is hidden, because
collapsing is geometrically impossible.

* fix(review): stop the compact Editor tab editing the desktop diff style

The dock's Split/Unified control returns null under the compact touch layout,
but the Settings copy of it kept rendering while the phone showed the
session-only unified diff. It looked dead and silently rewrote the persisted
desktop preference. Hide it on compact and state what the session is doing;
the prop defaults to false, so the plan editor and desktop are untouched.

* fix(portal): give the share portal the mobile app shell

The portal mounts the same plan editor App as the hook but kept the pre-mobile
entry document: no viewport-fit=cover (so every safe-area token was inert) and
a min-h-screen body without the shell's scroll ownership. Mirror the hook's
body class, root class, and viewport meta, and extend the entry-asset pin to
cover the portal alongside them.

* fix(plan): keep compact overlays out of the printed document

The compact plan stage and the compact navigator are full-viewport transient
surfaces with no print-hide marker, so printing on a touch device with
Annotations, Ask AI, Versions or Archive open clipped the document behind
them. Mark both with data-print-hide, which print.css already hides. The
desktop rail is untouched.

* fix(plan): give the selection toolbar real touch targets

Copy / Delete / Comment / quick label / looks-good / Cancel measured 28x28
with 2px gaps on a phone because the toolbar never got the touch-target
markers the rest of the stack uses. Stamp them on its buttons and add a
compact-scoped gap so adjacent destructive and comment actions are not a
mis-tap apart. Both are inert outside the compact scope, so desktop geometry
is unchanged.

* docs: keep the new QA-batch comments free of em dashes
2026-08-13 11:35:00 -07:00
Michael Ramos 27791a6fba Mobile Phase 2B: Plan shell and navigation (#1303)
* feat(editor): add compact plan navigator

* feat(editor): simplify compact plan chrome

* fix(editor): close compact navigator after file selection

* feat(editor): add compact plan review surfaces

* fix(editor): hold navigator through cold file loads

* fix(editor): preserve desktop diff activation
2026-08-13 09:38:14 -07:00
Michael Ramos f9a6c1e39d feat: annotate accepts YAML, JSON, TOML and other plain-text files (#1099)
* feat(annotate): accept common plain-text config formats (.yaml, .json, .toml, …)

Annotate previously rejected every file that wasn't .md/.mdx/.txt (or
.html/.htm), even though the pipeline reads files as UTF-8 text and
renders anything. Widen the accepted set to unambiguously plain-text
config/data formats: .yaml .yml .json .jsonc .json5 .toml .ini .cfg
.conf .properties .csv .tsv .log .xml .env.example. They render exactly
the way .txt renders today.

- New single source of truth: packages/core/annotatable.ts
  (ANNOTATABLE_TEXT_REGEX / ANNOTATABLE_DOC_REGEX + predicates),
  re-exported through @plannotator/shared/resolve-file and vendored into
  the Pi extension.
- .env stays excluded (commonly holds secrets; annotate history copies
  file contents into the data dir). Source-code extensions stay with
  code review.
- Single-file accept + bare-filename fuzzy search widen in
  resolveMarkdownFile; folder discovery and the file-browser listing
  widen in all three runtimes (hook CLI, OpenCode, Pi).
- /api/doc gains a `doc=1` param set by the file browser so extensions
  that overlap CODE_FILE_REGEX (.yaml/.json/.toml/.ini/.xml) render as
  annotatable documents there while code-file links inside documents
  keep the syntax-highlighted popout.
- Error messages now list the wider set; docs updated (AGENTS.md,
  marketing annotate page).

Closes #1029

Claude-Session: https://claude.ai/code/session_01YXkgsNucxDwAL4GdR4XYRk

* fix(annotate): frontmatter, size caps, edit-guard, and skill docs from review

Review fixes for #1099:

- Frontmatter: `--- … ---` stripping is a markdown convention; for
  non-markdown annotatable sources (multi-document YAML, .txt starting
  with ---) the delimiters are real content. parseMarkdownToBlocks gains
  a { frontmatter } option and the editor keys it off the active
  document's path via shouldStripFrontmatter() (strip for .md/.mdx and
  pathless/converted sources; keep raw for other annotatable text).
- Size caps: new shared MAX_ANNOTATABLE_FILE_BYTES (2MB — same limit the
  code-file popout always had) now guards the annotate CLI single-file
  read in all three runtimes and the /api/doc document branches in both
  servers. Also applies to .md/.txt (behavior change for pathological
  inputs; previously unbounded).
- Editing guard: mid-edit file opens gate on isSourceSaveFilePath
  (.md/.mdx/.txt) instead of the wider annotatable set — config files
  are view-only, so switching to one mid-edit no longer silently
  downgrades "Done editing" to feedback-only edits.
- Skill docs: plannotator-annotate SKILL.md (core + Kiro) now mention
  the plain-text config formats.

Claude-Session: https://claude.ai/code/session_01YXkgsNucxDwAL4GdR4XYRk
2026-07-20 15:33:42 -07:00
Michael Ramos 1267cd50db Add file browser filtering 2026-07-08 22:32:20 -07:00
Michael Ramos 070d9a5f6d Make the document UI reusable as published building blocks (#957)
* 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.

* 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.

* 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.

* build(ui): packaging unblock for external install (Phase 1) — no runtime change

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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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).

* docs(adr): research + synthesis + spec for Phase 5 (comments/annotations/drafts)

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).

* docs(adr): accept ADR 005 — make comments/annotations/drafts host-overridable (Phase 5)

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.

* 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.

* 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.

* feat(ui): make external-annotation transport host-overridable (Phase 5 seam 3)

Add ExternalAnnotationTransport<T> (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.

* 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.

* docs(adr): accept ADR 006 — make extras (versions/settings/sharing/AI) host-overridable (Phase 6)

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.

* 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.

* 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.

* 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.

* 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.

* docs(adr): log Phase 6 completion (4 seams + diff CSS move)

* docs(adr): research + synthesis + spec for Phase 7 (carve @plannotator/core + publish)

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.

* 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.

* 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.

* docs(adr): ADR 007 — carve @plannotator/core, complete settings provider, publish

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.

* 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).

* docs(adr): align Phase 7 spec with ADR 007 (version 0.21.0 lockstep, CSS required, scope completeness)

* feat(core): carve @plannotator/core — move pure modules, extract node-bound types, shim shared (Phase 7 step 1)

* feat(ui): depend only on @plannotator/core — re-point all shared/ai imports (Phase 7 step 2)

* refactor(ui): relocate wideMode helper to @plannotator/ui/utils (Phase 7 step 3)

* feat(ui): add loadFromBackend settings rehydration + configurePlannotatorUI front door (Phase 7 step 4)

* build(ui): precompiled styles.css CSS build + madge circular-dep check (Phase 7 step 5)

* test(ui): per-seam override tests + configure routing test (Phase 7 step 6)

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.

* fix(ui): apply Phase 7 review findings — version lockstep + seam consistency

- 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)

* docs(adr): Phase 7 implementation plan (workflow-generated, durable artifact)

* fix(ui): reconcile #948 with the draft-transport seam + lockstep 0.21.1

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.)

* fix(ui): address review nits — host-path robustness + cleanups

- 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 <any> (variance).

* docs: collapse 29 ADR process docs into one packages/ui/README.md

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.

* 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.

* build: remove madge circular-dep check (unmaintained)

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.

* fix(ui): address review — TDZ guard, html-viewer export, doc corrections

- 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.)

* build(ui): don't bundle fonts in published styles.css — app loads fonts (review #1)

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.

* 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.

* chore(ui): post-rebase reconciliation — version lockstep 0.21.3, awaitable AI abort seam

Rebased onto main (0.21.3). Bump @plannotator/core + @plannotator/ui to 0.21.3
to stay in lockstep with the repo version.

Resolve the useAIChat conflict: main added postServerAbort (an awaitable abort
that prevents session-busy races) using a raw fetch. Route it through the
AITransport seam by making AITransport.abort return Promise<unknown> instead of
void, so the host override is honored AND main's await-the-abort behavior is
preserved. Update the abort mocks in the seam/configure tests accordingly.

* fix(ui): make postServerAbort never reject regardless of AI transport

The await site in ask() relies on postServerAbort resolving so a superseding
query can proceed. main's original guaranteed this with its own .catch on the
fetch; routing through the AITransport seam delegated that guarantee to the
transport. Restore it at the call site (Promise.resolve(...).catch) so a host
override that rejects — or returns void at runtime — can't throw out of ask().

* fix(ui): address review — core import, abort sync-throw, snapshot guards

- useAIProviderConfig: import Origin from @plannotator/core/agents (was the only
  ui file still importing @plannotator/shared); drop the masking shared/* path
  alias from ui/tsconfig.json so a stray shared import now fails typecheck. The
  hook is part of the published surface — a standalone install had no
  @plannotator/shared to resolve.
- useAIChat.postServerAbort: defer the transport call into .then so a host abort
  that throws *synchronously* also can't reject (the .catch only caught async).
- useExternalAnnotations: default getSnapshot returns null (skip) on a malformed
  200 instead of coercing to []/0, so it can't clear annotations or reset the
  version cursor — restoring the pre-seam behavior.

* feat(ui): add upload + identity-editable seams for host backends

Two override points the Workspaces app needs that had no seam:

- UploadTransport (utils/upload.ts): image attachments hardcoded POST /api/upload
  with no override. Add a setX/resetX/getX seam (default = today's /api/upload,
  verbatim) and route AttachmentsButton through it. Workspaces sends bytes to its
  R2 asset API and returns the content-addressed URL.
- IdentityProvider.isEditable() (utils/identity.ts): the Settings rename/regenerate
  controls wrote to the cookie store, bypassing a host identity provider — so a
  host with server-owned identity could split one user across two author names.
  Add an optional isEditable() (default true) and hide the rename controls when a
  host returns false. Plannotator's cookie identity stays editable — unchanged.

Both wired into configurePlannotatorUI(); seam tests added; configure routing test
covers uploadTransport. HANDOFF.md updated with the Workspaces seam mapping from
the repo research (asset layer, identity, realtime, no-AI-infra, the Me
display-name backend follow-up). README publish command corrected to bun pm pack
+ npm publish.

* refactor(ui): capture sessionId synchronously in postServerAbort

Self-review: the deferred .then read sessionIdRef.current a microtask after the
guard checked it. Capture the id synchronously so the abort always targets the
session current at call time and there's no double-read.

* fix(ui): address review — seed host store, browser-safe timer type, harden abort

- configStore.loadFromBackend: seed the host StorageBackend with resolved defaults
  for keys it lacks. The constructor runs at module load (before a host installs
  its backend), so its default-seeding writes went to the cookie backend; without
  this a fresh host store was never populated and generated defaults (e.g.
  displayName) regenerated every reload. [P1, host path]
- Viewer.tsx: replace NodeJS.Timeout with ReturnType<typeof setTimeout> (2 refs)
  so a browser-only consumer compiling the published source doesn't need
  @types/node. Matches the pattern already used in configStore. [P1, published path]
- useAIChat: harden the create-session supersede abort the same way as
  postServerAbort, so a host transport that throws can't surface an unhandled
  rejection. No impact on Plannotator (default self-catches). [nit]
- .gitignore: correct stale 'prepublishOnly' comment to 'prepack'. [nit]

Plannotator behavior unchanged (it never calls loadFromBackend; the timer/abort
changes are behavior-preserving). Strengthened configStore seam test to assert
first-run seeding. typecheck clean, 1773 pass / 0 fail.

* refactor(ui): single-source the never-reject abort via safeAbort helper

Self-review: the hardened abort pattern (defer into .then + .catch so a host
transport that throws can't reject) was duplicated across postServerAbort and the
create-session supersede site — the exact drift the review flagged. Extract a
module-level safeAbort(sessionId) so both call sites share one hardened
implementation and can't diverge again. Behavior unchanged; reads aiTransport at
call time so a late override is honored.

* chore(ui): post-rebase version lockstep to 0.21.4

Rebased onto main (0.21.4, adds markdown math #878 + parser hardening). Bump
@plannotator/core + @plannotator/ui to 0.21.4 to stay in lockstep with the repo.
katex (main's math dep) merged into ui; typecheck clean, 1810 pass / 0 fail.

* docs(ui): consumer-lens handoff hardening + ADR 005

- HANDOFF.md: add supported-imports allowlist vs unsupported (hardcoded
  /api/*) list; document the annotation anchor schema, reattachment
  order, and untested stale-anchor degradation; state that the markdown
  editor cannot take CM6/Yjs extensions yet and the plan of record;
  note AI avoidability re-verified post-rebase; fix stale 0.21.3 ref.
- adr/decisions/005: record the publish-as-packages decision (packages
  over copy/vendor, core/ui split, seam-singleton pattern + SSR revisit
  condition, the law, lockstep publish model).

* fix(ui): make shipped source strict-TS clean for consumers + seam type barrel

Consumers compile the published TS source with their own compiler options,
and strict mode failed with 35 errors inside the package:
- settings.ts: satisfies SettingDef<unknown> is contravariantly illegal
  under strictFunctionTypes (33 errors) — use SettingDef<any>
- useDismissOnOutsideAndEscape: RefObject<HTMLElement> rejects React 19's
  useRef<T>(null) refs — widen to HTMLElement | null
- globals.d.ts: declare *.png / *.webp modules, referenced from each
  asset-importing component so any consumer program that includes one
  gets the ambient declarations

Also unscatter the seam contract types: configure.ts re-exports every
seam type next to configurePlannotatorUI, and ServerSyncFn is now
exported from config/index.ts (it was unreachable through the exports
map). Verified: standalone Vite consumer importing the full supported
surface passes tsc --noEmit under full strict (was 35 errors).

* fix(ui): keep KaTeX fonts out of published styles.css (back to ~187KB, was 1.6MB)

Main's math PR imports katex/dist/katex.min.css in theme.css; the
publish build (Vite lib mode) force-inlines all 60 KaTeX math fonts as
data URIs, ballooning styles.css to 1.6MB (977KB gzip) and breaking the
package's consumer-owns-fonts policy. Alias the katex stylesheet to an
empty stub in vite.css.config.ts only — theme.css stays untouched (no
rebase surface) and Plannotator's own apps, which import theme.css
directly, still bundle KaTeX as before. Hosts that render math load
katex.min.css themselves (bundler import, CDN tag, or self-hosted copy
per HANDOFF.md), which also gets them lazy font loading. Verified:
fresh build is 186.9KB / 30.8KB gzip with zero @font-face data URIs;
consumer vite build CSS drops 1.66MB -> 200KB.

* docs(ui): HANDOFF corrections from adversarial consumer review

- Math rendering section: KaTeX css/fonts excluded from styles.css by
  design; three one-time host setup options (self-hosted recommended,
  CDN tag, bundler import)
- styles.css size claim corrected (~187KB / ~31KB gzip) + strict-TS
  guarantee documented (verified against a standalone consumer)
- AI-avoidability claim made precise: configure.ts statically imports
  useAIChat for its setter; unused AI code tree-shakes to zero (bundle-
  verified) — the runtime claim holds, the static wording was wrong
- Loud warning on the loadSettingsFromBackend ordering footgun:
  configuring before hydration seeds generated defaults into the host
  backend and nothing re-runs hydration
- DraftTransport.load() tombstone-generation contract spelled out
- Seam-type barrel documented on the configure row; 'everything is
  importable' softened (some components/*.ts don't resolve via the
  *.tsx wildcard); stale diff stats refreshed

* docs(ui): math setup pointer in README + pnpm caveat on the katex bundler-import option

* fix(ui): lazy settings resolution — zero cookies on a configured host

The configStore resolved all settings eagerly in its constructor, at
module import — before a host's configurePlannotatorUI() could install
its StorageBackend — writing 17 plannotator-* cookies (including a
generated identity) onto the host origin. Resolution now runs lazily on
first settings access (get/set/init/loadFromBackend): by then the host
backend is live, so the initial reads AND default-seeding writes route
through it. A configured host gets zero cookies, ever.

Plannotator unchanged: same resolution, same cookie seeding, same
values — on first settings read (same page load) instead of at import.
New configStore.lazyInit.seam.test.ts proves the contract from a fresh
module graph; full suite + consumer strict tsc green.

* chore(ui): post-rebase version lockstep to 0.22.0

* fix(ui): round-2 review batch — dedupe asset declarations, CI seam tests, strict consumer gate, doc corrections

- components/types.d.ts: drop the *.png/*.webp declarations that
  globals.d.ts now owns — both shipping was a duplicate-identifier
  error for any consumer with skipLibCheck: false
- untrack packages/ui/styles.css (generated by prepack, gitignored;
  got scooped into the carve commit during the rebase by git add -A
  before the ignore entry existed in the replay)
- CI: the DOM test step now runs ALL packages/ui tests, so the seam
  contract tests (AI/draft/external-annotations/file-tree/inline-
  markdown) actually execute in CI instead of skipping
- new packages/ui/tsconfig.strict-consumer.json wired into root
  typecheck: type-checks the supported-import surface under full
  strict, so the consumer strict-TS guarantee can't silently rot
- HANDOFF: rot-proofed the diff stat, strict guarantee now cites the
  CI gate, CDN katex pinned-version wording, theme-vs-styles.css
  caveats (theme still imports KaTeX + needs Tailwind), Viewer
  required props, Yjs plan-of-record updated to the atomic-editor fork
- README: @source fallback wording (build entry isn't shipped)

* test(ui): make the lazy-resolution seam test deterministic

The test asserted lazy resolution on the module singleton and relied on
its test file getting a fresh module graph — an isolation assumption
that doesn't hold under all bun test orderings (CI failed with zero
observed reads because another file had already resolved the store).
Test the contract on a fresh instance instead: ConfigStore is exported
as @internal ConfigStoreForTest, the spy backend is installed before
construction, and the test asserts construction reads nothing while the
first get() resolves and seeds through the live backend. Deterministic
by construction.

* test(ui): poll for the debounced reconnect refetch instead of a fixed sleep

The reconnect-refresh assertion waited a fixed 150ms against the SSE
watcher's 120ms debounce — a 30ms margin that slower CI runners lose,
flaking 'refreshes after an SSE ready event from reconnect'. The
watched logic is unchanged (verified byte-identical to main's inline
version — the seam only relocated it into the default watchTrees and
added the onChange indirection). Poll for calls.length===2 up to 1s so
the pass/fail is hardware-independent.

* test(ui): poll the committed tree state, not the fetch call count

Prior fix polled calls.length===2, but the fetch call is counted one
tick before its result commits to React state — so the poll exited
early and the next assertion (dirs[0].tree === reconnectedTree) lost the
race on slow CI (toEqual failure). Poll on the committed tree itself,
which is exactly what the assertion checks: now the only way to fail is
a genuine no-refresh, not a timing margin.

* test(ui): give the reconnect-refetch poll a 10s ceiling + 20s test timeout

A CI runner was measured at 6x normal speed (1676ms for a ~275ms test),
blowing through the 1.5s poll ceiling before the 120ms debounce fired —
same commit passed on a faster runner. Raise the poll to ~10s and set an
explicit 20s test timeout (bun's 5s default would otherwise kill the
poll). Root cause is load, not logic: this timing-sensitive test only
started flaking when the CI DOM step was broadened to run the whole ui
suite in one process.

* ci: run the file-browser DOM test isolated; scope the DOM step to DOM files

Root-causes the intermittent 'refreshes after an SSE ready event from
reconnect' failure. The round-2 change ran the ENTIRE ui suite under
DOM_TESTS=1 to catch the seam contracts; that load intermittently
starved the test's 120ms real-timer debounce so the reconnect refetch
never fired (observed failing after a full 10s poll — not a margin
issue). The hook logic is byte-identical to main, and main runs this
test in its own process (green for months).

Fix at the CI layer, not the test: run useFileBrowser.test.tsx isolated
(matching main), and run the seam contracts + remaining DOM-gated tests
as an explicitly-scoped light batch. The test file is reverted to main
verbatim (today's timing-poll experiments dropped). Follow-up issue to
file: the underlying re-subscription race the load exposed.
2026-07-06 20:39:09 -07:00
Michael Ramos 740d6fb2eb Add WebTUI agent panel to annotate mode (#941)
* feat(annotate): add WebTUI agent terminal

* feat(annotate): wire WebTUI agent into annotate UI

* docs: recap annotate agent terminal work

* fix(annotate): harden agent terminal runtime

* docs: add annotate agent terminal runtime ADRs

* fix(annotate): polish agent terminal integration

* fix(ui): preserve comment draft on Ask AI failure

* fix(annotate): address terminal review findings

* fix(annotate): harden agent terminal runtime fallback
2026-06-19 09:04:15 -07:00
Michael Ramos 195328f7a6 Persist saved annotate file edits in drafts (#936)
* Persist saved annotate file edits in drafts

* Handle stale saved file edit context

* Test source edit conflict actions

* Return source metadata for single-file docs

* Fix saved file edit conflict races

* Handle deleted source files in annotate edits

* Tighten annotate missing-file recovery

* Reset edit state for missing file reopen

* Tighten source edit restore path handling

* Harden source edit recovery paths

* Harden source edit disk reconciliation

* Fix live file tree startup delay

* Document file tree watcher startup ordering

* Preserve source save through missing files and symlinks

* Harden missing source file recovery

* Tighten annotate source edit boundaries
2026-06-18 21:31:25 -07:00
Michael Ramos 40210fdfd2 Add live annotate file tree workspace status (#931)
* feat(annotate): add live file tree workspace status

* fix(annotate): guard dirty feedback and rename stats

* fix(annotate): tighten live file tree status

* fix(annotate): surface deleted file browser roots

* fix(annotate): normalize file tree status paths

* fix(annotate): avoid optional git locks for workspace status

* fix(annotate): tighten workspace status git metadata

* fix(annotate): refresh file tree after reconnect

* fix(pi): expose file browser stream route
2026-06-17 14:25:28 -07:00
Michael Ramos 9ed3ba8937 feat(editor): markdown edit mode — direct document editing with diff-to-agent feedback
Adds direct markdown editing, source-backed annotate saves, folder edit buffers, and review-hardening fixes.
2026-06-16 16:35:51 -07:00
Michael Ramos c23df4db43 UI 2.0 visual refresh + HTML-render annotate (strictly UI, off main) (#863)
Extracts the UI 2.0 visual refresh and the HTML-render annotate feature onto main, standalone (no daemon). Faithful copy of feat's UI/HTML logic with the standalone transport kept.
2026-06-08 17:03:41 -07:00
Graham Lipsman be2c81fa3b annotate-last: pick which message to annotate (fixes #800) (#809)
* feat: message picker for annotate-last (#800)

When running /plannotator-last after /rewind, the newest transcript
entry is no longer the message the user intended to annotate, and there
was no affordance to pick a different one.

Adds a picker UI that surfaces the recent assistant messages so the
user can choose which one to annotate:

- A "Message N of M" button in the Viewer's sticky-top action bar
  (alongside Copy / Global comment / Attachments), so it stays
  accessible while scrolling.
- A "Messages" tab in the left sidebar with the full list
  (newest-first, preview + timestamp, default ★), mirroring the
  existing Files / Versions / Archive tab pattern.

Wired for Claude Code, Codex, and Droid (all share apps/hook/server).
OpenCode, Pi, and Copilot still get the original single-message
behavior — they don't emit recentMessages, so the picker affordances
hide cleanly.

Default selection (index 0) matches today's "last message" behavior,
so users who don't interact with the picker see no change.

* feat: extend annotate-last picker to Copilot and OpenCode

The picker UI from #800 was wired for Claude / Codex / Droid only. Pull
Copilot and OpenCode onto the same shape so users on those harnesses
also get the recent-messages picker when annotating the last assistant
message.

- Copilot: replace getLastCopilotMessage with getRecentCopilotMessages,
  walking events.jsonl newest-first up to 25 assistant.message events.
- OpenCode: rewrite the session walk to collect up to 25 messages
  (newest first) instead of bailing on the first hit; normalize the SDK
  time.created (ms epoch) to ISO to match the shared picker contract.
- Both pass recentMessages to startAnnotateServer only when length > 1,
  matching the existing Claude/Codex/Droid behavior.

Also trims a leftover narrating comment in MessagesBrowser and refreshes
the stale Copilot session-parser header.

Pi parity follows in the next commit (needs round-trip of the picker
selection through /api/feedback so its post-submit anchoring quotes the
right message).

* feat(pi): wire annotate-last picker with feedback round-trip

Extends the picker UI (#800) to Pi and fixes a Pi-specific anchoring bug
the picker would otherwise introduce.

Picker plumbing
- assistant-message: getRecentAssistantMessages walks the active branch
  newest-first, returning { messageId, text, timestamp? } in the same
  shape the other harnesses produce.
- Plumbed through plannotator-browser / plannotator-events so the Bun
  server's recentMessages option is populated when the branch has more
  than one assistant message.

Anchoring fix
- Pi quotes the targeted assistant message back to the agent because its
  UX is async — the conversation may have moved on by feedback time.
  With the picker, that target is no longer guaranteed to be the
  snapshot taken when the UI opened. The editor now sends the user's
  selectedMessageId with /api/feedback; Pi looks it up in the current
  branch via findAssistantMessageByEntryId and quotes that message
  instead. Falls back to the original snapshot if the entry is gone.
- The round-trip field is optional and only meaningful in annotate-last
  mode; other harnesses (and other modes) ignore it.

Timestamp safety
- Pi's SDK currently types SessionEntryBase.timestamp as string, but the
  picker contract everywhere else is ISO. Treat the value as unknown and
  normalize string/number(ms)/Date to ISO; drop anything else, rather
  than blind-casting and risking silent drift if the SDK changes.

* chore: strip issue-number references from comments

Comments shouldn't rely on external references — issue numbers age out
of context, link rot is a thing, and a reader shouldn't need to open
GitHub to understand why a line exists. Strip the `(#800)` and `(#570)`
parentheticals from comments and doc strings across the picker and
review-gate code; the surrounding "why" content is preserved.

* fix: prevent removeChild crash when switching annotate-last messages

Switching the picked message remounted nothing, so React reconciled new
content against DOM that web-highlighter had mutated with <mark> nodes,
throwing removeChild. Drive the Viewer key (and StickyHeaderLane's
remount token) off a shared viewerContentKey so a message switch fully
remounts the Viewer and re-anchors the sticky-header observer.

Also cap MessagesBrowser row previews via previewText() and drop the
redundant 'block' class that was overriding line-clamp-2.

* feat: persist annotate-last feedback across messages

---------

Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-06-03 13:01:36 -07:00
Michael Ramos ba2e4d2a1d feat(ui): markdown reader parity — HTML blocks, GitHub alerts, GFM inline extras (#597)
* feat(ui): markdown reader parity — HTML blocks, GitHub alerts, GFM inline extras

Brings the in-app markdown reader to parity with GitHub's flavored rendering.
Additive across the parser + renderer; no behavior change for existing blocks.

Refactor:
- Extract InlineMarkdown (262 lines) out of Viewer.tsx into its own file
- Extract BlockRenderer + block-type components (CodeBlock, HtmlBlock, AlertBlock,
  Callout) into components/blocks/ — Viewer drops from 1279 to ~770 lines
- Each new block-level feature lands in BlockRenderer or a new blocks/*.tsx,
  not Viewer

Block-level features:
- Raw HTML blocks (<details>, <summary>, etc.) via balanced-tag parser branch,
  rendered through marked + DOMPurify for nested-markdown support; inner innerHTML
  set imperatively so React reconciliation doesn't collapse open <details>
- GitHub alerts (> [!NOTE] / [!TIP] / [!WARNING] / [!CAUTION] / [!IMPORTANT])
  with inline Octicons, title-case labels, GitHub's Primer colors (light + dark)
- Directive containers (:::kind ... :::) with arbitrary kinds for project-specific
  callouts (note, tip, warning, danger, info, success, question, etc.)
- Heading anchor ids — slugifyHeading() strips inline markdown, preserves unicode

Inline features (all in InlineMarkdown, all code-span-safe):
- Bare URL autolinks (https://...) with trailing-punctuation trimming
- @mentions and #issue-refs — render as clickable links when repo is GitHub,
  styled spans otherwise; threaded via repoInfo.display through BlockRenderer
- Emoji shortcodes (👋, 🚀, 29 curated codes) via transformPlainText()
- Smart punctuation (curly quotes, em/en dashes, ellipsis) applied only to
  plain-text fragments after code spans have been consumed

Safety:
- Render-time transforms live inside InlineMarkdown's plain-text push, which
  is only reached after code-span regex consumes code content. Backticks stay
  literal for shell/regex snippets.
- DOMPurify allowlist (no on* handlers, no style attrs, no scripts) gates every
  raw HTML block. Unsafe link protocols (javascript:/data:/vbscript:/file:)
  stripped by sanitizeLinkUrl.

Tests: +40 (149 total). New files:
- utils/slugify.test.ts (10) — unicode, markdown stripping, edge cases
- utils/inlineTransforms.test.ts (9) — emoji + smartypants
- utils/parser.test.ts — alert detection (5 cases), directives (5 cases), HTML
  block balancing (5 cases)

Fixtures for manual verification:
- tests/test-fixtures/11-html-blocks.md
- tests/test-fixtures/12-gfm-and-inline-extras.md (release-plan-shaped demo)

Known limitations (not blockers):
- Bare URL regex doesn't balance parens (https://en.wikipedia.org/wiki/Foo_(bar)
  drops the trailing ")")
- Duplicate heading text → duplicate anchor ids (browser picks first on hash nav)
- Directive body is inline-only (no nested headings/lists)

For provenance purposes, this commit was AI assisted.

* fix(ui): wire typecheck for packages/ui, address PR review findings

Root-cause fix for the missing-import bug caught in review: the UI package
had no tsconfig.json and no typecheck script, so missing references like
`getImageSrc` in the extracted InlineMarkdown slipped past vite/esbuild
(which only type-strip, they don't resolve imports).

Infrastructure:
- Added packages/ui/tsconfig.json with proper module resolution, JSX config,
  and bundler-style paths.
- Added globals.d.ts to accept side-effect CSS imports.
- Added @types/react, @types/react-dom, @types/bun, @types/dompurify as
  devDeps on packages/ui so React / Bun / DOMPurify types actually resolve.
- Wired `tsc --noEmit -p packages/ui/tsconfig.json` into the top-level
  `bun run typecheck` script.

With the typecheck running, 0 errors remain in this PR's scope. Four
pre-existing errors on main (plan-diff SVG type narrowing, sharing.ts
SharePayload shape) are unrelated and tracked separately.

Review fixes:
- InlineMarkdown: import getImageSrc from ImageThumbnail. Was calling the
  helper without importing it — markdown images with relative paths
  (`![alt](./foo.png)`) would throw ReferenceError at render. Regression
  caused by the InlineMarkdown extraction.
- useAnnotationHighlighter: findTextInDOM now retries with the rendered
  form (transformPlainText) when the raw originalText doesn't match.
  Annotations made before smart-punctuation / emoji shortcodes shipped
  (straight quotes, `👋` text) still re-bind after reload.
- sanitizeHtml: allow the `open` attribute so `<details open>` preserves
  its default-expanded state instead of always rendering collapsed.
- parser.test.ts: narrow a string->AlertKind assertion to satisfy strict
  typechecking.

Deferred (tracked as known limitation in PR description):
- HtmlBlock relative URL rewriting for nested <img src="./logo.png"> /
  <a href="note.md">. New-feature gap, not a regression.

For provenance purposes, this commit was AI assisted.

* fix(ui): HtmlBlock rewrites relative <img>/<a> refs to match markdown paths

Raw HTML blocks inject sanitized HTML verbatim, so nested <img src="./logo.png">
and <a href="notes.md"> resolved against the plannotator server URL instead of
the plan's directory — images 404'd, .md links navigated away instead of
opening in the linked-doc overlay. This is the path README.md content hits
(hero <img>, YouTube thumbnails, <details> sections with anchors).

Fix: after setting innerHTML, walk <img> and <a> elements and apply the same
rewriting markdown content uses:
- <img> relative src → getImageSrc(src, imageBaseDir), routing through
  /api/image?path=... with the plan's base directory.
- <a> relative href matching .md / .mdx / .html → click handler that calls
  onOpenLinkedDoc, same pattern as [label](./foo.md) markdown links.
- http(s):, data:, blob:, mailto:, tel:, and #anchor hrefs pass through
  untouched.

BlockRenderer now threads imageBaseDir + onOpenLinkedDoc into HtmlBlock.
React.memo equality extended to compare those props too, so legitimate
changes still re-run the rewrite pass without forcing re-renders on
every parent update.

Verified against the repo's own README.md — hero image, YouTube thumbnails,
and <details> sections all render correctly in annotate mode.

For provenance purposes, this commit was AI assisted.

* feat(ui): table conveniences — hover toolbar, popout dialog with sort/filter

Extracts table rendering into blocks/TableBlock and adds two companion
surfaces: a hover toolbar for quick copy, and a full-screen popout dialog
with TanStack-powered sort/filter/copy for power use. No pagination — plan
tables don't get that big.

Hover toolbar (blocks/TableToolbar.tsx):
- Floats above the table on mouse enter via React portal, positioned with
  getBoundingClientRect + scroll/resize listeners, entry/exit animations.
  Same positional pattern as AnnotationToolbar's top-right mode.
- Debounced hover state in Viewer (100ms leave → 150ms exit animation),
  mirroring hoveredCodeBlock's state machine.
- Three actions: Copy markdown (icon), CSV (short uppercase button,
  RFC 4180 escaping), Expand (opens popout).

Popout dialog (blocks/TablePopout.tsx):
- Radix Dialog, fullscreen-ish card with ~2rem backdrop visible for
  click-to-close. max-w-[min(calc(100vw-4rem),1500px)].
- Portaled into Viewer's containerRef so the annotation hook can walk
  into the popout's text nodes — selection-based annotations, text-search
  restoration, and shared blockId all work across the collapsed and
  popped-out views.
- TanStack Table for the grid: click column headers to sort (asc → desc →
  clear), global filter input, no pagination. Row count indicator shows
  "15 of 27" when filter reduces the set.
- Copy / CSV buttons in the header row: filter- and sort-aware. When
  visible rows < total, tooltips read "Copy 15 rows as markdown" /
  "Copy 15 rows as CSV". When no filter, copies whole table (normalized
  whitespace). Read is one-shot on click — no derived state to sync.
- Floating X close button (absolute top-right), no header bar.

Chrome stacking while popout is open (CSS-only, via :has()):
- body:has([data-popout="true"]) drops four element types behind the
  dialog: annotation sidebar, sticky header lane, app nav header, overlay
  scrollbars. :has() observes the dialog's presence directly — when the
  dialog unmounts, the selector stops matching and everything returns to
  natural stacking. No JS state, no useEffect cleanup.

Shared helpers in TableBlock.tsx (exported):
- parseTableContent — pipe-delimited markdown → { headers, rows }
- buildCsvFromRows / buildMarkdownTable — inverse, from parsed data
- buildCsv — thin wrapper for the hover toolbar's raw-block path

Dependencies added:
- @radix-ui/react-dialog ^1.1.15 (~6 KB gzipped)
- @tanstack/react-table ^8.21.3 (~14 KB gzipped)

Fixture:
- tests/test-fixtures/12-gfm-and-inline-extras.md — added a 27×11
  "Detailed feature backlog" table to exercise wide + deep tables,
  horizontal scroll in the popout, and the sort/filter flows.

For provenance purposes, this commit was AI assisted.

* fix(ui): table popout — annotation flow, chrome stacking, sidebar tabs

Tightens the popout so annotations work inside it and chrome doesn't
overlap the dialog.

Annotation flow inside popout:
- Radix Dialog modal={false} so the focus trap doesn't yank focus back
  from CommentPopover's textarea (CommentPopover portals to document.body,
  outside the dialog's DOM subtree).
- Dialog.Content onInteractOutside handler whitelists the annotation
  toolbar, CommentPopover, and FloatingQuickLabelPicker so clicking
  them doesn't dismiss the dialog. Backdrop click + Escape still close.
- aria-describedby={undefined} on Dialog.Content (Radix opt-out; the
  popout doesn't need a description).
- React.memo on TablePopout with a custom comparator (block id/content,
  open, container, imageBaseDir, githubRepo). Prevents upstream Viewer
  re-renders from re-running TanStack's flexRender on every cell, which
  conflicted with web-highlighter's live DOM mutations and caused a
  NotFoundError in React's reconciler.

Widget markers for :has()-based chrome stacking:
- [data-comment-popover="true"] on CommentPopover (both popover + dialog
  variants).
- [data-floating-picker="true"] on FloatingQuickLabelPicker.
- [data-sidebar-tabs="true"] on SidebarTabs (left-side TOC/Files/Versions
  flags that sit on top of the dialog otherwise).
- theme.css extended: sidebar tabs join the annotation sidebar, sticky
  header lane, app header, and overlay scrollbars in dropping to
  z-index -1 while body:has([data-popout="true"]) matches.

Known limitation (not addressed): annotations created inside the popout
show their <mark> only while the popout is open; when it closes, the
<mark> unmounts with the popout's DOM and does not reappear on the
collapsed table. The annotation itself persists in state (sidebar,
shared URLs, exports). Round-tripping visual marks between popout and
collapsed view requires either a second web-highlighter instance or a
switch to the CSS Custom Highlight API — out of scope here.

For provenance purposes, this commit was AI assisted.

* fix(ui): review findings — flags, alerts, forges, tabs, anchors, URL brackets

Six targeted fixes from the v0.19 PR review. Each is small and scoped;
the riskier items from the review (plan-diff block variants, HTML
relative non-doc links) are tracked as follow-ups.

Smart punctuation — CLI flags preserved:
- Narrowed the `--` → en-dash rule to only fire between digits
  (`pages 3--5` still converts; `bun --watch` stays literal).

GitHub alerts — list/code/heading bodies absorb correctly:
- Blockquote merge now always merges into a previous alert blockquote,
  regardless of whether the new line starts with a block marker. Without
  this, `> [!NOTE]\n> - item` split the list off into a plain italic
  quote and emptied the alert.
- AlertBlock got a mini block-level renderer for the body so `- item` /
  `* item` / `1. item` render as real <ul>/<ol>, not flattened prose.

Forge-aware mentions/issue refs:
- packages/shared/repo: new parseRemoteHost() extracts the host from
  the git remote URL; RepoInfo gains an optional `host` field.
- packages/server/repo: getRepoInfo populates host alongside display.
- Viewer only passes githubRepo to InlineMarkdown when the host is
  exactly "github.com". Non-GitHub repos render mentions/issue refs
  as styled text, no wrong github.com links.

HTML block external links:
- rewriteRelativeRefs now forces `target="_blank"` and
  `rel="noopener noreferrer"` on every external http(s) link inside
  raw HTML. Fixes two problems in one pass: external links no longer
  hijack the review tab, and pasted-HTML links can't tab-nab the
  plannotator tab via window.opener.

Heading anchor dedup:
- New buildHeadingSlugMap() walks all heading blocks and assigns
  `foo`, `foo-1`, `foo-2`, ... for repeats (GitHub convention).
  BlockRenderer receives the anchor id as a prop from Viewer via a
  memoized map rather than computing per-block; first occurrence
  keeps the bare slug so existing links stay stable.

URL autolink bracket balance:
- Trailing `)`/`]`/`}` in bare URLs are kept when they balance an
  earlier opener inside the URL. Wikipedia-style
  `https://en.wikipedia.org/wiki/Function_(mathematics)` now keeps its
  paren; `(see https://x.com)` still trims the orphan.

Tests: +8 (157 total).
- utils/slugify.test: buildHeadingSlugMap dedup behavior, non-heading
  skipping, empty-slug skipping.
- utils/inlineTransforms.test: CLI flags stay literal, `3--5` still
  converts.
- utils/parser.test: alerts with list body / code fence body, blank
  line ending an alert.

Fixture:
- tests/test-fixtures/13-known-issues.md — reproduces each of the
  review findings end-to-end; useful as a regression check going
  forward.

Deferred (tracked for follow-up):
- Plan diff view doesn't render html / directive / alertKind semantics
  (SimpleBlockRenderer has no cases for the new block variants).
- Relative non-doc links inside raw HTML (.pdf, .csv) don't get
  rewritten — only .md/.mdx/.html are routed through the linked-doc
  overlay today. Not a regression; narrow audience.

For provenance purposes, this commit was AI assisted.

* fix(ui): round-3 review — drop host gate, link paren balance, data/blob images

- Viewer: remove repoInfo.host === 'github.com' gate so @user/#123 links
  render for GitHub Enterprise and runtimes (Pi) that don't populate host.
- HtmlBlock: treat protocol-relative //host links as external and harden
  with target=_blank rel=noopener noreferrer.
- InlineMarkdown: data:/blob: image sources bypass /api/image rewrite.
- InlineMarkdown: replace [text](url) regex with a depth-tracking scanner
  so URLs with balanced parens (Wikipedia /Function_(mathematics)) and
  backslash-escapes no longer truncate. Empty text/url guard preserves
  prior fall-through behavior.
- InlineMarkdown: isLocalDoc accepts .md/.mdx/.html/.htm with optional
  #fragment; fragment stripped before onOpenLinkedDoc so guide.md#setup
  opens the linked doc instead of a broken anchor.

For provenance purposes, this commit was AI assisted.

* fix(ui): round-4 review — table pipe escape, callout lists, emoji h-splitter

- TableBlock: buildMarkdownTable now re-escapes literal | as \| in each
  cell. parseTableContent already unescapes on parse; without the mirror
  on serialize, the popout's copy-as-markdown produces extra columns for
  tables with pipes in regex, shell, or boolean content.
- AlertBlock + Callout: extract the shared paragraph-and-list body
  renderer into blocks/proseBody.tsx. Fixes directive callouts (:::note
  with a bulleted list) rendering as literal hyphens instead of a list.
  Paragraph lines join with '\n' so InlineMarkdown's hard-break handler
  still fires. Callout passes an empty text-color class so directive
  color tokens inherited from the container are preserved.
- InlineMarkdown: drop `h` from the plaintext chunk-break class; it was
  splitting emoji shortcodes like ❤️, 👍, 🤔 at the
  h, so the :word: pattern never reassembled and transformPlainText
  couldn't replace the shortcode. Bare URL detection moves inline via
  emitPlainTextWithBareUrls, which scans chunks for https?:// at word
  boundaries and emits anchors, passing surrounding text through
  transformPlainText so emoji + smart punctuation still apply to
  non-URL slices.
- InlineMarkdown: extract trimUrlTail (shared between the top-of-loop
  URL branch and the new inline scanner) — one balanced-paren trim
  implementation instead of two. +8 unit tests covering the trim cases
  (Wikipedia parens, unbalanced brackets, stacked punctuation).
- Fixture: section 9 in 13-known-issues.md demonstrates the table copy
  corruption for manual verification.

For provenance purposes, this commit was AI assisted.

* fix(ui): resolve pre-existing typecheck errors surfacing in CI

- PlanCleanDiffView: narrow heading Tag to 'h1'..'h6' so hover props
  resolve to HTMLHeadingElement instead of the SVGSymbolElement branch
  of keyof IntrinsicElements.
- VSCodeIcon: spread mask-type as a kebab-case attribute; React 19's
  typings no longer expose the camelCase maskType prop on SVG masks.
- useSharing / sharing: cast decompress() result to SharePayload — the
  shared compress module returns unknown by design; callers were
  implicitly any and TS 5.x now flags the assignment.

For provenance purposes, this commit was AI assisted.
2026-04-21 18:56:41 -07:00
Michael Ramos 70dda029be feat(sidebar): merge vault browser into files tab + UX polish (#526)
* fix(annotate): add missing obsidian vault endpoints to annotate servers

Both the Bun and Pi annotate servers were missing the three Obsidian
reference endpoints (/api/obsidian/vaults, /api/reference/obsidian/files,
/api/reference/obsidian/doc), making the vault browser unavailable in
annotate mode. Added the handlers to both servers.

For provenance purposes, this commit was AI assisted.

* feat(sidebar): merge vault browser into files tab

Vault is conceptually just another directory — same tree UI, same
expand/collapse, same annotation counts. Rather than maintaining a
parallel tab and hook, vault now appears as a collapsible section
inside the Files tab alongside any other configured directories.

Changes:
- Add `isVault` flag to `DirState` and `addVaultDir()` to
  `useFileBrowser` — fetches from the Obsidian endpoint and stamps
  the dir so downstream code can route to the correct API
- Remove `useVaultBrowser` hook and `VaultBrowser` component entirely
- Remove `"vault"` from `SidebarTab` type
- Update `App.tsx`: drop all parallel vault state; wire vault into the
  combined file browser fetch effect; update `handleFileBrowserSelect`
  to pick the right URL builder based on `isVault`; update
  `handleOpenLinkedDoc` and annotation count derivation to cover vault
  dirs via the same path
- Fix sticky header lane: remove the `!linkedDocHook.isActive` gate
  that was suppressing sticky headers for linked docs and annotate mode
- Remove primary border on linked doc viewer card
- Add `ObsidianIcon` (full branded logo) and `ObsidianIconRaw`
  (compact purple mark) to `packages/ui/components/icons/ObsidianIcons.tsx`
- Display `ObsidianIconRaw` in the FileBrowser directory header for
  vault dirs

Net: -334 lines. One Files tab handles all directory types uniformly.

For provenance purposes, this commit was AI assisted.

* fix(sidebar): fix stale vault dirs and unrecoverable vault load failures

Three issues identified in review:

1. Switching vault paths accumulated stale sections — addVaultDir now
   atomically replaces all existing vault dirs instead of appending,
   so changing the configured vault never leaves a ghost section behind.

2. Disabling the vault left the old section visible until reload —
   a new clearVaultDirs() method prunes all isVault entries, called
   via a dedicated effect that fires whenever vaultPath becomes empty.

3. Vault load failures were unrecoverable in-session — the Retry button
   only called fetchAll(regularDirs). FileBrowser now accepts an
   onRetryVaultDir prop and routes vault dir retries to addVaultDir
   (which re-fetches from the Obsidian endpoint) instead of the
   regular-dirs-only fetchAll.

For provenance purposes, this commit was AI assisted.
2026-04-08 16:07:08 -07:00
Michael Ramos 57495ec816 feat: Zed-style overlay scrollbars (#509)
* feat(ui): Zed-style overlay scrollbars for plan mode

Wide, translucent, full-length overlay scrollbars replacing the 6px
WebKit rail that users couldn't reliably grab. Click the track to
page-animate toward the click, drag the thumb, no layout shift, Firefox
parity.

Wraps plan-mode scroll containers (main viewer, annotation panel,
sidebar, settings, export modal) in a new <OverlayScrollArea> component
backed by overlayscrollbars-react. The library handles pointer capture,
click-to-jump, hover reveal, auto-hide, RTL, touch, momentum, and
cross-browser consistency. ClickScrollPlugin registered explicitly so
`clickScroll: true` actually pages (otherwise it silently no-ops).

Plumbing:

- New ScrollViewportContext + useScrollViewport() hook so descendants
  (TableOfContents, PinpointOverlay, Viewer sticky observer) can reach
  the real scrolling element instead of document.querySelector('main'),
  which no longer returns the scroll node after wrapping.
- New useOverlayViewport() hook — canonical ref+state+callback pattern
  bridging the library viewport into components that need both
  imperative access and effect re-runs when the viewport mounts.
- useActiveSection gains an optional scrollElement arg so its
  IntersectionObserver root re-attaches when the viewport becomes
  available (ref mutations don't retrigger effects).

Theme:

- New .os-theme-plannotator tokens sourced from existing theme CSS
  variables (translucent muted-foreground for handle, transparent track
  at rest). 10px at rest, 14px on hover. Color + width transitions
  only — deliberately does not reintroduce transform/opacity in global
  transitions, preserving the abc952f scroll-jank fix.
- Firefox `scrollbar-width: thin` + `scrollbar-color` fallback for
  unwrapped surfaces (micro-scrollers, future dockview).
- print.css hides .os-scrollbar during print.

ResizeHandle (#354 regression guard):

- `side="right"` touch area retuned from `-right-3 left-0` to
  `-right-3 left-3` to clear the 14px hover scrollbar. Load-bearing
  comment added explicitly warning against simplification because #354
  has already regressed twice.

Intentionally left native: max-h-24 inline scrollers
(EditorAnnotationCard, AgentsTab, ThemeTab), dropdown menus — a 14px
overlay scrollbar would dominate those UI elements.

Fixes #354
Follow-up to #359, #465 (both fixed #354, which kept regressing)
Preserves #253 bidirectional annotation scroll
Preserves #452 file-switch reset (plan mode portion)

For provenance purposes, this commit was AI assisted.

* feat(review): overlay scrollbars for file tree, sidebar, and PR panels

Wrap the trivial code-review scroll containers in <OverlayScrollArea>:
FileTree, ReviewSidebar content area, AITab chat history, PRCommentsTab
timeline, and the ReviewPRSummary / ReviewPRChecks dock panels.

None of these components read scrollTop / scrollHeight / scrollLeft
directly — all descendant queries use containerRef.current.querySelector
and all scroll-to-target calls use element.scrollIntoView, which walks
up to the nearest scrollable ancestor (now the library viewport). No
plumbing changes required.

AITab was originally scoped to Commit B but an audit of its scroll
effects (jump-to-question + auto-scroll-to-bottom) confirmed it only
uses descendant scrollIntoView, so it ships here.

DiffViewer and LiveLogViewer follow in a separate commit — they
programmatically read/write scrollTop and need explicit viewport
plumbing via useOverlayViewport.

For provenance purposes, this commit was AI assisted.

* feat(review): overlay scrollbars for diff viewer and live logs

Wrap DiffViewer's main scroll container and LiveLogViewer's log pane in
<OverlayScrollArea>, plumbing containerRef through useOverlayViewport
so imperative scroll reads/writes and IntersectionObserver roots land
on the real library viewport, not the OverlayScrollArea host.

DiffViewer:

- `previousScrollFilePathRef` guard added: the file-switch reset
  (#452) now only advances the tracking ref once the scroll actually
  executed, closing a race where a file switch landing before the
  library viewport attached would leave the ref stale while the
  scrollTo silently no-oped.
- `viewport` added to every effect dep that reads containerRef.current
  (file-switch reset, annotation scroll, search-highlight apply +
  swap, scroll-to-match) so they re-run when the viewport mounts.
  Without this they'd silently no-op on first paint.
- Split-view sync via @pierre/diffs unaffected — its ScrollSyncManager
  attaches scroll listeners to its own internal codeDeletions /
  codeAdditions elements inside the content, not the outer scroller.

LiveLogViewer:

- React onScroll replaced with a native addEventListener('scroll', ...)
  inside a useEffect keyed on `viewport` because React's onScroll
  doesn't bubble across the library's wrapper layers.
- Follow-tail heuristic (`scrollHeight - scrollTop - clientHeight < 40`)
  and the auto-scroll-to-bottom assignment both preserved verbatim —
  only the ref target changed from the raw div to the library viewport.

Preserves #452 file-switch reset
Preserves #253 bidirectional annotation scroll (diff side)

For provenance purposes, this commit was AI assisted.

* fix(ui): address PR #509 review — viewport delivery and resize handle

Two P1 bugs from the review, plus four correctness/cleanup items.

P1: OverlayScrollbars viewport was never delivered to consumers.

handleOsRef was reading osInstance() synchronously from the React ref
callback, but `defer: true` queues the library's initialization for a
later frame — at ref-callback time, the internal instance ref is still
null. With a stable useCallback the ref never re-fires, so
onViewportReady was never called with a real viewport. Every consumer
of useOverlayViewport/useScrollViewport stayed permanently null:
useActiveSection, TableOfContents.handleNavigate, Viewer sticky
detection, PinpointOverlay, DiffViewer file-switch reset, LiveLogViewer
follow-tail — all silently no-ops.

Fix: deliver the viewport via the library's own `events.initialized`
and `events.destroyed` callbacks, which fire exactly when elements are
ready and when they're torn down. `getViewport()` prefers the tracked
viewport ref over the imperative osInstance() path so late callers
still work.

P1: right-side ResizeHandle touch area was 0px wide.

Touch area is an absolute-positioned child of a w-0 parent, so actual
width = parent - left - right. With side='right' I'd set `left-3
-right-3`, which evaluates to `0 - 12 - (-12) = 0`. The annotation
panel resize handle in plan mode and both right handles in code review
had no draggable region. The visible 4px track has no event handlers,
so the only affordance was cursor-style feedback — drag did nothing.

Fix: revert to `left-0 -right-3` (12px wide, entirely right of the
boundary, no left encroachment into the adjacent scrollbar zone —
which was the original correct value before this branch). Rewrote the
comment to explain the geometry trap instead of protecting the value
that broke it.

Additional fixes:

- OverlayScrollArea: prefers-reduced-motion now reactive via
  useSyncExternalStore — OS toggle mid-session propagates to mounted
  instances instead of staying frozen at mount-time snapshot.
- OverlayScrollArea: `ref as never` replaced with a narrow cast to
  `React.RefCallback<OverlayScrollbarsComponentRef<'div'>>` so future
  signature changes get type feedback.
- PinpointOverlay: window resize listener moved above the scroll
  viewport guard so it attaches unconditionally. Scroll listener still
  requires the viewport (correct). Old code always registered resize
  on window; new code was accidentally skipping it when viewport was
  null.
- TableOfContents: `className || default` changed to `className ??
  default`. An explicit empty string from a caller (SidebarContainer
  passing className="" now that it wraps us in an OverlayScrollArea)
  should mean "no container styling", not "use the default". The old
  || operator treated "" as falsy and applied the default, leaving
  dead overflow-y-auto and unintended backdrop-blur on the nav.
- useOverlayViewport: removed redundant double cast and `?? null`
  no-op in the ref setter.

For provenance purposes, this commit was AI assisted.

* fix(ui): print clipping with overlay scrollbar wrappers

When <main> is wrapped in OverlayScrollArea, the library adds its own
attribute-selector CSS rules: `[data-overlayscrollbars~="host"]` gets
`overflow: hidden !important` and `[data-overlayscrollbars-viewport]`
gets `overflow-x/y: hidden` (or scroll) with fixed viewport heights.
These beat our existing `main { overflow: visible !important }` print
override on specificity — attribute selectors outrank tag selectors
even when both use `!important`. Result: long plans printed only the
currently-visible viewport instead of flowing across pages.

Fix: add a print-scoped override that targets the library's attribute
selectors directly, setting overflow:visible, height:auto,
max-height:none, and display:block to defeat both the overflow clip
and the flex layout the library applies to host/padding wrappers.

Verified by printing a multi-page plan in the dev server.

For provenance purposes, this commit was AI assisted.

* fix(ui): persistent overlay scrollbar, remove dead reduced-motion rule

Switch autoHide from 'leave' to 'never'. The previous behavior faded
the scrollbar 800ms after the pointer left the scroll host, which felt
broken in a common interaction pattern: click a TOC entry (pointer in
the sidebar) → trackpad-scroll (pointer still in sidebar) → 800ms idle
→ scrollbar disappears. User had to hover the right edge to bring it
back on every interaction.

Persistent visibility matches the pattern used by every editor-class
technical app (VS Code, Zed, JetBrains, Xcode, Sublime) where the
scrollbar is both a position indicator and a targeting surface for
click-to-jump. Overlay scrollbars cost zero layout space, so "always
visible" has no downside.

Also removes the dead `.os-theme-plannotator .os-scrollbar` selector
from the reduced-motion block. The library applies the theme class
directly to the .os-scrollbar element itself (verified in the library
runtime source, not just CSS), so a descendant selector with a space
matches nothing. The track and handle selectors in the same block
work correctly (they really are descendants) and are preserved.

The component's own prefers-reduced-motion hook and helpers are now
unused (autoHide is unconditionally 'never') and removed. Reduced
motion is still honored for the hover color + width transitions on
track/handle via the CSS @media (prefers-reduced-motion: reduce)
block in theme.css, which the browser evaluates independently.

For provenance purposes, this commit was AI assisted.

* docs(ui): update OverlayScrollArea jsdoc to match persistent-scrollbar behavior

The component header still described the old autoHide:'leave' behavior
and implied the reduced-motion branch was in the component itself.
Neither is true after cf137bf. Rewrite the jsdoc to describe the
current behavior: always visible, no fade, reduced-motion handled via
a CSS media query in theme.css rather than a React branch.

For provenance purposes, this commit was AI assisted.

* fix(ui): recompute scrollbar on content resize (pierre/diffs expand-lines)

When pierre/diffs expanded context lines on a file whose diff previously
fit inside the viewport, the scrollbar stayed hidden until the user
manually scrolled or dragged the split-ratio handle to force a layout
recalculation.

Root cause: OverlayScrollbars' internal content observer doesn't see
the mutation because pierre/diffs renders inside a shadow DOM, and
MutationObserver does not pierce shadow DOM by default. The library's
own host-level size observer doesn't help either — the host (our
<main> / flex-1 container) has a fixed layout size that doesn't
change when content grows.

Fix: track the OverlayScrollbars instance in state and attach a
ResizeObserver to the viewport's first element child in a useEffect.
When the content's layout box grows — which happens even when the
growth originates inside a shadow tree, because layout size
propagates from shadow content to the shadow host — the observer
fires and calls `instance.update(true)` to recompute scrollbar
visibility. The call is debounced through requestAnimationFrame so
the browser commits the new layout before OverlayScrollbars reads
dimensions.

Verified manually in the compiled binary against PR #509 itself:
opening a small file, clicking pierre's expand-lines control, now
reveals the scrollbar immediately. No regression observed in normal
scroll / click-to-jump / file-switch / annotation-click paths.

For provenance purposes, this commit was AI assisted.

* docs(ui): document ResizeObserver content-resize mechanism

Add the content-resize auto-recompute behavior to OverlayScrollArea's
jsdoc header so the "what does this component do" summary is complete.
The inline comment on the effect already explains the mechanism; this
just surfaces it at the top.

For provenance purposes, this commit was AI assisted.
2026-04-07 14:17:32 -07:00
Michael Ramos ede10fef9c feat(ui): cross-file annotation visibility and adaptive navigation (#444)
- Shared CountBadge component replacing heavy TOC accent circles
- Per-file annotation count badges in FileBrowser and VaultBrowser
- Folder aggregate counts from descendant files
- Summary header showing total annotations across files
- Dot indicators on Files/Vault sidebar tabs when annotations exist
- Adaptive back button text (file list / file / message / plan)
- Cross-file annotation indicator in right panel with flash highlight
- Fix directory prefix matching bug (startsWith without trailing slash)
- Move flash animation CSS to shared theme stylesheet

For provenance purposes, this commit was AI assisted.
2026-03-30 21:39:00 -07:00
Michael Ramos b675e8677c feat: markdown file browser sidebar tab (#378)
* feat: markdown file browser sidebar tab (#367)

Add a Files sidebar tab that lets users browse and annotate markdown
files from their project. The project root is shown automatically —
no configuration needed. Users can add extra directories in Settings.

- Server: new /api/reference/files endpoint scans directories for .md
  files with broad exclusion list (node_modules, .git, dist, etc.)
- Server: projectRoot sent in /api/plan response (plan + annotate)
- UI: FileBrowser component with per-directory collapsible sections
- UI: useFileBrowser hook manages multi-directory tree state
- UI: Files tab in sidebar (collapsed + expanded), between Versions
  and Vault
- UI: Settings integration tab for additional directories
- Wired into linked doc system for annotation support

Closes #367

For provenance purposes, this commit was AI assisted.

* fix: simplify file browser - compute activeDirPath, fix re-fetch bug, dedupe settings logic

- Compute activeDirPath from activeFile + dirs instead of storing as
  separate state (removes state setter hack that abused setDirs as a
  read accessor)
- Fix auto-fetch not detecting directory changes: compare loaded paths
  against configured dirs instead of checking dirs.length === 0
- Extract addDirectory() helper in Settings to eliminate copy-pasted
  trim-validate-add-clear logic
- Remove "what" comments that described obvious code

For provenance purposes, this commit was AI assisted.

* fix: file browser prefix match, settings reactivity, and vault→files routing

- Add trailing slash to activeDirPath startsWith check to prevent prefix collisions
- Spread uiPrefs in handleFileBrowserChange so React sees a new reference on each settings edit
- Clear vaultBrowser.activeFile when opening a file browser doc to prevent misrouted links

For provenance purposes, this commit was AI assisted.
2026-03-23 15:47:58 -07:00
Michael Ramos a278fdaf77 feat: plan archive browser (#369)
* feat: plan archive browser with in-session sidebar tab (#362)

Add `plannotator archive` subcommand and archive sidebar tab for browsing
saved plan decisions from ~/.plannotator/plans/. Plans show approved/denied
badges and timestamps. In-session mode uses the linked doc overlay pattern
so users can reference old plans while reviewing a current one.

- New archive server (packages/server/archive.ts) following annotate pattern
- New ArchiveBrowser sidebar component, reusable in both contexts
- Archive listing/parsing functions in storage.ts (reads decision snapshots)
- Archive endpoints on plan server for in-session use (/api/archive/plans)
- Remove dead "Other Plans" UI, projectPlans state, /api/plan/history endpoint
- Fix resize handle touch area covering scrollbars in sidebar/main content
- Fix sidebar tab bar overflow when narrow

For provenance purposes, this commit was AI assisted.

* fix: code quality sweep for plan archive

- Remove `as any` cast: add "archive" to SessionInfo.mode union
- Replace inline import() type with proper import for ArchivedPlan
- Replace any[] with ArchivedPlan[] in fetch response types
- Fix infinite re-fetch when archive is empty (use hasFetched ref)
- Cache archive plan list in plan server (avoid re-scanning filesystem)
- Document ResizeHandle side prop behavior
- Remove redundant comment on Viewer archiveInfo prop

For provenance purposes, this commit was AI assisted.

* chore: remove dead marketing components

Step.astro and Landing.astro are unused — landing page inlines
step markup and pages use Base.astro directly.

For provenance purposes, this commit was AI assisted.

* fix: address code review findings for plan archive

- Path traversal: use resolve() + trailing separator guard (matches reference-handlers.ts)
- Thread customPath into in-session archive endpoints via query param
- Sort same-day archive entries by mtime instead of title
- Clear selectedArchiveFile on linked doc back to prevent badge leak
- Hide archive tab in annotate mode (server doesn't serve those endpoints)
- Add targetTab param to useLinkedDoc.open() to preserve calling sidebar tab
- Replace mutable render variable with index-based date grouping

For provenance purposes, this commit was AI assisted.

* refactor: collapse standalone archive server into plan server

Delete packages/server/archive.ts (187 lines) — nearly all duplicated
from the plan server. Add mode:"archive" option to startPlannotatorServer
instead. Fixes two bugs from code review:

- handleArchiveCopy now splits on "# Plan Feedback" marker instead of
  bare "---", preventing truncation at horizontal rules in plan content
- customPath support works in standalone archive mode (was only working
  in-session because the standalone server never received it)

For provenance purposes, this commit was AI assisted.

* refactor: extract useArchive hook from App.tsx

Move archive state (archiveMode, plans, selectedFile, isLoading) and
handlers (select, fetchPlans, done, copy) into a dedicated useArchive
hook. Reduces App.tsx by ~75 lines and makes the archive feature
self-contained.

For provenance purposes, this commit was AI assisted.

* feat: Pi archive parity + eliminate server duplication

Move runtime-agnostic storage, draft, and project functions from
packages/server/ to packages/shared/ — eliminating ~250 lines of
duplicated code in Pi's server.ts. Server package becomes thin
re-exports, preserving all existing import paths.

Add archive mode to Pi's plan review server (mode, routes, waitForDone)
and register /plannotator-archive command in the Pi extension. Consolidate
ArchivedPlan type to single definition in shared/storage.ts.

Simplify archive copy to include full content with feedback.

For provenance purposes, this commit was AI assisted.

* fix: drop -core suffix from Pi shared copies

The -core suffix broke cross-file imports — storage.ts imports from
./project which didn't resolve to project-core.ts. Using the original
filenames (no collision) lets relative imports work naturally.

For provenance purposes, this commit was AI assisted.

* fix: archive custom path bugs, disable sharing, update docs

- Normalize planDir via resolve() in getPlanDir() to handle relative
  paths and trailing slashes in the path traversal guard
- Re-fetch archive plans client-side with cookie-backed customPath
  so standalone archive respects the user's configured save location
- Disable sharing in archive mode (read-only viewer, no need)
- Remove dead /api/plan/history endpoint and listProjectPlans import
  from Pi extension
- Remove dead /api/plan/history mock from dev-mock-api
- Update CLAUDE.md and AGENTS.md: add archive flow, archive API
  endpoints, shared package structure, correct storage location,
  sidebar tab count, remove stale /api/plan/history references
- Update hook server docstring from four to five modes

For provenance purposes, this commit was AI assisted.

* fix: empty archive shows demo content, stale viewer after customPath fetch

- Clear demo markdown when archive opens with no plans (plan: "" was falsy,
  so setMarkdown was never called)
- Remove redundant fetchPlans() from archive init — server already sends
  archivePlans in initial response
- After fetchPlans() resolves with customPath results, auto-select and load
  the first plan into the viewer
- Remove dead listProjectPlans re-export from server barrel

For provenance purposes, this commit was AI assisted.

* refactor: gitignore Pi shared copies, add @generated headers

Pi's copied .ts files (storage, draft, project, feedback-templates,
review-core) are build artifacts generated from packages/shared/. They
looked like editable source files, leading to confusion about which file
to edit. Now gitignored like the HTML copies, with @generated headers
prepended by the build script.

For provenance purposes, this commit was AI assisted.

* fix: generate Pi shared copies in CI before tests

The Pi .ts copies are now gitignored build artifacts. CI needs to
generate them before running tests since server.test.ts transitively
imports them via server.ts.

For provenance purposes, this commit was AI assisted.

* fix: generate Pi shared copies in release pipeline test job

Same fix as test.yml — the Pi .ts copies are gitignored, so the test
job in the release pipeline also needs to generate them before bun test.

For provenance purposes, this commit was AI assisted.

* fix: use block scalar in CI workflow to avoid YAML parse error

The inline `run:` had a colon in the printf string that YAML
interpreted as a mapping key. Switch to `run: |` block scalar.

For provenance purposes, this commit was AI assisted.
2026-03-23 11:31:38 -07:00
Michael Ramos e5f3cfffbb feat: Obsidian vault browser sidebar tab (#209)
* feat: add Obsidian vault browser sidebar tab (#207)

Add a third sidebar tab that lets users browse their Obsidian vault's
file tree, click any markdown file to open and annotate it as part of
plan feedback. Includes wikilink support for navigating between vault
files.

- New /api/reference/obsidian/files and /api/reference/obsidian/doc endpoints
- useVaultBrowser hook and VaultBrowser sidebar component
- Wikilink [[filename]] and [[filename|display]] detection in Viewer
- "Vault Browser" toggle in Settings > Obsidian
- Bare filename search for wikilink resolution within vault

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: update docs and clear vault state on disable

- Add new reference endpoints to CLAUDE.md API table
- Update stale doc comments referencing "two tabs" in sidebar files
- Clear vaultPath and activeFile when vault browser is disabled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: simplify vault browser prop passing and derived state

- Move VaultNode to packages/ui/types.ts (shared type location)
- Pass useVaultBrowser return as single object prop through SidebarContainer
- Replace showVaultTab/vaultPath useState with useMemo derivations
- Extract buildVaultDocUrl helper to deduplicate URL template
- Wrap onVaultFetchTree in useCallback
- Fix toggle switch size to match existing toggles (h-6 w-11)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:32:59 -08:00
dgrissen2 93e9420cb4 feat: open linked .md files in read-only tabs with TOC navigation (#184)
* feat: open linked .md files in read-only tabs with TOC navigation

When a plan references local .md files via markdown links, they now open
in a new browser tab using the full Plannotator viewer in read-only mode.

Changes:
- server: add /api/doc?path= endpoint with 4-strategy path resolution
  (absolute, relative to project root, bare filename search). Returns 400
  with match list on ambiguous filenames, 404 when not found, 403 on
  path traversal. Only resolves files within process.cwd().
- Viewer: detect local .md links in InlineMarkdown and route them to
  /?doc=<path>&readonly=true with a new-tab icon. Add isReadOnly prop
  that guards the web-highlighter init (prevents phantom highlights) and
  suppresses annotation toolbars.
- App: parse ?doc= URL param on load and fetch from /api/doc instead of
  /api/plan. Set isReadOnly mode which shows a read-only banner, hides
  approve/deny/feedback buttons, ModeSwitcher, Settings, AnnotationPanel,
  and annotation panel toggle. Shows an error view (not a silent fallback)
  when the document cannot be found or is ambiguous. Opens the sidebar to
  the TOC tab automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: upgrade linked docs to same-view annotatable navigation with aggregated feedback

Replace the new-tab read-only approach with same-view navigation: clicking a
.md link swaps the plan content in-place, preserving all annotations via a
docCache. Users can annotate linked docs and all feedback (plan + linked docs)
is aggregated into the deny/approve payload sent to Claude.

Key changes:
- New useLinkedDoc hook for state swapping, caching, and highlight restoration
- React key on Viewer forces clean unmount/remount (fixes web-highlighter DOM crash)
- exportLinkedDocAnnotations() aggregates linked doc feedback with filepath context
- UI: 2px primary border, "Linked File" badge, "Copy file" button, TOC filepath
  indicator with back-to-plan button
- Fix Tailwind v4 cascade: move * { border-color } to @layer base so utility
  classes like border-primary actually override it
- Compact TOC spacing (padding, gaps, line-height) without affecting Version Browser
- Exclude node_modules/.git from /api/doc bare filename glob

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: harden /api/doc endpoint and align linked doc annotation output

Restrict /api/doc to .md/.mdx files only, fix heading hierarchy and
sorting in exportLinkedDocAnnotations, and update CLAUDE.md docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: complete linked doc annotations in getDocAnnotations and fix useMemo deps

- getDocAnnotations() now includes the active linked doc's live
  annotations alongside the cache, removing the implicit requirement
  to call back() before reading
- Remove unstable linkedDocHook object from annotationsOutput useMemo
  dependency array (was defeating memoization every render)
- Guard Cmd+Enter shortcut against linkedDocHook.isActive to prevent
  accidental approve/deny while viewing a linked doc

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: include linked doc annotations in approve feedback gate

The handleApprove gate only checked plan-level annotations, silently
dropping linked-doc-only feedback on "approve with notes" (OpenCode).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add getDocAnnotations to annotationsOutput useMemo deps

Ensures memo recomputes when navigating to/from linked docs, preventing
stale annotation output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-02-26 20:26:06 -08:00
Michael Ramos 819ba11f77 feat: plan diff UI with sidebar and dual view modes (#176)
* feat: add plan diff UI with sidebar, badge, and dual view modes

Shows what changed between plan iterations when Claude revises after
feedback. Adds a +N/-M badge below repo info that toggles the diff view,
a shared left sidebar with TOC and Version Browser tabs, and two diff
modes: rendered (color-coded borders) and raw markdown (+/- lines).

Closes #138, closes #111

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: update CLAUDE.md project structure and align first-run dialog labels

- Add plan-diff/ and sidebar/ component subdirectories to CLAUDE.md
- Add new hooks and utils to CLAUDE.md project structure
- Rename "Table of Contents" to "Auto-open Sidebar" in UIFeaturesSetup
  to match Settings.tsx label

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address code review findings for plan diff UX

- Fix badge stats mixing block counts with line counts (modifications
  now fold into additions/deletions)
- Gate hasPreviousVersion on diffBasePlan being loaded to prevent
  "Show Changes" no-op and ModeSwitcher disappearing
- Make sidebar reactive to Settings toggle (useEffect on tocEnabled)
- Match PlanDiffViewer badge layout to Viewer (flex-col) so badge
  doesn't jump position on toggle
- Add "Exit Diff" label to the close button in diff view
- Remove dead CSS (plan-diff-removed-marker, plan-diff-modified)
- Clean up stale header comment and unused lines prop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: second-round review cleanup for plan diff UX

- Fix stale "amber border" JSDoc in PlanCleanDiffView (actually green)
- Rename sidebar tab from "diff" to "versions" for clarity
- Gate VersionBrowser fetch on versionInfo being available
- Move .sidebar-tab-flag CSS into its own Sidebar section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add loading state for version selection in sidebar

Add isSelectingVersion to selectBaseVersion, mirroring the existing
isLoadingVersions pattern. Shows "Loading..." on the selected version
button while the fetch is in progress.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address third-round code review findings

- Fix duplicate border/backdrop on TOC inside sidebar (className override)
- Fix loading indicator targeting wrong version button (fetchingVersion state)
- Fix "Show Changes" button silent no-op (gate on hasPreviousVersion)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: move date to slug suffix, improve Other Plans UX

- Slug format changed from YYYY-MM-DD-{heading} to {heading}-YYYY-MM-DD
- Other Plans: single "coming soon" banner instead of per-item labels
- Strip date suffix from plan names in sidebar for readability
- Remove cursor-not-allowed from Other Plans items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add Plan Diff section to CLAUDE.md, alert on version fetch failure

- Document plan diff feature: engine, view modes, state management, sidebar
- Update slug format documentation to {heading}-YYYY-MM-DD
- Show native alert when version fetch fails instead of silent swallow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add table rendering to clean diff view

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:59:44 -08:00