Skip to content

fix(inbox): keep the list's place when returning in the same session - #2134

Merged
kalvinnchau merged 6 commits into
mainfrom
inbox-loses-position
Nov 5, 2026
Merged

kalvinnchau merged 6 commits into
mainfrom
inbox-loses-position

Conversation

@matt2e

@matt2e matt2e commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Returning to Inbox in the same session put the conversation list back at the top. A user who scrolled down, opened a row, followed the thread's #channel link and pressed Go back saw the thread tab again, but the list was at 0 and the selected row was off-screen. With this change, the list returns to where it was.

This is one of two independent PRs split from one branch. The empty-filter reset is in #2135. Either can merge first.

Before and after

inbox-scroll-restore-before-after.mp4

Left: main @ 67e404917. Right: this branch @ 3e7a41a48. Both run the same scripted take on the local fixture app and relay (Chromium, 1100×720, light theme): scroll the list, open "SOC2 evidence", follow the thread's #Beta link, then Go back. The dark box at the bottom left is a demo overlay, not app UI. It reports the list's scroll offset and whether the selected row is in view.

Before After
List offset when leaving Inbox 469 px 469 px
List offset after Go back 0 px 469 px
Selected row after Go back off-screen in view

What the user sees

  • Before: coming back to Inbox, through Back or the sidebar, always starts the list at the top.
  • After:
    • Coming back in the same session restores the list's last scroll offset once its rows render.
    • If the list has already been scrolled by then, the restore is skipped.
    • If the selected row was in view when the user left and has since moved out of view (for example a fresh reply moved it), it is scrolled back into view (block: "nearest").
    • If the user had scrolled away from the selected row, their offset wins. Rows shifting beneath it, or rows pushing it out of view while Inbox was shown, do not pull the list back.
    • A relaunch still starts at the top.

What changed

All behaviour is in src/bundled/inbox/InboxPage.tsx:

  • Session memory: the session-lifetime selections entry (a WeakMap keyed by RelaySession, which already held the opened tab and target) gains scrollTop and selectedShown. The effect that saves the opened tab and target now spreads the existing entry, so it no longer drops the new fields.
  • Save on scroll: the list's scroll container gets a ref and an onScroll handler. The handler saves scrollTop and whether the selected row overlaps the list's visible box. Because it saves on every scroll event, the last offset survives however the page is torn down.
  • Restore and record: a layout effect runs on every InboxList commit.
    • On the first commit with rows for a given scroller, while the list is still at 0, it applies the saved offset. The "still at 0" check exists because compositor scrolling can move the list before its scroll event fires.
    • It then reveals the selected row, but only if selectedShown says it was in view and selectedInView says it is not now.
    • After every commit it records selectedShown, because selecting a row, or rows arriving above it, fires no scroll event.
  • Helper: selectedInView(list) checks whether the li[data-selected] row overlaps the list's box.
  • Docs: docs/inbox.md describes the behaviour.

How it was tested

All runs were local (macOS) on 3e7a41a48, unless noted:

  • Vitest: bin/pnpm exec vitest run src/bundled/inbox/InboxPage.test.tsx passed 126/126. That is main's 124 plus 2 new cases:

    • same-session restore, including a Drafts round trip;
    • a scroll made before an empty list's rows arrive is not overridden.
  • Typecheck: bin/pnpm typecheck (tsc --noEmit) is clean.

  • Browser: bin/pnpm test:browser --project=chromium --project=webkit --no-deps tests/browser/inbox*.spec.mjs ran the ten Inbox suites in both engines. 73/74 passed, including all ten new cases (five variants × two engines). The one failure was WebKit inbox-archive.spec.mjs:520 ("selected row button advances and menu closes without losing the original draft"): the draft read back empty. That spec is unchanged here, and the case fails on main too. In a throwaway worktree, I added an assertion right after its editor.fill() and ran it 20 times. It failed 8/20 with main's InboxPage.tsx and 5/20 with this branch's, and every failure was at that new assertion. So the typed text never reached the editor. This is Playwright's WebKit fill() silently dropping text, not Inbox losing a draft.

  • Browser cases added: five variants in tests/browser/inbox-tabs.spec.mjs, × Chromium and WebKit. Each leaves Inbox through a thread tab's channel name and comes back with Go back:

    • kept: the plain round trip.
    • moved: a fresh reply moves the visible selected row while the user is away.
    • away: the user scrolled away from the selected row before leaving.
    • shifted: scrolled away, and a new row arrives above while away.
    • drifted: rows pushed the selected row out of view while Inbox was shown.

    These need a browser because the offset, the row's overlap with the list box, and compositor scroll timing need real layout. jsdom reports zero rects. No coverage was removed.

  • Fail-before: I ran this branch's tests against main's InboxPage.tsx.

    • Browser: kept, away and shifted fail in both engines. The list is at 0 px, where the test expects at least 1172 px.
    • moved and drifted pass on main. A list that never restores starts at the top, where their expectations already hold. These two guard the reveal rule instead. On the pre-split branch, each failed on the commit before its fix: restore with no reveal, and a reveal that recorded visibility only on scroll.
    • Vitest: the same-session restore case fails on main (0, expected 120). The early-scroll case is a guard and passes on main.
  • Review: five agent review rounds ran on the pre-split branch (tip 80e26c473). The last found no blockers. This commit and fix(inbox): offer a reset whenever filters leave the list empty #2135 recombine to that reviewed tree exactly; the only difference is where one existing Vitest case sits in the file.

  • Exercised: the recording above, in the fixture app.

Known caveats

  • Per-commit layout read: while a row is selected, the restore layout effect calls getBoundingClientRect twice on every InboxList commit (the list and the selected row). The browser lays out before paint anyway, and nothing commits on scroll or drag, so we judged it acceptable. It is still new steady-state work on every commit.
  • Accepted window-resize edge case: a window resize can push the selected row out of view with no commit and no scroll. In that case selectedShown stays true, so Back reveals the row instead of keeping the exact offset. Covering it would need a ResizeObserver; that was left out deliberately.
  • jsdom blind spot on the reveal branch: the reveal and the per-commit record depend on real rects, so only the browser variants cover them. Vitest covers save and restore of the offset, not the reveal decision. The browser variants rely on indirect barriers (visible row text and counts) to know the relay events have landed. They held 50/50 with --repeat-each=5 on the pre-split branch.
  • Diff size: the production diff is +63/−5 in InboxPage.tsx, plus one changed line in docs/inbox.md. The planning note budgeted about 40 production lines, and that budget covered both this fix and the filter reset. This PR alone exceeds it.
    • The extra is the row-reveal rule that the review rounds iterated on: selectedShown, selectedInView and the per-commit record.
    • The cheaper alternative is the original plan: restore the offset only, with no reveal. That loses only the "moved" case, where a reply moves the visible selected row while the user is away.
  • Not covered: the desktop app (WKWebView), dark theme, narrow widths and enlarged text. The video and browser runs use the fixture build.

Status

Draft. A human has not tried it yet, so buzz-review-completed is not set. To try it in the app:

  1. Open an Inbox with enough conversations to scroll.
  2. Scroll down and open a row.
  3. Click the thread's #channel name, then Go back. The list should be where you left it, with the row still selected and in view.
  4. Repeat after scrolling the selected row out of view before leaving. The list should keep your offset and not jump back to the row.
  5. Relaunch. Inbox should start at the top.

🤖 Generated with Claude Code

kalvinnchau pushed a commit that referenced this pull request Nov 2, 2026
When an Inbox filter choice left the list empty, most empty views
offered no way back. Two examples are Filters → **DMs** on an Inbox with
no DMs, and Status → **Archived** with nothing archived. Only Archived +
Unread only had a button, "Show all archived conversations". It cleared
Unread only and left the user in an empty Archived view. Otherwise the
user had to reopen the filter menu and work out which choice to undo.
With this change, every empty view caused by a filter or by Archived
offers a reset.

This is one of two independent PRs split from one branch. The Inbox
scroll-position fix is in #2134. Either can merge first.

## Before and after


https://github.com/user-attachments/assets/2ff6e6df-badf-4aac-809d-9fb98fa1aa60

Left: `main` @ `67e404917`. Right: this branch @ `e532565c2`. Both run
the same scripted take on the local fixture app and relay (Chromium,
1100×720, light theme): Filters → **DMs**, then reset; Status →
**Archived**, then return to Inbox. Before has no button, so its half
resets through the filter menu at the same moments.

| | Before | After |
|---|---|---|
| Empty DMs view | text only | **Clear filters** |
| Empty Archived view | text only | **Show Inbox** |
| Focus after the reset | Inbox filters trigger (via the menu) | Inbox
filters trigger |

## What the user sees

- **Clear filters:** any non-default Type, From or Unread only choice
that empties the list now offers **Clear filters**. It resets those
three and keeps Status.
- **Show Inbox:** an empty Archived view without those filters offers
**Show Inbox**. It goes through the same handler as Status → Inbox in
the menu.
- **Focus:** either button returns focus to the Inbox filters trigger.
- **No action when there is nothing to reset:** an unfiltered, empty
Inbox still shows text only.
- **Replaced button:** "Show all archived conversations" (Archived +
Unread only) is gone. That state now offers Clear filters.

## What changed

- `src/bundled/inbox/InboxPage.tsx` (`+22/−9`):
- The Status menu's inline handler becomes `showView`, which the menu
and Show Inbox share. It cancels a pending Retry, clears the selection
and sets `show`.
- `filtered` is true when `activity !== "all"`, `senderFilter !==
"everyone"` or `unreadOnly` is set.
- The `EmptyState` action renders when `filtered || show ===
"archived"`. Clear filters resets activity, sender and attention. Show
Inbox calls `showView("inbox")`. Both then focus the filters trigger
(`fallbackControl`).
- **Design system:** this reuses the existing `EmptyState` action slot
and the same `Button size="sm" variant="ghost"` that the removed reset
used. There is no new component, token or variant.
- `docs/inbox.md`: the sentence describing the old single reset now
describes the new ones.

## How it was tested

All runs were local (macOS) on `e532565c2`, unless noted:

- **Vitest:** `bin/pnpm exec vitest run
src/bundled/inbox/InboxPage.test.tsx` passed 127/127. That is main's
124, with one case rewritten and 3 added:
- **Rewritten:** the archive test now walks Archived + filters → **Clear
filters** (Status stays Archived, focus on the trigger). It then
restores the last row → empty Archived → **Show Inbox** (focus on the
trigger, Status back to Inbox). Finally it empties an unfiltered Inbox
and checks there is no action.
- **Added:** an `it.each` that clears each of DMs (Type), Agents (From)
and Unread only from an emptied Inbox, checking focus and that every
filter is reset.
- **Typecheck:** `bin/pnpm typecheck` (`tsc --noEmit`) is clean.
- **Browser:** `bin/pnpm test:browser --project=chromium
--project=webkit --no-deps tests/browser/inbox*.spec.mjs` ran the ten
Inbox suites in both engines. 64/64 passed. That is the same suites
without the scroll PR's ten new cases, and includes WebKit
`inbox-archive.spec.mjs:520`, a case that is flaky on main because
Playwright's WebKit `fill()` can drop text (see the scroll PR). No
browser case was added or changed. The behaviour is the empty-state
matrix and focus, which the component tests cover in jsdom. No browser
journey referred to the removed button's label.
- **Fail-before:** I ran this branch's `InboxPage.test.tsx` against
main's `InboxPage.tsx`. All four new or rewritten cases fail: the three
`it.each` cases and the archive walk. Main has no Clear filters or Show
Inbox button.
- **Review:** five agent review rounds ran on the pre-split branch (tip
`80e26c473`). The last found no blockers. This commit and #2134
recombine to that reviewed tree exactly; the only difference is where
one existing Vitest case sits in the file.
- **Exercised:** the recording above, in the fixture app.

## Known caveats

- **Two-step reset in an empty filtered Archived view:** Clear filters
always keeps Status. Suppose Archived plus a Type, From or Unread only
filter is empty, and the archive itself is also empty. Then Clear
filters lands on an empty Archived view, and **Show Inbox** is a second
click. That matches the agreed behaviour, where Clear filters never
changes Status, but it takes two clicks to get back to a populated
Inbox.
- **Design checks not done:** the recording is Chromium, light theme,
1100×720. Dark theme, narrow widths, enlarged text and keyboard-only use
were not checked in the app.
- **Not covered:** the desktop app (WKWebView) was not exercised.

## Status

Draft. A human has not tried it yet, so `buzz-review-completed` is not
set. To try it in the app:

1. In Inbox, choose a Type or From that matches nothing. **Clear
filters** should appear. Click it: rows return and focus lands on the
filters trigger.
2. Turn on Unread only after everything is read. **Clear filters**
should appear and reset it.
3. Choose Status → **Archived** with nothing archived. **Show Inbox**
should appear. Click it to return to Inbox.
4. Choose Archived plus a filter that matches nothing. Clear filters
should keep you in Archived.
5. With no filters, archive everything. The empty Inbox should show no
button.

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

Signed-off-by: Matt Toohey <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
@matt2e
matt2e force-pushed the inbox-loses-position branch 2 times, most recently from 7beb5e0 to 370336a Compare November 3, 2026 12:43
matt2e added a commit that referenced this pull request Nov 4, 2026
…2138)

## Problem

`tests/browser/reactions-polish.spec.mjs:110` "reaction emojis and hit
areas grow together without clipping" failed on PR #2134 (run
[41412759993](https://github.com/block/buzz-app/actions/runs/41412759993),
Chromium shard 8/9) at line 208:

```
expect(await pill.boundingBox()).toEqual(hitArea)
- "y": 203.9375
+ "y": 247.6875     (x, width, height unchanged)
```

The failure screenshot and every screencast frame before and after the
read show the 👍 pill at y ≈ 204. The diff in that PR touches only Inbox
files, and the test passed 16/16 in the other failed-job logs of the
previous 11 hours and 30/30 locally.

## Cause

The emoji fixture's custom-emoji media is not served, so each
custom-emoji `<img>` fails on load and `CustomEmoji` retries it 2 s
later (`RETRY_DELAYS_MS[0]`). The retried `<img>` briefly sits in
Chromium's broken-image state (`complete`, `naturalWidth 0`): its alt
text wraps inside the 16 px inline-block, whose baseline then follows
its last internal line, so the message text and the reaction row beneath
drop by several lines until React swaps the image for its literal.

The CI trace lines this up: first emoji-media requests at :20.93, the
hover's dynamic import of `reaction-name` + emoji data at :22.13 (a ~400
ms page stall), the retry's second round of emoji-media requests at
:23.02, and the failing `boundingBox` read resolving at :22.97–23.00,
inside that window.

A probe (`MutationObserver` reading the pill's rect after each DOM
mutation, 8× CDP CPU throttling) reproduced the shift in 4/4 runs ~1.0 s
after the hover: the paragraph grew 23 → 89 px while the re-inserted
`<img>` was in the broken state; the shift reverted when React replaced
it with `:party:`.

## Fix

Same remedy #2068 applied to "reaction previews center above each emoji
bubble": serve artwork for `**/emoji-media/**` and wait for the first
reaction image to load before measuring, so no retry runs while the test
reads layout. Test-only; 10 lines.

## Validation

- Probe with the route: 0 shifts in 6/6 throttled runs (vs 4/4 without).
- `bin/pnpm test:browser --project=chromium --project=webkit --no-deps
tests/browser/reactions-polish.spec.mjs`: 18 passed (9 tests × 2
engines) at ed17dc6.
- Biome clean; `git diff --check` clean.
- The assertion failure itself does not reproduce locally under
throttling alone (the read lands ~1 s before the retry without CI's
import stall), so fail-then-pass evidence is for the mechanism via the
probe, not for line 208.

Deferred: hosted CI on this branch; human run of the spec.

## Follow-up (not in this PR)

The layout jump is a product behaviour, not only a fixture artefact:
whenever a custom emoji image fails (and on each 2 s / 10 s / 30 s
retry) the message's line box grows for a frame or more. `overflow:
hidden` on `.customEmoji` would make the broken-image inline-block's
baseline its bottom edge and remove the jump; left out to keep this
change test-only. Two other journeys in this file (`:473`, `:551`) also
load the wrap fixture without a route and could hit the same class if
they ever read layout across a retry.

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

Signed-off-by: Matt Toohey <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
@matt2e
matt2e marked this pull request as ready for review November 4, 2026 11:39
@matt2e
matt2e requested a review from a team as a code owner November 4, 2026 11:39

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Star Lord’s automated source review via Wes’s account, with independent Rocket, Mantis and Groot coverage.

No actionable findings in the reviewed change. The session-scoped scroll state preserves the existing tab/selection fields, restores a remounted list without overriding an earlier user scroll, and keeps the existing retry/focus ownership. Reviewed the changed tests, callers and tab host; the public description and sampled demo frames showed fixture content with no identified disclosure issue.

Head: 370336a73f532382340639aa1e79658b0fbd97dc
Base: 90ce5e37a851909cc0e5fd4da2f469010e4eb7bf

Source-only review: no PR code, tests, builds or apps were run, and CI was not checked. Browser/native scroll geometry, covered-tab behavior, accessibility and per-commit layout cost remain unverified. The demo records an earlier revision. The stated resize caveat remains a documented limitation. This COMMENT is not approval or merge authorization.

@matt2e
matt2e force-pushed the inbox-loses-position branch 2 times, most recently from f1c84d1 to aad2821 Compare November 5, 2026 01:04
matt2e and others added 5 commits November 5, 2026 12:33
Returning to Inbox in the same session, through Back or the sidebar,
restores the list's last scroll offset once its rows render, unless the
list has already been scrolled; compositor scrolling can move it before
its scroll event, so only a list still at 0 is placed. A relaunch still
starts at the top.

The offset lives in the session-lifetime `selections` entry beside the
opened tab and target, saved from scroll events so any teardown keeps
it. Beside it is one fact: whether the selected row overlapped the
list's visible box. The scroll handler records it, and the restore
layout effect records it after every commit, since selecting a row or
rows arriving above it scroll nothing. On return, after the saved
offset is applied, the selected row is revealed (nearest) only if it
was in view on leaving and is not now. The offset the user chose
otherwise wins: scrolling away from the row, rows shifting beneath it,
or rows pushing it out of view while Inbox showed do not pull the list
back.

Vitest covers same-session restore, including a Drafts round trip, and
a compositor scroll made before an empty list's rows arrive. A browser
case in inbox-tabs, which needs real layout, leaves through a thread
tab's channel name and comes Back in five variants: kept, a fresh reply
moving the visible selected row, scrolled away from it, scrolled away
as rows shift, and rows pushing it out of view while Inbox shows. Each
fails without its part of the fix and passes in both engines.

Signed-off-by: Matt Toohey <[email protected]>
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
The restore layout effect recorded whether the selected row was in view
after every InboxList commit. When a thread tab moved to Inbox's main
pane is selected, PanelWorkspace marks the list's pane data-covered and
inert, and Panels.module.css gives it content-visibility: hidden so the
retained tab skips layout. The row keeps data-selected, because isOpen
holds for a tab in either pane, so each unread, profile or feed commit
read the scroller's and the row's rects and forced layout of the
subtree the pane was skipping.

The record now returns early while the list sits under [hidden] or
[inert], matching PageTabs' closest() checks. What it recorded before
the list was covered still holds. Base UI modals mark outside content
with aria-hidden and data-base-ui-inert rather than inert, so an open
dialog does not suppress the record. The restore and reveal path is
unchanged.

This has no user-visible effect, so the coverage is a Vitest case. It
moves the opened tab to the main pane, delivers a mention while the
list is covered and asserts that nothing inside the list is measured.
The case fails without the early return, because the scroller and the
selected row are both measured, and passes with it. A browser case
cannot observe the difference. A list still mounted records again on
the commit that uncovers it. Inbox left while covered returns covered,
because the moved tab stays selected. The reveal's scrollIntoView then
has no effect in Chromium or WebKit, whatever was recorded. The saved
offset is still restored.

The duplicated scroller and scrollTo test helpers now live at module
scope beside rows().

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
When Inbox returned under a thread tab moved to its main pane, the
restore layout effect ran while the list was inert and given
content-visibility: hidden. Its scrollTop write took effect, but
revealing the selected row scrolled nothing, and placed was set, so
uncovering never tried the reveal again. The write's scroll event then
measured the covered list and overwrote the selectedShown recorded
before it was covered. So when a fresh reply had moved the selected
row out of view, docs/inbox.md's promise to bring it back was false on
this return path.

The whole effect now returns early while the list sits under [hidden]
or [inert], not only the record. Selecting the list's tab is a PageTabs
state change, which hands InboxList a new isOpen closure. The restore
and reveal therefore run in the commit that removes inert, before
paint, from an inert scroller still at 0 and the pre-cover
selectedShown. A list still mounted while covered behaves as before,
because placed is already set. Inert set imperatively by
useModalBoundary clears without an InboxList commit, so a restore under
it waits for the next commit. That is unlikely during a return
navigation.

The scroll handler no longer measures a covered list either. Rows
arriving above a scrolled, covered list can still anchor it and fire a
scroll event, so it saves only scrollTop then. A covered() helper
beside selectedInView now holds the check for both callers.

A sixth inbox-tabs variant moves the thread tab to the main pane,
follows its #Beta link, delivers a reply that moves the row, goes Back,
waits for the covered list to render the moved row, then selects the
Inbox tab and expects the row in view. It fails at that last assertion
in Chromium and WebKit with the previous tip's InboxPage.tsx, and also
with only the scroll-handler change, and passes with this fix. The
covered Vitest case now anchors the covered list and asserts that its
scroll is not measured. It then remounts and asserts that scrollTop
stays 0 while covered and is the saved 160 once the Inbox tab is
selected. Without the deferral, scrollTop is already 160 while
covered. Without the scroll-handler change, the list and row are
measured.

docs/inbox.md now says the position is restored once the list shows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
A list placed itself with the session's saved offset on the first
commit that showed rows, even when the rows appeared because the user
changed a filter. With Unread only on, scrolling the list and leaving
saved that offset. If the conversations were then read elsewhere, Inbox
returned to an empty list. Pressing Clear filters, or turning Unread
only off, then opened All activity at an offset saved for a different
list. Had the list emptied while it was mounted, the browser would have
clamped it to 0 and the scroll event would have saved that.

setFilter now records the mounted scroller as placed. The scroller
stays mounted while the empty state shows inside it. Every filter
choice, Clear filters and Show Inbox go through setFilter, so rows a
filter change reveals keep the list where it is. While Drafts is shown
the scroller is unmounted, so leaving Drafts still restores, and
returning to Inbox later still restores the offset scrolled last.

setFilter is unchanged by #2135, which added Clear filters and Show
Inbox, so the fix applies on either merge order. A Vitest case empties
an Unread only list away from Inbox, returns, turns Unread only off
through the toolbar toggle, and expects the list at 0. It then scrolls,
returns again and expects that offset restored. It fails at 0 (with
120) without the fix and passes with it. It also passes with #2135's
InboxPage.tsx hunks reversed. An ad hoc variant that presses Clear
filters also fails without the fix and passes with it.

docs/inbox.md now says a refiltered list is not restored.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
The setFilter comment said the saved offset belonged to the list as it
was filtered, and docs/inbox.md said a refiltered list is not restored.
Both imply a saved offset never carries to another filter. It does when
the user leaves Drafts for a different type. With All activity at 400,
choosing Drafts unmounts the scroller, and choosing Mentions calls
setFilter while scroller.current is null, so placed is cleared and
Mentions mounts and restores 400.

That is intended. The rule is that a filter change keeps the list's
current offset. A mounted All to Mentions switch keeps 400 because the
browser leaves scrollTop alone. f30398f07 applies the same rule to a
list that is empty, at 0, when it is refiltered: it stays at 0 rather
than taking the saved offset. While Drafts shows there is no scroller,
so the list's current offset is the saved one, and the remounted list
restores it. Dropping the saved offset there would make leaving Drafts
for another type start at the top, unlike the same switch made
without passing through Drafts.

The setFilter comment and docs/inbox.md now state that rule, including
the Drafts case. The scroller and placed refs, with their comment, move
above setFilter, which uses them. setFilter only runs from event
handlers, so they were always initialised in time, but a reader no
longer has to look 250 lines down for them. Behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
@matt2e
matt2e force-pushed the inbox-loses-position branch from aad2821 to 5927f58 Compare November 5, 2026 01:33

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Star Lord’s automated source review via Wes’s account, with independent Rocket, Mantis and Groot coverage.

No new actionable defects found in the follow-up commits and relevant base integration. The covered-tab guard matches the host’s inert panels, defers placement until the list is uncovered, and preserves the last visible-row state. The filter-change marker prevents delayed rows from restoring an obsolete offset while preserving the documented Drafts return behavior. Reviewed the added tests, tab lifecycle, row geometry ownership and existing retry/focus paths.

Optional: refresh the description’s change summary and test-case counts for the covered-tab and filter-change additions. Its reported runs and demo are explicitly tied to an older revision and do not validate this head.

Head: 5927f58a3a9c5212a36529326bf3d096f06e85e8
Base: b95f9df80d970610a720baec68ca93258251f7c3

Source-only: no PR code, tests, builds or apps were run; CI was not checked. Browser/native geometry, accessibility and performance remain unverified. The documented resize caveat remains. The public description and sampled fixture-video frames showed no identified disclosure issue; the clip is unchanged from the earlier review, not current-head workflow evidence. This COMMENT is not approval or merge authorization.

The "around a row that moved under a main-pane tab" case focused the side
thread tab and pressed Alt+Shift+ArrowLeft as soon as the Inbox row was in
view. Opening a thread at a message reveals it: useMessageReveal focuses
the selected row on an animation frame once the thread has loaded. The
case only waited for the thread's channel button, which shows before
that, so on a slower runner the move came first. It put focus on the tab
in the main pane, and the reveal then took it, so toBeFocused on the
moved tab never held.

On Linux WebKit in the pinned Playwright image the case failed 14 of 16
repeats; a focus log showed the tab's focusin followed by a focus() call
from the reveal onto the thread row 50 to 130 ms later. macOS was fast
enough for the reveal to land first.

The case now waits for that row to hold focus before moving the tab, so
the move is the last focus change, as it is for a reader who acts after
the thread has opened. No assertion, timeout or retry changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Star Lord’s automated source review via Wes’s account.

No new actionable findings in the follow-up from reviewed head 5927f58a3a9c5212a36529326bf3d096f06e85e8. The complete tree difference is eight added lines in tests/browser/inbox-tabs.spec.mjs: the covered-list scenario now waits for the opened message to receive focus before moving focus to the tab and sending the move shortcut. This matches the shared reveal lifecycle and preserves the existing scroll, selection and tab-focus assertions. No test cases or production code changed.

Reviewed under the small-follow-up solo exception; the previously reviewed feature and integration code are unchanged. Public description inspected; its linked demo video was not inspected.

Head: 0319f9bedf386368f644d9edd2d3836dcd98f335
Base: b95f9df80d970610a720baec68ca93258251f7c3

Source-only review; no tests, builds or app execution performed locally. Hosted-check snapshot: required CI and completed checks passed; Windows native validation was skipped. This does not establish native behavior, timing improvement, or elimination of all flakes.

@kalvinnchau
kalvinnchau merged commit dc19d72 into main Nov 5, 2026
46 checks passed
@kalvinnchau
kalvinnchau deleted the inbox-loses-position branch November 5, 2026 17:43
cheng-block added a commit to cheng-block/buzz-app that referenced this pull request Nov 5, 2026
…ettings-shift-esc

* upstream/main: (40 commits)
  feat(conversation): let plugin rows manage messages without visiting a channel (block#2512)
  feat(github): make side-panel link opening optional (block#2629)
  feat(messages): show a blurred thumbnail until an image loads (block#2556)
  fix(relay): give large roster and read-state reads a 30s deadline (block#2627)
  fix(user-status): show a status emoji's :name: on hover where the full status shows (block#2384)
  fix(inbox): keep the list's place when returning in the same session (block#2134)
  fix(messages): pass verified labels to the forward recipient picker (block#2633)
  feat(messages): forward a message to a person (block#2167)
  docs(agents): design relay-facing paths for the whole fleet at once (block#2620)
  Route debug sessions through the credential helper (block#2541)
  perf(search): skip no-op slow-search resets on each keystroke (block#2605)
  perf(agents): dedupe channel members only for dm connection setup (block#2603)
  perf(search): keep the search tab row across palette keystrokes (block#2588)
  perf(sidebar): skip the hidden actions menu in solo and team headers (block#2582)
  perf(search): rank each palette name once per render (block#2606)
  perf(sidebar): skip re-rendering unchanged animated channel rows (block#2572)
  feat(settings): follow the system clock and let people hide message times (block#2440)
  feat(plugins): repository access rules for Git imports (block#2484)
  feat(desktop): add Settings to the macOS app menu (block#2594)
  Preserve and renew external app approval (block#2464)
  ...

Signed-off-by: Cheng Peng <[email protected]>
Co-authored-by: Cursor <[email protected]>
johnmatthewtennant added a commit that referenced this pull request Nov 5, 2026
…thumbnails

* origin/main: (120 commits)
  feat(conversation): let plugin rows manage messages without visiting a channel (#2512)
  feat(github): make side-panel link opening optional (#2629)
  feat(messages): show a blurred thumbnail until an image loads (#2556)
  fix(relay): give large roster and read-state reads a 30s deadline (#2627)
  fix(user-status): show a status emoji's :name: on hover where the full status shows (#2384)
  fix(inbox): keep the list's place when returning in the same session (#2134)
  fix(messages): pass verified labels to the forward recipient picker (#2633)
  feat(messages): forward a message to a person (#2167)
  docs(agents): design relay-facing paths for the whole fleet at once (#2620)
  Route debug sessions through the credential helper (#2541)
  perf(search): skip no-op slow-search resets on each keystroke (#2605)
  perf(agents): dedupe channel members only for dm connection setup (#2603)
  perf(search): keep the search tab row across palette keystrokes (#2588)
  perf(sidebar): skip the hidden actions menu in solo and team headers (#2582)
  perf(search): rank each palette name once per render (#2606)
  perf(sidebar): skip re-rendering unchanged animated channel rows (#2572)
  feat(settings): follow the system clock and let people hide message times (#2440)
  feat(plugins): repository access rules for Git imports (#2484)
  feat(desktop): add Settings to the macOS app menu (#2594)
  Preserve and renew external app approval (#2464)
  ...

Signed-off-by: John Tennant <[email protected]>

# Conflicts:
#	src-tauri/src/relay.rs
agude-sq added a commit to agude-sq/buzz-app that referenced this pull request Nov 5, 2026
* origin/main: (28 commits)
  fix(mcp): accept create scopes in durable approvals (block#2650)
  feat(bestie): Bestie tab and Remembers you for a hosted Bestie (block#2478)
  fix(relay): p-tag a one-to-one DM's agent partner on new messages (block#2475)
  fix(agents): show each message author's username and display name (block#2628)
  fix(agents): preserve visible edits in conversation history (block#2355)
  fix(canvas): keep todo edits from approving unrelated instructions (block#2350)
  fix(messages): keep messages in their canonical channel (block#2349)
  NEXUS-4165: Add Thinking to agent creation (block#2409)
  feat(plugins): let a plugin paint a backdrop behind the shell (block#2437)
  feat(search): lead empty quick open with unread conversations (block#2634)
  NEXUS-4170: Allow external apps to create private Solo chats (block#2411)
  fix(acp): count grouped thinking levels when checking a synced level (block#2640)
  feat(conversation): let plugin rows manage messages without visiting a channel (block#2512)
  feat(github): make side-panel link opening optional (block#2629)
  feat(messages): show a blurred thumbnail until an image loads (block#2556)
  fix(relay): give large roster and read-state reads a 30s deadline (block#2627)
  fix(user-status): show a status emoji's :name: on hover where the full status shows (block#2384)
  fix(inbox): keep the list's place when returning in the same session (block#2134)
  fix(messages): pass verified labels to the forward recipient picker (block#2633)
  feat(messages): forward a message to a person (block#2167)
  ...

# Conflicts:
#	tests/browser/settings.spec.mjs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants