# PR #22930 proof matrix v2 (controlled fixture)

PR [#22930](https://github.com/pingdotgg/t3code/pull/22930) (GitCafe [#22927](https://git.cafe/pingdotgg/t3code/pulls/22927)), issue [#22886](https://github.com/pingdotgg/t3code/issues/22886).

- **BEFORE** = [8d3b061121](https://github.com/pingdotgg/t3code/commit/8d3b061121fe65669d5d43d011b48cd3027a4108), origin/main and the merge-base. It already includes [b9c1293c0a](https://github.com/pingdotgg/t3code/commit/b9c1293c0a) ([#22917](https://github.com/pingdotgg/t3code/pull/22917)), so Refresh no longer empties the diff in BEFORE.
- **AFTER** = [2b388c27fa](https://github.com/pingdotgg/t3code/commit/2b388c27faf4b44caac61730dfd47027ef22c4dd).

Line numbers are at AFTER unless they are prefixed `B:`, which means BEFORE (`git show 8d3b061121:<path>`).

This matrix replaces the 34-row [matrix.md](/tmp/ev-22930/matrix.md). It covers only the gaps named in the pass-2 re-audit: a controlled fixture instead of a live forge PR, a new push, background refresh, a selected commit, thread switch-and-back, mixed expand/collapse, and short and long fixtures. It is read-only research: nothing here has been run yet, and each "expected" column comes from reading the code.

## 1. What the code says (the basis for every expectation)

### Where the state lives

| State | BEFORE | AFTER |
|---|---|---|
| Fold state | Component `useState`. `toggledFiles` and `foldOverride` are reset whenever `scopeKey = [env, PR, commit]` changes (B:`PullRequestCodeTab.tsx:313-321`). | Zustand `codeViews[codeViewKey]`, with `codeViewKey = [env, threadId, PR]` (`pullRequestReviewStore.ts:48-54`; read in `PullRequestCodeTab.tsx:287-298`). |
| File key | `buildFileDiffRenderKey` = Pierre `cacheKey`, which includes the whole patch (`lib/diffRendering.ts:222-227`, see the comment at `:235-237`). A file whose patch changes therefore gets a **new key and loses its toggle**. | `buildFileDiffIdentityKey` = old path + new path + type (`lib/diffRendering.ts:218-220`). The toggle survives a content change. The row version includes `buildFileDiffContentVersion` (`PullRequestCodeTab.tsx:710-713`), so the new content still repaints. |
| Selected commit | `useState` in the detail panel (B:`PullRequestDetailPanel.tsx:330-335`). | Store field `selectedCommitOid` (`PullRequestDetailPanel.tsx:335-347`). |
| Scroll | Never saved. | Saved on every scroll (`PullRequestCodeTab.tsx:1584-1589`, `savePullRequestCodeScroll` in `pullRequestCodeScroll.logic.ts:47-57`). Restored by `PullRequestCodeScrollRestoration` (`pullRequestCodeScroll.logic.ts:11-44`; effect at `PullRequestCodeTab.tsx:1356-1416`). Any wheel, touch, pointer, mouse or key input cancels the restore (`pullRequestCodeScroll.logic.ts:91-106`). |
| Condensed header chrome | `condensedByTab` `useState`. | Stored per code view (`PullRequestDetailPanel.tsx:388-391`, `:1696-1705`). |

The store is an in-memory zustand store and is not persisted. A full page reload resets everything in **both** builds.

### Selecting a commit resets the view in both builds

- **AFTER:** `selectCodeCommit` writes `{...EMPTY_CODE_VIEW, selectedCommitOid}` (`PullRequestDetailPanel.tsx:340-347`).
- **BEFORE:** `scopeKey` includes the commit (B:`PullRequestCodeTab.tsx:313-321`).

So the maintainer's sequence ("expand/collapse, scroll, select a commit, then trigger") loses the pre-selection fold and scroll in both builds by design. The rows below therefore **re-apply** the mixed fold and mid scroll after selecting the commit, then trigger the update. R8 records the reset itself.

### Update paths into the Code tab

1. **Explicit token `refreshToken`.**
   - Explicit Refresh: `refreshFromHost` invalidates, then increments `refreshToken` (`PullRequestDetailPanel.tsx:573-580`).
   - A new revision: when `detail.updatedAt` changes, it also increments `refreshToken` (`PullRequestDetailPanel.tsx:544-559`; `pullRequestDetail.logic.ts:144-149`). **A new push reaches the Code tab through this path once the detail refetch sees the new `updatedAt`.**
   - BEFORE: clears every slice and refetches page 1 (B:`PullRequestCodeTab.tsx:430-438`), so the viewer remounts at scrollTop 0.
   - AFTER: refetches every loaded cursor and starts a new scroll restore from the live or saved scrollTop (`PullRequestCodeTab.tsx:432-473`; `pullRequestDiff.logic.ts:61-72`).
2. **Background token `backgroundRefreshToken`.**
   - Source: `turnRefresh = usePullRequestTurnRefresh(env)` (`PullRequestDetailPanel.tsx:441`, `:1810`; `state/pullRequests.ts:325-327`). It is the server's `pullRequestRefreshes` epoch, delivered over the `pullRequestsSubscribeRefreshes` subscription (`packages/client-runtime/src/state/pullRequests.ts:109-115`).
   - Bumped by `invalidate({notifyReaders:true})` (`apps/server/src/ws.ts:2525-2527`; `PullRequestService.ts:3214-3216`), by `refreshAfterTurn` after agent turns and in-app pushes (`PullRequestService.ts:3219-3234`; `git/refreshPushedPullRequests.ts:8-30`, called from `ws.ts:2840`), and by mutations (`PullRequestService.ts:3236-3254`).
   - **Both builds** keep the slices mounted and merge page 1 (B:`PullRequestCodeTab.tsx` background effect; AFTER `PullRequestCodeTab.tsx:474-483` with `mergePullRequestDiffSlices`). An unchanged page is a no-op in both builds.
   - Explicit Refresh bumps **both** tokens in the same panel, because its `invalidate` notifies readers.
3. **Live refresh.** `useLiveRefresh` refetches the detail on window focus or visibility once at least 10 s have passed, and every 5 min while the user is interacting (`hooks/useLiveRefresh.ts:3-7`, `:97-136`; wired at `PullRequestDetailPanel.tsx:563-568`). The server caches detail and diff for 60 s (`PullRequestService.ts:133`, `:139`). So an **external** `git push` is seen only after about 61 s plus a focus event, unless something invalidates the cache.
4. **Server watch.** `PullRequestWatchReactor` sweeps every 2 min (`orchestration-v2/PullRequestWatchReactor.ts:43`, `:636-639`). On a head change it only invalidates the cache and wakes the **agent thread** (`:468-470`, `:543-555`). It does **not** bump the client epoch, so it is not a deterministic UI trigger. It is not used as a seed.

### Mount lifetimes

- **Summary, Timeline and Guide tabs:** the Code tab stays mounted and is made invisible (`PullRequestDetailPanel.tsx:1793-1794`, `mountedTabs`). **BEFORE already preserves everything on a tab round trip.**
- **Thread surface:** the panel is keyed `${thread}:${surface.id}` (`chat/RightPanelSurfaceContent.tsx:133`). Switching threads remounts it. Closing the right panel unmounts it after the exit animation (`ChatView.tsx:959-970`, `usePanelPresence`). The tab comes back from `surface.detailTab` (`RightPanelSurfaceContent.tsx:149-152`). **BEFORE loses fold, scroll and commit; AFTER restores them from the store**, because `codeViewKey` has no surface id.
- **Pull Requests page:** this panel passes `threadId = null` (`PullRequestsPage.tsx:1469`). It is out of scope here, except as the trigger in R3.

## 2. Seed: local Forgejo fixture forge (recommended)

### Why Forgejo

- No provider has a configurable base URL, and no fake provider can be enabled in a running server. `TestSourceControlHost` (`packages/source-control-testing/src/TestSourceControlHost.ts:43-66`) and `PullRequestProviderRegistry.fromProviders` (`PullRequestProviderRegistry.ts:17-25`) exist only in tests.
- Hosts are chosen by matching the remote URL (`packages/shared/src/sourceControl.ts:211-279`). GitHub and GitCafe force HTTPS to fixed hosts (`GitHubRequestPolicy.ts:113-125`; `GitCafeApi.ts:160-180`, `gitCafeHosts.ts:1`).
- **Forgejo** is selected for any host with a `forgejo` DNS label, and keeps an explicit `http://` origin as its base URL (`sourceControl.ts:228-238`). Requests are plain HTTP to `${baseUrl}/api/v1/...` (`ForgejoCli.ts:339-365`).
- Diffs come from the forge API, not local git:
  - PR diff: `pulls/N.diff`
  - per-commit diff: `git/commits/<sha>.diff` (`ForgejoPullRequestProvider.ts:415-426`)
  - commits: `pulls/N/commits` (`:335`)
- Docker is available on this box. `forgejo.127.0.0.1.nip.io` resolves to 127.0.0.1 here, which I checked with `getent`. Plain `*.localhost` does not resolve.

### Authentication without touching $HOME

- The token is read from `$XDG_DATA_HOME/forgejo-cli/keys.json` (`ForgejoCli.ts:92-100`, `:248-256`; schema at `:53-62`).
- `authenticateFj` still runs `fj --host <url> whoami` as a gate (`ForgejoCli.ts:427-437`), and discovery runs `fj version` (`ForgejoSourceControlProvider.ts:89-110`). A stub `fj` that exits 0 is enough.
- `fixPath` puts the login-shell PATH first (`apps/server/src/os-jank.ts:21-41`), but no real `fj`, `tea` or `glab` is installed here (`which` found none), so a stub on the inherited PATH is still found.
- **Verify in a dry run:**
  - the `keys.json` host key format, which I expect to be `host:port` from `publicLogins` (`ForgejoCli.ts:294-312`)
  - that the detail atom refetches when the epoch bumps

### Files (all under `/tmp/ev-22930/fixture/`, outside both worktrees)

| Path | Role |
|---|---|
| `forgejo-data/`, `forgejo-data.seed/` | Container volume, plus a pristine copy taken after seeding. Restore it before each build run so BEFORE and AFTER see identical SHAs, timestamps and PR numbers. |
| `seed.sh` | Starts `docker run -d --name ev22930-forgejo -p 127.0.0.1:3000:3000 -v $PWD/forgejo-data:/data -e FORGEJO__security__INSTALL_LOCK=true -e FORGEJO__server__ROOT_URL=http://forgejo.127.0.0.1.nip.io:3000/ -e FORGEJO__server__DOMAIN=forgejo.127.0.0.1.nip.io codeberg.org/forgejo/forgejo:11`. Then: `docker exec -u git … forgejo admin user create --admin --username fixture …`; `… forgejo admin user generate-access-token --username fixture --scopes all --raw`; `POST /api/v1/user/repos {name:"review"}`; pushes the branches; opens the PRs with `POST /api/v1/repos/fixture/review/pulls`. |
| `src.git` | Local **bare** repo, the pristine source of truth. It is built with fixed `GIT_AUTHOR_DATE`/`GIT_COMMITTER_DATE` so the SHAs are byte-stable, then `git push --mirror` to Forgejo. |
| `xdg/forgejo-cli/keys.json` | `{"hosts":{"forgejo.127.0.0.1.nip.io:3000":{"type":"Application","token":"<raw>"}}}` |
| `bin/fj` | `#!/bin/sh` + `exit 0` (it answers `version` and `whoami`) |
| `review-a/` | Clone on branch `review-l`, origin `http://forgejo.127.0.0.1.nip.io:3000/fixture/review.git`. It is project A, used by thread A. |
| `review-b/` | Clone on branch `review-s`. It is project B, used by thread B. |
| `push-c4.sh` | Scripted new push (see C4 below). |

### Fixture contents

`main` holds the base.

**PR #1 "L" (long), `review-l`.** 8 files:

| File | Size |
|---|---|
| `short-1.ts`, `short-2.json`, `short-3.md`, `short-4.yml` | ≤ 15 changed lines each |
| `long-a.ts` | 420 lines changed |
| `long-b.ts` | 600 lines, with edits spread through it |
| `long-c.md` | 300 lines |
| `renamed.ts` | a rename with an edit |

Three commits:

| Commit | Files touched |
|---|---|
| C1 | `short-*`, `long-a.ts` |
| C2 | `long-b.ts`, `long-c.md`, `short-2.json`, `renamed.ts` (C2 has ≥ 4 files, 2 of them long, so it can hold a mixed fold and a mid scroll) |
| C3 | small edits to `long-a.ts` and `short-4.yml` |

**PR #2 "S" (short), `review-s`.** 7 files of ≤ 8 lines in 3 commits. At 1440×900 its diff is about 1.3 viewports, so the saved scroll must **clamp** on restore (`pullRequestCodeScroll.logic.ts:37-40`).

**C4 (the new push), `push-c4.sh`.** Adds commit C4 to `review-l` in `review-a/` that:

- edits `long-b.ts` in a file the script has **expanded** (lines 250-270), and
- edits `short-1.ts`, which the script has **collapsed**,

and leaves the other files alone. It has two modes:

- **In-app (default, deterministic):** commit only, then press Push in thread A's Git action. That path calls `refreshPushedPullRequests` → `refreshAfterTurn` (`ws.ts:2840`), which invalidates the project cache and bumps the epoch immediately.
- **`--external`:** `git push` from the shell, then wait at least 61 s and blur and refocus the window.

### Dev server per build

- Use one worktree per SHA, created by the parent, never `~/wt/pr-22930` itself.
- Start it with `XDG_DATA_HOME=/tmp/ev-22930/fixture/xdg PATH=/tmp/ev-22930/fixture/bin:$PATH vp run dev`.
- The worktree's own `.t3` home is fresh. `migrate-dev-db` is not needed; an empty database is fine because the fixture provides the data.
- Add projects A and B (the `review-a`/`review-b` clones), create thread A in A and thread B in B, and open each thread's PR from its branch PR chip.
- Settings: `diffFilesCollapsed=false` and default diff layout, the same in both builds.
- Record the PID at spawn and stop only that PID.

### Rejected alternatives

| Alternative | Why not |
|---|---|
| GitHub or GitCafe | No local host is possible. |
| Gitea through `tea` | Works too (`ForgejoCli.ts:678-697`), but needs a tea login in `~/.config/tea`, which writes to `$HOME`. |
| GitLab with a stub `glab api` (`GitLabPullRequestCli.ts:482-506`, `:612-646`) | **The only way to exercise multi-page diff restore** (`loadedPageCount`), because Forgejo always returns `nextCursor: null` (`ForgejoPullRequestProvider.ts:421-425`). It means hand-writing every GitLab JSON shape. Recorded as an explicit gap (G1) rather than a row. |

## 3. Shared script

**Prelude P** (every row; identical in BEFORE and AFTER, driven by locators and fixed waits on network idle, never by timing):

1. Restore `forgejo-data.seed` and start the dev server. Open thread A → PR #1 → **Code** tab, with All commits.
2. Press Collapse all (`foldOverride=folded`). Expand `long-b.ts` and `renamed.ts`.
3. Scroll the Code viewer with the wheel to the middle of `long-b.ts`. Target scrollTop 3000 px, read back with `viewer.getScrollTop()` and logged.
4. Select commit C2 from the commit selector. Both builds reset here (see §1).
5. Re-apply the mixed fold inside C2: collapse `short-2.json` and `long-c.md`, and keep `long-b.ts` and `renamed.ts` expanded.
6. Scroll back to the middle of `long-b.ts`.
7. Log the state: selected commit label, each file's collapsed flag, scrollTop, and the first visible line number.

**Assertions** after each trigger, once the network is idle plus two animation frames:

- selected commit label
- per-file collapsed flags
- scrollTop within ±1 row height, or the clamp limit
- the first visible diff line

These assertions are logged into `captures.json`, not taken from dwell times.

## 4. Matrix

| id | type | viewport / theme | scripted steps (after P unless noted) | expected BEFORE | expected AFTER | seed | kind |
|---|---|---|---|---|---|---|---|
| R1 | recording | 1440×900 light | Variant of P that **stops after step 3** (All commits, mixed fold, mid scroll). Run `push-c4.sh` in-app mode, then press Push in thread A. Wait for the commit list to show C4. | The epoch bump merges page 1. Then the new `updatedAt` bumps `refreshToken`, which empties the slices (B:`PullRequestCodeTab.tsx:433`). **scrollTop goes to 0.** `long-b.ts` gets a new cacheKey, so its toggle is lost and it **folds again** under `folded`. Unchanged files keep their state. The C4 lines appear. | **The scroll is restored** to the saved scrollTop. `long-b.ts` **stays expanded** (identity key) and shows the C4 lines (content version). `short-1.ts` stays collapsed. The other files are unchanged. | Forgejo + `push-c4.sh` (in-app) | **bug-repro** |
| R2 | recording | 1440×900 dark | Full P (C2 selected). `push-c4.sh --external`, wait 61 s (`PullRequestService.ts:133`, `:139`), then blur and focus the window (`useLiveRefresh.ts:134`). | Commit C2 is still selected (panel state survives). `updatedAt` changes, so `refreshToken` empties the C2 slices and **scrollTop goes to 0**. The C2 patch is unchanged, so the cacheKeys match and the **fold state survives**. | C2 is still selected. **Scroll, fold and C2 content are all unchanged.** The commit list now shows C4. | Forgejo + `push-c4.sh --external` | **bug-repro** (scroll only) |
| R3 | recording | 1440×900 light | Full P. Trigger a **background-only** refresh: in a second browser tab, open `/pull-requests`, select PR #1, press Refresh there. That runs `invalidate` with `notifyReaders` (`ws.ts:2527`) and bumps the epoch, but the thread panel's own `refreshToken` is untouched. Then return to tab 1. | Slices are kept, page 1 merges unchanged, and the page is a no-op: **everything is preserved.** | Same as BEFORE. **No scroll jump and no restore restart** (the background effect does not create a restoration, `PullRequestCodeTab.tsx:474-483`). | Forgejo, no push | **no-regression** |
| R4 | recording | 390×844 dark | Full P at narrow width. Open the PR header menu and choose **Refresh**. | `refreshFromHost` bumps both tokens. Slices are emptied (B:`:433`), so **scrollTop goes to 0**. Fold and C2 are preserved. The diff refills ([#22917](https://github.com/pingdotgg/t3code/pull/22917)). | All loaded cursors are refetched (`pullRequestDiff.logic.ts:61-72`) and the **scroll is restored**. Fold and C2 are preserved. | Forgejo, no push | **bug-repro** (scroll only) |
| R5 | recording | 1440×900 dark | Full P. Code → Summary → Code, then Code → Timeline → Code, then Code → Guide → Code (if the server lists Guide). | The Code tab stays mounted and invisible (`PullRequestDetailPanel.tsx:1793-1794`): **everything is preserved**, including condensed chrome. | Same as BEFORE. | Forgejo | **no-regression** |
| R6 | recording | 1440×900 light | Full P. (a) Hide the right panel with the toggle or shortcut and show it again. (b) Close the PR surface with its X, then reopen it from the thread's PR chip. | (a) and (b) both unmount the panel (`ChatView.tsx:959-970`; key at `RightPanelSurfaceContent.tsx:133`). **C2 is cleared back to All commits, fold goes to its default, and scroll goes to 0.** In (a) the tab returns to Code (`surface.detailTab`). In (b) a new surface may open on Summary. | **C2, fold and scroll are all restored** from `codeViews[env, threadA, PR#1]`. The surface id is not in the key. In (b), whichever tab opens, switching to Code shows the restored state. | Forgejo | **bug-repro** |
| R7 | recording | 390×844 light | Full P in thread A. Switch to thread B, open PR #2 "S", apply a mixed fold, scroll to the bottom, and select a commit. Switch back to A, then back to B. | Each switch remounts the panel (key `${thread}:${surface}`): **both threads reset** to All commits, default fold and top. | A restores **PR #1 C2 + fold + mid scroll**. B restores its own state, **clamped** to S's max scroll (`pullRequestCodeScroll.logic.ts:37-40`). **No state leaks** between threads (`threadId` is in the key, `pullRequestReviewStore.ts:48-54`). | Forgejo, two projects | **bug-repro** |
| R8 | recording | 1440×900 dark | Reset boundaries. Run P steps 1-3. Select C2, then go back to **All commits**. Then reload the page. | Selecting C2 resets fold and scroll, and returning to All commits resets them again (`scopeKey`). A reload resets everything. | Same resets: `selectCodeCommit` writes `EMPTY_CODE_VIEW` (`PullRequestDetailPanel.tsx:340-347`), so the **All-commits state from steps 2-3 is not remembered** across a commit round trip. A reload resets everything (in-memory store). | Forgejo | **no-regression** (documents intended scope; the maintainer should confirm the commit-round-trip reset is intended) |
| S1 | still | 1440×900, light **and** dark (4 images: before/after × theme) | At rest after R4's sequence at 1440 width (P, then Refresh, then network idle plus 1 s, mouse parked outside the panel). Whole surface. | Code tab at the **top** of the C2 diff, fold preserved. | Code tab at **mid `long-b.ts`**, fold preserved. Otherwise pixel-comparable to BEFORE. | Forgejo + seed restore (stable SHAs and dates; relative times are frozen by the fixed commit dates, but the header's "updated N ago" may still differ, so mask it) | **bug-repro** (at-rest outcome) |
| S2 | still | 390×844, light **and** dark (4 images) | Same as S1 at narrow width. | Same as S1, at narrow width. | Same as S1, at narrow width. | Same as S1. | **bug-repro** (at-rest outcome) |

**Coverage of the requested update paths:**

| Requested path | Rows |
|---|---|
| (a) new push | R1 (in-app), R2 (external) |
| (b) background refresh | R3 (pure); also part of R1 and R4 |
| (c) explicit Refresh | R4, S1, S2 |
| (d) leave and return | R5 (tabs), R6 (hide/show and close/reopen) |
| (e) thread switch | R7 |
| mixed fold | all rows |
| selected commit | R2-R7 |
| short/long fixtures | PR #1 L everywhere; PR #2 S in R7 |

## 5. Gaps and caveats

- **G1, multi-page diffs.** Forgejo never paginates, so restoring `loadedPageCount` (`pullRequestCodeScroll.logic.ts:33-34`) and refetching every cursor in `restartPullRequestDiffPagination` are not exercised. The only local route is a GitLab stub `glab`. Those paths are covered only by the unit tests in `pullRequestCodeViewState.test.ts`.
- **G2, server watch.** The watch reactor never bumps the client epoch (`PullRequestWatchReactor.ts:468-470`). "Watch refresh" in the UI is the R2 path (live refresh), not the watch itself.
- **G3, input cancels restore.** Any wheel, pointer or key input during a restore cancels it (`pullRequestCodeScroll.logic.ts:91-106`). The script must not touch the viewer between the trigger and the assertion, or AFTER will legitimately stop restoring.
- **G4, dry-run checks before capture.** Confirm the `keys.json` host key format, that the stub `fj` is accepted by discovery, that the detail refetches on the epoch bump in R1, and the Forgejo image tag. If the in-app Push in R1 shows no PR chip because the branch and PR association fails, fall back to `--external`, as in R2.
- **Surfaces.** The change is web-only (7 files under `apps/web/src/components/pullRequest/`). Desktop wraps web. Mobile does not use these components. The Pull Requests page panel (`threadId=null`) inherits the store, but no row covers it beyond the R3 trigger.

Audit: Claude Opus 5.5 via Claude Code (T3 Code), proof auditor pass 1 (matrix v2), read-only.
