Commit Graph

1098 Commits

Author SHA1 Message Date
Michael Ramos 2f1d6a94a9 feat(ui): re-export slashCommands and selectionToolbar through the MarkdownEditor surface 2026-07-09 16:42:46 -07:00
Michael Ramos 1056821f1a chore: relock against published atomic-editor 0.7.0 / markdown-editor 0.3.2 2026-07-09 15:09:27 -07:00
Michael Ramos 7d846879a1 chore(ui): retarget deps to engine 0.7.0 / wrapper 0.3.2 (release train moved)
0.6.0 and 0.3.1 will never be published; the engine is stamped 0.7.0 (adds
preferResolvedLabel on WikiLinksConfig — labeled links opting into resolved
titles, the flag the consumer needs) and the wrapper 0.3.2 (peer widened to
^0.5 || ^0.6 || ^0.7). The previous ^0.6.0 range would have excluded 0.7.0
under 0.x caret semantics and stripped preferResolvedLabel from the
re-exported type.

- @plannotator/atomic-editor ^0.7.0, @plannotator/markdown-editor ^0.3.2
- HANDOFF/README version references updated
- wikiLinks re-export test now sets preferResolvedLabel: true — a type-level
  seam assertion (excess-property checking rejects unknown keys, verified by
  negative control)

Re-verified against fresh npm-pack tarballs of both packages: full repo
typecheck, ui tests (354+66 skip plain / 420 DOM, 0 fail), build:css — all
green. Install still resolves only after the user publishes 0.7.0 + 0.3.2.
2026-07-09 11:30:40 -07:00
Michael Ramos b297410b5d chore(ui): 0.27.0 — wiki-link seams HANDOFF/README docs + version bump
Documents the 0.27.0 round for the Workspaces consumer: the MarkdownEditor
'extensions' seam with its capture-once-per-documentId caveat, the wikiLinks
re-export through the ui surface, InlineMarkdown's sync resolveLinkedDoc
semantics, and the retirement of the H-ask-1 html-viewer patch. Marks HANDOFF
rough edge #4 (no live-collab extensions) resolved.

Publish is manual and user-gated per HANDOFF (bun pm pack, then npm publish
--provenance, core then ui); requires @plannotator/markdown-editor 0.3.1 and
@plannotator/atomic-editor 0.6.0 on npm first.
2026-07-09 11:23:50 -07:00
Michael Ramos e0519f84c2 fix(ui): remove the two TS6133 offenders in html-viewer (retires Workspaces' H-ask-1 patch)
Workspaces carried two byte-identical one-line fixes against 0.25/0.26 for
strict tsc (--noUnusedLocals) on the shipped source. Verified with tsc under
the strict-consumer flags scoped to components/html-viewer: exactly two
TS6133s, no other strict errors.

- HtmlViewer.tsx: drop the unused React default import (jsx: react-jsx;
  zero React.* uses)
- useHtmlAnnotation.ts: drop the unused 'annotations' destructured binding;
  the UseHtmlAnnotationOptions property stays (callers pass it — removing it
  from the options type would be a breaking API change)
2026-07-09 11:22:26 -07:00
Michael Ramos 95c9a111cf feat(ui): resolveLinkedDoc — sync host resolution of wiki-links in InlineMarkdown
New optional prop:
  resolveLinkedDoc?: (target: string) =>
    { label?: string; status?: 'active' | 'deleted' } | null

Semantics (consumer-contracted, sync-only by design — the host backs it with
an in-memory cache; no async variant, no loading states, no phantom docs):
- absent or null → exactly today's rendering (stored label, live link)
- label → displayed instead of the stored label; stored label is the
  fallback, the target the last resort
- status 'deleted' → muted non-link span (no anchor, no pointer, no link
  icon, title "Document deleted"), even when onOpenLinkedDoc is present
- the callback receives the RAW stored target (doc_01XYZ), before the
  .md-appending path normalization; onOpenLinkedDoc keeps receiving the
  normalized path for non-deleted links, exactly as before

Threaded through all six recursive InlineMarkdown call sites so wiki-links
inside emphasis/strikethrough resolve too. Tests pin every branch, including
null → byte-identical innerHTML with and without onOpenLinkedDoc.
2026-07-09 11:21:35 -07:00
Michael Ramos 36441ab398 feat(ui): re-export wikiLinks + config types through the ui surface
Hosts' import allowlist forbids @plannotator/atomic-editor; @plannotator/ui is
the single contract. wikiLinks and its types (WikiLinksConfig,
WikiLinkSuggestion, WikiLinkResolvedTarget, WikiLinkStatus) now re-export from
components/MarkdownEditor — colocated with the 'extensions' prop they feed,
already on the supported-import allowlist and under the strict-consumer gate.

Usage: build wikiLinks(config) and pass it via the shim's extensions prop.

Test pins identity with the engine export and exercises the type surface.
2026-07-09 11:19:19 -07:00
Michael Ramos 502b19dc69 feat(ui): 'extensions' passthrough on the MarkdownEditor shim
Declares extensions?: readonly Extension[] (@codemirror/state) on the shim's
props; the existing prop spread forwards it to @plannotator/markdown-editor,
which threads it into the atomic-editor engine. This is the seam Workspaces
needs to install wikiLinks(...) and, later, y-codemirror.next collab bindings
without importing the engine directly.

The doc comment carries the consumer-required rider prominently: the engine
captures the array ONCE per documentId — swapping the array later is silently
ignored until remount; config callbacks may close over live state.

Test mounts through the SHIM with a facet-based probe extension
(EditorView.editorAttributes) and asserts the attribute lands on the engine's
.cm-editor DOM, proving the seam end-to-end, plus byte-fidelity is untouched.
2026-07-09 11:18:36 -07:00
Michael Ramos 62c0809350 chore(ui): require @plannotator/markdown-editor ^0.3.1 and @plannotator/atomic-editor ^0.6.0
0.3.1 adds the editor 'extensions' passthrough; 0.6.0 adds the wikiLinks
extension. Neither is published to npm yet (publishes are imminent and
user-gated): install/CI resolves only after they land. Local verification
for this branch ran against tarballs built from the source repos via
npm pack.
2026-07-09 11:15:42 -07:00
Michael Ramos 858b7ff5dd chore(ui): 0.26.0 — resize-handle seams + file-browser filtering HANDOFF notes + version bump
The published 0.25.0 tarball predates the file-browser filtering
(1267cd50) and resize-handle click-to-collapse seams (f4f6bdb0, #1028);
bump so those ship as 0.26.0.

Claude-Session: https://claude.ai/code/session_01SFy9fY27SA8g5BtotWPi1G
2026-07-08 23:06:59 -07:00
Michael Ramos f4f6bdb0e9 feat(ui): handle-wide click-to-collapse + suppressible hover on resize handles (#1028)
* feat(ui): handle-wide click-to-collapse + suppressible hover on resize handles

Rework the sidebar/panel resize-handle interaction:

- useResizablePanel: new `onClick` option that fires on pointer-up only when
  the pointer never traveled past `clickThreshold` (4px default), so a genuine
  click on the handle can be told apart from a drag-start. Plannotator is
  unchanged unless `onClick` is passed.
- ResizeHandle: `hideHoverTrack` to suppress the hover color-change, a
  cursor-following `tooltip`, a `trackClassName` prop, and a
  `[data-resize-track]` attribute (mirrors `[data-collapse]`) so a host can
  restyle/suppress the hover reveal from plain CSS.
- Wire all plan-editor and review-editor handles to the new UX: no track
  highlight, cursor tooltip ("Click to close · Drag to resize"), single-click
  collapse (double-click reset becomes unreachable), chevron retained.
- README: document the new resize-handle host seams.

* refactor(ui): self-review polish on resize-handle UX

- Skip tooltip-position state updates while dragging (they were hidden anyway).
- Extract the repeated resize-handle tooltip string into a per-app constant
  (RESIZE_HANDLE_TOOLTIP) instead of 7 inline literals.

* fix(ui): don't treat pointercancel as a click-to-collapse

pointercancel fires when the browser aborts a gesture (palm rejection, system
gesture, focus loss). It was routed through the same onUp path as pointerup, so
a no-move cancel with onClick set would collapse the panel. Skip the onClick
branch on cancel — only clean up drag state (a real in-progress drag still
commits its width, matching prior behavior).
2026-07-08 22:50:29 -07:00
Michael Ramos 1267cd50db Add file browser filtering 2026-07-08 22:32:20 -07:00
Michael Ramos 381a876dac chore(ui): 0.25.0 — HANDOFF rendering-neutrality notes + version bump
HtmlViewer no longer injects bare theme tokens, root classes, or
color-scheme into rendered documents (#1023); host theming is opt-in
via <meta name="plannotator-theme" content="host">. Supersedes
Workspaces' H-ask-1 patch.

Claude-Session: https://claude.ai/code/session_01MDqD8jdgTbdjiXcVVigzV2
2026-07-08 13:15:55 -07:00
Michael Ramos 1047539801 Render arbitrary HTML in HtmlViewer without altering it (#1023)
HtmlViewer injected host-app state into rendered documents: ~26 bare
theme tokens (--muted, --background, ...) written into :root and
re-applied inline on the author's documentElement on every host theme
flip, a `light` class toggled on the author's root, an asymmetric
color-scheme:light injection, and unconditional ins/del diff styles.
Documents defining the same token names rendered with wrong colors in
both Plannotator and @plannotator/ui consumers.

Arbitrary documents now render exactly as in a standalone tab:

- Host tokens are pushed only under the viewer-owned --pn-* prefix;
  annotation CSS and the bridge read only var(--pn-*, fallback). The
  bridge refuses non---pn- writes and never touches the root class
  list unless the document opts in.
- Host theme-following is opt-in per document via
  <meta name="plannotator-theme" content="host">, which restores the
  bare-token push, the light class, and a symmetric color-scheme sync.
  The visual-explainer skill now emits the tag in generated artifacts.
- Diff CSS is injected only while the version-diff view is active and
  scoped to ins/del.plannotator-diff, which htmlDiff now emits on its
  generated wrappers; author <ins>/<del> markup is never restyled.

The srcdoc injection logic moves to a pure module (srcdoc.ts) with
tests pinning the neutrality contract, including a DOM test that runs
the actual bridge script and asserts a theme flip lands nothing on the
author's root except --pn-* properties.

Claude-Session: https://claude.ai/code/session_01MDqD8jdgTbdjiXcVVigzV2
2026-07-08 13:11:01 -07:00
Michael Ramos d3c9de1ef0 Fix annotate terminal startup in large folders
Avoid starving folder annotate sessions by excluding agent work directories and capping folder file-browser scans. Improve the annotate WebTUI panel startup/stop behavior.
2026-07-08 12:33:12 -07:00
Michael Ramos e20fd97be7 Consumer enablement for @plannotator/ui (0.24.0) (#1017)
* feat(ui): AnnotationPanel renderCardFooter + readOnly host props

Per-card footer slot for host reply/resolve UI (clicks inside don't
select the card); readOnly hides delete/edit on all card kinds.
Both optional, both no-op by default — Plannotator unchanged.

* feat(ui): bless 6 exports into the strict-consumer surface

TableOfContents, ResizeHandle, useResizablePanel, useActiveSection,
useScrollViewport, utils/annotationHelpers — verified strict-clean and
backend-free (useResizablePanel persists via the storageBackend seam);
added to the gate and the HANDOFF supported-imports table.

* feat(ui): Viewer allowImages + readOnly host props

allowImages threads to both CommentPopover sites (popover already gated
its attach affordance; Viewer never exposed the knob). readOnly
suppresses every composer entry point — selection toolbar (highlighter
'enabled'), pinpoint, global comment, attachments, checkbox toggles —
while existing annotations still render and select.
Both default to today's behavior.

* fix(ui): lazy-import Viewer/CommentPopover in the consumer test (DOM-less bun test crashed on web-highlighter)

* feat(ui): strict-consumer gate gains verbatimModuleSyntax + noUnusedLocals/Parameters

Fixed the 24 violations the flags surfaced across the supported-import
graph: type-only imports (verbatimModuleSyntax) and dead imports/locals.
Consumers with stricter tsconfigs no longer have to relax them.

* feat(ui): opt-in content-verifying annotation restore

verifyRestoredContent on useAnnotationHighlighter: after a meta-based
fromStore restore, the painted text is checked against originalText
(whitespace-normalized). Mismatch -> highlight removed, text-search
fallback re-anchors; if that fails too, onRestoreMismatch(annotation,
restoredText) fires and nothing is painted. Default off — today's
trust-the-positions behavior. Workspaces hit this live after document
drift; correctness upgrade for every consumer.

* chore(ui): 0.24.0 — HANDOFF consumer-enablement notes + version bump
2026-07-07 19:09:52 -07:00
Michael Ramos 58f87500f1 Migrate packages/review-editor to Base UI (#1014)
* refactor(review-editor): migrate tour QA checkbox to Base UI

First component of the Radix→Base UI migration (internal package).
@base-ui/react added alongside Radix; Radix deps removed in the final
commit. Root typecheck, bun test (1955 pass), build:review all green.

Note: bun.lock also absorbs pre-existing manifest drift (the committed
lock was stale relative to several package.jsons at HEAD — any install
regenerates it identically).

* chore(review-editor): give the package its own typecheck gate

The package had no tsconfig and is absent from the root typecheck
chain (it is typechecked only through apps/review, whose own tsc run
is broken for unrelated reasons: it never declares @types/react).
Adds a package tsconfig modeled on apps/review's compilerOptions plus
@types/react(-dom) devDeps so migration transforms are type-verified.
53 pre-existing errors surfaced (import.meta.env, response-type
mismatches) — recorded, untouched; gate for the migration is zero
errors in migrated files.

* refactor(review-editor): migrate all 7 popover files to Base UI

Portal>Content becomes Portal>Positioner>Popup (positioning props and
z-index on Positioner), asChild triggers become render props, state
classes move to Base UI presence attributes (data-popup-open on
triggers), --radix-* vars become --transform-origin, and keyframe
enter animations restate as data-starting/ending-style transitions.
Focus interception (onOpenAutoFocus) maps to Popup initialFocus,
including WorktreePicker's conditional search-input focus.

Flagged, not patched: collision defaults differ slightly at viewport
edges; exit fade now animates where Radix unmounted instantly. See
.migration/popover.md. Tests (1955) and build:review green; zero tsc
errors in migrated files.

* refactor(review-editor): migrate DiffTypePicker to Base UI Menu

Radix DropdownMenu is renamed Menu in Base UI; same restructure as the
popovers (Positioner/Popup split, render triggers, --anchor-width for
trigger-width matching). Item onSelect becomes onClick. Flagged: focus
loop now wraps by default (Base UI default). See
.migration/dropdown-menu.md.

* refactor(review-editor): migrate FileTreeNode context menu to Base UI

Same menu anatomy via @base-ui/react/context-menu (which re-exports
Menu's Positioner/Popup/Item parts). Row button keeps left-click
select / double-click open; items switch onSelect→onClick. See
.migration/context-menu.md for the hand-verify checklist — this is
the file tree's core interaction.

* refactor(review-editor): drop all Radix deps, finish Base UI migration

Removes the 7 @radix-ui/* dependencies — three of them (collapsible,
dialog, tooltip) were never imported anywhere and go without a code
change. Also fixes two keyboard-nav guards in FileTree/SectionsPanel
that matched Radix's [data-radix-popper-content-wrapper] portal
attribute (Base UI popups carry ARIA roles directly, so the role
selectors already cover every floating surface — verified against the
shipped popup implementations).

Zero radix references remain in the package (deps, imports, class
strings, CSS vars, DOM selectors). Root typecheck, 1955 tests, and
build:review all green. packages/ui untouched. Full summary in
.migration/project.md.

* fix(review-editor): initialFocus fallback semantics in pickers

Self-review catch: Base UI's initialFocus function form treats a
returned undefined as 'do nothing' (focus stays on the trigger) while
null falls back to the default focus. The '?? undefined' I introduced
would strand focus on the trigger for short worktree lists — the exact
arrow-keys-leak-to-file-tree bug the original Radix code's comment
warned about. Returning searchRef.current directly (element or null)
matches the original semantics. Also corrects the popover report and
adds a Tooltip-prop coordination note for the parallel ui migration.

* docs(review-editor): record automated QA results in migration reports

Playwright pass against the demo review session: context menu (full
flow incl. real clipboard), tour checkboxes (all three toggle paths,
incl. the span+hidden-input label association), diff-options popover,
and DOM invariants (roles, data-popup-open, no data-state on migrated
primitives) all pass. Demo mode never populates gitContext, so the
diff-type dropdown and worktree/base pickers don't mount there —
their checks remain on the manual list against a real session.

* docs(review-editor): annotate QA checklist items untestable from the standalone entrypoint

* chore: regenerate bun.lock after rebase onto Base UI ui migration
2026-07-07 12:42:41 -07:00
Michael Ramos ca34a8bbf7 Migrate @plannotator/ui to Base UI (0.23.0) (#1013)
* chore: restore packages/core workspace entry missing from bun.lock

#957 merged without its bun.lock update; fresh installs couldn't
resolve @plannotator/core workspace links.

* chore(ui): install @base-ui/react alongside radix; migration assessment

* feat(ui): migrate badge to Base UI (Slot -> useRender, asChild -> render)

* feat(ui): migrate button to Base UI Button primitive (asChild -> render)

* feat(ui): migrate tabs to Base UI (Trigger->Tab, Content->Panel, data-active)

* feat(ui): migrate dialog to Base UI (Overlay->Backdrop, Content->Popup)

* feat(ui): migrate dropdown-menu to Base UI Menu + sweep both consumers

* feat(ui): migrate Popover wrapper to Base UI (Positioner model; drop PopoverAnchor)

* feat(ui): migrate Tooltip wrapper to Base UI, public API preserved

* feat(ui): migrate PopoutDialog to Base UI (non-modal + reason-based dismissal guard)

* feat(ui): remove all @radix-ui dependencies — package is fully Base UI

* chore(ui): 0.23.0 release notes — Base UI engine, drop tailwindcss-animate peer

HANDOFF.md documents the engine swap for the Workspaces consumer:
dependency changes, 11 breaking/behavior items, what did not change.
Version bumped to 0.23.0; publish stays owner-gated.

* docs(ui): record browser smoke results in migration report

* fix(ui): self-review findings — caret Base UI range, Button type default documented, PopoutDialog focus-out guard hardened

- @base-ui/react 1.6.0 -> ^1.6.0: a consumer's own Base UI install must
  dedupe with ours or portals lose context across copies
- HANDOFF item 12 + button report: Button now defaults type="button"
  (implicit form submit is a consumer-only breaking change)
- PopoutDialog: guard both event.target and relatedTarget on focus-out —
  blur-shaped events carry the annotation toolbar in relatedTarget

* docs(ui): record human hand-verification pass in migration report

* feat(ui): 150ms exit fade on popovers + prefers-reduced-motion for all Base UI popups

Popover enter/exit unified on starting/ending-style transitions (the
popover-enter keyframe only existed in review-editor CSS — plan-app
popovers had no enter animation at all). Reduced-motion rule in theme.css
covers dialogs, menus, popovers, tooltips in both apps.
2026-07-07 12:32:51 -07:00
Michael Ramos fc191e120d feat(marketing): set a dedicated OG/social image for /code-review (#1012)
Uses a 1200x630 crop of the guided-review shot for the page's og:image and
twitter:image (summary_large_image), instead of the generic site card.
2026-07-07 09:29:17 -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
Leonardo Reis 73cd4b121d feat(annotate): add sidebar shortcuts (#986)
* feat(annotate): add sidebar shortcuts

* fix(shortcuts): double-tap requires solo presses

The double-tap engine counted ANY two keyups of the tracked key within
the window, so the new Shift Shift agent-terminal toggle fired during
ordinary Shift usage: typing two capitalized words, extending a
selection with two Shift+Clicks, or pressing this PR's own Mod+Shift+B
twice. A tap now only counts when the key went down and came up alone —
any other keydown, a pointer interaction mid-press, or another modifier
held at release breaks the press and resets the sequence. Also honors
the binding's preventDefault flag in the double-tap path (it was a
silent no-op).

---------

Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-07-06 19:13:12 -07:00
egouilliard-leyton 73efacfa0c feat(annotate): per-file version diff for .md and .html (rendered HTML highlights) (#961)
* feat(annotate): version diff for annotated files

Annotate mode never tracked version history, so the existing Plan Diff
(highlighted diff vs a previous version) only worked in plan mode. Wire
per-file version history into the annotate server so the same diff UI —
badge, Version Browser, block-level comments — works when annotating a
standalone .md/.txt/.html file.

- key history by file path (stable across edits) rather than the plan
  flow's heading+date slug
- save the markdown (or raw HTML source) to history on each open, expose
  previousPlan + versionInfo + diffCurrent on /api/plan
- add /api/plan/version and /api/plan/versions to the annotate server

Markdown lights up end to end; HTML needs frontend follow-ups (feed the
HTML source as the diff content, surface the badge on the html surface,
default to source diff mode).

* feat(annotate): rendered HTML version diff with inline highlights

For --render-html files, render the version diff as the real page with
inline <ins>/<del> highlights instead of a markdown/source diff:

- add packages/shared/html-diff.ts: a tag-aware htmlDiff() that wraps
  changed text in <ins>/<del> while keeping tags balanced (script/style
  opaque). 9 unit tests.
- annotate server computes diffHtml = rewriteHtml(htmlDiff(prev, current))
  and exposes it on /api/plan
- HtmlViewer: inject ins/del highlight CSS, add a 'Show/Hide changes'
  toggle in its action bar
- App: store diffHtml, swap the iframe to the diff page when toggled, and
  suppress the markdown block-diff path on the HTML surface

Commenting still works because the diff page renders through the same
HtmlViewer iframe bridge.

* docs(annotate): document the annotate version diff + endpoints

* feat(annotate): mirror version diff into the Pi server

Parity for the Pi (node:http) runtime: per-file version history,
previousPlan/versionInfo/diffCurrent + diffHtml on /api/plan, the
/api/plan/version[s] endpoints, and project wiring from the Pi CLI.
Vendors @plannotator/shared/html-diff into pi-extension/generated.

* review fixes: pi diff dependency, attr-aware tokenizer, history opt-out, hide dead version picker on HTML

- apps/pi-extension/package.json: declare the 'diff' dependency —
  generated/html-diff.js imports it at module load, so a standalone Pi
  install failed to resolve it and broke every annotate session (the
  monorepo masked this via root hoisting)
- packages/shared/html-diff.ts: tag tokenizer now consumes quoted
  attribute values whole, so a '>' inside title="a > b" no longer
  splits the tag and corrupts the diff output; 3 regression tests
- annotate history is now gated by PLANNOTATOR_ANNOTATE_HISTORY /
  config.annotateHistory (default on) and disclosed in AGENTS.md —
  it writes copies of annotated files into the data dir, which users
  should be able to see coming and turn off
- packages/editor/App.tsx: hide the sidebar Versions tab on the HTML
  surface — the base-version picker has nothing to drive there (the
  HTML diff is fixed to current-vs-previous); the viewer's Show
  changes toggle is unaffected

* fix(ui): document content clears the badge cluster dynamically

The repo/diff badge cluster is absolutely positioned in the card's top
padding, sized by guesswork (py-5..py-12). One chip row fit; the diff
badge's second row overflowed into the H1, and mobile wrapping made the
badge sit on top of the heading. Measure the cluster (ResizeObserver)
and insert exactly the clearance it needs — zero when it fits, so
existing single-row layouts don't shift. Pre-existing plan-mode bug
surfaced by the annotate version diff.

---------

Co-authored-by: Edouard Gouilliard <edouard.gouilliard13@gmail.com>
Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-07-06 19:13:10 -07:00
Stan 762700fa03 feat(install): add --minimal binary-only install mode (#989)
* feat(install): add --minimal binary-only install mode

install.sh installs the plannotator binary and then writes a large amount
of extra state: the sem sidecar, the agent-terminal runtime, and per-agent
skills, hooks, slash commands, and config for Claude, Codex, OpenCode,
Gemini, and Kiro. There is no way to get just the binary — even the
--no-extras path still writes ~/.claude skills and OpenCode commands.

Add a --minimal flag (aliased --binary-only) plus a PLANNOTATOR_MINIMAL
env var for `curl | bash` runs. Minimal mode installs only the binary to
~/.local/bin, prints PATH advice, and exits before any sidecar download,
agent integration, skill checkout, config write, cache clear, or cleanup
migration. Precedence: flag > env var > default (off).

The PATH-advice block is extracted into print_path_advice() so both the
minimal early exit and the normal flow reuse it.

Closes #977

* feat(install): mirror minimal mode to ps1/cmd, add --no-minimal, docs

Extend the binary-only install mode to full cross-installer parity and
address the Copilot review on #989.

install.sh:
- Add --no-minimal (MINIMAL_FLAG=0) so a CLI flag can override
  PLANNOTATOR_MINIMAL=1 in both directions; the -1/0/1 comment is now
  accurate. --minimal/--no-minimal are mutually exclusive.
- Document the --binary-only alias and soften the absolute "nothing
  written outside ..." wording to "no persistent state" (minimal mode
  still uses a temp download file and may read the config dir).

install.ps1 / install.cmd:
- Add -Minimal (alias BinaryOnly)/-NoMinimal and --minimal/--binary-only/
  --no-minimal with PLANNOTATOR_MINIMAL resolution, mirroring install.sh.
- Extract PATH advice into Show-PathAdvice / :PrintPathAdvice and reuse it
  in both the minimal early exit and the normal flow.
- Early-exit after the binary is placed, before the sidecar/agent/skill/
  config work.

Tests: add minimal-mode assertions to the install.ps1 and install.cmd
describe blocks plus a shared-behavior test asserting all three installers
support it; extend the sh tests for --no-minimal.

Docs: document --minimal / PLANNOTATOR_MINIMAL in the installation guide
(noting minimal mode needs no git) and add PLANNOTATOR_MINIMAL (and backfill
PLANNOTATOR_SKIP_SEM_INSTALL) to the env-var registries.

* docs(install): correct minimal-mode PATH wording (ps1 persists PATH)

The PowerShell installer's minimal path calls Show-PathAdvice, which
persistently writes the user PATH — so "no persistent state is written
outside $installDir" overpromised. Reword the install.ps1 comment and the
installation docs to describe what minimal mode skips (sidecar, agent
runtime, per-agent integrations) and note the binary + PATH entry are still
added on Windows. Addresses the second Copilot review on #989.

* fix(install.cmd): use delayed expansion for PLANNOTATOR_MINIMAL checks

The three new env-var checks used immediate %VAR% expansion; under
setlocal enabledelayedexpansion a poisoned value re-exposes cmd
metacharacters at parse time (command injection). Switch to the !VAR!
idiom the script already uses for every other untrusted env var (e.g.
the PLANNOTATOR_VERIFY_ATTESTATION block).

---------

Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-07-06 15:43:42 -07:00
Michael Ramos 8fcecce495 fix(review): render tables and inline video in PR comments / descriptions / commit messages (#1007)
The lightweight MarkdownBody renderer (PR comments, PR descriptions,
commit messages) had no 'table' case — pipe tables fell through to the
paragraph branch and collapsed into one line of raw pipes. Add a
compact table renderer reusing parseTableContent from TableBlock (one
pipe parser), with inline markdown in cells.

Also render a paragraph consisting solely of a link (or bare URL) to a
video file (.webm/.mp4/.mov/.m4v/.ogg) as an inline <video controls>
player with a fallback link — GitHub can't inline-play API-posted video
links, but our review UI now can.

MarkdownBody moves to its own module (it was living inside
PRSummaryTab while serving three components); importers re-pointed.
New markdownBody.test.tsx covers tables, escaped pipes, video links,
bare video URLs, and non-video paragraphs via renderToString (runs in
plain CI, no DOM needed).
2026-07-06 14:27:16 -07:00
Michael Ramos ca3f7607d7 chore(ui): move to the plannotator/atomic-editor fork (editor 0.2.0) (#1002)
Swap @atomic-editor/editor@^0.4.3 for @plannotator/atomic-editor@^0.5.0
and bump @plannotator/markdown-editor to ^0.2.0 (its peer dependency was
renamed to the fork). The fork adds proper frontmatter parsing (a leading
--- block no longer misrenders as an HR + giant heading) and the
Obsidian-style Properties widget, with byte-fidelity preserved.

Also exempt @plannotator/atomic-editor from bun's minimum-release-age
gate (same first-party trust as @plannotator/markdown-editor).

Verified: typecheck clean, DOM_TESTS=1 markdownEditorFidelity 4/4,
full suite 1892 pass / 0 fail, review + hook builds succeed.
2026-07-06 13:07:38 -07:00
Michael Ramos 9863e44aec Code review page: copy pass + inline guided-review demo (#1006)
* docs(marketing): tighten code-review page copy to match project voice

Rewrites the /code-review copy in a plainer, more technical register:
leads with the git/jj/Perforce diff viewer and the annotate → agent
feedback loop, names GitHub/GitLab PRs and worktrees, adds the local-API
and ~/.plannotator compound-engineering framing, and states the
MIT / Apache-2.0 license. Drops 'keep the final say' from the
agent-reviews heading — now just 'Run review agents'.

* feat(marketing): inline guided-review demo video

The Guided Review section now plays a muted, looping demo inline (~5 MB,
1440px), clickable to open a fullscreen player with controls. Other
feature screenshots keep the image lightbox. Ships guided-review-demo.mp4
+ poster (committed to public/assets and uploaded to the CDN).

* chore(marketing): update hero demo video with guided-review footage

Re-renders the code-review demo with the real Guided Review recording
swapped into the guided scene, and refreshes the CDN copy + poster.
2026-07-06 12:59:37 -07:00
Michael Ramos 5fdc3e4542 feat(marketing): code review landing page with demo video (#1003)
- New /code-review page: hero with a demo-video launcher + modal player,
  alternating feature sections (annotate, git status, commits, PRs, Ask AI,
  guided review, agent reviews), the external-annotations integration diagram,
  and a compound-engineering loop section.
- Feature screenshots open in a fullscreen lightbox (mobile-friendly); on
  mobile the screenshot sits above its prose.
- Ships the ~2-min product demo (code-review-demo.mp4) + poster, committed so
  marketing deploys re-ship them; also uploaded to the CDN.
- Footer 'Code Review' now links to the new page instead of the docs command.
2026-07-06 10:02:27 -07:00
Michael Ramos 82f5648ccb chore: bump version to 0.22.0 v0.22.0 2026-07-05 11:57:30 -07:00
Michael Ramos 42686f8f1e docs(marketing): bring the code-review reference up to the since-base era
The page still described the pre-#990 product: uncommitted default, no
Git status/Tree/Commits panel views, no Guided Review, an agent roster
without the marker engines, and a Server API table missing nine
endpoints. Rewritten to match what ships.
2026-07-05 11:50:41 -07:00
Michael Ramos 3ebb28a835 fix(review): avatar lookups can no longer delay /api/commits
The gh/glab runner has no subprocess timeout, and the commits endpoint
awaited avatar resolution before responding — a hanging network (proxy
black-hole, dead DNS) held the already-computed commit list hostage to
decoration. resolve() now races a 4s ceiling: on expiry the endpoint
returns with the initials fallback, the in-flight fetch finishes in the
background, and later calls pick its results out of the cache. Timed-out
attempts memoize no misses. Shared module, so both runtimes get it.
2026-07-05 11:50:41 -07:00
Michael Ramos b37d10a8e0 fix(review): anchor commit-diff annotations to their commit in feedback
Annotations made on a commit:<sha> diff carry line numbers from that
commit's diff-vs-parent, but the feedback export derived its one header
from the send-time diff — annotate a historical commit, switch back to
since-base, hit Send Feedback, and the agent reads those line numbers
against the wrong diff. Stamp annotations with the active commit (sha +
subject) at creation, mirroring the prUrl pattern, and label any
anchor/header mismatch in the export (both directions). The commit-diff
header itself now reads "Commit `abc1234` — subject" instead of the raw
mode string.
2026-07-05 11:50:27 -07:00
Michael Ramos 9fc2c6e9bd fix(review): keep the filename visible in git-status rows
Long paths in the Sections panel end-truncated, hiding the one part that
matters. TruncatedPath lets the directory absorb all truncation (ellipsis
lands right before the final slash); only a filename that alone exceeds the
row truncates at its own end. Full path stays on the row tooltip.
2026-07-05 11:50:13 -07:00
Michael Ramos ed4f2e82fe feat(review): Guided Reviews intro dialog + Guide button discovery hint (#1000)
* feat(review): one-time Guided Reviews intro dialog + Guide button hint

New GuideIntroDialog slots into the announcement chain (look-and-feel ->
guide intro -> review setup, never stacking): a hero screenshot of a
guided review with a brief plain-language description and a mock header
strip pointing at the real Guide button. Shows once per user (cookie),
and only when a Guide button actually exists (hasSearchableFiles) --
skipping the render does not consume the one-shot cookie.

The hero is CDN-hosted at https://plannotator.ai/assets/guided-review.webp
(committed to apps/marketing/public/assets so every marketing deploy
re-ships it past the --delete sync; uploaded to S3 now so it is already
live) instead of growing the single-file bundle; on load failure the
image container hides entirely for a clean text-only dialog.

Independently of the dialog, the header Guide button shimmers
(TextShimmer) with a pulsing dot until the guide is first opened --
acked on ANY open path (button, shortcut, job auto-open), not just the
button click.

* fix(review): guide intro first in the dialog chain, full-width panel

Chain is now guide intro -> look-and-feel -> review setup (was second);
panel widened to max-w-5xl to match the look-and-feel dialog. Intro seen
flag switched to the versioned-cookie pattern so future revisions can
re-show it.

* fix(review): latch guide-intro eligibility at first post-load render

hasSearchableFiles changes on mid-session diff switches; an
empty-to-non-empty switch would pop the intro over work in progress, or
yank an open look-and-feel dialog (its gate reads guideIntroVisible).
Eligibility is now decided once when loading clears -- dialogs only
mount after that, so the latch is always set before they render. A
skipped session keeps the cookie unconsumed. Also corrects AGENTS.md's
chain order (guide intro first).
2026-07-05 09:49:00 -07:00
Michael Ramos aa46dbb29a feat: guide per-file summaries + GitHub Copilot CLI agent engine (#997)
* feat(guide): per-file summary on guide diff refs

Each diffs[] entry now carries a required (schema-enforced) 1-2 sentence
summary of the semantic change in that file, written from the diff hunks
alone. Sanitizer passes it through when it's a non-blank string and omits
it otherwise -- a missing summary never drops the ref or fails the guide.
Rendered as a muted inline-markdown line above each diff in the guide;
marker-engine contract and demo data updated to match.

* fix(guide): repair prompt must not invent missing summaries

The guide schema now requires summary, so a schema-enforced repair of a
payload that lacks them (marker-engine output) would force the model to
fabricate captions with no diff in sight. Instruct it to fill missing
required fields with an empty string instead; the sanitizer already
renders nothing for blanks.

* feat(agents): GitHub Copilot CLI as a marker engine for review + guide jobs

Adds copilot as the fourth marker engine (no schema flag, so it uses the
nonce-tagged marker-block contract like Cursor/OpenCode/Pi):

- marker-review.ts: COPILOT_ENGINE — 'copilot --output-format json' JSONL
  stream (assistant.message carries the assembled text; deltas skipped),
  models discovered from 'copilot help config', live-log formatting for
  tool.execution_start/complete. Non-interactive posture: --no-ask-user
  auto-denies unallowed tools, --deny-tool=write, read-only-oriented shell
  allowlist (git/gh/glab/jj/wc), builtin MCPs and auto-update disabled.
- Exported MarkerEngineId and replaced every 'cursor'|'opencode'|'pi' cast
  with it (Bun server, Pi server mirror, guide-review) so the next engine
  is a two-edit change.
- agent-jobs (both runtimes): copilot in SERVER_BUILT_PROVIDERS; capability
  entry + model discovery come free from the MARKER_ENGINES loop.
- UI: copilot review/guide engine with per-surface model settings
  (useAgentSettings), AgentsTab launch + config rows, GuideEmptyState
  launcher, job-detail labels, CopilotIcon (currentColor, official mark).
- Tests: argv/read-only flags, help-config model parsing, stream reduction,
  full marker pipeline; profile-map expectations widened.

Verified live: composed review prompt through the real copilot binary,
marker block parsed, seeded bug found.

* fix(agents): structurally deny high-consequence verbs for Copilot jobs

git:*/gh:*/glab:* stay allowed for inspection ergonomics, but Copilot's
deny-precedence rules now block the verbs a prompt-injected background
job could abuse: git push/reset/clean/checkout/restore (local reviews run
in the user's real working tree), and PR/MR/issue comment/create/merge/
close/edit/review on gh and glab. Probe-verified: git log runs, git push
--dry-run is denied by the shell(git push) rule.

Addresses the one confirmed finding from the PR #997 review round; the
other two (untracked-file reads, git -C) were refuted by live probes.
2026-07-05 09:12:20 -07:00
Michael Ramos d6b98f0c0d feat(review): Guided Review + Pi agent-job provider (#993)
* docs(adr): Guided Review ADR/spec/research + agent-provider fit studies

ADR 006 (Guided Review as a first-class feature), four implementation
spikes, synthesis, spec (iterated through preflight and review fixes),
recap, and the Flue/Pi provider-fit research syntheses.

* feat(review): Guided Review takeover + Pi agent-job provider

Guided Review (ADR 006) — a Linear-Guides-style chaptered review:
- guide agent-job provider (packages/server/guide/guide-review.ts):
  schema-constrained sections (title/overview/file refs) over the live
  patch, coverage-validated server-side (every changed file exactly once,
  fabricated paths dropped, fail-closed on empty output), importance-first
  ordering prompt with speed discipline (diff-first, no repo exploration)
- runs on claude/codex natively and cursor/opencode/pi via the marker
  contract; guide-scoped low effort defaults for quicker generation
- takeover UI (packages/review-editor/components/guide/): Guide header
  badge + Mod+Shift+G, full-width screen that CSS-hides (never unmounts)
  the file tree/dock, empty state with inline model pickers, skeleton
  loading, generating state with collapsed activity log, two-column
  section cards (sticky prose column with internally-scrolling file list,
  content-height diffs, review-independent collapse, peek semantics)
- annotation parity: guide diffs mount the real DiffViewer with
  file-scoped handlers into the same CodeAnnotation state and feedback
  export; per-section reviewed state persisted via /api/guide/:jobId
- reviewed/tour prompt speed sections; renderMarkdownProse shared between
  tour and guide (fenced-block support, muted tone variant)

Pi agent-job provider:
- third MarkerEngine (pi --mode json --no-session --no-approve) with
  live model-catalog discovery, thinking-level control (--thinking),
  fail-closed marker parsing (Pi exits 0 on in-run errors by design)
- available for review + guide jobs from both launch surfaces

Shared UX:
- searchable, provider-grouped model pickers (auto over 12 options)
- full Claude version catalog with latest-resolving aliases labeled
- AgentControls primitives extracted from AgentsTab; cross-instance
  settings sync in useAgentSettings
- Pi extension server hand-mirrors + vendor.sh entries throughout

* fix(guide): PR-993 review fixes + failure recovery ladder

Review fixes:
- coerce marker-engine title/intent at ingest (prompt-enforced output
  could store non-strings and crash the takeover)
- keep placed files out of unplacedFiles (exactly-once guarantee)
- shared REVIEW_ENGINE_LABEL (fixes 'generated by pi' header; typed
  exhaustiveness prevents recurrence)
- key ActiveGuide by jobId so per-guide focus state resets
- formatModel handles marker-engine tour/guide jobs (empty model chip)
- drop dead guideCodexFast; memoize estimateDiffHeight
- store + display Pi thinking level on job cards

Failure recovery ladder (auto -> one click -> manual -> regenerate):
- mechanical JSON repair pass (fences/slice/trailing-commas/bracket
  balance) before any guide parse fail-closes
- failed payloads captured per job (200KB cap, per-engine extraction)
- 'Fix output' repair job: pure text transform on a schema-capable
  engine (claude/codex preferred), forced low effort, lands as a
  normal guide job; repairOf threaded through launch validation
- editable output panel: GET /api/guide/:jobId/output prefills a
  textarea, POST /api/guide/:jobId/submit runs the same server-side
  validation and opens the fixed guide; inline errors for iteration
- Pi extension mirrors + AGENTS.md endpoint/body docs

* fix(guide): string-aware mechanical JSON repair (self-review)

- terminate a dangling string literal before appending bracket closers
  (truncation mid-string is the most common shape; closers appended
  inside the string never parsed)
- strip trailing commas outside string literals only (the regex could
  rewrite overview content, violating the repair contract)

* fix(guide): launch-snapshot validation, codex strict schema, repair persistence

PR-993 round-2 review fixes + live-test findings:
- validate guide completion (and manual repair) against the LAUNCH-time
  changed-file set, snapshotted per job — switching diff/base/PR while a
  guide generates no longer destroys valid output (client already
  degrades stale refs per-file)
- codex strict structured output: unplacedFiles now required in the
  guide schema (OpenAI 400s schemas with optional properties under
  additionalProperties:false — live-confirmed, then live-verified fixed:
  5 sections / 83 files placed on a ChatGPT-account codex)
- manual repair survives reload: successful /submit flips the job to
  done via completeJobExternally + status guard on the endpoint
- codex model defaults -> gpt-5.5 (5.3-codex is deprecated; ChatGPT
  accounts reject it) with one-shot migration of saved picks; guide
  codex default reasoning stays low
- pi extension guide branch snapshots launch state (TOCTOU hygiene)
- paragraph collector no longer swallows an unspaced code fence
- tour hook backports: out-of-order fetch guard; save outside updater
- guide empty-state copy rewritten value-first

* fix(guide): PR-993 round-3 — repair-diff fidelity, failure surfacing, pi tool hardening

- repair jobs validate against the FAILED job's recorded file set (the
  payload under repair references that changeset; validating against the
  diff on screen at repair time re-introduced destroy-on-switch)
- takeover follows the NEWEST running guide (progress + Cancel), and a
  failure newer than the shown guide surfaces as a dismissible strip
  (error + Fix output + Details) instead of being silently masked
- pi jobs run with --exclude-tools edit,write (bash stays: the agent
  needs git inspection; full read-only would break generation)
- tour/guide result ingestion fails closed on unexpected throw (done-
  looking jobs no longer 404 their result)
- sidebar guide mode gated on file availability like the header badge
- drop orphaned DEFAULT_GUIDE_CODEX_FAST (fast mode intentionally not
  offered for guide); retry owns its fetch cancellation; final
  trailing-comma pass after bracket-closing in the repair ladder;
  collapsed-row checkbox/expand are sibling buttons (a11y)

* fix(guide): PR-993 round-4 — callout text, guide job detail, toggle races

- single-line callouts (> [!IMPORTANT] message) keep their message — the
  form our own prompt solicits rendered an empty labeled box; type now
  derives from the [!TAG] capture, not a whole-line scan that mistyped
  '> [!NOTE] this is important'
- job detail panel gets a guide status card (Open guide via a new
  ReviewStateContext.openGuide) instead of mislabeling 'Guide Generated'
  as a red Incorrect review
- marker-engine guide jobs get readable live logs (engine fallback in
  the formatter lookup; provider stays 'guide')
- repair prefers the failed job's own schema-capable engine (proven
  working on this machine) before falling back by binary presence
- toggleReviewed/toggleChecked: pure functional updaters + persistence
  effect with seed-skip — race-proof AND StrictMode-safe (resolves the
  tradeoff previous rounds accepted)
- dedups: formatDuration/ElapsedTime shared from AgentsTab; whichCmd
  exported in the pi extension; SEARCHABLE_THRESHOLD imported by
  InlinePicker
- new renderMarkdownProse tests (single-line/multi-line callouts,
  paragraph passthrough)

* fix(guide): PR-993 round-5 — keep engine-default model option, label Pi jobs

- GuideEmptyState's marker catalogs mirror AgentsTab's per-engine
  semantics: opencode/pi prepend the engine-managed Default ('' value)
  to the discovered list (dropping it left saved-default users a blank
  pill with no way back); cursor replaces (its list includes 'auto')
- job detail provider pill labels pi review jobs 'Pi' instead of the
  'Shell' fallback

* fix(guide): PR-993 round-6 — context scoping, stale models, repair engine, staging gate

- guide takeover + auto-open scoped to the current review context: a
  guide launched against PR A no longer shows over PR B, and a guide
  finishing for an away context defers its auto-open until the reviewer
  returns to that context (unmarked in the dedupe set on purpose)
- guide launcher reconciles saved cursor/opencode/pi model ids against
  the live catalog at read time (picker + launch share one effective
  value) — no more posting dead ids after an account switch
- repair prefers the failed job's OWN engine whenever its binary is
  present (provably runnable; marker binaries resolved via
  MARKER_ENGINES — cursor's CLI is 'agent'); claude/codex are fallback
  only, killing the broken-claude repair doom loop
- per-file staging gate (canStagePath) in guide diffs AND the
  pre-existing same gap in ReviewDiffPanel — committed-only files in
  since-base reviews no longer offer a no-op Git Add

* fix(review): scope tour auto-open to current context (self-review)

Same cross-context gap just fixed for guides: a tour finishing for PR A
popped its dialog over PR B. Both auto-open effects now share one
jobMatchesCurrentContext helper with the same deferred-open semantics.

* fix(guide): PR-993 round-7 — complete the context-scoping story

Round 6 scoped guides to their review context; this closes the two
surfaces that scoping left dangling, plus a focus gap:

- switching to a context that already HAS a completed guide now shows
  that guide: the takeover falls back to the context's newest done
  guide job when activeGuideJobId belongs elsewhere (previously landed
  on the empty state with the guide sitting unreachable)
- 'Open guide' affordances are context-gated everywhere they exist:
  job cards hide it for cross-context guides (opening can't switch
  PRs), and the job detail panel explains where the guide belongs
  instead of offering a dead button; one shared
  jobMatchesReviewContext predicate now backs App, GuideScreen,
  AgentsTab, and the detail panel
- file-chip navigation retargets the guide's focus arbiter (scrolling
  under a stationary pointer fires no pointerenter, so the annotation
  toolbar stayed bound to the previous diff)

* fix(tour): retry owns its fetch cancellation; document prUrl-only scoping

- useTourData retry converts to the nonce-driven effect re-run pattern
  (the round-4 backport carried the fetch guard but not the retry fix
  useGuideData got in the same batch — half a backport)
- jobMatchesReviewContext now documents WHY it matches by prUrl only
  and not diffScope/diffContext: guides/tours reference files and
  degrade per-file when the diff shifts; scope-strict matching would
  hide useful artifacts on layer/base/mode switches. Deliberate,
  do-not-tighten-without-UX-decision

* fix(guide): PR-993 round-9 — worktree-aware context, guide-scoped marker settings

- jobMatchesReviewContext now compares diffContext.worktreePath for
  local jobs: prUrl + worktree define WHERE the review is (matched
  strictly); mode/base/scope remain WHAT VIEW (deliberately loose) —
  a guide launched against worktree A no longer opens over worktree B.
  Jobs predating the snapshot coalesce to the main tree.
- Cursor/OpenCode/Pi get guide-scoped model/thinking settings
  (guideCursor/guideOpencode/guidePi), mirroring the existing
  guideClaude/guideCodex isolation: tuning a guide's marker model no
  longer silently changes the next code review with that engine.
  Defaults are each engine's natural default, not seeded from review
  settings (same precedent as guideClaudeEffort='low'). AgentsTab's
  reconcile effects snap both surfaces against the shared catalogs.

* fix(review): keep worktree parsing browser-safe (round-9 build fix)

parseWorktreeDiffType lives in shared/review-core, which imports
node:path at module top — pulling it into App.tsx broke the vite
browser build (typecheck and tests don't catch it; only the bundle
does). Context matching now reuses App's existing hand-parsed
activeWorktreePath memo — one parse, and it's the same one that
drives the sections/tree UI, so matching aligns with what's on
screen. Also fixes a deps-array reference to the removed memo that
bundled fine (treated as a global) but would have thrown a
ReferenceError at runtime.

* docs(review): mark worktree-subtype lockstep between server parser and App copy

* fix(review): PR-993 round-10 — guide shortcut gate, settings sync race, heading markdown

- bare a/v staging/viewed shortcuts suspend while the guide takeover is
  open (the dock is only CSS-hidden, so a diff panel stayed 'active'
  underneath and bare keys acted on an invisible file)
- cross-instance settings sync replaces the was-last-update-remote
  boolean with value comparison (lastSyncedJsonRef): the flag conflated
  'a commit happened' with 'the last change was remote', so a local
  edit batched with an incoming broadcast silently skipped its own
  cookie write and rebroadcast
- ### headings in guide/tour prose run through renderInlineMarkdown
  like every other block (backticked symbols rendered as raw tokens);
  regression test added

* fix(guide): PR-993 round-11 — reveal channel for sidebar jumps, repair exit code

- new guideRevealFile channel closes the documented round-7 gap and its
  AI sibling: sidebar jumps (annotation clicks, AI line citations) made
  while the guide takeover is open no longer mutate the hidden dock's
  active file; instead the GuideSectionCard containing the target file
  expands its collapsed (reviewed) section, focuses the diff, and
  scrolls to it — so jumps into collapsed sections stop silently
  no-opping. Cleared on guide close/switch so keyed remounts don't
  replay the last reveal.
- completeJobExternally resets exitCode to 0 (both servers): a
  successfully repaired guide kept showing the failed run's Exit 1 chip
  in the job detail panel

* fix(guide): clear reveal channel in-batch at guide-switch sites (self-review)

The clear-on-change effect fires after a switched guide's keyed cards
have already mounted (child effects run before parent effects), leaving
one commit where a stale reveal from guide A could expand+scroll a
same-named file's section in guide B. All three setActiveGuideJobId
sites now clear synchronously in the same batch; the effect stays as a
backstop for close and future set sites.

* fix(guide): PR-993 round-12 — titled sections, honest large-PR validation

- sanitizeGuideSection gives every surviving section a non-empty title
  ('Untitled section' fallback): a diffs-only section rendered as a
  blank chapter inside a 'Guide Generated' job — no parse failure, so
  no recovery flow. Keeping (titled) beats dropping: placed files are
  not in unplacedFiles, so dropping would orphan them silently.
- guide launches on large PRs (layerPatchIncomplete) recompute
  changedFiles from the local checkout (git diff --numstat
  origin/<base>...HEAD, rename/binary handling) when available — the
  PR-mode prompt tells the agent to read that full local diff, but the
  changed-files block and validation snapshot came from the truncated
  platform patch, under-listing files to the model and then dropping
  its valid refs (or failing the guide) at validation. Falls back to
  the partial list on any failure. Mirrored in the Pi extension server.

Round-12's P1 (Pi marker extraction reads message_end, 'should' read
text_delta) was refuted by live probe: Pi's assistant message_end
carries the fully-assembled content blocks, which is exactly what
piExtractText reads; the delta-reading code the finding cross-
referenced is the Ask-AI STREAMING provider, a different job.

* test(guide): first direct coverage for guide-review's pure logic (self-review)

The module's repair ladder, validation, and stream parsing had zero
unit tests — exercised only end-to-end through live agent runs. Pins
the behaviors the review rounds fixed: blank-title fallback (round 12),
first-placement-wins dedup, changedFiles filtering + fail-closed empty
guides, prose-only vs lost-diffs section handling, unplacedFiles
merge/dedup/fabrication-filtering, title/intent coercion, trailing-
comma and unbalanced-bracket repair, truncated stream-line recovery.
13 tests; server suite 374 → 387.

* fix(guide): PR-993 round-13 — full-stack recompute regression, flag snapshot, unplaced reveal

- the round-12 large-PR recompute now runs ONLY in layer scope: in
  full-stack scope launchPatch is already a local full recompute, and
  the layer diff (origin/<base>...HEAD) was the WRONG file set — it
  omits earlier stack layers' files, dropping their refs at validation
  or failing the guide. Introduced by round 12; caught before any
  release. Both servers.
- layerPatchIncomplete is snapshotted with the other launch locals
  (launchLayerPatchIncomplete): coherence with launchPatch was
  positional (same sync segment) — now structural, so a future await
  inserted upstream can't silently desync them. Both servers.
- the 'Everything else' bucket handles guideRevealFile: sidebar jumps
  to unplaced files now focus + scroll their diff (the section-card
  effect only covers placed files) — closes the residual gap noted in
  the round-11 self-review
2026-07-04 10:23:59 -07:00
Michael Ramos e8df06db7c feat(review): Commits panel — linear history rail with per-commit diffs (#994)
* docs(adr): spec for the commit-list review view (linear history rail)

* feat(review): commit:<sha> diff mode along the shared diff-type seams

A new git-only diff family for reviewing one historical commit against
its first parent (git-show style), threaded through every seam a diff
type crosses:

- runGitDiff: git diff <sha>^ <sha>; a root commit diffs against the
  empty tree. Label is 'Commit <shortsha> — <subject>'. The sha is
  validated as bare hex (parseCommitDiffType) before it reaches any
  argv position, on top of --end-of-options.
- getGitDiffFingerprint: anchored to the sha alone (present/gone), NOT
  headSha — new commits landing mid-review don't change this diff and
  must not raise the staleness banner. A vanished commit (rebase + gc)
  flips present→gone and fires it honestly.
- getFileContentsForDiff: old side <sha>^ (null on root), new side <sha>.
- parseWorktreeDiffType learns worktree:<path>:commit:<sha> (the
  sub-type contains a colon, so it needs its own split) — commit review
  composes with worktree sessions like every other mode.
- vcs-core: the git provider owns commit:*; staging stays gated off
  (canStageFiles allowlist unchanged — nothing in a historical commit
  is stageable).
- Ask AI context: a commit-mode inspect string ('git show <sha>', with
  an explicit 'historical commit, not the working tree' note).

Also adds listCommitHistory for the upcoming /api/commits endpoint:
one --first-parent page from HEAD (before=<sha> continues at its first
parent, +1 fetch for an honest hasMore), with isHead / isRepoUser /
isPastBase flags. isPastBase is reachability from the base — computed
as the complement of 'rev-list --first-parent HEAD ^base', which is a
prefix of the walk, so the client's single divider is exhaustive. An
unresolvable base degrades to no divider (matches since-base's posture
on such repos).

* feat(review): GET /api/commits — linear history endpoint (Bun + Pi)

One page of the branch's --first-parent history (?limit=&before=),
served by listCommitHistory against the active diff's cwd (worktree-
aware via resolveVcsCwd) and the active base (so the divider tracks
the same baseline the review compares against, including the startup
origin/<default> upgrade).

Gated to plain local git sessions — PR / workspace / jj / p4 return
400, mirroring the client's commitsCapable gate. Both runtimes; the
route-parity test covers the pair.

commit:<sha> switching needs no endpoint changes: /api/diff/switch
already dispatches through runVcsDiff (the git provider owns the new
family), the epoch guard applies as-is, the sections sidecar correctly
stays since-base-only, staging 400s via the canStageFiles allowlist,
and baseRelevantDiffType keeps the behind-GitHub banner suppressed for
commit diffs.

* feat(ui): Commits panel view — the linear history rail

Third left-panel view: 'Git status | Commits | Tree'. The Commits view
is a pure commit list (short sha, subject, relative age compacted to
'2h', author only when it isn't the repo user, HEAD dot, a divider
where the branch meets the resolved base, 'Show more' paging). It
never becomes a file list: clicking a commit switches the diff to
commit:<sha> and the existing needsInitialDiffPanel flow lands the
center dock on the all-files surface; re-clicking the active commit
just re-focuses that panel.

Persistence: reviewPanelView gains 'commits' with NO diff coupling —
the view persists, the diff opens on the user's normal default until a
commit is clicked, and a sha is never persisted (it may not survive a
rebase). The sections⟺since-base coupling is untouched, but the
classic-diff→Tree snap in Settings/ReviewSetupDialog now only fires
when the current view is 'sections', so picking a default diff no
longer stomps a Commits preference. The load-time self-heal ignores
'commits' by construction (it keys on panelView === 'sections').

Gates: commitsCapable = plain local git session (no PR / workspace /
jj / p4, matching the server's /api/commits gate); the segment is
hidden elsewhere. Staging is inert in commit diffs on both ends
(STAGEABLE_DIFF_TYPES and the server allowlist never match commit:*).
Worktree sessions compose — the client parses
worktree:<path>:commit:<sha> and handleDiffSwitch prefixes as usual;
the rail refetches on worktree/base changes but deliberately not on
commit clicks, so paging state survives selection.

Accepted edges (v1): no j/k selection in the rail (each move would run
a full diff switch); toggling Commits→Tree keeps a commit diff active,
where the tree's diff-type dropdown marks nothing — the gitRef header
still names the commit.

* fix(review): self-review cleanups on the commit-list feature

- listCommitHistory: a repo with no commits yet (unborn HEAD) returns
  an empty page instead of null — the panel showed a 500-backed 'Retry'
  error where 'No commits' is the truth. Every other review surface
  already degrades gracefully on a commit-less repo; now this one does.
  Only a genuinely unanswerable repo (not a repo at all) stays null.
- Hoist the bare-hex sha rule into one BARE_HEX_SHA_RE shared by
  parseCommitDiffType and the before-cursor validation (was written
  twice).
- The 'all' diff case now uses the getEmptyTreeSha helper the commit
  mode extracted, instead of keeping its own inline copy.
- Drop CommitsPanel's isLoadingDiff prop — declared and passed but
  never used.

* feat(ui): commit cards, labeled groups, avatars — review round 1

Four items from the live review round:

- Toggle: Commits moves to the far right (Git status | Tree | Commits)
  and the segment text goes 10px → 12px (padding trimmed to keep three
  segments inside the 256px header).
- The bare '── origin/main ──' divider confused more than it explained.
  Replaced with two labeled groups: 'On this branch' (commits not yet
  reachable from the base) and 'In <base>' (shared history), each with
  a tooltip spelling out what the boundary means.
- Rows become overview cards: subject (2-line clamp) on top; a meta row
  with author avatar, author name (only when it isn't the repo user),
  a HEAD badge, short sha, and compact age. Active card gets the
  primary border/tint.
- Author avatars, reusing the PR machinery rather than reinventing it:
  parseRemoteUrl/parseRemoteHost classify the origin forge; the gh/glab
  invocation shape (incl. the --hostname self-hosted convention) and
  GitLab's relative-avatar absolutization rule are mirrored from
  pr-github/pr-gitlab. What could NOT be reused directly: PR avatars
  are keyed by platform login, which local commits don't have — so
  GitHub resolves via one repos/{o}/{r}/commits?per_page=100 call per
  session building an author-EMAIL → avatar map (unpushed commits by
  the same author resolve through it; verified live), and GitLab uses
  its per-email /avatar endpoint (capped, deduped). Everything is
  memoized per session and fails closed to the initials fallback — a
  repo with no remote, an opaque self-hosted host, or an unauthenticated
  CLI never delays or errors the commits endpoint.

The Avatar component moves out of PRCommentsTab into its own module,
now shared by the PR timeline and the commit cards. New shared module
commit-avatars.ts is vendored to Pi (vendor.sh) and the enrichment runs
in both runtimes. CommitListEntry gains authorEmail (resolver key) and
avatarUrl (server-enriched).

* ui(review): flush commit rows, prominent base boundary — review round 2

- Cards are gone: rows stack flush (no borders, no margins), hover tint
  only. Subject is strictly one line, ellipsized (no clamp/wrap).
- Author name always shown (was: only when it differed from the repo
  user), with the avatar, on the compact meta line under the subject.
- The base boundary is a prominent labeled rule ('— In origin/main —',
  foreground-weight lines) instead of the faint border under a group
  header; the 'On this branch' header above stays quiet.

* docs(adr): evaluate local stack-parent detection — scenario matrix, no build decision

Empirical stress-test of the merge-base-reachability algorithm across 9
scenarios plus this repo itself. Findings: the stacked-ness test is graph
truth (never lied); parent attribution has one content-harmless failure
(same-merge-base sibling labels) and one dangerous, provably graph-
undetectable one (branches pointing inside B's own history win and
silently hide the user's commits). Determination: only viable as a
suggest-and-confirm affordance with per-branch persisted choice — never
silent auto-defaulting. Decision to build deliberately left open.

* fix(ui): Commits view is session-only — never the opening view

Live-review catch: the panel toggle inherited the sections/tree
'last choice becomes the default' cookie write, so clicking Commits in
one session made the NEXT review open on the history rail instead of
the diff. Ruling: a review always opens on the persisted sections/tree
default; Commits is entered via the toggle each session.

- reviewPanelView narrows back to 'sections' | 'tree'; a stale
  'commits' cookie is treated as unset (self-heals to the default).
- The live view is now a session overlay in App state: selecting
  Commits flips it without touching config; selecting Git status/Tree
  clears it and persists as before. Single choke point in
  selectPanelView, so every toggle site inherits the rule.
- Settings loses the Commits segment (it configures the OPENING view,
  which Commits can no longer be). Spec updated with the revision.

* ui(review): the panel toggle is session-only — it never writes the default

Second live-review ruling on persistence: the header toggle shouldn't
persist ANY choice, not just Commits. Looking at another view mid-review
must not silently change what the next review opens on.

- selectPanelView becomes a pure session override (local state layered
  over the persisted value); no configStore writes from the toggle path.
- handleSwitchToSections drops its defaultDiffType write — that write
  only existed to keep the persisted view/diff pair consistent, and the
  toggle no longer persists a view. Settings and the setup dialog remain
  the only reviewPanelView/defaultDiffType writers and keep enforcing
  the sections ⟺ since-base coupling; the load-time self-heal still
  repairs conflicted pairs those writers may leave.
- Spec + settings-registry comments updated to name the new rule.

* feat(ui): commit description card + collapsed all-files for commit diffs

Clicking a commit now answers 'what and why' before 'which lines':

- New commitInfo sidecar (Bun + Pi, same mode-conditional shape as
  sections): when a commit:<sha> diff is active, /api/diff and
  /api/diff/switch carry the commit's full metadata — subject, multiline
  body, author (+ avatar via the session resolver), sha, age — from a
  single 'git show -s' (getCommitDiffInfo in review-core, body as the
  trailing format field so newlines survive).
- CommitDescriptionHeader heads the all-files surface: subject, author
  row, and the full body rendered as markdown via the same MarkdownBody
  the PR viewer uses. Long bodies scroll inside the card (max-h) so the
  diff keeps the viewport.
- Commit diffs open FOLDED: AllFilesCodeView gains defaultCollapsed —
  items seed collapsed at build time, the seed is part of fileSetKey
  (CodeView seeds once per instance, so a seed change must remount),
  and the collapse-all toggle state initializes to match. Every other
  diff mode keeps opening expanded; leaving the commit diff clears the
  card and the folded default together (the sidecar is absent).

Verified live through the compiled binary: switching to a real commit
returns subject/body/avatar, switching back to since-base clears it.

* ui(review): commit description scrolls with the diff — no pinned card

Round feedback: the fixed header with its own inner scrollbar felt
embedded; the description should read as the top of the document and
scroll away with the files.

- AllFilesCodeView gains leadingContent: rendered via a portal INTO
  CodeView's scroll container (absolutely positioned at content top,
  so it participates in the scrollable overflow), with its measured
  height fed into layout.paddingTop so the virtualized items start
  below it. CodeView stays the single scroll authority — no nested
  scrollers, no wrapper flex row.
- CommitDescriptionHeader shows the full body inline (no inner
  OverlayScrollArea). Only genuinely huge bodies (>24 lines / >1800
  chars) get a CSS max-height clamp with a fade mask and a Show
  more/Show less toggle — CSS clamping keeps the markdown intact, and
  the ResizeObserver feeds the expanded height back into paddingTop.
  Keyed by sha so the toggle resets per commit.

* ui(review): entering the Commits view auto-opens the HEAD commit

Toggling to Commits used to leave the previous mode's all-files diff on
screen — a rail full of commits next to content that belonged to none
of them. Now entering the view selects the HEAD commit (top of the
rail) so the center immediately shows its diff + description card.

Once per entry, ref-guarded: an already-active commit diff survives a
toggle round-trip, a user click supersedes it, and a failed switch
doesn't retry-loop. When entry races the first log fetch, the effect
fires as soon as the log lands.

* perf/ui(review): fast commit navigation — instant veil + skip context recompute

Rail clicks felt laggy and jumpy: the old diff sat on screen for the
whole switch round-trip and then snapped, and the switch itself was
doing a full getVcsContext recompute (branch + worktree + recent-commit
enumeration — three git walks) that a commit click can never invalidate.

- Same-cwd commit:<sha> switches skip the context recompute in both
  runtimes (the client keeps its existing context when the field is
  absent — long-standing contract). Measured through the compiled
  binary: commit switches now ~40-60ms server-side.
- The center dock shows an immediate 'Loading commit…' veil whenever a
  commit switch is in flight (or the Commits view was just entered and
  HEAD auto-select hasn't landed) — the stale previous diff never shows
  and the click reads as instant. Errors surface through the normal
  empty-state, never trapped under the veil.

* fix(ui): PR-994 review round — toggle/settings separation + commit-nav fixes (App)

The headline fix: the load-time settings repair guarded on the live
panelView, which now carries the session toggle override — so clicking
'Git status' once mid-review (saved prefs tree + a classic diff) hit
the healer and silently persisted defaultDiffType='since-base'. The
toggle must never be a settings writer, directly or through a repair
path. Keyed to persistedPanelView now; audited the remaining
configStore.set call sites — no toggle path can reach a write.

Also in this file, same review round:
- Commit-navigation veil gains a real predicate: it drops on a commit-log
  fetch error (rail shows Retry; center was stuck under the spinner
  forever) AND on a genuinely empty history (zero commits — the second
  stuck-veil trigger the automated review missed), while still covering
  the log-loading and pre-auto-select frames.
- handleSelectCommit composes the worktree prefix once and calls
  fetchDiffSwitch directly (the equality check and the switch previously
  used two different composition paths).
- commitsCapable/showCommitsPanel hoisted above the global keyboard
  handler; Cmd+F is no longer intercepted in the Commits view (no search
  input exists there — the browser find takes over instead of a silent
  no-op).

* fix(review): PR-994 review round — commit-log staleness, loading flags, avatar cap

- useCommitLog clears its cached list when the history context changes
  (worktree/base switched while the view was away): the HEAD auto-select
  acted on the stale rows and opened the previous context's commit. Same
  contextKey still keeps the cache (no empty flash on re-entry).
- The hook's cleanup now resets both loading flags: a generation-skipped
  finally never cleared them, leaving 'Show more' stuck disabled as
  'Loading…' after leaving mid-page.
- commit-avatars memoizes misses only for emails an attempt actually
  covered — the GitLab per-call cap (10) was recording every email past
  it as permanently unresolvable for the session. Regression test added.
- GitLab host detection tightened from a bare substring to a structured
  match (gitlab.com / gitlab.* / *.gitlab.*) — 'mygitlabproxy.example.com'
  no longer classifies.
- Collapse-all mirror re-derives from live item state after per-file
  toggles: with commit diffs seeding all-collapsed, expanding one file
  left the dock button on 'Expand all', and clicking it re-collapsed the
  file the user just opened.
- The commit description card element is memoized on commitInfo identity
  so the measuring ResizeObserver stops churning on every context render.

* fix(ui): entering the Commits view ends any open search session (self-review)

The Cmd+F guard from the review round only blocked NEW searches in the
Commits view; a search opened in Git status/Tree survived the toggle as
hidden-but-live state — the query kept matching against the commit
diff, marks kept rendering in the all-files body, Enter/F3 kept
stepping matches, and the input to see or edit the query didn't exist
anywhere on screen. handlePanelViewSelect (the single choke point every
toggle site routes through) now clears and closes search on entry to
Commits.

Also re-traced the rest of the round under self-review — veil terminal
states (log error/empty/loaded, switch failure, background refresh with
a commit active), the hook's cleanup-before-rerun flag ordering, the
avatar attempted-set on broken platforms, and both collapse-mirror
paths — no further findings.

* fix(ui): PR-994 round 2 — live rail freshness, non-destructive errors

- The rail now keeps itself fresh: while the Commits view is visible, a
  quiet 10s poll head-compares page 1 and adopts it only when history
  actually moved. The commit DIFF's sha-anchored fingerprint stays as
  designed (a historical commit never goes stale, so the banner
  correctly stays quiet) — but the rail no longer freezes while an
  agent commits in the background. Adoption bumps the fetch generation
  so an in-flight 'Show more' from the old history can't append stale
  rows, and transient poll failures disturb nothing.
- A page-1 fetch resets isLoadingMore: a refresh superseding an
  in-flight paging request skipped that request's generation-guarded
  finally, leaving 'Show more' stuck on 'Loading…' — rare before, a
  live race once the poll exists.
- Errors are non-destructive when a list is on screen: a failed page or
  background refresh renders as an inline row with Retry under the
  list instead of replacing the whole rail; the full-panel error state
  is reserved for an empty rail.

* perf/refactor(review): parallel GitLab avatar lookups; drop dead isRepoUser

- The ≤10 per-call GitLab avatar lookups run in parallel instead of
  sequentially — they sit on /api/commits' critical path, and serial
  subprocess spawns added seconds to the rail's first paint on
  multi-author GitLab histories (PR-994 round 2 nit).
- CommitListEntry.isRepoUser removed: v1 showed the author only when it
  wasn't the repo user; the live-review pivot to always-shown names left
  the field computed (one git config subprocess per page), serialized,
  and tested but never read, with a doc comment describing behavior the
  panel no longer has. Spec updated to match the revised row design.

* fix(ui): commit-poll adoption clears all superseded fetch state (self-review)

Adopting a new history from the background poll bumps the generation,
which strands any in-flight fetch's generation-guarded finally — the
same stale-flag class just fixed for isLoadingMore, but for isLoading
(slow first load overtaken by the poll) — and a lingering inline error
from the replaced history would otherwise sit under the fresh list.
Adoption now clears isLoading, isLoadingMore, and error together.

* fix(ui)/docs: PR-994 round 3 — worktree switches drop commit diffs; API docs

- handleWorktreeSwitch treats commit:<sha> as non-portable: every other
  diff mode recomputes meaningfully against the target worktree, but a
  commit diff is context-bound content (worktrees share one object
  database — the reviewer's claimed git error doesn't exist, verified
  empirically), so 'preserving' it just re-rendered the OLD context's
  commit byte-for-byte. It now falls back to the session default (same
  option-availability rule resolveInitialDiffType applies). Swept every
  other activeDiffBase/diffType carrier: the whitespace toggle,
  staleness refresh, and fetch-base deliberately recompute the SAME
  diff (valid for commits); handleBaseSelect and the base-picker row
  already exclude commit mode; job diff-context labeling and the
  guarded diff-type dropdown are display-only. This was the single leak.
- AGENTS.md (CLAUDE.md symlink): /api/commits row added to the Review
  Server table; /api/diff and /api/diff/switch now document the
  commitInfo sidecar and the commit:<sha> diffType family; the
  since-main section describes the three-segment session-only toggle
  and the never-persisted Commits view.

* docs: normalize arrow glyphs in the /api/commits table row (self-review)

* fix/refactor(review): PR-994 round 4 — rebase-safe paging, locale-proof ages, shared parsing

- Pagination is rebase-safe: a 'Show more' cursor from a history that was
  rewritten mid-session (rebase/force-push) still resolves in the object
  store but is no longer on the branch — paging from it walked the
  orphaned pre-rewrite chain for the ≤10s window before the freshness
  poll adopts the new history. listCommitHistory now ancestor-checks the
  cursor (merge-base --is-ancestor) and returns an empty terminal page
  for orphaned AND vanished cursors alike; the poll replaces the list
  moments later. Regression test rewrites history between pages.
- Ages are locale-proof: git localizes %cr via gettext ('vor 2 Stunden'),
  which the English-only compactAge regex silently couldn't shorten.
  CommitListEntry/CommitDiffInfo now carry committedAt (epoch ms, %ct);
  the rail and description card format it with the existing
  formatRelativeTime — compactAge and the third formatter variant are
  gone.
- The %x1f over-split repair (fixed head/tail fields, rejoined free-text
  middle) was triplicated across listRecentCommits, listCommitHistory,
  and getCommitDiffInfo; one splitCommitFormatFields helper (head/tail
  counts, tail=0 covers the multiline-body shape) owns the edge case.
- Avatar resets its broken-image state when src changes — latent today
  (every caller keys by identity), but it's a shared component and must
  be safe for callers that update src in place.

Left alone from this round: hashString/hashFingerprintPart duplication is
pre-existing, and the suggested cross-package import would drag node:path
into the browser bundle — the documented reason client mirrors exist.

* fix(review): PR-994 round 5 — merge-commit prompts, boundary-aware poll, auto-select guard

- Agent prompts for commit:<sha> diffs instruct the exact on-screen
  command: git diff <sha>^ <sha> (first parent), explicitly steering
  away from git show — whose combined-diff presentation for a MERGE
  commit renders a different (often empty) changeset than the one under
  review. Root-commit fallback noted. One site covers Ask AI and every
  launched reviewer (and Pi via vendoring); prompt test pins the command.
- The rail's freshness poll now adopts on boundary/base movement, not
  just a new head: an agent running git fetch advances origin/<base>
  while HEAD stays put, which re-partitions isPastBase — previously the
  head-only compare skipped adoption and the 'In origin/main' divider
  stayed stale until the view was reopened. Boundary is compared over
  the page-1 overlap window (a divider paged deeper than the probe can
  see re-syncs on the next full reload — accepted micro-edge). The
  reviewer's proposed trigger (the Fetch banner) is unreachable in
  commit mode; the external-fetch trigger is the real one.
- The HEAD auto-select never fires while any diff switch is in flight —
  hardening only: the claimed first-click overwrite isn't reachable
  (isLoadingDiff was never a dependency of that effect, and auto-select
  settles before a human can click), but the guard makes the invariant
  explicit.

* docs(adr): spec the pre-merge commits-view structure refactor

Three behavior-preserving moves scoped for PR #994 before merge, motivated
by the audit finding that every recent review-round bug lived at the
App.tsx <-> useCommitLog seam: (R1) unify the commits-session state
machine (poll, auto-select, veil) in one hook; (R2) lift the commit-rail
block out of the 1.6k-line review-core into commit-history.ts; (R3) share
the isSameCwdCommitSwitch predicate both runtimes inlined. Ownership
tables name what deliberately stays put; execution runs smallest-first
with full gates per commit.

* refactor(review): share isSameCwdCommitSwitch across runtimes (spec R3)

The ~10-line parse-and-compare predicate both /api/diff/switch handlers
inlined moves to review-core beside its inputs (parseWorktreeDiffType /
parseCommitDiffType — the canonical home for diff-type string logic);
both call sites collapse to one line. Unit tests cover plain, worktree,
cross-worktree, and non-commit-target cases. Behavior identical.

* refactor(shared): lift the commit-rail block into commit-history.ts (spec R2)

review-core.ts (1.6k lines) was absorbing the ~230-line Commits-panel
data layer it doesn't need to own: CommitListEntry/CommitHistoryPage/
listCommitHistory and CommitDiffInfo/getCommitDiffInfo move verbatim to
a new commit-history.ts, matching the package's per-concept split
(jj-core, pr-stack, commit-avatars). The commit:<sha> DIFF plumbing
(parseCommitDiffType, the runGitDiff/fingerprint/file-contents cases)
stays in review-core with the other diff types — it participates in the
dispatch; the rail does not.

Wiring: review-core exports its three parsing primitives (COMMIT_FIELD_
SEP, splitCommitFormatFields, BARE_HEX_SHA_RE — listRecentCommits still
uses them, so they can't move); package exports + vendor.sh gain the
module (vendored siblings import relatively, same as pr-provider →
pr-github); types.ts re-exports split by source; both runtimes import
from the new module. History/metadata test suites move to
commit-history.test.ts with the package-style per-file git harness;
commit-DIFF tests stay in review-core.test.ts. Behavior identical —
verified live through the compiled binary (/api/commits + commit
switch + commitInfo sidecar).

* refactor(ui): useCommitsView owns the whole commits-session machine (spec R1)

The list cache, freshness poll, HEAD auto-select, and center-dock veil
were split between useCommitLog and App.tsx — and every sync bug found
across three review rounds (stuck flags, stale auto-select, adoption
races) lived at exactly that seam. The auto-select effect and the veil
derivation move into the hook verbatim (renamed useCommitsView, git mv),
so the machine's invariants are locally checkable in one file.

App now supplies only what it owns — visibility, the active commit,
switch state, and onOpenCommit (its handleSelectCommit: the SAME path
user clicks take, so auto- and user-selection cannot diverge by
construction) — and consumes panel props plus veilActive. Net ~45 lines
of App.tsx's most delicate logic deleted.

One equivalence note: the hook's single "enabled" (showCommitsPanel &&
origin) now gates auto-select/veil where App used bare showCommitsPanel;
the two only differ when origin is unset, which implies no gitContext
(demo mode) and therefore showCommitsPanel === false — identical in all
reachable states.

Deliberately still in App (each guards a flow App owns): capability
gating, handleSelectCommit, the Cmd+F / worktree-fallback /
staleness-refresh / search-clear guards, and render wiring. Behavior
identical; spec: adr/specs/refactor-commits-view-structure-20260703.md.
2026-07-04 10:13:11 -07:00
Michael Ramos 795f381ebe feat(review): "All changes" git-status review view (#990)
* feat(review): "Since main" git-status review view + correctness fixes

Adds a composite `since-base` diff — merge-base(origin/main, HEAD) vs the
working tree, plus untracked — as the default code-review view, rendered as a
three-section "git status" panel (Committed / Changes / Untracked) with a
Sections|Tree toggle, a first-run setup chooser, and a "baseline behind GitHub"
fetch banner. Everything normalizes to one git patch, so the diff viewer,
annotations, and feedback path are unchanged.

New diff type wired through runGitDiff / fingerprint / file-content / context /
staging / agent-context in packages/shared + both server runtimes (Bun +
Pi mirror). Default flipped from `unstaged` to `since-base`; the old
DiffTypeSetupDialog is replaced by ReviewSetupDialog (view + default-diff
chooser, screenshots, Settings access).

Includes a reviewed batch of correctness fixes:
- P0: baseBehindRemote was permanently true (rev-parse missing --verify)
- fingerprint blind to quoted/unicode untracked paths (unquote)
- sections parser: record both sides of a rename; staged-delete wins over
  untracked (rm --cached collision)
- graceful degrade when merge-base can't resolve (trunk/no-remote repos)
- /api/fetch-base re-queries remote (narrow-refspec honesty)
- /api/diff/switch concurrency guard (diffSwitchEpoch) + draft rekey
- decouple remote-staleness probe from initialBase (Pi parity)
- keyboard file nav in the sections view; banner gated to base-relative modes

See adr/decisions/005-since-base-github-view-default-20260701-223706.md

* fix(review): since-base review-round hardening (11 fixes, both runtimes)

Addresses the multi-agent + PR-990 review findings:

- Sections rename parser split ` -> ` on the raw quoted porcelain token, so a
  filename containing ` -> ` tore into garbage and a dirty file could render as
  "Committed". Now quote-aware (splitPorcelainRename + unit tests).
- /api/diff/switch captured the epoch AFTER await req.json(), letting a
  slow-body older switch overwrite a newer confirmed one. Epoch now captured
  before any await; hideWhitespace committed only on win. Both runtimes.
- Unresolvable base (trunk / no origin/HEAD) no longer auto-defaults to a
  degraded since-base that hides committed work — getGitContext only offers
  since-base when the base ref resolves, so the default falls through to
  uncommitted. Fingerprint + file-content degrade to HEAD to match the diff.
- git rm --cached no longer yields two diff entries for one path (untracked
  files already in the tracked patch are dropped) — fixes wrong-file-open and
  j/k nav looping in the Sections panel; also fixes latent uncommitted/unstaged.
- Settings' "Default Diff View" list now preserves the sections<->since-base
  coupling (can't leave an invalid sections+classic pair).
- "Behind GitHub" banner only treats origin/* as fetchable; a bare local base
  ("main") is upgraded to its tracking ref at startup so Fetch can clear it.
- hashUntracked resolves untracked paths against the repo toplevel, not cwd, so
  a review launched from a subdirectory isn't blind to untracked edits.
- Agent review instruction now tells agents to enumerate/inspect untracked files.
- Terminology: one "Committed changes" label; the live "Since <base>" label is
  dynamic (matches the header); "Since main" kept only as product copy.
- Docs: CLAUDE.md endpoints/fields + since-base view section; ADR recap.

Verified: typecheck (all projects), bun test 1793 pass, and empirical repro of
the rm --cached dedup, trunk->uncommitted fallback, and rename parser.

* fix(review): restore "(PR view)" on the Committed-changes label (unify toward it, not away)

* fix(review): close since-base coverage gaps from PR-990 review round 2

- First-run setup no longer forces since-base on repos where it isn't available
  (base ref can't resolve). getGitContext omits since-base there; the first-run
  block now checks the same availability before resetting the default or showing
  the chooser, so committed work isn't silently hidden on trunk/no-origin repos.
- reviewBase now includes 'since-base', so changing the base while in the
  git-status view passes the selected base to /api/file-content — expandable
  context is fetched from the right merge-base (was falling back to default).
- Settings now mirror ReviewSetupDialog's coupling exactly: Sections ⇒ force
  since-base; Tree ⇒ leave the diff (Tree + since-base is valid and now
  saveable); classic diff ⇒ Tree; since-base diff ⇒ leave the view. Removes both
  coercions that previously discarded a supported preference.

* fix(review): PR-990 review round 3 — fingerprint/dedup/base-canonicalization

- Fingerprint now uses `git status --porcelain -uall`, so editing a file inside
  a brand-new untracked directory changes the fingerprint and the "Diff out of
  date" banner fires (was collapsed to `?? dir/` and hashed as unreadable).
- extractTrackedPatchPaths pairs `---`/`+++` and excludes only the file's KEYED
  path (new side, or old side for a pure deletion) — no longer drops a recreated
  rename-source file (`git mv a b && touch a`). Verified rm --cached still
  dedups to one entry and the recreated file still shows.
- resolveReviewBase canonicalizes a bare local default name ("main") to its
  tracking ref ("origin/main") on every call, so a client that loaded before the
  startup upgrade resolved can't revert the server to the stale local base on the
  next refresh/switch. Both runtimes (new Pi resolveReviewBase helper).
- Removed the dead activeDiffLabel prop chain (FileTree -> DiffTypePicker).
- ADR recap: documented the staleness-banner scope limitation (default base only).

Verified: typecheck (all projects), bun test 23/23 review-core+fingerprint pass,
empirical repro of the untracked-dir fingerprint, rm --cached dedup, and
rename-recreate cases.

* fix(review): PR-990 review round 4 — dedup content, staging gate, banner timing

- Dedup rewrite: instead of dropping the untracked side of a same-path collision
  (which hid content), drop the tracked DELETION block and keep the untracked
  working-tree content. `git rm --cached f` + edit now shows the new content, not
  a phantom deletion — still one entry per path (no dock/nav collision). New
  stripHeaderPath also strips git's trailing-tab metadata, so space-named files
  dedup correctly. Verified: rm --cached+modify shows content (1 entry),
  space-named (1 entry), rename+recreate still shows both.
- All-files (and the `a` shortcut on the focused file) now gate staging per-file:
  committed files in since-base mode are not stageable, matching SectionsPanel /
  FileTreeNode. Shared isPathStageable helper threaded via ReviewStateContext →
  AllFilesCodeView. Stops the confusing `git add` no-op that still flipped local
  staged/viewed state.
- /api/diff/switch now awaits recomputeBaseBehindRemote before building the
  response, so the "Baseline behind GitHub" banner reflects the new base
  immediately (no ~5s lag switching in, no stale banner switching away). Both
  runtimes.
- ReviewSetupDialog re-applies the recommended default only on first-run dismiss,
  not when reopened from the header menu (was snapping a mid-session diff back).
- SectionsPanel "N added" header now counts sidecar-staged files too, so it
  matches the staged dots on rows.

Verified: typecheck (all projects), bun test 1830 pass, empirical dedup repro,
both bundles build.

* refactor(review): reuse parsePatchPathToken in removeTrackedDeletions (self-review)

Drop the duplicated stripHeaderPath helper — the shared diff-paths
parsePatchPathToken already strips a/|b/ prefix + C-quoting + git trailing-tab,
and verifies the prefix instead of blindly slicing two chars. Documents the
binary-deletion edge (no --- line, so a binary rm --cached is not deduped).

* fix(review): PR-990 review round 5 — mixed-base + staging/sort/flicker nits

- Atomic base upgrade: the startup origin/* canonicalization swapped currentBase
  without rebuilding the patch, so /api/diff could advertise origin/main while
  the served hunks came from local main (mixed-base review on origin/HEAD-absent
  repos). Now rebuilds the diff for the new base and commits base+patch+ref+
  fingerprint together (only if no user switch happened); the fingerprint change
  makes the client's freshness poll pick it up. Both runtimes.
- isPathStageable: gate staging OFF when since-base is active but the sidecar
  hasn't loaded (was falling through to true, allowing a git-add no-op on a
  committed file).
- SectionsPanel: session-staged files now float to the top of Changes — the sort
  key was `false ?? stagedFiles.has(...)` which short-circuits; now ORs them.
- /api/diff/fresh: early returns now carry baseBehindRemote, so a snapshot change
  mid-probe no longer clears the "behind GitHub" banner for one poll. Both runtimes.
- Removed the now-dead activeLabelFallback prop from DiffTypePicker (FileTree
  stopped forwarding it in round 3).

Skipped (agreed): the fetch-base input-validation nit (no realistic attacker)
and the diff-switch TOCTOU (concurrent switches aren't UI-reachable).

Verified: typecheck (all projects), bun test 1830 pass, both bundles build.

* refactor(review): drop redundant staged-OR in SectionsRow (self-review)

item.staged (the grouping key) already ORs in stagedFiles as of the round-5
sort fix, so the row-level `|| stagedFiles.has(...)` is dead weight.

* fix(review): surface the startup base upgrade to already-loaded clients

Review round 6 fixes:

- The startup main -> origin/main upgrade re-baselined the freshness
  fingerprint, so a client that fetched /api/diff before the rebuild kept
  the old patch and every /api/diff/fresh probe reported fresh (the probe
  compares server state to itself, never to what the client renders). Now
  the fingerprint is only re-baselined when no client has loaded the
  pre-upgrade snapshot; otherwise the stale baseline trips the normal
  "Diff out of date - Refresh" banner. Bun + Pi.
- preserveFile refreshes (staleness Refresh, post-Fetch) now adopt the
  server's returned base — exactly the paths where the server may have
  canonicalized main -> origin/main; keeping the old name sent
  /api/file-content and Ask AI context against the wrong base.
- ReviewSetupDialog: clamp the fixed 800px height to the viewport so the
  dialog fits on small laptop screens.
- Docs: /api/diff/fresh response also carries baseBehindRemote/agentCwd.
- Documented the committed-deletion + untracked-recreation dedupe edge as
  accepted (code comment + ADR) — fixing it needs two same-path diff
  entries, which the path-keyed UI cannot represent.

* fix(review): stop the Git-status default from silently reverting to tree

Users with reviewPanelView=sections could keep opening in the tree view on
a stale diff type despite their cookie saying Git status. Three causes:

- The header Sections toggle persisted the view but not defaultDiffType,
  creating a conflicted pair (sections + non-since-base default) that every
  UI writer is supposed to prevent. It now couples the diff default like
  the setup dialog and Settings do.
- configStore's debounced POST /api/config could be lost when a session
  closed within 300ms of a change, leaving cookie and config.json split.
  Pending writes now flush on pagehide with a keepalive fetch.
- configStore.init() applies config.json over the cookie without the UI
  coupling, so a stale server value re-corrupted the pair on every load.
  The app now self-heals at load: if the view says sections but the diff
  default isn't since-base, it repairs the default (cookie + config.json)
  and switches the live session to since-base.

* fix(ui): keep the stage (+) button border visible on the active file row

The button's --border border has no contrast against the active row's 30%
primary tint, so it vanished on the selected row until hovered. Tint the
border with the row's primary color on active rows (both sections and tree
views); the button's own hover border still applies.

* ui(review): spell out the panel view toggle — 'Git status | Tree' text instead of icons

* fix(review): PR-990 review round 7 — export label, diff snapshot race, unicode paths

- exportFeedback describeDiff(): add the missing since-base case — every
  feedback export in the new default mode read "**Diff:** since-base".
- /api/diff GET: snapshot patch/base/ref/error BEFORE the sections-sidecar
  await and pass the pinned base into buildSectionsSidecar. The startup base
  upgrade landing mid-await could pair a rebuilt patch with sections grouped
  against the old base, with initialDiffServed still false so no refresh
  banner ever came. Bun + Pi.
- SectionsPanel: a session-staged untracked file now moves to the Changes
  section immediately (anticipating the server's next sidecar) instead of
  sitting in Untracked with a staged dot until refresh.
- unquoteGitPath: real C-style unquoting with octal (UTF-8 byte) escape
  decoding. JSON.parse rejects octal escapes, so non-ASCII names kept their
  literal \303\251 form — an untracked "café.txt" was silently ABSENT from
  the review (git diff --no-index could not access the quoted name) and the
  deletion dedupe Set lookup could never match. getUntrackedFileDiffs now
  unquotes ls-files output. Unit tests: octal decoding + end-to-end unicode
  untracked file in a real repo.
- review-core.test: worktree subtype round-trip now covers since-base and
  all (was 6 of 8).

Parked (deliberate): SectionsPanel/FileTree keyboard-nav dedup refactor.

* fix(shared): unquoteGitPath keeps literal unicode intact (self-review)

The byte-collector treated literal non-ASCII code units as single bytes,
which would mojibake headers synthesized by our own quoteGitPath
(JSON.stringify leaves unicode unescaped inside quotes — workspace-mode
prefixed headers round-trip through the same parser). Literal chars now
append as string code units (surrogate-safe); only octal escapes go
through the UTF-8 byte decoder.

* fix(review): PR-990 review round 8 — pre-staged toggles, explicit base, AI context race

- useGitAdd: session Set replaced with a tri-state override map folded over
  the sections sidecar; stagedFiles is now the EFFECTIVE staged set (sidecar
  + session stages - session unstages). Fixes pre-staged files: the first
  `a` press actually unstages (was a git-add no-op), the sidebar dot clears
  after a real unstage (was stuck via sidecar OR), and the All-files header
  agrees with the sidebar on load. Overrides reset whenever a fresh sidecar
  arrives (switch, preserveFile refresh, PR response) so stale session
  intent can't fight new porcelain truth. SectionsPanel drops its own
  sidecar OR; stagedCount = effective size.
- Explicit base picks are honored verbatim: the picker sends explicitBase,
  and the server permanently disables local-name -> origin/* canonicalization
  once set (the local/remote groups are distinct choices). The behind-GitHub
  banner also exempts an explicitly-picked local name — Fetch advances
  origin/*, so the banner would be un-clearable nagging. Bun + Pi.
- buildCurrentAiReviewContext(patch, base): GET /api/diff builds Ask AI
  context from the same served snapshot as the patch — the startup base
  upgrade could hand Ask AI a different changeset than the screen. Bun + Pi.
- recomputeBaseBehindRemote: capture remoteDefaultInfo once — a concurrent
  refresh nulling it mid-await threw. Bun + Pi.
- Revert stray "Status Update / Updated!" edit to adr/0001 (test debris
  swept into the round-2 commit).
- splitPorcelainRename comment corrected (porcelain v1 does NOT quote plain
  spaces; a name containing " -> " is ambiguous without -z) + ADR notes for
  the index-only-changes semantics and the rename edge.

* fix(review): FileTreeNode uses the effective staged set — round 9

Round 8 made stagedFiles the effective set (sidecar + session overrides)
and removed SectionsPanel's sidecar OR, but missed the same OR in
FileTreeNode's since-base row (sectionStaged). In the Tree fallback a
pre-staged file unstaged this session kept its dot and the next toggle
re-staged it. Grep-swept: this was the last surviving sidecar-staged OR.

* refactor(review): make the staged-display invariant unrepresentable

The round-8/9 bug class (sidecar staged flag ORed over the effective set)
existed because surfaces had a second staging source to reach for. Remove
it: stagedFiles is now a REQUIRED prop on SectionsPanel/FileTree/
FileTreeNode and the optional-prop fallback branches reading
sectionEntry.staged for display are deleted — a future surface cannot
reintroduce the OR because the pattern no longer exists to copy. The
sidecar type's staged field and AGENTS.md now document the invariant at
the point of temptation. Grep for display reads of .staged now hits only
useGitAdd (owner) and the sidecar builder (producer).

* refactor(review): self-review cleanups on the staged-invariant hardening

- orderFilesBySections: document why its snapshot .staged read is safe
  (only called at sidecar-fresh moments where snapshot = effective) and
  that it must not be reused mid-session — the one remaining display-side
  snapshot read the invariant sweep surfaced.
- FileTreeNode: drop the now-pure sectionStaged alias; use isStaged.

* fix(review): PR-990 review round 10 — header staging gate, fingerprint cap, single-writer coupling

- Single-file diff header now uses the per-path staging gate (canStagePath),
  closing the last ungated staging trigger: committed-only files in
  since-base offered a no-op Git Add that flipped local staged/viewed state.
  Full trigger inventory swept: App `a` shortcut, all-files `a` + header,
  SectionsPanel rows, FileTreeNode rows, single-file header — all six now
  per-path gated or group-gated; no context-menu staging exists.
- Fingerprint circuit-breaker: the freshness poll's `git status --porcelain
  -uall` degrades permanently (per cwd, per process) to collapsed -unormal
  once its output exceeds 2MB — a forgotten node_modules/ no longer burns
  CPU every 5s for the whole session. Costs untracked-dir edit sensitivity
  only on such repos; one possibly-spurious staleness banner at the switch.
- The sections ⟺ since-base coupling now has a single writer:
  setReviewPanelView/setReviewDefaultDiffType in @plannotator/ui/config.
  All five call sites (setup dialog x2, Settings x2, header toggle,
  first-run reset, self-heal) converted; grep for direct writes of either
  setting now hits only reviewView.ts.
- /api/diff/switch responses pass snapshot args to the AI context builder
  (both branches, Bun + Pi) — correct today, now robust against future
  awaits between the epoch check and the response.
- Docs: explicitBase in the /api/diff/switch body.

Parked per discussion: SectionsPanel/FileTree nav+search+footer dedup
(extract when the commit-list view adds a third panel), querySelector row
measurement, fetch-base stderr passthrough (standing decision), Pi
hasAgentLocalAccess (pre-existing follow-up list).

* fix(review): PR-990 review round 11 — subdirectory launches, escape decode, settings note

- Repo-root-relative patch paths now resolve against the git toplevel
  everywhere they meet a filesystem path or pathspec, via a shared
  resolveRepoToplevel helper: file-content working-tree reads (hunk
  expansion returned null from a subdirectory launch) and gitAddFile/
  gitResetFile (stage/unstage failed with pathspec errors). Both bugs
  pre-existed for uncommitted/unstaged; since-base made them the default
  experience. The two existing inline toplevel resolutions (untracked
  diffs, fingerprint) now use the same helper. Shared code — Pi inherits
  via vendoring. Real-repo subdirectory tests for both.
- unquoteGitPath decodes \uXXXX (JSON.stringify emits it for control
  chars without a short escape; our synthesized workspace headers
  round-trip through this decoder). Malformed \u stays literal. Tests.
- Pi explicitBase guard matches Bun byte-for-byte on empty-string base.
- Settings Git tab notes when the CURRENT repo can't serve the Git-status
  view (base ref unresolvable) instead of letting the preference look
  silently broken — it's a global preference, so the options stay.

Same-class items left parked (pre-existing, untouched by this PR):
open-in root resolution and code-nav file reads from subdirectory
launches — on the follow-up list with the Pi divergences.

* fix(review): PR-990 review round 12 — per-client freshness, keyboard-operable row controls

- Freshness is now judged PER CLIENT: every patch-carrying response
  (/api/diff, /api/diff/switch, pr-switch, pr-diff-scope) includes
  snapshotId (the server's draftKey), and the client echoes it on
  /api/diff/fresh probes. A mismatch reports stale for THAT client
  regardless of the VCS fingerprint. This fixes the round-12 finding —
  reloads/second tabs after the startup base upgrade got a permanently
  bogus staleness banner from the shared pre-upgrade baseline — and
  deletes the round-6 conditional re-baseline hack entirely (the
  fingerprint recaptures unconditionally again; the old client's banner
  now comes from its snapshot mismatch, not a deliberately stale
  baseline). Also gives unfingerprintable modes (P4, PR layer) snapshot-
  level staleness for free. Bun + Pi + client hook.
- StageControl and ViewedControl (which had the identical gap) are
  keyboard-operable: tabIndex + Enter/Space activation + focus outline.
  They're spans inside the row <button> (nested real buttons are invalid
  HTML), so they need their own focus stop; the a/v shortcuts remain the
  power path.
- Docs: snapshotId on /api/diff, ?snapshot= on /api/diff/fresh.

* fix(review): PR-990 review round 13 — re-key snapshots on scope switch, PR-tab refresh

- The PR scope switch now re-keys draftKey (= snapshotId + draft storage
  key) at BOTH commit points, unconditionally — matching every other
  snapshot commit site. The full-stack branch previously kept the layer
  patch's key: stale layer tabs never got the banner after a cross-tab
  scope switch (the exact case snapshotId exists for), and full-stack
  drafts collided with layer drafts (pre-existing). The layer branch's
  !layerPatchIncomplete conditional is gone — it only stayed consistent
  because full-stack never re-keyed. Invariant now: every currentPatch
  commit is followed by a re-key. Bun + Pi.
- Refresh works for any stale PR tab: re-selects the CURRENT scope
  instead of no-opping for layer (only full-stack could go stale in the
  fingerprint-only world; snapshot mismatch changed that). Accepted
  residual (documented in code): after a cross-tab PR switch, refresh
  updates the patch but not prMetadata — the scope endpoint doesn't
  carry it, and the full-rehydrate refactor isn't worth the two-tab edge.

* fix(review): stale incomplete-layer Refresh uses the non-blocking upgrade path (self-review)

Round 13 made Refresh re-select the current scope for stale PR tabs; for
an INCOMPLETE layer patch that POST triggers the server's local recompute,
which can park for minutes behind checkout warmup — and
handlePRDiffScopeSelect renders the full-screen switch overlay the whole
time. Route that case through handleLoadFullDiff (same POST, progress
notice instead of modal), which exists for exactly this slow path.

* fix(review): round 14 — composite snapshot id, fully-pinned /api/diff, "All changes" label

- snapshotId is now content hash + diff type (+ PR scope), built by a
  single currentSnapshotId() helper used at every response site and the
  freshness compare. A cross-tab MODE switch with a byte-identical patch
  (layer vs full-stack on a single-PR stack) now flags old tabs; the base
  is deliberately excluded so a same-commit main -> origin/main
  canonicalization stays banner-silent (round-12 noise-avoidance kept).
  draftKey stays a pure content hash — drafts survive content-identical
  round-trips. Bun + Pi.
- GET /api/diff pins ALL served fields (diffType, hideWhitespace,
  prDiffScope join patch/base/ref/error/snapshotId) and the sidecar + AI
  context builders take the pinned type instead of reading globals — a
  concurrent tab switch during the sidecar await can no longer produce a
  since-base patch labeled with another mode. Bun + Pi.
- First-user feedback: the since-base label is now plain English —
  "All changes since origin/main" (dynamic, follows the picked base);
  dialog/Settings short form "All changes"; feedback exports match.
  uncommitted reverts to "Uncommitted" where it had borrowed "All
  changes", so the two stay distinguishable side by side.

* docs(review): finish the Since-main -> All-changes terminology sweep in comments (self-review)

* fix(review): PR-990 review round 15 — base-revert race, freshness reset, unstage regroup

- resolveReviewBase gains a second, probe-independent rule: a non-explicit
  echo of the bare local name of the CURRENT origin/* base stays on the
  tracking ref. The existing rule keys off remoteDefaultInfo, which comes
  from a second network probe that can lag the startup upgrade by seconds;
  in that window a diff-type/whitespace switch echoing "main" committed
  the session back onto the stale local branch and set baseEverSwitched,
  permanently blocking the upgrade. Bun + Pi.
- useDiffFreshness resets stale/dismissed state on snapshotId too — since
  round 14 a new snapshot can reuse identical patch text with a different
  id, and the old banner state wrongly carried over until the next poll.
- SectionsPanel: unstaging a PRE-staged add moves it back to Untracked
  (mirror of the round-7 stage regroup). Detection deliberately uses the
  sidecar's snapshot flag to recognize "was pre-staged"; staged
  modifications stay in Changes, staged renames remain a refresh-heals
  edge.

* fix(review): PR-990 review round 16 — rename staged count, guarded fetch-base replay

- "N added" no longer double-counts staged renames: the sidecar truthfully
  marks BOTH porcelain sides staged, but an above-threshold rename renders
  as ONE file — the hidden old path inflated the count and left a phantom
  effective-staged entry that unstaging the visible row couldn't clear.
  The client now filters sidecar-staged paths to rendered files, which is
  correct in both patch shapes (below-threshold renames render delete+add
  as two rows and both sides pass the filter). Client-only; the sidecar
  stays faithful to porcelain.
- The fetch-base completion only replays the diff refresh if the user's
  diff type/base selection is unchanged since the click — a slow fetch no
  longer yanks the review back to the view captured at click time.

Declined with reasons (in review thread): first-run persist-before-confirm
(approved forced default, second flagging), explicit-base flag ordering
(early-set preserves user intent; commit-after-win would leave picker and
server disagreeing), base-picker revert target (near-unreachable compound
race, deferred).

* fix(review): PR-990 review round 17 — find origin/main on feature-only clones

getDefaultBranch's chain (origin/HEAD -> local main -> blind "master")
skipped the fetched remote-tracking ref entirely. On checkouts with no
origin/HEAD symref and no local main/master — CI checkouts, `clone
--branch feature`, extra worktrees — it guessed "master", the base
didn't resolve, getGitContext suppressed since-base, and the flagship
"All changes" view silently disabled itself for the whole session even
though origin/main was fetched and diffable. The startup upgrade can't
rescue it either (its guard reads the bogus "master" as a deliberate
non-default base).

Chain is now: origin/HEAD (verified) -> origin/main -> local main ->
origin/master -> "master" — remote-tracking refs preferred, matching the
function's stated prefer-upstream intent. Shared core (Pi inherits);
real-repo test reproducing the exact clone shape.
2026-07-04 09:53:49 -07:00
Michael Ramos 9bded34324 chore: bump version to 0.21.4 v0.21.4 2026-07-01 11:32:47 -07:00
Michael Ramos 17b862339d chore(test): use loadable image URLs in the LaTeX/media fixture
The <img>/<picture> examples pointed at example.com placeholders that render
broken. Point them at picsum.photos / placehold.co so a manual run actually
shows images loading (and still proves the tags parse as media, not text).
2026-07-01 11:26:45 -07:00
Michael Ramos 16db952000 fix(ui): stop unclosed math from pairing with a stray delimiter in a later code fence
The unclosed-$$/\[ guard scanned ahead to EOF for the closing delimiter, so an
unterminated opener could match a $$ (or \]) that appears far below - e.g. inside
a code fence - and swallow every heading/paragraph in between into one broken
math block. The scan now stops at a blank line: real display math has no blank
line before its close, so a blank both confirms "unclosed" and keeps the search
from reaching distant delimiters.

Adds a regression test and a manual LaTeX + media test fixture.
2026-07-01 11:23:18 -07:00
Michael Ramos cd934e6de2 fix(ui): stop stray math delimiters from mangling plan/PR text
Two math-parser regressions from #878, both the same "greedy delimiter" class
as the <video> fix in ec551c83:

- Unclosed $$ / \[ swallowed every following block to EOF - a stray delimiter
  or informal "$$100k" hid headings/paragraphs from the TOC and made them
  unannotatable. The block parser now scans ahead for the close without
  committing; with no close it falls through and treats the line as ordinary
  text, matching the unclosed-HTML-tag policy.
- Inline $A$B where a digit abuts the closing $ ("$5-$10", "$50,000-$100,000",
  "$5/mo and tier B is $10/mo") rendered currency amounts as KaTeX. Left literal
  now - real inline math never abuts a digit across the closing delimiter.

Adds regression tests and updates the test that codified the old
swallow-to-EOF behavior as intended.
2026-07-01 11:15:55 -07:00
Michael Ramos d06b1a337a fix(review): scope PR description/comment notes to their PR
The two prose-annotation stores added in #981 carried no PR identity, so after
an in-place PR switch they still rendered, counted, and exported against the new
PR - leaking one PR's notes into another PR's agent feedback and GitHub/GitLab
review body. Stamp each note with the active prUrl at creation (mirroring
CodeAnnotation) and filter display/export/count to the active PR via a shared
proseAnnotationMatchesPr helper. Notes are never discarded - the full arrays and
the draft keep every PR's notes, so a switch back re-reveals them.
2026-07-01 10:57:38 -07:00
Michael Ramos ec551c83af fix(ui): stop unclosed <video>/<picture> from swallowing the document
An unclosed or self-closing <video>/<picture> ran the HTML-block scanner off
the end of the document, funneling every following heading/table/code block
into one opaque html block (gone from the TOC, unannotatable). The scanner now
only extends the block when it actually finds the matching close tag; otherwise
it keeps the block to the opening line. Also fixes multi-line <img> (was
truncated to a bare "<img" fragment) and allowlists srcset/media/sizes so
<picture>/<source> and responsive <img> render instead of rendering inert.

Reachable via GitHub PR description/comment media (#981) and plannotator
annotate on any .md file.
2026-07-01 10:57:38 -07:00
Devin d39bc871b7 feat(ui): render markdown math (#878)
* feat(ui): render markdown math

* feat(ui): support more markdown math delimiters

* feat(ui): renders spaced dollar-delimited TeX support

* chore(deps): update katex

* Update bun.lock

chore(deps): update

chore(deps): update

chore(deps): update

chore(deps): update

chore(deps): update

* feat(highlight): highlight math

* feat(ui): support formule & text highlight together

* fix(ui): make rendered math drag-selectable for annotation

Drag-selecting an inline formula did nothing: the browser normalizes the
selection focus to offset 0 of the following text node, so the strict
anchor/focus check in selectionHasNonMathContent treated every inline
drag as "mixed prose+math" and bailed without claiming the event. Control
fell to web-highlighter, which excludes .katex/.math-annotatable, so no
annotation was created and the selection just collapsed.

Drop the anchor/focus check and rely on the existing clone-and-strip logic
(leftover text => genuinely mixed), plus a guard for selections wholly
inside one formula. Add a regression test that reproduces the real
spilled-endpoint drag, which the prior empty-selection tests missed.

Also paint a preview highlight the moment a formula's toolbar/popover
opens (stripped on cancel, committed on submit) and show cursor: pointer
on hover so formulas read as annotation targets.

* chore(ui): reconcile lockfile and math styles after rebase onto main

Rebase reconciliation, not new behavior:
- Re-add katex to bun.lock. The lockfile conflicted on every replayed
  commit and was resolved to main's copy throughout; regenerate it here so
  package.json (katex) and the lockfile agree again.
- Move the math annotation styles (.math-inline-annotation /
  .math-block-annotation) into packages/ui/theme.css. main relocated all
  annotation-highlight rules there to share them with the code-review
  description annotations; the PR had added the math variants to the old
  editor/index.css location.

* test(ui): lazy-load highlighter hook so DOM-less bun test skips cleanly

The math-annotation test statically imported useAnnotationHighlighter, which
pulls in @plannotator/web-highlighter — a UMD bundle that reads `window` at
module-eval time. Under the default `bun test` (no DOM), that threw a
ReferenceError on import, failing CI before the skipIf(!hasDom) guards ever
ran. Import the hook lazily, gated on hasDom, so the file loads and its tests
skip cleanly in CI while DOM_TESTS=1 still exercises them.

* fix(ui): stop display math from swallowing the document

The `$$`/`\[` block parser only recognized the closing delimiter when it was
the last non-space text on its line. A line like `$$E=mc^2$$.` (trailing
period) or `$$E=mc^2$$ where E is energy.` therefore looked like an
unterminated opener and consumed every following line until the next fence or
EOF — silently hiding the rest of the plan.

Detect the closing delimiter anywhere on the line and re-process any trailing
text as its own line, so the equation renders and the following content is
preserved instead of dropped. Same fix for the multi-line closing path and the
`\[ ... \]` branch. Adds parser regression tests (these run in CI; no DOM).

---------

Co-authored-by: ishowman <ishowman@users.noreply.github.com>
Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-07-01 10:48:53 -07:00
Michael Ramos e3de938914 feat(review): PR Overview panel + description/comment annotations + media (#981)
Combine the PR Summary/Comments/Checks tabs into one PR Overview panel, then
make the description and comments annotatable and render their media.

- PR Overview panel (one sidebar entry) + comment UI (avatars, filters, hide
  bots, live context, responsive stacking).
- Annotate the PR description (select → comment) and PR comments (Annotate
  button), with Ask AI; notes show in the Annotations sidebar and ship to the
  agent.
- Split/Unified diff toggle relocated into the dock tab strip.
- Render images + video in descriptions and comments (raw HTML + markdown),
  capped to the card so nothing bleeds.
- Review-flow fixes: copy-all feedback, prose-only feedback preamble, no image
  control on prose notes, GitHub review-body seeding; stronger review trailer.
- Add Claude Sonnet 5 as the default Ask AI model.

No server, endpoint, or Pi-runtime changes.
2026-06-30 23:05:43 -07:00
Michael Ramos ce5afdf94a feat(review): add "Copy agent instructions" for external review comments (#983)
Mirrors plan mode's agent-instructions feature in code review. Adds
buildReviewAgentInstructions() — the clipboard contract teaching an
external agent to read the diff via /api/diff and POST line/file/general
scoped comments to /api/external-annotations (accurate against
transformReviewInput). Wires a gated "Agent Instructions" item into the
review header menu (shown only in a live session).

Also drops the now-confusing cross-reference to code review from the
plan instructions module.
2026-06-30 11:32:21 -07:00
Michael Ramos 819b14c0d7 fix(review): show pending agent job launches (#980) 2026-06-29 21:43:19 -07:00
Michael Ramos f9ff55bfe0 feat(annotate): show annotation count badge in plan/annotate header (#979)
The plan-review and annotate apps share AppHeader, whose annotations
toggle had no count badge — unlike the code-review app's equivalent
button. Add a numeric badge (capped at 99+) driven by the existing
feedbackAnnotationCount, so both surfaces now surface how much feedback
is pending. Closes the parity gap with the code-review header.
2026-06-29 15:12:43 -07:00
Michael Ramos 1eee3eb313 chore(vscode): bump extension to 0.16.7 (clipboard/keyboard bridge #970, data-dir #795) 2026-06-28 12:51:07 -07:00
Michael Ramos ec8342c34a chore: bump version to 0.21.3 v0.21.3 2026-06-28 11:37:37 -07:00