Skip to content

Desktop: the wave-2 redesign — navigation, GitHub surfaces, and seven sweeps of repair - #26

Draft
antonarnaudov wants to merge 180 commits into
mainfrom
redesign/wave-2
Draft

Desktop: the wave-2 redesign — navigation, GitHub surfaces, and seven sweeps of repair#26
antonarnaudov wants to merge 180 commits into
mainfrom
redesign/wave-2

Conversation

@antonarnaudov

Copy link
Copy Markdown
Contributor

This branch takes the Electron desktop app from what its owner called "a rough construction of a POC" to something that can ship. On main it was a set of disconnected tabs over a read-only tree of HEAD; here it is a navigable Git client — three new rail destinations, a ⌘K palette, a real back/forward history, and roughly a dozen routed full-page surfaces replacing the modals and leftover-height panes people were expected to work in. Underneath that, about a dozen commits repair ways GitStudio could silently delete, corrupt or orphan work while reporting success, several of which ship to the VS Code extension too because they live in packages/git-service. The 180 commits are 10 feat: and 137 fix:, the fixes produced by seven adversarial review sweeps — and the pattern that kept repeating is that each sweep's most productive lens was the one pointed at the previous sweep's own fixes.

What this adds (user-facing)

  • Three new rail destinations, and a different front door. Inbox (apps/desktop/src/renderer/views/notifications.ts) is a triage page with facets and per-row e-to-mark-read instead of a top-bar bell popover. My Work (views/mywork.ts, backed by main/github/myWork.ts) runs four @me searches in parallel and dedupes each item into the most actionable of Review requested / Assigned / Your PRs / Mentions. Explore (views/explore.ts) puts GitHub repo, people, org and code search in the app. The app no longer opens on Code; it lands on Changes, with the rail reordered by daily use (⌘1 Changes … ⌘6 Code).
  • ⌘K jumps anywhere. renderer/commandPalette.ts (new, 360 lines) fuzzy-searches sections, every branch and tag, recent repositories, open PRs and issues, and the headline verbs (new branch/PR/issue, fetch/pull/push, clone, open repo, terminal, theme, check for updates). Local groups paint instantly, GitHub groups stream in; typing past your local repo offers "Search GitHub for …" with live results, debounced and generation-checked in renderer/searchDebounce.ts so a slow answer to an old query never renders over a newer one.
  • A history you can walk. Back/forward chevrons and ⌘[ / ⌘] ride renderer/navStack.ts, and every detail page's Back button pops it rather than pushing (on main, Back left Forward disabled — proof it was appending). Back now names its true destination: leaving PR #106 for a pipeline and pressing back says "Pull Request #106", not "Actions". renderer/focusReturn.ts returns the cursor to the row you opened instead of <body>.
  • A commit is a page, not a row to reveal. views/commit.ts shows message, both identities, parents, refs and every changed file with a / filter, plus cherry-pick, revert, branch-from and reset. Previously every "show me this commit" routed to the graph and called reveal(sha), which shows no files and returns silently when the sha is outside the loaded page — so from a long-lived PR the click did nothing at all, with nothing on screen saying so.
  • CI logs are a document. The log moved out of a pane inside the run page (measured: 523px of log in a 913px window that itself scrolled 1,048px) into its own route: views/jobLog.ts over renderer/logView.ts + logModel.ts. It virtualizes 200k lines to ~120 DOM nodes, colours ANSI, folds ##[group] steps, keeps a sticky step strip, draws an error map whose ticks land on the failure, and adds n for next failure, in-log search, a timestamps toggle, live tail with a Follow mode that goes dead when there is nothing left to follow, and save-to-Downloads.
  • Peeks, and Branches as a ref manager. renderer/peek.ts + peeks.ts add a stacked drill-in overlay across branches, remotes, tags, stashes, commits and org repos/teams/members — click a branch to read its history, click a commit inside for its files, to come back without losing your place. Branches itself became one kind per screen behind a segmented switch, with Fetch promoted out of a hover-revealed kebab, standing facets (merged, upstream gone, diverged, unpublished), an Active/Stale cut and a "Delete N finished…" sweep.
  • Read any GitHub repo without cloning, then clone it in one click. renderer/repoBrowser.ts (peek stack) and views/exploreRepo.ts (full page with breadcrumbs, ref switcher, go-to-file, rendered README, Monaco-highlighted contents); renderer/ghOpen.ts (ghrepo:open) opens owner/repo as a normal local repo — instantly if a clone exists, otherwise cloning behind a progress card — with destinationSheet.ts for where it lands. On main an org repo dead-ended in a metadata card.
  • Modals and split panes became routed pages. detailPage() in views/common.ts backs commit, ref, job log, the issue/PR composer, the release composer, and the PR/issue/release/gist/run/Explore pages. The release composer replaces a 560px card that gave the notes ~180px, and adds a tag combobox that says out loud when it will create a tag, a target ref picker, "Generate release notes", latest-release, and publish-vs-draft as two named buttons.
  • Writing stops losing work. renderer/mdEditor.ts gives all five composers one Write/Preview editor whose preview renders through the same renderMarkdown the published body uses, plus ⌘Enter to submit; renderer/draftStore.ts keeps unsent text through an accidental Escape, keyed by repo (the app had already shipped handing repo A's draft to repo B's issue #31). renderer/proseNav.ts routes links and bare #123 refs in READMEs, bodies, comments and release notes to the in-app page instead of the browser; renderer/highlight.ts colours fenced code through Monaco's tokenizers.
  • Loops closed: Compare gained "Create pull request" and real per-file diffs through the shared Monaco panel (compare:fileDiff); Actions runs show run number, actor, attempt, per-step timings and artifacts, with Cancel / "Re-run failed" hidden when inapplicable rather than greyed out forever; the repository manager moved out of Settings onto the top-bar repo chip and ⌘K; ? opens a cheat sheet audited against the keys the app answers to; renderer/prefs.ts lets view modules honour Settings → "Prune on fetch", which only two of four fetch buttons obeyed.

Correctness and data safety

  • Conflict side labels were backwards during a rebase. Stage 2 is "ours" in a merge, but a rebase checks the upstream out and replays onto it, so stage 2 is the upstream. "Take your version (current change)" discarded the commit being replayed, with tooltip and success toast both agreeing it had done the opposite (sideLabels(), main/gitBridge.ts:3202). The fix then lumped git am in with rebase and re-created the same lie one operation over. test/conflictSideLabels.test.ts drives a real conflicting rebase and a real conflicting am and asserts the stage assignment, not the strings.
  • "Mark resolved" destroyed data three ways, each under "Resolved and staged." The result pane is seeded with the base and the button was armed from open, so pressing it first wrote the merge base over the file; the first gate counted only conflictsPending, leaving auto-mergeable blocks at base, so it now counts every pending block and seeds from git's auto-merge (renderer/diffPanel.ts:679-705). It wrote with writeFile(abs, text, "utf8"), which follows a conflicted symlink out of the repo and puts binaries through a UTF-8 round trip — a 4,508-byte PNG came back 4,565 with 89504e47 rewritten to efbfbd504e47, staged (now textWriteSafe() at gitBridge.ts:2867 plus a realpath of the parent dir). And readWorking caps at 512KB, so a larger conflicted file was truncated to its first 512KB.
  • git add on an unmerged path is declaring the conflict resolved; four routes ignored it. "Stage all" then Commit produced a commit with <<<<<<< in five files, and staging cleared the unmerged entries so the conflict count fell to zero and re-enabled Continue. add -A -- . ':!path' stages the excluded path anyway against real git, so the guard is an explicit allow-list (gitBridge.ts:897-907); UD/DU are held back on kind; a conflicted binary was waved through because "no markers means resolved" is vacuously true for it. Per-file stage() and both partial-staging routes had the same hole, closed in lineStageable, later narrowed to paths git reports unmerged so a file documenting conflict markers stays stageable.
  • "Take ours" deleted every file with a non-ASCII name. An earlier sweep replaced ls-files -u -- <path> with unfiltered ls-files -u plus hand-rolled matching; without -z, core.quotePath prints "caf\303\251.txt" while the renderer sends café.txt, so nothing matched and "no stages found" fell into the branch that runs git rm. On one six-file conflict, café.txt, emoji🎉.md, 日本語.md and sub dir/ünï.ts were deleted with the deletions staged and ok:true. Fixed with -z and a [\s\S] path capture, and an unreadable listing is now a refusal rather than a delete; missingSide was brought to the same shape.
  • Four of git's seven conflict kinds had no resolution, and one had a destructive one. modify/delete ran git show :2:/:3: unconditionally, so taking the deleting side surfaced a raw fatal: for a button the app offered — an absent stage now reads as that side's answer and stages a deletion. DD was drawn as a modify/delete; UA/AU were told "deleted in X" about a file with no base; Discard ran git checkout -- <path>, which refuses unmerged paths, so a destructive confirm was followed by raw stderr — it uses checkout --merge now (gitBridge.ts:814-839).
  • Short refnames are ambiguous, and it cost a branch. %(refname:short) returns the shortest unambiguous name, so a branch colliding with a tag comes back as heads/stacked-a and went into update-ref refs/heads/heads/stacked-a — junk branch, success reported, the user's real branch orphaned outside the rewritten history, which is exactly what --update-refs exists to prevent, pre-ticked by default (main/rebaseBridge.ts:290-330). Same root cause in SyncOps.push, which sent a bare branch name and failed with src refspec v2 matches more than one; every path now sends refs/heads/x:refs/heads/y (packages/git-service/src/SyncOps.ts:154-205), and branches checked out in another worktree are excluded via %(worktreepath).
  • The Rebase view built plans git will not execute. git log <base>..HEAD lists merges and already-upstream commits; the sequencer uses --no-merges --topo-order --cherry-mark --right-only. Either shape left the repo detached at the base, mid-rebase, clean, with the in-progress card asking the user to resolve conflicts that do not exist and Continue re-running the same todo forever. In packages/git-service/src/rebasePlan.ts, update-ref is now emitted after the last folding row (foldEnd, drop transparent) — the old placement pointed a branch at a commit a following fixup then rewrote. A plan dropping every commit also passed validation: Start erased the range and offered to force-push it.
  • Every reword after a rebase stop was silently discarded — messages were queued in a temp dir and popped by counting editor invocations, so a conflict ended that process and --continue ran with GIT_EDITOR=true. Sha-keying then introduced a wildcard ({sha: ""} from an optional-field shim, startsWith("") matching everything) that wrote the first message onto every reworded commit in the shipping extension; the identity stamp meant to fence an abandoned queue was defeated because git rebase --abort restores onto/orig-head byte-identically. The queue now lives inside git's own rebase-merge/, whose lifetime git enforces. Same area: rebaseInProgress grepped git status for a translated string, so on a French or Chinese git the "exit 0 is not finished" guard was always false.
  • git rebase --skip hard-resets the tree, and the banner offered it as the primary button at a deliberate edit stop (diff --cached --quiet HEAD is true there too). opState now decides kind/canContinue/canSkip once where the git knowledge is; test/opMatrix.test.ts (462 lines) crosses every mid-operation state with every tree shape and asserts capability, not button labels. Skip is confirm-gated, banner buttons disable for the round trip, and serialize() queues a second call instead of dropping it — two clicks used to throw away two unrecoverable patches.
  • Line staging worked in the wrong coordinate space. fileDiff builds working-tree diffs as HEAD-vs-working whatever the stage state, but the unstage path matched those line numbers against index coordinates: on a MM file, clicking one staged line left it staged and silently rolled back a different change, returning ok: true under "Unstaged selected lines." The pre-existing test could not catch it (it writes nothing after staging, so the two spaces coincide). Separately indexMode returned 100644 for any path with no index entry, so a new executable script staged by line or hunk committed non-runnable (packages/git-service/src/StagingProvider.ts).

Architecture and shared packages

  • One deliberate breaking change in the shared engine. RebasePlan.rewordMessages: string[] became rewords: Array<{sha, message}> in rebasePlan.ts/RebaseRunner.ts, non-optional on purpose (the rewords? shim is what minted the startsWith("") wildcard). Both extension callers migrated: apps/extension/src/graph/graphPanel.ts:605 and src/rebase/rebaseWorkspacePanel.ts:185. runRebasePlan also refuses to start while a rebase is in flight, before writing the queue.
  • The extension inherits that for free — this is the blast radius. apps/extension/src/rebase/rebaseRunner.ts is a 40-line binding, so VS Code picks up sha-keyed rewords, the persisted queue, core.commentChar=auto + pickCommentChar (a #123 body line no longer stripped), and exit-0-with-a-live-rebase reporting an edit stop. It does not pick up skipRebase, the message-carrying abortRebase, or the onRun observer.
  • Nine Electron-free modules under apps/desktop/src/main, extracted so they unit-test: githubPaging.ts (RFC-5988 Link rel=next + a per-surface PAGE_CAPS), github/maps.ts (one home for mapRun/mapPull/mapIssue, which existed in two divergent copies), github/searchGuard.ts (clock-injectable token bucket for GitHub's separate 30/min and 10/min search budgets, returning limited{retryInMs} instead of a 403), github/searchQuery.ts, githubStatus.ts, githubRemote.ts, localRepos.ts, appSettings.ts, ghRepoOpen.ts, plus shared/cloneName.ts.
  • githubClient.ts grew a transport layer: a private fetchRes behind request, new requestPaged/requestPagedKey with an explicit policy (first-page failure throws, a follow-up page's failure returns what was gathered), AbortSignal.timeout on every fetch (20s API, 30s body-less, 300s asset upload), a separate fetch for uploads.github.com, and listOpenPullslistPulls(state).
  • The IPC contract nearly doubled: apps/desktop/src/shared/ipc.ts 1458 → 2193 lines, adding search:*, repos:*, ghrepo:*, settings:*, update:*, the am:*/revert:*/cherryPick:* operation controls, tag:delete/tag:push, actions:jobLogChunk, and GitOpState (which names amApplying separately from rebasing because both live in rebase-apply/). main.ts's handle() wrapper is now the single error seam: every ok:false and every throw reaches ErrorReporter with a human actionLabel(channel), gated by isExpectedError.
  • The pre-1.4 keychain token migration was deleted, not fixed. GitHubBridge.adoptLegacyToken is gone and ensureLoaded unlinks any leftover safeStorage blob unread. Consequence: anyone upgrading from <1.4 is signed out once, in exchange for the macOS keychain prompt never firing again on a re-signed binary.
  • Other shared-surface moves worth a second look: diffView.ts/mergeView.ts dropped editor.getModel()?.dispose() (the standalone editor owns the model; the double-dispose threw on every teardown and aborted worker-sync unregistration) — that also fixes the extension's diff/merge webview. --gs-amber now prefers --vscode-charts-yellow over gitDecoration-modifiedResourceForeground in both tokens.css and hostTokens.ts, reversing the previous comment's AA-contrast rationale without answering it. COLUMN_DROP_TAIL_AT moved into packages/webview-ui/src/limits.ts and changed 760 → 860, so the extension's graph drops Date/SHA at a wider host width.

How this was verified

934 unit tests across five packages, 364 headless UI checks, five typecheckers clean.

# unit tests (all workspaces) and typechecks
npm test
npm run check-types

# just the desktop suites
npm test -w apps/desktop

# the headless renderer harness (macOS, Chrome required)
cd apps/desktop
harness/gen.sh                      # builds the renderer, assembles harness/page
node harness/check.mjs              # all 364 cases; non-zero exit on any failure
node harness/check.mjs graph rebase # filter by substring
harness/shot.sh 'issues~open31' out/detail.png light   # screenshot a scene
node harness/probe.mjs 'prs~open106' 'return box(".det-title")'  # measure a surface
  • apps/desktop/harness/ is 11,222 new lines across five committed files (checks.js 8,818, shim.js 1,603, check.mjs 548, probe.mjs 139, gen.sh/shot.sh 75). gen.sh builds the renderer and copies dist/renderer/{renderer.js,renderer.css,theme-boot.js} next to the shim; Chrome runs --headless --dump-dom against harness.html?scene=…&check=…, and the verdict comes back in document.title as CHECK {json}. No Electron, no git, no network.
  • Scenes are the driver vocabulary: "<view>~step~step" with open<N>, click:, text:, type:, key:, scroll:, palette, bell, esc — e.g. prs~open106~text:Files~click:.pr-files-list%20.file-row:nth-child(5). Two details are load-bearing: a text: needle prefers a match inside #view-host over the nav rail (shim.js:1508), and key: dispatches on document.activeElement rather than document, or every handler guarded by e.target.closest() no-ops and the check passes on a broken build (shim.js:1532).
  • shim.js fixtures ~89 of the 242 IPC channels, with a dynamic block for state that must change mid-scene, a real on()/__gsEmit() registry for push paths, ?fail=pr:commits to make a channel reject, and switches like staging=checkboxes, norepo=1, clean=1, onfeature=1, op=merge. Unfixtured channels are collected and printed once per run — an unfixtured read answers undefined, so a check can pass over a throw; that is how the PR label picker went unchecked.
  • 364 cases over 273 assertion ids across 19 scene roots (changes 57, actions 47, prs 43, issues 32, branches 31, code 28…), re-run across widths 880–1920, both themes and arg values; no-view-hides-its-own-content-or-locks-out-the-keyboard runs over all 15 views. Cases run serially, one Chrome each (parallel Chromes fight the GPU lock and produce flaky geometry), capped at 90s.
  • The unit suites went 79 → 143 .test.ts files (+64, +440 test() calls, none removed) with no jsdom by policy. Three kinds: pure functions (textFit, truncate, logModel), in-process modules with a hand-settled host stub (cache.test.ts's stale-while-revalidate races), and real-git integration tables — opMatrix.test.ts (462 lines), opState.test.ts (519, pinning git am vs rebase apart by the marker file in .git/rebase-apply), stagingSafety.test.ts (a PNG staged line-by-line is no longer round-tripped through a JS string).
  • A distinct class: source-census tests that assert properties no behavioural test can. gitBridgeArgGuards.test.ts requires every CommitActionResult method in gitBridge.ts to call safeArg or appear in a REVIEWED map with a written reason — writing that list found the one real gap, WorktreeProvider.remove building ["worktree", "remove", path] with no --. destructiveGuards.test.ts censuses destructive renderer controls for a confirm or an in-flight disable. readFailuresSurface.test.ts bans .catch(() => []) in githubBridge.ts. cssTokens.test.ts and stylesheet.test.ts catch the two app.css failures that have actually shipped here (an undeclared var(--accent), and a comment terminated early by a */ inside a selector glob).

Reviewing this

Read in this order:

  1. docs/desktop-redesign.md (+362, new) — the spec every GitHub view was converted to: the list-page/detail-page/right-rail contract and the nav("issues", {number})nav("issues", {list:true}) routing rule.
  2. apps/desktop/src/shared/ipc.ts (+743) — the typed catalogue of everything the app can now do.
  3. apps/desktop/src/renderer/views/common.ts (+1,368) — ~30 exports (detailPage, secRow, sectionList, facetBar, wireListNav, disposeOnDetach) that are the vocabulary every other view file is written in. Most remaining view files are instances of these three.

Then the riskiest diffs, in descending order:

  • 37e62b5 ("visibility, flexibility and reach across every GitHub surface") is effectively unreviewable as a commit: 89 files, +21,662, including a wholesale app.css rewrite (8,987 lines in that commit) across nine independently-scoped phases. If there is budget for one deep read, spend it here — the 100+ following fix: commits are largely its fallout, and reviewing them without it is reviewing patches to code you have not seen.
  • apps/desktop/src/renderer/diffPanel.ts (+594) — split/inline diff, the conflict merge editor, the "Mark resolved" gate. No harness check can reach it (see below) and the conflict*.test.ts files drive GitBridge in the main process, not the renderer. This is where the fix that could write the merge base over both sides' work landed with no test, and where the commit five later says that fix's gate "was counting the wrong thing." Read it line by line.
  • packages/git-service/src/RebaseRunner.ts (+483/-25) — highest blast radius, because the shipping extension routes through it. Read the reword-queue chain as one unit: SHA keying, then the identity stamp, then the move into .git/rebase-merge/. The first two each declared the bug class closed and were wrong.
  • apps/desktop/src/main/gitBridge.ts conflict paths — sideLabels() (:3202), textWriteSafe() (:2867), the staging allow-list (:897-907), Discard (:814-839).
  • apps/desktop/src/renderer/renderer.ts — 8,721 lines, ~8,480 of them one class App with 142 methods and 226 private fields, grown +4,827/-560 across 40+ commits. There is no seam; routeView (line 1103) is the only entry point worth tracing.

Changes an existing desktop user will notice on first launch, none of them in a changelog: the default view is Changes and the rail is ⌘1–6; ⌘W no longer closes the repository (⌘⇧W) and ⌘R is Refresh rather than Electron's hard reload; the clone/repository manager moved out of Settings onto the top-bar repo chip; the whitespace toggle now sends "trailing" instead of "all" so Split and Inline agree, which changes what the toggle does; and "Prune on fetch" is now honoured by the Branches and Releases fetch buttons that ignored it.

Known gaps

  • The harness is not in CI. git diff main...HEAD -- .github/ is empty; .github/workflows/ci.yml still runs only npm run check-types + npm test, and apps/desktop/package.json's test script is still tsx --test "test/**/*.test.ts". There is no npm run harness. The 364 checks are a manual, macOS-only pre-commit gate — all three entry points hardcode /Applications/Google Chrome.app/…. Several commit messages cite check counts as evidence; nothing on the branch makes that reproducible on a PR.
  • Monaco is invisible to the harness. gen.sh copies only renderer.js/renderer.css/theme-boot.js, so editor.worker.js never runs, and Monaco paints its diff decorations on an animation frame that headless Chrome's virtual clock starves — an idle page never advances to a requestAnimationFrame, so mid-transition geometry reads stale (hence the transition:none!important sheet at checks.js:36). The unified diff view and the merge accept gutter are therefore pinned by unit tests and by reasoning, not by any end-to-end check — the unified-diff half is handed to packages/engine/test/whitespaceRule.test.ts, where both surfaces read ignoreTrimWhitespaceFor() from one function. That is also the exact surface where every work-destroying bug on this branch lived.
  • The harness replaces the main process entirely, so nothing under apps/desktop/src/main/ is exercised by it; there is no pixel diffing (screenshots are for human review); and the only production-side hook is window.__GS_ROUTES (renderer.ts:1104, :7639), pushed only when the array already exists.
  • Known regression for the extension: refClick. commit-graph.ts now calls preventDefault()/stopPropagation() and returns on any .chip[data-ref] before the row-selection path, and only the desktop's graphMount.ts:48 handles the resulting action — packages/webview-ui/src/graph/main.ts has no refClick case and no default, so in VS Code clicking a branch or tag chip does nothing where it previously selected the commit and opened the details dock. The same commit gave the chip role="button", cursor: pointer and a hover underline, so it now looks more interactive while doing less.
  • Shared-package behaviour moves with almost no extension-side testing. Only 5 files under apps/extension/ changed (+254) against 45 under packages/. StagingProvider.indexMode now asks the working tree for a file's mode, which also means hunk/line staging in the extension stages a chmod +x and pays two extra git processes (ls-files, config --bool core.fileMode) per call. commit-graph.ts (+288), gutter.ts, refLayout.ts and RefProvider.ts (4 new for-each-ref fields) all ship to the extension graph, verified only through the desktop harness.
  • Some fixes are verified by reasoning and say so. be5f1b4 fixes a per-visit Monaco diff-editor leak on the commit page and declines to add a check because a DOM probe "reports success on the broken build"; it notes this is the third leak of that shape. The class is still open: five Monaco owners under two disposal conventions (activeMonacoView in renderer.ts, 3 sites; disposeOnDetach in views/common.ts, used by commit.ts and prs.ts), with nothing stopping a sixth from registering with neither. 32 of the 162 source-touching commits carry no test or harness change at all.
  • Security surface: two deliberate relaxations, one real fix. main.ts replaces the blanket setPermissionRequestHandler(→false) with callback(permission === "clipboard-sanitized-write"), and the renderer→main channel count went 194 → 242 while src/preload/preload.ts remains a 36-line generic ipcRenderer.invoke(channel, payload) passthrough with no runtime allowlist — the types are compile-time only, so each handler's own safeArg/containedPath guards are the whole boundary. Against that, 3e17dd8 closes a fail-open hole in markdown.ts's sanitizer and pins it as a property, and 7a109e7 fixes three leaks in the crash-report scrubber (Windows backslashes, paths with spaces, IPv6).
  • Review nit: the binary-conflict rationale comment is duplicated verbatim at gitBridge.ts:876-887.
  • An eighth sweep has run and its findings are still open. It confirmed 33 defects that are NOT fixed in this branch, 22 of them "broken". The worst: opening a file in the Code browser renders a 5px-tall editor (the Monaco host has no CSS rule at all); leaving Commits and coming back leaves the graph blank with dead scroll; the top-bar branch chip offers a row called "origin" that checks out a junk local branch; the New pull request form validates only after it has closed, discarding what you typed; and each Actions filter change leaks another permanent 12-second poll chain. Full list in the sweep report linked from the description of this work. This is why the PR is a draft.

Three of the branch's worst data destroyers were introduced by earlier commits on this branch: the am labels by the commit that fixed the rebase labels; the non-ASCII git rm by a commit whose message called the change "not a bug being fixed — it is a dependency being removed"; and the symlink/binary write by the sibling of the function that had just been hardened against exactly that. Each was caught by the next sweep rather than by a test, and each commit message says so in its first paragraph. That is the strongest available evidence the sweeps did real work, and the weakest possible argument for trusting any single one of these fixes without the negative test that accompanies it.


🤖 Generated with Claude Code

antonarnaudov and others added 30 commits August 27, 2026 17:21
…surface

The second wave of the section-page redesign. Nine phases, each landing with
its own tests: the app now answers questions it used to send you to a browser
for, and stops hiding metadata GitHub already gives us.

Pipelines (A1, A2)
  Mapper consolidation into github/maps.ts, then real log depth: inline,
  virtualized, ANSI-aware panes with live tail, replacing the small modal.
  Runs show #runNumber (not the internal id), actor, attempt and durations;
  jobs show their runner, queue latency and per-step timings.

Clone control (E1, E2)
  Where clones land is a setting, with a per-action destination sheet and a
  folder-name override on every surface. ghrepo:open returns structured codes,
  so a collision reopens the sheet prefilled instead of dead-ending. Settings
  gained a manager for every clone on this machine — origin, badges, reveal,
  forget, and a typed-confirm delete whose refusal rule is one pure function.

Metadata (A3)
  PRs carry merged-by, requested reviewers, commits, review comments and fork
  origin; issues distinguish closed-as-completed from closed-as-not-planned;
  comments carry edited markers, association badges and reactions. subjectRef()
  parses a notification's subject url, which is what lets Inbox Release and
  Commit rows open in-app instead of bouncing to github.com.

Filters (A4)
  One facet vocabulary across Actions, PRs, Issues, Inbox and My Work. A facet
  without a predicate is server-side: Actions' filters re-fetch, so narrowing
  reaches runs the first page never loaded.

Explore (E3, E4, E5)
  Global GitHub search as a rail page — repositories, people, organizations
  and code — with full repository pages (routed breadcrumbs, ref switcher,
  go-to-file over the whole tree) and account pages. Search is metered by a
  token-bucket guard that refuses before spending and reports how long to wait.
  Cmd-K searches GitHub live, debounced and generation-checked.

Pure logic lives in DOM-free modules so it is node-testable: facetModel,
exploreRoutes, searchDebounce, logModel, searchQuery, searchGuard, cloneName.
269 tests pass; both tsconfigs clean; every surface verified in dark and light
through the in-repo headless harness (also added here).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 1 and 2 of the polish pass. Both are root causes, not screens — each
one closes a cluster of findings from the surface sweep.

Overlays
  Every floating layer mounts on document.body, so a view swap could not take
  it with it: an Inbox facet menu survived navigation and hovered over the next
  view, filtering a list that was no longer on screen. Layers now register how
  to dispose themselves (overlays.ts) and routeView dismisses whatever is open.
  openMenu also stopped leaking: it removed the previous menu's element but
  never its capture-phase listeners, leaving a handler that still answered
  Escape and refocused a detached anchor.

  The command palette highlighted the wrong row. It re-selected by item
  identity while search groups are PREPENDED, so the selection slid downward
  as results arrived above it and Enter fired the bottom row. Identity is now
  preserved only for groups appended below.

  Eleven ad-hoc modal widths (420/480/520/540/560/640/760) collapse to three
  tokens; two dialogs had been inheriting a width by accident.

Lists
  The count badge reports what is ON SCREEN — it used to report the fetched
  page size and never move, so filtering to two rows still read "8", and an
  empty result read "8" above an empty state. It now reads "2 of 8".
  The Inbox counted unread once and never recomputed, contradicting its own
  summary line; that line is gone, the badge is the single source.

  One meta order everywhere: author, then assignees, then counts. Issues led
  with assignees, PRs trailed with the author, My Work used bare text — the
  same circle in the same slot meant something different on each list and said
  so nowhere. Avatars now carry their role.

  Facet menus speak the language of the rows. They listed raw API values
  ("subscribed", "PullRequest") beside rows reading "watching" and "PR" — and
  "manual" maps to "subscribed" while "subscribed" maps to "watching", so the
  raw value was actively misleading.

  Empty states gained a secondary action, ending four hand-copied "Clear
  filters" blocks (one of which offered to clear filters that were not set),
  and no longer sit 400px below the filter that emptied the list.

  Issues' "Reason" is "Closed as" and only appears where it can match. The
  Inbox toggle is a segment showing state instead of a button naming the next
  action. Releases adopted the shared segmented control.

  Initials no longer render punctuation: "s-ohta" was a tile reading "S-".

  The notifications popover had two left edges (the unread dot was absent
  rather than hidden on read rows), clipped every title mid-word at 424px, and
  wrapped its refresh button onto an otherwise empty row. It is wider, titles
  wrap to two lines, and the facet bar stays on the full Inbox page.

278 tests green; both tsconfigs clean; verified in dark and light.

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

Phase 3 and the first half of phase 5 of the polish pass.

Pull Requests
  The list was permanently open-only — it fetched `state=open` and had no
  control — while Issues had Open/Closed/All one rail item away. It now has
  Open/Merged/Closed/All. "Merged" is not a GitHub state (a merged PR is
  closed with merged_at set), so it fetches closed and narrows locally; Closed
  means closed-and-not-merged, so the two segments are disjoint rather than one
  quietly containing the other.

  The facet named "State" contained no states: Ready / Draft / From-a-fork.
  Draft-ness and where the head branch lives are different questions, so they
  are now "Review" and "Origin".

Actions
  The run detail was two hollow rows in 900px of empty page: every step, every
  runner, every duration was hidden behind a chevron. Jobs now open with their
  steps, which is the content of the page.

  One run had two numbers on one screen — the crumb showed the internal id
  ("#9100") while the title showed the run number ("#411"). The run number is
  the identity now; the id stays in the rail where it is copyable.

  The rail printed Status, Branch and Trigger that the header already showed
  40px away; those are gone. Status returns only while a run is live, where it
  is news rather than an echo.

  Cancel is hidden on a finished run and "Re-run failed" on a successful one,
  instead of sitting there greyed out forever. "View all logs" became a real
  toggle rather than a one-way button that kept offering to do what it had
  already done.

  Run rows dropped the status word — the coloured icon says it, and now
  carries the label for hover and screen readers — and no longer print the
  workflow name twice when a scheduled run's title IS the workflow name.

Toolbars
  gh-head-tools had no flex-wrap and no min-width, so a row of up to twelve
  controls could only squash, clipping the search field and facet labels. It
  wraps now; verified at 1100px.

278 tests green; both tsconfigs clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phases 4-7 of the polish pass, worst-first.

Log viewer
  `##[endgroup]` was emitted as a line. It carries no payload, so every group
  left a blank NUMBERED row the raw log does not contain; the group now closes
  at its last real line instead. The pane also had no ground of its own — in
  dark its background was byte-identical to the app's, in light it sat 1/255
  from its own toolbar — so a terminal dissolved into the page. Its height was
  a fixed 380px, so a three-line log reserved the full box.

  Searching stranded you: a search or error jump turned follow-tail off but
  left "Jump to latest" hidden, so there was no way back to the tail. The
  toolbar's titles never tracked their state ("Show timestamps" while
  timestamps were showing) and used a history icon to mean timestamps.

Organizations
  The header ran name → @login → buttons → a full-width rule → description, so
  the description was cut off from the thing it described by the actions and a
  divider, and "Copy login" led the page. It is now one identity block —
  avatar, name, @login, description — with the actions beside it.
  Cards were 280px and clipped mid-number ("★ 2,1…"); they are wider and their
  meta wraps. The filter placeholder no longer clips to "Filter repos, teams,
  mer".

Safeguards and paths
  The typed-delete confirmation used the required text as the input's own
  placeholder — showing the answer inside the box you must type it into, which
  teaches copying what is already on screen. Paths now shorten from the MIDDLE
  (new textFit.ts): right-truncation ate the repo folder, so two clones under
  different parents both read ".../Developer/GitStu…".

  Rebase's loading state was a spinner in the top-left corner of a blank pane,
  and became permanent if the response was an unexpected shape — the check
  read `state.ok` outside the try. It is centred, inside the try, and offers a
  retry.

Naming
  The repo menu was the app's only Title Case surface. Cloning was called
  three different things within two clicks; it is "Clone repository"
  everywhere.

Also: the Inbox gained the search field every sibling list already had.

283 tests green; both tsconfigs clean.

Note: the sweep's "Commits has no toolbar" finding was a false positive — it
was observed against an empty graph fixture. With real rows the view has its
ref chip, count, search and filters.

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

The last of the sweep's layout findings.

Changes
  The M/A status letters could not be scanned as a column: staged rows carry
  one action ("Unstage") and unstaged two ("Stage", "Discard"), so the two
  groups reserved different widths for their hover cluster and the letters sat
  ~50px apart. The slot is now sized for the widest case — measured, all five
  letters land on the same pixel.

  "Changes" named the view, the pane, and the unstaged group all at once, so
  one set of files had three names. The group is "Unstaged", which pairs with
  "Staged" the way it always should have.

Commits
  Two empty states used to sit side by side, the second contradicting the
  first: "No commits yet — this branch has no history" beside "Select a commit
  to inspect its message". With no history there is nothing to select, so the
  details pane now stands down.

Branches
  Group counts were pushed to the far right edge of the window — ~1300px from
  the label they count — at 75% alpha, which in light theme was invisible.
  They sit beside their label at full contrast.

Compare
  Empty count badges rendered as grey dots, so the tab strip looked like it
  carried two unread markers.

Code
  The repository name was printed twice within 45px, both with a folder-ish
  icon: once in the top-bar switcher and again as the only breadcrumb at the
  root. The crumb trail appears when there is a trail to draw.

283 tests green; both tsconfigs clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A native checkbox renders as a bright white box — on the dark settings page it
was the highest-contrast element on screen, louder than every heading around
it. It now uses the same border, panel and accent tokens as everything else,
with a real focus ring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tag peek told you to fetch from the remote and then offered no way to do
it: an instruction with no affordance. It has a Fetch button.

Expanding a job log grew the pane to 78vh while it sat ~320px down the page,
pushing its tail — the error line, the toolbar — below the fold, so 'expand'
made the thing you wanted less visible. It now scrolls into view.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Actions
  Opening a log from the row's Logs button left the card's chevron pointing
  right and its steps hidden, so the two ways in produced visibly different
  cards. The dispatch menu truncated every workflow to ".github/workflows/…",
  cutting the file name — the only part that tells them apart; it shows the
  file name.

Explore
  The repository page had no title: the repo was named only in 13px of toolbar
  breadcrumb. The ref switcher said "default branch" — the KIND of thing
  selected — while the rail said the default is "main"; it now names the
  branch. Star and fork counts print with separators, matching the footer on
  the same screen. The header subtitle and the start state said the same thing
  twice in different words; the start state now says what to do.

Issues and Gists
  A not-planned row stated the same fact twice, two glyphs apart — a
  slash-circle icon and a "Not planned" pill. The icon carries the words in
  its tooltip. Gists' badge counted the unfiltered set, so it read "2" above
  "No matching gists".

Compare
  The swap control was a bare glyph with no border or fill, wedged between two
  bordered pickers — it read as a separator. It is a real button, and uses a
  two-way arrow rather than the view's own compare icon.

Settings and dialogs
  The SSH card said "Public keys found in ~/.ssh" and then, on the next line,
  "No SSH keys found in ~/.ssh". The clone dialog's GitHub tab said the folder
  name is "Derived from the URL" — a field that tab does not have. The command
  palette's hint column restated the group header it sat under.

283 tests green; both tsconfigs clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A re-inspection of the fixed build confirmed 17 defects gone and found that
four of my own fixes were incomplete or had introduced new problems.

Changes composer
  The branch label showed "…" and never resolved, and the button stayed
  "Commit" instead of "Commit to main" — the placeholder that replaced the
  false "detached HEAD" was never filled in, which is worse than the wrong
  answer it replaced. The composer now reads the awaited `head:get` (what the
  top bar uses) and repaints when it lands.

Facet menus
  Only the selected option carried a check glyph and nothing reserved the
  gutter for the rest, so the label column jumped 25px depending on what was
  selected. Every row now occupies the icon slot.

Actions header at narrow widths
  Letting the tools row wrap fixed the clipping but not the collision: below
  ~1200px the search field ran into the first facet pill and lost its border,
  and the facet GROUP — one atomic flex item — still ran off the right edge.
  The search field has a floor, the header stacks below 1280px, and the facets
  wrap individually. Verified at 1150px: nothing overflows.

Log pane expand
  Resizing changes the pane's scroll height, which the scroll listener read as
  "the user scrolled away from the bottom" — so expanding silently turned
  follow-tail off and dumped you into the middle of the log. Resizing is not
  scrolling. The expanded height is also capped to the viewport so the pane's
  toolbar and "Jump to latest" pill stay reachable.

283 tests green; both tsconfigs clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e than one question

The harness could only show that a surface renders. It could not show that the
count badge tracks the filter, that a disabled button is disabled, that a menu
is dismissed on navigation, or that two columns share an x — and those are the
things that kept regressing.

harness/check.mjs drives a scene in headless Chrome, runs an assertion from
harness/checks.js INSIDE the page after the driver finishes, and reports
pass/fail with a non-zero exit code. 36 cases cover the behaviour this polish
pass changed: overlay dismissal, palette selection, facet labels and their
alignment, the commit button's enabled state, Compare's default refs, the
Changes status column, log rendering and follow-tail, run identity, PR state,
Explore search, org card clipping, toolbar overflow at 1150px, and the
Settings list's per-row action rules.

Writing them found three defects in my own checks (wrong selectors, and a
driver that could not type into a textarea) before they could vouch for
anything — which is the point.

Fixtures: every run returned the same two jobs and every job the same failing
log, so the success path, the failure path and a multi-job matrix were all
unreviewable — the fixture answered every question the same way. Runs now
carry job sets that match their conclusion, and a green job's log ends green.

Step duration bars are normalised across the whole RUN rather than per job.
Per-job scaling drew an 11s step and a 7m48s step at the same length in
adjacent cards, which makes a bar that exists to be compared actively
misleading. Asserted: bar width is monotonic in duration across every card.

36/36 functional checks pass; 283 unit tests green; both tsconfigs clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Row meta
  The cluster packs right-to-left, so any slot whose width depends on its
  content — one avatar vs three, "+12 −4" vs "+1240 −180" — dragged every slot
  left of it out of column, and a row missing a datum slid its avatars into the
  place where the next row shows its comments. Optional slots are now reserved
  (blanked, not omitted) and each kind reserves the same width on every row.
  Asserted: author avatars share one x and times share one right edge, across
  Issues and PRs.

Command palette
  The highlighted row IS the button Enter will press, and it measured 1.14:1
  against the panel — a wash, not a state. It has an accent bar and a real
  fill, and the check computes the contrast rather than trusting the eye.

Dialogs
  In the state you first see, the primary action is usually disabled, and a
  washed-out purple beside a solid Cancel made Cancel the loudest thing in the
  dialog. Cancel recedes instead.

Peek cards
  The header packed three buttons and a close X into one row, leaving the
  identity — the reason the card is open — about a third of the width.

283 unit tests; 40 functional checks; both tsconfigs clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Workflows tab
  Each row was a name at the far left and a path at the far right with ~1000px
  of nothing between, and carried no state at all — you could not tell from the
  list whether a workflow had ever run. Rows now show the last run's status,
  number and age, and the file name sits with the workflow name instead of
  stranded at the opposite edge.

  The first version of this claimed "never run" on every row, because the
  Workflows tab never loaded runs — asserting something false rather than
  admitting we had not looked. It now loads them (sharing the Runs tab's cache
  key, so switching tabs is free) and only says "never run" once that is
  actually known. The check asserts both halves.

Settings
  Every block inside a card was separated by the same gap with a few ad-hoc
  margins on top, so hierarchy read flat. Headings get air; their explanatory
  line stays tight to them.

Housekeeping
  Removed CSS orphaned by this pass (the inbox toggle became a segment; run
  rows dropped the duplicated status word), and dropped two rules I had just
  written for classes the renderer never emits.

Fixtures: runs now carry the workflowId of the workflow they belong to, so the
last-run column is exercisable instead of uniformly empty.

283 unit tests; 41 functional checks; both tsconfigs clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Explore people/org results
  A 40px row with a small avatar in a 1350px pane read as ~93% empty. My first
  attempt added a sub-line — which said "Person on GitHub" identically on every
  row, i.e. filler, the exact fault this pass has been removing. Reverted: the
  answer to a sparse row is a DENSER row. The search API gives a login, an
  avatar and a type, so the row is one tight line with a larger avatar.

Projects
  The board's only action was a bare unlabelled external-link glyph sitting
  alone under the title. Labelled "GitHub", like the Organizations header.

Gists
  The visibility pill was placed before the H1, pushing the title ~110px right
  of the left rule every other heading starts on. It follows the title now.

283 unit tests; 40 functional checks; both tsconfigs clean.

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

CSS failure I just caused

While de-emphasising one button I replaced a rule's body and left
`.dc-createpr {` unclosed, which swallowed every rule after it and collapsed
the section lists into inline fragments two per line. The functional harness
caught it on a view I was not looking at; a screenshot of the view I WAS
looking at would have shipped it.

So the stylesheet now has structural guards (test/stylesheet.test.ts), and
they carry negative tests, because a guard that cannot fail proves nothing:
  • no comment closes early on a star-slash inside a selector glob — the bug
    that silently killed --sp-1, .sec-list padding and .gh-head-tools margin
  • braces balance — an unclosed rule swallows everything after it
  • no selector sits directly inside a rule block (at-rules excepted)
Writing them turned up a fourth instance of the first bug: in the test file's
own doc comment.

Changes
  Selecting a file no longer reflows the toolbar — "Stage lines" and the
  whitespace toggle are disabled rather than hidden, so the button you were
  aiming at stops sliding ~160px out from under the cursor.
  Staged rows carry an invisible twisty cell, so the list has one left edge
  instead of two ~29px apart. Asserted.
  "Create pull request" is no longer the only accent-coloured control on a
  screen whose job is staging and committing.
  Stash is labelled: it moves your working tree, and its only affordance was
  an unlabelled archive glyph between two text buttons.

Actions
  Run rows adopt the same column contract as the other lists: actor, branch,
  event and duration each keep their slot, so a run missing one does not slide
  the rest out of line.

288 unit tests; 42 functional checks; both tsconfigs clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… actions and header anchoring

Branches drew ahead/behind three ways and styled a live network action as a
passive count: the behind badge WAS the pull button. Ahead and behind are now
a matched pair beside the branch name, and Pull joins Checkout and Delete in
the row's action cluster. The branch peek's chips now say the same thing the
list does.

Organization members rendered the three-person ORGANIZATION glyph when they
had no avatar, so a person read as the org itself; they use the person avatar
(initials + per-login hue) like every other list. The card grid held three
densities in one track size — a lone team card sat in a 330px column beside
1200px of nothing, a member card was 90% empty at the same width. Repos and
teams use auto-fit so a short list reads as full-width rows; members are
fixed-width chips that wrap from the left.

The bottom dock's Output tab carried a 32px toolbar Terminal did not, so the
content origin jumped as you switched tabs — and on an empty log that bar
offered 0
…r where you asked, one clone form, and two views the harness could never see

Six lists printed their meta as "whatever this row happens to have", packed
right-to-left. A gist without comments slid "1 file" 69px; an unpublished
release shoved its tag and author 90px past every other row's; a read inbox
thread landed 79px right of an unread one because the hover cluster is one
button narrower; an Actions run with a long branch name pushed its actor
avatar out of line. Every optional datum now holds its column and goes
invisible rather than absent, and each column has a floor sized to its widest
real value — the Inbox in particular went from three ragged edges to three
clean ones.

The functional check that guards this was rewritten to measure what actually
matters: for every KIND of meta a list carries, one column. It works on any
list instead of assuming issue-shaped rows, keys the Nth of a kind separately
(a row can hold an author stack AND an assignee stack), and now runs against
six lists. It found every misalignment above.

"No results" was centred: ~290px below and ~600px right of the search box you
were still looking at. emptyState grew an `anchor` — hero for "there is
nothing here at all", inline for "your query matched nothing", which anchors
top-left beside the control that emptied the list. Applied to Explore, the
four GitHub lists (filtered only) and the org sub-tabs.

The clone dialog's three consecutive fields had three structures: a bare
placeholder-only input, a label-value-button row in a bordered card, and a
caption above an input inside a SECOND bordered card. One `cloneField` now
builds all of them — caption above control, one left edge, real <label>s.

Explore's People results were 40px rows holding one login across a 1350px
pane; they are chips that wrap from the left, like the org Members tab. A code
hit rendered one bordered box PER MATCHED LINE — stitched by :has() rules that
could not close the row body's own gap — so one hit read as three; it is one
block with a diff-style separator between non-adjacent fragments.

Rebase and Compare had no fixtures, so every screenshot of them was their
ERROR state. With fixtures: the rebase anchor row now stands in the action
column so every subject shares a left edge, its legend stopped wrapping two of
six glosses, and Compare's file list adopted the Changes name-then-directory
row (a 370px column was truncating away the filename) and says "2 commits only
on <base>" instead of making you work out whose commits those were.

Also: empty project-board columns yield their width (a lone "No status · 0"
held a quarter of the board), and sentence case reaches the last Title Case
labels — Create branch here…, New issue, Start rebase, Open repository…, and
Clone repository… on the welcome card, which the palette had already called
that.

288 tests, 70 functional checks, both tsconfigs clean.
…the two fixtures that were hiding it

The commit graph's CHANGES cells left-aligned their file count, so 3, 11 and
17 stepped every proportion bar right by a character. A column of meters you
cannot line up is not a column: the count now sits in a fixed right-aligned
slot and every bar starts on one x. This is in the shared webview package, so
the VS Code graph gets it too.

The reason nobody had seen the column at all is that the harness answered
`commit:rowStats` with nothing, so five rows of empty cells sat under a
labelled header and read as an app bug. Two fixtures were also parked in the
wrong table — the shim keeps plain values in `fixtures` and callables in
`dynamic`, and a function left in `fixtures` is handed back AS a function, so
the caller silently gets nothing.

Also extends the meta-column check to My Work, and adds one for the graph's
own columns — it reads through the Lit element's shadow root, which nothing in
the suite had needed before.

288 tests, 72 functional checks, three tsconfigs clean.
…l rhythm

The Settings column was pinned to the 820px reading measure, which left ~255px
of empty gutter on each side at 1600px while squeezing the rows that are not
prose at all — a clone row carries a name, an owner, a badge, a path and an
action cluster. The column moves to its own token (1040px) and the prose
inside keeps a reading measure of its own, so a paragraph never runs 130
characters just because the form got wider.

Inside a card every child was 12px from the next, which meant "App icon" sat
exactly as far from the sentence explaining it as that sentence sat from the
control above it. There were no groups, only a list. A label now sits tight to
what it introduces and pushes off from what precedes it.

The check asserts the relationship rather than the numbers: for every field
label, the gap above must exceed the gap below.

288 tests, 73 functional checks.
… filter that shows its states, a palette hint column worth reading, and a peek that is about its subject

Collapsed to icons, the rail hid the group LABEL and the group RULE, so
fifteen destinations became one undifferentiated column separated by nothing
but a slightly bigger gap. The label goes; the rule stays and carries the
group's name in its tooltip.

The Actions Status filter listed its five states as plain text — in a product
that gives those exact five a colour and a glyph on every run row, every job
card and every step. The menu now uses the same lead icon the rows use, which
also meant fixing the generic `.dropdown-item .glyph` muted rule that was
repainting them all grey.

The command palette's hint column restated its own group header: "view" six
times under GO TO, "branch" three times under BRANCHES & TAGS, "tag" beside a
tag glyph. Only hints that say something new survive — "current", "#104". And
the list fades its bottom edge instead of slicing the last row exactly in
half, with the fade off when the list fits.

A peek's header gave three buttons and a close X ~540px of a 700px card and
left the identity — the thing the card is about — around 230. The actions wrap
to their own line, the name keeps a floor, and a member peek shows that
person's avatar rather than a generic account glyph.

Settings' About and SSH cards stacked two action languages (a bordered button
above a bare purple text link); both are buttons now, side by side, and the
version stopped rendering as code. A gist's detail page dropped the "updated
2d ago" line that sat directly above an About rail saying "Updated · 2d ago".

288 tests, 77 functional checks. Every finding in the 100-item sweep is now
either fixed or recorded as a false positive.
…he shared checks

shot.sh shows what a surface looks like and check.mjs asserts a named
invariant, but neither lets you simply ASK a rendered scene a question — how
wide is that column, does that button have an accessible name, what does the
focus ring compute to in light theme. Answering one meant first adding a case
to harness/checks.js, which serialises investigation behind a single shared
file.

probe.mjs runs a JS body inside the driven page and prints what it returns,
with a small vocabulary ($, $$, box, css, text, settle) so every caller
measures things the same way. It works on surfaces no check has ever touched —
a PR detail page answers its tabs and crumb on the first try.

Rebased onto main (prune-on-fetch + 1.12.0 / 1.6.0). The Settings conflict was
two additive sibling cards; both are kept and both are wired. 288 tests, 77
checks green on the new base.
Three bugs in the stale-while-revalidate cache that every list in the app reads
through. None of them look like anything on screen — the list just quietly
stops updating, or shows an error it will never recover from — which is why
they survived a hundred-finding visual sweep.

`gget` short-circuits on an entry's in-flight marker BEFORE it looks at the TTL.
That is correct for deduping concurrent readers, but the marker was only ever
cleared inside an `if (!superseded())`, so any request that settled after the
cache had been invalidated left its marker in place forever:

  - Staging a file fires bust("status") and bust("diff"). Both bump the global
    epoch. If a GitHub list happened to be loading at that moment, its entry
    kept a settled promise, and every later read returned that same answer for
    the rest of the session. Refresh did nothing.
  - If that request had FAILED instead, the entry served the rejection forever:
    a list stuck on an error with no way back short of switching repos.

Clearing the marker and publishing the value are two different decisions, so
they are now two different things: always retire the marker, publish only when
nothing invalidated the cache meanwhile. A failed read leaves the last-known-good
readable via `peek` but keeps its ORIGINAL timestamp, so the next `gget` still
refetches.

The third bug fell out of the same rewrite: settling now checks that the entry
it is about to touch is still OURS. A value seeded by `prime()` from a push
event — newer than a read that started earlier — used to be clobbered by that
older read's answer, and a request that outlived a bust could overwrite the
newer request that replaced it.

Twelve tests drive the cache through a stubbed host with hand-settled promises,
because every one of these only happens in the window between a request
starting and finishing. Three of them failed before this change.
…po that is actually open

`removeRecent` already knew that raw string equality is the wrong way to
compare repo roots — it resolves both sides, with a comment explaining that a
recent stored through a symlink would otherwise never match and "Forget" would
silently do nothing. `promoteRecent` compared raw strings anyway, and the
constructor trusted whatever the persisted JSON held.

So the same repo could occupy two slots in a twelve-slot list: the file said
"/x/repo/", discovery handed back "/x/repo", and opening it added a second
entry instead of moving the first. That list is the app's memory of where your
work is; two of the same thing pushes a real repo off the end.

The comparison is now one exported function, used on the way in, on the way out
and when persisting, and the list math is pure so the ordering rules are
testable without a git repo on disk.

Separately: when a second `open()` overtakes the first, the loser correctly
touches no shared state — but it still returned the root IT discovered. Its
caller was told "you opened A" while the active context was B, so the window
would render A's identity against B's repo. It now reports whatever is actually
open, falling back to its own root only when nothing is (so a race on first
launch cannot raise a false "not a Git repository").

Also: the Open Recent submenu's empty label was the last Title Case string in
the app.
…g unparseable tags through verbatim

sanitizeHtml is the one security boundary in the renderer. Every issue body, PR
description, review comment, release note, gist and README the app shows is
attacker-controlled text from GitHub.

The allowlist pass rewrites each tag it matches, but its pattern needs a `>`
after a balanced run of attributes. A tag with an UNTERMINATED attribute quote
— `<img src="x` — therefore does not match at all, and unmatched text was
passed through VERBATIM: the one tag that most needed filtering was the one tag
that skipped it.

That is not merely untidy. The browser runs the open quote on to the next `"`
anywhere in the document, and the sanitizer supplies one itself from a later
tag's `title="…"`. Everything after that quote is then parsed as attributes of
the unfiltered tag. A body containing

    <img src="x

    some ordinary text

    <b title="onerror=alert(document.domain) x">bold</b>

produced an <img> carrying a live onerror attribute. Verified in headless
Chrome rather than by reading the regex: the element came back with
["src","onerror",…] and the handler fired.

SEVERITY, stated accurately. In the shipping app that handler does NOT run: the
renderer's CSP is `script-src 'self' blob:` with no 'unsafe-inline', which
blocks inline event handlers, and the window is contextIsolation:true,
nodeIntegration:false, sandbox:true. I confirmed this too — the same payload
under the app's real CSP reports pwned:false, with the onerror attribute
present but inert. So this was defence-in-depth doing its job, not a live RCE.

It is still worth fixing properly, because the sanitizer was failing OPEN. It
put attacker-chosen attributes onto elements it never inspected, and the only
thing standing between that and script execution was one meta tag.

The fix is to stop trusting anything the pattern could not parse. Allowlisted
tags are parked behind a sentinel, every remaining `<` is escaped, then the
parked tags are restored. A `<` that the sanitizer did not itself produce can no
longer reach the DOM — which also subsumes the old lone-`<` rule, so `a < b`
still reads as prose. Well-formed but disallowed tags are still dropped rather
than escaped, so an <iframe> does not become visible source.

The regression test asserts the PROPERTY rather than a list of payloads: every
`<` in the output must begin a tag the sanitizer produced. That is what makes
the class impossible instead of one instance of it. Sentinel forgery is covered
too — a body carrying U+E001 cannot name a parked slot.

328 tests.
This function's own contract says it removes absolute paths "INCLUDING the
file/project names in the tail", and it calls itself the last line of defense
before anything leaves a user's machine. It was not doing that in the two
places real users actually live.

**Windows, for the user's own machine.** `safeHome()` collapses the home
directory to `~` before the path pass runs, and on Windows what follows a home
directory is a BACKSLASH. The tail rule only accepted a forward slash, so it
matched the `~` and stopped. Every crash report from a Windows user shipped
their whole project path:

    at load (~\Projects\acme-secret\src\billing.ts:42:11)

**Any path with a space in it.** Every path pattern used `[^\s"':]+`, so
`C:\Users\John Smith\projects\acme-secret\index.ts` redacted as far as the
space and left `<path> Smith\projects\acme-secret\index.ts`. Same for
`/Users/John Smith/…` and for UNC shares, where spaces are the norm
(`\\CORP-FS01\Team Share\…`). A trailing run is now redacted too — but only
when it still contains a separator, so a real sentence ("/Users/bob is not a
repository") keeps its words instead of collapsing to a marker.

**IPv6 was never handled** despite "IPs" in the contract. It is now, in the
full eight-group form and the compressed `::` form.

The IPv6 rule is deliberately narrow. The obvious pattern — two or more
colon-separated hex groups — redacts every `01:23:45` timestamp in a log and,
much worse, the `:42:5` line and column this function goes out of its way to
preserve so a crash stays locatable. I wrote that version first and it silently
turned `billing.ts:42:11` into `billing.ts:42<ip>`. The compressed rule now
requires a literal `::` ahead of it and refuses to start after a word
character.

Also: env-var-rooted Windows paths (`%USERPROFILE%\Documents\Acme`) redact
everything after the variable, which identifies nobody by itself.

Six regression tests, written as "this string must not appear in the output"
rather than as expected-output comparisons, so they keep meaning if the exact
markers change. 23 tests in the package.
… a template

Both callers pass literal labels today, so this is not a live hole. But the
line above it already assigns remote error text, and a template that
interpolates a label straight into innerHTML becomes an injection the moment
someone passes a branch name or an error string through it.
…operty, not a habit

Every mutation on the git bridge takes strings straight from the renderer and
hands them to git, which reads any argument beginning with "-" as an OPTION.
`safeArg` exists for exactly this and is applied in two dozen places — but
"applied in two dozen places" is a habit, and a habit is what the next handler
skips.

So the rule is now checked. A structural test reads gitBridge.ts, finds every
method returning a CommitActionResult, and requires each to either guard its
arguments or appear in a reviewed list WITH the reason it does not need to
("path goes after `--`", "message is the value of -m", "no arguments"). A third
test keeps that list honest in the other direction: an entry that has since
grown a guard, or that no longer exists, is a comment claiming something
untrue.

Auditing the 36 mutations to write the list turned up one real gap.
`WorktreeProvider.remove` builds `["worktree", "remove", path]` with no `--`,
so a worktree path beginning with "-" reaches git as a flag. The paths come
from git's own worktree listing today rather than from free text, so this is
hardening rather than a live hole — but it was the one mutation on the bridge
without the guard all its neighbours have, which is precisely the shape this
test exists to notice.

Parsing note: the first version matched signatures with one regex and a lazy
span between the method name and its return type. That span happily runs
THROUGH the following method to find a matching return type, so it reported
`stashList` and `opState` as mutations and lost `stage` entirely. It counts
parens and braces now.

332 tests.
Every git command the open repo runs is streamed to the renderer's Output tab
as `git ${args.join(" ")}`, verbatim. Two of those commands take a URL as a
positional argument — `git remote add <name> <url>` and `git remote set-url` —
so a remote with credentials in it puts the token on screen:

    git remote add origin https://oauth2:ghp_…@github.com/Acme/repo.git

That log is a surface we actively invite people to read: it has its own tab, a
copy button, and its whole purpose is to be pasted into a bug report. git's
stderr, forwarded on the same channel, echoes remote URLs on auth failures too.

`scrub()` already exists but is the wrong tool — it is built for crash reports
and removes hosts, paths and repo names, which would leave the log saying
nothing. So this adds a narrow sibling, `redactCredentials`, that takes out
only the secret: the password half of a URL's userinfo, and any bare GitHub
token. The user half stays, because it is usually "oauth2" or
"x-access-token" and knowing which is half the reason to read the log.

Applied to args, to the rendered command string, and to stderr. Four tests,
including one asserting that ordinary commands come through byte-identical —
a redactor that quietly rewrites `git status` is worse than none.
… pane

Every tool that draws a progress bar — npm, pip, docker, gradle, cargo —
rewrites ONE logical line in place with carriage returns and terminates it with
a single newline. The log parser kept that text verbatim, and a `\r` paints as
nothing in HTML, so the pane rendered

    Downloading  0%Downloading 25%Downloading 60%Downloading 100%

as a single run-on line. Any real `npm ci` or `docker pull` step looked like
garbage, which is most of what people open a job log to read.

Carriage returns are now applied the way a terminal applies them: return to
column 0, and what follows overwrites what was there. That is a genuine
overwrite rather than "keep the last segment", so a short redraw over a longer
line leaves the longer line's tail behind exactly as a terminal does — "abcdef"
then "\rxy" is "xycdef". It also drops the stray trailing `\r` that a CRLF log
was leaving on every single line, which nobody could see but which came along
whenever you copied the log.

Order matters and cost a first attempt: the overwrite has to run AFTER the
timestamp is stripped. GitHub stamps once per newline, so the stamp sits before
the first segment, and applying the overwrite to the whole raw line lets a
later redraw paint over the timestamp. There is a test for exactly that, and
one for a `##[error]` directive still classifying when it arrives after a
redraw.

338 tests.
…caped its href

Every panel in this extension builds HTML by concatenating strings, and each
carries its own three-line `esc()` because most of them live inside a webview
script that cannot import anything. Five copies of the same function is a shape
where one quietly falls out of step, and one had: the AI results panel escaped
`&`, `<` and `>` but not `"`, while its four siblings all escape it.

That matters because `esc()` runs over the WHOLE markdown source before any
HTML is built, and the link rule then writes the URL straight into an
attribute:

    [t](https://a"onmouseover="alert(1))
    -> <a href="https://a"onmouseover="alert(1" target="_blank" …>

which is a live event handler on the anchor. `safeUrl` did not catch it either:
it tests what a URL STARTS with, not what it contains.

Severity, stated accurately: the webview CSP is `script-src 'nonce-…'` with no
'unsafe-inline', so that handler would not have run. Same story as the desktop
sanitizer fixed earlier today — the escaper failed open and the CSP was what
stood behind it. The input is model output rather than a raw issue body, which
lowers it further, though model output does quote repository content.

Both layers are fixed: `esc()` now escapes `"` and `'` like its siblings, and
`safeUrl` rejects a URL containing a quote or an angle bracket rather than only
checking its scheme.

And the rule is now checked rather than remembered. A new test walks every
`function esc(` in the extension and requires it to cover `& < > "`, and to
replace `&` first (or it double-encodes its own output). Removing the quote
again fails it, by name and line — I confirmed that before keeping it.

116 tests in the extension.
Two defects in the commit composer, both from the same root: amend state was
written in more than one place and read in fewer.

**Ticking Amend left both commit buttons dead.** The prefill assigns
`textarea.value` directly, and a programmatic write fires no `input` event —
which is what `syncCommitEnabled` and the surviving draft both hang off. So the
previous commit's message appeared in the box, the button relabelled to "Amend
commit", and it stayed greyed out insisting you "write a commit message first"
while it sat in front of you. Commit & Push was dead the same way, so there was
no working alternative. There is now one way to put text in the composer, and
it goes through the same path a keystroke does.

**Repainting the list made the label lie.** Staging a file re-runs
showChangesView(), and the label was rewritten unconditionally afterwards
without consulting `amend`. The toggle stayed lit, the message was wiped, and
the button went back to reading "Commit to main" — while a click still sent
`amend: true`. You would type a fresh message for what you read as a new commit
and rewrite the previous one instead. The label now comes from one function
that both writers call, and the prefilled message survives the repaint because
it reaches `composerDraft` like any typed text.

Also in `streamInto`: the closing `.trim()` is a programmatic write too. If a
model returned only whitespace it emptied the box without telling anyone, and
the commit buttons stayed enabled over an empty message.

Found by a 270-agent adversarial sweep of the surfaces no functional check
covered — 84 findings confirmed of 128, after two independent verifiers had to
agree on each. These two were the only ones that could destroy work.

Three new checks hold the line: the prefill enables BOTH buttons, the label
still says "Amend commit" after a repaint (a label that disagrees with the flag
is the whole bug), and — from the same sweep — no element anywhere in the app
carries an inline event handler, plus a route-churn check that a view rendered
for the third time weighs exactly what it did the first.

81 functional checks, 338 tests.
The same work-loss bug fixed for rebase hours ago, introduced one
operation over by the very change that fixed it — `sideLabels` lumped
`am` in with `rebase`.

They are not alike. A rebase checks the upstream out first and replays
your commits onto it, so stage 2 ("ours") is the upstream. `git am` does
nothing of the kind: it applies a mailbox patch onto the branch you are
standing on, so stage 2 is YOUR branch and stage 3 is the patch.
Verified against real git, in the test.

So during a patch application the button reading "Take Upstream (what
you're rebasing onto)" handed you your own branch, and "Take Your commit
(being replayed)" handed you someone else's mailed patch — with the
tooltip and the success toast agreeing with the label rather than with
what happened. Whichever side you took, you were told you had taken the
other one.

`opState` never confused the two and does not need changing: a rebase on
the apply backend uses the same `rebase-apply/` directory but writes
`rebasing` rather than `applying`, and is reported as "rebase". Only the
labels were wrong.

Two tests, one of them driving a real conflicting `git am` to prove the
stage assignment rather than assuming it. Both fail on the old branch.
The existing case asserted that sideLabels(cherry-pick) equals
sideLabels(merge) — which is a statement about the function agreeing
with itself and proves nothing about git. That is precisely how the am
labels came to be wrong: grouped by assumption, never checked.

All four operations that can leave a conflict now have their stage
assignment proven by a real conflicting operation: merge, rebase, am,
cherry-pick and revert.
The checks written this morning against the new push-event registry
invented their payloads. `git:log` was sent as `{at, args, code, ms,
action}`; the real `GitLogEntry` is `{id, args, command, durationMs,
exitCode, failed, action, actionId, at}` — almost none of those field
names. `terminal:exit` omitted `exitCode` entirely.

Both checks passed anyway, which is the point. A fixture can be wrong
about reality and everything built on it still goes green: the Output
check counted four rows for four entries and called that "the commands
are logged", and that reading was an artifact of the wrong shape.

With the real one it is four rows for one, because four identical
commands under a single `actionId` COALESCE into one row with a ×4
badge — which is what stops the status poller flooding the pane, and is
better behaviour than the check was asserting. It now pins that, plus
the other half: distinct commands each keep their own row.

Verified the shim's `on()` against the real preload while here — same
contract, listener receives the payload and gets an unsubscribe back.
The agent emits tool_denied and then a tool_result carrying the sentence
it feeds back to the MODEL — "Do not retry it; adapt or stop and
explain" — which was rendered as a red error step, addressed to the
person who had just made the decision.

The check drives both halves at once: one tool denied, one tool that
genuinely fails. The declined step must be marked declined and not an
error, must say so in a word the reader owns, and must not put the
model-facing instruction on screen; the failure must still read as one.
Two assertions fail on the old code.
…keeps it

`is-danger` was styled for `.dropdown-item` only. The merge bar's
"Delete the file" — offered for the side of a modify/delete conflict
that has no version of it — carried the class and got nothing from it,
so the two buttons on that bar were pixel-identical: one keeps your
file, one removes it and stages the deletion.

Added the treatment for `.mini-btn.is-danger` too, and the state-table
check now asserts the two buttons do not render the same colour. It
fails on the missing rule.

A destructive control that looks like its opposite is worse than an
unlabelled one — this was found by asking what a class I had just added
actually did, rather than assuming it did something.
`routeView` disposes exactly one thing — `activeMonacoView` — and a view
that does not register there leaks whatever it built. The commit page
built a DiffPanel and registered nothing, so every commit you read left
an editor behind with its two models and their tokenizers, for the life
of the window.

That is the third leak of this shape (the PR diff panel, the job log's
pane and its 200,000-line document, now this), and the third hand-rolled
MutationObserver written to fix one. They share `disposeOnDetach` in
views/common.ts now; the other two are migrated onto it.

No check guards it, deliberately. The leak is in the editor OBJECTS, and
`monaco` is not exposed to the page, so a probe can only count
`.monaco-editor` DOM nodes — which go away when the host's children are
replaced whether anything was disposed or not. Measured: it reports
success on the broken build, which is worse than not checking at all.
The reason is recorded at the call site.
From sweep 6's state table over the Assistant, which enumerates the gate
against the turn state — the two axes these all sit on.

- a ✨ action fired while the Assistant was already on screen routed to
  it with `force: true`, which drops the view from the cache and rebuilds
  it. So asking for a second explanation while the agent was answering
  the first destroyed the transcript and the Stop button and orphaned the
  run, with no confirm and no cancel. Exactly the defect the refreshAll
  exemption fixes, reached through a different door. A live Assistant now
  takes the goal directly, and says so if it is busy rather than
  discarding the work.

- the four quick-action chips stayed enabled during a run, where
  `runGoal`'s `if (running) return` swallowed the click.

- both "New chat" entry points — the header + and the history menu —
  stayed enabled while gated, where their own `if (gated) return`
  swallowed it.

Each guard was correct and none of them was visible. A control that
cannot act now says so, through one rule that owns the composer, the
chips and the chat buttons together, rather than three places setting
`disabled` and drifting apart. Both new states are pinned; each fails on
the old code.
`is-streaming` draws a blinking caret after the last line of a partial
answer. The throw path in runAgentTurn removed the thinking indicator
and appended the error, but never settled the stream — so a turn that
died mid-sentence left its half-written reply apparently still arriving,
for as long as the chat stayed open, with a failure message underneath
it. The other failure path, `!done.ok`, already goes through
finalizeStream.

Getting the check to fail on the old code took three attempts, each
instructive: an `assistant` event settles the block by itself, so
driving it with one proves nothing; deltas do create the streaming block
synchronously, but only while the turn is still live, so ai:chatSend has
to be held open and rejected AFTER them.
- the Assistant's gate could NEVER lift. The listener that re-runs it
  was guarded on `wrap.isConnected` — added 40 minutes ago to stop it
  leaking — and connecting a model means going to Settings, which PARKS
  this view: detached, alive, about to be shown again. So the guard
  fired on the one path that matters and unsubscribed, undoing the fix
  it was added to protect. The leak is answered by identity instead:
  only the newest build acts, older listeners drop out with their
  closures.

- opening any file rewrote the whitespace toggle's tooltip to the "turn
  it on" text, whatever the toggle was set to. `syncWs` owns that title;
  the line-control gating added an hour ago overwrote it. That is the
  "titles never change with their state" defect the log toolbar was
  fixed for, reintroduced one toolbar over.

- a deleted EMPTY tracked file was reported as "staged as a new file but
  is no longer on disk". Two things arrive with both sides empty and the
  path gone: git's `AD`, and a file that was empty in HEAD and has now
  been deleted. Only the first is new; saying it about the second tells
  someone their committed file was never committed. `onlySide` tells
  them apart.

- a turn whose whole answer arrives at the END — no streaming, which is
  every non-streaming provider — landed below the fold, because the
  end-of-turn settle measured "is the reader at the bottom" AFTER
  appending the block that had just pushed them away from it. The same
  before/after mistake the streaming path was fixed for.

- returning to a parked view restored a pixel offset. For the
  Assistant's transcript, which keeps taking a live turn's output behind
  your back, that pinned you to a fixed point while the answer wrote
  past you. The snapshot records whether the offset WAS the tail, which
  is a different intention from "this many pixels down".

531 tests; every Assistant and diff check green.
- the forward-truncate ran BEFORE the "is this the same place" check, so
  every route that reached it discarded the forward entries — including
  the one `refreshAll` performs, which the file watcher fires on every
  save, a window focus fires whenever the fingerprint moved, and every
  git action fires too. Going Back and then saving a file killed
  Forward, constantly, for no visible reason. The truncate belongs with
  the push it accompanies: re-routing to where you already stand is not
  navigating.

- the Assistant's transcript had no tabindex, so it could not take focus
  and therefore could not be scrolled by PageUp, Home or the arrows at
  all. It is the longest-lived scroller in the app — a chat you have
  been working in all day — and only a pointer could move it.

- closing the dock while the keyboard was in the terminal left focus on
  <body>, so the next Tab started from the top of the window and no
  shortcut bound to a view could fire. The restore only covered the case
  where something outside had been remembered, and the dock is usually
  opened from inside itself. The view host takes `tabIndex = -1` and the
  keyboard lands there — Tab then continues from the view rather than
  from nowhere.

Three checks, each confirmed to fail on the old behaviour.
that keeps nothing

Two from sweep 6, on the surface the owner has reported before.

- the drop indicator was a fixed line UNDER the hovered row and the
  insert was always `move(from, i)`. Those two agree only when you drag
  DOWNWARD: dragging up, `splice(i, 0, …)` puts the commit ABOVE the row
  while the line underneath it promised below. So every upward drag
  landed one row away from where the app said it would — on the view
  whose entire job is to say where commits will land. It also made
  position 0 unreachable with a pointer, there being no row to draw a
  line above.

  The line now follows the pointer's half of the row and the insert
  follows the line. Pinned by a four-cell table: up/down × top/bottom
  half, plus the first position specifically.

- a plan that drops every commit passed validation, which only looked
  for orphaned folds. Pressing Start then erased the whole range and
  offered to force-push it — `git reset --hard` wearing a rebase's
  clothes, with the preview reading "5 → 0 commits" beside a lit button,
  and a force-push confirm downstream that talks about rewriting history
  rather than deleting all of it. Start is closed on that plan, and says
  which tool actually means it.
- "Create pull request" was hidden by two independent conditions writing
  to the same flag. The swr answer ("GitHub could take a PR") went
  straight to `prBtn.hidden`, and the base===head path then hid the
  button on its own — but nothing ever un-hid it: the swr callback had
  already delivered its cached answer and never fires again, and the
  success path never touched the button. So picking your own current
  branch as the base ONCE removed the view's whole purpose for the rest
  of the session. The two conditions are kept apart and re-asserted on
  every exit.

- Swap exchanged `undefined` into the HEAD slot when there was no base,
  which renders a picker with an icon, a chevron and an empty label, and
  then compares against nothing. It needs two sides to exchange, and
  says so when it hasn't got them.

- the summary and both tab badges kept the PREVIOUS comparison's numbers
  while a new one loaded, so "Comparing A … B" sat directly under
  "12 commits · 9 files" describing an entirely different pair of refs.
  `last` was already nulled for exactly this reason; only the body
  honoured it.

- and the base default only ever searched local heads, so a fresh clone
  with one branch got "This repository has only one branch" while the
  picker eight pixels above it listed every remote-tracking branch and
  tag in the repo. It falls through to the upstream now.
- ⌘Enter on a branch row ran the row's verb AND opened the row. The
  shortcut sheet documents "⌘Enter — run the focused row's main action"
  and `wireListNav` implements it, but `promoteToDivRow`'s own keydown
  had no modifier guard, so both fired for one keypress: the branch was
  checked out and the list you were working in disappeared underneath
  the action you had just taken.

- every tick in the one-list staging model was named for its STATE —
  "Not included", "Included in the commit" — so six ticks shared three
  names. That is useless to a screen reader, which hears "not included"
  with no idea what isn't, and it broke the focus rescue outright:
  `sameThing` matches on `title`, so ticking the fourth file moved the
  keyboard to the second. Each tick names its file.

- a restored assistant message rendered at the pane's full 820px while
  the identical message, live, was 760: only `.assistant-turn` carries
  the measure, and `restoreChat` appended the block without one. The
  same text at two widths depending on whether you had left the chat and
  come back.
- the integrated terminal read `background` and `foreground` from the
  live tokens and then hardcoded all sixteen ANSI entries to VS Code's
  DARK palette. In the light theme that paints a dark palette on a white
  ground: brightWhite #ffffff is 1.00:1 — literally invisible — and
  yellow, brightYellow, brightGreen and white are all under 2:1. Any
  tool that colours its output printed whole lines nobody could read.
  The light row is GitHub's, matching what the job log one pane over
  already ships, so both surfaces speak one ANSI vocabulary.

- an ANSI run that sets a BACKGROUND and no foreground — `ESC[41m` on
  its own — got `log-bg-1` and no fg class, so the block was painted
  from the theme-independent true palette while the text fell through to
  the page's ink: near-black on dark red in the light theme. The pairing
  rules added earlier only covered spans that set both. It takes the
  palette's own default foreground now, and the two pale blocks take its
  black instead of white on white.

- the exited-terminal row I added an hour ago receded with a blanket
  `opacity: 0.62`, which multiplies with whatever each child already
  uses: the "exited" badge, already muted, measured 2.41:1 in light and
  the shell's NAME — the only thing saying which one died — sat at
  3.72:1. It recedes by colour instead.

The contrast check took four attempts to make honest, each worth
recording: `css()` is a probe helper and throws in a check;
`getComputedStyle().color` does not include an ancestor's opacity, so it
passed on the broken build; `document.body` paints no background here,
which drove every ratio to 1.00:1; and walking opacities to the root
folds in `.dock-body`, which sits at 0 behind a transition this harness
never completes. It measures between the text and the surface it sits
on, and reproduces the reported figures to two decimal places.
…eakpoint

The graph|details resizer clamps the details column so the graph is
never squeezed past the width where it drops its Date and SHA columns —
"otherwise columns silently vanish and their resize handles go with
them". It restated that width as a literal 800, beside a comment quoting
a third value (760), and the graph package has since raised its
breakpoint to 860. So the resizer permitted a drag 60px past the point
it exists to prevent.

The threshold moves to webview-ui/limits.ts, which is exactly the module
for shared responsiveness thresholds, and both sides read it. A literal
in one package describing another package's behaviour cannot help but go
stale; this one had, twice over.
width scrolled the whole window sideways

Sweep 6's top finding, and it corrects my own work.

`seedAssistantGoal` was given a busy-check so a ✨ action fired during a
run would refuse rather than rebuild the view. It gated on
`live.el.isConnected` — and every ✨ action fires from ANOTHER view,
where a keep-alive Assistant is parked DETACHED. So the branch was
unreachable in every real case and its toast had never once been shown,
while the force-route went on destroying the transcript, orphaning the
run in the main process, and starting a second turn against the same
chat.

The same file carries a comment about exactly this, twenty lines below,
on `onAiChanged`: "NOT gated on `wrap.isConnected` … PARKS this view in
the keep-alive cache — detached, but very much alive." I wrote that an
hour before making the same mistake one function away.

The busy test asks about identity now. The IDLE branch keeps
`isConnected` deliberately — a parked view can be evicted, and running a
goal into an evicted node would lose it — and the route that follows is
never forced, so the same node comes back with its transcript and its
live Stop button. Measured: one turn instead of two, the answer intact.

Also: the PR detail header's action cluster could not shrink below its
labels and had no wrap, so at the window's own 880px minimum it
overflowed the body to 902px and the topbar slid off to reveal it. It
wraps. A new check asserts that no surface scrolls the app sideways at
880, across four views.
None of a branch row's meta slots can give up a pixel — the upstream
name is a fixed 160px, the ahead/behind track 78, the remote 70 — so at
the window's own 880px minimum the row's content sums to about 717px in
a 648px row. `.sec-row` is `overflow: visible` inside a clipping
ancestor, which means the surplus does not scroll: it renders OUTSIDE
the window. Measured: three of five rows overflowing, their actions
ending at x=941, unreachable by pointer and by keyboard.

The upstream NAME drops below 1040px — the biggest slot, the most
droppable, and already in the row's tooltip; the track beside it is the
part you act on. 1040 is the breakpoint the topbar already uses.

The sideways check missed this entirely, which is the more useful half
of the finding: `document.body.scrollWidth` reports a page that fits
while rows render past its edge, because the overflow is clipped by an
ancestor rather than scrolled. It now measures the rows themselves.
- pressing Stop ended the turn in the main process and left the agent's
  "Approve destructive action" dialog on screen. `onConfirm` opened a
  modal and awaited it forever, and nothing in the cancel path closed
  it — so its Approve button then posted an approval for a run that no
  longer existed. Measured: one `ai:agentConfirm` sent for a dead turn.
  `confirmDialog` takes a signal now, and the turn owns one: Stop, an
  outside cancel and the `finally` all fire it, so no approval dialog
  can be left behind by any of the three.

- folding a group in the job log rebuilds the whole window of rows, so
  the row the keypress came from was destroyed by its own handler. Focus
  fell to <body> and the next Enter went nowhere: folding one section of
  a build log by keyboard ended the keyboard's involvement with it. The
  row carries its doc index — `data-doc-idx`, deliberately not
  `data-num`, which focusReturn keys its own identity off and which log
  line numbers would poison — and focus follows the rebuild.

- ⌘Enter did not post a comment on the Issue or PR detail pages, though
  the shortcut sheet says it does. Neither composer wired it, so the one
  keystroke people reach for after typing a comment did nothing at all,
  on both pages.
- the commit page reopened file #1 on every build, and `refreshAll`
  re-routes the current view with its history target on ANY save
  anywhere in the repository. So a build touching one file swapped the
  diff you were reading for the first in the list, silently, while you
  were reading it. `SectionTarget.file` exists for exactly this and the
  Code browser already used it; the page records which file is open and
  restores it, falling back to the first as before.

- the Assistant's model, thinking and access chips are read when a turn
  STARTS and travel with it. Changing one mid-run relabelled the chip
  and left the running turn on the old value, so the chip stated in the
  present tense something the agent working below it was not doing —
  most consequentially for Access, which is the write-permission
  control. They stay usable, because setting up the next message during
  a run is the normal thing to want, and they say when the change
  applies.
- the Rebase footer preview counted ROWS while the confirm dialog beside
  it counted `state.replayCount`. The list is capped, so on a range
  longer than the cap the preview read "50 → 50 commits" for a rebase
  the dialog one line down correctly called 214 — and the smaller,
  safer number was the more prominent of the two. It counts what will be
  replayed, and says how many are not shown.

- a transient rebase banner hid itself after four seconds, and it shares
  the banner with the host's PERSISTENT note — the one saying the base
  fell back or the list was capped. So one refused squash permanently
  destroyed the only statement that the plan was not the whole story.
  Flashes restore the note now, sequenced so an interrupted one cannot
  restore over a newer message.

- the chat restore skipped itself while a turn was running, which was
  the right guard for an ordering problem that no longer exists (runGoal
  awaits the gate). Skipping it was its own bug: a seeded ✨ turn ran
  into a chat whose history was never drawn, so the answer arrived with
  no sign of the conversation it was continuing.

- two contrast values. A label chip whose GitHub hex is dark — the `bug`
  red, `documentation` blue — rendered at 3.20:1 in the DARK theme: the
  70/30 pull toward the foreground was tuned for light and never applied
  to the other side. And Monaco's inactive line numbers sat at 1.93:1 on
  white, under the 2:1 the log pane's own gutter was raised off, in the
  surface where a line number is how you refer to a line at all.

That closes all 16 of sweep 6's confirmed findings. 895 unit tests and
five typecheckers green.
Sweep 7's regression lens, plus what it found next door.

- the commit page's `setPageTarget` had no `isConnected` guard, so a page
  abandoned while still loading stamped its auto-opened file onto the
  history entry of whatever view you had moved to — and the next refresh
  re-routed that view with it. The job log's `openJob` carries this exact
  guard, written an hour before this line and then not written here.

- the rebase empty-plan guard counted only the rows ON SCREEN, and the
  list is capped. Above the cap, dropping every visible commit disabled
  Start with "this plan keeps no commits" for a rebase that would in fact
  have replayed everything the view never drew. `total` four lines up had
  already been taught to use `replayCount`; two fixes from one batch, in
  one function, disagreeing. `hiddenTail()` was already there for it.

- the ANSI default-ink rule I added an hour ago picked WHITE. That is a
  dark-theme palette: measured against all sixteen blocks, black wins on
  fourteen and white on two — the greys. So thirteen of sixteen blocks
  were under 3:1 and block 15 was white on white, 1.00:1. Inverted, the
  worst pairing in the set is now 3.26:1, and a check walks all sixteen.

- `openCodeFile` set `activeMonacoView` before awaiting the file read and
  never checked the route afterwards. Leaving during that read built a
  Monaco editor into a node its owner had already let go of — nothing
  held a reference that could dispose it. `showCodeView` guards its own
  read this way.

- and the dock's reserve reached the detail pages and three scrollers but
  not the Code file surface, the section detail panes, or a project
  board's columns, so the end of each sat behind an open terminal.
Sweep 7 carried a lens whose whole job was to re-drive the things Anton
reported and ask whether the complaint would still be made. Two would.

- "it teleports u to the commit graph which tells u nothing about the
  changed files" was reported about a commit chip, and every sha in the
  app was moved to the commit page for it — pull requests, releases,
  notifications, ref detail. The Actions RUN page's chip was missed, so
  the complaint was still one click away from a surface people live in.
  It opens the commit now. (`refDetail`'s remaining graph route is a
  deliberately-named "Show in the graph" action and stays.)

- "scrolling super fast or instead of me is pure ragebait" was fixed by
  disarming the tail when the reader scrolls away — but the test for
  "still at the bottom" is a two-line dead band, and the wheel is damped
  to 0.45. So ONE notch on a trackpad moves less than the band, left
  `follow` armed, and four seconds later the tail poll pulled the reader
  back down with nothing to say why. The keyboard already disarms on
  intent rather than distance; the wheel now says the same thing.

Also: the abandoned-commit-page guard from the last commit moves earlier,
where the sweep's refuter showed it belongs. Guarding `openFile` stops
the stamp; guarding the build stops the whole detached render, and
settles a second race it found — two refreshes in quick succession, the
first finishing detached and re-stamping file #0 over the file the
second had just restored.
The state table sweep 7 ran over conflict kinds — the highest-stakes
surface in the app, and the one a mistake in cannot be undone.

- "Mark resolved" was gated on `conflictsPending`, and the result pane is
  seeded with the BASE. So a block nobody had accepted still held the
  pre-merge original whether it conflicted or not, and the button
  unlocked while auto-mergeable hunks sat at base — saving wrote the
  original over both sides' work in every one of them. The gate added
  earlier tonight to stop exactly this was counting the wrong thing.

  It counts every pending block now, and the view starts from git's own
  auto-merge (`applyAllNonConflicting`) so the stricter gate is invisible
  in the ordinary case. Without that second half the fix would trade
  data loss for drudgery: a file with one true conflict and forty clean
  hunks would need forty gestures to redo work git had already done.

- "Stage all" held back modify/deletes because they cannot contain
  markers, and the guard is "no markers means resolved". A conflicted
  BINARY cannot contain markers either — so the one kind of conflict the
  app itself refuses to open a text merge for was the one kind Stage all
  waved through, declaring it settled with whichever side happened to be
  in the worktree.

- git's `DD` (deleted on both sides) was folded into `missingSide` and
  drawn as a modify/delete, offering a "Take <side>" button for a side
  with nothing to take — which `conflictTakeSide` then refuses,
  correctly, contradicting the panel that offered it. It is its own
  state, with the two buttons removed and Discard named instead.

- and `UA`/`AU` — added on one side, no common ancestor — were told the
  same modify/delete story: "deleted in X", about a file with no history
  to have been deleted from. They branch on `hasBase` now.

534 tests, including real-git coverage for all four.
- the Assistant's gate listener returned early when the Assistant was
  ungated, so it could open the gate and never close it. Removing the
  last model left the composer live and the header still advertising a
  connection that no longer existed — measured: "· Claude (BYOK)" beside
  an empty account. It is symmetric now, and pinned in both directions.

- adding a KEYLESS preset — a local model, a CLI agent — never announced
  the change. Every other path does (`setKey`, `removeConnection`,
  `setDefault`), but a keyless provider is usable the moment it is added
  and no key dialog follows, so nothing fired: the users of the one
  provider class that needs no key could never lift the gate at all.

- the Appearance card unsubscribed its own live-sync hook the first time
  Settings was left. Settings is keep-alive, so leaving PARKS the card
  and returning re-attaches it verbatim — with the hook gone, a theme
  changed from anywhere else meanwhile left the card showing the old one
  for the rest of the session, highlight and `aria-pressed` both stale.
  Nothing accumulates without the guard: it is a single slot, overwritten
  by the next build.

That is the third `isConnected` guard on a keep-alive view found this
session, each firing on precisely the path it needed to survive.
- Enter on a control nested inside a clickable row ran the ROW's action.
  On a project card the kebab opened the issue instead of the item menu,
  which made "Move to" unreachable by keyboard entirely; on a release
  asset the Delete button DOWNLOADED the asset — a destructive control
  you could not reach, and a different action silently taken in its
  place. `orgs.ts`, `explore.ts` and `common.ts` already carried the
  `e.target !== row` guard; these two did not. Pinned as one invariant,
  including the half a careless guard would break: the row itself must
  still answer Enter.

- the clone dialog and its destination sheet declared no
  `hasUnsavedWork`, and every file saved in the open repository fires the
  watcher, whose overlay sweep takes every layer down. So a build
  touching one file destroyed a half-typed clone URL, a repository picked
  from the list, or a clone already in flight. Both compare against what
  the form OPENED with rather than truthiness, since both prefill.
It signs you out and then looks for the Sign-in button on the rebuilt
card. But `showSettingsView` returns as soon as the card's SHELL is in
the DOM, and everything the card actually shows arrives in an async body
it does not await — so the handler searched a card still holding a
loading spinner, found nothing, started nothing, and left you signed
out, under a label promising the opposite. The comment above it already
described this failure and the `await` it added was of the wrong thing.

The card's body is a promise now, and the handler awaits it.

The instructive half is the check. One already existed for this button
and was green the whole time: it asserted a `.gh-flow` was on screen
without stubbing `github:status`, which the fixture answers
SYNCHRONOUSLY — so the card had always painted before the handler looked
and the race never opened. The new one holds that read for 400ms, which
is what a network call does, and fails on the old code.
- one failed `ai:settings` read left the AI Models card permanently
  dead. The catch returned before the "Connect a model" button was
  built, so a transient error — a locked settings file, a slow disk —
  took away the card's entire purpose with no way to retry short of
  restarting the app. The button is hoisted so both exits have it, and
  the error path offers Try again.

- a clone COLLISION retry reopened the destination sheet with a new name
  and no destination, so the sheet fell back to the configured default
  folder and confirming quietly relocated the clone — on the one path
  whose whole premise is that the user had chosen somewhere else. The
  sheet takes a destination now and shows it rather than "Loading…", and
  neither the settings read nor its failure path overwrites one.

- the job log page opened with the keyboard on the Back button.
  `detailPage` focuses Back on every new page, which is right for a page
  you read top-down and wrong for this one: j/k, n/N between failures,
  Home, End and / all live on the scroller, so the page opened with none
  of them working and nothing saying why. It focuses the log — with
  `preventScroll`, because moving the port on a live job is the one
  thing this pane must never do.

Not taken: the Split/Inline whitespace disagreement. The refuter's
analysis is that the tempting fix — making `normalizeAllWhitespace`
collapse inner runs — would make the extension's "Trim whitespaces" and
"Ignore whitespaces" identical and weaken the merge model's own
whitespace handling, and that NO test pins the current inner-collapse
behaviour, so it would pass the suite green while changing what the
product means. That is a decision about diff semantics across two
packages, not a bug fix, and it is Anton's to make.
`replaceResultLines` routes a block that reaches end-of-file to a branch
that replaces to the end WITHOUT appending a newline. Monaco reports one
line for an empty document, so `endExclusive > lineCount` is false for a
single-block accept into an empty result — and the other branch appends
one, writing a trailing blank line the accepted side never had.

The empty result is the ordinary case for a conflict with no common
ancestor (git's AA / UA / AU), where the seed is the empty base: accept
one side, mark resolved, and the file gains a line nobody wrote.

An empty document is always at end-of-file whatever the span says, so
that is what the condition says now.

That closes 22 of sweep 7's 23 confirmed findings. The one left is the
Split/Inline whitespace disagreement, deliberately: the tempting fix
changes what "ignore whitespace" MEANS across two packages and the
extension, no test pins the current behaviour, and it is a product
decision rather than a defect.
… views

Split computes through the engine in-process; Inline computes in Monaco's
own diff worker, whose only whitespace option is `ignoreTrimWhitespace`.
Each derived that flag from the app's toggle separately, and the app sent
the engine's "all" mode — which additionally collapses whitespace runs
INSIDE a line, something no editor option can do. So a file whose only
change was a doubled internal space read as unchanged side-by-side and as
a change unified: the same file, the same setting, two answers.

Both now read the rule from one exported `ignoreTrimWhitespaceFor`, and
the toggle sends "trailing" — the mode Monaco can actually match. Its
label says what it now does rather than overselling it.

The harness gains a whitespace-only fixture and the discriminating one (a
doubled space mid-line); the second check fails on the old mapping.
@antonarnaudov antonarnaudov changed the title Desktop: the wave-2 redesign — navigation, GitHub surfaces, and eight sweeps of repair Desktop: the wave-2 redesign — navigation, GitHub surfaces, and seven sweeps of repair Sep 2, 2026
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.

2 participants