Skip to content

Diff review modal in session menu#78

Merged
setkyar merged 18 commits into
mainfrom
feat/diff-review-modal
Jun 16, 2026
Merged

Diff review modal in session menu#78
setkyar merged 18 commits into
mainfrom
feat/diff-review-modal

Conversation

@setkyar

@setkyar setkyar commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds a working-tree Diff review entry to the session "…" menu — full-page modal that lazy-loads @pierre/diffs, renders the uncommitted diff (tracked changes plus untracked files synthesized in Go), and supports a Split/Unified toggle that follows the app theme.
  • GitHub-style inline comments via the gutter "+" or a drag-selected line range; comments persist in a new review_comments SQLite table (per-session), survive reload, and a Submit review button composes them into a chat prompt for pi.
  • Backend: new git.WorkingTreeDiff (caps untracked + total size, all git calls have a 10s timeout) and three handlers — GET /api/git/diff, GET/POST/DELETE /api/diff/reviews. Frontend: DiffModal.svelte driven by an action (no bind:this DOM ops) with a staged-and-instrumented load so the rare hang names which stage stalled.
  • Hardens stale-cache recovery (no-cache on the app shell, service-worker cache purge, Vite base: /static/) and ships diff strings in all 14 locales.

Test plan

  • make check — lint, format, knip, 639 frontend tests, build, go vet, all Go tests pass
  • e2e/tests/diff.spec.ts (6 Chromium tests):
    • full-page render + Split/Unified toggle
    • not-a-git-repo state
    • gutter "+" → save → reload → still persisted → submit into composer
    • re-themes the diff when the app theme changes
    • multi-line drag-select range comments
    • reclaims height after deleting the last comment (no phantom gap)
  • New Go tests: WorkingTreeDiff table-driven, binary untracked, 1000-untracked perf bound; review_comments CRUD/validation
  • Frontend unit test for the review-prompt builder

Manual smoke after pulling: `make build`, restart pi-web, hard-reload, open session → "…" → Diff.

setkyar added 18 commits June 15, 2026 01:43
Wire up the session menu's "Diff" action to a full-screen modal that
renders the uncommitted working-tree diff (tracked + untracked) via
@pierre/diffs with a split/unified toggle. Reviewers select line ranges
to attach GitHub-style comments, persisted per session in SQLite, and
"Submit review" composes them into a chat prompt for pi.

Backend adds git.WorkingTreeDiff plus /api/git/diff and /api/diff/reviews
handlers. The heavy diff renderer is lazy-loaded in its own chunk and
kept out of the export bundle. New diff.* strings translated across all
locales.
Address the diff review modal's rough first cut:
- Make the sheet fill the viewport instead of a small centered dialog,
  and move its styles into the embedded session.css (the live page loads
  global stylesheets, not Svelte-scoped <style>).
- Use the bundled pierre-dark/pierre-light themes so the diff matches the
  app instead of rendering on a white background.
- Surface a discoverable gutter "+" button (enableGutterUtility) so users
  can actually start a review comment on a line or selected range.

Add a Chromium e2e spec covering full-page render, the split/unified
toggle, the not-a-repo state, and the comment round-trip into the composer.
Three bugs in the diff modal:
- Adding a comment did nothing: CodeView.updateItem only re-renders when an
  item's `version` changes, but makeItem never set one, so the gutter "+"
  never produced a compose box. Stamp a fresh monotonic version per update.
- Comments now persist (they were saved server-side already; the broken add
  path just meant nothing was ever stored to reload).
- The diff ignored app theme changes. Observe data-theme and push the new
  themeType into CodeView so the diff re-themes live.

Rework the e2e to exercise the real flow — gutter "+", type, save, reload,
reopen (persisted), submit — plus a theme-switch test.
CodeView.setOptions REPLACES the options object rather than merging, so
each setLayout (diffStyle) and theme-change call was silently wiping
enableLineSelection, enableGutterUtility, renderAnnotation, and
onGutterUtilityClick. After any split/unified toggle or theme switch the
diff lost line selection and the comment "+", so multi-line drag-select
comments never worked.

Retain the full options object and merge patches into it. Add an e2e that
drag-selects a line range (after toggling to unified) and asserts the
persisted comment spans multiple lines.
WorkingTreeDiff spawned one `git diff --no-index` per untracked file, so a
tree with thousands of untracked files took tens of seconds and the modal
sat on "Loading diff…". Synthesize untracked-file patches in-process (one
read each), cap the combined patch at 2 MiB, skip oversized/binary files,
and add a 10s timeout to the git diff command.

As a frontend safety net, race the load against a 30s timeout so a stalled
endpoint or renderer chunk shows an error instead of spinning forever.
manualChunks forced @pierre/diffs + all of shiki (every language grammar)
into a single 'diffs' chunk, producing a ~9.9 MB blob that the diff modal
must download before it can render. On a slow or remote (e.g. Tailscale)
connection that stalls past the load timeout, surfacing as "Diff timed
out". Let the dynamic import split it naturally: shiki lazy-loads only the
grammar for each diffed file, so the initial download drops dramatically.
The Go server serves built assets under /static/assets/, but Vite's default
base (/) made the chunk loader request dynamically-imported chunks from
/assets/… — which 404. The app entry happened to load (its path is built
from the manifest with the /static/ prefix), but lazily-loaded chunks
(hljs, the rolldown runtime, and the @pierre/diffs shiki grammars) were
fetched from the wrong path. Setting base to /static/ aligns the chunk
loader with where assets are actually served; verified no more 404s.
The diff renders fine on localhost but a user hits "Diff timed out" remotely,
so make the failure self-diagnosing and rule out stale assets:
- Load the renderer, diff, and reviews as labelled stages with per-stage
  timeouts and console timing logs; the timeout message now names the stalled
  stage (renderer/diff/reviews). Reviews are best-effort and no longer block.
- Send no-cache on the app-shell HTML so a content-hashed shell can't pin the
  browser to an old bundle after a rebuild.
- Purge old Cache API entries when the (no-fetch-handler) service worker
  activates, in case an older worker cached app assets.
Only the diff command was timeout-protected; rev-parse and ls-files used an
unbounded exec. On a repo with a large untracked tree, `git ls-files
--others` can block for tens of seconds, hanging /api/git/diff and the diff
modal. Wrap run() in a 10s context timeout so the endpoint always returns.
CodeView's reconcileHeights stops re-measuring a file once it has zero
annotations, so removing the last comment (or canceling a draft) stranded
the height the annotation had reserved — leaving a large empty gap above
the file. Force a layout-cache reset (via a no-op unsafeCSS bump, the only
option with no visual effect that resets per-item layout) when a file drops
to zero annotations. Verified: ~104px gap before, ~8px after; covered by an
e2e that asserts the file offset stays put across add+delete.
CI ran the diff spec on Mobile Chrome too — the browserName==='chromium'
skip lets it through, and the mobile command-menu surface is a different
DOM (#mobile-command-panel), so the openDiffModal click never finds a
visible item. Gate on testInfo.project.name via beforeEach instead.

The multi-line drag also flaked on Desktop Chrome: the unified
line-number elements weren't laid out when boundingBox() was read.
Wait for both gutter cells to be visible first.
@pierre/diffs CodeView uses the container we hand to setup() as the
scroll viewport — it reads root.scrollTop and attaches scroll listeners
there. .diff-codeview had overflow: hidden, so tall diffs were clipped
at the visible region with no way to scroll past it.
… the sheet header

Mirror the diff sheet's open state to a `?diff=open` query param via
replaceState, and restore it on mount in <SessionShell>, so refreshing
the page keeps the sheet open instead of dropping the user back to the
chat. replaceState (not push) keeps the back-button flow identical —
FullScreenSheet still owns the mobile history entry.

Also: <FullScreenSheet> takes a new optional `headerExtra` snippet that
renders between the back button and the close-X. <DiffModal> uses it to
move Split/Unified + Submit review into the header, reclaiming the
second toolbar row that was eating vertical space on mobile.
After a keyboard refresh (Cmd-R / F5) the browser stays in keyboard
focus mode, so the auto-focus that ran when a URL-restored sheet opened
landed :focus-visible on the back button and showed a stray blue ring
on the "← Diff" label.

Focus the panel itself (tabindex=-1, outline:none) instead. The focus
trap still works because panelEl contains every focusable, so Tab moves
into them — but nothing visibly grabs the ring on open.
… review composer

Diff modal additions:
- Per-file collapse via a chevron rendered into the file header through
  renderHeaderPrefix. Toggles via @pierre/diffs' built-in `collapsed` item flag.
- Toolbar "Collapse all" / "Expand all" button derived from a reactive
  collapsedCount vs fileCount comparison. New `diff.collapseAll`,
  `diff.expandAll`, `diff.collapseFile`, `diff.expandFile` keys
  translated into all 14 locales.

Mobile layout:
- On viewports ≤ 900px the toolbar moves out of the sheet header and renders
  as a second row in the sheet body. The phone-width header couldn't host
  "← Diff" + Split/Unified + Collapse all + Submit review without crushing
  the back button.
- The line-comment composer renders into document.body as a viewport-bottom
  sheet instead of inline in the diff. The diffs library's
  `code { contain: content }` creates a fixed-positioning containing block,
  so a `position: fixed` annotation child stayed pinned to its (very
  narrow) split-view column. Hosting the composer in light DOM escapes
  the containment; cleanup is hooked into save/cancel/teardown.

E2E + tooling:
- New tests for per-file chevron toggle and bulk Collapse all/Expand all.
- The reload-persistence test no longer re-clicks the command menu —
  `?diff=open` restores the sheet, so we just assert it's still open.
- knip.config.js ignores @pierre/diffs explicitly: its Svelte parser
  stops surfacing the dynamic `import('@pierre/diffs')` once DiffModal
  carries enough $state/$derived runes.
Two PWA failure modes after the diff modal moved its toolbar into the
sheet header:

1. The back button "← Diff" was hidden behind the macOS traffic lights.
2. Clicking Split / Unified / Collapse all / Submit review did nothing —
   the OS swallowed the events as window-drag attempts because the sheet
   header sits inside the title-bar strip in window-controls-overlay mode.

Inset the header past the OS controls and mirror the drag / no-drag
pattern .session-header-bar already uses (drag on the bar, no-drag on
its <a> / <button> children) so the title-bar strip is draggable but the
buttons remain clickable.

Also force flex-shrink: 0 on the diff sheet's back button — a slightly
narrow PWA or split-screen window was otherwise crushing it to zero
width before WCO even came into play.
Review comments live in the app DB keyed by session id, outside the
append-only JSONL, and had no cleanup path: deleting a session left its
comments behind forever. Add reapOrphanedReviewComments, swept lazily on
the index load against the full session list, so the delete path stays
untouched.
@setkyar setkyar merged commit 9abddbf into main Jun 16, 2026
5 checks passed
@setkyar setkyar deleted the feat/diff-review-modal branch June 16, 2026 15:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant