Commit Graph

66 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: keep the new QA-batch comments free of em dashes
2026-08-13 11:35:00 -07:00
Michael Ramos fc348687bf fix(review): contain /api/call-flow analysis throws as JSON error responses (#1272)
* fix(review): contain /api/call-flow analysis throws as JSON error responses

A hard VCS failure during patch materialization escaped the handler in
both runtimes. On Pi the unhandled rejection reached the process-level
handler and killed the user's session; on Bun it surfaced as a non-JSON
500 the client's quiet-failure UX could not parse. Both handlers now
return the standard { status: "error", reason: "analysis-failed" }
envelope.

* fix(review): cut the Call Flow consent copy down to the three facts that matter

Six sentences of disclosure read as noise. The dialog and Settings now
say: what it does, what it installs (languages + size), Node 22+, and
that other languages install as needed. Nothing consent-relevant was
removed.

* test(review): pin consent-copy facts, not prose

The presentation test now asserts the server-derived facts (languages,
size, Node floor); the dialog and Settings tests assert only that the
disclosure prop renders, via a sentinel string. Copy edits no longer
break three test files.

* docs: add Testing Rules to AGENTS.md (no prose-pinning, no round-trip prop tests)

* docs: refine copy-pinning rule — deliberate locks allowed, incidental snapshots banned

* fix(review): use the maintainer's Call flow description in the intro dialog and Settings

* fix(review): Call flow description is the maintainer's exact copy; remove the dynamic disclosure plumbing

The intro dialog and Settings now show only: 'Diffs for function call
stacks across git commits. 22 languages supported (AST-based, built
using Tree-sitter).' The callFlowEnableDescription prop, its App wiring,
and getCallFlowEnableDescription are removed; install size and Node
requirements remain visible in the Call Flow panel itself.

* fix(review): reject empty-path worktree diff types; clean up QA findings

- parseWorktreeDiffType returns null for a worktree diff type with no path.
  An empty path resolved to an empty cwd, and Bun.spawn({ cwd: "" }) runs
  git in the server's own directory instead of the target repo, so a
  malformed 'worktree:' switch returned an unrelated checkout's diff.
  Fail closed to the caller's real cwd. (Pre-existing; surfaced by QA.)
- Remove an orphaned JSDoc comment left by the callFlowEnableDescription
  prop removal in Settings.tsx.
- Add useCallFlowAnalysis.test.tsx to the CI DOM_TESTS list; its two
  tests were silently skipping on every run.
2026-08-11 22:55:18 -07:00
Michael Ramos caf7ce1ccd feat(review): install Call Flow automatically in the background on opt-in (#1271) 2026-08-11 17:48:18 -07:00
Michael Ramos 9ee2e83287 feat(review): make the CallDiff runtime a strictly opt-in, in-UI install (#1270)
* feat(review): make the CallDiff runtime a strictly opt-in, in-UI install

The merged CallDiff integration eagerly installed a ~784MB runtime for
every user at install time, for a feature that is off by default. The
runtime is now strictly opt-in and the opt-in lives in the review UI:
toggle Call flow, click Install in the panel, watch staged progress, and
use the analysis in the same session.

Installers: the default sequence no longer installs the runtime. Opt in
with --with-call-flow (PowerShell: -WithCallFlow),
PLANNOTATOR_INSTALL_CALLDIFF=1, or { "installCallFlow": true } in
config.json (flag > env > config). PLANNOTATOR_SKIP_CALLDIFF_INSTALL is
deleted; --minimal keeps excluding the runtime; the installer prints an
honest note pointing at the in-app install. The headless CLI path
(plannotator install-runtime call-flow) is unchanged.

Server (both runtimes, contract-identical): POST /api/call-flow/install
starts installCallFlowRuntime() in the background via a single-flighted
coordinator (concurrent POSTs join the in-flight install), runs a
Node 22+ preflight before any download (distinct node-unavailable
error), and rejects cross-origin POSTs with 403. GET
/api/call-flow/install-status reports idle/running/done/error with
stage: downloading, verifying, installing-deps, building. Install
completion invalidates the 30s runtime probe cache so the next
capability advert resolves available without a server restart.

Client: the Call flow Dock's runtime-missing state is now the opt-in
funnel with an honest disclosure (about 800 MB on disk, Node 22+,
one-time), staged reduced-motion-safe progress, and error + retry with
a no-node hint. On done the advert is refreshed through
POST /api/review-analysis and the existing available-change refetch
starts the analysis for the current snapshot with no reload. The intro
dialog and Settings toggle note the separate first-use runtime.

Docs: AGENTS.md env table + Review Server API table, marketing
environment-variables / installation / ui-settings / code-review /
api-endpoints pages, and the CallDiff ADR runtime-boundary and server
contract sections.

* test(review): stop leaking PLANNOTATOR_DATA_DIR from the install endpoint tests

The call-flow install endpoint tests overrode PLANNOTATOR_DATA_DIR at
module-eval time and never restored it. bun runs CI's full suite in one
process and evaluates every test file's module before running tests,
while Pi's generated/storage.ts caches its data dir at import time; the
override therefore made storage's cached dir and later files' live
getPlannotatorDataDir() calls disagree, failing the Pi annotate-history
unwritable-dir test and both durable-submit-record tests.

An afterAll restore alone is not enough: it reproduces the same three
failures with the mismatch inverted (storage caches the leaked dir at
module eval, tests then run against the restored one). The env var is
now never touched at module-eval time at all; it changes only inside
tests and is restored to its original value in afterEach, exactly like
the PORT/PATH pattern. The config writes the advert tests persist
through the process's frozen config module are snapshotted at load and
restored in afterAll so a standalone run never flips a real
config.json setting, and the process-global scope of the mock.module
seams is documented.

Regression proof (previously failing in either mismatch direction, now
green in both orderings):

  bun test packages/server/call-flow-install-endpoint.test.ts \
    apps/pi-extension/server/annotate-history.test.ts \
    apps/pi-extension/server/annotate-submission.test.ts

* feat(review): install CallDiff grammars selectively

* fix(review): harden CallDiff worker environment

* fix(review): close CallDiff verification gaps
2026-08-11 16:28:08 -07:00
Michael Ramos 3245310aa8 feat(review): add optional CallDiff call-flow analysis (#1268)
* feat(review): add optional CallDiff call-flow analysis

* fix(review): harden CallDiff integration
2026-08-11 13:18:35 -07:00
Michael Ramos 156761a3fa feat(review): author suggestions by editing code in place (experimental, flag-gated) (#1193)
* feat(ui): add experimental editSuggestions setting (cookie-only, default off)

Registers the flag in the settings registry and surfaces it under a new
Experimental section of the review Display tab. Not synced to server
config while experimental; when off, no edit UI renders anywhere.

* feat(review): edit-to-suggestion core (adapter wall, derivation, session controller)

pierreEditAdapter.ts is the single module allowed to reference
@pierre/diffs/edit: the editor chunk loads via dynamic import on first
use, types are derived structurally, and the EditProvider factory
declines attaches until the module is ready (CodeView retries).

deriveSuggestions.ts diffs the session's final contents against the
pre-edit new-side content with the diff package and emits one minimal
hunk per contiguous changed region, expanding pure inserts/deletes to
include an unchanged anchor line so originalCode and suggestedCode stay
non-empty. CRLF input is normalized.

cloneDiff.ts deep-clones FileDiffMetadata before a session starts;
Pierre's editor mutates it in place (additionLines, hunks,
editSessionDirty), and the clone is what gets republished when the
session ends.

useEditSession.ts owns the lifecycle: one file at a time, full-content
hydration before entry (partial diffs throw in applyDocumentChange),
suggestion creation on completion, and session end as ONE combined item
write (edit off plus restored pristine fileDiff with a fresh cacheKey),
which is upstream's documented commit pattern and avoids racing
CodeView's teardown re-render. persistState is deliberately unused
(upstream bug, open PR #1048). Existing annotations are projected to
editor severity markers best-effort via onAttach.

* feat(review): wire edit-to-suggestion into the plain all-files view (flag-gated)

The plain all-files dock panel is the only surface that opts in; Guided
Review deliberately stays off because its viewport manager evicts
CodeViews beyond ~8 mounted, which would destroy an active editor
session. When the flag is off the surface renders byte-identical to
before: no EditProvider, no edit props, no header buttons.

FileHeader gains the Edit entry button (disabled with a tooltip when
content cannot be fetched) and the in-session Editing badge with
Suggest/Discard controls. Slot portals republish before React commits
state, so header rendering reads the session refs, not state.

AllFilesCodeView routes every collapse path through finishIfEditing so
a collapse ends the session on our one code path, blocks the annotation
toolbar and augmentation applies for the file being edited, and drops
session state on fileSetKey remounts (diff switches are user-initiated;
in-progress edits are discarded, documented v1 behavior).

App.tsx converts derived hunks into ordinary suggestion annotations
(type comment with suggestedCode/originalCode, side new, anchored to
new-side line numbers), the same shape SuggestionModal produces, so
rendering, sidebar, drafts, and feedback export work unchanged. The
browser never writes files; the agent applies suggestions.

* test(review): cover edit-to-suggestion derivation, clone invariant, adapter wall, and flag-off UI

deriveSuggestions: no-op edit, single and multi region edits, whole-line
add and delete with anchor expansion, insertion at file start, file
emptied, CRLF vs LF, trailing newline. cloneDiff: pristine clone stays
byte-identical after the live object is mutated the way Pierre's edit
session mutates it. adapterWall: only pierreEditAdapter.ts may
reference @pierre/diffs/edit, and only via import type or dynamic
import. FileHeader DOM tests (DOM_TESTS=1): zero edit UI without the
feature, entry button, disabled tooltip, and the Suggest/Discard
session controls; registered in test.yml's DOM step.

* fix(review): resolve anchor collisions in suggestion derivation

deriveSuggestionHunks could emit overlapping hunks when adjacent regions
claimed the same anchor line (a file-start region expanding forward into a
line the next region's backward expansion also claimed). An agent applying
the suggestions against original line numbers would silently drop one or
duplicate code.

Regions are now emitted left to right with collision-aware anchoring:
backward expansion is preferred but only when the preceding line is not
already claimed by the previous hunk; otherwise the region anchors forward
(always an unchanged line, and later regions see it as claimed); when both
directions are unavailable (previous hunk adjacent and region runs to end
of file) the region merges into the previous hunk as one spanning
suggestion. A defensive fold enforces the non-overlap invariant
(lineStart > previous lineEnd) as a safety net.

Tests: unit cases for both reproduced collision shapes plus adjacent
regions at file start and end, and a 5000-case seeded fuzz asserting zero
overlaps, exact originalCode anchors, and full reconstruction of the
edited text under a sequential offset-tracking applier. A 20,000-case run
of the same generator reports zero overlaps, zero anchor mismatches, and
zero round-trip failures.

* feat(review): export originalCode as a Replaces block in feedback

Suggestions carried originalCode internally but the export never emitted
it, so the applying agent had no way to validate an anchor before
applying. Each suggestion now exports a fenced Replaces block (the exact
current lines being swapped out) ahead of the existing Suggested code
block, for both line and file scoped annotations. SuggestionModal-authored
and edit-session-derived suggestions flow through the same single format.
A deletion-only suggestion (no suggestedCode) still emits its Replaces
block so the anchor stays verifiable.

Tests assert the block pairing per annotation, the deletion-only case,
and that annotations without originalCode are unchanged.

* fix(review): never silently discard a dirty edit session on file-set change

fileSetKey changes on sort-order and collapse-default flips as well as
diff switches and refreshes, and the remount tears the editor down
without a completion callback. A dirty session was dropped silently
there, destroying in-progress work, while the file-switch path prompted.

The session now keeps the FileContents delivered with each change event
(its contents getter is lazy and stays readable after editor cleanup),
and handleFileSetChange recovers the last-known document text, derives
the suggestions, and prompts to keep them, matching the dirty
file-switch pattern. The call site is a post-commit effect, so the
synchronous confirm is safe. Declining discards; a clean session or a
no-op edit still drops without a prompt.

New DOM-gated test (registered in test.yml's DOM step) covers the
confirm, decline, clean, and no-op paths through the real hook, plus
non-DOM units for the pure recovery helper.

* fix(review): give editor markers real range width so they render

Single-line annotation markers were projected as collapsed ranges (start
and end both at character 0 of the same line); upstream's overlay
renderer skips zero-width blocks, so markers never painted. Marker
ranges now end at character 0 of the line after the last annotated line
(exclusive-end LSP form), covering every annotated line in full; the
editor's TextDocument clamps a past-the-end position back to
end-of-document. Verified via CDP against a live review server: the
severity squiggle renders across the annotated line during an active
edit session and the hover popover shows the annotation source and
message.

Also corrects overstating comments: the flag-off claim that the editor
module is never imported (the single-file build inlines dynamic imports,
so the namespace exists at page load but stays inert) and the
createEditor decline-and-retry framing (the React wrapper throws on an
undefined return; the controller awaits the chunk before any item
enters edit mode, and onAttach delivers once per editor rather than
re-firing across virtualization re-attaches).

* polish: drop suggestion card left accent, move Edit to far right of file header

Suggestion cards keep the colored SUGGESTION header as their sole identity
marker; the green left border on the card body is removed for all suggestion
cards (inline and sidebar, including modal-authored ones).

The Edit entry button moves to the far right of the file header action row,
after the Sem badge and adjacent to the file actions dropdown, so the
experimental affordance stays out of the everyday Viewed/Add/Comment cluster.
Responsive isVeryTight behavior is unchanged (icon-only at narrow widths).

* prototype: edit-session HUD strip below the file header

While an edit session is active, a slim strip renders directly below the
file header (inside the file card, above the content) carrying the session
controls and state: Suggest, Discard, a debounced net change count
(for example 3 changes), and the experimental label. The header no longer
renders in-session controls; the Edit entry button still lives there when
no session is active.

The HUD rides Pierre's memoized custom-header slot portal, so the change
count is delivered through a small external store (useSyncExternalStore)
that useEditSession updates on a 250ms debounce per change event; slot
height re-measures via the version-bumped updateItem that session start
and end already perform.

Prototype for design review; revert this commit to drop the HUD.

* fix(review): pierce shadow DOM in file-nav keyboard guards

Window-level keydown handlers in SectionsPanel and FileTree guarded only
against e.target being an input or textarea. Events from inside the Pierre
editor's shadow DOM retarget to the host element, so Home/End/arrow keys
typed during an edit session fell through to file navigation and switched
the visible file mid-edit. Use composedPath()[0] and isContentEditable,
matching the guard AllFilesCodeView already uses.

* feat(review): Make annotation selection action in edit sessions

Selecting text in a Pierre edit session now shows the editor's Selection
Action popover with a single Make annotation button (plain DOM, inline
styles, colors via inherited theme tokens so light/dark and theme switches
work through the shadow boundary). Clicking it snapshots the selection and
opens the app's existing CommentPopover anchored at the button rect; the
submitted comment becomes a normal line-scoped CodeAnnotation.

Anchoring: the selection lives in the edited buffer, but annotations anchor
to the rendered diff's new side, which is the session's pre-edit content.
mapEditedRangeToPristine (edit/selectionAnchor.ts) maps the selected line
range through diffLines(preEdit, edited) at click time. Unedited regions map
exactly; ranges overlapping session edits anchor to the pristine lines those
edits replace and are flagged approximate; pure insertions anchor to the
adjacent pristine line. Pre-edit coordinates are session-invariant, so the
anchor stays correct whether the session completes or is discarded.

The captured selection text is stored on the annotation (selectedText, plus
selectedTextFromEdits when approximate) and exported in feedback as a
Highlighted text block with an honesty note for approximate anchors. New
comments are re-projected into editor markers mid-session, and the editor
selection collapses after submit so the popover does not reopen over the
annotated lines.

Entry UX note: the comment entry deliberately lives outside the editor's
popover. Focusing an input inside it would blur the editor, collapse the
selection, and tear the popover down mid-typing, so the popover only
snapshots and hands off to the app-side CommentPopover.

Tests: anchor-mapping unit tests (exact mapping through shifts, edited and
inserted regions, deletions inside a selection, clamping, CRLF), export
coverage for the Highlighted text block, and a DOM-gated popover builder
test registered in the CI DOM step. Adapter wall unchanged and passing.

* refactor(review): rename Display settings tab to Editor and move edit toggle to top

* refactor(review): do not project annotations as editor markers

Wavy underlines read as errors, which misrepresents comments. Annotations
render in their normal slots below the code instead.

---------

Co-authored-by: Michael Ramos <ramos@plannotator.ai>
2026-08-04 11:03:30 -07:00
Michael Ramos 47157e7a55 feat(editor): add Vim keyboard annotation controls and live HUD (#1127)
* feat(editor): add Vim keyboard annotation controls

* feat(ui): add optional live Vim HUD

* feat(ui): finish Vim HUD experience

* feat(ui): promote Vim to dedicated settings panel

* feat(ui): make Vim document focus automatic

* feat(ui): let Vim HUD hide its key panel

* fix(ui): harden Vim selection UX
2026-07-26 22:08:35 -07:00
Brad Beebe ac3a84ba5a fix(opencode): Fix hardcoding default build OpenCode agent when sending responses (#1131)
* fix(opencode): default agent switching to disabled

* fix(opencode): keep plan-approval build handoff; default no-switch for review feedback only

The agent switch cookie is shared by plan approval and code review, so
flipping the stored default to `disabled` also removed OpenCode's
plan-approval hand-off for every user who never configured the setting.

Make the unset default surface-aware instead: `getAgentSwitchSettings('plan')`
keeps the historical build hand-off, `getAgentSwitchSettings('review')`
stays on the current agent. An explicit user choice still applies to both
surfaces. Settings and the agent warning resolve the default from the mode
they render in, and the OpenCode "agent not available" warning now names
plan approval on the plan path.

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

---------

Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-07-26 22:08:04 -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
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 c23df4db43 UI 2.0 visual refresh + HTML-render annotate (strictly UI, off main) (#863)
Extracts the UI 2.0 visual refresh and the HTML-render annotate feature onto main, standalone (no daemon). Faithful copy of feat's UI/HTML logic with the standalone transport kept.
2026-06-08 17:03:41 -07:00
Nikolai Perlt 13a6adde18 Add full-file diff context setting (#858)
Co-authored-by: Nikolai Perlt <perlt@Nikolais-MacBook-Pro.local>
2026-06-05 08:59:59 -07:00
J-token 2a8eb9d647 Fix flickering AI-tip input in label settings (#830)
The "Add AI instruction tip" editor in Settings > Labels flickered
intensely on open. Its `tip-slide-open` keyframe animated `max-height`
(a layout property) to 60px -- larger than the input's natural height --
with no animation-fill-mode. Inside the OverlayScrollbars viewport and a
flex `min-h-0` container this formed a layout feedback loop that
restarted the animation ~40x/second, jittering the editor height.

Animate `opacity` and `transform` instead, so no layout property is
animated. Measured on the live demo, animation restarts and height
reversals dropped from ~17/10 to 0, with height stable at its natural
value.

Fixes #829

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 09:58:00 -07:00
Hrand Liu e0aee7451b feat: add PLANNOTATOR_DATA_DIR env var to customize data directory (#795)
* feat: add PLANNOTATOR_DATA_DIR env var to customize data directory

* fix: update missed hardcoded paths to use PLANNOTATOR_DATA_DIR

OpenCode plugin and VS Code extension still used hardcoded
~/.plannotator paths, causing the IPC registry and plan backing
file to diverge from the server when PLANNOTATOR_DATA_DIR is set.

Also exports data-dir from @plannotator/shared and documents the
new env var in AGENTS.md.

Co-authored-by: Chris Werner Rau <14326070+cwrau@users.noreply.github.com>
Co-authored-by: João O. Santos <34689526+Joao-O-Santos@users.noreply.github.com>

* fix: vendor data-dir.ts into Pi extension and rewrite imports

The Pi extension copies shared/server modules into generated/ at
build time. Without vendoring data-dir.ts and rewriting the
parent-relative imports, typecheck fails on all generated files
that import getPlannotatorDataDir.

* refactor: eliminate duplicated data-dir logic and clean up call sites

- VS Code extension: replace inlined getPlannotatorDataDir() copy with
  import from the canonical packages/shared/data-dir.ts (esbuild bundles
  it, so no runtime dependency needed)
- storage.ts: hoist repeated getPlannotatorDataDir() calls to a
  module-level DATA_DIR constant, matching the pattern config.ts uses
- data-dir.ts: remove inaccurate docstring claim about relative path
  resolution (the code does not call resolve())
- improvement-hooks.ts: hoist to DATA_DIR constant, clarify comments
  on the two-level hook lookup (hooks/ subdir vs root fallback)

* fix: resolve relative PLANNOTATOR_DATA_DIR to absolute path

A relative value like ./data would break readArchivedPlan's path
traversal guard, which compares a resolve()'d absolute path against
the still-relative planDir prefix. Always return an absolute path
so all callers get consistent path shapes.

* fix: use @plannotator/shared/data-dir imports in server package

Switch from relative ../shared/data-dir imports to the package
export, matching the convention every other server file follows.
Update Pi vendor script sed rules to match the new import style.

* fix: use package imports in server and respect data dir in compound skill

Server modules: switch from relative ../shared/data-dir imports to
@plannotator/shared/data-dir, matching the convention every other
server file follows. Update Pi vendor script sed rules to match.

Compound skill: update hardcoded ~/.plannotator paths to check
PLANNOTATOR_DATA_DIR first, so the skill reads plans and writes
the improvement hook to the correct location when users set a
custom data directory.

Co-authored-by: Chris Werner Rau <14326070+cwrau@users.noreply.github.com>
Co-authored-by: João O. Santos <34689526+Joao-O-Santos@users.noreply.github.com>

* fix: remove remaining hardcoded ~/.plannotator assumptions

- Settings UI: replace hardcoded path in label and placeholder with
  generic text that doesn't assume a specific data directory
- quickLabels: update agent tip to reference PLANNOTATOR_DATA_DIR
  so the agent checks the correct plans directory
- codex-review: hoist getPlannotatorDataDir() to module-level DATA_DIR
  constant, eliminating redundant per-call resolution in debugLog()
- Tests: make submit-plan and storage tests resilient to
  PLANNOTATOR_DATA_DIR being set in the environment
- Install scripts (sh, ps1, cmd): check PLANNOTATOR_DATA_DIR before
  falling back to ~/.plannotator for config.json attestation lookup

* fix: expand tilde in install script and update test assertions

install.sh: PLANNOTATOR_DATA_DIR set to ~/... stays literal inside
double quotes, so the config file check silently failed. Add case
statement to expand ~ the same way the runtime data-dir.ts does.

install.test.ts: update three assertions that checked for hardcoded
~/.plannotator paths — now verify PLANNOTATOR_DATA_DIR awareness
instead.

* docs: add PLANNOTATOR_DATA_DIR to env var reference with VS Code note

Document the new env var on the marketing site's environment
variables reference page. Include a footnote about ensuring
VS Code inherits the variable when launched from the Dock.

---------

Co-authored-by: Michael Ramos <mdramos8@gmail.com>
Co-authored-by: Chris Werner Rau <14326070+cwrau@users.noreply.github.com>
Co-authored-by: João O. Santos <34689526+Joao-O-Santos@users.noreply.github.com>
2026-05-26 14:56:07 -07:00
Michael Ramos 2a5528693f Add Ask AI to plan and annotate reviews (#763)
* Add Ask AI to plan and annotate reviews

- mount shared AI runtime on plan, annotate, review, and Pi servers

- add shared document chat UI and comment-popover Ask AI entry point

- default providers from detected agent origin and document first-run announcement

- reuse shared code-review AI hook and document setup/docs

* Address Ask AI review followups

Stabilize AI chat session resets and callbacks, preserve per-origin provider defaults, share AI chat formatting utilities, and avoid blocking startup on model discovery.

* Fix AI permission details and Pi CLI lookup

Show formatted tool input in document AI permission cards and make Pi AI provider CLI detection work on Windows.

* Fix Pi model discovery in AI capabilities

Keep AI runtime startup non-blocking while making the capabilities endpoint await pending Pi/OpenCode model discovery before returning provider metadata.
2026-05-21 01:32:17 -07:00
Andrey Skripalschikov 50191e6c1d Add diff line background intensity option (#691) 2026-05-11 10:18:27 -04:00
Michael Ramos 579127e2c9 feat(ui): add Hooks visibility tab to Settings dialog (#695)
Add Hooks tab to plan review Settings dialog for visibility into the
improve-context system. PFM Reminder toggle card with config persistence,
Improvement Hook status card with collapsible content preview. New
GET /api/hooks/status endpoint and pfmReminder in POST /api/config
on both Bun and Pi servers. Dev mock API wired up.
2026-05-11 08:26:58 -04:00
Michael Ramos 49c55e6e25 Revert "Expose bypass clear reminder permission mode (#668)"
This reverts commit 3b88415aeb.
2026-05-06 16:10:31 -07:00
Nehr 3b88415aeb Expose bypass clear reminder permission mode (#668)
* Preserve truthful approval semantics for Claude plan bypass

Thread a clear-context reminder flag through approval decisions, expose a Claude Code-only approval entry that requests bypass mode, and keep the hook response honest by emitting a reminder instead of claiming context was cleared.

Constraint: Claude Code PermissionRequest hooks have no documented clearContext response field and bypassPermissions is only a request when the mode is available.

Rejected: adding a permission-mode enum or undocumented clearContext field | those would misrepresent hook capabilities and broaden the contract.

Confidence: high

Scope-risk: moderate

Directive: Do not claim Plannotator clears context until Claude Code documents a hook field for that behavior; keep reminder copy truthful.

Tested: bun test packages/server apps/hook; bun test packages/editor/wideMode.test.ts packages/ui/hooks/useAgentSettings.test.ts; bun test; bun run build:review; bun run build:hook; bun run typecheck; git diff --check --cached.

Not-tested: Browser warning-dialog replay and interactive Claude Code smoke test.

Co-authored-by: OmX <omx@oh-my-codex.dev>

* Keep approval payloads truthful across Claude Code and Pi

Constraint: Claude Code hooks cannot clear context directly; the new action remains a truthful reminder plus bypass-mode approval.
Rejected: Sending OpenCode agent-switch state for Claude Code | it leaked build (?) UI state and misleading payload fields.
Confidence: high
Scope-risk: narrow
Directive: Keep OpenCode agent switching gated to opencode-origin approval payloads.
Tested: bun run typecheck; bun test; bun run build:review; bun run build:hook
Not-tested: Manual browser click-through of the Claude Code dropdown.

* Expose the clear-context reminder permission default

Constraint: Claude Code hooks can only emit a systemMessage nudge, not clear context directly.

Rejected: Server protocol changes | existing permissionMode plus clearContextNudge wire fields already support the behavior.

Confidence: high

Scope-risk: narrow

Directive: Keep bypassPermissionsClearReminder as a UI/storage-only synthetic mode that decomposes before /api/approve.

Tested: bun test packages/editor/approvalBody.test.ts; bun run --cwd apps/review build && bun run build:hook; bun run --cwd packages/ui typecheck; git diff --check

Not-tested: Root bun run typecheck could not run because tsc was not on PATH for the root script.

* Reject invalid persisted permission modes

Constraint: Stored browser values can be stale, corrupt, or from a future Plannotator build.

Rejected: Trusting the storage read with a type assertion | invalid values could flow into UI state and approval request construction.

Confidence: high

Scope-risk: narrow

Directive: Keep PermissionMode storage reads validated against PERMISSION_MODE_OPTIONS when adding or renaming modes.

Tested: bun test packages/editor/approvalBody.test.ts; bun run --cwd packages/ui typecheck; git diff --check

Not-tested: Full repo typecheck/build, outside this review-fix scope.

* Ensure approvals use the live permission setting

Settings persisted permission mode changes, but App kept the original mode in React state and used that stale value when building approval payloads. Push the Settings change back into App so the selected clear-reminder mode reaches the hook decision.

Constraint: Permission mode storage is cookie-backed while approval payload construction reads App state.\nRejected: Read permission cookies during approval | would couple approval construction to browser storage and duplicate Settings state.\nConfidence: high\nScope-risk: narrow\nDirective: Keep permission-mode writes synchronized with approval state when adding or changing modes.\nTested: bun test packages/editor/approvalBody.test.ts packages/ui/components/ApproveDropdown.test.tsx; bun x tsc --noEmit -p packages/ui/tsconfig.json; bun run build:hook; direct Playwright hook smoke verified bypassPermissionsClearReminder UI and hook payload.\nNot-tested: Live interactive Claude CLI session; direct hook/server simulation covered the wire output.

---------

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-05 12:27:16 -07:00
Michael Ramos 308e272b10 feat(ui): 49 themes with matched syntax highlighting + preview mode (#664)
* feat(ui): expand theme system to 49 themes with matched syntax highlighting

Add 25 new themes sourced from shiki's bundled TextMate themes (Everforest,
Nord, Solarized, GitHub, One Dark Pro, Night Owl, Material, Vitesse, Vesper,
Poimandres, Ayu Dark, Houston, Laserwave, Andromeeda, Aurora X, Dark+/Light+,
Min, One Light, Plastic, Red, Slack, Snazzy Light, Vitesse Black). Each theme
was audited against the shiki source JSON to ensure correct color mapping.

Wire pierre/diffs syntax highlighting to match the active UI theme via a
SHIKI_THEME_MAP (35 of 49 themes get matched syntax token colors in diffs).

Add theme preview mode — a compact bottom-docked picker that lets users
browse themes while seeing the page update live behind it.

Settings panel enhancements:
- Syntax highlighting badge on themes with diff color matching
- Mode unavailability dimming (dark-only themes dim in light mode)
- Plannotator pinned first, rest alphabetical

* fix(themes): address review feedback — contrast, stale CSS, syntax mode safety

1. Material light: foreground #90A4AE → #546E7A (Blue Grey 600) for readable
   UI text (~7:1 contrast on white). Primary-foreground #FAFAFA → #263238
   for contrast on teal buttons.

2. DiffHunkPreview: add colorTheme to useEffect deps so unsafeCSS (bg/gutter
   injection) recomputes when themes change, preventing stale mixed-theme
   state in mounted tooltips during preview mode.

3. SHIKI_THEME_MAP: make mode-aware with null entries for modes lacking a
   shiki counterpart. resolveSyntaxTheme() returns undefined when the current
   mode has no match, so pierre falls back to its defaults rather than
   applying dark token colors on a light background (fixes Tokyo Night light).

* fix(review): self-review cleanup — memo, double-call, escape key

- DiffHunkPreview: extract resolveSyntaxTheme to a variable instead of
  calling it twice in the spread expression
- PierreDiffContent memo: compare syntaxTheme by dark/light string values
  instead of reference equality (resolver returns new objects each call)
- Preview mode: add Escape key handler to return to full settings

* fix(ui): consume Escape event in theme preview to prevent leaking to page
2026-05-04 23:25:30 -07:00
Michael Ramos b4b1d432c8 feat(review): diff display options — hide whitespace + quick-settings popover (#631)
* feat(review): add hide whitespace setting for diffs

Adds a "Hide Whitespace" toggle that suppresses whitespace-only changes
in diffs, matching GitHub's ?w=1 behavior. Uses parseDiffFromFile with
ignoreWhitespace when full file contents are available. Includes demo
data with a whitespace-heavy file (settings.ts) and a vite dev server
plugin to serve file contents in demo mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(review): add quick-settings popover on file header

Adds a gear icon to the file header that opens a compact Radix popover
with all diff display options (style, overflow, indicators, inline diff,
line numbers, background, hide whitespace). Provides quick access to
settings without opening the full settings dialog. Exports option
constants from Settings.tsx for reuse.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 23:51:17 -07:00
Michael Ramos a68463b6a4 feat(review): add "All files" diff type (#629)
* feat(review): add "All files" diff type for reviewing entire codebases

Adds an "all" diff type that diffs the empty tree against HEAD, showing
every tracked file as an addition. Solves the case where a repo has only
committed changes and no working tree diffs — previously there was no way
to launch a review at all.

For provenance purposes, this commit was AI assisted.

* fix(review): add "All files" to Settings panel diff type options

The Settings panel (gear icon) had its own DEFAULT_DIFF_TYPE_OPTIONS list
that was missing the new "all" diff type, so users couldn't select or see
it as the default from the settings UI.

For provenance purposes, this commit was AI assisted.

* fix(review): rename "All files" to "All files (HEAD)" for clarity

Makes it clear this mode shows committed state at HEAD, not the working
tree. Updated tooltip to distinguish from "Committed changes" (which
diffs against a base branch).

For provenance purposes, this commit was AI assisted.
2026-04-29 16:43:06 -07:00
Michael Ramos 7ab2d8f868 fix: remove ghost dot on Options menu (new-settings-hint system)
The version-gated hint dot was permanently visible for users who never
matched the cookie version, with no way to dismiss it. Removes the
entire system: utility, state, prop plumbing, and pulsing dot JSX.

For provenance purposes, this commit was AI assisted.
2026-04-27 22:47:02 -07:00
Michael Ramos bb404f8d14 feat: stacked PR review — PR switching, scope toggling, multi-PR posting (#620)
* feat(shared): add isSameProject, PR stack types, and PR list provider

Extends PRRef/PRMetadata with defaultBranch, PRStackInfo, PRStackTree,
PRStackNode, PRDiffScope, and PRListItem types. Adds isSameProject()
for owner/repo validation on PR switching. Adds fetchPRStack() and
fetchPRList() dispatch functions (GitHub-only for now, GitLab stubs).

Includes 9 new tests for isSameProject covering GitHub, GitLab, and
cross-platform scenarios.

For provenance purposes, this commit was AI assisted.

* feat(shared): add GitHub PR stack tree walking and PR list fetching

Implements fetchGhPRStack() which walks up/down the PR stack via
GraphQL, resolving numbers and titles for each node in the chain.
Collapses queryPRsByHead/queryPRsByBase into a single queryPRsByRef
helper. Adds fetchGhPRList() using gh pr list. Fixes GHE support
by removing hostnameArgs from fetchGhPRList (--repo already handles
GHE). Filters jq "null" string from defaultBranch detection.

For provenance purposes, this commit was AI assisted.

* feat(shared): fetch defaultBranch for GitLab MRs

Queries the project's default_branch via glab API so getPRStackInfo
can detect stacked MRs on GitLab. Best-effort — caught errors fall
back to undefined.

For provenance purposes, this commit was AI assisted.

* feat(shared): add PR stack detection and full-stack diff module

New pr-stack module with:
- getPRStackInfo(): detects stacked PRs from baseBranch vs defaultBranch
- getPRDiffScopeOptions(): generates layer/full-stack scope options
- runPRFullStackDiff(): computes diff from default branch to HEAD
- resolvePRFullStackBaseRef(): resolves origin/main or local main
- checkoutPRHead(): fetches and checks out a PR head in a worktree
- buildMinimalStackTree(): builds UI tree from stack info

Includes 13 tests covering ref resolution, branch fallbacks, and
GitLab ref formats.

For provenance purposes, this commit was AI assisted.

* feat(shared): add worktree pool for per-PR agent isolation

Creates a session-scoped pool of git worktrees — each PR visited
during a stacked review gets its own isolated checkout. Agents run
in their PR's worktree undisturbed by PR switches. Handles
deduplication of concurrent ensure() calls for the same PR.

Includes 11 tests covering caching, cross-repo restrictions, GitLab
ref formats, and cleanup.

For provenance purposes, this commit was AI assisted.

* feat(shared): add diffScope/prUrl to agent jobs, branch diff type

Adds prUrl and diffScope optional fields to AgentJobInfo so agent
findings carry the PR and scope context they were launched under.
Exports new pr-stack and worktree-pool modules from package.json.
Adds 'branch' to DefaultDiffType union for branch diff as default.

For provenance purposes, this commit was AI assisted.

* feat(ui): add PR annotation fields, Popover, and SearchableSelect

Extends CodeAnnotation with prUrl, prNumber, prTitle, prRepo, and
diffScope fields for stacked PR attribution. Adds shared Popover
wrapper around radix-ui. Adds SearchableSelect for filterable
dropdown lists (used by PR selector).

For provenance purposes, this commit was AI assisted.

* feat(ui): add branch diff as default option, new Git settings tab

Adds 'Branch' as a fourth default diff type option in both the
first-run dialog and settings panel. Moves the default diff type
setting from the Display tab to a new Git tab in review mode.
Updates config store validators to accept 'branch'.

For provenance purposes, this commit was AI assisted.

* feat(server): add prUrl/diffScope plumbing to agent jobs and prompts

Threads prUrl and diffScope through the agent job lifecycle so
findings carry the PR and scope they were generated under. Adds
full-stack prompt branch to codex-review and tour-review — when
in full-stack mode, the diff is inlined in the prompt instead of
telling the agent to run git diff. Re-exports isSameProject and
new PR provider functions from server/pr.ts.

For provenance purposes, this commit was AI assisted.

* feat(server): add stacked PR support to Bun review server

Adds PR switching, layer/full-stack scope toggling, PR list caching,
worktree pool integration, and multi-PR platform posting to the Bun
review server. Key additions:

- /api/pr-diff-scope: switch between layer and full-stack diffs
- /api/pr-list: cached PR list for the current repo
- /api/pr-switch: in-place navigation between PRs in a stack
- /api/pr-action: targetPrUrl support for multi-PR posting
- /api/file-content: full-stack branch for hunk expansion
- prSwitchCache/prStackTreeCache for session-scoped caching
- diffScope tagging on agent job completion
- Scope guard: returns 400 on full-stack diff failure instead of
  overwriting the working diff with empty content

For provenance purposes, this commit was AI assisted.

* feat(ai): pass cwd to Claude agent SDK for worktree support

Forwards the working directory to the Claude agent provider so
agents run in the correct worktree when reviewing stacked PRs.

For provenance purposes, this commit was AI assisted.

* feat(pi): add stacked PR support to Pi server (Bun parity)

Mirrors all stacked PR features from the Bun server:
- PR switching, scope toggling, PR list, multi-PR posting
- prSwitchCache/prStackTreeCache with initial PR seeding
- diffScope/prUrl plumbing in agent jobs
- Worktree pool creation and lifecycle
- Full-stack file-content resolution matching Bun's guard structure
- targetPrUrl support on /api/pr-action

Hoists worktreePool declaration to outer scope in plannotator-browser
to fix TS18004 scoping error. Updates vendor.sh for new shared modules.

For provenance purposes, this commit was AI assisted.

* feat(hook): create worktree pool for PR review sessions

Creates a worktree pool when opening a PR review with --local,
seeding it with the initial PR's checkout. Integrates pool cleanup
into server shutdown. Passes the pool to startReviewServer for
agent isolation during PR switching.

For provenance purposes, this commit was AI assisted.

* feat(review-editor): add hooks for PR stack, context, and annotations

- useAnnotationFactory: stamps prUrl/prNumber/prTitle/prRepo/diffScope
  onto annotations, only when viewing a stacked PR
- usePRStack: handles scope selection and PR switching with loading state
- usePRContext: adds URL-change detection to prevent stale-fetch race
  when switching PRs (discards in-flight responses for previous PR)

For provenance purposes, this commit was AI assisted.

* feat(review-editor): add stacked PR UI components

- PRSelector: searchable dropdown for switching between PRs in a repo
- PRSwitchOverlay: loading animation during PR switch
- StackedPRLabel: stack tree popover with scope selector and PR navigation
- ReviewSubmissionDialog: multi-PR submission dialog with per-target
  status, orphaned findings section with copy-as-markdown, and
  partial failure retry

For provenance purposes, this commit was AI assisted.

* feat(review-editor): multi-PR export with heading hierarchy

Updates exportReviewFeedback for multi-PR sessions:
- Groups annotations by prUrl, then by file within each PR
- Uses proper heading hierarchy (## for files, ### for annotations
  in multi-PR mode)
- Detects single-PR mismatch (annotations from a different PR than
  the current view) and uses annotation-level PR context
- Adds diffScope labels per PR group when present

Includes 5 new tests: multi-PR headings, single-PR mismatch,
diffScope labels, and non-stacked annotation handling.

For provenance purposes, this commit was AI assisted.

* feat(review-editor): integrate stacked PR into sidebar, diff panel, and agents

- ReviewSidebar: groups annotations by PR in multi-PR sessions,
  shows PR headers with annotation counts
- ReviewDiffPanel: filters annotations by prUrl and diffScope so
  only matching annotations appear in the diff gutter
- ReviewStateContext: adds prDiffScope to shared review state
- ReviewAgentJobDetailPanel: shows diffScope in job detail
- PRSummaryTab: shows stack info in PR summary
- index.css: PR switch shimmer and overlay animations

For provenance purposes, this commit was AI assisted.

* feat(review-editor): wire stacked PR into main review app

Integrates all stacked PR features into the review editor:
- PR stack state management (prStackInfo, prStackTree, prDiffScope)
- applyPRResponse: shared handler for PR switch and scope toggle,
  preserves active file index on scope changes
- Multi-PR platform posting via Promise.allSettled with parallel
  requests, partial failure retry, and per-target status tracking
- ReviewSubmissionDialog replaces inline dialog JSX
- useAnnotationFactory stamps PR context onto annotations
- keepalive on /api/feedback to survive tab closure
- Proper try/catch/finally on handlePlatformAction

For provenance purposes, this commit was AI assisted.

* docs: add stacked PR review documentation

Updates AGENTS.md, code-review command docs, and AI code review
guide with stacked PR review capabilities.

For provenance purposes, this commit was AI assisted.

* fix(server): stamp prNumber/prTitle/prRepo on agent findings

Agent annotations only had prUrl and diffScope, missing prNumber,
prTitle, and prRepo. When agent findings were the only annotations
for a PR target in the submission dialog, the target rendered as
#0 with no title. Now resolves full PR context from prSwitchCache
at job completion and stamps all five fields. Both Bun and Pi.

For provenance purposes, this commit was AI assisted.

* feat(ui): rename diff options — "Committed" replaces "Branch" / "Current PR Diff"

Consolidates two confusing committed-diff options into one:
- Settings/first-run: "Committed" — "Everything you've committed on this branch"
- Mid-session switcher: "Committed changes" (replaces both "vs main" and "Current PR Diff")

Uses merge-base under the hood (matches GitHub PR behavior). Removes
the two-dot branch diff from the UI — it stays in the runtime DiffType
union for backwards compat. Old "branch" values in config/cookies are
silently upgraded to merge-base.

Git settings tab now uses radio cards with descriptions instead of
a cramped segmented control. First-run dialog descriptions rewritten
in plain language — no git commands.

For provenance purposes, this commit was AI assisted.

* fix(review-editor): rename client-side "PR Diff" labels to "Committed changes"

DiffTypePicker.tsx had a hardcoded "PR Diff" override for merge-base
when the base picker is present. exportFeedback.ts also used "PR Diff"
in export labels. Both now say "Committed changes" to match the
server-side label and settings UI.

For provenance purposes, this commit was AI assisted.

* fix(server): discover stack UI for root PRs targeting the default branch

Root PRs (baseBranch === defaultBranch) were excluded from stack
detection because getPRStackInfo returned null. Now the server
always fetches the stack tree in PR mode. If the tree reveals
descendant PRs, prStackInfo is retroactively set with source
"tree-discovered", enabling the stack UI, scope selector, and
PR navigation from the root of a stack.

Both Bun and Pi servers updated. Adds "tree-discovered" to the
PRStackInfo source union.

For provenance purposes, this commit was AI assisted.

* fix(review-editor): derive diff scope from annotations, not UI state

The export function now reads diffScope from annotations instead of
the prReviewScope parameter. Fixes two issues:

1. Agent job "Copy All" showed the wrong scope when the user switched
   between layer/full-stack after launching the agent
2. Mixed-scope annotations produced a confusing "layer, full-stack"
   comma-joined label instead of grouping by scope

Extracts renderScopedGroups helper for scope-aware grouping — used
by both single-PR and multi-PR export paths. When annotations share
one scope, it appears in the header. When mixed, annotations are
grouped under ## Layer / ## Full-stack headings.

Includes 4 new tests: uniform scope derivation, mixed scope grouping,
single scope header, and prReviewScope override prevention.

For provenance purposes, this commit was AI assisted.

* fix(server): add tree-discovered stack fallback to pr-switch handler

The initial-load path upgrades prStackInfo for root PRs when the
stack tree reveals descendants, but the pr-switch handler was missing
this logic. The server now sends correct prStackInfo after switching
to a root-of-stack PR. Both Bun and Pi.

Also removes stale prReviewScope dependency from agent job panel's
copyAllText useMemo.

For provenance purposes, this commit was AI assisted.

* fix: extract resolveStackInfo helper, fix stack UI on non-stacked PRs

Extracts the tree-discovered stack fallback into resolveStackInfo()
in pr-stack.ts — eliminates 4 copies of the same logic across Bun
startup, Bun pr-switch, Pi startup, and Pi pr-switch.

Fixes StackedPRLabel showing on every PR: the check now counts
non-default-branch nodes (> 1) instead of all nodes (> 1). Without
this, every PR showed a "Stack (1 PR)" popover because the tree
always has at least [defaultBranch, currentPR].

For provenance purposes, this commit was AI assisted.

* fix(review-editor): don't re-open already-succeeded PR tabs on retry

On partial failure retry, openUrls was pre-seeded with URLs from
previously succeeded targets, causing those PR pages to re-open
in the browser alongside newly succeeded ones. Now starts empty —
only URLs from the current attempt are opened.

For provenance purposes, this commit was AI assisted.

* refactor(review-editor): extract PR session state into usePRSession hook

Consolidates 5 independent useState calls (prMetadata, prStackInfo,
prStackTree, prDiffScope, prDiffScopeOptions) into a single
usePRSession hook with atomic updatePRSession callback.

Replaces two identical 5-line setter blocks (initial load and
applyPRResponse) with single updatePRSession calls. All ~60 consumer
sites unchanged — same variable names via destructuring.

For provenance purposes, this commit was AI assisted.
2026-04-27 22:07:55 -07:00
Michael Ramos 0287b96b63 feat(review): configurable default diff type (#531)
feat(review): configurable default diff type with first-run setup

Add `defaultDiffType` as a persistent user setting (cookie + config.json) with 'unstaged' as the default, matching `git diff` semantics. A first-run setup dialog prompts users to choose their preferred default on first review session. The setting is also available in Settings > Display.

Entry points (hook, opencode, pi-extension) read the config via `resolveDefaultDiffType()` instead of hardcoding. The setup dialog applies the chosen diff type to the active review immediately. P4 users are excluded from the Git-specific dialog.

Based on the community contribution in #521 by Hendrik Richert.

Co-authored-by: Hendrik Richert <hendrik.richert@swisscom.com>
2026-04-10 14:54:21 -07:00
Michael Ramos 7e8f914904 feat(plan): unified header dropdown + Agent Instructions (#515)
* feat(plan): unify header settings into combined dropdown

Replace the desktop sprawl (ModeToggle, Settings button, Export
sub-dropdown) and the separate MobileMenu with a single PlanHeaderMenu
modeled on ReviewHeaderMenu — one ActionMenu rendered at all
breakpoints. Move version + release notes into the dropdown footer,
drop the origin/agent badge, and stub an "Agent Instructions" item
that copies the current plan plus an /api/external-annotations URL
for external agents (real protocol body lands in a follow-up).

Also fix the annotate-folder Send button: it was hidden whenever a
file was opened via linkedDoc, so users in `plannotator annotate ./`
couldn't submit feedback. Gate now allows linked-doc state through in
annotate mode. Settings is always mounted instead of skipping when a
linked doc is active, so the dropdown's Settings item works in every
state.

For provenance purposes, this commit was AI assisted.

* feat(plan): real Agent Instructions clipboard payload

Replace the placeholder string in handleCopyAgentInstructions with a
real, contract-accurate markdown payload that teaches an external agent
(Claude Code, Codex, custom scripts) how to:

- read the current plan via GET /api/plan
- POST single or batch annotations to /api/external-annotations
- choose COMMENT vs DELETION vs GLOBAL_COMMENT and use originalText
  for inline highlighting (or skip it for sidebar-only)
- list, delete-by-id, and delete-by-source for cleanup before reposting

The payload lives in packages/ui/utils/planAgentInstructions.ts as
buildPlanAgentInstructions(origin) — a single pure function that takes
the base URL and returns the markdown. Plan and code-review modes have
different annotation shapes, so each owns its own instructions module;
the review counterpart will live alongside this file when it's added.

For provenance purposes, this commit was AI assisted.

* docs: list planAgentInstructions.ts in AGENTS.md utils enumeration

For provenance purposes, this commit was AI assisted.

* fix(plan): restore 'system' theme option in header dropdown

The unified header dropdown only exposed light/dark, dropping the
'system' (follow OS) option that the previous ModeToggle and MobileMenu
both supported. Clicking either explicit mode would silently overwrite
a prior 'system' setting. Add it back as a third segmented control,
matching the old MobileMenu's three-way layout.

For provenance purposes, this commit was AI assisted.

* refactor(ui): extract shared theme-mode icons

PlanHeaderMenu and ThemeTab each inlined their own Sun/Moon/System
SVGs (and the deleted MobileMenu had its own copy too). Pull them into
a shared icons/themeIcons.tsx module so the iconography stays consistent
across the dropdown and the settings tab. Use the cleaner Heroicons
paths from ThemeTab as the canonical art.

ReviewHeaderMenu still inlines its own copies — left alone for this PR
since the broader review/plan menu unification is a separate follow-up.

For provenance purposes, this commit was AI assisted.

* fix(plan): use ReviewAgentsIcon for Agent Instructions menu item

Reuse the magnifying-glass-with-cog icon already used for the Review
Agents tab so the same visual marker identifies agent-related affordances
across plan and review modes.

For provenance purposes, this commit was AI assisted.

* fix(plan): drop DELETION + unanchored COMMENT from agent instructions

Plannotator's existing render and export pipeline only handles two
shapes well: an anchored COMMENT with originalText, and a GLOBAL_COMMENT
with no anchor. The previous instructions also documented:

- COMMENT without originalText — sidebar shows an empty quote bubble
  ("") and the export header reads `Feedback on: ""`. The text survives
  but the framing is broken.
- DELETION with explanatory text — the text is invisible in the sidebar
  and silently dropped on export, replaced by a generic template. Real
  information loss.

Rather than expand the export pipeline to honor those edge cases, narrow
the documented surface to the two shapes that work today: inline COMMENT
(requires originalText) and GLOBAL_COMMENT (no anchor). DELETION is
omitted entirely; agents that need to suggest a removal can post an
inline COMMENT pointing at the phrase.

For provenance purposes, this commit was AI assisted.

* fix(plan): clear current new-settings hints, surface pulse on menu trigger

The new-settings indicator infrastructure (hasNewSettings / version
constant / markNewSettingsSeen) stays — it's the scaffolding for future
hint flagging — but every current marker is removed:

- Drop the four inline 'new' badges in Settings.tsx (Plan Width row,
  Quick Labels row, mobile + desktop tab navigators).
- Drop the dead pulse on the standalone gear button — that button now
  lives inside <div className="hidden"> after the header refactor, so
  the pulse was unreachable anyway.

Surface the pulse on the new PlanHeaderMenu trigger instead. App.tsx
seeds local hasNewSettingsHints state from hasNewSettings(), passes it
to the menu, and clears it (plus calls markNewSettingsSeen) when the
user opens Settings from the dropdown.

For provenance purposes, this commit was AI assisted.

* docs: list icons/ subdirectory in AGENTS.md components tree

For provenance purposes, this commit was AI assisted.

* fix(server): require originalText for plan COMMENT annotations

The plan-mode external-annotation contract documents that COMMENT
annotations must carry an originalText anchor; sidebar-only feedback
uses GLOBAL_COMMENT instead. transformPlanInput already enforced this
for DELETION but silently defaulted COMMENT's originalText to "" when
missing — producing a broken render (empty quote bubble in the sidebar,
exported as `Feedback on: ""`).

Reject COMMENT without a non-empty originalText and steer the caller
toward GLOBAL_COMMENT in the error message so the fix is self-evident
to misbehaving agents.

For provenance purposes, this commit was AI assisted.

* chore: drop dead new-hints scaffolding leftovers

After clearing the four current inline hint markers, two pieces of
support code were unreachable:

- The destructured setShowNewHints setter in Settings.tsx — the state
  itself stays as a stable mount-time snapshot for any future hint
  markers, but the setter has no callers. Drop it and add a comment
  explaining why the useState wrapper is intentional scaffolding.
- The settings-ping keyframe in editor/index.css — only the deleted
  gear-button pulse referenced it; the new pulse on PlanHeaderMenu's
  trigger uses Tailwind's built-in animate-ping instead.

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

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

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

Plumbing:

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

Theme:

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

ResizeHandle (#354 regression guard):

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

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

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

For provenance purposes, this commit was AI assisted.

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

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

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

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

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

For provenance purposes, this commit was AI assisted.

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

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

DiffViewer:

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

LiveLogViewer:

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

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

For provenance purposes, this commit was AI assisted.

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

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

P1: OverlayScrollbars viewport was never delivered to consumers.

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

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

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

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

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

Additional fixes:

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

For provenance purposes, this commit was AI assisted.

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

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

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

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

For provenance purposes, this commit was AI assisted.

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

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

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

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

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

For provenance purposes, this commit was AI assisted.

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

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

For provenance purposes, this commit was AI assisted.

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

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

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

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

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

For provenance purposes, this commit was AI assisted.

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

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

For provenance purposes, this commit was AI assisted.
2026-04-07 14:17:32 -07:00
Michael Ramos dd04e6b1cd feat: add Conventional Comments support to code review (#501)
* feat: add Conventional Comments support to code review

Add structured label system based on the Conventional Comments spec
(conventionalcomments.org) to the code review annotation toolbar.

- Label picker with 9 default labels (suggestion, nit, question, issue,
  praise, thought, note, todo, chore) shown above the comment textarea
- Per-label blocking/non-blocking toggle for labels where severity is
  ambiguous (issue, suggestion, todo, chore)
- Labels exported as `**label** (decoration): text` in markdown feedback,
  naturally readable on GitHub and machine-parseable
- Label badges shown on inline annotations and sidebar cards
- Settings tab (Comments) with: master on/off toggle, editable label
  list with add/remove/rename, per-label blocking decorator toggle,
  educational intro with spec link and example output
- Feature is off by default, persisted via config store
- Custom labels supported via settings (up to 12)

For provenance purposes, this commit was AI assisted.

* fix(review): address conventional comments review feedback

Fix label clearing bug where editing an annotation to remove its
conventional label silently preserved the old value. Fix parseCCLabels
accepting 'non-blocking' as a truthy blocking value. Fix duplicate
custom label identities when adding multiple labels. Prepend
conventional prefix to platform PR/MR inline comments.

For provenance purposes, this commit was AI assisted.

* fix(server): persist conventionalComments setting to config.json

All six server config handlers (bun plan/review/annotate + pi
plan/review/annotate) now accept and save the conventionalComments
boolean, so the setting survives server restarts.

For provenance purposes, this commit was AI assisted.

* fix: add conventionalComments to server config round-trip

getServerConfig() was not returning the conventionalComments value
from config.json, so the setting silently reset to false on cookie
clear or new browser sessions. Also adds the field to the
PlannotatorConfig interface so saveConfig() is properly typed.

For provenance purposes, this commit was AI assisted.

* fix: label display, export, and draft persistence for conventional comments

- Remove slug derivation from label editing — store display text
  verbatim so badges and exports show what the user typed
- Emit conventional prefix in export even when annotation has no
  prose text (suggestion-only annotations were losing their label)
- Include conventionalLabel in draft save condition so label
  selection persists across file switches

For provenance purposes, this commit was AI assisted.

* fix: spec-compliant export format and empty label support

- Wrap label + decorations + colon in markdown bold per the
  Conventional Comments spec examples (was bolding only the label)
- Allow users to clear all labels — empty array no longer falls
  back to defaults in either the picker or the settings parser
- Enable blocking decorator on the question label by default; the
  spec permits blocking questions and they sometimes are critical

For provenance purposes, this commit was AI assisted.

* fix: server-sync conventionalLabels and small cleanups

- Sync conventionalLabels to ~/.plannotator/config.json so custom
  label configurations survive cookie clears and remote/SSH
  sessions, matching the persistence story for conventionalComments.
  Stored as a parsed array on disk; serialized as JSON string in
  the local config store to match the cookie format.
- Remove no-op intermediate variable in handleSubmitAnnotation;
  pass conventionalLabel/decorations directly with proper coalescing
  per call path (edit preserves null, add coalesces to undefined).
- Use index-prefixed key in the picker label list to avoid React
  warnings if duplicate labels ever exist.

For provenance purposes, this commit was AI assisted.
2026-04-06 22:27:47 -07:00
Michael Ramos 6b84ce4107 feat(review): custom diff font family and size overrides (#441)
* feat(review): custom font family and size overrides for code review diffs

Add Code Font and Code Font Size settings to the review Display tab,
letting users override the theme's monospace font in the diff viewer.

Supports 9 fonts (Fira Code, Hack, IBM Plex Mono, Inconsolata,
JetBrains Mono, Red Hat Mono, Roboto Mono, Source Code Pro, Atkinson
Hyperlegible Mono) loaded on demand from Google Fonts / jsDelivr CDN.
Font size uses a continuous slider (8–24px) with a Reset button.

Settings persist via cookies and sync to ~/.plannotator/config.json
under diffOptions.fontFamily / diffOptions.fontSize, matching the
existing config pattern.

For provenance purposes, this commit was AI assisted.

* fix(review): line-height scaling, inline annotation font coverage, and comment card cleanup

- Add line-height: 1.5 alongside font-size in Pierre shadow DOM so large sizes don't clip
- Include .review-comment-body selectors in font override CSS so inline annotation code spans get the custom font
- Simplify comment card: remove drop shadow, left border accent, translateX hover; use tight border with subtle hover

For provenance purposes, this commit was AI assisted.
2026-03-30 13:45:37 -07:00
Michael Ramos e0dd5783b2 fix(ui): type cleanse — fix stale types, missing initializer fields, React 19 compat
- CommentPopover: add width to position state type (was returned but not declared)
- Settings: add missing ObsidianSettings fields (filenameSeparator, autoSave, vaultBrowserEnabled)
- Settings: add missing planWidth to UIPreferences initializer
- Viewer: use React.ElementType for dynamic heading tag
- useLinkedDoc: use SidebarTab instead of string for sidebar.open
- useActiveSection: accept RefObject<HTMLElement | null> (React 19)
- useInputMethodSwitch: pass explicit undefined to useRef
- package.json: add exports for config, ImageAnnotator, sidebar/*, plan-diff/*
- types.d.ts: add __APP_VERSION__ global declaration

For provenance purposes, this commit was AI assisted.
2026-03-29 21:43:00 -07:00
Michael Ramos 9b016da4ae feat(review): diff display options with ConfigStore integration (#428)
* feat(config): add DiffOptions to PlannotatorConfig with deep-merge support

Extends the server-side config system to support diff display options
namespaced under `diffOptions` in ~/.plannotator/config.json. The
saveConfig deep-merge ensures partial writes (e.g. changing only
overflow) don't clobber sibling keys.

For provenance purposes, this commit was AI assisted.

* feat(server): accept diffOptions in POST /api/config handlers

All six server handlers (plan, review, annotate x Bun + Pi) now
whitelist diffOptions alongside displayName, forwarding it to
saveConfig for deep-merged disk persistence.

For provenance purposes, this commit was AI assisted.

* feat(config): register diff display settings with deep-merge write batching

Adds 6 diff option settings to the ConfigStore registry (diffStyle,
diffOverflow, diffIndicators, diffLineDiffType, diffShowLineNumbers,
diffShowBackground), all synced to disk under the diffOptions namespace.

Fixes write batching for nested server keys — replaces Object.assign
with deepMerge so rapid changes to multiple diff options within the
300ms debounce window don't clobber each other. Includes legacy cookie
migration for the old review-diff-style key.

For provenance purposes, this commit was AI assisted.

* feat(review): wire diff display options through to @pierre/diffs

Migrates diffStyle from raw cookie storage to ConfigStore. Reads all 6
diff options via useConfigValue and forwards them to FileDiff: overflow,
diffIndicators, lineDiffType, disableLineNumbers, disableBackground.

Adds a Scroll/Wrap toolbar toggle alongside the existing Split/Unified
toggle. Disables the custom resizable split dragger in wrap mode since
Pierre manages its own grid layout. Fixes the unsafeCSS grid override
to only target scroll mode, preventing per-character wrapping in split
view.

For provenance purposes, this commit was AI assisted.

* feat(review): add Display tab to review-mode Settings dialog

Adds a Display tab when Settings is in review mode, exposing all 6 diff
options: Diff Style, Line Overflow, Change Indicators, Inline Diff
Granularity, Show Line Numbers, and Show Diff Background. Changes take
effect immediately and persist via ConfigStore (cookie + disk).

Extracts reusable SegmentedControl and ToggleSwitch helpers for the
review display controls.

For provenance purposes, this commit was AI assisted.

* chore: update bun.lock

For provenance purposes, this commit was AI assisted.

* fix(review): reapply search highlights after display option changes

Add new diff display props to the search highlight and scroll-to-match
effect dependency arrays so highlights are restored after Pierre re-renders.

For provenance purposes, this commit was AI assisted.
2026-03-29 13:20:22 -07:00
Stacey Haffner 2d90d381c9 fix: Detect calling agent via env vars and centralize agent config (#418)
* fix: detect calling agent via env vars for correct badge display

Add prioritized agent detection chain: CODEX_THREAD_ID (codex) >
COPILOT_CLI (copilot-cli) > default (claude-code). All subcommands
use the shared detectedOrigin constant. Display name updated from
'Copilot CLI' to 'GitHub Copilot'. TypeScript origin union type
updated to include 'copilot-cli'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor: centralize agent config into @plannotator/shared/agents

- Create packages/shared/agents.ts as single source of truth for Origin type,
  agent display names (AGENT_CONFIG), getAgentName(), and getAgentBadge()
- Replace all inline origin type unions across 8 files with Origin import
- Type detectedOrigin and ServerOptions.origin as Origin for compile-time safety
- Add @plannotator/shared dependency to @plannotator/editor package.json
- Adding a new agent is now a one-file change to AGENT_CONFIG

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: restore gitContext in startReviewServer call

Accidentally removed alongside the origin refactor. Without it, local
code reviews lose branch detection, diff-type switching, and CWD resolution.

For provenance purposes, this commit was AI assisted.

---------

Co-authored-by: Yecats <Yecats@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-03-28 16:37:56 -07:00
Michael Ramos 401793e35f feat: custom display name + config file foundation (#399)
Adds user-editable display names and persistent config via ~/.plannotator/config.json.

- ConfigStore singleton with precedence: server config file > cookie > default
- Editable identity input in Settings with "Use git name" and regenerate buttons
- POST /api/config endpoint for write-back across all 6 servers
- getServerConfig() reads config fresh per request (no stale cache)
- Eager constructor hydration so the store is safe to read before init()
- vendor.sh as single source of truth for Pi extension vendoring
- Vendor parity test to prevent missing generated modules

Closes #396
2026-03-26 11:54:22 -07:00
Michael Ramos b675e8677c feat: markdown file browser sidebar tab (#378)
* feat: markdown file browser sidebar tab (#367)

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

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

Closes #367

For provenance purposes, this commit was AI assisted.

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

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

For provenance purposes, this commit was AI assisted.

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

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

For provenance purposes, this commit was AI assisted.
2026-03-23 15:47:58 -07:00
Michael Ramos 4b097197a0 feat(ai): AI backbone + inline chat for code review (#363)
* feat(ai): add provider-agnostic AI backbone for inline chat

Introduces @plannotator/ai — a pluggable provider layer that powers
AI features (inline chat, plan Q&A, code review assistance) across
all Plannotator surfaces.

- ProviderRegistry: class-based, multi-instance provider management
- SessionManager: in-memory session tracking with eviction, ID aliasing
- Context builders: system prompts and fork preambles for all three modes
- SSE streaming endpoints: session create/fork, query, abort, list
- Claude Agent SDK provider: fork from parent sessions, read-only tools,
  per-query abort, cached SDK import, concurrent query guard
- 34 passing tests covering all layers

For provenance purposes, this commit was AI assisted.

* fix(ai): address code review findings from PR #363

- Concurrent query race: reject with session_busy error instead of
  racing; add query generation counter so stale finally blocks don't
  clobber newer query state
- Text dropped before tool_use: mapSDKMessage now returns AIMessage[]
  and flushes accumulated text before each tool_use block
- defaultProvider returns instance ID: ProviderRegistry.getDefault()
  returns { id, provider } so capabilities endpoint sends the correct
  key for multi-instance setups
- Fork preamble re-sent on error: use _firstQuerySent boolean instead
  of _resolvedId to guard preamble injection
- Dead abortController params: remove unused abortController from
  CreateSessionOptions and AIProvider.resumeSession
- Unused context in memory: remove context from SessionConfig and
  baseConfig — already consumed to build systemPrompt/forkPreamble
- Fix tool_result mapping: SDK wraps tool results in SDKUserMessage
  (type "user"), not a "tool_result" type

For provenance purposes, this commit was AI assisted.

* feat(ai): inline AI chat on code review diffs

Add "Ask AI" to the code review toolbar — select lines, ask questions
about agent-written code, get streaming responses. Conversation persists
across file switches with inline markers on the diff showing where
questions were asked.

- useAIChat hook: SSE streaming, lazy session creation, multi-turn
- AskAIInput: question input with per-selection AI history
- InlineAIMarker: compact sparkle markers on diff lines via @pierre
- AITab: sidebar conversation grouped by file with collapsible sections
- SparklesIcon: shared 3-star sparkle from automations branch
- ReviewPanel: AI tab with message count badge, file group flash highlight
- Server: mount AI endpoints in review server with graceful fallback
- Support three question scopes: line, file, and general
- Toolbar ergonomics: typed text submits as AI question, no double popover

For provenance purposes, this commit was AI assisted.

* fix(ai): toolbar popover ergonomics and visual consistency

- X button closes entire toolbar in both modes (no ghost popover)
- Comment/Ask AI toggle is inline in the action row, same position
  in both modes (left side, mirrored layout)
- AI history appears below the input, not above
- File group flash highlight uses --muted to match file tree hover

For provenance purposes, this commit was AI assisted.

* fix(ai): visual consistency, shared primitives, markdown rendering

Visual consistency:
- AI tab file group headers match FileTree pattern (sizing, hover, badges)
- Q&A bubbles match annotation card padding and border-radius
- Empty state uses circle-bg icon (same as annotations)
- General input matches search input styling
- Removed redundant CSS classes, moved to Tailwind

Shared primitives:
- Extract formatTimestamp to shared utility (was duplicated)
- CountBadge component for consistent badge styling
- CopyButton component with hover-reveal + "Copied" flash

Affordances:
- Copy button on AI responses (hover-reveal)
- Send button on general input with keyboard shortcut hint
- Timestamps on Q&A pairs
- Keyboard shortcut title on Ask button

Markdown rendering:
- Add marked dependency for full block-level markdown in AI responses
- renderMarkdown utility using marked + DOMPurify
- Scoped .ai-markdown CSS for headings, lists, code blocks, blockquotes

For provenance purposes, this commit was AI assisted.

* feat(ai): permission inheritance + inline approval UI

The inline AI agent now inherits the user's existing Claude Code
permission rules via settingSources: ['user', 'project']. Tools
already approved in ~/.claude/settings.json auto-approve silently.

For tools not covered by existing rules, the SDK emits control_request
messages. These are mapped to a new AIPermissionRequestMessage type and
surfaced as inline approval cards in the AI tab — showing the tool name,
command/input, and Allow/Deny buttons.

Backend:
- Map control_request to AIPermissionRequestMessage in provider
- Store Query object on session for sending control_response back
- respondToPermission() method on AISession sends allow/deny
- POST /api/ai/permission endpoint for frontend → backend decisions
- Add settingSources to ClaudeSDKQueryOptions

Frontend:
- PermissionCard component with Allow/Deny buttons + resolved state
- useAIChat tracks permissionRequests array
- respondToPermission() POSTs decision to server
- AITab renders pending permission cards inline in conversation

For provenance purposes, this commit was AI assisted.

* fix(ai): streaming reactivity, maxTurns, popover→sidebar linking

Streaming fixes:
- State updates now create new object references via prev.map()
  instead of mutating objects inside the array — fixes React not
  re-rendering the sidebar during streaming
- Complete assistant text messages only used as fallback when no
  streaming deltas received (prevents duplicate text)
- Result text used as fallback when response is empty (agent spent
  all turns on tools)

Config:
- maxTurns raised to 99 (was 3 — agent ran out of turns reading
  files and never got to write its answer)

Popover → sidebar linking:
- Clicking AI history items in comment popover now passes questionId
  through to handleViewAIResponse, triggering the file group flash
  highlight in the sidebar (same behavior as clicking inline markers)

Debug logging (temporary):
- Server-side: [AI STREAM] logs every message the provider yields
- Client-side: [AI SSE] logs every message received in the browser

For provenance purposes, this commit was AI assisted.

* refactor(ai): simplification pass — types, perf, cleanup

From the three-agent code review analysis:

Quality:
- Rename AIMessage → AIChatEntry in review-editor to avoid collision
  with the wire-protocol AIMessage from packages/ai
- Type permissionMode as union (not string) in SessionConfig + QueryOptions
- Type _activeQuery with narrow interface instead of any
- Make toolUseId optional on AIToolResultMessage (SDK doesn't populate it)
- Remove redundant sessionIdRef double-write in useAIChat
- Fix handleAskGeneral useCallback dep: [aiChat] → [aiChat.ask]

Performance:
- Wrap QAPair in React.memo to prevent re-renders on sibling updates
- Memoize renderMarkdown output via useMemo (avoid re-parsing on every token)
- Fix auto-scroll useEffect: depend on messages.length not messages ref
  (prevents querySelectorAll on every streaming token)

Cleanup:
- Remove debug console.log from SSE endpoint and browser parser
- Extract isMac/submitHint to shared utils/platform.ts
- Extract generateId to shared utils/generateId.ts

Net: -2 lines. No behavior changes.

For provenance purposes, this commit was AI assisted.

* chore: biome lint fixes — import types, button a11y, unused import

- Use `import type` where only types are consumed (endpoints, CountBadge,
  CopyButton, renderMarkdown)
- Remove unused AIContext import from session-manager
- Template literal instead of string concat in context.ts truncate()
- Add type="button" + aria-hidden on CopyButton SVGs

For provenance purposes, this commit was AI assisted.

* feat(ai): Codex SDK provider + multi-provider backend

- Add CodexSDKProvider implementing AIProvider interface
  - Maps Codex ThreadEvents to AIMessage types
  - Delta tracking for streaming text from cumulative item.updated events
  - Supports resume, abort, sandbox modes
  - Factory self-registration pattern matching Claude provider
- Enhance capabilities endpoint with provider models + metadata
- Register both providers in review server (graceful degradation)
- Add CodexSDKConfig type, models list (GPT-5.4 family)
- Add reasoningEffort to CreateSessionOptions (Codex only)
- Extract DiffFile interface to shared types.ts
- Update CLAUDE.md with build order documentation
- 54 tests passing (20+ new for Codex event mapping)

For provenance purposes, this commit was AI assisted.

* feat(ai): provider settings UI + inline config bar

- Settings → AI tab: provider cards with per-provider model dropdowns
- Inline config bar in AI chat sidebar (provider, model, reasoning effort)
- Provider icons: Claude SVG + Codex PNG from marketing assets
- Cookie-based persistence for provider + per-provider model preferences
- useAIChat accepts providerId, model, reasoningEffort from props
- Session reset on provider/model switch (next question starts fresh)
- Reasoning effort selector (Codex only: Low/Medium/High/Max)
- Update code review docs with AI provider documentation

For provenance purposes, this commit was AI assisted.

* fix(ai): review fixes — reasoning props, input blocking, cwd resolution, codex-sdk dep

- Add missing selectedReasoningEffort + onReasoningEffortChange props to
  populated-state AIConfigBar (was only on empty-state, causing crash)
- Disable GeneralInput while streaming to prevent concurrent queries
- Thread cwd through CreateSessionOptions so AI sessions use the correct
  working directory (worktree-aware via getCwd resolver)
- Add @openai/codex-sdk as a real dependency (was only an optional peer)
- Eagerly verify codex-sdk importability at startup to avoid advertising
  a broken provider

For provenance purposes, this commit was AI assisted.

* fix(ai): polish config bar, input, and sidebar header

- Replace native <select> dropdowns with custom popover menus that show
  provider icons, model names, and checkmarks on active selection
- Remove disabled gear placeholder button
- Auto-grow textarea upward on multi-line input (capped at 6 lines)
- Push tab icons to far right in sidebar header (w-full on flex container)
- Add centered divider between file-scoped and general questions

For provenance purposes, this commit was AI assisted.

* fix(ai): honest fork capability + permissionMode doc accuracy

- Endpoint now checks capabilities.fork before calling forkSession(),
  falling back to createSession() for providers that can't truly fork
- Codex forkSession() now throws as the interface contract requires
  (was silently faking a fork with no history — misleading UX)
- Fixed permissionMode JSDoc: said "plan" but code defaults to "default"

For provenance purposes, this commit was AI assisted.

* fix(review): move search result count inside input field

Count now appears inline to the left of the X button instead of
squeezing the input from outside. Dynamic right padding accommodates
the count + clear button when a query is active.

For provenance purposes, this commit was AI assisted.

* fix(ai): use claude_code preset, pass selected code to queries

- Claude provider now uses { preset: "claude_code", append } instead of
  a raw system prompt, so inline chat inherits Claude Code's full
  built-in capabilities
- Stripped custom role/guidelines preamble from context builders — just
  emit the review context (diff, plan, document)
- handleAskAI now extracts selected code via extractLinesFromPatch and
  sends it with the query, fixing hallucinations on large PRs where the
  file's diff was truncated out of the system prompt

For provenance purposes, this commit was AI assisted.

* fix(ai): bundle both SDKs, remove peer dependencies

Both @anthropic-ai/claude-agent-sdk and @openai/codex-sdk are now
direct imports (no string indirection) so bun build --compile bundles
them into the binary. Users don't need to install SDKs separately.

Removed peer dependency declarations from packages/ai/package.json —
meaningless for a compiled binary.

For provenance purposes, this commit was AI assisted.

* refactor: canonical platform.ts, file renames, file-level comments

- Created packages/ui/utils/platform.ts as the single source for
  isMac, modKey, altKey, submitHint, isWindows
- Consolidated inline navigator.platform checks from 6 files
- Renamed renderMarkdown → renderChatMarkdown (distinct from plan editor)
- Renamed formatTimestamp → formatRelativeTime (clearer purpose)
- Added file-level comments to InlineAIMarker, PermissionCard, generateId

For provenance purposes, this commit was AI assisted.

* docs(ai): add AI features guide + link from settings

New docs page at guides/ai-features covering providers, models,
configuration, session behavior, permissions, and available settings.

AISettingsTab now links to the docs in both empty and populated states.

For provenance purposes, this commit was AI assisted.
2026-03-23 12:13:03 -07:00
Michael Ramos 6b775ea1ed feat: /plannotator-last — annotate the last agent message (#325)
* feat: add /plannotator-last command to annotate last assistant message

Adds a new slash command that extracts the last rendered assistant message
from Claude Code's session log and opens it in the annotation UI.

Session log parser (apps/hook/server/session-log.ts):
- Parses Claude Code JSONL logs at ~/.claude/projects/{slug}/*.jsonl
- Finds the last assistant message.id with text content blocks
- Skips noise entries (progress, system, file-history-snapshot, queue-operation)
- Filters system-generated user messages by prefix to avoid false turn boundaries
- Walks backward through empty turns when back-to-back user messages exist
- No anchoring — reads from end of log since <command-message> isn't written
  until after the binary completes

New files:
- apps/hook/commands/plannotator-last.md — slash command definition
- apps/hook/server/session-log.ts — Claude-Code-specific log parser
- apps/hook/server/session-log.test.ts — 30 tests covering streaming chunks,
  tool call turns, sub-agent noise, stop hooks, thinking blocks, and edge cases

Modified:
- apps/hook/server/index.ts — annotate-last subcommand

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: remove 3 redundant real-world scenario tests

These duplicated coverage already provided by focused unit tests:
- "full conversation" → covered by "grabs last message.id in multi-tool turn"
- "stop hook interrupted" → covered by "skips progress and system noise"
- "long tool-only sequence" → covered by "skips tool-only assistant entries"

Kept the thinking block test (unique coverage). 27 tests remain.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add /plannotator-last command to Pi extension

Uses Pi's session manager API to find the last assistant message —
walks backward through ctx.sessionManager.getEntries(), finds the
last entry with role "assistant" and text content, opens it in the
annotation UI. Reuses existing isAssistantMessage(), getTextContent(),
startAnnotateServer(), and runBrowserReview() from the extension.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add /plannotator-last to OpenCode plugin + extract command handlers

Adds annotate-last command that fetches session messages via
client.session.messages(), finds the last assistant message with text
parts, and opens it in the annotation UI.

Refactors command handling: extracts review, annotate, and annotate-last
handlers from the inline event hook into commands.ts module. Reduces
index.ts by ~120 lines and makes adding future commands cleaner.

New files:
- apps/opencode-plugin/commands.ts — extracted command handlers
- apps/opencode-plugin/commands/plannotator-last.md — command metadata

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: context-aware UI labels for annotate-last mode

Adds "annotate-last" mode to the annotate server, passed through to the
UI via /api/plan response. The editor uses this to show "Copy message"
instead of "Copy plan", and "annotations on the message" in the
completion overlay.

- packages/server/annotate.ts: new `mode` option on AnnotateServerOptions
- packages/editor/App.tsx: annotateSource state derived from mode
- packages/ui/components/Viewer.tsx: copyLabel prop for button text
- All three harnesses pass mode: "annotate-last" in their callers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add Codex support to annotate-last command

Detects Codex via CODEX_THREAD_ID env var (injected by Codex into every
spawned process). Uses the thread ID to find the rollout file in
~/.codex/sessions/, parses the Codex rollout JSONL format to extract
the last assistant message.

Also adds `plannotator last` alias for shorter usage in Codex bang
commands (!plannotator last).

New files:
- apps/hook/server/codex-session.ts — Codex rollout parser
- apps/hook/server/codex-session.test.ts — 9 tests

Modified:
- apps/hook/server/index.ts — Codex detection + `last` alias

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: context-aware feedback title + top spacing for paragraph-first content

- exportAnnotations now accepts a title param: "Message Feedback" for
  annotate-last, "File Feedback" for file annotation, "Plan Feedback"
  for plan review (default)
- Adds top spacer when content starts with a paragraph (not a heading)
  and has no frontmatter, fixing tight spacing in annotate-last mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: add sandbox scripts for Pi and Codex testing

- sandbox-pi.sh: builds extension, creates temp project, installs via
  `pi install`, launches Pi with sample files
- sandbox-codex.sh: compiles binary, creates temp project, launches
  Codex. Test with `!plannotator last`

Both follow the same pattern as sandbox-opencode.sh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add hook build step to opencode sandbox script

The opencode build copies HTML from hook/dist/ — without building hook
first, the sandbox could use stale HTML. Pi and Codex sandboxes already
had this step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove command body from plannotator-last to prevent agent response

The .md body was being sent to the agent as a prompt, causing it to
respond with "Opening annotation UI..." before the event handler could
fetch messages. That response became the "last message" instead of the
actual one. Empty body = agent stays silent, event handler intercepts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use command.execute.before hook for OpenCode annotate-last

Moves plannotator-last from the passive event hook to the
command.execute.before hook. This intercepts the command before the
agent sees it, clears output.parts so the agent stays silent, fetches
session messages, opens the annotation UI, then sends feedback via
client.session.prompt() — same pattern as review/annotate.

Previously the agent would respond to the command body before the
event handler could fetch messages, polluting the session history.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add Codex to origin type and agent name mapping

Origin "codex" was falling through to the default "Coding Agent" label.
Added "codex" to the origin union type across annotate server, editor,
and removed the `as any` cast in the hook.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remote share link, plan-specific prose, and codex type unions

- Add writeRemoteShareLink to annotate-last onReady callback so remote
  sessions get a reachable URL
- Add subject parameter to exportAnnotations so feedback says "message"
  or "file" instead of "plan" when appropriate
- Add 'codex' to origin type unions in useAgents, Settings, UpdateBanner,
  and App.tsx fetch handler

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: correct JSDoc for projectSlugFromCwd (leading dash is kept, not stripped)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: use RenderedMessage type instead of inline structural type

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:49:34 -07:00
Michael Ramos d5e0ff6fe6 feat: Octarine notes integration + auto-save for all integrations (#297)
* feat: Octarine notes integration + auto-save for all integrations

Add Octarine as a third notes app integration alongside Obsidian and Bear.
Uses the octarine:// URI scheme to create notes via deep links, following
the same x-callback-url pattern as Bear.

- New file: packages/ui/utils/octarine.ts (cookie-backed settings)
- Server: saveToOctarine() in integrations.ts, wired into /api/approve
  and /api/save-notes endpoints
- UI: Octarine tab in Settings, card in Export > Notes, dropdown button,
  Cmd+S shortcut support
- Parallelize all integration saves with Promise.allSettled (was sequential)
- Add auto-save on plan arrival toggle to Bear and Octarine (Obsidian
  already had this), consolidate into a single effect + single API call

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add YAML frontmatter properties to Octarine notes

Prepend Octarine-compatible YAML frontmatter with tags, Status, Author,
and Last Edited properties. Uses the same extractTags() as Obsidian for
auto-generated tags (project name, title words, code fence languages).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use fresh=true for Octarine saves to prevent content duplication

Octarine's create action appends by default. If auto-save fires on plan
arrival and the user then approves within the same minute, the same path
gets hit twice — doubling the content. Using fresh=true replaces instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: trim workspace and folder in saveToOctarine before building URI

The UI checks workspace.trim().length > 0 but the server used raw values.
Accidental whitespace in settings would cause Octarine saves to fail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use isOctarineConfigured() for client-side Octarine guards

Auto-save and approve checks used raw workspace truthiness, so
whitespace-only workspace would trigger a save attempt that the server
rejects. Now uses isOctarineConfigured() which trims before checking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prevent Bear duplicate notes and align default-save dropdown gates

Bear creates a new note on every save, so skip it on approve when
arrival auto-save already succeeded. Gate the default-save dropdown
by actual configuration (vault path for Obsidian, workspace for
Octarine) instead of just the enabled toggle, matching the shortcut
behavior. Self-heal stale defaults back to "ask".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:07:01 -07:00
Michael Ramos e1bd27aae4 feat: custom theme system with 18 built-in themes (#294)
* feat: custom theme system with 15 built-in themes

Consolidate CSS theming into a single source of truth (packages/ui/theme.css)
and introduce a multi-theme architecture where each theme defines both dark
and light mode variants. Users can pick a color palette (theme) and separately
toggle dark/light mode within it.

- Extract shared color tokens, Tailwind bridge, and base styles into packages/ui/theme.css
- Replace hardcoded oklch values with token references (oklch from var syntax)
- Fix 3 light-mode bugs in review-editor diff colors
- Create 15 built-in themes: Plannotator (default), Claude+, Soft Pop, Adwaita,
  Caffeine, Cyberdyne, Cyberfunk, Doom 64, Dracula, Gruvbox, PaulMillr,
  Quantum Rose, Solar Dusk, Terminal, Tinacious
- Expand ThemeProvider to manage colorTheme + mode independently
- Add Theme tab to Settings with mode toggle, search, and swatch grid
- Dark-only themes (Dracula, Terminal, etc.) suppress light class to prevent
  broken styling; light-only themes (Tinacious) force it
- Cookie persistence: plannotator-color-theme for palette, existing key for mode
- Include theme conversion script for FinSitter theme adaptation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: refine themes, add Catppuccin/Rosé Pine/Monokai Pro/Synthwave 84/Tokyo Night

Theme curation:
- Replace Cyberdyne with Synthwave '84 (from robb0wen/synthwave-vscode)
- Replace Cyberfunk with Catppuccin (Mocha dark + Latte light), Rosé Pine
  (dark + Dawn light), Monokai Pro (dark only), Tokyo Night (Storm + Day)
- Rewrite Gruvbox from canonical source (morhetz/gruvbox)
- Rewrite Adwaita from canonical VS Code theme (piousdeer/vscode-adwaita)
- Rewrite PaulMillr from Ghostty canonical palette

Theme fixes:
- Fix faded Send Feedback button on Solar Dusk, Quantum Rose, Caffeine
  (accent colors were too dark/invisible at 15% opacity)
- Fix Dracula/Terminal/PaulMillr/Tinacious light mode breakage — dark-only
  themes now suppress .light class via modeSupport in ThemeProvider
- Fix code block backgrounds — use --code-bg token instead of --muted
- Add faint green grid overlay for Terminal theme
- Alphabetize theme registry (Plannotator first)

Code review diff theming:
- Pass theme colors into @pierre/diffs shadow DOM via unsafeCSS prop
- Dynamic themeType based on resolved mode

18 built-in themes: Plannotator, Absolutely, Adwaita, Caffeine, Catppuccin,
Doom 64, Dracula, Gruvbox, Monokai Pro, PaulMillr, Quantum Rose, Rosé Pine,
Soft Pop, Solar Dusk, Synthwave '84, Terminal, Tinacious, Tokyo Night

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: increase settings modal height for theme grid visibility

Remove 340px cap on theme grid, bump content area from 70vh to 85vh
so all themes are visible without scrolling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: memoize ThemeProvider context, deduplicate Mode type, clean up DiffViewer

From /simplify review:
- Export Mode type from ThemeProvider, import in ThemeTab (was duplicated)
- Add resolvedMode to context — consumers no longer re-query matchMedia
- Memoize context value with useMemo, setters with useCallback (prevents
  unnecessary re-renders of all useTheme consumers)
- Consolidate DiffViewer's two pierre state vars into single object
- Use resolvedMode from context in DiffViewer instead of classList check
- Format crammed single-line extended tokens in 4 theme CSS files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prevent FOUC, fix system mode stale read, restore marketing theme class

P1: Marketing site inline script now sets theme-{name} class on <html>
    before first paint (reads plannotator-color-theme cookie). Without this,
    CSS tokens under .theme-* selectors were never active.

P2: System mode effect now re-reads matchMedia.matches immediately when
    entering system mode, not just on future changes. Fixes stale resolvedMode
    when OS preference changed while pinned to explicit dark/light.

P3: ThemeProvider applies theme class synchronously during render (not in
    a passive useEffect) to prevent flash of unstyled content on hard refresh.
    Also extracted resolveThemeClasses to module scope to avoid useCallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add Quick Copy button, fix import icon, comment out agent badge

- Add Quick Copy button to annotation panel footer (splits horizontally
  with existing Quick Share). Copies annotations wrapped with the deny
  preamble so output is paste-ready for agent sessions.
- Export Modal annotations copy also wraps with deny preamble
- Extract wrapFeedbackForAgent() utility in parser.ts as single source
  of truth for the preamble text
- Fix desktop import icon to match mobile (arrow-into-document, not download)
- Comment out code review agent badge — unreliable across multiple harnesses

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 21:26:51 -07:00
Marcelo Terreiro Prado d7ccc55f33 feat: configurable Bear tags + fix double-title bug (#283)
* feat: configurable Bear tags + fix double-title bug

- Fix double title: strip H1 from Bear note body since `title` URL
  param already carries it
- Add custom tags setting (comma-separated, kebab-case) with
  auto-normalization; empty = auto-generated tags (existing behavior)
- Add tag position setting: prepend (after title) or append (default)
- Settings UI in Bear tab with inputs matching existing design
- Dev mock: add /api/approve + /api/save-notes handlers for Bear
  testing in dev mode

* revert: drop dev-mock-api changes

* fix: simplify H1 stripping regex — no wording assumption

* test: add Bear integration tests + restore JSDoc comments

* refactor: export Bear helpers from integrations, import in tests

* test: use dummy title in Bear tests
2026-03-12 16:30:51 -07:00
Michael Ramos 7417f49076 feat: add new settings indicator for Plan Width and Quick Labels
Pulsing dot on the Settings gear button and "new" pill badges on the
Display and Labels tabs signal newly added settings. Dismissed on first
open via cookie-based version gating.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 01:04:00 -07:00
Itay Grubman 9a23c249b2 feat: add quick label selection mode for one-click annotations (#272)
* feat: add quick annotation labels for one-click preset feedback

Add preset label chips (Needs tests, Security concern, Break this up, etc.)
that allow instant annotation without typing. Includes ⚡ toolbar button,
Alt+1..8 keyboard shortcuts, label customization in Settings, and label
summary in export output.

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

* feat: add quick label selection mode for one-click annotations

* feat: redesign quick label picker UX and add label tips

- Redesign FloatingQuickLabelPicker as a vertical context-menu style list
  with cursor-anchored positioning (appears at mouseup point, not selection center)
- Unify label dropdown: toolbar and quick-label mode now share the same
  FloatingQuickLabelPicker component (removed duplicate InlineQuickLabelDropdown)
- Fix above/below flip positioning (follow CommentPopover pattern with
  conditional translateY)
- Add label tips: optional instruction text on QuickLabel that gets injected
  into agent feedback as a blockquote below the label
- Add tip editor in Settings with three visual states (empty/editing/filled)
- Add "Missing overview" default label with a tip for requesting narrative context
- Extend keyboard shortcuts from Alt+1-8 to Alt+1-9
- Suppress input method toggle (Alt) when label picker is open
- Reorder default labels: Clarify this, Needs tests, Consider edge cases,
  Missing overview, Security concern, Break this up, Wrong order, Discuss first,
  Nice approach

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

* feat: curate default labels, add cyan/amber colors, bare digit shortcuts

Finalize the 10 default quick labels based on user feedback data:
clarify, overview, verify, example, patterns, alternatives, regression,
out-of-scope, tests, nice-approach. Each label gets a unique color
(added cyan and amber to the palette). Bare digit keys (1-0) now apply
labels when the picker is open, Alt+N still works everywhere. Tip editor
cursor starts at beginning for readability.

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

* fix: prevent duplicate annotation when digit key fires both toolbar and picker handlers

When the quick label picker is open from the toolbar's zap button,
let FloatingQuickLabelPicker own all keyboard input instead of both
components handling the same keypress.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-03-11 23:39:46 -07:00
Itay Grubman c0076621ac feat: add quick annotation labels for one-click preset feedback (#268)
Add preset label chips (Needs tests, Security concern, Break this up, etc.)
that allow instant annotation without typing. Includes ⚡ toolbar button,
Alt+1..8 keyboard shortcuts, label customization in Settings, and label
summary in export output.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-03-11 16:49:30 -07:00
Itay Grubman 5437a37da9 feat: add mobile compatibility (#260)
* feat: add mobile compatibility

- Add responsive hamburger menu with all header actions (MobileMenu)
- Annotation panel renders as full-screen overlay on mobile with backdrop and close button
- Panel starts closed on mobile (<768px)
- Touch support for resize handles, pinpoint annotations, and toolstrip buttons
- Mobile text selection creates annotations via highlighter.fromRange() bridge
- Card action buttons always visible on touch devices (hover:none media query)
- Settings modal uses horizontal tab bar on mobile
- CommentPopover width capped to viewport on small screens
- Replace mousedown with pointerdown for touch-compatible click-outside handling
- Add useIsMobile reactive hook for breakpoint detection
- Desktop layout (>=768px) unchanged

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

* fix: mobile layout polish — panel gap, button overlap, responsive labels

- AnnotationPanel: fix bottom gap on mobile overlay (inset-y-12 → top-12 bottom-0)
- Viewer: push in-plan action buttons below badges on mobile (mt-6), add
  clear-right before frontmatter, show short labels (Comment/Copy) on mobile
- App: reduce mobile horizontal padding to 8px (px-2)
- test-hook.sh: build review before hook to fix missing dist error

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

* feat: add plan width display setting with compact/default/wide options

Adds a configurable plan width preference (compact 832px, default 1040px,
wide 1280px) with an abstract layout preview in Settings. Dynamic max-width
flows through to Viewer, PlanDiffViewer, and the toolstrip. Default is
compact to preserve existing behavior.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-03-11 16:25:48 -07:00
Michael Ramos 75968541c2 feat: Obsidian saving enhancements — separator, auto-save, sanitization (#240)
* fix: harden title sanitization for Obsidian filenames

Strip parentheses, brackets, curly braces, hash, tilde, and backtick
from plan titles when generating Obsidian filenames. Re-trim after
stripping to handle edge cases where stripped chars leave leading/trailing
whitespace. Closes part of #235.

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

* feat: filename separator + auto-save on arrival for Obsidian

Filename separator: adds a dropdown in Obsidian settings to replace
spaces with dashes or underscores in generated filenames. Fixes issues
with CLI tools fumbling space-escaped paths inside Obsidian vaults.
The setting flows through the save request to generateFilename() as a
post-processing step. Live preview reflects the choice.

Auto-save on arrival: adds an opt-in toggle that automatically saves
plans to Obsidian the moment they load in the browser, before the user
approves or denies. This fixes the workflow where approving from the
Claude Code CLI kills the hook server before the user can Cmd+S.
Guarded by a ref to fire only once per session.

Closes #235, closes #228.

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

* fix: pass filenameSeparator in ExportModal Obsidian save path

The ExportModal's save-to-Obsidian callsite was missing the
filenameSeparator config, causing the separator preference to be
silently ignored when saving via the Export Modal.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:27:11 -08:00
Michael Ramos 2739660fa6 feat: pinpoint mode for point-and-click annotations (#232)
* feat: add pinpoint mode for point-and-click annotations

Adds a new input method toggle (Drag / Pinpoint) orthogonal to the
existing annotation action modes (Selection / Comment / Redline).

In pinpoint mode, hovering over blocks and inline elements shows a
dashed outline with a label tooltip. Clicking auto-selects the
element's text via highlighter.fromRange(), firing the same CREATE
event as drag-selection — all downstream annotation flow is unchanged.

New files:
- blockTargeting.ts: resolves hover targets (inlines, cells, list items, blocks)
- usePinpoint.ts: hover/click state machine with text-range bridge
- PinpointOverlay.tsx: dashed outline + label badge
- InputMethodToggle.tsx: Drag/Pinpoint segmented toggle
- inputMethod.ts: cookie-based persistence

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

* feat: unified AnnotationToolstrip with smooth animations

Merge InputMethodToggle + ModeSwitcher into a single compact toolbar
with two pill groups: input method (Select/Pinpoint) and action mode
(Markup/Comment/Redline). Buttons expand to show labels on hover/active
with explicit pixel-width transitions. Suppress transitions on mount
to prevent initial flash.

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

* feat: keyboard shortcuts tab and settings reorganization

Add Shortcuts tab to settings with OS-aware key cap styling and
organized shortcut groups. Move Obsidian and Bear integrations
to dedicated tabs under an Integrations sidebar group, leaving
compact navigation links on the Saving tab.

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

* feat: Alt/Option key to switch between Select and Pinpoint

Hold Alt to temporarily switch input method (reverts on release).
Double-tap Alt to permanently toggle. Added to keyboard shortcuts
panel in settings.

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

* fix: remove md:py-8 to use consistent py-3 padding

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

* fix: toolbar polish — icon swap, full opacity active states, styling

Move text-bracket icon to Select, pencil to Markup. Active buttons
use full opacity backgrounds with contrasting foreground. Help link
lowercase with updated styling. Revert md:py-8 padding. Add gap
between buttons in pill groups.

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

* fix: match toolbar button sizing to main branch

Use text-xs (12px) and px-2.5 padding to match main branch button
rendering. Active state uses bg-background with no color tint.

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

* feat: smarter pinpoint hover — edge-zone tables, row targeting, list groups

Table edge-zone detection: top/bottom edges target whole table, left/right
edges target the specific row, interior targets individual cells. List group
wrappers create hover zones in gaps between consecutive list items. Demo
plan now includes sample tables for testing.

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

* chore: remove unused InputMethodToggle component

Superseded by AnnotationToolstrip which unified input method + mode switching.

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

* chore: remove unimplemented S3 paste store stub

Never used — paste service runs on Cloudflare KV and local FS.

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

* chore: remove dead ModeSwitcher component

Replaced by AnnotationToolstrip which unified mode switching + input method toggle.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 00:27:59 -08:00
Michael Ramos e5f3cfffbb feat: Obsidian vault browser sidebar tab (#209)
* feat: add Obsidian vault browser sidebar tab (#207)

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

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

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

* fix: update docs and clear vault state on disable

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

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

* refactor: simplify vault browser prop passing and derived state

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

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:32:59 -08:00
Michael Ramos d2a159ca53 Add customizable filename format for Obsidian exports (#202)
* feat: add customizable filename format for Obsidian export (#167)

Add a filename format setting to the Obsidian integration that supports
template variables ({title}, {YYYY}, {MM}, {DD}, {Mon}, {D}, {HH}, {h},
{hh}, {mm}, {ss}, {ampm}). Default format preserves existing behavior.
Settings UI shows a live preview of the resulting filename.

https://claude.ai/code/session_01QycvXHHvAy38N36PVP3jFF

* fix: guard against double .md extension in custom filename format

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-02 11:21:16 -08:00
Michael Ramos effdd24c71 feat: Plan Diff marketing dialog, Pi origin, and docs (#177)
* feat: add Plan Diff marketing dialog, Pi origin support, and docs

- Add PlanDiffMarketing first-run dialog announcing the Plan Diff feature
  with per-origin video demo URLs (Claude Code, OpenCode, Pi)
- Add 'pi' as a first-class origin with display name and violet badge
- Add Plan Diff blog post (plan-diff-see-what-changed.md)
- Add brief Plan Diff mentions across READMEs and marketing docs


* polish: tighten blog post copy and reduce em dash usage


* fix: move PlanDiffMarketing to plan-diff/ and fix stale useEffect deps


* add plan diff preview screenshot for marketing dialog
2026-02-23 00:24:25 -08:00