Commit Graph

99 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 7d6dd29c08 perf(ui): load the HTML viewer bridge by URL for hosts, with a protocol version and ready timeout (#1398)
Opt-in bridgeScriptUrl on HtmlViewer so multi-chunk hosts can serve the 185 KB bridge as a hashed asset instead of an inlined string; the inline bridge stays the default and Plannotator's own builds, the Pi and OpenCode copies, and the live-app proxy are unchanged apart from a protocolVersion field on the bridge's ready message. The parent checks the version (one warning naming both versions; on the URL path a dismissible banner plus onBridgeUnavailable while the old bridge keeps working), arms a ready timeout on the URL path only, and resolves the URL against the parent document before it reaches the frame so a page's own base href cannot redirect the load. A prepack-generated bridge-script.asset.js (byte-for-byte the inline string) and a bridge-script.lite.ts alias target ship in the tarball. CSP and CORP requirements for hosts are documented.

AI-assisted (Claude) under maintainer direction.
2026-08-27 08:45:22 -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 6903d7a3dd feat(webmcp): expose plan review and annotate as WebMCP tools for browser agents (#1393)
Phase 1 of WebMCP support: a zero-dependency, feature-detected engine in packages/ui/webmcp plus a read-and-comment tool catalog for plan review and annotate (read_document, add_comments, update_comment, remove_comments, reveal, nudge_user, list_documents). No decision tools; the human approves. Zero footprint in browsers without document.modelContext (DOM, network, console, timers, and cookies identical to main), idle until called where the API exists, and never registered inside the annotate iframes. Adds an optional inReplyTo field on annotations for threaded replies. Client-only; no server changes.

AI-assisted (Claude) under maintainer direction.
2026-08-26 14:39:37 -07:00
Michael Ramos 0ae40e73a4 feat(annotate): restore a restricted thumbs-up on comment-only HTML surfaces
The v0.27.5 comment-only ruling removed every label affordance from HTML
and live-app annotate surfaces, leaving no one-click positive feedback:
the only path was opening the composer and typing prose. Restore exactly
ONE affordance, the hardcoded 'Looks good' thumbs-up, on both input
routes:

- selection toolbar: commentOnly + a provided onQuickLabel now renders
  only the thumbs-up (no Delete, no Zap picker, Alt+digit suppressed);
  HtmlViewer passes a handler that filters by label id as defense in
  depth
- pinpoint: the composer gains an optional one-click 'Looks good'
  footer action (disabled once anything is typed, so it can never
  discard a draft), emitting the same isQuickLabel comment shape with
  the draft's multi-select targets

The trust-boundary clamp is untouched: redline/quickLabel modes stay
collapsed to selection, so a hostile page still cannot force a DELETION
or an arbitrary label. THUMBS_UP_LABEL moves to utils/quickLabels as
the canonical definition.
2026-08-24 09:27:43 -07:00
Michael Ramos b23ffee9ec fix(vscode): migrate the legacy auto-seeded dark theme cookie to system (#1362)
#1357 made the panel defer to the app's stored theme mode and seeded
System only when no mode was stored. That helped first-time panels and
nobody else: every panel opened before it already stored `dark`, written
by ThemeProvider on its first mount rather than chosen by anyone, so the
seed never fired and the panel stayed dark in a light IDE. That is issue
#1053 exactly, still broken for the users who reported it.

There is no provenance in the store to read: it is one flat cookie string
in globalState with no timestamps and no per-cookie metadata, and the app
writes the same `plannotator-theme=dark` whether the user picked Dark or
never opened the theme settings. The migration leans on the three signals
that do exist. `light` and `system` are values the auto-seed cannot
produce, so they are choices and are never touched. A new
`plannotator-vscode-seed` marker, written on every load, makes the
re-seed run at most once per store, so a Dark picked afterwards is
permanent. And a mode the user actually picked is recorded server-side in
~/.plannotator/config.json by configStore.set, which configStore.init
applies over the cookie and writes back, so a real choice outranks the
seed and re-asserts itself in the same page load.

What remains is a Dark that exists only as a cookie with nothing in
config.json behind it. That is indistinguishable from the auto-seed and
is reset once: invisible in a dark IDE, and in a light IDE one re-pick
makes it stick for good.

Also fixes the type error #1357 shipped in applyPanelCookieDefaults and
adds the extension's own tsc to CI, which had never run there.

Co-authored-by: Michael Ramos <backnotprop@gmail.com>
2026-08-21 08:42:09 -07:00
Michael Ramos 4f80360351 fix(vscode): user-chosen theme wins over IDE theme sync (#1357)
The theme bridge wrote VS Code's colors as inline custom properties on
<html>, the same element ThemeProvider stamps `theme-<palette>` and
`light` on, and it forced the `light` class to the IDE's theme kind. An
inline property outranks every `.theme-*` rule, so picking Light in a
dark IDE produced dark VS Code tokens sitting under a `.light` class,
and any palette chosen in Plannotator's settings was painted over.

The bridge now reconciles instead of applying once on arrival: VS Code
colors are only painted while the user is on the default palette and the
app is already rendering the IDE's light/dark side, anything it painted
is removed the moment that stops holding, and it no longer writes the
`light` class except to map System onto the IDE's theme kind.

Panels that have never stored a mode seed System, so a first-time user
in a light IDE still gets a light panel now that the bridge does not
force the mode.

Reported by @it-sha.
2026-08-20 13:02:34 -07:00
Burak Varlı a0aa448f98 fix(review): clamp annotation toolbar to viewport (#1354) 2026-08-20 12:38:25 -07:00
Michael Ramos 2ca55c8332 feat(annotate): live local app annotation through a loopback reverse proxy (#1352)
* feat(bridge): additive live-mode gate + LIVE_BRIDGE_BOOTSTRAP

Adds the config-gated live branch to BRIDGE_SCRIPT: frame gate, pinned
parent origin, token-stamped postToParent, origin+token checks on both
inbound handlers, pinpoint-only clamp, vim and resize off, pageUrl on
ready, and coalesced page-change reporting for SPA history navigation.
With no config present (srcdoc) every branch is inert and behavior is
unchanged; the existing html-viewer suites pass unmodified as the
regression proof. LIVE_BRIDGE_BOOTSTRAP installs the annotation CSS
from the JSON config prelude before the IIFE runs. New package export
exposes the string constants without the React barrel.

* feat(ui): live-session parent side for proxied app annotation

useHtmlAnnotation gains a live option (origin + token validated before
parseBridgeMessage; token + concrete targetOrigin on every outbound
post) and a validated page-change message with onPageChange. HtmlViewer
gains src/liveSession/currentPageUrl/onPageChange: src-mode iframe with
no sandbox and no srcdoc, ready pageUrl handling, per-page restore
filtering with explicit clear-marks + re-sync on navigation, and one
postToBridge choke point for its direct posts. Annotation.pageUrl is
additive; exportAnnotations groups by page (with global numbering kept)
only when a pageUrl is present, byte-identical otherwise. AnnotationPanel
shows the page label; AnnotationToolstrip can hide the input switch.
The editor app wires mode annotate-app: full-viewport live surface,
forced pinpoint, vim off, diff/share hidden, pageUrl stamping.

* feat(server): loopback reverse proxy for live app annotation

Whole-origin mirror of a local dev server on a dedicated 127.0.0.1
port: streaming bridge injection (after the head open tag, before a
bare </head>, or appended; exactly one per document; 8-byte holdback
plus a state machine for tags split across chunks), header hygiene
(upstream Host rewrite, X-Forwarded-*, identity Accept-Encoding on
document intent only, hop-by-hop strip), CSP drop-and-replace with
frame-ancestors listing the editor origins, X-Frame-Options removal,
target-origin Location rewrite, byte-identical passthrough for assets
and encoded HTML (no injection, once-per-session diagnostic), SSE
streaming, and WebSocket passthrough with a bounded pending queue for
HMR. Host header validation runs before any upstream contact; the bind
is the literal loopback constant and the advertised-URL override is
never applied. Tests boot a fake dev server and cover injection,
hygiene, fidelity, WS echo, and the security posture.

* feat(annotate): annotate-app server mode + CLI live probe with remote hard-off

startAnnotateServer gains mode annotate-app and a liveApp option: it
throws under PLANNOTATOR_REMOTE, generates the per-session token,
composes the proxy-served bridge body (JSON config prelude with both
editor origin forms, localhost first, plus bootstrap and bridge
supplied by the caller so packages/server never imports
@plannotator/ui), starts the loopback proxy after the annotate port is
known, serves the live /api/plan payload (no rawHtml, no version
fields, sharing off), and stops the proxy with the server. Version
history and durable submission records stay excluded via the explicit
mode gate.

The CLI resolution probes loopback http URLs (3s, accept text/html)
and defaults them to live mode when the probe returns HTML; --static
forces conversion, --app forces live and fails loudly on non-loopback,
https, unreachable, or non-HTML targets; both flags are mutually
exclusive transport-shape flags never echoed in the tolerant handoff.
A live resolution under PLANNOTATOR_REMOTE is a startup failure
suggesting --static. OpenCode and Pi parsers are untouched this phase.

* test(live-annotate): protocol, server, and probe suites + smoke script + docs

htmlLiveProtocol.test.tsx covers the parent trust boundary (origin and
token rejection before parseBridgeMessage, token + targetOrigin on
every outbound post, validated page-change and ready pageUrl, per-page
restore filtering with full-list numbering) and the bridge live gate,
executed as the composed config + bootstrap + bridge body inside a
dedicated harness iframe so the srcdoc suites keep running the same
script uncontaminated in this process. annotate.test.ts gains
annotate-app cases (live payload shape, composed bridge served by the
proxy, no-history version endpoints, proxy stopped with the server,
remote rejection); annotate-live-resolution.test.ts covers the probe
matrix. The two post helpers now drop unmatched-targetOrigin posts
silently, matching browser semantics where some DOM environments throw.
Adds the manual Vite/Next smoke script and the AGENTS.md live app
annotation section (phase gate, security posture, limitations).

* test(annotate-cli): cover the CLI layer of the live app remote hard-off

Spawns the real CLI entry (async, so the in-process fake app can answer
the live probe) with PLANNOTATOR_REMOTE=1 against a loopback HTML
server and asserts the startup-failure exit with the --static hint.
Completes per-layer coverage of the three-layer hard-off (CLI exit,
server throw, unconditional loopback proxy bind).

* fix(live-annotate): harden the loopback trust boundary end to end

- isLoopbackHostname (now canonical in live-proxy.ts, re-exported by the
  CLI resolution) requires localhost, ::1, or a LITERAL 127/8 IPv4
  address: DNS names like 127.0.0.1.evil.example no longer classify as
  loopback, so neither the default probe nor --app can start a live
  proxy against an off-box origin.
- The live-eligibility probe judges the FINAL response URL: a target
  that redirects off its loopback origin falls back to the static
  pipeline (or fails loudly under --app) instead of opening a live
  session whose iframe immediately leaves the proxy.
- WS upgrades with a browser Origin not naming the proxy itself are
  refused, so a hostile page's cross-site connect is never laundered
  into the origin-less shape dev servers trust as a non-browser client
  (Vite CVE-2025-24010 class).
- /__plannotator__/bridge.js refuses cross-site/same-site
  Sec-Fetch-Site fetches: the per-session token is no longer readable
  via an off-origin script include on modern browsers.
- X-Frame-Options is stripped only on HTML responses (where
  frame-ancestors replaces it); non-HTML responses keep the app's own
  framing protection.
- Redirect Locations are re-anchored by loopback-host + port
  equivalence instead of a string prefix: alternate loopback spellings
  are now caught and lookalike ports (5173 vs 51730) pass through
  untouched.
- --app on a non-URL target fails loudly instead of being silently
  swallowed.

* fix(live-annotate): session correctness for SPA restores, origins, and pathful targets

- A live find-and-mark that resolves nothing keeps its record, seeded
  with unresolved placeholder targets from the durable anchor/text
  params, so the mutation-driven reconcile re-acquires the pin once a
  lazy route or data-dependent tree renders (SPA navigation no longer
  permanently drops pins). Srcdoc restores keep the fail-closed drop.
- The bridge posts every outbound message once per listed editor
  origin; the browser delivers only the one matching the parent
  document, so an editor opened at 127.0.0.1 instead of localhost no
  longer silently loses ready and every subsequent message.
- The advertised appUrl is the proxy under its localhost spelling with
  the target URL's own path and query: the framed app stays same-site
  with the editor, shares the dev app's host-only localhost cookies
  and storage, and a pathful target opens its page instead of the app
  root. The proxy still binds the 127.0.0.1 literal.

* ci(live-annotate): run the live protocol DOM suite; document the hardened posture

htmlLiveProtocol.test.tsx is DOM-gated and was absent from the
workflow's DOM_TESTS file list, so none of its trust-boundary
assertions ran in CI. Add it, and update the live-app section of the
project docs: literal-loopback gate, probe redirect rule, WS Origin
check, bridge.js delivery gate, localhost appUrl advertisement, live
restore resilience, and the remote-mode behavior change (loopback URL
annotate under PLANNOTATOR_REMOTE now exits asking for --static
instead of silently converting).

* fix(live-annotate): absorb the v0.27 mainline into the live session surface

Post-rebase seam work after replaying the branch onto main (v0.27.4 era):

- Route the bridge's unanchored-transparency report through postToParent so
  live sessions deliver it token-stamped to the listed editor origins; the
  raw '*' post main introduced for srcdoc would be dropped by the live
  parent's message authentication exactly where restores fail most. New
  live-harness test pins the contract.
- Extend the live remote hard-off to --tailscale sessions (flag postdates
  the branch): CLI startup failure + startAnnotateServer throw keyed on
  tailnetPublished, matching how the annotate agent terminal treats tailnet
  publication. Covered in annotate.test.ts and documented in AGENTS.md.
- Keep main's compact-touch input controls and effective mode/input values
  on the HTML surface while preserving the live pinpoint-only clamps.
- Regenerate the pinned guide-viewer manifest (CSS hash moved with the new
  UI classes; JS unchanged).

* feat(live-annotate): Interact/Annotate mode toggle for live app and raw HTML sessions

A live app session used to be unusable: the pinpoint capture-phase click
handler owned every click, so buttons, checkboxes, inputs, and links never
fired. One boolean mode now governs the HTML/live viewer surface:

- Interact: the bridge is fully passive. Pinpoint capture, hover outline,
  drag-selection toolbar, [data-annotate] clicks, and committed-highlight
  click interception are all gated behind annotateModeActive, so clicks,
  forms, text selection, and SPA navigation reach the page natively.
  Committed markers and highlights stay VISIBLE, and marker buttons keep
  their clicks (a marker click still opens its comment).
- Annotate: classic behavior, unchanged. Live sessions annotate exclusively
  via pinpoint while armed.

Control: a single bubble icon button in the editor header (icon never
changes; armed = accent + visible border, idle = transparent border of the
same width, so the box is pixel-identical in both states), plus a subtle
inset accent ring floated over the viewer while armed (pointer-transparent,
no layout shift). Keyboard: Mod+Shift+A through the shortcut registry
(html-annotate scope; the bridge mirrors the chord inside the iframe and
forwards it over the authenticated postToParent path). Esc gains a final
ladder rung: draft closes first, then the hover outline clears, then Esc
exits Annotate back to Interact (bridge posts annotate-exit; a parent-side
listener covers Esc with editor focus). The parent owns the mode and pushes
it with the same re-post-on-ready pattern as set-input-method, so it
survives live page changes, HMR reloads, and bridge re-injection without
ever reloading the iframe.

Defaults: live app sessions START in Interact; static/raw HTML sessions
START in Annotate (today's behavior preserved, and the srcdoc bridge default
keeps behavior byte-identical when no set-annotate-mode ever arrives).
Session-only state, no persistence. Vim navigation is available only while
Annotate is armed.

Covered by new bridge-harness and parent-side DOM tests in
htmlLiveProtocol.test.tsx and htmlPinpointProtocol.test.tsx: Interact
pass-through, armed capture, the Esc ladder order, mode survival across
re-injection, marker clicks in Interact, and both defaults.

* feat(live-annotate): pinpoint-armed default, always-on drag comments, comment-only HTML surfaces

Simplifies the Interact/Annotate design after live review. The new
contract replaces the previous one where they conflict:

- BOTH surfaces (raw HTML and live app) now START ARMED with pinpoint;
  the live-session Interact default is gone. Esc keeps the ladder
  (close draft, clear hover, then exit to Interact) and the header
  toggle re-arms. The bridge also paints the pinpoint cursor at init
  instead of waiting for the parent's first round trip.
- The header toggle is a PEN icon: the old bubble sat next to the
  annotations-panel bubble and the two were indistinguishable. Same
  box geometry (armed = accent + visible border, idle = transparent
  border of identical width), aria-pressed, Mod+Shift+A, and the
  armed ring over the viewer are all unchanged.
- Text drag-selection commenting is ALWAYS live on HTML/live surfaces,
  in BOTH states: the selection pass is ungated from annotateModeActive
  and from the pinpoint input method. In armed pinpoint, click = pin an
  element and drag = select text, simultaneously; the >4px drag arming
  decides which one a gesture was, a completed drag's trailing click
  never re-pins (one-shot dragEndedClick), and a plain click is never
  swallowed (the pass only acts on a real selection and never
  preventDefaults). Esc in Interact still closes an open drag draft
  before yielding to the page.
- HTML/live surfaces are COMMENT-ONLY: useHtmlAnnotation clamps
  redline/quickLabel (both the host mode and a bridge-posted
  modeOverride, so a hostile page cannot force a DELETION), the
  selection toolbar drops Delete and quick labels behind a new
  commentOnly seam on AnnotationToolbar, and the quick-label picker
  portal is gone from HtmlViewer. Markdown surfaces keep the full
  toolbar, and persisted DELETION annotations still restore.
- The "Show tools"/"Hide tools" header button is removed. It hid the
  floating toolstrip (now gone from HTML surfaces entirely: with
  comment-only plus both input paths live there is nothing left to
  switch), the collapsed sidebar tab flags, and the viewer's floating
  action cluster (attachments + global comment + version-diff toggle),
  all of which are now always visible. htmlChrome persistence keeps
  only the sidebar/panel state; an old cookie's toolsHidden flag is
  read tolerantly and ignored, so a stale record cannot strand a user
  with hidden chrome and no way back.
- HTML surfaces pin the viewer input method to pinpoint (the drag/
  pinpoint switch is meaningless when both are live); the Alt input
  switch no-ops there. Vim stays armed-only, as built.

No server, proxy, or protocol-security changes; the armed flag stays
session-only.

Tests: the live-bridge harness is reworked around the armed default
(forged-DISARM posture, drag-selection passes in armed and Interact,
the trailing-click guard), the pinpoint suite covers the comment-only
toolbar and the redline/quickLabel clamp at the trust boundary, a new
AnnotationToolbar.commentOnly seam test guards both surfaces'
toolbars, App.htmlChrome.test.tsx replaces App.htmlHideTools.test.tsx
(no tools button, stale-cookie tolerance, pen armed default), and the
htmlChrome tests cover the narrowed persisted shape.

* feat(live-annotate): collapsible floating controls cluster

The simplification removed the Hide tools toggle, which left the floating
comment/attachments cluster permanently over the page. Restore a hide
affordance on the cluster itself: a collapse chevron shrinks it to a small
expand pill in the same corner, so the page is never obstructed without a
way back. Collapsed state persists with the rest of the HTML chrome cookie
(sidebar/panel), tolerantly read. Hosts that do not wire the toggle
(readOnly viewers, review-editor panels) are unchanged.

* feat(live-annotate): header Show/Hide tools replaces the collapse pill

The collapse pill was a half measure: it left its own artifact over the
page and the sidebar tongue tabs stayed. Revert it and restore the real
thing as a header control: an eye toggle immediately left of the pen that
removes ALL floating chrome over the page from the DOM (sidebar tongue
tabs + the comment/attachments cluster), leaving nothing behind. The
toggle lives in the header, so a hidden state always has a way back,
which also makes honoring a persisted (or pre-existing) toolsHidden
cookie safe again.
2026-08-19 10:44:21 -07:00
Michael Ramos 15f8d4fe4c feat(review): collapse generated files by default in the all-files view (#1346)
* feat(review): collapse linguist-generated files by default (#1317)

Code review now respects linguist-generated (and linguist-generated=true)
from .gitattributes, collapsing those diffs by default the way GitHub does.

Server (Bun + Pi mirror): a generatedFiles sidecar rides /api/diff and
/api/diff/switch, resolved through git's own attribute machinery — one
batched 'git check-attr --stdin -z' over the served patch's paths at the
review cwd, so stacked and negated rules land exactly as git resolves
them. Plain local git sessions only; PR worktrees, workspace multi-repo,
jj, GitButler, and P4 omit the sidecar (degrade to no-collapse). Shared
logic in packages/shared/generated-files.ts, vendored to Pi.

Client: generated files SEED their CodeView item collapsed (the existing
Pierre collapse state — same mechanism as commit-diff folding), render
the one-line FileHeader bar with a 'generated' tag next to the +/- counts,
and expand per file on click. Expansion is session-local App state so it
survives remounts and diff switches. Presentation-only: the diff data,
annotations, search, and Edit Mode are untouched; the file tree and
single-file tabs list generated files normally (tag, no auto-collapse).

Guide viewer manifest pin regenerated (AllFilesCodeView/FileHeader are
bundled into the guides.show viewer) from a clean frozen-lockfile install.

* feat(review): built-in generated defaults, visible collapsed strip, review-round fixes (#1317)

Round 2 on PR #1346, per maintainer review.

Built-in generated defaults (industry-standard two-layer detection):
packages/shared/generated-files.ts (vendored to Pi) now carries
DEFAULT_GENERATED_PATTERNS — lockfiles (package-lock.json, yarn.lock,
bun.lock, Cargo.lock, go.sum, ...) plus *.min.js / *.min.css / *.map —
matched against the path's last segment only. Explicit .gitattributes wins
in BOTH directions: linguist-generated (set/true) marks any file,
-linguist-generated / =false un-marks even a built-in name, unspecified
falls through to the defaults. In plain local git sessions check-attr
refines the defaults; the non-git degrade modes (piped patches, PR
worktrees, workspace, jj, GitButler, P4) now emit the sidecar from the
name-based defaults alone instead of omitting it.

Visible collapsed state: a collapsed generated card no longer renders as a
bare header — a GeneratedFileNotice strip ('Generated file collapsed',
+N/-N, 'Click to view') styled like the other below-header notices sits in
the card, and clicking it expands through the SAME reportFileCollapsed
funnel as the chevron.

Review findings:
- F1: search-match and sidebar-comment navigation expanded items without
  reporting through the funnel, so those expansions died on diff switch.
  Both now call syncAllCollapsedMirror + reportFileCollapsed; the funnel
  invariant comment lists the navigation-driven sites.
- F2: the check-attr call gets the same 5000ms timeout as review-core's
  stdin git callers, and Pi's vcs.ts stdin write gets the one-line EPIPE
  guard (call-flow.ts shape) a timeout kill makes reachable.
- F3: removed the dead prevGeneratedRef + collectSetDelta leg — a changed
  generated set always remounts via fileSetKey, so the delta path was
  unreachable.

Tests: default-list matching (glob + directory-named-bun.lock), both-
direction precedence, non-git sidecar from defaults (dual-runtime), the
placeholder strip through the funnel, and search expansion surviving a
re-seed round-trip. AGENTS.md payload docs updated. Guide viewer manifest
pin regenerated from this clean frozen-lockfile worktree.
2026-08-17 23:32:29 -07:00
Michael Ramos a899ea657f fix(review): stop the call-flow Lens closing mid-scroll and opening on drive-by hovers (#1338)
* fix(review): stop the call-flow Lens closing mid-scroll and opening on drive-by hovers

The Lens popup scrolls internally but never contained overscroll, so momentum
that hit its edge chained to the page; the page scroll moved the anchor, the
popup tracked it out from under a stationary pointer, and the resulting
mouseleave closed the Lens mid-scroll (worst under Safari rubber-banding).
Scrolling the diff also dragged file headers under the cursor, and the
zero-delay hover open popped the Lens for every badge that passed.

- overscroll-behavior: contain on .call-flow-popover
- 100ms hover-intent delay before opening; a pointer that leaves first never opens it
- close grace 140ms -> 250ms
- while a scroll is in flight, pending hover-closes are held; once it settles
  the Lens closes only if the pointer really ended up outside

Reported by @acewhocares on X (Safari).

* ci(guides-show): publish the built viewer manifest as an artifact

The viewer build is deterministic per platform but not across platforms, and
the committed pin must match the Linux CI build. When the manifest check fails
on a PR, a maintainer on macOS previously had no way to obtain the
authoritative hashes. The uploaded manifest.json closes that: download, drop
into apps/guides-show/dist/viewer/, run sync:manifest, commit.

* fix(guides): regenerate viewer pin for the Lens changes; correct the manifest-artifact comment

The cross-platform nondeterminism claim in the previous commit was wrong. A
clean install builds identical viewer hashes on macOS and Linux; the divergence
observed here came from a drifted local bun store carrying stale duplicate
package versions (rm -rf node_modules + frozen install reproduces CI exactly,
verified against the deploy run's published names). The artifact upload stays:
it is the authoritative recovery path when a local environment is the thing
that is wrong. Pin regenerated from a verified-clean build for the Lens
bundle changes.
2026-08-17 10:37:05 -07:00
FND e1ce7dabe1 feat(ui): Totman/Classic P favicon style switcher (#1325)
Favicon style switcher in Settings > Theme: the Totman mascot or the historical dark-navy P tile (byte-identical to the pre-Totman asset, sha256 pinned). Served server-side from first paint in both runtimes; opt-in for hosts of the published UI package. Contributed by @FNDEVVE
2026-08-16 21:58:46 -07:00
Michael Ramos d0c32c8863 fix(review): preserve dragged diff ranges on compact touch before commenting (#1333)
* feat: add mobile touch range selection prototypes

* docs: redirect mobile range selection spike

* revert: remove command-mediated touch range prototype

* docs: align mobile line selection with DiffsHub

* feat: preserve mobile diff ranges before commenting

* fix(review): repaint a preserved mobile range on a second drag

A preserved range leaves pendingSelection non-null, so DiffViewer hands
Pierre a defined selectedLines and Pierre switches to controlled
selection: updateSelection then only records a proposed range and leaves
painting to the host. With no change handler wired, a second drag never
repainted, so the old highlight stayed put and the finger was untracked
until release. Wire onLineSelectionChange back into app state, only on
compact touch, so desktop keeps an options object with no such key.

Also route a null range to the toolbar host instead of swallowing it in
the preserve branch, mirroring AllFilesCodeView's early return so an open
composer (Ask AI included) closes with the selection it was written for.

* fix(review): 44px hit area for Pierre's gutter comment button on touch

With a dragged range now preserved instead of opening the composer, that
button is the only way to start writing about it, and it is roughly 20px
square: below the data-pn-touch-target standard the rest of the compact
shell holds. Grow its invisible ::before hit area to the 44px token,
leaving the glyph alone.

The rule ships through the same unsafeCSS both diff surfaces already
inject, which lands in Pierre's shadow root inside @layer unsafe (last in
the library's layer order, so no !important). It is injected only when
the shell is compact rather than gated in CSS: html:has() matches nothing
from inside a shadow root, and @media (pointer: coarse) would wrongly
claim a desktop with a touchscreen.

* test(review): cover the compact-touch preserved range on both diff surfaces

DiffViewer.compactTouchSelection.test.tsx drives the FileDiff options the
component hands Pierre: a drag preserves the range instead of opening the
composer and paints it through selectedLines, a second drag repaints
through onLineSelectionChange, the gutter action opens the composer, a
cleared range still reaches the toolbar host, and desktop keeps routing
completed drags straight to the composer with no change handler at all.
The last three assertions fail against the pre-fix component.

The AllFilesCodeView compact test only checked what was published upward,
which a range nothing paints would also satisfy; it now asserts the range
reaches the CodeView props. That needs the App loop, so the mount feeds
published selections back down as pendingSelection: without it the
reconcile effect clears the highlight the preserve branch just painted.

* chore(guides-show): regenerate viewer manifest pin for the touch selection changes
2026-08-16 21:47:43 -07:00
Michael Ramos 64062af9a1 feat: Portable Guided Reviews — export, share links, agent-authored guides, guides.show (#1324)
A Guided Review can now leave Plannotator: as a single self-contained HTML file that renders exactly like the in-app guide, as an encrypted-by-default share link on guides.show, or authored by any agent through the new guide CLI.

Highlights: packages/guide-viewer extracted from review-editor at the injection seam (read-only host, no third renderer); guides.show Worker with R2-backed share storage, per-IP rate limiting on creation, delete tokens hashed at rest, and 128-bit ids; portable exports pin the viewer by SRI hash with budget and manifest gates in PR CI and at deploy; two-runtime parity across Bun and Pi verified; v0.27.x saved guides load unchanged. Retention is indefinite by explicit decision, to revisit with the lean sharing refactor.

Decision record: adr/decisions/007-portable-guided-reviews-20260815.md
2026-08-16 12:17:13 -07:00
Michael Ramos d6d727b34f ci(release): add SBOM and Grype release gate (#1298) 2026-08-13 11:45:48 -07:00
Michael Ramos 58598bbf2b ci(security): add isolated ZAP DAST monitoring (#1299) 2026-08-13 09:46:41 -07:00
Michael Ramos 64e3fa7762 feat(review): add compact touch review shell (#1301)
* feat(review): add compact touch review shell

* fix(review): let submission dialog own initial focus

* fix(review): refine compact mobile review chrome

* fix(review): restore reliable mobile diff scrolling

* fix(review): preserve mobile file identity

* docs(mobile): specify phase 2b plan shell

* fix(review): close mobile shell regressions

* fix(review): restore narrow overview stacking

* test(review): preserve real syntax theme resolver
2026-08-13 09:29:24 -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 356b628b6f ci(security): add Semgrep CE and Trivy monitoring (#1294)
* ci(security): add Semgrep CE and Trivy monitoring

* fix(ci): diagnose Trivy coverage assertions

* fix(ci): accept Trivy repository scan metadata

* fix(ci): harden scanner failure diagnostics
2026-08-12 20:40:08 -07:00
Michael Ramos 3282be673b fix(review): hide viewed and stage controls in file headers when toggled off (#1288) 2026-08-12 17:06:19 -07:00
Michael Ramos d4ce3dcb57 ci: harden releases and add security scanning (#1274)
* ci: harden release and add security scanning

* Harden release and deploy recovery paths

* Fix npm artifact pack destinations
2026-08-12 11:40:15 -07:00
Michael Ramos 8e7b5ce300 feat(review): refine Call Flow navigation and annotations (#1277)
* feat(review): refine Call Flow navigation and annotations

* fix(review): align viewed controls with panel navigation

* feat(review): add Call Flow path search controls

* fix(ui): wrap long tooltip identifiers

* feat(review): annotate raw Call Flow output

* feat(review): refine call flow lens context

* fix(review): keep call flow lens search accessible

* fix(review): scope call flow find shortcuts
2026-08-12 11:18:29 -07:00
Michael Ramos fc348687bf fix(review): contain /api/call-flow analysis throws as JSON error responses (#1272)
* fix(review): contain /api/call-flow analysis throws as JSON error responses

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(review): install CallDiff grammars selectively

* fix(review): harden CallDiff worker environment

* fix(review): close CallDiff verification gaps
2026-08-11 16:28:08 -07:00
Michael Ramos e53a933106 fix(ci): bump Bun build pin to 1.3.14 for sandbox env loading (#1249) (#1250)
Bun <= 1.3.11 loads an empty process.env when a cwd ancestor directory
is unreadable, the normal state inside OS sandboxes (Seatbelt/Landlock):
every released binary silently ignored all PLANNOTATOR_* env vars there
(oven-sh/bun#27802, fixed in 1.3.13). The 1.3.11 pin existed for the
Bun 1.3.12 cross-compile signing regression (#541, binaries SIGKILLed
on macOS); verified gone on 1.3.14: cross-compiled darwin-arm64 output
carries the same linker-signed CodeDirectory as the known-good shipped
binaries and executes cleanly on macOS 26.3, and the unreadable-ancestor
env repro passes on a 1.3.14 build.

Guardrails so neither regression class can ship silently again: the
release smoke matrix gains a macOS arm64 leg (executing the binary is
the signing assertion), and a new smoke step runs the annotate server
from a cwd with an unreadable ancestor, where responding on the fixed
PLANNOTATOR_PORT proves env vars were read.
2026-08-09 21:06:26 -07:00
Michael Ramos 4d9147200d ci: wire 8 orphaned DOM-gated test files into the DOM test step
The DOM step runs an explicit file list, and eight describe.if(hasDom)
suites were never added to it, so they silently reported 0 pass / all
skip on every CI run: the three new #1243 suites (htmlPinpointProtocol,
htmlChrome, inputMethod) plus five older vim/highlight suites (vimHud,
Viewer.vimMode.integration, useVimSelection, codeHighlight,
vimNavigation). Found by the pre-release sweep. All 69 tests pass
together locally under the exact CI invocation.
2026-08-09 17:33:32 -07:00
Michael Ramos 005c32c7c8 ci: remove the install-script auto-sync job (manual sync only)
The deploy-install-scripts job added in #1214 assumed the CI deploy role
could write to the plannotator-install-scripts bucket. It cannot, by
design, and the job's first-ever real execution (the #1239 installer
change) failed on AccessDenied. Install-script syncs are manual with
local credentials; the workflow now carries a comment pointing at the
release runbook's exact commands instead of a job that cannot succeed.
2026-08-09 16:24:47 -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 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 548497e6f1 fix(annotate): hide the collapsed sidebar tab flags with "Hide tools" (#1226)
The header's "Hide tools" toggle dropped the annotation toolstrip and the
HtmlViewer action cluster, but the collapsed sidebar tab flags kept
protruding from the left edge, so a rendered HTML page never actually got
the whole viewport.

Fold both overlay guards into one derived `htmlChromeHidden` and apply it
to the SidebarTabs render. The flags unmount rather than fade, so nothing
focusable stays in the tab order and no click target sits over the page.

The toggle only exists on HTML surfaces, and both restore paths stay
visible: "Show tools" lives in the header (never hidden), and Mod+B still
opens the sidebar directly.
2026-08-06 18:29:07 -07:00
Michael Ramos 75e8b78cc7 fix(review): stop stubbing files whose worktree content the size probe cannot find (#1220)
The oversized preflight in buildBoundedTrackedDiff mapped every object the
cat-file batch could not size to infinity. `missing` is routine: for
tree-vs-worktree diffs git hashes the WORKING-TREE content of any path pulled
into rename/copy detection and prints that hash in --raw output without ever
writing the blob, and partial clones report it for unfetched blobs. Those
files were excluded by pathspec and replaced with a contents-free binary stub,
so a renamed-and-edited file rendered as a silently empty card and
/api/file-content refused it as binary.

Missing now means unknown, not oversized, and an unreadable new side is bounded
by the working-tree file's stat size instead. Every rendered diff stays bounded
git-side by core.bigFileThreshold (#1205), genuinely oversized files still stub
via real probe sizes plus the stat door, and a blob git truly cannot read now
fails loudly through assertGitSuccess instead of blanking a file.

Client side, a chunk with a binary marker and no hunks now renders an explicit
placeholder in both the all-files view and the single-file viewer, so an empty
card can never again pass for "no changes here".

AI-assisted.
2026-08-06 00:47:19 -07:00
Michael Ramos 7ba4e3b3e4 fix(review): mint content-derived diff cache keys so single-file tabs render fully (#1219)
* fix(review): mint content-derived diff cache keys so single-file tabs render fully

Single-file diff tabs have not rendered their full-content diff since
v0.26.0: the expansion gap bars show no chevrons and clicking them does
nothing, at every file size.

@pierre/diffs 1.3.2 (the 1.2.8 -> 1.3.2 bump, upstream "Fix diff rerender
in edit mode (#878)") added name-based cacheKey defaulting in
FileDiff.render: an unset `fileDiff.cacheKey` becomes the file's name.
`areDiffTargetsEqual` — the only identity check its render and highlight
caches make — compares nothing but that key.

DiffViewer renders each file twice on one surviving FileDiff instance
(key={filePath}): first the PARTIAL diff from getSingularPatch, then the
AUGMENTED full-content diff from processFile once /api/file-content
lands. Neither set a cacheKey, so both defaulted to the filename and
Pierre served the stale partial render forever. Only the augmented diff
is expandable, hence the dead gap bars.

Both diffs now mint content-derived keys (`<path>#<hash>` and
`<path>#full#<hash>`), matching how AllFilesCodeView already keys its
items — which is why the all-files view was never affected. The hash
(not patch.length) matters because Pierre's worker highlight cache is a
singleton that outlives remounts. The partial diff needs its own key too:
with key={filePath} the instance also survives diff-type and base
switches, where a same-named new patch would otherwise hit the same
name-keyed stale cache.

hashString moves from AllFilesCodeView to utils/hashString.ts so both
surfaces mint keys the same way.

Covered by a new DOM test that mounts DiffViewer against the real
@pierre/diffs renderer, holds the /api/file-content response until the
non-expandable partial baseline is asserted, then requires the expansion
affordances to reach the pixels. It fails against the unfixed tree.

* fix(review): explain why an oversized file's card has no diff

Files over the 5 MB review limit are replaced by a contents-free stub
(buildOversizedTrackedStub, plus the untracked equivalent), which renders
as a header-only card with no counts and no explanation. Users read that
as a broken diff.

The stub now carries an explicit marker line in its extended header
(OVERSIZED_REVIEW_STUB_MARKER). A marker rather than a client heuristic
because the only other signal, `Binary files ... differ`, is exactly what
a genuine binary file emits, so a heuristic would put a false size-cap
explanation on every image in the diff. The marker lives in
shared/diff-paths so the browser bundle can detect it without pulling in
the node-facing review core; both server runtimes pick it up from
review-core, which vendor.sh already copies to Pi. Git ignores unknown
extended-header lines and @pierre/diffs parses the stub identically with
or without it, so nothing else moves. Which files get stubbed is
unchanged.

Both review surfaces now render one line under the file header saying the
file is over the limit and only a stub is shown.

* test(review): make the diff-swap proof machine independent, not stopwatch based

CI failed two tests that pass locally. Both were timing races, neither was
an app bug.

1. DiffViewer.fullContentSwap: the swap assertion carried a 15s internal
   wall-clock budget, which a cold, contended CI runner blows and a warm
   laptop clears. Two changes, both aimed at the clock rather than the
   symptom:

   - The waits are now budgeted in SCHEDULER TURNS, not milliseconds. A
     slower box spends longer inside each turn but needs no more of them,
     so the budget never has to be retuned for CI hardware.
   - Pierre's shared Shiki highlighter is preloaded before mounting. It is
     a module singleton, and building it was the entire multi-second cost
     the old budget was accidentally measuring; warming it moves that work
     into an unbounded await OUTSIDE the observed window. Disposed in
     afterAll, because packages/ui/utils/codeHighlight.test.ts asserts the
     pre-attachment behaviour of that same singleton.

   Verified against an artificially stalled clock: forcing 20s of dead time
   into every wait (41s total, far past the old 15s budget) still passes,
   and with the cacheKey fix removed it still fails on the assertion (not
   as an opaque timeout) in ~12s. A 20-turn budget with the preload removed
   and every core saturated also passed 10/10, so 400 turns is a wide
   margin rather than a guess.

2. App.archiveReadOnly compared the fenced block's innerHTML before and
   after a click. Since #1218, applyHighlight writes plain text first and
   swaps in Shiki markup when the grammar attaches, so that MARKUP changes
   on its own schedule and the assertion was racing the swap. The test is
   checking that the click opened no mutation entry point, which textContent
   plus the absence of an annotation <mark> says exactly, and which no
   highlight swap can perturb. Latent on main; the branch's run happened to
   lose the race.

Also fixed while confirming the above: codeHighlight.test.ts asserted a
GLOBAL precondition ("no grammar attached yet") that any earlier file
attaching a typescript fence invalidates, so
`DOM_TESTS=1 bun test packages/ui packages/editor` failed by file order
alone. It now resets the attachment cache through the existing
__resetCodeHighlightCacheForTests seam and asserts the contract instead of
the run order. Not currently reachable from CI (that file is not in the DOM
list), but one list edit away.

* test(review): drop the highlighter preload, harden the swap proof, report why a paint is missing

The preload added in the previous commit made CI strictly worse, so it is
gone. Before it, CI's partial diff painted and only the swap was missing;
with it, CI never painted at all. It was an optimization for a theory the
evidence has since killed, and it mutated a process-wide singleton to buy
it, so it is not worth keeping while the real failure is unexplained. The
afterAll dispose that existed only to undo the preload goes with it.

What the CI log actually shows:

  - The "WorkerPoolManager: operation canceled because the pool terminated"
    error is inside discardRestoreRender.test.tsx's own group, ~0.3s BEFORE
    this file's group opens. It is that file's provider unmounting and
    terminating the pool singleton it created: end-of-file teardown, the
    same benign noise documented on #1209. It also prints on every local
    run, where the whole list passes. It is not a mid-test terminator, and
    nothing in this file uses the worker pool (no WorkerPoolContextProvider
    is mounted, so useWorkerPool() is undefined and rendering takes the
    main-thread path).
  - This file's group prints NOTHING for its whole 10.3s: no console.warn
    from the stale-content guard, no error. Pierre simply painted nothing.

Not reproducible locally: the exact DOM list from test.yml, one bun
process, forward and reverse order, 13 runs with every core saturated, all
green. So the remaining difference is the environment, which cannot be
reasoned out from here. Three changes make the next CI run answer it
instead of costing another guess:

  - renderDiagnostics() dumps what Pierre actually painted (container /
    separator / chevron / line-number counts plus a markup fragment) when a
    wait gives up. Prints only on failure, so it is worth keeping.
  - The precondition is asserted rather than assumed: the REAL
    getSingularPatch and processFile must produce partial-then-full on
    these fixtures. Bun's mock.module is process global and an earlier file
    in this very list mocks '@pierre/diffs', so a leaked mock now fails in
    milliseconds with a clear message instead of as a render that never
    arrives.
  - The first paint is now REPORTED, not asserted. The verdict belongs to
    the swap; gating on the partial paint let a slow or absent first paint
    mask the result the test exists for. Removing the cacheKey fix still
    fails it (verified), because that tree paints no chevrons at any point.

Also fixed a real trap in the fixture: the hunk header said @@ -61 while
its context lines start at line 59 of both contents. Pierre realigns a
misaligned header rather than rejecting it, so it was silently tolerated.

* test(review): stop a leaked module mock from silently unrendering the diff tests

Root cause, and it was never a timing problem.

AllFilesCodeView.lifecycle.test.tsx calls
`mock.module('@pierre/diffs', ...)` with a hunk-less `getSingularPatch` and
`processFile: () => null`. Bun's module mocks are process global and are not
unwound at file boundaries, and that file sits immediately before
DiffViewer.fullContentSwap.test.tsx in the DOM step's list. On the Linux
runner the stub reached this file; on macOS it did not, which is why 13
local runs of the exact list, both orders, cores saturated, stayed green.

It explains both CI symptoms exactly, including the one that looked like a
contradiction: `processFile: () => null` means the augmented diff never
exists, so no chevrons ever (the failure before the preload); the stub
`getSingularPatch` has `hunks: []`, so nothing paints at all (the failure
after it). The "WorkerPoolManager: operation canceled because the pool
terminated" line was a red herring throughout: it is inside
discardRestoreRender's own group, ~0.3s BEFORE this file's group opens, is
that file's provider unmounting the pool it created, and prints on every
local run too.

The precondition assertion added in the previous commit is what proved it,
turning a 10.3s mystery into a 0.45ms verdict:

  228 |       expect(expected?.isPartial).toBe(false);
  error: expect(received).toBe(expected)
  Expected: false
  Received: undefined

Fixed at both ends:

  - Source: the mocking file now captures the real modules before it stubs
    them and restores both library specifiers in afterAll, so no later file
    in any run inherits its stubs. This fixes the class for every future
    DOM test that needs the real renderer, which was the actual leak.
  - Consumer: the two tests that render against the real @pierre/diffs get
    their own CI step, the same isolation (and for the same kind of reason)
    this workflow already gives useFileBrowser.test.tsx. They are removed
    from the shared list so that step is their single source of truth. The
    restore above should make this unnecessary; it is not something to bet
    a green build on from a machine that cannot reproduce the platform
    behaviour.

Verified with a CI-faithful harness: one bun process per step, the exact
lists from test.yml, isolated + shared-forward + shared-reverse, 10
iterations with every core saturated, then 6 more after the final split.
All green, plus the full suite and typecheck.

* docs(test): point the diff-renderer DOM tests at the CI step that actually runs them
2026-08-06 00:31:25 -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 8396eeb787 ci: auto-sync install scripts to their dedicated S3 bucket (#1214)
plannotator.ai serves /install.sh, /install.ps1, and /install.cmd from the
plannotator-install-scripts bucket, not the marketing bucket the deploy
workflow syncs. Nothing in CI wrote to that bucket, so the served scripts
went stale between hand syncs: v0.26.0 shipped while the site still served
the July 31 installer, missing --skip-skills, the checkout guard, and the
PowerShell < 7.2 fix. This adds a deploy-install-scripts job triggered by
changes to the three scripts (plus a workflow_dispatch target), uploading
them with content types and invalidating exactly those paths.
2026-08-05 10:44:06 -07:00
Michael Ramos 7aff70281f fix(review): repaint pristine diff immediately on edit-mode Discard (#1209)
writeRestore's combined item write leaves upstream's highlighted render
cache serving the stale edited content; clear the live instance's render
cache and rerender so the pristine diff paints immediately (plaintext
first, then the normal async highlight). Covers Discard, Suggest,
finishIfEditing, and the deferred external-teardown restore.

Adds a DOM-gated integration test driving the real @pierre/diffs
CodeView + EditProvider + worker pool through startEdit -> real
Editor.applyEdits -> cancelEdit, asserting the pristine content is
back in the shadow DOM within a bounded settle.
2026-08-05 08:50:53 -07:00
Michael Ramos e97b703479 ci: glob the OpenCode smoke tarball instead of pinning the version (#1203)
The installed-package smoke passed a hardcoded plannotator-opencode-0.25.1.tgz
path to the fixture, so every version bump would silently break the job on the
bump commit. npm pack writes exactly one plannotator-opencode-*.tgz into
RUNNER_TEMP, so a glob resolves to the same file and survives bumps.
2026-08-04 21:51:28 -07:00
Michael Ramos 10a5104888 fix(install): repair the skills checkout guard, add --skip-skills (#1201)
* fix(install): make a failed skills checkout stop reporting success

The skills/commands checkout runs in a subshell written as
`( set -e; ... ) || checkout_failed=1`. POSIX ignores `set -e` for every
command of an AND-OR list except the last, and bash 3.2.57 (what
`curl | bash` gets on macOS), bash 5.3, dash, zsh and ksh all carry that
suppression into the subshell. The `set -e` was inert.

A failed clone therefore ran the whole block anyway, the subshell exited
with the status of its trailing `if` (0), `checkout_failed` stayed 0, and
the installer printed "YOU'RE ALL SET!" with no skills installed. It also
blamed the wrong thing, printing "Tag vX.Y.Z predates the per-agent skill
layout" when the real cause was a failed clone.

Drop the inert `set -e` and guard the four fetch steps with explicit
`|| exit 1`. Everything after the checkout stays best-effort, matching
install.cmd, which only checks git clone and lets every xcopy run
unchecked. A local cp/mkdir/rm failure must not surface as the
"network or git error" message.

Verified on bash 3.2.57 in a sandboxed HOME: the failure case now exits 1
with the fetch error and no success banner, and original vs patched
success runs produce byte-identical trees (74 paths, 35 files) and
identical logs.

* feat(install): add --skip-skills opt-out

The skills and slash commands come from a sparse `git clone` of the release
tag. There was no way to decline that fetch short of --minimal, which also
drops the sem sidecar, the agent-terminal runtime, the hooks, and every
per-agent config. Anything that installs a tag github.com cannot serve had
no option at all.

Add --skip-skills to all three installers, following the existing
--skip-codex / --skip-gemini / --skip-kiro / --skip-opencode family: CLI
flag (-SkipSkills in PowerShell), PLANNOTATOR_SKIP_SKILLS_INSTALL env var,
skipInstall.skills config key, resolved flag > env > config. It is not a
per-agent switch; it covers every scope the checkout writes (Claude,
~/.agents, OpenCode, Gemini, Kiro), the extras, and the skill-scope cleanup
sweeps. Skip means do-not-write: nothing already installed is replaced or
removed, and git stops being a hard requirement. The run reports
"Skills: skipped (<source>)" and the closing banner no longer claims the
/plannotator-* commands are ready, which is the same false-success the
checkout guard exists to prevent.

Use it in the install-script-smoke job. That job installs a synthetic
v9.9.9: the fake curl serves the freshly built binary for any URL, but the
skills clone goes to real github.com, where the tag does not and cannot
exist. That clone has always failed; it only went unnoticed while the
broken guard let the installer exit 0 anyway. The job asserts Codex hook
config, not skills, so it opts out rather than ignoring a real error. Both
run_installer call sites go through the one function definition.

Verified in an env -i sandbox on bash 3.2.57 (what `curl | bash` gets on
macOS) with a fake curl and a local stand-in remote. Flag, env var, and
config each skip and name their own source; flag beats env=0; env=0 beats
config true; an explicit "skills": false stays a veto. Without the flag,
pre-change and post-change runs produce byte-identical trees (39 entries)
and identical logs. A bad clone URL without the flag still exits 1 with the
fetch error and no success banner. A --skip-skills re-run over an existing
install leaves all 12 skill and command files byte-identical. The CI step
was reproduced locally: both run_installer calls exit 0 and every Codex
assertion still passes.

* test(install): cover --skip-skills and repoint the pinned source strings

scripts/install.test.ts asserts against exact install-script source text, so
six assertions broke when --skip-skills landed. Each is repointed at the new
string with its intent preserved, not weakened:

- The "hook/config writing happens before the git hard-fail" ordering test
  keeps proving the ordering; it just matches the gate's new conditional
  form. git being a hard requirement is now a narrower invariant (it applies
  only when the checkout actually runs), so that is asserted separately
  rather than dropped.
- The skipInstall walk assertions follow codex/gemini/kiro/opencode gaining
  a skills entry, in install.sh's `for _agent` loop and install.cmd's
  PowerShell key list.
- The three "never remove" sweep assertions follow the Codex stale-skill
  cleanup gaining its skills-opt-out arm, in all three installers.

Add nine tests covering --skip-skills itself in the same style as the
per-agent family: flag/switch parsing, PLANNOTATOR_SKIP_SKILLS_INSTALL,
skipInstall.skills, and flag > env > config precedence by textual layering,
for each installer. Each installer also gets a test that the opt-out bails
before the clone and leaves the checkout guard intact (#1201's fix must keep
failing a real fetch error), and one that the run reports honestly, never
prints the "commands are ready" banner over an empty skills dir, and
suspends the extras, the model-invocation rewrite, and the stale-stub sweeps
rather than applying them partially.

bun test scripts/: 116 pass, 6 skip, 0 fail. Full bun test: 2884 pass,
234 skip, 0 fail. bun run typecheck clean.
2026-08-04 21:49:49 -07:00
Michael Ramos d7be3406f6 fix(ci): repair the OpenCode 2 installed-package smoke (#1202)
The smoke's wait budgets were sized on a warm macOS dev box (5s to a healthy
server, 20s to plugin activation). On a cold Linux runner OpenCode 2 needs
longer to boot and has to install the packed plugin plus its whole dependency
closure through the fixture's throwaway registry first, so the job has failed
on every run since it was introduced.

Measured, same opencode2 build and the same packed tarball:

  macOS, warm caches:      healthy 0.8s, plugin activated 6.3s
  linux/amd64 container:   healthy 8.2s, plugin activated 45.3s

Raise the budgets to 120s and 300s (overridable via
PLANNOTATOR_SMOKE_HEALTH_TIMEOUT_MS / PLANNOTATOR_SMOKE_PLUGIN_TIMEOUT_MS) and
give the job a 25 minute backstop. The assertion is untouched: the smoke still
requires the plugin registry to report the plannotator plugin.

Also make a failure legible and prompt. Each poll gets a per-request timeout so
one wedged request cannot swallow the budget, waits report progress, failures
carry the elapsed time and the last HTTP status/body, and teardown escalates to
SIGKILL and force-closes the registry. The CI failure previously burned five
minutes in teardown before printing anything.
2026-08-04 20:47:45 -07:00
Michael Ramos 6b8723cc57 test(review): repair guide virtualization mock and run its tests in CI (#1200)
The AllFilesCodeView lifecycle test stubs '@pierre/diffs/react' with
mock.module, which replaces the whole specifier. #1193 added an
EditProvider import to AllFilesCodeView, but the stub was never
extended, so the file throws at import when run on its own.

The three DOM tests #1158 added for Guided Review virtualization were
also never registered in the DOM_TESTS=1 CI step, so nothing enforced
the 8-viewer mount cap and nothing surfaced the broken stub.
2026-08-04 20:44:28 -07:00
Sergiy Dybskiy 050dfcda9a feat(opencode): add OpenCode 2 plan review adapter (#1194)
* feat(opencode): add OpenCode 2 plan review adapter

* fix(opencode): harden V2 review lifecycle

* fix(opencode): address V2 review feedback

* fix(opencode): update V2 target and isolate tests

* test(opencode): assert prompt composition invariants
2026-08-04 17:56:33 -07:00
Michael Ramos 156761a3fa feat(review): author suggestions by editing code in place (experimental, flag-gated) (#1193)
* feat(ui): add experimental editSuggestions setting (cookie-only, default off)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Michael Ramos <ramos@plannotator.ai>
2026-08-04 11:03:30 -07:00
Michael Ramos 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
Michael Ramos 93b66e0ab2 feat(cli): add safe uninstall lifecycle (#1170)
* feat(cli): add safe uninstall lifecycle

* fix(uninstall): harden cleanup and add Windows QA

* fix(uninstall): detach Windows self-delete worker

* fix(uninstall): preserve PowerShell worker syntax

* fix(uninstall): harden purge and host recovery

* fix(uninstall): revalidate purge boundary

* fix(uninstall): unlink managed link entries safely
2026-08-01 10:26:42 -07:00
Michael Ramos d53cbfb373 fix(annotate): enforce archive read-only surfaces (#1171)
* fix(annotate): enforce archive read-only surfaces

* fix(archive): close remaining read-only leaks
2026-07-31 17:23:44 -07:00