Skip to content

Review: a global review destination, from queue to submitted PR - #7

Merged
jbachorik merged 14 commits into
mainfrom
feat/review_ui
Jul 28, 2026
Merged

Review: a global review destination, from queue to submitted PR#7
jbachorik merged 14 commits into
mainfrom
feat/review_ui

Conversation

@jbachorik

@jbachorik jbachorik commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Rebuilds Review as a global destination that spans repositories, per the design handoff.

Reviewing a 75-file worktree: queue rail, intent rail, diff column with folded unchanged runs and ◆n pins, findings margin, and the verdict bar refusing approval while a blocking finding is open.

Narrowed past the collapse thresholds (§4.9)

Queue and intents fold to their icon strips; the findings margin and the verdict bar stay. Collapsing a rail never removes an action.

Review is no longer a per-session sub-tab. The old shape gated Review off for remote repositories — but reviewing a remote PR is the primary use case — and a per-session tab cannot show a queue that spans repos at all. OpenSessionTab.SubTab is now CLAUDE · TERMINAL · EXPLORER, and ⌘4 navigates.

Commit Milestone
8d77f06 M1 — scope handles, queue assembly, sidebar destination, ⌘4 as navigation
9d6bbcc M2 — virtualized diff column: hunk cards, gutter, syntax highlighting, folded runs, densities
29196c4 M3 — findings margin, threads, ◆n pins, verdict bar, submit
7831e94 M4 — the MCP review surface and the intent rail
6f32e22 M5 — the PR loop: checkout gate, read-patch-only, hand-off to Finish
71bdeb4 M6 — symbol lens and the MCP activity panel
50d1ac9e5bfd4c restart-stable scope handles, base-branch correctness, visual-verification fixes
a2a5739 merge of origin/main

Three things worth a close look

1. The handoff specifies a gh flag that does not exist. It gives the checkout as gh pr checkout <n> --worktree. There is no such flag (checked against gh 2.96), and gh pr checkout always operates on the working tree it runs in — so following it literally would move the reviewer's main checkout onto the PR branch mid-task. drydock instead does git worktree add --detach <dir> and then runs gh pr checkout <n> --branch pr-<n> inside that worktree. The gate prints that sequence rather than the handoff's, because the reader may run it themselves.

2. The MCP schema is deliberately narrowed in one place. §5 lists review_verdict / review_resolve / review_submit under "human-side writes". Exposing them as agent-callable tools would let an agent approve its own work and resolve its own findings, so they are the UI's actions against the store and agents observe them read-only through review_state. This is the one intentional departure from the schema's surface — flagged for a decision.

3. The agent step timeline is not built. It needs per-step line attribution and a survives-to-HEAD calculation; both are real work, and a plausible-looking approximation would be worse than nothing when the panel's entire value is answering "which of the agent's edits actually made it" correctly. promptHistory returns empty and t is unbound.

Bugs found by testing and by looking, rather than by reading

  • Reply drafts were wiped whenever an agent touched the finding — M3's own done-when. Drafts now live in the margin keyed by (scopeId, id).
  • ⋯ N unchanged could never appear. With git's default 3-line context no unchanged run is ever longer than the fold threshold, so the feature rendered correctly and never showed. Review asks for -U12.
  • Persisted findings were orphaned on restart — scope handles were random per process; now HMAC-derived from scope identity under a persisted secret.
  • A git switch in another terminal discarded every in-progress review. base was part of scope identity, so anything moving it re-derived every worktree's handle and its findings vanished from the UI while sitting untouched in the store. Identity is now a place, plus which PR it is; the base is the repository's default branch.
  • The ◆n pins were never on screen. They rendered correctly, but the source column grew and pushed each pin to the right edge of the content — far outside the viewport on a wide diff.
  • The verdict bar was dead on every freshly opened item. Intents are derived from the diff, which arrives asynchronously; the bar rendered once against an empty diff and never re-rendered, so Approve, Request change and Submit all did nothing. No existing test could reach it — every one supplied the diff synchronously.
  • Diff rows were nearly twice their intended height. §4.8's "row padding" is the prototype's ipad, applied to the queue and intent cards, not to code lines.

Keying

Findings, verdicts, threads and drafts are keyed by (scopeId, id), never by id alone: finding ids are agent-chosen and repeat across scopes, so a reviewer re-run against two worktrees produces f_leak_1 in both. The regression test is the first test in AnnotationStoreTest. review_reply refuses an ambiguous id rather than guessing. Annotations written before scope handles existed are migrated rather than dropped.

Testing

924 tests, 0 failures, 0 skipped (from 828 before this branch).

This adds a headless JavaFX harness the repository did not have — TestFX + Monocle, so view tests show a real Stage, apply the real stylesheets and drive real key events with no window server, and run identically on the macos-14 CI runner. Mutation-checked to confirm it is not vacuous.

It also adds in-process visual verification: -Dapp.drydock.diag.screenshot=<path> renders the scene graph to a PNG on the FX thread, needing no screen-recording permission. The images above came from it. Three of the bugs listed were invisible to the test suite by construction, because they were about what reaches the screen.

Theme and font integration is enforced by ThemeTokenContractTest: both theme sheets define identical token names, app.css references no undefined token, and every font size is a px literal so the interface-size slider scales it.

Merge with main

a2a5739 merges origin/main. Five conflicts, plus one integration defect it surfaced: McpToolRouter signed every agent-written finding with the literal string "Claude" — precisely what main had just finished removing from the rest of the UI, and wrong for a Codex session. It now derives the name through main's own AgentLabels.

Review notes

docs/ui-redesign.md carries the new structure plus ~45 known deviations grouped by milestone, and flags the five open decisions in §10 of the handoff rather than inventing answers.

Not implemented, and flagged: MCP notifications (§7) need a server-initiated channel McpServer does not have — agents poll review_state; and the STACK queue group is modelled but never produced, since stacked PRs are open decision §10.4.

🤖 Generated with Claude Code

jbachorik and others added 8 commits July 28, 2026 09:43
Review stops being a per-session sub-tab and becomes a destination that
spans repositories. The old shape gated Review off for remote repositories
-- but reviewing a remote PR is the primary use case -- and a per-session
tab cannot show a queue that spans repos at all.

OpenSessionTab.SubTab is now CLAUDE - TERMINAL - EXPLORER, and Cmd-4 is a
navigation command that lands on Review scoped to the current session's
checkout.

Domain:
- ReviewScope / ReviewScopeRegistry mint opaque rs_ handles (MCP schema
  section 0), with grants so a human can hand one session's agent another
  worktree's scope. Minting is idempotent on identity, because the queue is
  reassembled on every worktree change and a fresh id per assembly would
  orphan everything keyed by (scopeId, ...).
- ReviewQueueService assembles MINE/AGENTS/REQUESTED/STACK from
  WorktreeService, GitStatusService and a new GhCliService.listReviewRequests.
  A failing repository or a missing gh contributes nothing and is logged;
  the rest of the queue still assembles. Review degrades, never blocks.

UI:
- ReviewDestinationView (36px title bar, two-row item header, a Host seam
  where the diff column lands) and ReviewQueueRail (236/206/44px, animated
  like the Explorer's search rail). Every row is a real focusable Button --
  the prototype having zero focusable controls was a defect, not a style.
- Sidebar destination row above the tree with a count badge, and a per-
  worktree finding badge that jumps into Review scoped to that worktree.
- Showing Review hides every native terminal, through one writer over three
  independent conditions, so a modal closing over Review cannot un-hide a
  terminal through it.

Theme and fonts:
- Three chrome greys and ten semantic tokens added to BOTH theme sheets.
  Semantic names stay separate from same-valued existing ones: a MED-risk
  intent is not a dirty worktree, and collapsing them would make a future
  palette change to one silently move the other.
- All new CSS is structure-only with px font literals, so the interface-size
  slider scales it. ThemeTokenContractTest now pins both invariants, which
  nothing guarded before.

Testing:
- A headless JavaFX harness (TestFX + Monocle), which this repository did
  not have: view tests show a real Stage, apply the real stylesheets and
  drive real key events with no window server. headless.geometry is
  load-bearing -- Monocle's default 1280x800 screen overflows the software
  pixel buffer for a wider Scene.
- ReviewDestinationViewTest covers the keyboard table M1 binds and the
  focus-traversability rule; verified non-vacuous by mutating clamp-to-wrap
  and clearing focusTraversable, which failed exactly those two tests.
- Queue assembly is tested against real temporary git repositories,
  including the missing-gh path.

ReviewView (the per-session diff tab) is deleted rather than parked; its
hunk-row model is kept for the diff column. docs/ui-redesign.md records the
structure and eleven known deviations, and flags the five open decisions in
section 10 of the handoff rather than inventing answers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hunk cards over a virtualized row list: gutter, sign column, syntax-
highlighted source, add/delete tints, collapsed unchanged runs, three
densities, and a jump into the Explorer.

Cards are drawn from a per-row edge (top/body/bottom) rather than by nesting
rows inside real card containers, because the column has to stay virtualized
-- a 21-file diff of this repository renders 853 rows across 28 cards.
Nothing sets a cell height and no cell graphic is bound to the cell's height:
a fixed row height was the bug that hid code with no scrollbar to reveal it.

Review asks git for a wider context window (-U12, not git's default -U3).
Found by testing rather than by reading: with a three-line window no
unchanged run is ever longer than the fold threshold, so the "N unchanged"
fold would have rendered correctly and simply never appeared. Showing more
context and folding it is the whole point of the feature. DiffService gained
an explicit contextLines parameter; every other caller keeps git's default.
On this repository the wider window produces 51 folded runs.

The lexer moves to app.drydock.ui.code now that two surfaces render code,
with two output shapes over one set of language rules: plain span data for
the diff column's per-line TextFlow, and the RichTextFX StyleSpans the
Explorer's CodeArea already consumed. One editor control per diff line would
have been thousands of controls for read-only text.

Density is a style class, and its measurements are px literals in app.css --
so the interface-size slider still scales them and density stays a relative
choice on top of an absolute one.

The Explorer jump reports when it has nowhere to go (no bound session, or a
closed tab) and explains why in its tooltip, instead of appearing to do
nothing.

Testing: the row model is pure and covered directly (card edges, fold
threshold, run keys surviving a re-diff that shifts line numbers, the render
cap, context-free mode). The column is covered against real temporary git
repositories through the headless harness, including that rows keep their
natural height and that density actually changes it.

The per-session diff tab's old row model is deleted; this replaces it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everything in the review store is keyed by (scopeId, id), not by id alone.
Finding and intent ids are agent-chosen and stable across re-runs, so the
SAME id genuinely appears in two worktrees at once -- a reviewer re-run
against two branches produces f_leak_1 in both. Keying on the id made
resolving a finding in one worktree resolve it in another. The regression
test for that is the first test in AnnotationStoreTest.

ReviewAnnotation is now the full finding of the MCP schema: severity,
confidence, intent, evidence, a proposed patch, deviates-from, ASK chips, a
thread, and a human severity override that records the reviewer's original
opinion alongside the human's. Verdicts are keyed by (scopeId, intentId) for
the same reason findings are.

Annotations written before scope handles existed are migrated rather than
dropped. A v1 file names a session and a DiffScope, neither of which can
identify a handle minted this process, so those entries are parked under a
deterministic placeholder scope and adopted the first time the matching
scope is minted -- and a finding the live scope already has always wins.
Dropping them would have silently thrown away real review comments on
upgrade.

The margin sits beside the code, never inline, so the diff stays continuous;
the verdict bar sits below both columns and is always in the layout, so
collapsing every rail cannot take the primary action with it. Approving an
intent with an open blocking finding is refused inline -- with the reason
beside the button and the two ways out (resolve, or downgrade after a
discussion) in the margin next to it.

Reply drafts are held by the margin, keyed by (scopeId, id), not by the card
node. Found by testing: a card is rebuilt whenever its finding changes -- an
agent appending a reply, say -- and a draft living inside the node went with
it. The scoped key is also what keeps a draft out of an identically-named
finding in another worktree.

Apply patch is a hand-off, not an edit: drydock sends the proposal to the
scope's live session as every other worktree action does, and records only
that the hand-off happened. An agent that writes to the tree behind the
review is a bug, not a feature.

Intents come from review_intents, or from the by-file fallback the schema
specifies -- which is what keeps the verdict bar and the submit flow working
on a plain diff with no reviewer configured. Review must never require an
agent.

review_reply now refuses an ambiguous finding id, listing the candidate
scopes and asking for scopeId rather than picking one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
review_scope, review_intents, review_finding, review_answer and review_state,
plus the rail that renders what review_intents supplies.

review_scope pages on a byte budget and returns a cursor. A hunk larger than
the whole budget comes back truncated and flagged rather than dropped or
failing the call -- one generated file must not make a scope unreadable.
Every line carries the stable o/n line key that every anchor in every other
call refers to, which is what lets findings survive a re-diff.

Three rules keep the human's work theirs:

- review_finding upserts on the agent-chosen id, so a re-run keeps the
  thread, the human's severity override and their resolution. The reviewer's
  opening statement refreshes; nothing else the human touched does.
- review_answer's proposeSeverity and proposeResolve are recorded in the
  thread as labelled suggestions and never applied. An agent cannot downgrade
  or resolve its own finding.
- The human-side calls in schema section 5 are deliberately NOT exposed as
  agent-callable tools. Making them tools would let an agent approve its own
  work; they are the UI's own actions against the store, and agents observe
  them through review_state. This narrows the schema on purpose and is
  flagged in docs/ui-redesign.md for a second look.

A findings batch is decoded in full before anything is stored, so one
malformed entry writes nothing rather than half a review. Unknown and
forbidden scope handles produce the same refusal, so probing tells an agent
nothing.

PromptSafety gained checkInboundText, which is a different check from the
outbound one and says so: it refuses control characters (a finding's words
can later be typed into a live terminal by "Ask the agent to fix it") but
allows a leading ! / #, because those rules are about what the claude TUI
does with a typed line and a finding body is not typed as a line. Markup is
allowed because the margin renders findings as text -- there is no renderer
to confuse.

Notifications (schema section 7) are not implemented: they need a
server-initiated channel McpServer does not have, and agents poll
review_state instead. Flagged rather than faked.

Verified in the real app: 5 queue items, 26 hunk cards, 61 folded runs, no
exceptions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Checking a pull request out into its own worktree, a read-only patch path
for when you do not want one, and handing a submitted review to the Finish
flow that already exists.

The handoff's literal command does not exist. It gives the sequence as
`gh pr checkout <n> --worktree`; gh has no such flag -- checked against 2.96,
where `gh pr checkout` takes --branch, --detach, --force and
--recurse-submodules and always operates on the working tree it is run in.
Running it in the repository root would therefore move the reviewer's MAIN
checkout onto the PR branch, mid-task, which is the one thing a review must
never do to someone.

What drydock does instead, to the same end:

  git worktree add --detach <dir>          (main checkout untouched)
  gh pr checkout <n> --branch pr-<n>       (run INSIDE that worktree)
  claude --cwd <dir>

The gate prints that sequence rather than the handoff's, because the reader
may well run it themselves and the handoff's would fail.

A failure at the second step removes the worktree the first step made: a
half-made checkout is worse than none, because the next attempt collides with
what was left behind. The test for that also pins that the main checkout's
branch and HEAD do not move.

Checking out lives in its own service rather than GhCliService, whose
contract is read-only observation. `gh pr diff` -- the "Read the patch only"
path -- is read-only and does belong there; its output goes through the same
UnifiedDiff parser as a local git diff, so a PR read without a worktree
renders exactly like one with, under a banner saying the agent actions are
unavailable, because everything that needs a session is.

A successful checkout starts a session on the new worktree and grants it the
scope handle -- the human action the MCP schema requires before an agent may
address a scope. Submitting a review hands the worktree to the Finish flow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two of the three extras. The third is deliberately not built; see below.

The symbol lens is a local lexical index of the diff, never MCP -- "where is
this symbol used" is a question about the code in front of the reader, and
asking an agent would make it slow, non-deterministic, and unavailable
exactly when Review has no reviewer configured, which it must always
survive. Identifiers the index knows get a dotted underline; the popover
chips every occurrence `in diff` or `not touched` and says plainly that
these are occurrences, not resolved references, because that is what a
lexical index can honestly promise. Keywords, identifiers under three
characters, and symbols appearing once are excluded -- an underline on every
word would mean nothing.

The MCP activity panel renders a bounded ring buffer written by the one
place every tool call passes through. Bounded because a long review makes
thousands of calls and a panel nobody has open must not leak; the counters
behind the budget bar are not bounded, so the bar still reflects the whole
session. A hidden panel unsubscribes, so a closed panel costs nothing. The
header names the scope and says when no session is bound -- an empty log
then means "nothing can call", not "nothing has".

Escape now unwinds topmost-first through Review: the lens popover, then the
activity panel, then Review itself.

The agent step timeline is NOT implemented, and promptHistory returns empty.
It needs the session transcript (cheap), per-step line attribution
(reconstructing per-turn diffs), and a survives-to-HEAD calculation
(diffing each against the current tree). The last two are real work, and a
plausible-looking approximation would be worse than nothing: the entire
value of that panel is answering "which of the agent's edits actually made
it" correctly. Flagged in docs/ui-redesign.md rather than faked.

Found while writing the tests: Set.of rejected a duplicate stop word, which
failed every view test at class-initialization time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scope handles were minted at random per process, so a finding persisted
under a scopeId was orphaned on the next launch -- the store kept it, and
nothing could address it again. Handles are now an HMAC of the scope's
identity (kind, repo root, worktree, base, head, PR) under a per-profile
secret persisted beside the findings, bumping the annotations schema to 3.
The same worktree therefore resolves to the same handle across restarts,
while the handle still exposes no repository paths. Identity paths are
normalized first, so a symlinked or relative root cannot produce a second
handle for the same scope.

Also:

- PrCheckoutService removes the worktree it created when the checkout step
  fails by timeout, interrupt or launch failure, not only on a non-zero exit
  -- all of those happen after step 1 and stranded the worktree.
- The checkout gate shows progress for "Read the patch only" too; reading a
  patch is a network call and the click has to visibly do something.
- `ReviewScopeRegistry::normalized` did not resolve (the helper lives on the
  nested Identity record); corrected to Identity::normalized.

911 tests pass. Verified in the app: 5 queue items, 26 hunk cards, 62 folded
runs, no exceptions.
Scope identity included base and head. Findings and verdicts are keyed by
(scopeId, ...), so anything that moved the base re-derived every worktree's
handle -- and that worktree's review vanished from the UI while sitting
untouched in the store under the old handle. Nothing on screen said so; the
queue simply showed a fresh handle with no findings against it.

The trigger was not exotic. The base was the main checkout's CURRENT branch,
so `git switch` in another terminal did it. So did checking out a pull
request. A reviewer part-way through `feat/sidebar-resize` -- three findings,
one blocking, two intents approved -- would come back to an empty margin and
a renumbered intent list, and the surviving line-key anchors would resolve
against a diff computed from a different base, pinning "this event filter
leaks" to whatever now sits at that line.

Two fixes, because there were two halves to it:

- Scope identity is now a PLACE, plus which pull request it is: kind, repo
  root, worktree, PR number. Base and head are properties of a review, not
  what makes it that review -- a commit inside a worktree moves its head, and
  that is not a different review.
- The base is the repository's default branch (origin/HEAD, then the
  conventional names, then the current branch as a last resort) instead of
  whatever happens to be checked out, so the diff itself stops moving too.

Found while doing this: GitStatusService.runLines throws on any non-zero
exit, and `symbolic-ref --quiet refs/remotes/origin/HEAD` exits 1 whenever
origin/HEAD is absent -- normal for a local-only repository. That turned a
routine "no default branch" into a failed scan, which also flipped the
scan-completeness flag and silently disabled scope revocation. Probes where
"not there" is a legitimate answer now use a variant that tolerates it.

Both fixes are mutation-checked: restoring base/head to the identity fails
the two registry tests, and restoring the current-branch base fails the two
queue tests, and nothing else.

911 tests pass. Verified in the app: 5 queue items, no exceptions.

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

Copy link
Copy Markdown
Contributor Author

Fixed: a branch switch orphaned every in-progress review

Follow-up on the gh pr checkout discussion in the description. Tracing the consequence through the workflow turned up a live bug that had nothing to do with PR checkout — that was only one of its triggers.

base was part of scope identity, and the base was the main checkout's current branch. Findings and verdicts are keyed (scopeId, …), so anything that moved the base re-derived every worktree's handle. That worktree's review then vanished from the UI while sitting untouched in the store under the old handle — no error, nothing on screen to say so, just an empty margin.

git switch in another terminal was enough to do it.

What a reviewer would have experienced: part-way through feat/sidebar-resize with three findings and two approved intents, switch branches for something unrelated, come back — empty margin, renumbered intents. And the surviving line-key anchors resolve against a diff computed from a different base, so "this event filter leaks" ends up pinned to whatever now sits at that line, still looking authoritative.

Two fixes, for the two halves

  • Scope identity is a place, plus which PR it is — kind, repo root, worktree, PR number. Base and head are properties of a review, not what makes it that review; a commit inside a worktree moves its head, and that is not a different review.
  • The base is the repository's default branch (origin/HEAD, then conventional names, then the current branch as a last resort), so the diff stops moving too.

Found on the way

GitStatusService.runLines throws on any non-zero exit, and symbolic-ref --quiet refs/remotes/origin/HEAD exits 1 whenever origin/HEAD is absent — normal for a local-only repository. That turned a routine "no default branch" into a failed scan, which also flipped the scan-completeness flag and silently disabled scope revocation. Probes where "not there" is a legitimate answer now use a variant that tolerates it.

Verification

Four regression tests, mutation-checked: putting base/head back into the identity fails the two registry tests, restoring the current-branch base fails the two queue tests, and nothing else fails either way.

911 tests, 0 failures. Verified in the app — 5 queue items, no exceptions.

c8e7d3d

Follow-up to the base change, and a regression it introduced.

origin/HEAD names a REMOTE default. `git clone -b feat/x`, and deleting a
local main after a merge, both leave origin/HEAD -> origin/main with no local
branch of that name -- so handing the bare name to the diff failed every
worktree item with "fatal: ambiguous argument 'main...HEAD': unknown
revision". The previous behaviour could not hit this, because the main
checkout's current branch always resolves. Verified against a real clone
before fixing.

defaultBranch now returns the local branch when there is one and the
remote-tracking ref otherwise, and the conventional-name fallbacks are only
offered when the ref exists.

Testing: a new case builds the exact repository shape (clone, switch to a
feature branch, delete local main) and asserts the base is origin/main. Both
that and a broader one run the REAL diff for every assembled queue item
rather than only checking the base string -- a base is only useful if git can
resolve it, and asserting on the name would not have caught this. Mutation-
checked: dropping the resolvability check fails the new test alone.

Also adds a diagnostic that walks every queue item and reports its diff, so
the visual pass can prove each item resolves its base rather than only the
selected one. Against this repository: 4 items diff correctly against
base=main, and the fifth fails for an unrelated pre-existing reason -- a
worktree whose .git file points into another repository's missing admin
directory, so `git -C` there fails before any base is considered.

913 tests pass.

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

Copy link
Copy Markdown
Contributor Author

Checked: the base change did not break the other items' diffs — but it had introduced a different regression

Verified by running the real diff for every queue item, not by asserting on the base string. Against this repository:

item: Working tree      [base=main] 7 files
item: feat/review_ui    [base=main] 73 files
item: feat/ssh_ttl      [base=main] 8 files
item: fix/ui_bugs       [base=main] 15 files
item: fix/release_action[base=main] FAILED: Not a Git repository: …/olifer-release-action

Four of five resolve correctly. The fifth is unrelated and pre-existing: that worktree's .git file points into a different repository's admin directory (/Users/…/src/olifer/.git/worktrees/…) which no longer exists, so git -C there fails before any base is considered. It failed identically before this branch. Worth fixing separately — say the word — but it is not fallout from this change.

The regression the check did find

origin/HEAD names a remote default. git clone -b feat/x, and deleting a local main after a merge, both leave origin/HEAD → origin/main with no local branch of that name. Handing the bare name to the diff then fails every worktree item:

fatal: ambiguous argument 'main...HEAD': unknown revision or path not in the working tree

The old behaviour couldn't hit this, because the main checkout's current branch always resolves. I reproduced it against a real clone before fixing it.

defaultBranch now returns the local branch when there is one and the remote-tracking ref (origin/main) otherwise; the conventional-name fallbacks are only offered when the ref exists.

Why the earlier tests missed it

They asserted on the base string (assertEquals("main", …)), which is exactly the wrong altitude — a base is only useful if git can resolve it. The new tests run the actual diff for every assembled item, and one builds the precise repository shape that breaks. Mutation-checked: removing the resolvability check fails the new test and nothing else.

919 tests, 0 failures.

d40716d

Adds -Dapp.drydock.diag.screenshot=<path>, which renders the scene graph to
a PNG from inside the process on the FX thread. macOS screen capture is
unavailable here ("could not create image from display"), so the FX layer had
no visual evidence at all -- every check so far proved data flowed, not that
the UI was right. An in-process snapshot is better than a screen capture
anyway: no OS permission, and it cannot accidentally photograph another
window. It uses java.desktop, already on the jlink module list, rather than
javafx.swing, which is not.

Looking at the result found two bugs immediately.

The verdict bar sat on "no intent" for every freshly opened item, so Approve,
Request change and Submit were all dead. Intents fall back to one-per-file
and are therefore derived FROM the diff, and the diff arrives on a background
thread: the bar rendered once against an empty diff, correctly concluded
there were no intents, and never re-rendered. Every existing test handed the
view a diff synchronously, so none of them could reach it.
ReviewIntentFallbackTest drives the real asynchronous path against a real
repository, and fails without the fix.

Diff rows were almost twice their intended height. Section 4.8's "row padding
8 / 6 / 4px" is the prototype's `ipad`, which it applies to the QUEUE and
INTENT cards -- code line height there is font-size x line-height. Applying
the padding to code rows as well gave 36px rows where the design wants 20px.
Verified against the prototype source rather than guessed.

Also fixes a latent flake this surfaced: aScopeWithNoChangesSaysSo waited for
any Message row, and "Diffing..." is one, so it raced the placeholder instead
of the result. It passed by timing luck until the machine was loaded.

Every test that asserts on a base string now also runs the real diff, for the
reason the previous commit gave: a base is only useful if git can resolve it.

920 tests pass.

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

jbachorik commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Visual verification, and two bugs only looking could find

macOS screen capture is unavailable in this environment (could not create image from display), so the FX layer had no visual evidence at all — every check so far proved data flowed, not that the UI was right. Added -Dapp.drydock.diag.screenshot=<path>, which renders the scene graph to a PNG from inside the process on the FX thread. No OS permission needed, and it cannot accidentally photograph another window. Uses java.desktop (already on the jlink module list) rather than javafx.swing (which is not).

Looking at the first image found two bugs in about ten seconds.

The verdict bar was dead on every freshly opened item

It showed no intent and 0/0 intents settled — so Approve, Request change and Submit did nothing at all.

Intents fall back to one-per-file, so they are derived from the diff — and the diff arrives on a background thread. The bar rendered once against an empty diff, correctly concluded there were no intents, and never re-rendered.

No existing test could have caught it: every one of them handed the view a diff synchronously. ReviewIntentFallbackTest now drives the real asynchronous path against a real repository, and fails without the fix.

Diff rows were almost twice their intended height

~36px where the design wants 20px. §4.8's "row padding 8 / 6 / 4px" is the prototype's ipad, and checking the prototype source shows it is applied to the queue and intent cards — code line height there is font-size × line-height. I had applied it to both.

Before → after, same window: rows went 33px → 20px (= 12.5 × 1.6, exactly the spec), and the bar went from no intent to intent 1 · Approve intent · Request change · Ask the agent to fix it · 0/6 intents settled · Submit (6 left).

Also

A latent flake this surfaced: aScopeWithNoChangesSaysSo waited for any Message row, and "Diffing…" is one — so it raced the placeholder instead of the result. It passed by timing luck until the machine was loaded by the full suite.

920 tests, 0 failures.

29f9422

Photographing the surface with every rail expanded found three more things
that no test and no row count could have shown.

The ◆n pins rendered correctly and were never visible. The source column had
Hgrow, so it filled the row and pushed each pin to the right edge of the
CONTENT -- which on a diff whose widest line is hundreds of characters is far
outside the viewport. They now sit immediately after the code. The design
right-aligns them to the card edge, which a virtualized row as wide as the
longest line in the whole diff cannot do; noted as a deviation.

A pin whose finding is filtered out rendered as "◆0". The numbers are the
margin's render order, so a finding it is not rendering has no number -- 0
was invented. It keeps a bare dimmed diamond now: the line still carries a
finding, and a made-up number is a lie.

Pins also had no background, so they sat directly on the add/delete row tint
and were hard to pick out; they now carry the code background and a border.

Two diagnostic flags make the surface photographable at all:
windowSize, because the rails collapse at documented widths and showing them
expanded means asking for a wide enough window; and seedFindings, which seeds
representative findings anchored to lines genuinely in the diff, because the
margin's severity pills, patch blocks and ASK chips cannot be seen -- or
shown to a reviewer -- without findings to render.

What the expanded shot confirms, first time any of it has been seen: queue
rail with group headers and count badges, intent rail with numbered cards and
risk heat bars, findings margin with severity pills, coloured pins, a patch
block, ASK chips and per-card reply boxes, the `open` filter correctly hiding
the resolved nit (4 threads, 3 open), and the verdict bar refusing approval
with "a blocking finding is still open".

920 tests pass.

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

Copy link
Copy Markdown
Contributor Author

Every rail expanded — and three more bugs only the picture showed

Photographed at 1800×1000 with representative findings seeded, so the intent rail and findings margin are both expanded and populated. Two new diagnostic flags make that possible: -Dapp.drydock.diag.windowSize= (the rails collapse at documented widths) and -Dapp.drydock.diag.seedFindings=true (the margin's pills, patch blocks and ASK chips cannot be seen without findings to render).

What it confirms — first time any of this has actually been seen

  • Queue railMINE group header, ❯_ icon with its open-finding count beneath, mono title, dim subtitle, accent border on the selected item
  • Intent railINTENTS 0/5 · i, numbered cards, by-file fallback rationale, change kind tag, risk heat bars
  • Findings marginFINDINGS 4 threads · 3 open, all/open filters, severity pills (blocking red / question amber / deviation purple), matching coloured ◆1 ◆2 ◆3, the proposed patch block, ASK chips, per-card reply boxes, Apply patch accented
  • The open filter correctly hides the resolved nit — 4 threads, 3 open
  • Verdict barintent 1 · Approve intent · Request change · Ask the agent to fix it · ⚠ a blocking finding is still open, with 0/5 intents settled and Submit (5 left)

The three bugs

The ◆n pins were never on screen. They rendered correctly the whole time. The source column had Hgrow, so it filled the row and pushed each pin to the right edge of the content — which on a diff whose widest line is hundreds of characters is far outside the viewport. They now sit immediately after the code. The design right-aligns them to the card edge, which a virtualized row as wide as the longest line in the whole diff cannot do; recorded as a deviation.

A filtered-out pin rendered as ◆0. The numbers are the margin's render order, so a finding it is not rendering has no number — 0 was invented. It keeps a bare dimmed diamond now: the line still carries a finding, and a made-up number is a lie.

Pins had no background, so they sat directly on the add/delete row tint and were hard to pick out. They now carry the code background and a border.

920 tests, 0 failures.

e5bfd4c

jbachorik and others added 3 commits July 28, 2026 16:04
Five conflicts, and one integration defect the merge surfaced.

- ReviewView.java: modified on main, deleted here. Kept deleted -- the
  per-session diff tab is what this branch replaces. Main's change to it was
  part of its "stop the UI claiming Claude" pass, whose intent is carried
  into the new code instead (below).
- ShortcutsOverlay: kept the sectioned table this branch introduced, adopting
  main's "Agent view" rename for the first sub-tab.
- OpenSessionTab: kept both sides -- main's per-button shortcut hints and the
  agent-named sub-tab, and this branch's updateGeometryNow(), which restores
  the native surface's geometry after Review gives the centre back. Dropped
  main's showKeyHint for the Review sub-tab: there is no such button any
  more, and Cmd-4 is advertised in the overlay and on the sidebar row.
- MainWorkspace / DrydockApplication: took main's agentRegistry wiring
  alongside this branch's Review wiring.

The defect: McpToolRouter attributed every agent-written finding and answer
to the literal string "Claude", which is precisely what main had just
finished removing from the rest of the UI -- a Codex session's findings would
have been signed with the wrong agent's name. The router now asks the context
for the caller's reviewer name, and the context derives it through main's own
AgentLabels rather than a second copy of that logic; AgentLabels is public
for that reason.

924 tests pass. Verified in the merged app: 5 queue items, 21 intents, the
findings margin and verdict bar rendering, no exceptions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two images, generated by the in-process snapshot hook rather than a screen
capture: every rail expanded on a 75-file worktree review, and the same
review narrowed past the section 4.9 collapse thresholds so the queue and
intent rails fold to their icon strips while the findings margin and the
verdict bar stay.

Also adds -Dapp.drydock.diag.reviewItem=<n>, which selects a queue item by
index. Without it the visual pass could only ever photograph whatever sorted
first -- on a clean tree that is the working-tree item, whose diff is empty,
which illustrates nothing.
They belong in the pull request, not in the repository -- 456 KB of PNG that
main would carry forever to illustrate one review. The images move to an
orphan branch that never merges; the pull request references them by
commit-pinned raw URL.

The -Dapp.drydock.diag.reviewItem flag added alongside them stays: it is what
lets the visual pass photograph a chosen queue item rather than whichever
sorts first.
jbachorik added a commit that referenced this pull request Jul 28, 2026
@jbachorik
jbachorik merged commit 8007b64 into main Jul 28, 2026
1 check passed
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