Commit Graph

104 Commits

Author SHA1 Message Date
Michael Ramos 1cab9dd9a8 feat(review): mark files viewed as you scroll past them (#1430)
* feat(review): mark files viewed as you scroll past them

Reviewers reading the all-files diff top to bottom had to check every file
off by hand. Now a file marks itself viewed when the reviewer MOVES ON from
it, after its content was actually on screen long enough to have been read.
Arriving at a file never marks it; leaving it downward does.

- All-files surface: a file marks when the reader scrolls past it (its
  successor has reached the viewport top, so it genuinely scrolled out above)
  and has accumulated at least 1000ms as the reported reading file. Dwell is
  cumulative per diff snapshot, so bouncing between two files still accrues,
  while a momentum flick to the bottom marks nothing. The last file, which can
  never scroll out above, marks on reaching the end of the diff.
- Single-file panel: opening a file never marks it; navigating away after the
  same dwell floor does. Keyboard file navigation drives the same panel
  switches, so keyboard-only parity is automatic.
- Collapsed cards never mark. Generated files seed collapsed, so nobody
  reviews a lockfile by scrolling past its folded header.
- Un-viewing a file suppresses auto-view for it until it is marked viewed by
  hand again. That set rides the review draft as an additive optional field.
- Inert inside the Guided Review takeover and on a commit detour, where the
  files on screen are not the change under review.
- A viewed file whose patch changes under a refresh loses its checkmark, but
  only while auto-view is on, so the off state stays byte-identical to today.
- PR sessions batch the marks into one /api/pr-viewed request rather than one
  per file.

The setting is reviewAutoViewed, cookie-only and on by default, with two off
switches: Settings > Git and a row in the file-list gear popover. The first
time auto-view actually fires, a toast says so and offers Turn off; using
either switch consumes that one-time notice.

The decision core is pure and clock-injected (utils/autoViewed.ts), the
binding is a hook (hooks/useAutoViewed.ts), and AllFilesCodeView only gains
one optional emission callback on the rAF path it already runs. No server
changes in either runtime.

AI-assisted (Claude) under maintainer direction.

* fix(review): scope auto-mark-viewed to the transitions it was meant for

Four review findings on the auto-mark-viewed branch.

Rule 5 fired on EVERY applied diff switch, not just the staleness refresh.
The review app funnels every transition through one apply path, so entering
the Commits detour (the rail auto-opens HEAD), switching base branch, and
toggling hide-whitespace all un-viewed files whose per-path patch text
legitimately differs, which contradicts both Rule 4's "a commit detour is
inert" and Rule 5's own rationale. The apply path now goes through
resolveDiffSwitchUnviews, which requires the caller to opt in
(`contentRefresh`) and re-checks the identity of the diff on top of that:
same selection, same base, and never a commit-family type on either side.
Only the staleness refresh and the post-fetch base refresh opt in. The pure
delta resolver is unchanged. A source-level test pins which call sites may
opt in, since that is where the guarantee actually lives.

The at-bottom branch fired on the mount tick. A diff shorter than the
viewport is at-bottom from the very first report, and that report is the
mount seed, so the file on screen marked itself about a second later with
zero interaction and fired the first-time toast at a motionless page. It now
requires a real scroll event on the current file set.

Staging a file marked it viewed without clearing auto-view suppression,
unlike v, the header button and the tree row, so a file the reviewer
un-viewed and later staged stayed permanently off-limits to auto-view.

Dwell accrued while the setting was off, so enabling mid-read could mark the
current file instantly on time the reviewer spent with the feature
deliberately disabled. Disabled is now fully inert: the clock does not
accrue, and enabling starts a fresh one rather than replaying the gap.

AI-assisted (Claude) under maintainer direction.

* chore: refresh pinned guide viewer manifest after merging main
2026-08-31 09:13:49 -07:00
Michael Ramos 4465950f0c feat: add bounded annotation undo and redo (#1426)
* feat: add bounded undo and redo history

* fix: address undo redo review feedback

* chore: refresh guide viewer manifest

* fix undo history review regressions

* chore: refresh guide viewer manifest
2026-08-31 09:12:13 -07:00
Michael Ramos 42978fe847 fix(share): invalidate stale short links (#1425)
* fix(share): invalidate stale short links

* fix(share): address short-link review feedback

* test(ci): isolate short-link lifecycle coverage

* test(ci): isolate registered DOM suites
2026-08-31 09:11:35 -07:00
Michael Ramos c2950e709f fix: pre-release QA findings for 0.27.9 (#1405)
Fixes from the 0.27.9 pre-release review. Servers: an unreadable rendered-HTML root falls back to the startup snapshot on both runtimes with a once-per-process warning instead of hanging (Pi) or answering 500 (Bun); the version diff is recomputed against current bytes on reload and carried through the in-app Refresh instead of being dropped, with no history write on a GET. Client: a Refresh action on the compact touch shell; HtmlSurfaceControls renders Refresh independently of the eye; the dead HtmlSurfaceActions removed. Threading: one linear, cycle-safe reply resolution shared by the annotations panel, its sort, and the export (5,000-chain tests), PATCH ingest on both runtimes rejects self-references and cycles, nothing is ever dropped from feedback. WebMCP and viewer hygiene: bounded tombstone and request memories, per-instance minted ids, nudge id caps, waiter cleanup on unmount, a shared retry epoch for diagram blocks. Docs: HTML Refresh documented, the WebMCP design pointer fixed, marketing pages updated.

AI-assisted (Claude) under maintainer direction.
2026-08-27 15:23:28 -07:00
Michael Ramos 0b167cc478 perf(ui): lazy diagram and math renderers with eager entries for Plannotator (#1394)
Bundle-weight optimization of @plannotator/ui for multi-chunk hosts, requested by Workspaces: the Mermaid runtime and Graphviz engine load inside the render effect, the username dictionary sits behind a synchronous identity generator slot, and KaTeX sits behind a math renderer slot with a loader seam on configurePlannotatorUI. Plannotator's own apps import eager entries (math, identity, and Mermaid for the plan editor) so their behavior is unchanged: single-file builds within noise of main, math typeset on first paint, identities from the full dictionary, and the share portal keeps Mermaid in its entry chunk so its failure surface matches main. Built-HTML registration markers guard the eager imports. Hosts that omit the eager entries get the lazy paths, a one-shot automatic re-attempt, and a Retry affordance on the diagram error panel; the module-map limitation of in-page retries is documented.

AI-assisted (Claude) under maintainer direction.
2026-08-27 07:35:49 -07:00
Michael Ramos 44611e5300 feat(ui): publish the HTML annotation seams hosts were hand-rolling (#1395)
Parity seams for hosts of @plannotator/ui, requested by Workspaces after the HTML annotation handoff: projectHostThreads and buildPersistedHtmlAnchor in @plannotator/core; HtmlViewer onUnanchoredChange completed over the annotations prop with a restore-keyed report so hosts can drop their mark-applied listeners; published useHtmlRefresh with a fetchSnapshot adapter; published HtmlSurfaceControls (eye, refresh, pen) with label overrides; AnnotationPanel unanchoredIds chip (wired for Plannotator too); HtmlViewer maxAdditionalTargets and scrollBehavior carried on the bridge; shortcuts and utils/inputMethod blessed as consumer exports. Plannotator's behavior is unchanged apart from the new Unanchored chip, verified by a real-browser A/B including the orphan and re-anchor cycle and by a combined cross-surface verification with #1394.

AI-assisted (Claude) under maintainer direction.
2026-08-27 07:35:22 -07:00
Leonardo Reis 6407ef5d97 feat(annotate): manual refresh of rendered HTML from disk (#1232)
Local rendered-HTML annotate sessions get a Refresh action beside Hide tools: the document is re-fetched through /api/doc, the sandboxed viewer remounts, annotations are re-anchored and the ones that no longer match are reported while their comments are kept, and stale diff and share state is reset. Maintainer additions on top of the contributor's work: share-link invalidation no longer keys on the resolver's identity, /api/plan and /api/share-html serve a local root HTML file from its current bytes on both runtimes so a reload does not revert the page under the annotations, the Refresh button keeps keyboard focus via aria-disabled, and the tests were hardened. Verified end to end in a real browser.

Thanks @leoreisdias.

AI-assisted (Claude) under maintainer direction.
2026-08-26 14:53:28 -07:00
Michael Ramos 27791a6fba Mobile Phase 2B: Plan shell and navigation (#1303)
* feat(editor): add compact plan navigator

* feat(editor): simplify compact plan chrome

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

* feat(editor): add compact plan review surfaces

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

* fix(editor): preserve desktop diff activation
2026-08-13 09:38:14 -07:00
Michael Ramos e181b824cc Mobile-safe plan and code comment composition (#1297)
* feat: harden mobile comment composition

* fix(ui): keep mobile app inside Safari viewport

* docs: record physical mobile triage

* fix(ui): extend plan canvas behind Safari controls

* fix(ui): let mobile plans drive Safari chrome

* fix(ui): release Safari top edge on mobile plans

* docs: triage mobile feedback and close phase 1b

* fix(ui): harden compact touch behavior
2026-08-13 08:58:55 -07:00
Michael Ramos d3633c9c52 Mobile foundation and quieter first run (#1295)
* feat: establish mobile foundation and simplify onboarding

* fix(ui): finish mobile foundation cleanup
2026-08-13 08:45:00 -07:00
Michael Ramos 98113182b5 feat(guide): reviewer-supplied extra instructions for Guided Review (#1267)
* feat(guide): reviewer-supplied extra instructions for Guided Review (#1265)

Adds a quiet, collapsed-by-default Custom instructions affordance to the
guide launch page. The text is APPENDED to the built-in organizer
methodology as a clearly delimited section (composeGuideMethodology) and
never replaces it; absent or blank instructions produce byte-identical
prompts to before. Persisted in a dedicated cookie
(plannotator-guide-instructions) so a standing team preference survives
sessions without bloating the plannotator.agents blob past the browser's
per-cookie limit.

Server side, the launch body gains an optional guide-only instructions
field (both the Bun and Pi node:http agent-jobs handlers accept and
thread it); prompt composition lives in the shared guide-review.ts that
vendor.sh already vendors to Pi, so both runtimes compose identically.
Text is capped at GUIDE_EXTRA_INSTRUCTIONS_MAX_CHARS (2000) server-side
and mirrored by the textarea maxLength. Repair launches deliberately
ignore instructions: a repair is a mechanical JSON fix, not a rewrite.

Tests pin the regression contract (empty input keeps prior prompt bytes),
appended-not-replacing composition, the length cap, repair isolation, and
the cookie round-trip via the storage backend seam.

* refactor(guide): store standing instructions server-side, not in a cookie

Review findings on the cookie approach (silent write failure past the
encoded 4KB per-cookie limit for multi-byte text) pointed at the real
design problem: the instructions are consumed by the SERVER at launch
time, so they belong in the data dir like review-skills.json, where no
size ceiling or encoding inflation exists and the preference follows
the machine instead of one browser profile.

New GET/PUT /api/agents/guide-instructions in both runtimes backed by
shared guide-instructions-store (vendored to Pi). Guide launches apply
the stored text when the body carries none; the launch page still sends
its live textarea value (explicit wins), so a just-typed preference can
never race the debounced save. The sidebar surface sends nothing and
inherits the stored text server-side. All cookie machinery removed.

Also folds in the review fixes: marker-tag-shaped strings in
instructions are defanged so first-match nonce recovery cannot be
hijacked by pasted examples.
2026-08-11 10:24:39 -07:00
Michael Ramos f8951cd3c6 feat(annotate): shift-click multi-element selection for raw-HTML pinpoint (#1254)
* feat(annotate): shift-click multi-element selection for raw-HTML pinpoint drafts

One comment covering multiple targets: shift-clicking elements while a
pinpoint draft composer is open toggles them in/out of the SAME draft.

- bridge: pendingMultiTargets registry with per-target pinned outline boxes,
  DOM-identity + anchor-equality toggle dedup, primary promotion on removal,
  draft cancel on last removal, shift-hover preview, rAF pointer relay for
  the composer yield, remove-target/flash-target parent messages, capped at
  16 additional targets at the source
- pins: registerPin now allows several elements per annotation id (deduped
  by (id, element)/(id, anchor)); badges number by first-seen id so all
  targets of one annotation share one number; find-and-mark restores
  additionalAnchors as same-numbered pins (anchor-only, fail-closed)
- parent hook: multi-target-added/removed/pointer messages validated and
  capped at the trust boundary (key<=64, label<=64 truncated, text via the
  10k surrogate-safe cap, anchors via parseHtmlElementAnchor, array cap 16);
  draftTargets state, chip removal with deterministic promotion mirrored on
  both sides, composerFocusToken for focus return
- types: additive Annotation.htmlAdditionalTargets (label/text/anchor per
  extra target; anchor optional so fail-closed targets still export)
- CommentPopover (all seams optional, default off): horizontally scrollable
  target chips with remove buttons and hover-to-flash, refocusToken,
  captureStrayKeys first-keystroke guard, yieldState fade/click-through with
  180ms transition and prefers-reduced-motion fallback
- HtmlViewer: composer-yield state machine (composerYield.ts) fed by parent
  mousemoves plus bridge-relayed pointer positions, with 48px/96px hysteresis
- export: multi-target comments append an 'Also applies to N more elements'
  block (label + excerpt per target); single-target output byte-identical
- share URLs: additional targets are dropped exactly like htmlAnchor (the
  compact tuple format never carried anchors); drafts carry them verbatim

* test(annotate): cover shift-click multi-select across bridge, DTO, composer, export, drafts, sharing

- srcdoc.test.ts (bridge DOM): shift-click add + toggle-off with echoed
  removals and per-target pinned boxes; create-mark commits all targets under
  one id with one badge number (second annotation numbers 2); primary
  promotion and last-removal cancel; parent remove-target mirrors without
  echo; flash-target; 16-target cap at the source; find-and-mark restores
  additionalAnchors as same-numbered pins with stale anchors failing closed
- htmlPinpointProtocol.test.tsx: multi-target-added/removed/pointer DTO
  validation (key/label/text/anchor caps, hostile payloads), selection
  targetKey/targetLabel validation; mounted-composer flows — primary chip,
  shift-adds into ONE submitted comment carrying htmlAnchor + 2 additional
  targets, single-target submit shape unchanged, promotion onto the comment,
  last-removal closes the composer, chip removal, 16-cap at the trust
  boundary, drag selections never arm multi-select
- CommentPopover.multiTarget.test.tsx: chips render primary-first with
  remove/hover handlers, refocusToken focus return, captureStrayKeys stray
  keydown routing (and non-interference when focused), yieldState classes +
  reduced-motion-aware 180ms style; default composer renders none of it
- composerYield.test.ts: distance + hysteresis state machine (48px over-exit,
  80/96px near enter/exit)
- parser.test.ts: multi-target export block (labels, excerpt clipping,
  fail-closed targets) and byte-identical single-target output
- useAnnotationDraft.seam.test.tsx: multi-target annotations round-trip the
  draft transport verbatim (save body + restoreDraft)
- sharing.multiTarget.test.ts: share tuples drop anchors AND additional
  targets while the comment itself still shares

* fix(annotate): keep pinpoint drafts alive when the pinned element scrolls out of view

Found by the real-browser signoff harness: reaching a second element to
shift-click often scrolls the pinned primary out of the viewport BEFORE any
additional target exists, and the scroll-out teardown then cleared the
bridge's pin state mid-compose — the shift-click landed on a dead draft and
started a new one instead of adding to it (and even a single-target pinpoint
draft silently lost its visual pin on commit after scrolling).

Pinpoint drafts (pendingPinViaPinpoint) now survive scroll-out; drag
selections keep the existing close-on-scroll-out behavior unchanged.

* fix(annotate): address adversarial review of multi-select (arm handshake, label sanitization, iframe shift relay, removal resync)

D1 (blocker): the bridge accepted shift-toggles for ANY pinpoint draft while
the parent only mirrors targets when the comment composer owns it — in
quickLabel mode the user could pin elements the saved annotation would never
carry. Multi-select is now ARMED EXPLICITLY: the parent posts
arm-multi-select (keyed to the primary, so a stale arm can never arm a new
draft) only from the composer flow, and the bridge refuses the toggle —
shift-click behaves as a plain click — until armed.

D2: target labels derive from page-controlled attributes (aria-label), so
newlines could smuggle real markdown structure (fake headings) into
agent-read feedback. parseTargetLabel now collapses all whitespace at the
trust boundary, and the exporter collapses again (defense in depth for
persisted pre-fix drafts).

D3: the composer yield armed Shift only from parent-window keydown/mousemove,
but window blur (focus entering the iframe) cleared it and modifier keydowns
don't reach the parent from the sandbox — from the second shift-click on,
the composer never yielded. The bridge pointer relay now carries the
observed shiftKey (validated strict boolean) and arms/disarms yield directly.

D4: a forged multi-target-removed desynced parent (promotes) from bridge
(keeps original). applyTargetRemoval now ALWAYS echoes remove-target —
idempotent for legit bridge-side removals, forcing convergence after forgery.

D5: the 'Also applies to N more elements' block gains a leading blank line so
markdown lazy continuation cannot fold it into the preceding blockquote.

D6: the stray-key guard registers in capture phase (a bubbling global
shortcut can no longer both fire and have its character appended), inserts at
the textarea's remembered caret instead of end-of-text, and refocusToken now
preserves the caret rather than jumping to the end.

D7: the expanded dialog no longer carries the dead yield class/style — its
data-comment-popover wrapper spans the viewport, making proximity
meaningless; the dialog deliberately does not yield.

Also reverts the incidental bun.lock version-catch-up churn.

Tests: unarmed/stale-arm refusal, quickLabel non-arming and non-mirroring,
newline-label collapse at both layers, bridge-shift-driven yield, forged
removal echo + bridge-side idempotent resync, caret-preserving stray keys,
blockquote separation. Signoff harness re-run green (14/14) on
rules-ui-signoff.html including the arm handshake.

* fix(annotate): reset multi-select arm on every new pinpoint draft (D1-R)

The re-review caught that multiSelectArmed was never cleared when
annotateElement started a fresh draft — only clearPendingPin reset it.
So a comment-mode draft (armed) followed by a mode switch the parent
doesn't mirror (quick label posts no arm) and a new pinpoint click left
the stale arm live: the bridge accepted shift-clicks and pinned elements
the saved annotation would never carry. Reset the flag at the top of
annotateElement alongside clearMultiTargets.

Regression test reproduces the exact sequence (armed draft -> new
unarmed draft -> shift-click must not add a target); mutation-verified
that removing only this reset fails it.

* docs(annotate): correct the first-keystroke guard comment reasoning

The comment claimed capture phase prevents a global shortcut from also
firing; preventDefault does not stop the dispatcher (it ignores
defaultPrevented by design). Restate the actual invariant: the guard is
safe only because no bare printable single-key binding exists on this
surface, and flag that as a constraint for future bindings. Comment only.
2026-08-10 10:19:21 -07:00
Mehdi 696bf1f24e fix(vim): preserve plan position on refocus (#1252) 2026-08-10 10:09:55 -07:00
Raúl 183cae2c44 fix(vim): keep j/k cursor clear of HUD bands when scrolling (#1154)
Document Vim navigation moved the cursor with
`Element.scrollIntoView({ block: 'nearest' })`, which parks the target
flush against the nearest viewport edge — exactly where the sticky action
bar (top) and the key HUD / status pill (bottom) float. Motion to the top
or bottom of a document then hid the caret behind an overlay, while a
mouse wheel (which the browser lets overshoot) kept the same line nearer
centre.

Add vimScroll.ts: a pure computeVimScrollDelta returning the signed
scrollTop delta needed to clear a HUD band at each edge (0 when the
target is already inside the safe band; a target taller than the band
aligns to its top edge so reading order wins), resolveVimScrollMargin
sizing the fallback band as clamp(20% of viewport height, 24px..160px),
and a scrollVimTargetIntoView wrapper.

Both bands are measured from live geometry instead of guessed constants:
the top band widens past the ratio margin to clear the sticky action
bar, and the bottom band derives from the portaled key HUD / mode pill
rects ([data-vim-key-hud] / [data-vim-mode-badge]). The opt-in key HUD
(fixed bottom: 150, height: 88 — a ~238px band, past the 160px clamp) is
actually cleared, while the default pill reserves ~49px instead of
over-reserving 160px. An expanded key HUD is a deliberate modal state
that can outgrow the viewport, so it keeps the ratio margin.

The scrolling element is the native-scroll host fed through
ScrollViewportContext, so the wrapper takes that element from the caller
(useScrollViewport() in Viewer, the same node the reticle measures
against) and falls back to the historical scrollIntoView when it is
absent, so behaviour never regresses.

Route every cursor/target move in useVimSelection through it, and add
vimScroll.test.ts to the DOM allowlist in test.yml so its integration
tests run in CI.
2026-08-09 16:16:59 -07:00
Michael Ramos ffd49080ee fix(skills): harden skill references before first release (#1235) 2026-08-07 14:22:58 -07:00
Michael Ramos 7ad4d39ed9 feat(comments): reference agent skills with / or $ in plan review and annotate comments (#1229)
* feat(comments): reference agent skills with / or $ in plan and annotate comments

Typing / or $ at the start of a word in the document-UI comment composer
opens a picker of the user's global agent skills (~/.claude/skills,
~/.codex/skills, ~/.agents skills roots), served by a new GET /api/skills
on the plan and annotate servers in both runtimes (Bun + Pi mirror).
Multiple references per comment are supported; references live in the
comment text itself and are appended to exported feedback as a
'Skills referenced' block so the acting agent knows which skills to apply.

Human-invocation-only skills (disable-model-invocation: true frontmatter)
stay listed and selectable but render dimmed with a badge, warn in the
menu and composer, and are marked in the export so the agent is never
asked to invoke something it cannot.

Discovery reuses the review-skill loader (same roots, precedence, and
skip-and-log discipline), reads only an 8KB head per SKILL.md, caps the
catalog at 500 skills, takes no client input, and is never persisted;
any failure degrades to plain typing.

* fix(comments): harden skill references per review (trigger, IME, seam, fail-closed frontmatter)

Blockers:
- B1: a trigger now requires at least one query character. A bare / or $
  no longer opens the catalog, so Enter stays a newline and Tab still
  leaves the field ("This costs $" + Enter, "cd /" + Tab, bullets).
- B2: the menu ignores keys mid-IME-composition (nativeEvent.isComposing),
  matching the 16 existing guards; Enter committing a Pinyin/Telex/Korean
  candidate can no longer insert a skill.
- B3: the catalog request is a host seam (skillCatalogTransport via
  configurePlannotatorUI), defaulting to the existing GET /api/skills.
- B4: resetSkillCatalogCache() invalidates outstanding requests
  (generation counter), and a late-resolving stale request can no longer
  overwrite a newer cached value or the export registry. The catalog
  tests reset in beforeEach, so they hold in any file order.

Also:
- F1: skillReferences={false} is fully inert — the human-only notice memo
  and the cache seed are gated on the prop.
- F4: frontmatter flag parsing no longer fails open: trailing YAML
  comments are stripped, on/1 (and TRUE/yes etc.) read as true, the head
  read is 64KB, and truncated unterminated frontmatter fails CLOSED on
  disable-model-invocation.
- F5: extraction ignores markdown link destinations ([x](/name)), shell
  redirects (cat /x > out), and /-triggered FHS root names (/run, /tmp);
  menu insertion switches / to $ for those names so inserted references
  always survive extraction.
- F6: the 500-skill cap slices after sorting, so which skills survive no
  longer depends on readdir order.
- F3: /api/skills wiring guards for the Bun and Pi plan + annotate
  servers (skills-endpoint.test.ts).
- Keyboard state machine tests against the real CommentPopover in
  happy-dom (bare trigger, insertion, composition, Escape, highlight
  bounding, opt-out inertness), added to the CI DOM step.
- The insertion path dismisses the trigger start so the menu close is
  ordering-safe against React's select-plugin re-reading a stale caret.

* feat(comments): redesign the skill reference menu (bare triggers, no preselection, highlighted tokens)

Per maintainer direction, reversing the earlier bare-trigger opt-out
deliberately: typing a bare / or $ at the start of a word now opens the
full skill catalog immediately, and the safety story moves from the
trigger to the menu itself.

No preselection (the load-bearing rule): the menu opens with NO row
active, and while nothing is active every key behaves exactly as if the
menu were closed. "This costs $" + Enter is a newline; "cd /" + Tab
leaves the field (the proven regression that must never return). A row
activates only via ArrowDown/ArrowUp (Down from none lands on the first
row, Up on the last); only then do Enter/Tab insert. Pointer hover never
activates a row, because the menu floats exactly where the mouse rests
over the composer; a click inserts directly and never arms Enter.
Continuing to type re-filters and disarms any active row. Escape clears
the active row and dismisses when the user engaged (query typed or row
active); an unengaged bare-trigger menu passes Escape through so closing
the composer still costs one press.

Menu redesign to the reference look: icon, bold name, dimmed inline
description with ellipsis, right-aligned source column (Agents / Claude
/ Codex from the discovery roots), rounded generously padded rows, and a
subtle active-row background; human-only rows stay dimmed with their
badge and the warning now shows while such a row is ACTIVE.

Inserted references render highlighted in the composer via a mirrored
aria-hidden overlay behind a transparent-text textarea (identical font,
padding and wrapping metrics; scroll synced; tokens change color and
background only, drawn from the --primary theme token so every palette
works in light and dark). The caret keeps --foreground, selection uses a
translucent primary wash, and IME composition temporarily restores
native textarea text so composition underlines render normally.
skillReferences={false} still renders the plain pre-feature textarea.

Also, per review:
- extraction: dropped the over-broad shell-redirect exclusion (false
  negatives on prose like "use /animate <- this one"; the motivating
  case stays covered by the reserved-path rule)
- frontmatter: an unterminated frontmatter block now fails CLOSED on
  disable-model-invocation even in complete (untruncated) files
- the reserved-path / to $ insertion switch stays: extraction still
  reads /run as a path, and the new token highlight makes the switch
  self-explanatory (an unhighlighted insert would look broken)

The composition guard, transport seam, catalog generation counter,
enabled gating, and export rules are unchanged and re-covered by the
rewritten DOM test matrix.

* fix(comments): give the skill reference menu adaptive, viewport-clamped placement

The menu rendered bottom-full with a fixed max-h-64: always upward, up to
256px, with no viewport awareness. With the comment popover near the top of
the viewport (annotating near the top of a document), typing a trigger ran
the menu off the top of the screen with its upper rows unreachable.

Placement now mirrors the popover's own computePosition idiom: measure the
space above and below the composer wrapper against window.innerHeight,
prefer above (the shipped direction; keeps the action row and human-only
notice visible), flip below when the list fits below but not above, and when
neither side fits pick the roomier side. The list's max height is clamped to
the available space (still capped at the former 256px), so the menu never
extends past a viewport edge. Recomputes on every commit (drag moves,
popover flips, filtering changing the item count, warning-footer toggles)
plus capture-phase scroll and resize listeners, matching the popover's
tracking. Visual design of the menu and rows is unchanged.

* feat(comments): inject human-only skill instructions into exported feedback

A human-only skill (disable-model-invocation: true) referenced in a review
comment used to export as a dead name the agent could do nothing with. A
human referencing a human-only skill IS the human invocation, so the export
now injects the skill's SKILL.md body verbatim (frontmatter stripped) inside
clearly delimited BEGIN/END SKILL INSTRUCTIONS markers, with the absolute
skill directory and the resolve-relative-paths pointer so references/,
scripts/, and assets/ stay actionable. Model-invocable skills keep exporting
as names the agent can invoke itself.

Transport is lazy: a new GET /api/skills/content?name= endpoint (Bun and Pi)
serves one discovered skill's body, capped at 20k chars with an explicit
truncation notice pointing at the file; the client fetches contents only for
the human-only skills actually referenced, keyed off comment state, and the
catalog now carries each skill's absolute dir so every failure path (deleted
skill, unreadable file, race with submit) degrades to naming the skill plus
its directory. Names are matched against discovery only and never used as
paths, so traversal cannot escape the skill roots. A per-export dedupe
injects each skill once even when several comments reference it, and
GLOBAL_COMMENT annotations run through the same block.

The referenced-skills header now says the reviewer is asking for the
invocation, and the human-only menu footer and composer notice explain that
the skill's instructions will be included with the feedback instead of
warning that the reference will not work.

* polish(comments): quiet, progressive human-only skill treatment

The human-only surfaces shipped with too much emphasis: a dimmed row plus
a bordered uppercase badge, an amber warning footer, and a persistent
amber notice in the composer after insertion. Human-only is a property of
a skill, not an error state, so the treatment is now quiet and
progressively disclosed:

- Menu rows render at full strength with a small muted 'human-only' pill
  (bg-muted / muted-foreground tokens; no border, no dimming).
- The plain-language explanation (a model cannot invoke it, so its
  instructions will be included with your feedback) appears as a muted
  footer only while a human-only row is active (keyboard) or hovered
  (pointer). Hover disclosure is purely visual state local to the menu;
  it never touches activeIndex, so the no-preselection invariant and the
  hover-never-arms-Enter rule are unchanged and re-asserted by a new test.
- When not disclosed, the same sentence stays in the DOM sr-only and
  human-only rows point at it with aria-describedby, so the state reaches
  assistive tech as text rather than as a purely visual badge (this does
  not attempt the #1233 combobox semantics, and does not worsen them).
- After insertion, the highlighted token itself carries the quiet inline
  marker (a dotted primary underline; text-decoration cannot move glyphs,
  so overlay alignment is untouched) and the standing amber notice is
  replaced by a native <details> disclosure: a single muted 'Includes
  skill instructions' summary line that expands to the full accurate
  sentence, operable by pointer, keyboard, and AT alike.

No amber remains; every color is a theme token (muted, muted-foreground,
border, primary, ring), so the treatment follows every palette in light
and dark. Copy is unchanged where it was accurate. Behavior is unchanged:
human-only skills stay selectable and injection still happens.

* fix(comments): harden human-only skill injection per adversarial review

Three findings on the injection path, each with tests that fail pre-fix:

1. Marker forgery: an injected SKILL.md body containing our own
   `--- BEGIN/END SKILL INSTRUCTIONS ---` markers (or an
   `[Instructions truncated:` notice) could close the block early — making
   everything after it read as the reviewer's own words — forge a block for
   a skill nobody referenced, or forge a truncation notice pointing at an
   attacker-chosen path. Body lines matching the structural marker forms
   (leading-whitespace and case variants included) are now visibly
   neutralized before injection: kept verbatim but prefixed, never silently
   deleted (neutralizeSkillMarkerLines).

2. Forged human invocation: POST /api/external-annotations is
   unauthenticated on localhost, so any local process could submit a
   comment referencing a human-only skill and cause its instructions to be
   injected "at the reviewer's request". Annotations carrying a `source`
   now still LIST their skill references but never cause verbatim
   injection — human-only references fall back to naming the skill plus
   its directory, with an honest reason. The content-prime effect skips
   external texts for the same reason. A human referencing a human-only
   skill IS the human invocation; a tool is not.

3. Unbounded read: readReferenceSkillContent read the whole SKILL.md
   before slicing to the 20k cap, so an unauthenticated no-cors fetch loop
   could balloon RSS by file size per request (measured +64.4MB for a 64MB
   file). It now uses the same bounded readFileHead as the catalog,
   reading only frontmatter allowance + 4 bytes per capped char + slack;
   truncation detection is unchanged for any file whose frontmatter fits
   the catalog bound, and frontmatter that overflows the read falls back
   to null rather than serving raw YAML. Measured: 12 reads of a 64MB
   SKILL.md now cost +5.1MB total.

Also: the fast-fail guard no longer rejects legitimately discovered names —
`name.includes("..")` 404'd a real `v1..2` skill dir forever (and `\` is
legal in POSIX names) while defending nothing, since the name is only ever
matched against discovery output and never joined into a path. It now
rejects exactly the names that can never be a readdir entry: empty, `.`,
`..`.
2026-08-07 09:50:42 -07:00
Michael Ramos c08b188812 perf(ui): single Shiki highlighter, palette-matched code blocks, drop highlight.js (#1218)
* perf(build): stub out the dead Oniguruma WASM in every bundle

@pierre/diffs picks its Shiki engine with a runtime ternary:

    engine: preferredHighlighter === "shiki-wasm"
      ? createOnigurumaEngine(import("shiki/wasm"))
      : createJavaScriptRegexEngine()

Plannotator pins `preferredHighlighter: 'shiki-js'` (and Pierre's own
default is 'shiki-js'), so the Oniguruma branch never executes. Because
the choice is a runtime ternary, bundlers keep the `import("shiki/wasm")`
edge anyway and inline `@shikijs/engine-oniguruma/wasm-inlined`, a
~622 KB base64 blob, into the single-file HTML builds. The review app
paid for it twice: once on the main thread (via
`highlighter/shared_highlighter.js`) and once inside the `?worker&inline`
Pierre worker.

Alias `shiki/wasm` to a stub that throws if it is ever reached. Wired via
`resolve.alias` rather than a plugin because `resolve.alias` is shared
with Vite's worker build and `plugins` are not.

Highlighting output is unchanged: the JS regex engine was already the one
doing the work. Opting back into 'shiki-wasm' now fails loudly instead of
silently costing every user a megabyte of dead bytes.

    apps/review/dist/index.html  19,424,646 -> 18,180,545  (-1,244,101 raw / -463,348 gzip)
    apps/hook/dist/index.html    23,032,467 -> 22,410,416    (-622,051 raw / -233,485 gzip)

* perf(ui): consolidate code highlighting onto Shiki, drop highlight.js

The app shipped two highlighters. Shiki already tokenised the code-review
diff pane (via @pierre/diffs, JavaScript regex engine); highlight.js
separately coloured markdown fences and review suggestion snippets at
~982 KB minified for a full build of ~190 grammars. That second
highlighter is now gone.

Every call site moves onto `packages/ui/utils/codeHighlight.ts`, a thin
wrapper over Pierre's SHARED Shiki instance:

  CodeBlock, Viewer, PlanCleanDiffView   markdown fences
  InlineMarkdown                          code-file hover preview
  HighlightedCode                         review suggestion snippets

Reusing Pierre's instance rather than standing up a second fine-grained
one is deliberate. Pierre imports Shiki's full bundle, so every grammar
and theme is ALREADY inlined in the single-file builds: a separate
highlighter with a curated language list would have duplicated a subset
of bytes that are already there. Sharing costs nothing, gives every
language Shiki bundles instead of a shortlist, and — the point of the
change — guarantees fences resolve the exact same theme the diff pane
resolves.

Theming. `SHIKI_THEME_MAP` / `resolveSyntaxTheme` move from
`packages/review-editor/hooks/usePierreTheme.ts` to
`packages/ui/utils/syntaxTheme.ts`; usePierreTheme re-exports them, so
the review editor's imports are unchanged. `useFenceTheme()` feeds the
components and re-highlights on palette or mode change. Code blocks now
follow the active palette across all ~52 themes in both light and dark,
instead of always rendering github-dark and relying on hand-written
`.hljs-*` override stacks to stay legible. Those stacks are deleted:
`packages/editor/index.css`'s light-mode token palette, and
`colorblind.css`'s hand-tuned tokens which existed to APPROXIMATE
@pierre/theme's protanopia-deuteranopia themes that are now simply used.

Behaviour held fixed:

  - Language-less fences stay plain text (#1212). No auto-detection
    anywhere, including the hover preview, which previously called
    `hljs.highlightAuto`. `HighlightedCode` derives its language from
    the caller's file path; an unknown extension renders plain.
  - `applyHighlight(el, ...)` keeps the imperative `hljs.highlightElement`
    DOM contract the annotation layer reaches into, and writes plain text
    at final size first so async highlighting causes no layout shift.
    Already-attached grammars highlight synchronously — no flicker on
    cached highlights.
  - It also verifies the rendered text is byte-identical to the source
    and falls back to plain otherwise, because annotations address code
    blocks by text offset.
  - `@plannotator/ui`'s public API is unchanged: the highlighter is a
    module-level default like the package's other seams, no new props.

The `hljs` class on fenced `<code>` becomes `pn-code` (it is a
structural hook for blockTargeting, vim navigation and print.css, and it
named a library we no longer ship). `language-*` stays.

    apps/review/dist/index.html  18,180,545 -> 17,270,889  (-909,656 raw / -291,921 gzip)
    apps/hook/dist/index.html    22,410,416 -> 21,704,434  (-705,982 raw / -238,096 gzip)

Verified the diff pane is untouched: the rendered Pierre shadow-DOM
markup is byte-for-byte identical between an origin/main build and this
one (SHA-256 aa1ee88a…).

* fix(ui): strip stray NUL bytes from the code-highlight source

Two U+0000 bytes slipped into comments in the previous commit, which made
git treat the file as binary. Replaced with spaces; no behaviour change.

* fix(ui): keep code-block annotation marks across highlight swaps

Fenced code is annotated by hand: one `<mark data-bind-id>` inside the
`<code>` element, which `applyHighlight` also owns. Every highlight swap
(palette change, dark/light toggle, or the first async grammar attach
after load) replaces that element's children, so the mark was silently
wiped and nothing put it back. Annotation state, the sidebar panel and
exports were unaffected; the loss was purely visual, and deterministic.

`applyHighlight` now publishes every write through `onCodeHighlightSwap`,
synchronously, immediately after it. `Viewer` subscribes and re-paints the
fence's mark, so a swapped block ends up with BOTH the new theme's tokens
and its annotation. The shared painter (`paintCodeBlockMark`) moves the
token spans into the mark instead of flattening them to text, so creating
an annotation no longer costs a block its colours either.

Being driven by the swap also fixes the cousin race by ordering rather
than timing: share/draft restore runs on a timer after load, and on a slow
machine the first async swap could land after it and wipe the restored
marks per block. A restore that painted before the swap is now
re-established in the same task the swap ran in, and one that runs after
finds the mark already there.

Removal tombstones the id before re-highlighting, because the host drops
the annotation from state a tick later — without it the swap listener
would paint the just-removed annotation back in, and a fence carrying a
second annotation would end up bare.

Also closes the named gap in the WASM coverage: entry-assets only grepped
source, so a future @pierre/diffs bump could reintroduce the inlined blob
through a different import specifier unnoticed. It now greps the built
`apps/{review,hook}/dist/index.html` for the base64 WASM magic, skipping
on an unbuilt checkout and running for real in the CI job that builds the
bundles.
2026-08-05 21:54:40 -07:00
Michael Ramos 463f6ed57c fix(ui): fall back to legacy copy in insecure browser contexts (#1174)
* fix(ui): fall back to legacy copy in insecure browser contexts

navigator.clipboard only exists in secure contexts. Remote mode serves
plain HTTP on a non-localhost host, so every bare
navigator.clipboard.writeText call threw TypeError and copy buttons
silently broke.

Add copyTextToClipboard(text): Promise<boolean> to
packages/ui/utils/clipboard.ts: it tries the async Clipboard API
(guarded against synchronous throws), falls back to the existing
copy-event plus execCommand path, reports success as a boolean, and
never throws. copyTextWithFallback now returns whether the copy
happened and accepts an optional focusOwner; copyTextPreservingFocus
keeps its exported signature and behavior unchanged.

Route all bare call sites through the helper, preserving each site's
UX: Copied states only flip on success, error toasts and console
errors remain for the failure case, fire-and-forget sites stay
fire-and-forget. GoalSetupSurface gains the fallback and keeps its
error surface for the all-strategies-failed case.

Add DOM-gated unit tests for the helper and register them in CI.

Closes #1173

* fix(ui): address clipboard fallback review findings

Review follow-ups for the insecure-context clipboard fallback:

1. Tag the fallback textarea with data-clipboard-fallback and whitelist
   it in PopoutDialog's ANNOTATION_SELECTORS so the transient focus
   shift during a fallback copy no longer closes popout dialogs
   (TablePopout copy buttons, CodeFilePopout copy contents).

2. Widen AnnotationPanel's onQuickCopy prop to Promise<void | boolean>.
   A false resolution now suppresses the Copied flash; void resolution
   stays success so existing hosts keep today's behavior. The editor
   quick-copy site returns the helper's boolean.

3. In copyTextWithFallback, only flag the copy-event path as success
   when clipboardData was present and setData actually ran, and only
   when execCommand also reported success. A null clipboardData no
   longer calls preventDefault, so the textarea retry still runs.

4. Add tests pinning that the fallback runs synchronously when
   navigator.clipboard is absent (execCommand fires before the call
   returns, keeping it inside the user-gesture window), that a copy
   event without clipboardData is not treated as success, and that the
   fallback textarea carries the PopoutDialog focus-out marker and is
   removed after the copy resolves.

5. GoalSetupSurface surfaces the real writeText rejection message when
   the Clipboard API exists but fails and the fallback also fails; the
   generic unavailable message is reserved for the API-absent case.
2026-08-03 10:14:34 -07:00
Raúl 37acde15d5 fix(ai): defer Codex model discovery until a Codex session starts (#1145)
* fix(ai): defer Codex model discovery until a Codex session starts

Opening any plan, annotate, or code review builds the shared AI runtime, and the
runtime called every provider's fetchModels() while constructing itself. For the
Codex provider that starts a throwaway `codex app-server` process, so a review
launched Codex even when the user never opened Ask AI. On macOS with a
quarantined Homebrew Codex payload this surfaces as a Gatekeeper confirmation
dialog in front of the review the user actually asked for.

Codex discovery now runs on explicit activation instead. The provider is still
registered and still advertised through /api/ai/capabilities using its static
fallback model metadata, so nothing about discovery is user-visible until a
session is created for it. createBestEffortOnce() memoizes the discovery call so
it runs at most once per runtime and a failure leaves the static fallback in
place rather than blocking session creation.

/api/ai/session gained a beforeProviderSession hook, invoked for the resolved
provider id before the session is created. /api/ai/capabilities deliberately
does not invoke it: the editor probes capabilities automatically on load, so
activating a provider there would reintroduce the same eager launch through a
different path.

Because discovery can replace the provider's model list, the session handler
compares the requested model against the pre-activation default. A caller that
sent no model, or sent the pre-activation default, gets the post-activation
default; an explicitly chosen model is always honored. Without this a first
Codex session would pin the static fallback model that discovery just replaced.

Both runtimes are changed the same way, and the other providers keep their
existing eager discovery, which beforeCapabilities still awaits.

Tests cover the regression with a fake Codex executable rather than a real one:
runtime construction and a capabilities probe must not invoke discovery, the
first Codex session must, the second must not, and a failing discovery must
still create a session on the fallback metadata.

* fix(ai): refresh provider metadata on explicit activation

Follow-up to the deferred Codex discovery change, addressing the review
findings on #1145 while keeping the deferral intact: constructing the
runtime and probing /api/ai/capabilities still never spawns
`codex app-server`.

- /api/ai/capabilities now accepts ?activate=<providerId>: it runs the
  same createBestEffortOnce initializer the session path uses (no second
  discovery path) and responds with the refreshed capabilities payload.
  A plain capabilities probe still activates nothing. Both runtimes get
  this through the shared endpoint (packages/ai is vendored into the Pi
  server by vendor.sh).
- The apps activate the selected provider on explicit user gestures --
  opening the Ask AI surface or switching the provider picker -- via the
  new useAIProviderActivation hook (single-flight per provider id), then
  merge the refreshed models and reasoning efforts into state so the
  model picker and per-model reasoning-effort selector populate past the
  static fallback. (review finding 1)
- A resolver-derived model is no longer persisted: useAIProviderConfig
  and AISettingsTab write the per-provider model preference only on an
  explicit user pick, so a saved Codex model the pre-activation fallback
  list doesn't include survives instead of being clobbered by the
  fallback id. The session request still falls back; the cookie doesn't.
  (review finding 2)
- The session handler resolves the requested model by membership in the
  post-activation model list instead of comparing against the
  pre-activation default, so sessions after the first can no longer pin
  a stale fallback id that discovery already replaced. (review finding 3)

Tests: activation endpoint behavior (shared endpoints plus both runtimes
against a hermetic fake codex on PATH), effectiveModel membership
resolution, and saved-preference no-clobber (DOM tests for
useAIProviderConfig persistence).

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

---------

Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-07-29 23:36:47 -07:00
Michael Ramos b752080204 feat(ai): add Claude Opus 5 across providers and review agents (#1151)
* feat(ai): add Claude Opus 5 to the model catalogs

Adds `claude-opus-5` to both static Claude model lists:

- `packages/ai/providers/claude-agent-sdk.ts` — the Ask AI picker
- `packages/ui/components/AgentsTab.tsx` — the review-agent picker, which
  `TOUR_CLAUDE_MODELS` spreads, so tour and guide pick it up too

No `claude-opus-5[1m]` variant: the `[1m]` suffix opts the 4.x models into
the larger context window, and the 5-series models are already 1M by default
— which is why the existing `claude-fable-5` and `claude-sonnet-5` entries
carry no variant either. A test pins that invariant.

Guide, tour, and marker review need no change — they pass the picked model
straight through to the CLI with no server-side allowlist.

Tests cover the Bedrock/Vertex family matcher accepting the new bare alias
(a matcher keyed on "opus-4" would have silently dropped it) and pin the
existing Fable fallthrough, which has no ANTHROPIC_DEFAULT_*_MODEL of its own.

Also refreshes the marketing Ask AI model list, which had gone stale at
Sonnet 4.6 / Opus 4.6 / Haiku 4.5.

* feat(ai): default Claude review agents to Opus 5

Bumps the two Claude review-agent defaults from Opus 4.7 to Opus 5:

- `DEFAULT_CLAUDE_MODEL` (packages/ui/hooks/useAgentSettings.ts) — the
  review-agent picker's default for users with no saved cookie preference
- `buildClaudeCommand`'s default parameter (packages/server/claude-review.ts)
  — the server-side fallback when a job arrives with no model

BEHAVIOR CHANGE: users who have never picked a Claude review model move from
Opus 4.7 to Opus 5. Saved picks are untouched — the cookie already holds an
explicit model for anyone who has chosen one.

Kept deliberately separate from the catalog commit so it can be reverted on
its own if the maintainer prefers to ship the new models as options only.

Ask AI's default (`DEFAULT_MODEL` in claude-agent-sdk.ts) stays on Sonnet 5,
and the tour/guide defaults stay on the `sonnet` latest-alias — those are
interactive, latency-sensitive surfaces where the cheaper tier is the
intentional choice.
2026-07-29 08:51:33 -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
Ben Newman 6b542da8b9 feat(annotate): extend per-file version diff to folder sessions (#1105)
* refactor(annotate): extract per-file version history into a shared helper

Move the single-file annotate-history pipeline (slug derivation,
saveToHistory, previous-version lookup, degrade-on-error) out of the
Bun-specific annotate server and into packages/shared/annotate-history.ts,
built on node:fs/node:path/node:crypto only so other runtimes can vendor it
unmodified.

annotate.ts now calls computeAnnotateHistory() instead of inlining the
pipeline; behavior for single-file sessions is unchanged.

* feat(annotate): extend per-file version history to folder annotate sessions

Eligible folder files served through /api/doc now get snapshotted into the
same version history the single-file flow uses, and their doc responses
carry the same previousPlan/versionInfo/diffCurrent fields /api/plan already
returns for single-file sessions. The pipeline runs lazily on first open and
is memoized per resolved absolute path for the life of the server, so
reopening a file never re-snapshots it.

Eligibility mirrors the single-file source-save gates: a local file under
the session's folder root, markdown-branch documents only (.md/.txt, not
HTML, not a Turndown-converted doc), under the existing 2MB annotatable-file
cap, and gated by the same annotateHistory config toggle. Storage failures
degrade to a plain render (never a gate on the request) via the same
try/catch computeAnnotateHistory already wraps.

/api/plan/version and /api/plan/versions gain an optional path (+ base)
query param so folder sessions can ask for a specific file's history; the
slug is always derived server-side from the resolved, containment-checked
path — never accepted from the client, since it gets joined unsanitized
into a filesystem path. Omitting path keeps today's single-session-binding
behavior unchanged.

* test(annotate): cover folder annotate version history

Adds a new describe block exercising the folder-mode history pipeline added
in the previous commit: first-open snapshot + same-session memoization,
storage-level dedupe, cross-mode slug continuity with the single-file flow,
first-ever-open field shape, the config toggle, an ineligible (HTML) file
type, degrade-on-unwritable-history-dir, and the path-parameterized version
endpoints (including containment rejection and the no-path fallback).

* feat(ui): add a docKey seam to usePlanDiff for per-document resets

usePlanDiff's diff-base state (diffBasePlan, diffBaseVersion, versions, ...)
was seeded once from its constructor args and only ever synced later via a
"still falsy" guard - fine for a single root document, but switching to a
different document (a different previousPlan/versionInfo) would silently
keep the previous document's diff base around instead of adopting the new
one's.

Add an optional docKey param identifying which document the current
previousPlan/versionInfo belong to. When it changes between renders, reset
diffBasePlan/diffBaseVersion/versions (and in-flight loading/selecting
flags) to the newly-provided values. Omitting docKey (or keeping it stable)
preserves exactly today's one-time-hydration behavior, so the root
document's call site is unaffected until it opts in.

No caller passes docKey yet - this is purely additive.

* feat(ui): carry a per-document version-diff baseline through useLinkedDoc

/api/doc now returns previousPlan/versionInfo/diffCurrent for eligible
folder files (same shape /api/plan already returns for single-file
sessions). Extend LinkedDocLoadData with those fields and carry them
through the same activate/cache/back lifecycle annotations and markdown
already use, so a document's diff baseline:

- is captured once when the document is first opened
- persists in the per-filepath cache across back()/re-open, instead of
  being lost or needing a re-fetch
- resolves cache-first via the new resolveDiffBaseline helper, gated on
  whether a baseline was ever captured (versionInfo presence) rather than
  truthiness of previousPlan - a document at its first-ever version
  legitimately caches previousPlan: null, which is a resolved fact, not a
  cache miss

The hook exposes the active document's baseline as diffPreviousPlan/
diffVersionInfo, both null when no document is active or the active one has
no eligible history (every non-folder linked doc, since /api/doc never
populates these fields for those).

Not yet consumed by App.tsx - purely additive.

* feat(editor): render folder-doc version diffs via the active document

Folder annotate's version-diff UI (inline PlanDiffViewer blocks, the +N/-M
badge, and the Version Browser) was root-document-coupled: usePlanDiff was
fed only the root's previousPlan/versionInfo, and every render site keyed
off linkedDocHook.isActive to blank out the badge/version tab whenever any
linked or folder document was open.

Wire the two new per-document seams together instead:
- Feed usePlanDiff the active document's own previousPlan/versionInfo/
  filepath (falling back to the root document's when none is active), using
  the document's filepath as usePlanDiff's new docKey so switching documents
  resets the diff base instead of inheriting the previous one's.
- Add per-document fetchers (fetchVersion/fetchVersions with
  &path=<filepath>) so selecting a base version or listing versions targets
  the active document's own history, not the session-bound bare endpoints.
- Replace the root-only versionInfo/showVersionsTab reads with the active
  document's, so the Version Browser now reflects whichever document is on
  screen (previously it kept showing the root document's versions while a
  linked doc was open).
- Drop the blanket "linkedDocHook.isActive ? null/false : ..." suppression
  at the Viewer callsite and in DocBadges - planDiffStats/hasPreviousVersion
  already resolve to the active document's own (possibly absent) diff data,
  so the badge now shows for folder docs with history and stays hidden for
  every other document exactly as it did before.

Root-document behavior (single-file, plan, review, HTML surfaces) is
unaffected: none of those ever set a docKey or have an eligible document
history, so they fall through to the same defaults as before.

* feat(pi): extend per-file version history to folder annotate sessions

Mirrors the Bun runtime's folder annotate history support
(packages/server/annotate.ts + reference-handlers.ts) in the Pi Node server:

- Vendor the shared annotate-history helper (deriveAnnotateHistorySlug,
  computeAnnotateHistory) from packages/shared into generated/ via
  vendor.sh, and delegate the single-file version-history pipeline in
  serverAnnotate.ts to it instead of the hand-duplicated inline block.
  Behavior for single-file sessions is unchanged.
- Eligible folder files served through /api/doc now get snapshotted into
  the same version history the single-file flow uses, and their doc
  responses carry the same previousPlan/versionInfo/diffCurrent fields
  /api/plan already returns. The pipeline runs lazily on first open and
  is memoized per resolved absolute path for the life of the server, so
  reopening a file never re-snapshots it.
- /api/plan/version and /api/plan/versions gain an optional path
  (+ base) query param so folder sessions can ask for a specific file's
  history; the slug is always derived server-side from the resolved,
  containment-checked path (resolveAllowedDocPath in reference.ts) —
  never accepted from the client.

* test(pi): cover folder annotate version history

Adds apps/pi-extension/server/annotate-history.test.ts, the Node mirror of
packages/server/annotate.test.ts's folder-history describe block: first-open
snapshot + same-session memoization, cross-mode slug continuity with the
single-file flow, the config toggle, an ineligible (HTML) file type,
degrade-on-unwritable-history-dir, and the path-parameterized version
endpoints (including containment rejection and the no-path fallback).

History writes land in the real ~/.plannotator data dir rather than a
per-test PLANNOTATOR_DATA_DIR override: generated/storage.js caches its
data directory in a module-level constant at first import, so a per-test
env var override taken after that point silently no-ops. Each test uses
its own unique project namespace instead, same approach as the Bun-side
suite.

* ci: run docKey/linked-doc DOM tests in CI

usePlanDiff.test.tsx and useLinkedDoc.test.tsx use the test.skipIf(!hasDom)
pattern but were never added to the DOM_TESTS step, so they silently
skipped under CI's plain `bun test` and never actually ran.

* refactor(annotate): drop diffCurrent from the folder /api/doc path

diffCurrent equals the document's own markdown and the client never reads
it off /api/doc — it only exists on /api/plan for legacy single-file
shape parity, which is untouched. Stop merging it into folder /api/doc
responses and stop retaining it in the per-launch folder history memo
(Bun and Pi), and drop the now-unused field from LinkedDocLoadData.

- packages/server/reference-handlers.ts: new FolderAnnotateHistory type
  (AnnotateHistoryResult minus diffCurrent); applyDocOptions no longer
  copies diffCurrent onto the response
- packages/server/annotate.ts: the folder memo now stores/returns only
  slug/previousPlan/versionInfo
- apps/pi-extension/server/reference.ts + serverAnnotate.ts: mirrored
  changes for the Pi runtime
- packages/ui/hooks/useLinkedDoc.ts: removed the unused diffCurrent field
  from LinkedDocLoadData

* test(annotate): stop leaking history dirs; update diffCurrent expectations

The folder annotate history tests (Bun and Pi) minted a fresh project
namespace per test but never cleaned up, leaving hundreds of directories
under the real ~/.plannotator/history over repeated runs. Track every
minted project and remove its history directory in afterAll — this also
covers the stray non-directory artifact the "unwritable data dir" test
deliberately plants inside its own project's history dir, since removing
the project dir recursively takes it with it.

Also update the two assertions that expected diffCurrent on the folder
/api/doc response: that field is no longer propagated on the folder path
(see the preceding diffCurrent-removal commit), so both now assert its
absence instead.

* fix(ui): remember per-document diff-base selection across navigation

usePlanDiff reset diffBasePlan/diffBaseVersion to the newly-provided
document's defaults on every docKey change. That discarded a manually
selected base version when navigating away from a document and back
(e.g. root -> linked doc -> root), regressing behavior upstream relied
on keeping (nothing reset the selection before this seam existed).

Track each docKey's selection in a ref-held Map (keyed by docKey,
including null for the root document) and restore it on return instead
of re-seeding defaults; a key visited for the first time still seeds
from its own initialPreviousPlan/versionInfo exactly as before, and
selections never leak between distinct keys.

Adds two DOM-gated tests: restoring a manual selection after a detour to
another document, and confirming distinct docKeys don't leak into each
other.

* fix(annotate): match folder history eligibility to the single-file plain-text set

The folder /api/doc history gate was a hardcoded /\.(md|txt)$/i in both
runtimes, so any other annotatable plain-text file (.mdx, .yaml, .json,
.toml, ...) opened via a folder session silently skipped snapshotting —
breaking the cross-mode continuity this feature advertises (a .yaml with an
existing single-file version thread showed no diff when opened via its
folder).

Reuse the canonical predicate instead: isAnnotatableTextPath
(ANNOTATABLE_TEXT_REGEX in @plannotator/core/annotatable), the exact set the
single-file pipeline snapshots. HTML stays deferred and .env stays excluded,
both by that same definition. Tests extended in both runtimes: .mdx mints on
first open, .yaml single-file history serves as the folder baseline, .env
mints nothing, .html unchanged.

* feat(ui): label the folder diff badge with its baseline

The in-file version-diff badge in annotate/folder sessions shows +N/-M
against the file's last-reviewed snapshot, while the git badges in the file
tree count uncommitted-vs-HEAD — same numbers, different baselines. Give the
badge an optional baseline suffix and tooltip override (PlanDiffBadge
baselineLabel/baselineTooltip, threaded through DocBadges, Viewer, and
StickyHeaderLane) and have annotate mode pass 'since last review' /
'Changes since you last reviewed this file'. Plan review passes nothing and
renders byte-identically to before. DOM tests cover both the labeled and the
unchanged default rendering.

* fix(editor): exit diff view when the active document loses its baseline

Follow-up to the per-document diff baselines: with diff view active on file
A, opening a history-less file B left isPlanDiffActive latched on — the diff
viewer could not render for B, but the stale flag hid the annotation
toolstrip and sticky header until the user pressed Escape. Auto-exit the
diff view whenever the active (non-HTML) document has no baseline. The
--render-html surface is explicitly gated out: its diff view is driven by
htmlDiffHtml with usePlanDiff fed nulls, so hasPreviousVersion is always
false there and auto-exiting would kill the HTML diff toggle. Plan review is
unaffected — the root document's baseline never goes false mid-session. DOM
tests cover the exit, the keep-active document switch, the HTML gate, and
the no-baseline activation snap-back.

---------

Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-07-26 15:33:18 -07:00
Michael Ramos 6fbf0ef560 fix: align Codex model catalog and reasoning efforts with the current Codex CLI (#1096)
* fix(agents): align Codex model catalog and reasoning efforts with Codex CLI 0.144

- Remove gpt-5.3-codex from the Codex model picker: saved picks of it are
  already force-migrated to the fallback (ChatGPT-account Codex rejects it),
  so offering it was incoherent. The 5.2/5.1 family stays — those models
  remain available to API-key Codex users.
- Per-model reasoning-effort gating: the catalog (now in
  packages/ui/utils/codexModels.ts, re-exported from AgentsTab) carries each
  model's supported efforts and default effort from the CLI catalog. The
  reasoning pickers only offer the selected model's efforts, including the
  new max/ultra tiers on the GPT-5.6 family, and useAgentSettings clamps an
  unsupported saved effort to the model's catalog default before it reaches
  the pickers or a launch payload.
- Drop `minimal` everywhere: no current Codex model supports it. Saved
  perModel reasoning entries migrate minimal -> low, and the guide repair
  launch no longer hardcodes it.
- Ask AI: bump the codex-app-server static fallback model and DEFAULT_MODEL
  from gpt-5.4 to gpt-5.6-sol (runtime model/list discovery still replaces
  the fallback and already carries per-model efforts; added Max/Ultra labels).

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

* fix(agents): prune API-shut-down Codex models per OpenAI deprecations

OpenAI's API deprecations page schedules an API-level shutdown on
2026-07-23 for gpt-5.2-codex (-> gpt-5.5), gpt-5.1-codex-max (-> gpt-5.5),
and gpt-5.1-codex-mini (-> gpt-5.4-mini) — dead for ALL auth modes, not
just ChatGPT accounts — so they leave the picker and saved picks migrate:
the first two to the surface fallback (already gpt-5.5), codex-mini to
gpt-5.4-mini with its perModel preference carried under the new key (same
pattern as the gpt-5.6 -> gpt-5.6-sol rename).

gpt-5.2 stays selectable with no migration: it was retired from the
ChatGPT product (steered to 5.5) but the API still serves it, so API-key
Codex users retain it.

Claude-Session: https://claude.ai/code/session_01YXkgsNucxDwAL4GdR4XYRk
2026-07-20 11:54:09 -07:00
Raúl a5c701db01 fix(review): use canonical GPT-5.6 Sol model ID (#1076)
Migrate saved review, tour, and guide Codex preferences from gpt-5.6 to gpt-5.6-sol so existing selections continue to launch.
2026-07-20 10:58:34 -07:00
Kushida ee28f1a428 fix: handle Windows install and review edge cases (#1021) 2026-07-10 05:54:19 -07:00
Michael Ramos f4f6bdb0e9 feat(ui): handle-wide click-to-collapse + suppressible hover on resize handles (#1028)
* feat(ui): handle-wide click-to-collapse + suppressible hover on resize handles

Rework the sidebar/panel resize-handle interaction:

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

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

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

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

pointercancel fires when the browser aborts a gesture (palm rejection, system
gesture, focus loss). It was routed through the same onUp path as pointerup, so
a no-move cancel with onClick set would collapse the panel. Skip the onClick
branch on cancel — only clean up drag state (a real in-progress drag still
commits its width, matching prior behavior).
2026-07-08 22:50:29 -07:00
Michael Ramos e20fd97be7 Consumer enablement for @plannotator/ui (0.24.0) (#1017)
* feat(ui): AnnotationPanel renderCardFooter + readOnly host props

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

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

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

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

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

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

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

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

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

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

* chore(ui): 0.24.0 — HANDOFF consumer-enablement notes + version bump
2026-07-07 19:09:52 -07:00
Michael Ramos 070d9a5f6d Make the document UI reusable as published building blocks (#957)
* docs(adr): revert failed document-ui cutover, add ADR 004 with corrected reuse plan

The document-ui extraction/cutover (ADRs 002/003) was an AI-driven rewrite that
broke the app; the code was reverted. Add ADR 004 as the source of truth: share
@plannotator/ui as published building blocks for the Workspaces app, keep
Plannotator's app unchanged, gate on human-verified parity. Banner the reverted
ADRs and point AGENTS.md/CLAUDE.md at 004 so future agents don't rebuild the mess.

* docs(adr): add verified document-ui extraction plan, supersede draft inventory

36-agent verification of the reuse inventory: confirmed the /api coupling but
found the draft missed Viewer's transitive backend call, the cookie settings
layer, 3 React contexts + identity singleton, SSE transports, and harder
packaging blockers. Adds the verified per-subsystem extraction plan with a
parity guardrail on every step; flags the draft inventory as superseded.

* docs(adr): add document-ui extraction roadmap + parity checklist

Phase 0-7 execution roadmap (safety net -> packaging -> foundation seams ->
rendering -> navigation -> comments -> extras -> publish) and the reusable
'did it break?' parity checklist run after every step. Both enforce the law:
move + decouple, never rewrite; Plannotator's experience cannot change.

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

Phase 0: captured parity baseline (typecheck/test/build + shipped-bundle hashes).
Phase 1 packaging fixes to packages/ui, metadata only:
- add phantom dompurify ^3.3.3 dep (imported in sanitizeHtml/aiChatFormat, was undeclared)
- align diff ^8.0.3 -> ^8.0.4 with root
- add peerDependencies (react, react-dom, tailwindcss, tailwindcss-animate); keep as devDeps
- add files allowlist (excludes tests); remove dead tsconfig @plannotator/shared alias

Verified byte-identical: typecheck pass, 1620 tests pass/0 fail, all 3 builds OK,
shipped plan+review bundle hashes unchanged from baseline. Remaining Phase 1
blocker (@plannotator/ai + @plannotator/shared workspace:* deps) deferred pending
a publish-vs-inline decision; logged in worklog.

* feat(ui): make image URL resolution host-overridable (Phase 2, seam 1)

getImageSrc now delegates to a module-level resolver defaulting to the verbatim
Plannotator /api/image logic; add setImageSrcResolver/resetImageSrcResolver so a
host (Workspaces) can resolve images via its own backend. All 5 consumers and the
signature unchanged. Verified: default URLs byte-identical, typecheck pass, 1620
tests pass/0 fail, builds OK. No Plannotator behavior change.

* feat(ui): make settings storage backend host-overridable (Phase 2, seam 2)

storage.ts cookie impl is now the default 'cookieBackend'; add setStorageBackend/
resetStorageBackend so a host (Workspaces) can persist settings via its own
storage. getItem/setItem/removeItem delegate to the active backend; the ~24
consumers and literal plannotator-* keys are unchanged. Verified: swap works,
typecheck pass, 1620 tests pass/0 fail, builds OK, theme persists across reload.

* feat(ui): make MarkdownEditor theme mode host-supplyable (Phase 3)

Add optional mode? prop; mode now mode ?? resolvedMode. Plannotator passes no
mode (App.tsx:4261) so it keeps using ThemeProvider's resolvedMode unchanged. A
host without ThemeProvider can supply mode directly. Verified: typecheck pass,
1620 tests/0 fail, builds OK, App.tsx untouched.

* feat(ui): allow hosts to opt out of code-path validation (Phase 3)

Viewer gains optional disableCodePathValidation? threaded to a new disabled? arg
on useValidatedCodePaths; when set, the /api/doc/exists probe is skipped. Default
undefined for Plannotator => validation stays on, /api/doc/exists fires exactly as
today. Verified: typecheck pass, 1620 tests/0 fail, builds OK, App.tsx untouched.
Also logs Phase 3 workflow outcome + remaining scroll/docfetch pieces.

* feat(ui): make code-file hover preview fetch host-overridable (Phase 3)

Add DocPreviewFetcher seam (default = verbatim /api/doc fetch) +
setDocPreviewFetcher/resetDocPreviewFetcher; route handleMouseEnter through it,
useCallback deps unchanged. No caller overrides it => Plannotator fetches /api/doc
identically. typecheck pass, 1620 tests/0 fail, builds OK.

* feat(ui): ship ScrollViewportProvider with the library (Phase 3 scroll)

Add render-transparent ScrollViewportProvider (createElement, keeps .ts) so the
scroll-viewport context travels with @plannotator/ui instead of living only in
App.tsx. Rewire App.tsx provider tags (3-line delta); identical tree/value/
position, sidebar TOC still reads the MAIN viewport. Fix stale OverlayScrollbars
doc-comment. typecheck pass, 1620 tests/0 fail, builds OK, eyeball: TOC tracks.

* fix(ui): disabled code-path validation should keep links clickable (self-review)

The Phase-3 disabled branch set ready=true with an empty map, which makes
gateCodePath demote every code link to plain text. Leave ready=false so the
no-validation fallback renders links optimistically. No Plannotator impact
(never disables). Logs Phase 3 completion + reusability note. typecheck pass,
1620 tests/0 fail, builds OK.

* feat(ui): make file-tree backend host-overridable (Phase 4)

Lift useFileBrowser's three backend wires (load-dir fetch, obsidian-vault fetch,
and the SSE live-watch effect moved VERBATIM) into an injectable FileTreeBackend
with default + setFileTreeBackend/resetFileTreeBackend, same pattern as the image
/storage seams. useFileBrowser() stays zero-arg; default fetch/SSE URLs identical.
Sidebar confirmed noop (zero backend wires, already reused by review-editor).

Verified: useFileBrowser.test.tsx passes 6/0 UNMODIFIED (DOM_TESTS=1), typecheck
pass, 1620 tests/0 fail, builds OK, App.tsx untouched, manual eyeball (annotate
adr/: tree loads, file-switch works, new file appears live via SSE). Plannotator
byte-unchanged. Logs two pre-existing bugs found during testing (not regressions).

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

Five-probe code research of the comment system. Key finding: most comment UI is
already portable (panel/popover/toolbar/highlighter prop-driven; review-editor
already reuses the hooks). Phase 5 narrows to 3 seams — draft transport (+ the
3-party generation protocol), external-annotation transport (SSE->polling, move
verbatim), and identity/authorship — plus 2 non-extraction items: renderer
coupling (document as a contract) and replies/threading (defer as a new feature).

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

Three seams (identity, draft transport, external-annotation transport), each
defaulting to today's behavior; renderer coupling documented as a contract;
replies/threading deferred as a new feature. Locks in the recommended choices
from the Phase 5 spec/synthesis.

* feat(ui): make annotation identity host-overridable (Phase 5 seam 1)

Add IdentityProvider + setIdentityProvider/resetIdentityProvider in identity.ts;
getIdentity/isCurrentUser now delegate to a module-level provider defaulting to
today's ConfigStore tater behavior. The ~9 author-stamp sites and 2 (me)-badge
sites delegate with zero call-site edits. No caller overrides => Plannotator
byte-unchanged. typecheck pass, 1620 tests/0 fail, builds OK.

* feat(ui): make draft persistence transport host-overridable (Phase 5 seam 2)

Add DraftTransport (load/save/remove) + getDraftTransport/setDraftTransport/
resetDraftTransport in useAnnotationDraft.ts, default = today's /api/draft fetches
verbatim. useCodeAnnotationDraft reads getDraftTransport() live. The generation
pre-increment, 500ms debounce, keepalive retry-gate, and pagehide/visibilitychange
flush stay in the hooks; getDraftGeneration() still escapes to the host. save
rejects-on-failure so the gated retry is preserved. No caller overrides =>
Plannotator byte-unchanged. shared/draft.test.ts 10/0, annotationDraftPersistence
13/0, typecheck pass, 1620 tests/0 fail, builds OK.

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

Add ExternalAnnotationTransport<T> (subscribe/getSnapshot/CRUD) + setters in
useExternalAnnotations.ts; default = today's SSE->polling wire moved verbatim into
createDefaultTransport. The reducer (applyEvent), fallback-once gate, 500ms poll,
versionRef scoping, optimistic-before-await, and [enabled] gate stay in the hook.
A host (Workspaces) can implement the same event contract over Durable Objects.
No override caller => Plannotator byte-unchanged. external-annotations test green,
typecheck pass, 1620 tests/0 fail, builds OK. Logs Phase 5 completion.

* docs(adr): research + synthesis + spec for Phase 6 (versions, settings, sharing, AI)

Five-probe code research. Most of the four subsystems is already portable; the
real work is 5 seams (version fetchers + vscode-diff, config write-back, obsidian
detect, save-to-notes, AI transport) + 1 CSS move (block/raw diff classes from the
app shell into the package's theme.css). Fragile do-not-touch: the AI SSE reader
loop + epoch guards, and configStore debounce/deepMerge. Five Plannotator-only
pieces (OpenInApp, HooksTab, useUpdateCheck, useAgents/useAgentJobs) stay home.

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

Five seams + one CSS move, each defaulting to today's behavior. AI reader loop +
epoch guards and configStore debounce/deepMerge stay verbatim. Five Plannotator-
only pieces stay home. Locks the recommended choices from the Phase 6 spec.

* feat(ui): make version fetchers + vscode-diff host-overridable; move diff CSS into package (Phase 6 versions)

usePlanDiff gains optional fetchers (default /api/plan/version(s), error asymmetry
kept: selectBaseVersion alerts, fetchVersions silent). PlanDiffViewer gains optional
onOpenVscodeDiff (default /api/plan/vscode-diff). Relocate .annotation-highlight* +
.plan-diff-* block/raw CSS from editor/index.css into ui/theme.css (next to
.plan-diff-word-*) so the diff/highlights are self-styling from the package.
Verified: relocated CSS gone from index.css, present in shipped bundle (33x), diff
renders identical; typecheck pass, 1620 tests/0 fail, builds OK, App.tsx untouched.

* feat(ui): make config write-back + obsidian-detect host-overridable (Phase 6 settings)

configStore.setServerSync(fn) injects only the terminal POST /api/config; the 300ms
debounce, deepMerge batching, singleton, and eager cookie reads stay verbatim.
Settings gains optional onDetectObsidianVaults (default /api/obsidian/vaults), with
the [obsidian.enabled] effect dep + auto-select-first-vault verbatim. No override
caller => Plannotator unchanged. typecheck pass, 1620 tests/0 fail, builds OK.

* feat(ui): make save-to-notes host-overridable (Phase 6 sharing)

ExportModal gains optional onSaveToNotes (default = verbatim POST /api/save-notes);
showNotesTab = isApiMode && !!markdown kept byte-for-byte. Sharing utils already
parameterized (noop). No override caller => Plannotator unchanged. typecheck pass,
1620 tests/0 fail, builds OK.

* feat(ui): make Ask AI transport host-overridable (Phase 6 ai)

useAIChat gains a module-level AITransport (session/query/abort/permission) +
setAITransport/resetAITransport, default = the five /api/ai/* fetches verbatim. The
SSE reader loop, epoch/createRequest guards, and the supersede-abort position inside
createSession stay untouched. Capabilities + provider-resolution stay host-owned in
App.tsx. No override caller => Plannotator unchanged. ai.test.ts 97/0, typecheck
pass, 1620 tests/0 fail, builds OK.

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

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

Carve a browser-safe @plannotator/core: move the ~15 pure shared modules in,
extract types from the 3-4 node-bound ones (config/storage/workspace-status) so
nothing duplicates, shim @plannotator/shared so Plannotator's 99 import sites stay
unchanged, re-point @plannotator/ui to depend only on core, move wideMode.ts, then
publish core+ui (source-only). shared + ai stay private. Open: registry, versions,
CI job. Publish is the one outward-facing step — confirm before pushing.

* docs(adr): fold configurePlannotatorUI() front door + precompiled CSS into Phase 7 spec

Add the single typed configure() facade over the 9 global host-override setters
(zero-risk, additive) and an optional precompiled CSS bundle (smooths the
Tailwind-in-shared-lib wrinkle) to the Phase 7 publish scope. Both make the
published surface nicer to consume; neither touches Plannotator.

* docs(adr): lock Phase 7 publish decisions + carry over review fixes

Decided: ship JS as source (single internal consumer on controlled stack, no
build to maintain, no dist drift); precompiled CSS now REQUIRED (the @source glob
is fragile under pnpm symlinks); core CI typecheck node-free; pin ui->core exact.
Recorded the interrogation's carried-over Phase-5 code fixes (useExternalAnnotations
split-transport + fallbackRef reset, per-seam override tests, configStore loadFromBackend)
to do before publish.

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

Locks Phase 7 decisions: public npm; lockstep version at repo 0.21.0 (ui->core
pinned exact); JS ships as source + required precompiled CSS; core CI node-free;
ai stays unpublished-to-npm. Settings provider completed (loadFromBackend, prefetch
+sync) is now IN SCOPE — Workspaces uses the same UI settings stored in its own
backend. CI publish job wired but artifacts validated on-branch (pack + dry-run)
before merge; first publish gated. Carries the 2 override-path bug fixes + per-seam
override tests as pre-publish work.

* fix(ui): make external-annotation transport reads consistent + reset fallback on re-enable

Two override-path bugs found by the interrogation pass (both unreachable on
Plannotator's path; harden the host-override path for a real consumer):

1. Split-transport: the effect captured the transport at mount for subscribe/poll
   while the CRUD callbacks read the module global live, so a host swapping the
   transport after mount would split reads and writes across two backends. Capture
   once in a ref and use it in all four spots.

2. fallbackRef/receivedSnapshotRef were not reset on effect re-run, so an
   enabled false->true toggle inherited a stale 'already fell back' flag and
   silently stopped updating. Reset both at the top of the effect.

Plannotator unchanged: it never overrides the transport (same default singleton
captured) and enabled never toggles (reset is a no-op). typecheck clean; full
test suite shows zero delta (1605 pass / 45 pre-existing env failures, identical
with and without this change).

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

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

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

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

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

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

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

Add one override test per seam (setX(fake)→drive→assert→resetX()) for all
9 seams + loadFromBackend, modeled after the existing seam test pattern.
Fix configure.test.ts to defer mock.module() into beforeAll and restore with
captured real function references in afterAll so sibling seam test files are
not poisoned by spy replacements in the shared Bun worker module registry.

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

- Bump @plannotator/ui to 0.21.0 (lockstep with @plannotator/core + repo, per ADR 007) [was the 1 critical review finding]
- useAnnotationDraft: route persistNow/dismissDraft save+remove through getDraftTransport() so all paths read the transport consistently (matches the load path; makes the single-global invariant explicit)
- configStore.loadFromBackend: document it must be called BEFORE init() or server values get overwritten
- packages/core/tsconfig: add explicit types:[] so the node-free invariant is first-class (verified: planted node:fs still fails TS2882)

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

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

Rebased onto origin/main (picks up #948 draft-deletion fix, the 0.21.1 bump, and
the #949/#950 editor fix). The rebase auto-merged #948's code-draft logic
(hasHadAnnotationsRef, empty-state tombstone, clearTimeout in restore/dismiss) with
the Phase-5 transport refactor cleanly — except the empty-state tombstone delete was
left as a raw fetch('/api/draft', DELETE). Route it through getDraftTransport().remove()
so a host backend tombstones its own stored draft on clear (the #948 guarantee, for
hosts). Plannotator unchanged (default transport hits the same endpoint).

Bump @plannotator/core + @plannotator/ui 0.21.0 -> 0.21.1 to match main's version
(lockstep per ADR 007).

Verified: typecheck clean, madge no-cycles, plain suite 1637 pass / 0 fail, #948
draft-clear test 3/0. (The 45 DOM_TESTS failures are the known server/network
integration tests that need a real OS env — same set on main, not regressions.)

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

- PlanDiffViewer: wrap onOpenVscodeDiff in try/finally so a host opener that throws
  can't wedge the VS Code button in a permanent loading state (default unaffected)
- useExternalAnnotations: (re-)capture the transport inside the effect on enable so a
  host that installs a transport before enabling annotations is honored, not the stale
  default — keeps the split-transport fix (effect + CRUD share one ref)
- configure.ts: import ServerSyncFn from configStore instead of duplicating the type
- repoint the 2 remaining @plannotator/shared test imports to @plannotator/core
- AGENTS.md/CLAUDE.md: document the new packages/core package

All host-path only — Plannotator behavior unchanged. typecheck clean, no cycles,
full suite green. Skipped (not simple/over-engineering): usePlanDiff prop->module-level
(design change), Obsidian late-bind, getSnapshot guard (inert), transport <any> (variance).

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

The branch had accumulated ~6,200 lines of ADR scaffolding (6 decisions, 7 specs,
10 research spikes/synthesis, 6 worklogs/roadmaps/plans) for this one effort. Replace
all of it with a single concise README that ships with the published package: what
@plannotator/ui + @plannotator/core are, why they exist (commercial reuse), how the
host-override seams work (configurePlannotatorUI), how a consumer installs/builds, and
the one rule (don't reimplement from scratch — add a seam). Repoint the CLAUDE.md banner
at the README. No code references the deleted docs; main's pre-existing adr/ docs untouched.

* docs(ui): add packages/ui/AGENTS.md guardrail + CLAUDE.md symlink

Directory-scoped agent guidance for anyone editing @plannotator/ui: don't rewrite from
scratch, add a seam (default = today's behavior, Plannotator byte-for-byte unchanged),
core stays node-free, never delete working code until human parity. Points to README.md
for the architecture. CLAUDE.md -> AGENTS.md symlink mirrors the repo root convention.

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

madge is unmaintained (~3 years stale) and the check was never wired into CI, so it
was a dormant script + devDependency on a load-bearing path. Drop it: remove the
check:cycles script, the madge devDependency, and .madgerc.

The no-cycle invariant still holds by construction — @plannotator/core imports nothing
(zero @plannotator deps in its package.json), so any accidental core->shared/ui import
fails at publish-time bun pm pack (and review). No automated tripwire, but no stale
unmaintained tooling either.

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

- useExternalAnnotations: declare unsubscribe as let (not const) + guard calls, so a
  host transport that fires onError synchronously during subscribe falls back to polling
  instead of throwing a TDZ ReferenceError (Plannotator's EventSource fires async, never hit)
- package.json: add explicit ./components/html-viewer export (dir has index.ts; the
  ./components/* -> *.tsx wildcard can't resolve it, so external installers would fail)
- README: fix configurePlannotatorUI sample keys to the real option names
  (storageBackend/identityProvider/imageSrcResolver/externalAnnotationTransport)
- AGENTS.md: point the Ask-AI mapping at packages/core/agents.ts (shared/agents.ts is a shim now)

All publish/host-path/doc only — Plannotator unchanged. (#1 CSS-build font collision
deferred to publish-prep — it needs the asset pipeline + files allowlist, not a one-liner.)

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

Industry standard for a shared UI package: ship theme + component CSS, let the consuming
app load fonts. Drop the @fontsource imports from styles-entry.css (the publish CSS entry);
the theme still defines --font-sans/--font-mono, and the app provides those families. Fixes
the asset-name collision (every emitted .woff2 was renamed styles.css) and shrinks the
published stylesheet 555kB -> 185kB. README documents the two-line @fontsource install.

Plannotator unaffected: its apps (editor/review-editor index.css) load fonts via their own
entry CSS — styles-entry.css is consumed ONLY by the publish CSS build.

* fix(ui): build styles.css on prepack, not prepublishOnly (review #4)

prepublishOnly doesn't run for npm pack / bun pm pack / git / file: installs, so the
package exported ./styles.css without shipping it. prepack runs on any pack, so the
stylesheet is always present. Verified: bun pm pack now emits styles.css.

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

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

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

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

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

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

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

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

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

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

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

* refactor(ui): capture sessionId synchronously in postServerAbort

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fix at the CI layer, not the test: run useFileBrowser.test.tsx isolated
(matching main), and run the seam contracts + remaining DOM-gated tests
as an explicitly-scoped light batch. The test file is reverted to main
verbatim (today's timing-poll experiments dropped). Follow-up issue to
file: the underlying re-subscription race the load exposed.
2026-07-06 20:39:09 -07:00
Michael Ramos aa46dbb29a feat: guide per-file summaries + GitHub Copilot CLI agent engine (#997)
* feat(guide): per-file summary on guide diff refs

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(ui): support more markdown math delimiters

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

* chore(deps): update katex

* Update bun.lock

chore(deps): update

chore(deps): update

chore(deps): update

chore(deps): update

chore(deps): update

* feat(highlight): highlight math

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

No server, endpoint, or Pi-runtime changes.
2026-06-30 23:05:43 -07:00
Michael Ramos 819b14c0d7 fix(review): show pending agent job launches (#980) 2026-06-29 21:43:19 -07:00
Michael Ramos cb6667e991 fix(ai): drive Codex Ask AI via codex app-server (#971)
* fix(ai): drive Codex Ask AI via app-server + answer-first review prompts (#971)

Codex Ask AI previously ran via @openai/codex-sdk (codex exec), which forces
approval_policy=never and breaks in enterprise-managed Codex environments that
ban it (#971). Replace the transport with a long-lived 'codex app-server'
process over JSON-RPC.

- New provider packages/ai/providers/codex-app-server.ts (registered as
  'codex-sdk' to preserve cookie/agents.ts/UI-gate); omits approvalPolicy so
  Codex resolves the user's + managed policy, pins read-only sandbox, and
  surfaces interactive approvals through the existing PermissionCard.
- Delete codex-sdk.ts and drop the @openai/codex-sdk dependency (and its 6
  prebuilt platform binaries); gate registration on 'which codex'.
- SessionManager: additive, optional dispose?() hook to kill the spawned
  process on evict/remove — a no-op for Claude/OpenCode/Pi (they don't
  implement it).

Also rework the Ask AI prompts (all providers, separate from the transport):

- Every mode now instructs the agent to answer the user's message directly and
  not launch an unprompted review of the context.
- Code review stops pasting the whole diff for git-reproducible diff types and
  instead tells the agent how to inspect it (git diff <base>..HEAD, three-dot
  for merge-base); non-git/PR/workspace types still paste.
- Claude gains the Bash tool so it can run git (still gated by approvals).
- The UI passes diffType/base (session) and what the user is viewing (per
  question) into the context.

Verified: full typecheck, full test suite (101 ai tests), and a live
end-to-end smoke against codex app-server.

* fix(review): pin AI approval card above the input/model bar

Render pending approval cards just above the input + provider/model bar in both the document chat (DocumentAIChatPanel) and code-review AI tab, instead of at the top of the scroll, so the user sees them where they act.

* fix(ai): harden Codex abort/cancel + add Ask AI Stop button

Addresses code-review findings on the codex app-server provider:

- turn/interrupt was sent as a notification (no id) so Codex ignored it and
  abort never took effect. It's now a proper JSON-RPC request.
- Filter turn events/approvals by turnId and reject an aborted turn's
  stragglers, so a stopped turn can no longer leak output into — or
  prematurely finish — the next turn (ask-stop-ask race).
- Guard listeners by query generation and end the drain loop on the abort
  signal, so a superseded/stopped turn can't touch the live one and abort
  returns promptly instead of waiting for turn/completed.
- Handle abort during startup: once the turn id is known, interrupt it
  instead of running it in the background.
- Add a sendAndWait timeout so a stalled (alive-but-unresponsive) process
  errors instead of hanging forever.
- Drain stderr (stdio 'ignore') to avoid a pipe-buffer deadlock.

Also add a Stop button to both Ask AI surfaces (plan/annotate
DocumentAIChatPanel and code-review AITab via ReviewSidebar). It replaces
Send while streaming and calls the hook's abort -> /api/ai/abort ->
session.abort(); the hook already exposed abort but nothing surfaced it.

* feat(ai): drive Codex models + reasoning levels from model/list

- Codex provider fetches model/list at startup (throwaway app-server, like
  Pi/OpenCode) and populates the real models plus each model's actual
  supportedReasoningEfforts + defaultReasoningEffort. Replaces the hardcoded
  model list and the static AI_REASONING_EFFORTS (which mislabeled xhigh as
  'Max' and omitted minimal).
- AIProviderBar + AIConfigBar now show the selected model's real efforts and
  hide the control when a model reports none. xhigh is shown verbatim.
- Fix: the Stop button now also appears in the populated code-review chat
  state (a prior edit missed the second GeneralInput due to indentation).

* fix(ai): scope Claude Ask AI Bash to read-only git; clear stale approval cards on Stop

- Claude Ask AI no longer auto-allows bare Bash (which ran arbitrary shell
  with no Allow/Deny prompt). Replace it with scoped read-only git rules
  (Bash(git diff:*), show, log, status, rev-parse, merge-base, ls-files).
  git reads auto-run so the agent can inspect large diffs itself; any other
  command (write git, arbitrary, or injected compound) falls through to the
  permission card. Keeps the git-inspect approach (large diffs don't fit in
  the prompt) while closing the auto-exec hole.
- useAIChat.abort() now drops still-undecided permission cards: abort cancels
  them server-side, so leaving them visible was a dead Allow/Deny.

* feat(ai): code-review Ask AI shares the agent-review prompt machine, delivered as user messages

Code-review Ask AI built its own diff description (gitInspectInstruction) in
the system prompt from just diffType+base, which was wrong for full-stack,
hide-whitespace, untracked files, and PR worktrees. Replace it with the same
machine the review jobs use, delivered on the user's messages.

- Server: buildCurrentAiReviewContext() reuses buildAgentReviewUserMessageForTarget
  (contextOnly) for the current view and ships it as aiReviewContext in every
  diff payload (/api/diff + switch/PR/scope). Mirrored in the Pi server.
- Client: review-editor latches aiReviewContext onto each question via the pure
  buildReviewContextPreamble (packages/ui/utils/aiPrompt.ts) and buildDefaultPrompt
  — full block on the first message / when the view changes (incl. after a
  provider switch via the !sessionId fresh-session check), a short reminder
  otherwise (never re-pastes a large diff).
- context.ts: delete the duplicate gitInspectInstruction; code-review system
  prompt is now role-only. Providers untouched (provider-agnostic user message).
- Tests: machine scenario gaps (plain PR, full-stack default, PR-worktree
  origin/<base> + stale-main warning, untracked mention, jj-evolog, workspace
  lines); composition (first/reminder, command/pasted, preamble ordering).

* fix(ai): agentic remote-PR context, UTF-8 stream decode, real Stop on supersede/disconnect

Addresses review findings on the Ask AI prompt work:

- Remote PR without a confirmed local checkout no longer gets URL-only. The
  agent is told it's in a PR worktree that's being prepared, to verify the PR
  files exist before relying on them, and to diff with git diff origin/<base>...
  HEAD (URL fallback). Inform + trust the agent rather than pasting. Shared
  machine, so review jobs get the same framing.
- Decode Codex stdout with a streaming TextDecoder instead of per-chunk
  toString(), so multi-byte UTF-8 split across chunks no longer corrupts into
  U+FFFD (matches the Pi provider).
- Stop now actually stops the server turn: ask() awaits /api/ai/abort when a
  new question supersedes a streaming one (awaiting avoids racing the new query
  into session_busy), and the /api/ai/query SSE stream gains a cancel handler
  so tab-close/navigation aborts the turn too. Both reuse the existing
  per-provider session.abort(); factored a shared postServerAbort helper.

* fix(ai): per-model reasoning effort, shared PR-checkout readiness, Stop-then-ask race, type hole

- Reasoning effort is now tracked per model (a map keyed by model) instead of
  one global value, so switching to a model that doesn't support the prior
  level (e.g. xhigh) no longer posts a stale/unsupported effort that Codex
  rejects. Each model keeps its own level; nothing leaks across.
- Extract resolvePoolCwd into packages/shared/worktree-pool.ts (ready/pending/
  absent) and use it from both servers' resolvePRLocalCwd so the readiness rule
  can't drift. Fix the Pi Ask AI helper to ready-check like Bun, so a warming
  PR checkout no longer claims 'checked out at PR head' and misdirects the diff.
- Stop-then-ask no longer races into session_busy: the abort promise (from Stop
  or a superseding question) is stashed and the next ask() awaits it before
  sending. Stop still kills the turn instantly; a follow-up just waits the one
  abort round-trip.
- Declare aiReviewContext on the initial /api/diff response type.

* fix(ai): surface real Codex error messages, skip transient retries, auto-deny permission escalations

Validated against codex-rs:

- Codex's ErrorNotification nests the text at params.error.message
  (TurnError.message); we read params.message → always 'Unknown error'. Read
  the nested field (top-level fallback) so auth/usage-limit/stream failures show
  their real, actionable text.
- Skip transient error notifications (willRetry=true): Codex retries on its own
  and the turn continues, so surfacing them flashed a spurious failure before
  the real answer.
- Handle item/permissions/requestApproval: respond {permissions:{}, scope:'turn'}
  — byte-for-byte Codex's own cancel response (codex_delegate.rs) — instead of
  'Unsupported request'. Ask AI is read-only, so denying the escalation lets the
  turn continue sandboxed rather than failing. Interactive grant deferred.

* refactor(ai): share AI provider/model config in one hook; aggregate paginated model/list

- Extract useAIProviderConfig (packages/ui/hooks): one home for provider/model/
  reasoning-effort selection — initial state, auto-resolve on capabilities load,
  per-model effort (no leak across models), and persistence. Both the plan and
  code-review apps now call it and only compose the session reset (the hook can't
  own reset without a cycle through useAIChat). Plan editor adapted to the
  effect-based resolve (adds aiDefaultProvider state). This fixes the plan
  editor's stale-effort-on-model-switch bug by construction and stops the two
  apps' copies from drifting again.
- fetchModels now follows model/list's nextCursor and aggregates every page into
  one list, so larger model catalogs aren't silently truncated (with a page
  guard against a misbehaving cursor).

Skipped per discussion: legacy v1 approval methods (we're a v2 client).

* feat(agents): per-agent review default; bound Codex model discovery so it can't stall the AI panel

- The selected review profile is now tracked per review engine (claude/codex/
  cursor/opencode) instead of one flat global value, so each agent keeps its own
  review default. Public hook API (reviewProfileId/setReviewProfileId) is
  unchanged — getter derives the current engine's value, setter writes it — so
  AgentsTab needs no changes. One-shot migration seeds every engine with any
  existing flat pick. Adds parseReviewProfileByEngine + tests.
- fetchModels (Codex model discovery) now uses a short 6s timeout for its
  initialize + model/list RPCs instead of the 30s default. /api/ai/capabilities
  awaits model discovery, so an installed-but-unauthenticated codex could
  otherwise block the AI panel for ~30s; it now falls back to the static model
  list fast. Authed codex is unaffected (discovery completes in tens of ms).
2026-06-28 09:58:09 -07:00
Michael Ramos e138c824aa feat(review): Cursor + OpenCode review engines (unified marker-review) (#959)
Add Cursor CLI and OpenCode as background code-review engines alongside Claude/Codex/Tour, unified behind a shared marker-review module (prose marker-block parsing, since neither CLI exposes a schema flag). Includes multi-provider dynamic model discovery, review-profile support, fail-closed parse/ingest, Bun+Pi parity, and the review UI (Provider picker, brand icons).

Hardening from review: per-job nonce on marker tags (prevents echoed/quoted tags from corrupting extraction), truncation fail-closed, async model discovery (never blocks the event loop), nested-slash model ids, shared finding types.
2026-06-24 14:35:52 -07:00
Michael Ramos affeaa07fc feat(review): custom reviews as Agent Skills + whole-file/general findings (#955)
Custom reviews: pick an Agent Skill and its SKILL.md body becomes the review prompt (read live from global skill folders, never copied; default review byte-identical). Agent findings can be a line, a whole file, or a general review-level comment, and are never silently dropped. Plus: agent-icon engine picker, icon-only file-header opener, semantic diff restored as its own dock panel, and a dead-code sweep.
2026-06-23 20:49:48 -07:00
Michael Ramos 567cf8050e fix(review): persist annotation deletions so they don't resurrect on refresh (#948) (#951)
The code-review autosave skipped saving when the annotation list was empty
(`if (annotations.length === 0 && viewedFiles.size === 0) return`). So
deleting the last annotation never overwrote the stale draft on the server;
on refresh the draft-recovery dialog offered the deleted annotations back.

Mirror the plan-side useAnnotationDraft pattern: when the list is cleared,
DELETE the draft with a generation tombstone (so it can't resurface and a
late save can't revive it) instead of skipping. Guarded so an empty state
only triggers a delete once the user has actually authored annotations this
session — keyed on user-authored (non-source) annotations only, because
viewedFiles are seeded from GitHub on mount and external/SSE annotations have
their own lifecycle; counting either would let a fresh load or external churn
wrongly delete an unrestored draft. Also clear the pending autosave timer in
restoreDraft/dismissDraft so a stale save can't fire after restore/dismiss.

Adds real-stack regression tests (hook + disk layer via an /api/draft shim):
delete-to-empty removes the draft, a fresh/unengaged session preserves an
unrestored draft, and external-annotation churn issues no delete.

Reported by @alexanderkreidich.

Closes #948
2026-06-22 07:05:38 -07:00
Michael Ramos 740d6fb2eb Add WebTUI agent panel to annotate mode (#941)
* feat(annotate): add WebTUI agent terminal

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

* docs: recap annotate agent terminal work

* fix(annotate): harden agent terminal runtime

* docs: add annotate agent terminal runtime ADRs

* fix(annotate): polish agent terminal integration

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

* fix(annotate): address terminal review findings

* fix(annotate): harden agent terminal runtime fallback
2026-06-19 09:04:15 -07:00
Michael Ramos 2a6fe5c457 Open files in external apps + code-review UX pass (#942)
Adds a server-side "open the current file in an external app" control to the code-review and annotate surfaces (split-button with host-detected apps; last-used becomes the default; cross-platform launch mirrored across the Bun and Pi runtimes), plus a code-review UX pass: file-header change letters and line counts, the diff-settings cog grouped with Split/Unified, an all-files collapse/expand-all toggle, and the semantic diff moved to a resizable sidebar accordion. Also fixes Cmd+click code navigation (worker token transformer).

Hardened over three adversarial review rounds: shared, tested path-containment for open-in (Bun + Pi), annotate scoping aligned to /api/doc reference roots, strict PR-checkout rooting (never the launch repo), deduped app-catalog and semantic-diff fetches, macOS bundle-only availability, Windows launch fixes (reveal + terminal off cmd's parser), and PR-checkout tracking across switch and pool warmup.
2026-06-19 08:56:06 -07:00
Michael Ramos f24bb435d3 Fix live file tree startup latency (#935) 2026-06-19 08:01:03 -07:00
Michael Ramos 195328f7a6 Persist saved annotate file edits in drafts (#936)
* Persist saved annotate file edits in drafts

* Handle stale saved file edit context

* Test source edit conflict actions

* Return source metadata for single-file docs

* Fix saved file edit conflict races

* Handle deleted source files in annotate edits

* Tighten annotate missing-file recovery

* Reset edit state for missing file reopen

* Tighten source edit restore path handling

* Harden source edit recovery paths

* Harden source edit disk reconciliation

* Fix live file tree startup delay

* Document file tree watcher startup ordering

* Preserve source save through missing files and symlinks

* Harden missing source file recovery

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

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

* fix(annotate): tighten live file tree status

* fix(annotate): surface deleted file browser roots

* fix(annotate): normalize file tree status paths

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

* fix(annotate): tighten workspace status git metadata

* fix(annotate): refresh file tree after reconnect

* fix(pi): expose file browser stream route
2026-06-17 14:25:28 -07:00
Michael Ramos 9ed3ba8937 feat(editor): markdown edit mode — direct document editing with diff-to-agent feedback
Adds direct markdown editing, source-backed annotate saves, folder edit buffers, and review-hardening fixes.
2026-06-16 16:35:51 -07:00
Michael Ramos be2d06a7c2 Make HTML annotations render HTML by default
* feat(annotate): render html files by default

* fix(annotate): support raw html assets and sharing

* fix(annotate): address html first review followups

* fix(editor): avoid raw html sidebar init crash

* fix(annotate): support portable html shares

* fix(annotate): harden html share support

* fix(share): clear attachments when loading shared payloads

* fix(share): warn on remote share link failures

* perf(annotate): lazy-build html share payloads

* test(annotate): guard lazy html share generation

* test(annotate): drop flaky html share server test
2026-06-16 16:16:05 -07:00
Cody Partington 4cf9d215dc Unify agent mode engine selection (#868) 2026-06-11 10:52:52 -07:00
Michael Ramos c23df4db43 UI 2.0 visual refresh + HTML-render annotate (strictly UI, off main) (#863)
Extracts the UI 2.0 visual refresh and the HTML-render annotate feature onto main, standalone (no daemon). Faithful copy of feat's UI/HTML logic with the standalone transport kept.
2026-06-08 17:03:41 -07:00
Graham Lipsman be2c81fa3b annotate-last: pick which message to annotate (fixes #800) (#809)
* feat: message picker for annotate-last (#800)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: strip issue-number references from comments

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

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

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

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

* feat: persist annotate-last feedback across messages

---------

Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-06-03 13:01:36 -07:00
Benjamin Jesuiter 96aad592af Open Plannotator in Glimpse when available (#840)
* feat: open plannotator in glimpse

* fix: harden Glimpse browser integration

* chore: refresh lockfile after rebase

---------

Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-06-03 12:17:11 -07:00
Michael Ramos dbe2d6e4cb Replace update banner with passive dot indicator in Options menu (#793)
* Replace persistent update banner with passive dot indicator in Options menu (#790)

The floating update banner that appeared every session is removed. Update
availability is now surfaced as a small dot on the Options button that
auto-dismisses (cookie-persisted per version) when the menu is opened.
The version section inside the menu shows install command and release
notes when an update exists.

* fix: restore ActionMenuSectionLabel import in both header menus

* Add TextShimmer to update notice and simplify menu version section

- Add reusable TextShimmer component (motion-primitives adaptation)
- Add reusable BorderTrail component for future use
- Shimmer "New update available!" text in the Options menu
- Simplify button to plain text link in emerald
- Remove Project repo link, make "Plannotator" header link to repo
2026-05-25 12:09:42 -07:00