Skip to content

fix(desktop): fix the product races behind four E2E flakes - #8680

Merged
matt2e merged 5 commits into
mainfrom
flakey-tests-desktop-races
Oct 30, 2026
Merged

matt2e merged 5 commits into
mainfrom
flakey-tests-desktop-races

Conversation

@matt2e

@matt2e matt2e commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes the four desktop product races behind recurring E2E flakes in the 21–28 Oct 2026 CI audit. Each fix has a regression test:

  • In the channel browser, Enter acted on a stale deferred list.
  • A stale autocomplete swallowed the Enter that submits an empty edit.
  • Same-second messages swapped order on screen.
  • The unread pill read "Jump to latest" instead of "N new messages".

Two E2E specs that failed for the same ordering reason also get test-side fixes.

This is one of seven PRs split out of the flakey-tests branch (siblings below).

Related issue

None found for these. Draft #8559 does not fix the voice-note flake. Draft #8661 (Refs #6733) closes the #channel list synchronously when an edit is cleared. §11 below also covers that case, plus the @mention and :emoji lists, so the two PRs overlap in useChannelLinks.ts and empty-edit-delete.spec.ts.

Flaky tests fixed

Test Failing runs Cause Change
§5 channel-browser.spec.ts:402 "Enter with no matches jumps to create" 15 of 50 main runs, e.g. 40597484657, 40582387515, 40566841699, 40367495044 Product. The list renders from useDeferredValue(query). An Enter that landed before the deferred render read the old list, so it opened a stale match or skipped the create row. While the deferred query lags, Enter filters the live query with the same selectBrowserChannels the list renders from.
§11 empty-edit-delete.spec.ts:88, :111, :132 :88 in 3 jobs, :111 in 3, :132 in 2. :111 failed all 3 attempts on main 39822883395 and 39799090750 Product. Clearing the draft went through the 120 ms query debounce. The #channel list stayed open, so Enter re-inserted the suggestion instead of submitting the empty edit. useChannelLinks, useEmojiAutocomplete and useMentions (typed @ only) close as soon as the trigger is gone. Typing a query still debounces. ChannelAutocomplete gains data-testid="channel-autocomplete".
§10 video-attachment.spec.ts:1469 "right-click menus expose distinct selectors…" 10 of 50 main runs, e.g. 40528171327 Product. The live cache merge (sortMessages, ascending id within a second) and the channel-window projection (descending id) disagreed, so two same-second rows swapped on screen as each writer landed. A new shared/lib/eventOrder.ts holds compareRelayOrder and its exact reverse, compareChronologicalOrder. sortMessages and the window and reconciliation sorts all use the window's order, so older-page paging is unchanged. sortEvents keeps ascending id. The spec gives its three messages distinct seconds.
§16 voice-note.spec.ts:906 "starting a duplicate voice-note player pauses the other instance" 6 of 50 main runs. All 3 attempts on PRs 39622488737 and 40309414135 Same-second messages plus index-based card lookup. Distinct seconds, and each card is found through its own message row.
§13 stream.spec.ts:477 "keeps scroll position when new messages arrive above the fold" (integration) 7 of 50 main runs. All 3 attempts on PR 39546556833 Product. Frozen rows came back reordered (7 of 12 instrumented runs). The selector ended older history at the snapshot's first id, so moved rows were both prepended and kept in the frozen tail. The duplicates filled the row count, pendingCount fell to 0, and the pill read "Jump to latest". Deleting a frozen row also dropped the whole freeze. useBufferedTimelineMessages keys rows by renderKey, which is stable across an optimistic send and its ack (frozenRowKeys). It drops only deleted frozen rows, and ends older history at the first frozen row of any key. stream.spec is unchanged.

New regression tests:

  • channel-browser.spec: two forced Enter variants.
  • empty-edit-delete.spec: a #/@/: autocomplete table.
  • autocompleteTriggerClose.jsdom-test.
  • projectChannelWindow.test: the cache merge and the window agree in both arrival orders.
  • channel-dense-second-reach.spec: a RAF sampler sees one order.
  • useBufferedTimelineMessages.test: deletion, reorder and ack rows.
  • virtualization.spec: deleting a row while reading keeps the next arrival counted.
  • A useThreadReplies.test row: the thread fetch and a live append agree on same-second order.

Local before/after counts are from the original flakey-tests commits (238c08c7c, c66f411f3):

Test Before After
channel-browser 0/20 20/20
autocomplete table 0/30 30/30
jsdom 0/2 2/2
dense-second sampler 0/10 10/10
buffered-timeline unit 4/7 7/7
virtualization deletion 0/10 10/10
stream:477 against an isolated relay 25/30, all failures "Jump to latest" 30/30, then 20/20 and 10/10

Verification

This branch was pushed without local re-verification, by the user's choice: local test runs were overloading the machine and failing for that reason. Hosted CI is the check. It was pushed with git push --no-verify, because the pre-push hook runs typecheck and the unit suites. #8613's flake-check lane will also repeat the changed specs and unit tests.

  • Every file except empty-edit-delete.spec.ts and virtualization.spec.ts is byte-identical to its final version on flakey-tests (67185df6a). Those two carry only this PR's hunks; see "Merge order".
  • Checked for this PR: git diff --check, biome on both partial specs (no changes, the same as on main), and a conflict-free merge with all six siblings onto main.

Readiness

These are product fixes in the desktop app. Under AGENTS.md "Before opening a PR", this needs an agent review, an agent run of the affected flows, and a human in-app check before buzz-review-completed is added. Suggested check, in just dev or a local build against any community:

  1. Channel browser: open "Browse channels", type a name that matches nothing and press Enter straight away. The create-channel flow opens with that name. Then type an existing channel's exact name and press Enter straight away. That channel opens, not an earlier match.
  2. Empty-edit delete: edit one of your own messages and type #gen so the channel list opens. Select all, delete, and press Enter at once. The empty edit is submitted (the delete-by-clearing flow) and no suggestion is re-inserted. Repeat with @ (mention list) and :sm (emoji list).
  3. Same-second order: send two short messages in quick succession, within one second. They appear in send order and never swap while they land.
  4. Unread pill: in a busy channel, scroll up past the fold, and have a second client post one message. The pill reads "1 new message". While still scrolled up, delete one of your visible messages, then have the second client post again. The pill still counts the new message.

Merge order

No required order, but two files are shared:

  • desktop/tests/e2e/empty-edit-delete.spec.ts is also edited by flakey-tests-desktop-e2e-actions. This PR adds the autocomplete table after line 158. The sibling changes imports, helpers, beforeEach and line 143.
  • desktop/tests/e2e/virtualization.spec.ts is also edited by flakey-tests-desktop-e2e-gates. This PR adds one test after line 929. The sibling's hunks are all above line 551.

The hunks don't touch, so each pair merges cleanly in either order, as checked with git merge-tree. Draft #8661 overlaps this PR in useChannelLinks.ts and empty-edit-delete.spec.ts; whichever lands second will need a rebase.

Sibling PRs

Split from flakey-tests (one PR per area): #8676 (PostgreSQL lease + lane), #8677 (relay E2E helper + Windows shell), #8678 (mobile), #8679 (Tauri apt cache + Windows Rust cap), #8681 (desktop E2E state gates), #8682 (desktop E2E composer + row actions).

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

🔐 Codex Security Review

Note: This is an automated, security-focused review generated by Codex.
Use it as a supplement to human review; false positives are possible.

Scope

  • Exact PR diff: d263e2c7f7126b9a5caf1507140bb32e2c4e0ea3...a3f579cd1ce9766219ba884ba6ed9a18694bb426
  • Model: gpt-6.1-sol

💡 Click "edited" above to see earlier reviews for this PR.


Review Summary

Overall Risk: MEDIUM

One desktop reliability regression can hide an acknowledged message while the reader is scrolled up.

Findings

[MEDIUM] Preserve render keys through every live-cache writer

  • Category: Reliability
  • Location: desktop/src/features/messages/ui/useBufferedTimelineMessages.ts:37 (source)
  • Description: A later echo or reconnect replay also reaches useLiveChannelUpdates, which writes directly through mergeTimelineCacheMessages. That merge replaces an acknowledged same-id event without retaining its localKey. If the reader froze the acknowledged row under its optimistic render key, this filter now treats it as deleted and removes it whenever another frozen row survives. retainAcknowledgedLocalKeys only protects window projection; it cannot protect the separate cache write, and a projection following that write can also lose the key.
  • Impact: An already visible, acknowledged message disappears and is counted as a new message until the reader returns to the bottom.
  • Recommendation: Preserve existing localKey values by event id in the shared message merge used by all subscriptions. Cover both callback orders between the unread live-cache merge and window projection.

Notes

  • Review used read-only source inspection. Builds and tests were not run, as requested.

Generated by Codex Security Review |
Requested by: @matt2e |
Workflow run

@github-actions github-actions Bot added codex-security-review-current The posted Codex security review matches its recorded range. and removed codex-security-review-current The posted Codex security review matches its recorded range. labels Oct 29, 2026

@jedwards27 jedwards27 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES

Reviewed: eda26b9423328a06960bb0d5fd4c1af54600c419..66e4ca3b0da7379c888e67cbac9f7c621aa093b9 (exact head 66e4ca3b0da7379c888e67cbac9f7c621aa093b9)
Risk: high — this changes Desktop timeline ordering/buffering behavior and the regressions are specifically async/UI race conditions.
Behavior/contracts traced: optimistic send acknowledgement identity (localKey), authoritative channel-window head refetch/reconciliation, timeline renderKey projection, unread-tail freeze buffering, relay/bridge/Tauri channel-window ordering, focused Desktop E2E/product-race evidence.

Findings

Blocking:

  1. desktop/src/features/messages/lib/channelWindowReconciliation.ts:13-19, :43-60 and desktop/src/features/messages/ui/useBufferedTimelineMessages.ts:30-49 can drop an already-visible acknowledged send from the frozen unread-tail view after an authoritative head refetch.

    Send success preserves the optimistic id as localKey on the accepted event (desktop/src/features/messages/hooks.ts:759-762), and timeline rows use event.localKey ?? event.id as the stable renderKey (desktop/src/features/messages/lib/formatTimelineMessages.ts:478-481). But a head refetch replaces the page and removes the matching live overlay row by event id (desktop/src/features/messages/lib/channelWindowStore.ts:104-109). The reconciliation path only retains pending messages and non-broadcast thread replies from the prior cache, so an acknowledged top-level message with a localKey is not retained. The frozen-tail buffer then looks up frozen rows only by renderKey; when the authoritative row comes back with only its relay id, the previously frozen localKey no longer matches and the row is treated as gone.

    User impact: after sending a message, getting the ack, scrolling up, then hitting a subscribe/reconnect/head refetch, that already-visible message can disappear from the frozen reading view and be miscounted as a new hidden tail row until the reader jumps back to bottom. That is directly in the behavior this PR is trying to stabilize.

    A temporary exact-head regression row reproduced the failure shape:

    selectBufferedTimelineMessages({
      frozenRowKeys: ["a", "pending-b", "c"],
      isAtBottom: false,
      messages: ["a", "b", "c", "d"], // b is the same acked row after refetch, but no renderKey
    })
    
    actual:   ["a", "c"]
    expected: ["a", "b", "c"]
    

    Author action: preserve acknowledged local identity through authoritative window reconciliation (for example, reapply the prior localKey by event id when projecting page rows), or make the frozen snapshot store a stable alias set containing both renderKey and event id. Add a regression covering: optimistic send → ack with localKey → freeze while scrolled up → authoritative refetch returns the same event without localKey → frozen view still includes the row and the pending count only counts truly newer arrivals.

    Verification owner: author owns the fix and regression; reviewer/tooling should re-run the focused buffering/reconciliation tests after the patch.

Non-blocking confidence gap:

  • Current non-required Flake Check rollup still has two red Playwright-smoke repeats in desktop/tests/e2e/video-attachment.spec.ts:856 / line 922 (inline video hover reveals a timeline without a second play control, controls y delta 1 vs <0.5). The changed voice-note test passed immediately before that failure in both jobs, and I am not classifying this as the blocker above. Still, because this PR claims flake cleanup, the refreshed round should either rerun/refresh that evidence or explicitly document why the video hover failures are unrelated/pre-existing. Verification owner: author/CI flake-check evidence.

Validation

Evidence reviewed at exact head 66e4ca3b0da7379c888e67cbac9f7c621aa093b9:

  • PASS: just desktop-typecheck
  • PASS: just desktop-check (three existing warnings outside this diff)
  • PASS: targeted node tests covering messageQueryKeys, projectChannelWindow, useBufferedTimelineMessages, useThreadReplies, and relayClientShared (42/42)
  • PASS: targeted jsdom autocomplete tests (4/4)
  • PASS: targeted smoke E2E for the new fast-Enter, autocomplete-cleared-edit, same-second live-order, and delete-while-reading flows (7/7)
  • PASS: pnpm install --frozen-lockfile
  • PASS: cd desktop && pnpm test (169 tests)
  • PASS: cd desktop && pnpm check (exit 0, warnings noted above)
  • PASS: cd desktop && pnpm exec node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/ui/useBufferedTimelineMessages.test.mjs src/features/messages/lib/projectChannelWindow.test.mjs (21 tests)
  • FAIL as expected with a temporary mutation test for the blocking localKey/refetch/frozen-tail case described above.
  • Required checks observed green/skipped for the path selection: Desktop, Desktop Build (macOS), Desktop E2E Integration, Desktop E2E Relay, Desktop Release Candidate, and DCO Check succeeded; Rust/Web/Mobile/Security/relay-only required contexts were skipped by selection.

Manual/native evidence: focused browser/mock-bridge E2E evidence exists for the four changed Desktop journeys; no independent native Desktop app/manual in-app pass was completed in this review. That remains a confidence gap, not the author-actionable blocker.

Residual risk: browser/mock-bridge evidence does not prove native WebView/OS timing. After the code fix, the exact real-app journeys should still be spot-checked before release readiness.

Cleanup: disposable review worktrees/artifacts remain to be swept after the follow-up round/verdict; no source changes from this review are being submitted.

matt2e added a commit that referenced this pull request Oct 29, 2026
)

## Summary

`video-attachment.spec.ts:856` (inline video hover reveals a timeline
without a second play control) fails in about 1 of 15 runs. This change
fixes the test only.

The test reads the inline controls' page `y` at rest and again after
hovering, then asserts the two differ by less than 0.5px, so the
controls don't slide in. In failing runs the whole message row moves 1px
in the timeline between the two reads. The controls stay in the same
place relative to their player:

```
pass  {"restControls":529,"hoverControls":529,"restPlayer":392,"hoverPlayer":392}
fail  {"restControls":529,"hoverControls":528,"restPlayer":392,"hoverPlayer":391}
```

Now the test measures the controls' offset from their `video-player` in
a single `evaluate`, so it reads both values in the same frame. Moving
the row no longer matters, and a real slide-in still fails.

### Why it showed up now

On [buzz #8680](#8680), Flake Check
failed 6 of 14 Playwright shard runs on this test. That PR edits shared
helpers in the same spec file, so `scripts/flake-check.mjs` selects the
whole file and repeats every test in it 10 times. The test hadn't
changed on main since September. It just had never been repeated before.

## Testing

- Reproduced on clean `main` with `playwright test --project=smoke
video-attachment -g "inline video hover reveals" --repeat-each 15`: 1
failed, 14 passed.
- With this change: `--repeat-each 40` gave 40 passed. The full
`video-attachment` spec also passed, 13 of 13.
- To confirm the test can still fail, I temporarily added `translate-y-1
group-hover/video:translate-y-0` to the inline controls. The test failed
with `Received: 4`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Jitter <2b2e6415e748c35180847a37aadae06a11e30dddc76b784e7fd9354c4eb42e7a@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
matt2e added a commit that referenced this pull request Oct 29, 2026
… by keyboard (#8682)

## Summary

Test-side fixes for the two largest desktop E2E flake clusters in the
21–28 Oct 2026 CI audit:

- **Composer `fill()` races the app's deferred focus-to-end** (§1). Text
was appended to the old draft, or a clear did nothing.
- **Clicks on the hover-only message action bar while the timeline
re-pins** (§3/§8/§18). Reply and more-actions clicks timed out or were
intercepted.

Two shared helpers replace the per-spec workarounds, and §9 rides along
because its spec now imports them. The PR changes test code only. It is
one of seven PRs split out of the `flakey-tests` branch (siblings
below).

### Related issue

None found. #8559, #8622 and #8613 touch these specs or helpers, but had
no coverage for these clusters. Draft #8661 edits
`empty-edit-delete.spec.ts`'s `submitEmptyEdit` and `beforeEach`, the
same lines as this PR.

### New helpers

- `tests/helpers/composer.ts`:
- `clearComposer` waits for the app's focus, then repeats select-all +
Delete through the editor keymap until the editor is empty (250 ms per
try, 5 s overall).
- `replaceComposerText` clears, then fills. On an empty document, a late
collapse to the end is a no-op.
- `tests/helpers/channelHeadReady.ts`:
  - `GENERAL_CHANNEL_ID`.
- `openGeneral` clicks `#general`, then calls
`waitForMockChannelHeadReady`.
- `pressRowAction(button)` does focus + Enter. The action bar also shows
on focus-within, and more-actions is a Radix trigger that opens on
Enter.

### Flaky tests fixed

| Test | Failing runs | Cause | Change |
|---|---|---|---|
| §1 `inbox-edit.spec.ts:525` | 39641311877, 39821769576, 40333705511,
40348069929, 40388246283 | **Append:** entering edit mode, or closing
the non-member mention dialog, focuses the end of the draft in deferred
steps: RAF, a nested RAF, then ProseMirror's 20 ms selection resync.
`fill()` selects and inserts in separate protocol steps, so a step that
lands in between collapses the selection and the text is appended.
**Clear does nothing:** `fill("")` presses Delete before ProseMirror
handles `selectionchange`, so the editor blocks it. | `clearComposer` /
`replaceComposerText` |
| §1 `inbox-edit.spec.ts:560` | 39538411386, 39821769576 | Same as
above. | Same |
| §1 `mention-recipients.spec.ts:591` (both variants) | 39799090750,
40374286094, 39641311877 | Same | Same |
| §1 `mention-recipients.spec.ts:691` (both variants) | 40348069929,
40556019361, 39601763602 | Same | Same |
| §1 `messaging.spec.ts:3799` | 39827234118, 40445481983 | Same, plus a
bare `#general` click and a `force: true` Reply | Same, plus
`openGeneral` and `pressRowAction` |
| §1 `messaging.spec.ts:4022` | 40389392495 | Same | Same |
| §1 `messaging.spec.ts:4289` | 39822883395, 40557808800, 40566841699 |
Same | Same |
| §1 `messaging.spec.ts:4368` | 40574058918 | Same | Same |
| §1 `remote-owned-mentions.spec.ts:463` (both variants) | 39618692657,
40374286094, 40382726903, 40389392495, 40582387515 | Same, after the
Invite dialog closes | Same |
| §3 `messaging.spec.ts` :3531, :3647, :4122, :4208, :4450 (root and
reply) and :4633 (keyboard and button) | :3531 40583830861, 40595483966.
:3647 40566841699, 40597484657. :4122 39641311877, 40368315739,
40368363357. :4208 39525092486, 39796569342, 40367495044. :4450
39796569342, 40348069929, 40373404267. :4633 39530486096, 40348069929,
40368315739, 40582387515, 40368363357 | The action bar is
`pointer-events-none` until its row is hovered. Opening `#general`
refreshes the head after the live subscription lands, and the timeline
re-pins, so the row moves out from under the pointer. `force: true` only
hid this. | `openGeneral`, then `pressRowAction` on Reply or
more-actions. Six more messaging tests that used hover + `click({ force:
true })` get the same change, and each first waits for its last row to
contain the sent root. |
| §18 `thread-follow.spec.ts:350` | 39529963092, 40348069929,
40583830861 (all 3), 40595483966; PR 39541263507 (all 3) | Same; the
composer overlay intercepts the more-actions click. | Same |
| §8 `thread-head-stale-edit.spec.ts:55` | 39525092486, 39618692657,
39796569342, 39822883395, 40011714848, 40281448810, 40368363357,
40389392495, 40445481983, 40574058918; PR 40031299575 (all 3) | Same
(used `force: true`) | Same |
| `file-attachment.spec.ts:165` | 1–2 CI failures (audit long tail) | A
more-actions click on a moving timeline | Same |
| `empty-edit-delete.spec.ts` (`openMoreActionsMenu`, `submitEmptyEdit`,
the non-empty edit, `beforeEach`) | Covered under §11 in the
desktop-races sibling | The same hover and `fill` races | The head-ready
wait before emitting, `pressRowAction`, `clearComposer` and
`replaceComposerText`. The autocomplete table rows keep their
back-to-back keys, which is what they test. |
| §9 `file-attachment.spec.ts:419` "upload progress floats above the
dock and lifts Jump to latest" | 10 of 50 main jobs, including
40583830861, 40597484657, 40608383847 | A programmatic `scrollTop` keeps
the bottom intent armed. The upload card resizes the content, the
timeline re-pins, and the pill unmounts under `boundingBox()`. The fixed
250 ms wait read the card mid-slide. | `scrollTimelineUp()` leaves the
bottom with a real wheel, and one `expect.poll` checks all three layout
relations. |

Local before/after counts are from the original `flakey-tests` commits
(e286a6ae0, 816c7c35e, c66f411f3, 67185df6a), running `main`'s copy and
the changed copy side by side under forcings:

- **§1 at 4× CPU throttle:** 130/130 after; `main`'s copies ranged from
5/10 to 10/10.
- **§1 with `selectionchange` delayed, a stretched resync, or a gap
inside `fill()`:** `main`'s copies 0/5 to 4/5 and 0/20; after 65/65 and
20/20.
- **§3 at 4× throttle:** `main` had 3 Reply-click timeouts at :4208 and
3 at :4633 (keyboard); after 120/120.
- **`file-attachment:165`:** 0/3 before, 3/3 after.
- **§9 unforced:** 4/5 before, 5/5 after.

These were measured with the helper's first version, which waited two
frames plus the 20 ms resync. The final state-based helper ran unforced
only: 157/157 and then 196/196 over the touched specs. Its retry was not
checked under a forced late collapse. :3531, :3647, :4122, :4450 root,
:4633 button, `thread-head-stale-edit:55` and the six force-click tests
were not force-reproduced; they get the fix that matches their CI
signatures and reproduced siblings.

Dropped:

- `mention-recipients.spec.ts:933` (40608383847) fails on
`flavors.handled`, which is unrelated.
- `persistent-agent-audience.spec.ts:828` (`@Mor@Morg`) did not
reproduce in 79 forced runs.

### Verification

This branch was pushed without local re-verification, by the user's
choice: local test runs were overloading the machine and failing for
that reason. **Hosted CI is the check.** It was pushed with `git push
--no-verify`, because the pre-push hook runs typecheck and the unit
suites. #8613's flake-check lane will also repeat the changed specs.

- Every file except `empty-edit-delete.spec.ts` is byte-identical to its
final version on `flakey-tests` (67185df6a). That file carries only this
PR's hunks; see "Merge order".
- Checked for this PR: `git diff --check`, biome on the partial spec (no
changes, the same as on `main`), and a conflict-free merge with all six
siblings onto `main`.

### Readiness

Test-only change. AGENTS.md's readiness steps (agent review, then human
confirmation) are still outstanding, so `buzz-review-completed` is not
set.

### Merge order

No required order. `desktop/tests/e2e/empty-edit-delete.spec.ts` is also
edited by **flakey-tests-desktop-races**, which adds the autocomplete
table after line 158. This PR changes imports, helpers, `beforeEach` and
line 143. The hunks don't touch, so the two merge cleanly in either
order, as checked with `git merge-tree`. Draft #8661 edits the same
`submitEmptyEdit`/`beforeEach` lines; whichever lands second will need a
rebase.

### Sibling PRs

Split from `flakey-tests` (one PR per area): #8676 (PostgreSQL lease +
lane), #8677 (relay E2E helper + Windows shell), #8678 (mobile), #8679
(Tauri apt cache + Windows Rust cap), #8680 (desktop product races),
#8681 (desktop E2E state gates).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
matt2e added a commit that referenced this pull request Oct 29, 2026
## Summary

Test-side fixes for ten desktop E2E flake clusters and six long-tail
rows from the 21–28 Oct 2026 CI audit. Each spec now waits for the state
it assumes (a rendered row, a mounted listener, a finished animation, a
held boot read), not a fixed delay or a one-shot probe. Two mock-bridge
seams support this.

The PR changes test code and mock-bridge seams only, apart from one
product constant extracted with its value unchanged. It is one of seven
PRs split out of the `flakey-tests` branch (siblings below).

### Related issue

None found.

- #8622, #8244 and #8386 touch some of these spec files, but not the
tests fixed here.
- §7 mode A (`navigation:472`) is left to open PR #6852, which already
sets the width and measures in one polled evaluate.
- Draft #8279 queues cold-launch nostr-bind links in the product. Its
mock returns null, so the §12 listener seam is still needed.

### Mock-bridge seams

- `__BUZZ_E2E_EVENT_LISTENER_COUNT__(event)` counts live **app**
listeners per Tauri event. It wraps the mocked
`plugin:event|listen`/`unlisten`. The spec-facing
`__TAURI_INTERNALS__.listen` passes a marker target that the wrapper
skips, so a spec's own subscription can't satisfy the wait. Tauri
2.11.1's mock ignores targets, so delivery is unchanged.
- `deferFirstChannelsRead` arms the existing channels-read hold at boot.
`__BUZZ_E2E_RELEASE_CHANNELS_READ__` releases it.
- `CREATE_PROJECT_REFOCUS_DELAY_MS` moves to `createProjectFormFocus.ts`
with its value unchanged (50 ms). Both `CreateProjectFormContent` and
`project-commit-detail.spec` import it.

### Flaky tests fixed

| Test | Failing runs | Cause | Change |
|---|---|---|---|
| §2 `overscroll-boundary.spec.ts:34` (and its sibling) | 39601763602,
40367495044, 40583830861, 40597484657, 40373404267 | The
`message-timeline` test id first lands on the loading skeleton, which
doesn't overflow, so the scroll lock rightly cancels the wheel. | Wait
for a rendered row, and content taller than the viewport, before
dispatching wheels. |
| §4 `project-conversation-load-failure.spec.ts:73` | 40367495044 (all 3
attempts), 40608383847, 40583830861; PRs 39541263507, 40509460168 | The
seeded root and reply were dated `now`. The panel's live REQ (`since =
floor(now)`) still replayed the reply, so the error card never appeared.
| Date both events a minute in the past, and correct the comment. |
| §6 `workflows.spec.ts` `createWorkflow` cluster (:222, :365, :297,
:525, :471, :406, :614, :997) | 14 of 50 main jobs; :222 ×4, e.g.
40353619362 | `AnimatePresence mode="wait"` keeps the trigger body
mounted through its exit, so "Message text" could fill the trigger's
field (:222 and the callers). For :297, `isVisible()` read "closed" one
frame before the dialog opened its own channel list. | Wait for the step
body's "Step details" control before filling. Create dialogs wait for
the list; only the duplicate dialog clicks it. |
| §12 `nostr-bind.spec.ts` (:156, :198, :228, :247, :260, :335, :417,
:447) | 8 main jobs, each test once | The spec emitted
`deep-link-nostr-bind` before the app subscribed, and the mock drops
events that have no listener. | Wait for
`__BUZZ_E2E_EVENT_LISTENER_COUNT__` to report the app listener before
emitting. |
| §15 `profile.spec.ts:1128` (integration) | 7 of 50 main jobs | The 320
ms motion-sampling window started before the click. | Start the window
when `activeTab` reads "runtime". |
| §17 `agent-availability.spec.ts:602`, both shutdown-failure variants
(integration) | 6 jobs (4 disconnected-online, 2 failed-online) | The
"shutdown refused" toast sits over the Delete row and pauses while the
pointer is on it, so the retry click is intercepted. | Assert the toast,
then retry with focus + Enter. The toaster has no close button. |
| §19 `virtualization.spec.ts:247` "08 — cascading older pages…" | 4 of
50 main jobs; PR 40605736093 (all 3 attempts) | The 120 ms and 400 ms
windows were timed from before the wheel bursts, and CDP can deliver
wheels late. | Count the wheels the page receives. Each window opens at
the first wheel and closes only after all have landed, then waits for
still frames. **A second, product-side mode remains** (see below). |
| §20 `message-copy-link.spec.ts:160` | 4 of 50 main jobs (5 failed
attempts), e.g. 40595483966 | The huddle row reused `"d".repeat(64)`,
which is a seeded ordinary `#general` message. | Use ids that no seed
uses, and emit only after `waitForMockChannelHeadReady`. |
| §21 `navigation.spec.ts:687` | 4 of 50 main jobs | The pending chip's
`boundingBox()` was read once, and can be null while the row mounts. |
Poll until the chip has a width. |
| §23 `sidebar-snapshot.spec.ts:358`, `:388`, `:438` | PR 40427822003
(all 3 attempts); never on main | The 500 ms loading-state budget
counted app boot, so a slow runner finished the channels read first. |
Hold the first channels read with `deferFirstChannelsRead`, assert the
loading state, then release it. |
| `project-commit-detail.spec.ts:259` | 1–2 CI failures (audit long
tail) | A 50 ms refocus timer can land mid-fill and type the description
into the name field. | Once the name field is focused, wait out a page
timer of `CREATE_PROJECT_REFOCUS_DELAY_MS`; equal-delay timers fire in
order. |
| `project-pr-review.spec.ts:3520` | 1–2 CI failures | The same timer
pattern in `CreateProjectWorkItemDialog`. | Wait for `toBeFocused()` on
the title, which only that timer can satisfy. |
| `thread-load-failure.spec.ts:79` | 1–2 CI failures |
`openWelcomeThread` branched on a one-shot `count()` and fell back to a
hover-only Reply click. | Wait for the summary row, then click it. |
| `virtualization.spec.ts:201` | 1–2 CI failures | The drop point sat 2
px above the target's bottom edge, after two fixed frames. | Drop at the
centre, after dnd-kit's live region names the target. |
| `channel-composer-overflow.spec.ts:217` | 1–2 CI failures |
Same-second emits sort by id, and the clearance was checked once. |
`createdAt: base + i` and `expect.poll`. |
| `scroll-history.spec.ts:1289` | 1–2 CI failures | A fixed 250 ms wait
before idle sampling. | Poll for coverage first. |

Local before/after counts are from the original `flakey-tests` commits
(e286a6ae0, 816c7c35e, 67185df6a). Each used a forcing that made
`main`'s copy fail with CI's signature:

| Test | Before | After |
|---|---|---|
| §2 | 3/15 | 15/15 |
| §4 | 0/10 | 10/10 |
| §6 :222 | 0/3 | 3/3 |
| §6 :297 | 0/3 | 3/3 |
| §12 | 0/3 | 3/3 |
| §15 | 0/3 | 3/3 |
| §17 | 1/4 | 4/4 |
| §19 late-wheel modes | 0/3 | 3/3 and 2/3 |
| §23 | 2/6 | 6/6 |
| project-commit-detail | 0/3 | 3/3 |
| project-pr-review | 0/3 | 3/3 |

§20, §21, `thread-load-failure:79`, `virtualization:201`,
`channel-composer-overflow:217` and `scroll-history:1289` were not
reproduced; they get the fix that matches their CI signature.

Not fixed (follow-up): `virtualization:247` also has a product-side
mode. In one frame after a prepend, `scrollHeight` can drop 458–639 px
while `scrollTop` moves only 50–114 px, so the anchor row jumps 382–457
px. That is lost anchor compensation, and this test can still fail on
it.

### Verification

This branch was pushed without local re-verification, by the user's
choice: local test runs were overloading the machine and failing for
that reason. **Hosted CI is the check.** It was pushed with `git push
--no-verify`, because the pre-push hook runs typecheck and the unit
suites. #8613's flake-check lane will also repeat the changed specs.

- Every file except `virtualization.spec.ts` is byte-identical to its
final version on `flakey-tests` (67185df6a). That file carries only this
PR's hunks; see "Merge order".
- Checked for this PR: `git diff --check`, biome on the partial spec (no
changes, the same as on `main`), and a conflict-free merge with all six
siblings onto `main`.

### Readiness

Test and mock-bridge change; the one product edit is a constant
extraction with its value unchanged. AGENTS.md's readiness steps (agent
review, then human confirmation) are still outstanding, so
`buzz-review-completed` is not set.

### Merge order

No required order. `desktop/tests/e2e/virtualization.spec.ts` is also
edited by **flakey-tests-desktop-races**, which adds one test after line
929; this PR's hunks are all above line 551. They don't touch, so the
two merge cleanly in either order, as checked with `git merge-tree`.

### Sibling PRs

Split from `flakey-tests` (one PR per area): #8676 (PostgreSQL lease +
lane), #8677 (relay E2E helper + Windows shell), #8678 (mobile), #8679
(Tauri apt cache + Windows Rust cap), #8680 (desktop product races),
#8682 (desktop E2E composer + row actions).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Jitter <2b2e6415e748c35180847a37aadae06a11e30dddc76b784e7fd9354c4eb42e7a@buzz.block.builderlab.xyz>
matt2e added a commit that referenced this pull request Oct 29, 2026
…cap (#8679)

## Summary

Fixes the two CI workflow defects that turned `main` red most often in
the 21–28 Oct 2026 audit:

- **Desktop Tauri:** the lane now caches its apt `.deb`s. It no longer
re-downloads 62 MB from a slow Ubuntu mirror on every run.
- **Windows Rust:** the lane drops a duplicate `cargo check` and gets a
60 min cap, so a cold run can save its cache.

It also updates #8613's `scripts/flake-check.test.mjs`, which breaks
once the Tauri package list moves into an env var. There are no product
code or test-behaviour changes.

This is one of seven PRs split out of the `flakey-tests` branch
(siblings below).

### Related issue

None found. #8613 touched both workflows after the audit, but not these
steps. No open PR changes how the Tauri packages are fetched, the
Windows timeout, or its Check (workspace) step:

- #8422, #8321 and #8373 only add packages to the list, and would now
add them to `TAURI_APT_PACKAGES`.
- #8414 adds two mesh-llm Tauri steps to the Windows job. If it lands,
they must fit the cold budget below.

### CI failures fixed

| Lane / test | Failing runs | Cause | Change |
|---|---|---|---|
| Desktop Tauri, "Install Tauri dependencies (Linux)"
(`_ci-desktop.yml`) | 62 jobs in 51 run attempts, 22–27 Oct. On main:
39821769576, 39822883395, 39827234118, 40333705511, 40342468627,
40348069929, 40353619362, 40368315739, 40373404267, 40374519539,
40556742929 | All 3 `ci-apt-retry.sh` attempts hit their 420 s limit
while `azure.archive.ubuntu.com` served the 62 MB at 20–65 KB/s. The
25.5 MB `libwebkit2gtk-4.1-0` alone takes a whole attempt, and each
attempt loses its partial download. | The package list moves to a
job-level `TAURI_APT_PACKAGES`. A new "Resolve Tauri apt archives" step
builds the cache key from `apt-get install --print-uris -o
Acquire::ForceHash=SHA256` (name, size and SHA256 per file).
`actions/cache/restore` uses that key. The install step copies only the
restored files whose SHA256 matches the signed index, because apt 2.8.3
reuses any same-size cached `.deb` without hashing it. A best-effort
rebuild keeps exactly the listed files. `actions/cache/save` runs only
on core-lane pushes after a successful rebuild; PR runs never save. |
| Windows Rust (`_ci-rust.yml`) | All on main: 39796569342 (job
119885795086), 39821769576 (119975358772), 39827234118 (119994286695),
39865872891 (120128351939), 40402556064 (121852634685) | Every test
passed. Cold steps take 41.3–43.5 min, so the 45 min cap cancelled the
`Swatinem/rust-cache` post-run save 1.6–3.7 min in. No cache was
written, and the next push was cold again. | Delete "Check (workspace)".
The clippy step before it covers the same `--workspace --all-targets`
set with `-D warnings`, which saves 2.0–2.8 min. Raise `timeout-minutes`
from 45 to 60, with the measured breakdown in a comment: 39–41 min of
steps, a ~4.5 min save and ~15 min of margin. |
| `scripts/flake-check.test.mjs` "repeat invocations mirror the lanes
they stand in for" | None in CI yet: it fails only with this change
applied | #8613 added this test after `flakey-tests` branched. It checks
that the advisory `flake-tauri` lane installs the same packages as the
Desktop Tauri core lane, by regex-matching the inline `apt-get install
-y --no-install-recommends \` list in `_ci-desktop.yml`. That list now
lives in `TAURI_APT_PACKAGES`, so the regex matched nothing and the test
failed with "Desktop Tauri apt packages". | Read `TAURI_APT_PACKAGES`
from `_ci-desktop.yml` and compare package names (12 on each side) with
`_ci-flake.yml`'s list. Renaming one package in `_ci-flake.yml` still
fails the test. Giving the `flake-tauri` lane the same read-only `.deb`
restore is a follow-up. |

Local evidence is from the original `flakey-tests` commit (9aa768a3b):

- The apt steps ran verbatim in Ubuntu 24.04 containers with apt 2.8.3,
with the cache restore and the save step's `if:` emulated.
- On a miss, the install fetches everything ("Need to get 254 MB"). On
an exact hit with the network down, it fetches nothing ("Need to get 0
B/254 MB").
- On a fallback with one `.deb` missing and one corrupted at the same
size, the hash check rejects the corrupt file, and apt re-fetches only
the corrupt and missing files.
- The key was identical in all four simulated jobs.
- The Windows numbers come from the jobs API for the five runs above.

On this branch, the split session ran the `scripts/` node tests:
`ci-selection`, `ci-required-gates`, `ci-apt-retry` and `flake-check` on
macOS gave 84 pass, 0 fail and 8 skipped (Linux-only). Before the
`flake-check.test.mjs` fix, that test failed 1 case. The same session
ran actionlint 1.7.12 with shellcheck, which was clean, and the
rust-cache contract scripts, which passed.

### Verification

This branch was pushed without further local re-verification, by the
user's choice: local test runs were overloading the machine and failing
for that reason. **Hosted CI is the check.** It was pushed with `git
push --no-verify` over SSH. The pre-push hook runs tests, and the HTTPS
token lacks the `workflow` scope.

Only hosted CI can show:

- The runner's real package list (about 62 MB) and the same key in all
three Tauri lanes.
- The `actions/cache` round trip. The first PR run misses, because
`main` has no key yet. After merge, the first push to `main` saves it.
- A cold Windows run saving its cache within the new cap. The next
toolchain bump or cache eviction will show it.

Remaining risk: after Ubuntu updates a large package, runs fetch it from
the mirror until a push to `main` saves a new key.

### Readiness

CI-only change. AGENTS.md's readiness steps (agent review, then human
confirmation) are still outstanding, so `buzz-review-completed` is not
set.

### Merge order

Independent of the other six. It merges cleanly onto `main`, and with
every sibling, in any order.

### Sibling PRs

Split from `flakey-tests` (one PR per area): #8676 (PostgreSQL lease +
lane), #8677 (relay E2E helper + Windows shell), #8678 (mobile), #8680
(desktop product races), #8681 (desktop E2E state gates), #8682 (desktop
E2E composer + row actions).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
matt2e added a commit that referenced this pull request Oct 29, 2026
…#8677)

## Summary

Fixes two CI flakes from the 21–28 Oct 2026 audit, both in test code
with no product change:

- The relay E2E helper `collect_until_eose` now drops repeat copies of
an event id.
- Two Windows shell tests drop a 5 s cap that a cold Git Bash start can
exceed.

This is one of seven PRs split out of the `flakey-tests` branch
(siblings below).

### Related issue

None found for these two. The buzz-terminal `passwd_shell()` `getpwuid`
race
(`env_fence_tests::resolve_shell_falls_through_to_passwd_not_the_default`,
run 38590095289) is left to open PR #7687, which already replaces
`getpwuid`. #8636 and #7976 edit `buzz-test-client/src/lib.rs`, and
#7607 and #7915 edit `buzz-dev-mcp/src/shell.rs`, but none of them
changes `collect_until_eose` or these timeouts.

### Flaky tests fixed

| Test | Failing runs | Cause | Change |
|---|---|---|---|
| Relay E2E
`e2e_project::test_project_tombstone_deletes_coordinate_and_spares_members`
(e2e_project.rs:338, "left: 2 right: 1") | 40408678412#1, job
121879630394 (PR #8620, schema-only) | The EVENT handler sends OK, then
fans out in a spawned task. `handle_req` registers the live subscription
before its history query, and `req.rs`'s `seen_ids` dedups history rows
only. If fan-out runs after the REQ is registered, the event arrives
once live and once from history, both before EOSE, and
`collect_until_eose` kept every frame. | `collect_until_eose` keeps the
first copy of each event id, as production clients do. Its doc comment
says a test of the relay's own dedup must read frames with `recv_event`.
A new unit test, `collect_until_eose_keeps_one_copy_of_each_event_id`,
uses a fake relay that sends the duplicate. |
| Windows `buzz-dev-mcp` `shell::tests::basic_echo` (shell.rs:1026, exit
124) | 39675698081#1, job 119485363399 (green on re-run) | 124 is the
shell tool's own timeout exit code. `basic_echo` capped the shell at 5
s, and it is one of the first tests in the process to start Git Bash,
which can take longer than 5 s on a cold runner. | `timeout_ms: None`
(the production 120 s default) in `basic_echo` and in
`workdir_is_honored`, which has the same exposure. `timeout_fires` still
covers timeouts. |

Pre-check: no e2e test relies on the relay's own history dedup. Every
exact-count assertion uses a single filter. The NIP-33 "only newest
remains" assertions would see a failed replacement as two different ids,
and this dedup keeps both.

Local evidence is from the original `flakey-tests` commit (fc4518d39):

- The new unit test: FAIL before, PASS after.
- 13 of the 14 e2e files that call the helper, plus `e2e_thread_roots`,
against an isolated local relay. All pass, except `e2e_relay`
`test_unarchive_emits_member_added_notification`. That test fails by
itself 3/5 with the original helper too, and CI's Relay E2E lane doesn't
select it.
- The Windows failure can't be reproduced on macOS. The Windows Rust
lane is the check.

Follow-up (out of scope): make the relay deliver each event id at most
once per subscription, keeping the live/history overlap.

### Verification

This branch was pushed without local re-verification, by the user's
choice: local test runs were overloading the machine and failing for
that reason. **Hosted CI is the check.** It was pushed with `git push
--no-verify`, because the pre-push hook runs clippy and unit tests.

- Both files are byte-identical to their final versions on
`flakey-tests` (67185df6a).
- Checked for this PR: `git diff --check`, `rustfmt --check`, and a
conflict-free merge with all six siblings onto `main`.

### Readiness

Test-only change. AGENTS.md's readiness steps (agent review, then human
confirmation) are still outstanding, so `buzz-review-completed` is not
set.

### Merge order

Independent of the other six. It merges cleanly onto `main`, and with
every sibling, in any order.

### Sibling PRs

Split from `flakey-tests` (one PR per area): #8676 (PostgreSQL lease +
lane), #8678 (mobile), #8679 (Tauri apt cache + Windows Rust cap), #8680
(desktop product races), #8681 (desktop E2E state gates), #8682 (desktop
E2E composer + row actions).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
matt2e added a commit that referenced this pull request Oct 29, 2026
#8676)

## Summary

When the user-deletion worker dropped a lease heartbeat, its pooled
connection could be left inside an open transaction. The worker's final
lease `release` then reported success but never committed, and a rerun
saw the request as "leased by another worker" for up to 60 s. The
heartbeat is now one autocommit `UPDATE`.

The PR also fixes six PostgreSQL-lane test flakes from the 21–28 Oct
2026 CI audit. It moves one relay test off the 100 ms-acquire test
helper before that test starts flaking the same way.

This is one of seven PRs split out of the `flakey-tests` branch
(siblings below). It touches only `buzz-db`, `buzz-deletion` and
`buzz-relay`. Every relay file except `connection.rs` and
`boot_lifecycle.rs` changes only for the helper rename in the last row.

### Related issue

None found. Nothing on `main` (eda26b9) or in an open PR covers these.
G, a single hermit `pgschema` 504 that needs a workflow change, is out
of scope.

### Flaky tests fixed

| Test | Failing runs | Cause | Change |
|---|---|---|---|
| `buzz-deletion`
`user::worker::postgres_tests::dummy_executor_failure_retries_same_community_stage`
(:47) and `next_step_advances_one_handler_at_a_time_through_completion`
(:131) | 40439041234 (main), 40399635380#3, 40413732817#1 | **Product
bug.** `run_stage_with_heartbeat` drops the renewal future when the
stage handler wins `select!`. The heartbeat opened a transaction. sqlx
0.9 bumps `transaction_depth` only after `BEGIN` returns, so a `begin()`
dropped mid-`BEGIN` queues no `ROLLBACK`, and the connection goes back
to the pool inside an open transaction. The worker's autocommit
`release` then ran inside it and never committed. |
`EntityDeletionStore::heartbeat` is one autocommit `UPDATE`, guarded by
`lock_owned`'s ownership/status/expiry predicate. A superseded lease is
never renewed. A new regression test,
`dropped_heartbeat_leaves_no_open_transaction_for_the_release`, drops
the real heartbeat mid-`BEGIN` through a pausing proxy. |
| `buzz-db`
`session_statement_timeouts_bound_writer_and_reader_sessions`
(runtime/tests.rs:3138, `PoolTimedOut`) | 40128463987, 40314950864#1,
40374286094 (main), 40394821009#1, 40413732817#1 | Each config's lazy
reader pool dialled cold inside the 150 ms `READER_ACQUIRE_TIMEOUT`.
`Db::new` never runs the boot ping. | Warm the reader the way the relay
boots it (`read_pool_boot_ping_for_tests` + `await_idle_floor`). |
| `buzz-relay` `startup_owns_the_dependency_sampler` (boot_lifecycle.rs)
| Torn scrape: 39865872891 (main), 39624074244#2, 40385799750#1,
40413293801#1. Early exit: 39704588623 | metrics-exporter-prometheus
0.18.3 snapshots counters, then gauges. The scrape that first shows the
completion gauge can therefore predate the `checks_total` counters. |
Assert on a second scrape, taken after the gauge is seen. On an early
relay exit, the helper now panics with the exit status, stdout and
stderr. The early exit itself was not reproduced. |
| `buzz-db`
`sample_writer_waits_for_shared_floor_lock_before_sampling_time`
(replica_fence.rs:1086) and
`replica_floor_probe_waits_for_shared_writer_and_records_after_release`
(runtime/tests.rs:3452) | 39601763602 (main), 40391445284#1,
39825846222, 40301667418 | Both scan cluster-wide `pg_stat_activity` but
ran outside the serialized `postgres-cluster-global` group. Connection
churn from other tests masked activity (`MaskedActivity { masked: 1 }`).
| Rename both with a `cluster_global_` prefix. `nextest show-config
test-groups --profile postgres-ci` lists both in the group. |
| `buzz-db`
`stalled_reader_sessions_release_their_slots_for_new_connections`
(reader_pool_postgres_tests.rs:354) | 40399635380#2 | The floor warms
one connection, so the second warm-up acquire dials cold inside 150 ms.
| Retry only that warm-up acquire on `PoolTimedOut`, for up to 5 s
(#8472's precedent). The stall under test keeps the production budget. |
| `buzz-db`
`readiness_check_cancellation_balances_waiter_and_inflight_connection`
(runtime/tests.rs:391, `Elapsed`) | 39785370353 | The test aborted
`pg_sleep(5)` after a fixed 25 ms. Recovery lands at server start + 5 s,
but the bound is abort + 5 s, a ~22 ms margin locally. | Poll
`pg_stat_activity` until the query is active, then abort. Use
`pg_sleep(1)` within the unchanged 5 s bound. |
| `buzz-relay` `upgrade_would_deny_line_is_written_at_auth_while_open`
(connection.rs) | None yet (preventive, added by #8634) | It used the
100 ms-acquire `test_state_with_database_url` against the real test
database, the same shape as the `PoolTimedOut` flakes above. | Use
`test_state_with_database_url_and_pool_options` with sqlx's default
timeout. The 100 ms helper is renamed
`test_state_with_unreachable_database_url`; all its remaining callers
use closed ports. |

Local before/after counts are from the original `flakey-tests` commit
(1f0451915), on macOS with OrbStack Postgres:

- Regression test: 3/3 FAIL before, 3/3 PASS after.
- Worker stress: 3 failures in 288 iterations before; 2,100 passing runs
after.
- Statement timeouts: 44/50 FAIL before, 50/50 PASS after.
- Sampler: 2/200 FAIL before, 200/200 PASS after.
- `MaskedActivity` under `pgbench -C`: 2/50 iterations FAIL before.
After, the lane passed 3× (613/613) but was not re-run under pgbench.
- Stalled reader: 10/30 FAIL before, 30/30 PASS after.
- Forced late-`Execute` cancellation: 10/10 FAIL before, 10/10 PASS
after.

### Verification

This branch was pushed without local re-verification, by the user's
choice: local test runs were overloading the machine and failing for
that reason. **Hosted CI is the check.** It was pushed with `git push
--no-verify`, because the pre-push hook runs clippy and unit tests.

- Every file on this branch is byte-identical to its final version on
`flakey-tests` (67185df6a), where the counts above were measured.
- Not re-run for this PR: clippy, nextest and the Postgres lanes.
- Checked for this PR: `git diff --check`, `rustfmt --check` on the
changed files, and a conflict-free merge with all six siblings onto
`main`.

### Readiness

This PR has a product fix (the `buzz-db` lease heartbeat). Under
AGENTS.md "Before opening a PR", it needs an agent review and a **human
check against a local relay** before `buzz-review-completed` is added.
Suggested check:

1. With local Postgres and a relay, submit and approve a user deletion
with `buzz-admin user-deletions submit` / `approve`.
2. Run `buzz-admin user-deletions run` until it completes, then run it
again at once.
3. Working looks like this: both runs complete, neither reports the
request as leased by another worker, and `buzz-admin user-deletions
inspect <id>` shows no leftover lease. The original race is rare and is
not expected to reproduce by hand; this check confirms releases still
commit.

### Merge order

Independent of the other six. It merges cleanly onto `main`, and with
every sibling, in any order.

### Sibling PRs

Split from `flakey-tests` (one PR per area): #8677 (relay E2E helper +
Windows shell), #8678 (mobile), #8679 (Tauri apt cache + Windows Rust
cap), #8680 (desktop product races), #8681 (desktop E2E state gates),
#8682 (desktop E2E composer + row actions).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
matt2e and others added 3 commits October 30, 2026 10:10
Product changes behind four desktop E2E flakes from the 21-28 Oct 2026
CI audit, each with a regression test. Before editing I checked
origin/main and the open and merged PRs. None fixed these; draft #8559
does not fix the voice-note flake. Since then, draft #8661 (Refs #6733)
closes the #channel list synchronously when an edit is cleared. §11
below covers that case and the @mention and :emoji lists too, so the
two overlap in useChannelLinks.ts and empty-edit-delete.spec.ts.

§5 Channel-browser Enter acts on the deferred list
- The list renders from useDeferredValue(query). An Enter that landed
  before the deferred render read the old list, so it opened a stale
  match or skipped the create row.
- While the deferred query lags, Enter now filters the live query with
  the same selectBrowserChannels the list renders from.
- channel-browser.spec: two forced variants dispatch the input and
  Enter in one task, one creating a new name and one opening the typed
  match. Before 0/20, after 20/20 (10 repeats each).

§11 A stale autocomplete swallows Enter (empty-edit-delete, #6733)
- Clearing the draft went through the 120 ms query debounce. The
  #channel list stayed open, so Enter re-inserted the suggestion
  instead of submitting the empty edit.
- useChannelLinks, useEmojiAutocomplete and useMentions (typed @ only)
  now close as soon as the trigger is gone. Typing a query still
  debounces.
- empty-edit-delete.spec: one table over #channel, @mention and :emoji
  clears an open autocomplete and presses Enter at once.
  Before 0/30, after 30/30 (10 repeats per row).
- autocompleteTriggerClose.jsdom-test: the channel and emoji hooks
  close synchronously and leave Enter unhandled. Before 0/2, after 2/2.
- ChannelAutocomplete gains data-testid="channel-autocomplete" so the
  test can wait for it.

§10 One chronological order for same-second events
- The live cache merge (sortMessages, ascending id within a second)
  and the channel-window projection (descending id) disagreed. Two
  same-second rows therefore swapped on screen as each writer landed.
- shared/lib/eventOrder.ts now holds compareRelayOrder and its exact
  reverse, compareChronologicalOrder. sortMessages and the window and
  reconciliation sorts all use compareChronologicalOrder.
- The window's descending id is kept, so an older page fetched from a
  mid-second cursor still lands contiguously above the rows shown. The
  window's order is unchanged; only sortMessages moved.
- sortEvents keeps ascending id. It resolves raw history REQs,
  timeline consumers re-sort its output, and project issues replay
  same-second assignment operations in its order.
- projectChannelWindow.test: in both arrival orders, the cache merge
  and the window projection agree on same-second order. Fails before.
- channel-dense-second-reach.spec: a RAF sampler sees a single order
  for two same-second live rows. Before 0/10 (swapped on every run),
  after 10/10.
- Thread replies: loadThreadReplies stores sortMessages([...replies,
  ...receivedInFlight]) and appendMessage merges a live reply through
  mergeMessages, which also re-sorts with sortMessages, so the two
  writers already agree. A useThreadReplies.test row pins it: two
  same-second replies fetched in ascending id order, then one later
  live reply; the fetched pair keeps its order. Storing the pages
  unsorted makes it fail.
- Paging still holds: channel-dense-second-reach,
  channel-window-mock-paging, scroll-history and virtualization
  (including "08 - cascading older pages") all pass.
- Test fixes with the same cause: video-attachment.spec:1469 gives its
  three messages distinct seconds (5/5). For voice-note.spec:906, its
  two messages get distinct seconds (10/10), and it finds each card
  through its own message row rather than by index.

§13 Unread pill reads "Jump to latest" instead of "1 new message"
- Instrumenting stream.spec:477 against a local relay found frozen rows
  coming back reordered in 7 of 12 runs (5 of the 12 failed). A
  freeze-lag hypothesis did not reproduce (forced mock variants passed
  5/5 before), so there is no data-tail-frozen gate and stream.spec is
  unchanged.
- The selector ended older history at the snapshot's first id, so rows
  that had moved ahead of it were prepended and also kept in the frozen
  tail. The duplicates filled out the row count, pendingCount fell to
  0, and the label fell back to "Jump to latest". §10's comparator
  alone stops the reordering here.
- Deleting a frozen row also dropped the whole freeze (forced variant:
  delete a frozen row, then emit; before 0/5).
- useBufferedTimelineMessages now keys rows by renderKey, which is
  stable across an optimistic send and its ack (the ref is
  frozenRowKeys, since it holds renderKey ?? id, not event ids). It
  drops only deleted frozen rows, and it ends older history at the
  first frozen row of any key. The last change matters even with §10:
  an ack changes the row's id, so it can still move among same-second
  rows.
- useBufferedTimelineMessages.test: deletion, reorder and ack rows.
  Before 4/7, after 7/7.
- virtualization.spec: deleting a row while reading keeps the next
  arrival counted. Before 0/10, after 10/10.
- Integration lane (isolated relay on :3017, own databases and redis
  db): stream:477 before 25/30, all 5 failures "Jump to latest"; with
  all fixes 30/30 before the reorder change, then 20/20 and 10/10
  after it. The full stream.spec passed 10/10 on a fresh relay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Closing the moment an update carried no trigger broke selecting a
suggestion with Enter. With the menu open, ProseMirror splits the
paragraph before the composer's keydown handler runs. That update has no
trigger, so the menu closed and the handler never selected the
suggestion, which left "@bo" or "Ask @fifor a hand" in about 25 smoke
specs. Close immediately only when the draft is cleared, the stale-Enter
case this fix targets, and keep the debounce for every other update.

Co-authored-by: Jitter <2b2e6415e748c35180847a37aadae06a11e30dddc76b784e7fd9354c4eb42e7a@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
An acknowledged send keeps its optimistic id as localKey, and its row
renders under that key. A head refetch (replaceNewestChannelWindow) or a
live echo after the ack (mergeLiveChannelWindowEvent) brings the same
event back without one, and reconcileChannelWindowMessages kept only
pending sends and thread replies from the cache. The row's renderKey
then flipped to the relay id. Its React key changed, so the row
remounted, and a frozen unread tail treated it as deleted: it hid the
row and counted it as a new arrival.

reconcileChannelWindowMessages now carries a cached localKey over to
the window event with the same id. Every timeline projection goes
through it, so the key holds for a head refetch, a live echo, an older
page and a persisted-head seed alike. Matching the frozen tail on both
keys instead would fix only the pill and leave the remount.

useBufferedTimelineMessages.jsdom-test: send, ack with localKey, freeze
while scrolled up, then the event returns without localKey, once per
path (head refetch, live echo), then a newer arrival. The acked row
stays in the frozen view and pendingCount is 1. Before 0/2, after 2/2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e force-pushed the flakey-tests-desktop-races branch from 66e4ca3 to eb88a98 Compare October 29, 2026 23:30
@github-actions github-actions Bot added codex-security-review-current The posted Codex security review matches its recorded range. and removed codex-security-review-current The posted Codex security review matches its recorded range. labels Oct 29, 2026
Flake Check run 40973558941 failed "08 — cascading older pages never
snap the viewport toward newest" at `wheelTrace.received`: expected 4,
received 1. This branch does not cause it. The deep-history mock dates
every row in its own second, so the same-second ordering change cannot
reorder this channel, and a pure prepend buffers the same before and
after the frozen-tail change. The assertion came in with #8681 about
30 minutes before the run. Main had run it 4 times since; this branch
repeats the test only because it adds one to the file.

Cause: setting scrollTop to 180 crosses the start threshold, so the
older page is fetched before any wheel is sent, and the 300 ms mock
delay runs out during the burst. The settle gate admits a held page
after 100 ms with no wheel or scroll, so a runner that pauses that long
between wheels lets the page commit mid-burst. The trace stopped at the
commit, and its count of 4 was a race against the timing.

Chromium does not coalesce these wheels. The timeline's wheel listeners
are non-passive and Playwright awaits each ack, so a standalone page
with the same listeners saw all four as separate events even under
150 ms and 300 ms main-thread stalls.

Fix: a mock-bridge hold, `__BUZZ_E2E_HOLD_CHANNEL_WINDOW_PAGES__`, on
the `__BUZZ_E2E_HOLD_USERS_BATCH__` pattern. Test 08 holds each page
before the crossing and drops the 300 ms delay and the early break. The
motion trace releases the page once it is sampling, and asserts that
exactly one page was held. The anchor baseline therefore always comes
before the commit, where before it could come after an early commit.

Forced variant, a 400 ms pause after the first wheel of page 0: before,
fails with CI's signature (expected 4, received 1); after, passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@github-actions github-actions Bot added codex-security-review-current The posted Codex security review matches its recorded range. and removed codex-security-review-current The posted Codex security review matches its recorded range. labels Oct 30, 2026
Flake Check run 40978030438 failed two tests this PR changes or adds.

video-attachment "right-click menus expose distinct selectors": the
off-relay right-click opened no menu. A live row reaches the timeline
floor in two scroll steps. The first, to the virtualizer's estimated
floor, leaves the new video row visible but hanging under the composer
overlay (row 506-686, composer from 587). The second, to the measured
floor, settles it at 392. A force right-click taken between the two
computed its point under the composer. The contextmenu hit the composer
form and the pointer stayed over it, matching the CI artifacts: no menu
in any frame, no hover controls on either video. force also skipped the
hit-target check that would have caught this. Both video right-clicks
now wait for the player to clear the composer, then go through
Playwright's actionability checks. The wait matters too: after a failed
hit check Playwright scrolls the row itself, and that trailing scroll
can dismiss the menu it just opened.

Forced variant, ResizeObserver delivery delayed 400 ms for the
off-relay arrival: before, 2/2 fail with CI's signature (contextmenu at
547,596 on FORM.message-composer); after, 3/3 pass, clicking at the
settled centre with no scroll after it.

channel-dense-second-reach "same-second live messages": the sampler
read [] while the DOM held both rows in the right order. It sampled on
animation frames, but the rows commit in a task, so a read that beat
the next frame saw nothing. A MutationObserver now records the pair's
order after every commit, before any later task can read it. That also
catches a swap that reverts within one frame. Forced variant, reading
in the commit's own task: the old sampler returns [] 3/3, the observer
the expected order 3/3. Reverting sortMessages to ascending id within a
second still fails the test 3/3.

Audit of this PR's other added or changed E2E tests:
- virtualization "deleting a row while reading": it emitted its
  deletion and live arrival without waiting for live admission, which
  starts only after the first window renders. A probe found no live
  subscription right after the click, and no post-admission head
  refresh yet when the test scrolled up. It now opens #general through
  openGeneral, like its siblings. With a deleted frozen row dropping
  the whole freeze again, it still fails 2/2 ("Jump to latest").
- channel-browser forced-Enter variants, the empty-edit-delete
  autocomplete table and voice-note:906: no timing hazard. The first
  is ordered by React's microtask commit; the others wait on visible
  state or use non-force clicks.

Ran: pnpm build:e2e, tsc, biome on the three specs, and the fixed tests
on the real build (right-click 5/5, same-second 8/8, deletion 4/4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@github-actions github-actions Bot removed the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 30, 2026
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 30, 2026
matt2e added a commit that referenced this pull request Oct 30, 2026
…#8678)

## Summary

Fixes three mobile flakes from the 21–28 Oct 2026 CI runs, in test code
only:

- In two iOS `RunnerTests` classes, late bridge callbacks no longer fail
whichever test is running when they fire.
- The waits on a busy test-host main thread now cap at 10 s.
- Two Flutter avatar tests poll for the ready frame instead of sleeping
a fixed 50 ms.

This is one of seven PRs split out of the `flakey-tests` branch
(siblings below).

### Related issue

None found.

### Flaky tests fixed

| Test | Failing runs | Cause | Change |
|---|---|---|---|
| `BuzzPushSnapshotEnrichmentTests` (RunnerTests) | 40362671989 (job
121712747802, main), 39681273328#1 (job 119504560518), 40658588384 (job
122715950541, PR #8640) | The 1 s waits ran out while the test host's
Flutter app held the main thread, and the test returned and deleted its
temp directory. The bridge callback's assertions then ran later and were
blamed on whichever test was running (`ProfileSetupRecoveryTests`,
`IdentityFilePluginTests`). | Callbacks only record the value and
fulfill. Every assertion, including the `isRestricted` checks, runs
after the wait, so a late callback writes into a dead local. The seven
waits cap at 10 s (`callbackTimeout`). |
| `ProfileSetupRecoveryTests`
`testStartedEditorWithoutReadyRestoresProfileAndAllowsRetry` |
40585805357 (job 122477274956, PR #8568) | Engine release and editor
dismissal must both happen within 2 s, on the main thread the host app
keeps busy. | Both predicate waits cap at 10 s. |
| Flutter `channel_detail_page_test.dart` "profile sheet shows the
poster before autoplay and restores it on tap" and
`settings_profile_header_test.dart` "shows the poster until the animated
avatar is ready" | 40368315739 (job 121732711851, main) | After the
image response is released, both tests slept a fixed 50 ms of real time
before `pumpAndSettle`. A slower real decode left no frame scheduled, so
the ready key was missing. | A new `WidgetHelpers.pumpUntilFound` pumps
in 10 ms real-time steps until the ready key appears, capped at about 5
s. |

Local evidence is from the original `flakey-tests` commit (53aaec97c),
Xcode 27.1 on the iOS 26.2 simulator:

- Enrichment, with `Self.complete` delayed 1.5 s and 4 s: 6 failures
before, 107/107 after. With a 12 s delay, every failure lands in its
owning test. A mutated error code is still caught.
- Profile setup, with the engine release held 4 s: 2 failures before,
4/4 after. A leaked engine still fails.
- Avatar tests: under load, 94/96 before and 96/96 after. With a 200 ms
image delay, 0/5 before and 5/5 after. A widget that never signals ready
still fails.

Not included:

- The planned `AppDelegate` / `Runner.xcscheme` change that keeps
Flutter out of the RunnerTests host. It did not work locally: restored
scene sessions and the `UIMainStoryboardFile` fallback, and a normal
launch came up black afterwards.
- `IdentityFilePluginTests`
`testSyntheticDocumentsReadInPlaceAndRejectInvalidOrOversize`
(40441255307#1). It is left unchanged and stays open.

### Verification

This branch was pushed without local re-verification, by the user's
choice: local test runs were overloading the machine and failing for
that reason. **Hosted CI is the check.** It was pushed with `git push
--no-verify`, because the pre-push hook runs the Flutter tests.

- Every file is byte-identical to its final version on `flakey-tests`
(67185df6a).
- Checked for this PR: `git diff --check`, and a conflict-free merge
with all six siblings onto `main`.
- #8613's flake-check lane will repeat the two changed Dart test files.

### Readiness

Test-only change. AGENTS.md's readiness steps (agent review, then human
confirmation) are still outstanding, so `buzz-review-completed` is not
set.

### Merge order

Independent of the other six. It merges cleanly onto `main`, and with
every sibling, in any order.

### Sibling PRs

Split from `flakey-tests` (one PR per area): #8676 (PostgreSQL lease +
lane), #8677 (relay E2E helper + Windows shell), #8679 (Tauri apt cache
+ Windows Rust cap), #8680 (desktop product races), #8681 (desktop E2E
state gates), #8682 (desktop E2E composer + row actions).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Jitter <2b2e6415e748c35180847a37aadae06a11e30dddc76b784e7fd9354c4eb42e7a@buzz.block.builderlab.xyz>

@jedwards27 jedwards27 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 :bot: Jude's code review agent — APPROVED at head a3f579cd1ce9766219ba884ba6ed9a18694bb426.

I reviewed the fresh-head delta from 0c73e9eefbb93bdf0877f319f1960edab3118180 to a3f579cd1ce9766219ba884ba6ed9a18694bb426 and reconciled it with the prior full review. The delta is limited to the three E2E spec files:

  • desktop/tests/e2e/channel-dense-second-reach.spec.ts
  • desktop/tests/e2e/video-attachment.spec.ts
  • desktop/tests/e2e/virtualization.spec.ts

No production desktop, Tauri/native, relay, persistence, cache, or schema code changed in this delta. I found no author-actionable defects.

Key checks:

  • Same-second ordering test still asserts stable visible order and now observes committed DOM order via MutationObserver, which strengthens rather than weakens the race check.
  • Video context-menu test now waits for the actual video hit target and right-clicks without force, while preserving relay/off-relay menu assertions.
  • Delete-while-reading test now opens #general through the live-admission/head-refresh helper before exercising the reading-state delete/live-arrival behavior.
  • git diff --check 0c73e9eefbb93bdf0877f319f1960edab3118180..HEAD -- desktop/tests/e2e/channel-dense-second-reach.spec.ts desktop/tests/e2e/video-attachment.spec.ts desktop/tests/e2e/virtualization.spec.ts passed locally.
  • Live PR state immediately before this review: head a3f579cd1ce9766219ba884ba6ed9a18694bb426, base ef4130da4bfeea9fe37477d621166675e0ab3ef7; CI rollup is 51 SUCCESS / 31 SKIPPED with no failing/pending checks. Required desktop/DCO gates are green; unrelated path-selected gates are skipped.
  • Authenticated reviewer is jedwards27; PR author is matt2e.

Residual verification gap: I did not perform native/manual in-app hardware testing for this test-only delta. That is not author rework and should be owned by the usual release/readiness verifier if a final app-readiness claim is needed.

Verdict: APPROVE.

@matt2e
matt2e merged commit 1eb67bb into main Oct 30, 2026
82 checks passed
@matt2e
matt2e deleted the flakey-tests-desktop-races branch October 30, 2026 04:15
ravarora2 added a commit that referenced this pull request Oct 30, 2026
…vent-guards

* origin/main: (29 commits)
  Restyle community invitations (#8728)
  feat(relay): add incremental artifact reads (#8523)
  fix(mobile): show agent DM typing and balance reply bubble padding (#8731)
  perf(relay): route author- and #p-scoped global subscriptions by selector (#8666)
  fix(desktop): fix the product races behind four E2E flakes (#8680)
  test(scripts): stop file-size fixtures leaking git repos and fsmonitor daemons (#8727)
  test(mobile): keep late callbacks and fixed sleeps from failing tests (#8678)
  fix(mobile): resolve iOS pushes on a fresh connection and retry a stale one (#8722)
  ci(loadgen): publish buzz-loadgen as ghcr.io/block/buzz-loadgen (#8717)
  test(relay): fix main's build after the test-state helper rename (#8718)
  fix(db): keep lease releases durable and deflake PostgreSQL lane tests (#8676)
  test: dedupe collect_until_eose by event id and uncap two shell tests (#8677)
  ci: cache the Tauri apt .debs and fit a cold Windows Rust run in its cap (#8679)
  test(desktop): gate flaky E2E specs on the state they assume (#8681)
  test(desktop): settle composer edits and press hover-only row actions by keyboard (#8682)
  test(desktop): measure inline video controls against their player (#8683)
  relay: open the search pool lazily so a dead reader can't block boot (#8662)
  fix(mobile): address #8650 second-pass review (#8691)
  Revert "fix(mobile): push every message in your own Solo chats (#8615)" (#8690)
  fix: don't notify workflow owners through their attribution tag (#8694)
  ...

Signed-off-by: Ravneet Arora <rarora@squareup.com>
matt2e added a commit that referenced this pull request Oct 31, 2026
Units 6-9 of the 23-30 Oct desktop Playwright flake audit. All are
test-only. origin/main (1eb67bb, now including #8680) hasn't touched
these specs since fae3f03. No open PR touches the tests changed here:
#6134 (conflicting since 10-19) only swaps page.goto calls in three of
these files, #8187's hunk starts just after workflow-local-controls:151,
#5778 (draft, conflicting) creates projects-v3-screenshots from an
older base, and #8622, #7452, #7812, #8244 and #3342 edit other tests
in the same files.

Unit 6: measuring before an animation finishes

onboarding.spec.ts:1487, "hosted community address line stays within
the card for a long name"

Run: #8715 (Integration shard 1), which doesn't touch onboarding.
Signature: the centring check at :1545 expects <= 2 and receives 5.13.

Cause: "Connect and continue" ends in showPage("owned")
(WelcomeSetup.tsx:323), which mounts an OnboardingSlideTransition. Its
content slides in from translateX(48px) over 650 ms (components.css:521,
:676). fill() doesn't wait for stability, and the surface, input and
suffix boxes come from three boundingBox round trips, so they can be
read at different points of the slide.

Change: waitForAnimations(page) after the fill, before measuring.

onboarding.spec.ts:2289, "connected first-community profile keeps
navigation inside the card and balances the avatar editor"

Runs: main 40746677244 and 40925172215, and #8666 (Integration
shard 1). Signature: the spacing check at :2485 expects <= 4 and
receives 4.255.

Cause: the avatar dialog opens with MODAL_CONTENT_MOTION_CLASS, a 200 ms
zoom-in-95 (modalMotion.ts:5), and has a 250 ms height transition.
dialogBox is read in one round trip and the preview and editor boxes in
a later one, so a scale change between them skews the gap comparison.

Change: waitForAnimations(page) once the dialog is visible.

projects-v3-screenshots.spec.ts:280, "projects v3 workspace screenshot
states", failing in the expectInsetSection call at :893

Runs: main 39643531998, 40759209995 and 40969909906 (Smoke shard 8),
each passing on retry. Signature: the header's right inset at :326
expects 16 and receives 8. Four more runs fail the menu edge check at
:328 with fractional values (expected 984, received 975.6875):
39622053330, 39641311877, 40281448810 and 40856574572.

Cause: switching the context rail back from chat to repository narrows
it from the thread panel's 380 px to the repository's 280 px
(useProjectPanelWidths.ts:5, :17), animated by transition-[width]
duration-200 (ProjectContextRail.tsx:31). The workspace widens with it,
and expectInsetSection reads the workspace, header and menu boxes in
three separate round trips. The fractional widths are mid-transition
values.

Change: waitForAnimations(page) before that call, the only one that
follows a rail width change.

Unit 7: fill() racing a caret move the app makes a frame later

mention-recipients.spec.ts:376, "editing to a longer typed member
drops the original shorter reference", and :97, "ambiguous added
mention blocks editing before clearing the draft"

Runs: main 40969965981 (Smoke shard 5, a run with every earlier fix)
for :376. 40969858676 for :97, before the fixes. Signature: the
edit_message poll at :428 receives null. The error context shows the
edited message as "@scout hello @scout Jones hello": fill() appended.

Cause: entering edit mode sets the content, then focuses its end in a
requestAnimationFrame (MessageComposer.tsx:413). toHaveText passes as
soon as the content lands, before that frame. If the frame lands
between fill()'s select-all and its typing, the selection collapses and
the text is appended. #8682's replaceComposerText handles this, but
these two call sites (:115 and :403) were missed.

Change: replaceComposerText at both.

workflow-local-controls.spec.ts:151, "inserts template variables with
keyboard control and restores the caret"

Runs: main 40926393897 and 40957616146 (before the fixes), and #8239,
which changes no desktop files. Signature: :194 expects "Keep {{auth"
and receives "Hello {{trigger.text}}Keep {{auth".

Cause: selectVariable restores the caret in a requestAnimationFrame
(WorkflowTemplateTextarea.tsx:66). Setting the controlled value has
already put the caret at the end, so the selectionStart poll passes
before that frame runs. The frame then collapses fill()'s select-all.

Change: after the poll, wait two animation frames, then fill. This is a
frame barrier, not a timer. The app queued its frame before the
evaluate, so it runs first.

Unit 8: clicks that hit the wrong element

profile.spec.ts:1128, "renders agent profile ingress subviews from the
Playwright mock bridge", failing at :1543

Run: #8523, which changes no desktop files. Its base (ef4130d)
already includes #8681. Signature: archiveAgentRow.click() times out
because three stacked Sonner success toasts (data-expanded="true")
intercept the pointer.

Cause: the test's Stop and Start steps raise success toasts
(useAgentLifecycleActions.ts:51, :61). Sonner stacks them bottom right,
over the profile panel's action rows, and pauses their dismissal while
the pointer is over them. #8681 fixed this test's motion window, and
fixed the toast problem only in agent-availability:602.

Change: press the five action rows (:1528-:1547) through a local
pressActionRow helper: toBeEnabled, focus, Enter, as
agent-availability.spec.ts:744-745 does. toBeEnabled keeps the wait
click() gave, since these rows are disabled while a delete or archive
is pending.

config-bridge-screenshots.spec.ts:314, "07 — profile side panel —
Configuration section"

Runs: main 40967147375 (before the fixes), and #8235, which changes no
desktop files. Signature: "Model" isn't visible within 10 s at :352.
The error context shows nadia's panel ("Not managed on this device"),
not Charlie's.

Cause: the spec clicked .last() of the rows with a
[data-testid^="message-avatar-"] descendant. That matches only
UserAvatar's -image and -fallback children. Radix renders the image
only once it loads, and each fallback only after its own 200 ms delayMs
timer (UserAvatar.tsx:67, :119). Neither avatar in #agents has a URL,
so the matching set fills in row by row as the timers fire. Resolved
in between, .last() is nadia's row (seeded at -85 s), just above
Charlie's "reporting in." row (-80 s). The note blamed a misplaced
click; the code points at locator resolution instead.

Change: pick Charlie's row by its text, as profile.spec.ts does for
Charlie's other #agents message. Then check the panel's name row reads
charlie before opening Runtime, so a wrong panel fails there and says
so.

Unit 9: the createWorkflow copies (preventive)

navigation.spec.ts:153, "direct workflow detail links close back to
workflows"

Runs: 7 failures on main, the last at 29 Oct 16:12Z, none after #8681.
Signature: the Edit workflow dialog has no "Trigger: Message Posted"
at :176. Runs: 14 first-attempt failures in Smoke shard 7, all passing
on retry; on main 39827234118, 40718018673, 40789486699, 40802051064,
40840949170, 40891643205 and 40909291143.

Cause: the same race #8681 fixed in workflows.spec's createWorkflow.
Picking a channel opens the trigger pane (WorkflowDialog.tsx:702), and
for Message Posted the conditions editor expands "Message text" by
default (WorkflowTriggerConditions.tsx:157), rendering an input with
that label. After "Send Message", AnimatePresence mode="wait"
(WorkflowFormBuilder.tsx:907) keeps the trigger body mounted through
its exit and holds the step body back. An unscoped
getByLabel("Message text") can fill the trigger's filter. The step then
has no text, which yamlToFormState rejects (workflowFormTypes.ts:348),
so the Edit dialog opens in YAML mode (WorkflowDialog.tsx:88). That is
the signature the earlier audit recorded for navigation:153.

Change: wait for the step inspector's "Action" header and "Step
details" control, then fill inside the inspector, as workflows.spec
does.

workflows.spec.ts:716 ("captures the built editor at desktop and
narrow widths", fill at :726) and :820 ("pane routes use stable IDs and
Form/YAML changes stay synchronized", fill at :831) pick a channel and
then add a Send Message step the same way, with no failures in the
window. So the wait moves into one addSendMessageStep helper in
tests/helpers/workflows.ts, rather than four copies. Both specs'
createWorkflow and those two tests call it. A change to the inspector's
structure then needs one fix.

Left out: workflow-reaction-picker.spec.ts:203-204. The note listed it,
but its trigger is Reaction Added, whose condition fields are "Reaction
emoji", "Author" and "Message" (workflowConditionExpression.ts:40). No
trigger control there is labelled "Message text", so the unscoped fill
can only reach the step's textarea.

Verification

Not run locally: Playwright bogs this machine down, and the Flake Check
lane will repeat the touched specs. pnpm typecheck and pnpm check pass.
biome's 3 warnings are in untouched files. An ad hoc tsc ran over the
eight touched specs, using the desktop tsconfig with only those files,
and over HEAD's copies of them. Both gave the same errors: onboarding
16, navigation 7, profile 7, workflow-local-controls 7,
projects-v3-screenshots 2, and none in mention-recipients,
config-bridge-screenshots or workflows. The messages are identical once
line numbers are stripped, and all are pre-existing (no Node types,
import.meta.env, MockBridgeOptions drift and the like). Once the helper
moved to tests/helpers/workflows.ts, a rerun over navigation,
workflows and the helper gave navigation's same 7 and none in the other
two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e added a commit that referenced this pull request Nov 2, 2026
Units 2-5 of the 23-30 Oct desktop Playwright flake audit. All four are
test-only. origin/main (1eb67bb, now including #8680) hasn't touched
these specs since fae3f03. Of the open PRs, #8622 adds an unrelated
test at the end of navigation.spec, and #7812 (stale since 10-06) adds
tests elsewhere in three of them. #8680 didn't touch them, but it
carried navigation:472 mode A's product half (eb88a98), which is not
part of this commit. The code confirms each cause, so none is dropped.

navigation.spec.ts:472, "composer Buzz chip labels wrap without
orphaning their icons"

Runs: 11 jobs in the window, 6 after the earlier fixes, and 4 of the 7
main runs that carry every fix. Run 40970053997 (Smoke shard 7) failed
all three attempts.

Mode A: jobs 122971493553, 123066839967, 123572467962, 123819180597
and retry 2 of 123784825042. Signature: fragmentStep is null at :634.

Cause: rows key on renderKey = localKey ?? id. On main,
reconcileChannelWindowMessages keeps only pending sends and thread
replies from the cache, so a head refetch or live echo after the ack
drops the localKey. The key flips and the row remounts, losing the
220 px width the test set in an earlier evaluate.

Change: re-land the test half of closed PR #6852, which never merged.
Set the width and measure in one expect.poll, and set the width again
before each fragment measurement.

Mode B: jobs 123573056202, 123784199542, 123784825042 (twice),
123804428893, 123818728963, 123819543461, and 122924164125 (the
Infinity variant). Signature: the |tooltipCenter - cursorX| < 1 poll
at :667 receives 81.35 or Infinity. In failing attempt 40970053997 the
post-send settle (scrollTop 200, 296, 260, 302) took its last step
21 ms or less before focus, and the tooltip read closed 12 ms after
it. In the passing retry 40980513346, scrolling had stopped at least
15 ms before focus.

Cause: Radix Tooltip 1.2.16 closes when an ancestor of its trigger
scrolls (a capture-phase window listener). Reopening by pointer needs
a pointermove while hasPointerMoveOpenedRef is false, then the chip
provider's 500 ms delay. Any close clears that timer, and only
pointerleave resets the flag. useInlineTooltipPosition moves the
content only on pointermove and placement. So a scroll after focus can
leave the tooltip closed, or open without the fragment offset, and the
spec's single mouse.move never recovers it.

Change: before focusing the chip, poll until the timeline sits at its
floor (scrollHeight - clientHeight - scrollTop <= 1) at the start of
one poll, is still there two animation frames later, and its scrollTop
hasn't moved. Waiting only for scrollTop to hold for two frames isn't
enough: a timeline whose settle hasn't started yet reads as still, and
the failing run's last step landed under two frames before focus. The
settle's end state is the floor. useVirtualizedBottomSettle scrolls
only when Virtua's ResizeObserver reports a layout change, and after
the send and the 220 px wrap the timeline sits short of the floor
until the settle catches up. Focusing the chip and moving the pointer
over it don't change layout. The send's ack does. It removes the row's
"Sending…" status, and the confirmed row can lose its header, since a
pending row is never shown as a continuation (MessageRow.tsx:167). The
send's bottom intent is still armed, so that re-pins the timeline. So
before the floor poll, wait for the sent row's message-send-status to
reach count 0. If the ack has already landed, that wait is a no-op. The
standalone toBeVisible after focus is gone. positionOverFragment
asserts data-state matches /open/ instead, because a closing tooltip
stays visible through its exit animation.

No retry. Wrapping the measure, move and check in toPass, leaving and
re-entering the chip on each attempt, would also pass, but it retries
around the race instead of removing it. The ack and floor waits close
the scroll window. The only other way to scroll under the tooltip was a
remount that drops the row's 220 px width (mode A), and #8680's
retainAcknowledgedLocalKeys, on this branch's base (1eb67bb), keeps
the row's key. So positionOverFragment is a single pass, as on main,
plus mode A's width re-set: set the width, measure, move, assert open,
then poll the centre with the default timeouts.

huddle-transcription.spec.ts:637, "animates the responding agent with
the shared speaker ring"

Runs: 9 jobs, always Smoke shard 4 attempt 1. Main: 39896948198,
40718743531, 40732061485, 40840949170, 40906253761, 40969965981 and
40983029157. PRs: #8715, #8666. Signature: toBeFocused() at :697
reads inactive for all 14 polls over 5 s. At +3.0 s in the failure
video (40983029157, job 123827818084) the caret is in
"Message #huddle".

Cause: useComposerAutofocus re-runs when `focus` changes. That's
focusEnd, which depends on the editor, so it runs again once the
editor mounts. It backs off only for input, textarea, select and
contentEditable focus, so it takes focus from the focused trigger.

Change: wait for message-input to be focused before focusing the
trigger. The same spec already does this at :855.

scroll-history.spec.ts:1289, "fast middle-page scroll settles with
continuous mounted coverage"

Runs: 9 jobs in the window. After the fixes: main 40980404335 (Smoke
shard 9, job 123819180518), two attempts on #8720, and the
brain/native-account-setup branch. Signature: the viewportCoverage
poll expects < 100 and receives 200. The failure screenshot shows the
timeline at the floor. The 200 px is the bottom spacer
(--composer-overlay-height) under the last row.

Cause: TimelineMessageList arms useVirtualizedBottomSettle when the
channel first positions. Its ResizeObserver then pins to the floor
until listenForReaderScroll input or an accepted target cancels it.
handleScroll deliberately doesn't cancel, and a prepend delta doesn't
settle, so the intent survives the prepend and the spec's scrollTop
writes. Virtua's re-measure after the jump pulls the list back down.

Change: before the fast scroll, hover the timeline and wheel up 120 px.
Then poll until it is more than 32 px from the floor (the timeline's
at-bottom band) and scrollTop holds for two frames, so the writes stay
the last scroll.

custom-emoji.spec.ts:370, "reacting with a custom emoji renders via
the loopback media proxy"

Runs: main 40787609859 (10-28), main 40983029157 (Smoke shard 3, a
run with every fix), and #8241 (agent and auth files only). Signature:
the poll at :453 expects the inline "Add reaction" opacity "0" and
receives "1". The error context marks button "Open reactions"
[active].

Cause: the non-modal Radix Popover returns focus to its trigger when
the picker closes. FocusScope does that in a setTimeout(0) after the
content unmounts, and the content unmounts after its 100 ms exit
animation. That can land after the spec's blur. The action bar is
hidden by opacity only, so the trigger takes focus, and
group-focus-within/message keeps the button visible.

Change: wait for the "Open reactions" trigger to be focused, then
blur. The note suggested waiting for em-emoji-picker to reach count 0.
That still leaves the setTimeout window open. Focus reaches the
trigger only after unmount, so this wait covers both.

Verification

Not run locally: the Flake Check lane will repeat the touched specs.
pnpm typecheck and pnpm check pass. biome's 3 warnings are in
untouched files. An ad hoc tsc ran over the four specs, using the
desktop tsconfig with only those files, and over HEAD's copies of
them. Both gave the same error counts: navigation 7, huddle 17,
scroll-history 0, custom-emoji 2. The messages are identical once
line numbers are stripped, and all are pre-existing: no Node types,
findLast against the ES2022 lib, and the leadingBounds narrowing at
navigation:576. With the floor wait in place and the retry dropped,
navigation still gives the same 7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e added a commit that referenced this pull request Nov 3, 2026
message-target-highlight.spec.ts:79 is the most frequent unfixed
desktop flake. It failed in all 7 main runs whose SHA carries every
earlier flake fix (40969965981, 40970005965, 40970053997, 40980258899,
40980404335, 40980513346, 40983029157), and in 26 jobs since those
fixes landed, on main and on PRs #8235, #8239, #8241, #8666, #8680,
attempts failed. Only the two cases that pick mounted[2] fail: "same
channel, row loaded off screen" (15 jobs) and "other channel, row
further up" (11 jobs). The target scrolls in and flashes, then the
timeline lands on the newest message, so `stillOnScreen` at :179 reads
false (twice it was `flashedOnScreen` at :153).

Cause

The triage note blamed the thread panel opening. The failure videos
from run 40983029157 (Smoke shard 6) show that is a precondition, not
the trigger:
- Other channel: the panel opens at +0.36 s, the target flashes at
  +0.7 s, the older page prepends by +1.04 s, the target is still on
  screen at +1.47 s, and the timeline sits at the exact floor by
  +1.97 s.
- Same channel: the panel and the flash land in the same frame. The
  snap follows about 0.5 s later, with the load-older spinner still
  showing.

The panel opening never moved the target. A later floor report did:
TimelineMessageList's onScroll reporting within 32 px of the bottom,
during or after the older-page load that only a mounted[2] target sets
off. onVirtualizerAtBottomStateChange(true) reset the anchor to
at-bottom and set virtualizerAtBottomRef. With the panel open, the
split-panel settle, the viewport-resize settle or the next append
settle then pinned the timeline to the floor. The exact-floor landing
points at our own settleAtBottom rather than restored scroll. Which
Virtua layout pass emits the floor report is still unidentified.

Change

Accepting a virtual route target now sets targetHoldRef in
useAnchoredScroll. Only the two targetMessageId paths set it, on first
mount and in the later target effect, by passing holdTarget to
scrollToMessage. The public scrollToMessage, which MessageTimeline's
oldest-unread pill and find-in-channel stepping call, doesn't hold. It
also replaces an earlier hold, so a find jump after a deep link
follows the floor as on main. While the hold is set:
- shouldSettleForSplitPanel and shouldSettleVirtualizedViewportResize
  return false;
- a floor report still updates isAtBottom and the new-message count,
  but keeps the message anchor, so live appends don't settle either;
- a commit that changes the newest message sets isAtBottom false,
  clears virtualizerAtBottomRef and adds the rows after the previous
  newest one to the new-message count. Virtua reports bottom state
  only from scroll events, and an append below the viewport emits
  none. Without this, a floor report left isAtBottom true, so
  MessageTimeline hid the Jump to latest / N new pill while rows piled
  up out of sight (Review-Proven Rule 6). A realistic trigger is a
  mention or search hit on one of the last few messages in a busy
  channel: scrollToIndex clamps at the floor and emits that report.
  Clearing virtualizerAtBottomRef means a later release doesn't snap
  past the new rows either. The hold only acts on an arrival, so a
  reader at the floor with nothing new sees no pill. Counting rows
  rather than the length change matters: a cold target is merged in
  older than the head window, and the history page that fills the gap
  keeps both ends of the list. classifyTimelineMessageDelta calls that
  an append, but nothing arrived below, so it raises no pill. Neither
  does deleting the newest message;
- viewport reflow re-centres the target through
  virtualScrollToMessage, from the viewport-resize observer and when
  the split panel opens or closes. Rows rewrap around the target
  without a scroll event, so the target can drift, and a floor report
  from before the reflow can keep isAtBottom true while the newest
  rows drop below the fold. The re-centring scroll refreshes the
  bottom state. Virtua's scrollToIndex cancels any earlier one still
  in flight, so a burst of resizes only restarts its measure loop.
  The split-panel call fires only on a transition, not on mount or
  when the virtualizer API registers.

The hold is released by reader scroll input (a wheel, a touch drag, a
pointerdown on the scroller itself, a scroll key aimed at it), Jump to
latest, the reader's own send, and a channel change. The input
listeners are the ones that already cancel bottom intent in
useVirtualizedBottomSettle, now shared as listenForReaderScroll. A
release at the floor restores the at-bottom anchor, so the timeline
follows live messages again. onTargetReached does not release it.

The artifacts never show the panel pushing the target off screen, so
skipping the settle is the fix aimed at the CI failure. The re-centring
keeps the target's place and the pill right after a reflow.

Known limits:
- Programmatic scrolls (focus moves, scrollTop writes) don't release
  the hold. A find or unread-pill jump replaces it. A resize after
  such a scroll (Tab focus into a lower row, say) re-centres the
  target, away from the focused row.
- ChannelPane's composer-height settle still calls scrollToBottom
  within 32 px of the floor, which releases the hold.
- Live messages that arrive while the hold is set near the floor are
  not followed until reader input, Jump to latest or the reader's own
  send. The pill shows them, with their count.
- Under the hold, only rows after the previous newest message count.
  A live message that sorts just ahead of it (a same-second timestamp)
  raises no pill until a later row lands.

Unit before/after

useAnchoredScroll.lifecycle.test.mjs replays the CI sequence: accept a
route target, clear it, open the split panel, report the floor, resize
the viewport and append a live message. It asserts no bottom settle,
and that the panel opening and the resize each re-centre the target.
Then it asserts the append shows the pill (isAtBottom false, one new
message). A scrollbar pointerdown after that releases the hold, and a
resize neither settles nor re-centres. One case per release path
asserts the resize settle resumes and the re-centring stops. One of
them is a find jump: a public scrollToMessage on a held timeline, then
a floor report. A descendant pointerdown (row interaction) keeps the
hold, and a resize still re-centres. Reader input at the floor resumes
following a live append. Under a hold, a history page filling the gap
below the target, and then deleting the newest message, leave
isAtBottom true and the count at 0. A commit that adds more history
and one live row counts 1. The split-panel and viewport-resize policy
tables gain a held row.

Mutations over the lifecycle file and both policy tables (29 tests,
all pass intact):
- hold never set on accept (the old behaviour): 12 fail, including
  the CI-shaped hold case
- split-panel settle ignores the hold: 13 fail
- viewport-resize settle ignores the hold: 13 fail
- floor report resets a held anchor: 12 fail (the reset anchor is
  never re-centred)
- reader input does not release: 6 fail
- Jump to latest, own send, channel change don't release: 1 each
- release skips the at-bottom resync: 1 fails
- a held append doesn't raise the pill: 2 fail
- a held append leaves virtualizerAtBottomRef set: 1 fails
- every scrollToMessage holds (this commit's first draft): 1 fails
- a public jump keeps an earlier hold: 1 fails
- the held-append branch as first drafted (any non-prepend growth,
  counted by length): 1 fails, the gap-fill case
- a newest-id gate but a length-delta count: 1 fails
- a missing previous newest counts every row: 1 fails
- no rowsBelow > 0 guard: 1 fails
- rows counted from the previous newest, off by one: 2 fail
- no split-panel re-centre: 12 fail
- no viewport-resize re-centre: 12 fail
- re-centre ignores the hold: 1 fails

pnpm typecheck and pnpm check pass. All 46 tests in the files that
import the touched modules pass.

Only hosted CI can show

Playwright was not run locally. Whether the spec now holds in Smoke
shard 6 and in Flake Check's repeats is unverified, and so is the
unidentified Virtua floor report. scroll-history.spec.ts and the
other timeline specs need the full desktop smoke run to show the
hold doesn't regress bottom-follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e added a commit that referenced this pull request Nov 4, 2026
## Summary

A product fix for the most frequent unfixed desktop E2E flake,
`message-target-highlight.spec.ts:79`. A deep-linked message now holds
its place against bottom settles until the reader scrolls. This is one
of six PRs split from the reviewed `flakey-tests` branch (siblings
below).

- **Runs:** it failed in all 7 main runs whose SHA carries every earlier
flake fix (40969965981, 40970005965, 40970053997, 40980258899,
40980404335, 40980513346, 40983029157). It also failed in 26 jobs since
those fixes landed, on main and on PRs #8235, #8239, #8241, #8666 and
#8680. Only the two cases that pick `mounted[2]` fail: "same channel,
row loaded off screen" (15 jobs) and "other channel, row further up" (11
jobs). The target scrolls in and flashes, then the timeline lands on the
newest message. `stillOnScreen` at :179 reads false (twice it was
`flashedOnScreen` at :153).

### Cause

The failure videos (run 40983029157, Smoke shard 6) show that the thread
panel opening is a precondition, not the trigger. A later floor report
moved the target: `TimelineMessageList`'s `onScroll` reported within 32
px of the bottom, during or after the older-page load that only a
`mounted[2]` target sets off. `onVirtualizerAtBottomStateChange(true)`
reset the anchor to at-bottom. Then the split-panel settle, the
viewport-resize settle or the next append settle pinned the timeline to
the floor. Which Virtua layout pass emits the floor report is still
unidentified.

### Change

Accepting a virtual route target sets `targetHoldRef` in
`useAnchoredScroll`. Only the two `targetMessageId` paths set it, via
`holdTarget`. The public `scrollToMessage`, which the oldest-unread pill
and find-in-channel use, doesn't hold, and it replaces any earlier hold.
While the hold is set:

- The split-panel and viewport-resize settles are skipped.
- A floor report still updates `isAtBottom` and the new-message count,
but keeps the message anchor, so live appends don't settle either.
- A commit that changes the newest message sets `isAtBottom` false and
clears `virtualizerAtBottomRef`. It adds the rows after the previous
newest one to the new-message count. So the "Jump to latest" / "N new
messages" pill still appears for rows below the fold (Review-Proven Rule
6). A history page that fills the gap below a cold target raises no
pill, and neither does deleting the newest message.
- Viewport reflow re-centres the target through
`virtualScrollToMessage`. It runs from the resize observer and when the
split panel opens or closes.

The hold is released by reader scroll input: a wheel, a touch drag, a
pointerdown on the scroller itself, or a scroll key. Those listeners
already cancel bottom intent in `useVirtualizedBottomSettle`, and this
PR shares them as `listenForReaderScroll`. Jump to latest, the reader's
own send and a channel change also release it. A release at the floor
restores the at-bottom anchor, so the timeline follows live messages
again. `onTargetReached` does not release the hold.

### Known limits

- Programmatic scrolls (focus moves, `scrollTop` writes) don't release
the hold, but a find or unread-pill jump replaces it. A resize after
such a scroll re-centres the target.
- ChannelPane's composer-height settle still calls `scrollToBottom`
within 32 px of the floor, which releases the hold.
- Live messages that arrive under a hold near the floor aren't followed
until reader input, Jump to latest or the reader's own send. The pill
shows them, with their count.
- Under the hold, only rows after the previous newest message count. A
same-second row that sorts just ahead of it raises no pill until a later
row lands.
- Agent review raised two optional gaps, and scrolling recovers from
both:
- If the previous newest message is deleted and a new row arrives in the
same deferred commit, 0 rows are counted.
- A thread reply sent with "also send to channel" doesn't arm the bottom
intent, so it shows as "1 new message" instead of being followed.

### Related issue

None found.

### Testing

`useAnchoredScroll.lifecycle.test.mjs` replays the CI sequence: accept a
route target, clear it, open the split panel, report the floor, resize
and append a live message. It asserts no bottom settle, a re-centre on
the panel opening and on the resize, and the pill on the append. Further
cases cover:
- each release path;
- a find jump on a held timeline;
- a descendant pointerdown keeping the hold;
- a gap-fill page and deleting the newest message, neither of which
raises the pill.

The split-panel and viewport-resize policy tables each gain a held row.

Mutations were run over the lifecycle file and both policy tables (29
tests, all pass intact). Each of the 20 mutations fails at least one
test:

| Mutation | Tests that fail |
|---|---|
| Hold never set (the old behaviour) | 12 |
| Split-panel settle ignores the hold | 13 |
| Viewport-resize settle ignores the hold | 13 |
| Floor report resets a held anchor | 12 |
| No split-panel re-centre | 12 |
| No viewport-resize re-centre | 12 |
| Reader input doesn't release the hold | 6 |
| Held append raises no pill | 2 |

The commit message lists the other 12, each failing 1 or 2 tests.

`pnpm typecheck` and `pnpm check` pass. All 46 tests in the files that
import the touched modules pass.

Only hosted CI can show whether the spec now holds in Smoke shard 6 and
in Flake Check's repeats, because Playwright was not run locally. It
also has to show that `scroll-history.spec.ts` and the other timeline
specs keep bottom-follow working.

Pushed with `git push --no-verify`, because the pre-push suites fail
when the laptop is under load.

### Readiness and human check

An agent review of the combined `flakey-tests` branch (b432453af) found
no blockers. This changes desktop app behaviour, so it needs a human
in-app check before `buzz-review-completed` is added. Suggested check,
in `just dev` against a channel with a few pages of history:

1. Open a message link (a mention or search hit) to a message several
screens up, once in the same channel and once from another channel. The
message should flash and stay on screen after older history loads,
including when the thread panel opens or closes, or the window is
resized.
2. With the target still held, have someone post in the channel. The
timeline stays put, and the pill reads "1 new message".
3. Wheel-scroll to the bottom, or click Jump to latest. The next message
that arrives is followed.

### Siblings

- #8739 ci: Hermit fetch deadline and sccache fallback
- #8740 test(relay): thread_window aux-page hold and `api::media`
Postgres tests
- #8741 test(mobile): RunnerTests title fade and alert wait
- #8743 test(desktop): four specs racing scroll settles and focus
- #8744 test(desktop): five settle classes and remote-owned-mentions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
tee8z added a commit to voltagecloud/buzz that referenced this pull request Nov 4, 2026
…sh loadgen to the fork

Three follow-ups the merge's CI found:

- desktop/src-tauri/Cargo.lock: regenerating from upstream's lock rolled
  h2, negentropy, rtrb and ppmd-rust back below the versions our #105
  pinned for RUSTSEC-2026-0258, -0365 and -0274. Restore them with
  `cargo update --precise`. All three cargo-deny policies pass.
- ForumComposer.tsx: still passed isChannelAutocompleteOpen, which the
  merge removed with the rest of #27 in favor of upstream's block#8680. Take
  upstream's file. `tsc --noEmit` passes.
- loadgen-image.yml (new upstream): publish to the running repository's
  GHCR namespace, overridable with GHCR_LOADGEN_IMAGE, as #24 did for
  the other image workflows. The fork cannot write ghcr.io/block.

Signed-off-by: Tee8z <tee8z@protonmail.com>

This branch was previously deployed

1 inactive deployment
codex-review — a3f579cd Deployed Oct 30, 2026 by matt2e via Run Codex Security Review #10832
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

codex-security-review-current The posted Codex security review matches its recorded range.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants