Repository navigation
fix(inbox): keep the list's place when returning in the same session - #2134
Conversation
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]>
7beb5e0 to
370336a
Compare
…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]>
wesbillman
left a comment
There was a problem hiding this comment.
🤖 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.
f1c84d1 to
aad2821
Compare
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]>
aad2821 to
5927f58
Compare
wesbillman
left a comment
There was a problem hiding this comment.
🤖 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
left a comment
There was a problem hiding this comment.
🤖 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.
…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]>
…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
* 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
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
#channellink 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.What the user sees
block: "nearest").What changed
All behaviour is in
src/bundled/inbox/InboxPage.tsx:selectionsentry (aWeakMapkeyed byRelaySession, which already held the opened tab and target) gainsscrollTopandselectedShown. The effect that saves the opened tab and target now spreads the existing entry, so it no longer drops the new fields.onScrollhandler. The handler savesscrollTopand 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.InboxListcommit.selectedShownsays it was in view andselectedInViewsays it is not now.selectedShown, because selecting a row, or rows arriving above it, fires no scroll event.selectedInView(list)checks whether theli[data-selected]row overlaps the list's box.docs/inbox.mddescribes 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.tsxpassed 126/126. That is main's 124 plus 2 new cases:Typecheck:
bin/pnpm typecheck(tsc --noEmit) is clean.Browser:
bin/pnpm test:browser --project=chromium --project=webkit --no-deps tests/browser/inbox*.spec.mjsran the ten Inbox suites in both engines. 73/74 passed, including all ten new cases (five variants × two engines). The one failure was WebKitinbox-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 itseditor.fill()and ran it 20 times. It failed 8/20 with main'sInboxPage.tsxand 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 WebKitfill()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: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.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
getBoundingClientRecttwice on everyInboxListcommit (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.selectedShownstays true, so Back reveals the row instead of keeping the exact offset. Covering it would need aResizeObserver; that was left out deliberately.--repeat-each=5on the pre-split branch.+63/−5inInboxPage.tsx, plus one changed line indocs/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.selectedShown,selectedInViewand the per-commit record.Status
Draft. A human has not tried it yet, so
buzz-review-completedis not set. To try it in the app:#channelname, then Go back. The list should be where you left it, with the row still selected and in view.🤖 Generated with Claude Code