feat(components): cap mobile session-group previews at 5 rows - #323
Conversation
The mobile home list rendered every Session in every bucket, so one project with forty Sessions pushed every other project and worktree below the fold — the desktop sidebar has capped its groups at MAX_VISIBLE_SESSIONS for exactly this reason and mobile never got it. Each bucket now previews MOBILE_CHAT_PREVIEW_MAX_ROOTS (5) top-level rows behind a "Show all (N)" / "Show less" toggle, reusing the existing sessions.showAll / sessions.showLess keys. It counts TOP-LEVEL rows through countOpenedByTreeRoots and applies maxRoots AFTER rootRank, so the preview truncates the pinned-first / latest-activity order rather than reshuffling it, and never splits an opener from the Sessions it opened. Scoped by an explicit capGroupPreviews prop: workspace home passes it, the in-project list does not — you drilled into that page to read exactly that list. The cap is also suspended while archived multi-select is active, because "select all" operates on every id in the list and a capped surface would let the user confirm a permanent delete of rows it never showed. The list is deliberately not virtualized. VList must own the scroll element, but the home screen owns it (pull-to-refresh translates that subtree, the dock-collapse listener reads it, hidden home tabs stay mounted for scroll position), and its contain: strict would strip the liftAboveEdgeSwipeZone escape the opener chevron depends on. Measured on 200 Sessions across 12 projects at 393x852: 60 rendered rows instead of 200, 555 DOM nodes instead of 1219, median mount-to-stable 142ms instead of 201ms. Model: claude-opus-5[1m]
Ablated every mechanism this branch added and kept only what a test or a measurement defends. The ablations that found something: - Deriving the cap from `groupBy !== 'none'` instead of the explicit `capGroupPreviews` prop passed the whole suite. The prop stays — the project page is `none` for heading reasons, not cap reasons, so the derivation couples two unrelated decisions and fails silently in both directions. That was a test hole rather than dead code, so the opt-out test now also pins that the cap is independent of grouping, and it fails under the derivation. - The `scrollIntoView` layout effect looked redundant until the scenario was right: with the toggle near the document end, scrollTop clamping alone keeps it on screen. With content below it and `overflow-anchor: none` (Chromium emulating Safari, which ships no scroll anchoring) removing the effect drops the toggle from y=328 to y=-68. Kept, comment now cites the measurement. - The `AnimatePresence` key does what it claims: with the key carrying only `archived`, collapsing a 14-row bucket still reports 14 rows a frame later, animating out for 400ms. Comment now states the measured 14, not an extrapolated 35. Tests 10 -> 9. The exact-at-cap boundary folded into the main preview test as a third bucket, so one list now covers over / exactly at / under the cap. The scroll test asserts which element the list asks to keep on screen instead of tallying calls to a patched prototype. Dropped an unreachable `if (toggle)` guard in the expanded story. Nothing was removed for being uncovered: the multi-select suspension and the overflow gate each fail tests when ablated, and the shared-accessors constant stays as drift prevention, matching the desktop `SESSION_ROW_OPENED_BY_TREE_ACCESSORS`. Model: claude-opus-5[1m]
Ablation resultsEvery mechanism this branch adds was removed one at a time and re-checked.
A — the one that found a real holeReplacing the prop with Load-bearing and untested. The project page is So the prop stays, the rationale is now in the code, and the opt-out test was B — measured, because the first measurement was wrongMy first attempt showed the effect doing nothing (347 -> 310 with and without
This also confirms the code comment: Chrome's native anchoring does cover it, so C — verified, and the number correctedWith the key carrying only E — kept, but not because it is load-bearingInlining the accessors changes no behaviour and breaks no test. It is not dead Test pruning: 10 -> 9
Nothing was deleted for being uncovered. The remaining 9 each fail under a The scroll test is the one judgment call against the letter of "no mock Cleanup
|
`overflowsPreview` depended on `preview`, which `MobileChatList` rebuilds as an object literal for every bucket on every render, so the memo never hit. Fixing that dependency alone does not help: measured with a counter around `countOpenedByTreeRoots`, a state-only re-render (tapping one bucket's toggle) still recomputed both buckets, because `groupChats` runs in the parent's render body and hands every card a freshly built `chats` array too. A memo keyed on `chats` therefore cannot hit in this component at all, so the wrapper is removed rather than repaired — a cache that caches nothing costs a deps array and a comparison on top of the scan it fails to avoid, and reads as a guarantee that is not there. `previewEnabled` replaces the two `preview != null` reads so nothing depends on that literal's identity. Behaviour is unchanged and the scan is O(rows) per bucket, dwarfed by the row renders the cap removes. No test added: the only assertion available would be a call tally, which is the shape deleted in the previous commit. The pre-existing `treeNodes` memo above misses for the same upstream reason. Left alone here: memoizing `groupChats` is not a one-liner because it takes `nowMs = getServerNow()` and buckets by date, so caching it would freeze Today/Yesterday headings until the session list changes. Model: claude-opus-5[1m]
The mobile home list rendered every Session in every bucket, so one project
with forty Sessions pushed every other project and worktree below the fold.
The desktop sidebar has capped its groups at
MAX_VISIBLE_SESSIONSfor exactlythis reason; mobile never got it.
Each bucket now previews
MOBILE_CHAT_PREVIEW_MAX_ROOTS(5) top-level rowsbehind a Show all (N) / Show less toggle, reusing the existing
sessions.showAll/sessions.showLesskeys. It counts TOP-LEVEL rows throughcountOpenedByTreeRootsand appliesmaxRootsafterrootRank, so thepreview truncates the pinned-first / latest-activity order rather than
reshuffling it, and never splits an opener from the Sessions it opened.
1. Why 5, not the 3 that was originally suggested
A phone already fits roughly eight rows. At 3, every project becomes a two-tap
read and the toggle is back in your way on every single bucket — the cap starts
costing more attention than the clutter it removes. 5 is the point where a
project's recent work still reads as a list, while an idle project costs one
line more than its heading.
It also keeps the two platforms saying the same thing about the same workspace:
5 is the desktop
MAX_VISIBLE_SESSIONS. The value is a single exported constant(
MOBILE_CHAT_PREVIEW_MAX_ROOTS) if we want to tune it later.2. Why home only
Scoped by an explicit
capGroupPreviewsprop. Workspace home passes it; thein-project list does not.
The structural reason is that the project page has almost no grouping to cap.
chat-landing.tsx:6153force-pinschatGroupBy="none"there regardless of thehome tab's view-mode atom, so that surface has at most two buckets: Pinned,
and one unlabeled flat tail. A cap would therefore not be trimming one project
among many — it would be hiding the tail of the single list the page exists to
show.
That matters specifically for the worktree-visibility ask this change came from.
Each
ConversationRowcarries a per-rowWorktreeIcondriven byisWorktree(
mobile-project-screen.tsx:602), so inside one project the rows are exactlywhere worktree identity is visible. Capping the flat tail at 5 would hide rows
6..N and their worktree markers — the opposite of the request. On home, the same
cap does the reverse: it stops one project from burying the other projects and
worktrees.
3. Why no virtualization
Two structural blockers, both already documented in
mobile/AGENTS.md:VListmust own the scroll element, but the home screen owns it.Pull-to-refresh translates that whole subtree, the dock-collapse listener
reads it, and hidden home tabs stay mounted so their scroll position survives
a tab round-trip.
contain: strictonVListmakes the list its own stacking context,which would strip the
liftAboveEdgeSwipeZoneescape the opener fold chevrondepends on to clear the 48px edge-back swipe strip.
So the cap bounds the row count instead. Measured in Chromium at 393x852 with
200 Sessions across 12 projects, capped vs uncapped:
(Median of 7 runs. The mount figure includes a fixed ~80ms frame-settling
window in the harness, so the actual work is closer to 62ms vs 121ms.) This is
the cost that mattered:
atoms/doc-meta.tsflushes metadata in batches of 50per
setTimeout(0), and every batch re-renders the whole list, so first paintwas O(rows) x O(N/50). Each row is a framer-motion
motion.divwithlayout,so the row count is the multiplier.
Happy to revisit virtualization if the cap proves insufficient, but it would
need the scroll-ownership and stacking-context problems solved first, and this
change makes it much less urgent.
4. Out of delegated scope, kept deliberately: data-loss guard
The cap is suspended while archived multi-select is active. This was not
part of the request, and I would normally leave it out — but "select all"
operates on every id in the list, not on the rendered rows. A capped surface
would let the user confirm a permanent delete of Sessions it never showed them.
Group collapse already had this shape; adding a cap on by default would have
made it the common case. Flagging it explicitly so it gets reviewed on its own
merits rather than riding along as layout polish.
Design notes
Verified in a real browser at 393x852:
That column carries a row's status indicator and an opener's fold chevron, so
a chevron there would read as one of those. Same construction as the desktop
sidebar's "Show all".
min-h-11full-width: below rows (15px foreground) andheadings (14px semibold) in the hierarchy, while keeping a row-sized thumb
target.
active:bg-muted/40press wash, matchingConversationRow. Noscale(0.96)— scaling a full-bleed row reads wrong.
AnimatePresencekey, so "Show less" remountsinstead of running 35 simultaneous 0.4s height exits.
scrollIntoView({ block: 'nearest' })from a layouteffect. Chrome's native scroll anchoring would cover this, but WebKit has
never shipped
overflow-anchorand iOS is the target. Verified: the togglestays in view (347px -> 310px) after collapsing a 14-row bucket.
:focus-visibledoes not match on tap(
boxShadow: none), so the global inset focus ring is keyboard-only. Kept.Testing
pnpm checkpasses end to end: typecheck, lint, tests, i18n, and all threeboundary guards.
packages/components/tests/mobile-chat-list-preview-cap.test.tsxcovering the cap, opt-out, root counting vs raw-row counting, opener groups
surviving the cut, pinned-first truncation, the shared-atom isolation, the
multi-select suspension, and the scroll anchoring. No real sleeps — the
long-press test drives
vi.useFakeTimers().GroupPreviewOverflowandGroupPreviewExpanded.Related
Same batch, all independently reviewable and mergeable — no overlapping files
with this PR: #317 (mobile eager-sync staging), #319 (Live Activity throttling),
#320 (login white flash).
Model: claude-opus-5[1m]