From 614f794460ebd737f40e2815b982182b8ae51c61 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 3 Sep 2026 02:33:32 +0800 Subject: [PATCH 1/3] feat(components): cap mobile session-group previews at 5 rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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] --- .../src/components/mobile/AGENTS.md | 27 ++ .../components/mobile/mobile-chat-list.tsx | 191 ++++++++++- .../components/mobile/mobile-home-screen.tsx | 5 + .../src/stories/MobileChatList.stories.tsx | 93 +++++- .../mobile-chat-list-preview-cap.test.tsx | 306 ++++++++++++++++++ 5 files changed, 614 insertions(+), 8 deletions(-) create mode 100644 packages/components/tests/mobile-chat-list-preview-cap.test.tsx diff --git a/packages/components/src/components/mobile/AGENTS.md b/packages/components/src/components/mobile/AGENTS.md index b546d2c28..d7ee5d1f9 100644 --- a/packages/components/src/components/mobile/AGENTS.md +++ b/packages/components/src/components/mobile/AGENTS.md @@ -167,6 +167,33 @@ embedded` lazy-imported from `../tasks/tasks-workspace.tsx` (`embedded` - Lists: `mobile-chat-list.tsx`, `mobile-swipeable-row.tsx` (iOS-Mail-style row actions; also `touch-action: pan-y`), `mobile-filter-pill-bar.tsx`, `mobile-filter-drawer.tsx`, `mobile-inline-picker.tsx`. +- Group preview cap: under `MobileChatList capGroupPreviews` every bucket + previews at most `MOBILE_CHAT_PREVIEW_MAX_ROOTS` (5 — the desktop + `MAX_VISIBLE_SESSIONS`, copied rather than imported so the mobile bundle skips + `session-list.tsx`) and ends in a `sessions.showAll` / `sessions.showLess` + toggle. Workspace home passes it, because its buckets compete for the screen; + the in-project list does NOT — the user drilled in to read exactly that list + and there is nothing else there for a cap to make room for. It counts + TOP-LEVEL rows via `countOpenedByTreeRoots`, and `maxRoots` applies AFTER + `rootRank`, so a preview truncates the pinned-first / latest-activity order + rather than reshuffling it and never splits an opener from the Sessions it + opened. The expanded set is per + bucket id in `MobileChatList`, NEVER the shared + `sidebarCollapsedOpenedBySessionsAtom` — that atom is opener fold state the + drawer sidebar must agree on. The cap is SUSPENDED while archived multi-select + is active: "select all" takes every id in the list, so a capped surface would + confirm a permanent delete of rows it never showed. The preview state joins the + `AnimatePresence` key so "Show less" remounts instead of running a 0.4s height + exit per hidden row, and collapsing `scrollIntoView({ block: 'nearest' })`s the + toggle from a LAYOUT effect — the rows that vanish are above it. The toggle's + leading 16px slot stays EMPTY (it only aligns the label to the row-title x): + that column carries a row's status indicator and an opener's fold chevron, so a + chevron there would read as one of those. + 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 `contain: strict` would strip the `liftAboveEdgeSwipeZone` + escape the opener chevron needs. The cap bounds the row count instead. - Opened-by tree: `MobileChatListCard` runs the shared `lib/session-opened-by-tree.ts` model over EACH bucket, so a Session created by the `lody_session_create` MCP tool indents under its opener the same way diff --git a/packages/components/src/components/mobile/mobile-chat-list.tsx b/packages/components/src/components/mobile/mobile-chat-list.tsx index 4e9d8a3b6..53c73fb1c 100644 --- a/packages/components/src/components/mobile/mobile-chat-list.tsx +++ b/packages/components/src/components/mobile/mobile-chat-list.tsx @@ -1,4 +1,11 @@ -import { Fragment, useMemo, useState, type ReactNode } from 'react'; +import { + Fragment, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import { ChevronRight, @@ -11,7 +18,11 @@ import { import { useAtomValue, useSetAtom } from 'jotai'; import { useTranslation } from 'react-i18next'; import { getServerNow } from '@lody/shared'; -import { buildOpenedBySessionTree, pinnedFirstRootRank } from '@/lib/session-opened-by-tree'; +import { + buildOpenedBySessionTree, + countOpenedByTreeRoots, + pinnedFirstRootRank, +} from '@/lib/session-opened-by-tree'; import { sidebarCollapsedOpenedBySessionsAtom, toggleSidebarCollapsedOpenedBySessionAtom, @@ -86,6 +97,41 @@ export const PINNED_BUCKET_ID = '__pinned__'; section above — no heading; just the remaining rows. */ const FLAT_UNPINNED_BUCKET_ID = '__flat-unpinned__'; +/* Rows a bucket shows before it collapses behind "Show all (N)". + Same value as the desktop sidebar's `MAX_VISIBLE_SESSIONS` + (`../session-list.tsx`) — deliberately duplicated rather than imported so + the mobile bundle does not pull in the desktop session-list module. + + Why 5 and not 3: a phone screen already fits ~8 rows, so 3 turns every + project into a two-tap read and puts the toggle back in the user's way on + every bucket. 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 — and it + keeps the two platforms saying the same thing about the same workspace. + + The cap counts TOP-LEVEL rows only (`maxRoots`), so a preview never splits + an opener from the Sessions it opened. */ +export const MOBILE_CHAT_PREVIEW_MAX_ROOTS = 5; + +/* Tree accessors shared by the render pass and the overflow count, so the + "does this bucket overflow" question is answered by the same nesting model + that decides what renders. */ +const CHAT_OPENED_BY_TREE_ACCESSORS = { + getId: (chat: MobileConversationItem) => chat.id, + getOpenedBySessionId: (chat: MobileConversationItem) => + chat.openedByRowSessionId ?? chat.openedBySessionId, +} as const; + +/* Per-bucket preview state. Owned by `MobileChatList` (one flag per bucket id) + rather than by the shared `sidebarCollapsedOpenedBySessionsAtom`: that atom + is the opener FOLD state, which the drawer sidebar and this list must agree + on. How many rows a mobile bucket previews is a property of this surface + alone and must not leak into the sidebar. */ +export type MobileChatPreviewState = { + /** True once the user expanded this bucket past the preview cap. */ + showAll: boolean; + onToggle: () => void; +}; + /* Fixed date-bucket ids (ordered newest → oldest). Month buckets use `date:month:YYYY-MM` and sort after these named ones. */ export const DATE_BUCKET_TODAY = 'date:today'; @@ -371,6 +417,7 @@ export function MobileChatListCard({ archived = false, onRequestDelete, selection, + preview, secondaryField = 'branch', }: { chats: MobileConversationItem[]; @@ -392,6 +439,10 @@ export function MobileChatListCard({ toolbar's "select all" + count stay in sync across grouped sections. */ selection?: ChatSelectionState; + /** Caps the bucket at {@link MOBILE_CHAT_PREVIEW_MAX_ROOTS} top-level rows + and appends a "Show all (N)" toggle. Omit to render every row (what a + standalone card without a `MobileChatList` around it does). */ + preview?: MobileChatPreviewState; /** @deprecated Conversation rows are single-line; branch/project meta is no longer shown. Kept for call-site compatibility. */ secondaryField?: 'branch' | 'project'; @@ -419,18 +470,34 @@ export function MobileChatListCard({ opener in the drawer and in the mobile list can never disagree. */ const collapsedOpeners = useAtomValue(sidebarCollapsedOpenedBySessionsAtom); const toggleCollapsedOpener = useSetAtom(toggleSidebarCollapsedOpenedBySessionAtom); + /* A capped bucket renders at most `MOBILE_CHAT_PREVIEW_MAX_ROOTS` top-level + rows. `maxRoots` is applied AFTER `rootRank`, so the preview keeps the + pinned-first / latest-activity order the bucket already promises — it + truncates that order rather than reshuffling it. */ + const showAll = preview?.showAll ?? false; + const capped = preview != null && !showAll; const treeNodes = useMemo( () => buildOpenedBySessionTree(chats, { - getId: (chat) => chat.id, - getOpenedBySessionId: (chat) => chat.openedByRowSessionId ?? chat.openedBySessionId, + ...CHAT_OPENED_BY_TREE_ACCESSORS, isCollapsed: (openerId) => collapsedOpeners[openerId] === true, /* Bucket order is pinned-first then latest activity; rank an opener by its freshest opened Session so nesting cannot bury a just-updated row under a stale opener. */ rootRank: (chat) => pinnedFirstRootRank(chat.latestMessageAt ?? 0, chat.isPinned), + ...(capped ? { maxRoots: MOBILE_CHAT_PREVIEW_MAX_ROOTS } : {}), }), - [chats, collapsedOpeners] + [capped, chats, collapsedOpeners] + ); + /* Gate the toggle on TOP-LEVEL rows, matching what the cap actually limits: + five openers plus the Sessions they opened is not an overflowing bucket, + so it must not sprout a "Show all" the tap would not change. */ + const overflowsPreview = useMemo( + () => + preview != null && + countOpenedByTreeRoots(chats, CHAT_OPENED_BY_TREE_ACCESSORS) > + MOBILE_CHAT_PREVIEW_MAX_ROOTS, + [chats, preview] ); return ( /* Flat list — no rounded card shell or inter-row dividers. Rows @@ -449,8 +516,17 @@ export function MobileChatListCard({ per-row exit animations don't fire for every row at once (which felt like a freeze on lists with > 5–10 rows). The intentional single-row archive case still works because that - path only removes one item from the same key bucket. */} - + path only removes one item from the same key bucket. + + "Show less" is the same shape of bulk removal: collapsing a 40-row + project back to five would otherwise run 35 simultaneous 0.4s height + exits. The preview state joins the key so that transition remounts + instead — and `initial={false}` means expanding adds its rows with no + enter animation either. */} + {treeNodes.map((node) => { const conversation = node.item; /* Same builder the sidebar rows use, so the disclosure's aria-label @@ -552,10 +628,74 @@ export function MobileChatListCard({ ); })} + {overflowsPreview && preview ? ( + + ) : null} ); } +/* Tail affordance of a capped bucket. Sits inside the bucket body, directly + under the last row and above the next group heading, so it reads as the end + of THIS list rather than as chrome between sections. + + Geometry: the label starts on the row-title x (px-4 + the 16px leading slot + + gap-2.5 = 42px) via an empty spacer, exactly as the desktop sidebar's + "Show all" does. The slot stays EMPTY on purpose — it is the column that + carries a row's status indicator and an opener's fold chevron, so a chevron + here would read as one of those. The label alone says which way it goes. + + The type is quieter than a session title (15px medium foreground) and than a + group heading (14px semibold muted), which is the hierarchy: rows, then + sections, then this. Full-width `min-h-11` keeps a thumb target the size of + a row. */ +function MobileChatPreviewToggle({ + showAll, + totalCount, + onToggle, +}: { + showAll: boolean; + totalCount: number; + onToggle: () => void; +}) { + const { t } = useTranslation(); + const buttonRef = useRef(null); + const wasShowingAll = useRef(showAll); + /* Collapsing removes rows ABOVE this button, so on a long bucket the tap + target — and everything the user was reading — jumps off the top of the + viewport. Pull it back into view minimally (`nearest` is a no-op when it + is already visible). A layout effect runs after the rows are removed and + before paint, so the correction never renders as a visible scroll jump. + Chrome's native scroll anchoring would cover this on its own; WebKit has + never shipped `overflow-anchor`, and iOS is the surface this list is for. */ + useLayoutEffect(() => { + if (wasShowingAll.current && !showAll) { + buttonRef.current?.scrollIntoView?.({ block: 'nearest' }); + } + wasShowingAll.current = showAll; + }, [showAll]); + const label = showAll + ? t('sessions.showLess', 'Show less') + : t('sessions.showAll', 'Show all ({{count}})', { count: totalCount }); + return ( + + ); +} + /** * Build ordered list sections. Pinned sessions always form their own * top group (when any exist), regardless of `groupBy`. Unpinned rows @@ -803,6 +943,7 @@ export function MobileChatList({ onPermanentDelete, selectionLabels, rowSecondaryField, + capGroupPreviews = false, privateLabel, privateHelpAriaLabel, onPrivateHelp, @@ -837,6 +978,13 @@ export function MobileChatList({ multi-select flow. The promise lets the list wait before clearing its selection state. */ onPermanentDelete?: (chatIds: string[]) => void | Promise; + /** Caps every bucket at {@link MOBILE_CHAT_PREVIEW_MAX_ROOTS} top-level rows + behind a "Show all (N)" toggle. On for the workspace home list, where + buckets compete for the screen and one busy project would otherwise push + every other project and worktree below the fold. Off inside a single + project's page: the user drilled in to read exactly that list, and there + is nothing else there for a cap to make room for. */ + capGroupPreviews?: boolean; /** Copy for the multi-select toolbar + confirmation alert-dialog. All keys are optional with reasonable Chinese defaults; callers can override to localize. */ @@ -867,6 +1015,22 @@ export function MobileChatList({ return next; }); }; + /* Buckets the user expanded past the preview cap. Every bucket starts capped + at `MOBILE_CHAT_PREVIEW_MAX_ROOTS`, which is the whole point: without it a + project with forty Sessions pushes every other project and worktree off + the screen. This is deliberately NOT the shared opener-fold atom — see + `MobileChatPreviewState`. */ + const [expandedBucketIds, setExpandedBucketIds] = useState>( + () => new Set() + ); + const toggleBucketPreview = (bucketId: string) => { + setExpandedBucketIds((prev) => { + const next = new Set(prev); + if (next.has(bucketId)) next.delete(bucketId); + else next.add(bucketId); + return next; + }); + }; /* Pending permanent-delete confirmation. Drives one shared alert-dialog for two entry points: the multi-select toolbar (`fromSelection: true`, so confirming also exits selection mode) @@ -1019,6 +1183,17 @@ export function MobileChatList({ const expanded = !collapsedBucketIds.has(id); const onToggle = () => toggleBucket(id); const compactTop = index === 0; + /* Multi-select drives a permanent delete, and "select all" operates on + every id in the list. Capping the rows while it is active would let + the user confirm a delete of Sessions the surface never showed them, + so selection mode renders the buckets in full. */ + const bucketPreview: MobileChatPreviewState | undefined = + !capGroupPreviews || selectionToolbarActive + ? undefined + : { + showAll: expandedBucketIds.has(id), + onToggle: () => toggleBucketPreview(id), + }; const trailing = showFirstGroupTrailing && !trailingConsumedByFlatHeading && index === 0 ? firstGroupTrailing @@ -1051,6 +1226,7 @@ export function MobileChatList({ archived={archived} onRequestDelete={selectionEnabled ? requestSwipeDelete : undefined} selection={selectionState} + preview={bucketPreview} secondaryField={resolvedSecondaryField} /> @@ -1139,6 +1315,7 @@ export function MobileChatList({ archived={archived} onRequestDelete={selectionEnabled ? requestSwipeDelete : undefined} selection={selectionState} + preview={bucketPreview} secondaryField={resolvedSecondaryField} /> ) : null} diff --git a/packages/components/src/components/mobile/mobile-home-screen.tsx b/packages/components/src/components/mobile/mobile-home-screen.tsx index 116c1612c..9597bf767 100644 --- a/packages/components/src/components/mobile/mobile-home-screen.tsx +++ b/packages/components/src/components/mobile/mobile-home-screen.tsx @@ -2289,6 +2289,11 @@ function ChatsFlatView({ chats={visible} groupBy={groupBy} groupLabels={labels.chatGroupLabels} + /* Home aggregates every project and worktree into one scroll, so each + bucket previews its latest rows and offers the rest. Without it a + single busy project owns the screen — the whole reason the cap + exists. The in-project list deliberately does not pass this. */ + capGroupPreviews /* Active list is flat — no "全部对话" section label. Only the archived surface keeps a heading so the mode is obvious. */ flatHeading={archived ? (labels.archivedChatsHeading ?? '归档对话') : undefined} diff --git a/packages/components/src/stories/MobileChatList.stories.tsx b/packages/components/src/stories/MobileChatList.stories.tsx index 9bcdadddc..b3c21900b 100644 --- a/packages/components/src/stories/MobileChatList.stories.tsx +++ b/packages/components/src/stories/MobileChatList.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react'; import { useState } from 'react'; -import { fn } from 'storybook/test'; +import { fn, userEvent, within } from 'storybook/test'; import { MobileChatList, @@ -231,17 +231,86 @@ const openedByChats: MobileConversationItem[] = [ ...baseChats, ]; +/* The clutter the preview cap exists for: one busy worktree with twelve + Sessions, then two other worktrees that a flat list would push below the + fold. `busy-9` is an opener with two opened Sessions — expand the bucket and + its group arrives intact, because the cap counts TOP-LEVEL rows rather than + rendered ones. */ +const overflowingChats: MobileConversationItem[] = [ + ...Array.from({ length: 12 }, (_, index): MobileConversationItem => ({ + id: `busy-${index}`, + title: `修复 presence 分片 #${index + 1}`, + kind: 'local', + branchName: `fix/presence-shard-${index + 1}`, + latestMessageAt: now - (index + 1) * 0.4 * hour, + ageLabel: `${(index + 1) * 24}m`, + machineId: 'zx-macbook', + projectKey: 'zx-macbook:lody-main', + projectLabel: 'lody (main)', + ...(index === 0 ? { isWorking: true } : {}), + })), + { + id: 'busy-9-opened-a', + title: '子会话:分片回归测试', + kind: 'local', + latestMessageAt: now - 4.2 * hour, + ageLabel: '4h', + machineId: 'zx-macbook', + projectKey: 'zx-macbook:lody-main', + projectLabel: 'lody (main)', + openedBySessionId: 'busy-9', + openedByRowSessionId: 'busy-9', + }, + { + id: 'busy-9-opened-b', + title: '子会话:补 changelog', + kind: 'local', + latestMessageAt: now - 4.4 * hour, + ageLabel: '4h', + machineId: 'zx-macbook', + projectKey: 'zx-macbook:lody-main', + projectLabel: 'lody (main)', + openedBySessionId: 'busy-9', + openedByRowSessionId: 'busy-9', + }, + ...Array.from({ length: 3 }, (_, index): MobileConversationItem => ({ + id: `wt-review-${index}`, + title: `评审 worktree 里的改动 #${index + 1}`, + kind: 'local', + branchName: `review/${index + 1}`, + latestMessageAt: now - (6 + index) * hour, + ageLabel: `${6 + index}h`, + machineId: 'zx-macbook', + projectKey: 'zx-macbook:lody-review', + projectLabel: 'lody (review worktree)', + })), + { + id: 'wt-docs-0', + title: '文档站改版', + kind: 'local', + branchName: 'docs/site-refresh', + latestMessageAt: now - 20 * hour, + ageLabel: '20h', + machineId: 'zx-macbook', + projectKey: 'zx-macbook:lody-docs', + projectLabel: 'lody (docs worktree)', + }, +]; + function StoryShell({ groupBy, rowActions, flatHeading, archived = false, + capGroupPreviews = false, chats: chatsOverride, }: { groupBy: MobileChatGroupBy; rowActions?: MobileChatListRowActions; flatHeading?: string; archived?: boolean; + /** Mirrors the workspace home list, which caps each bucket's preview. */ + capGroupPreviews?: boolean; /** Story-only dataset override; defaults to the shared mixed list. */ chats?: MobileConversationItem[]; }) { @@ -274,6 +343,7 @@ function StoryShell({ groupBy={groupBy} flatHeading={flatHeading} archived={archived} + capGroupPreviews={capGroupPreviews} groupLabels={{ chat: 'Chat', local: 'Local', @@ -358,6 +428,27 @@ export const GroupByDate: Story = { args: { groupBy: 'date' }, }; +/* Group preview cap — the state this exists for. Three worktrees on the same + project plus a long-running one: without the cap the first bucket's twelve + rows push every other worktree off the screen. Each bucket previews five + top-level rows and ends in "Show all (N)". */ +export const GroupPreviewOverflow: Story = { + args: { groupBy: 'project', chats: overflowingChats, capGroupPreviews: true }, +}; + +/* One bucket expanded past the cap: the toggle reads "Show less" and stays + pinned to the tail of the bucket it belongs to. Collapsing it scrolls the + toggle back into view rather than dropping the user somewhere else in the + list. */ +export const GroupPreviewExpanded: Story = { + args: { groupBy: 'project', chats: overflowingChats, capGroupPreviews: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const [toggle] = await canvas.findAllByRole('button', { name: /Show all/i }); + if (toggle) await userEvent.click(toggle); + }, +}; + export const WithSwipeActions: Story = { args: { groupBy: 'project', rowActions: {} }, }; diff --git a/packages/components/tests/mobile-chat-list-preview-cap.test.tsx b/packages/components/tests/mobile-chat-list-preview-cap.test.tsx new file mode 100644 index 000000000..2e3cb8a81 --- /dev/null +++ b/packages/components/tests/mobile-chat-list-preview-cap.test.tsx @@ -0,0 +1,306 @@ +// @vitest-environment jsdom + +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { flushSync } from 'react-dom'; +import { createRoot, type Root } from 'react-dom/client'; +import { createStore, Provider } from 'jotai'; +import { + MOBILE_CHAT_PREVIEW_MAX_ROOTS, + MobileChatList, +} from '../src/components/mobile/mobile-chat-list'; +import type { MobileConversationItem } from '../src/components/mobile/mobile-project-screen'; +import { sidebarCollapsedOpenedBySessionsAtom } from '../src/atoms/focus-layer'; +import { initI18n } from '../src/i18n'; + +/** + * Every bucket of the mobile chat list previews at most + * `MOBILE_CHAT_PREVIEW_MAX_ROOTS` TOP-LEVEL rows and ends in a "Show all (N)" + * toggle — the same model the desktop sidebar's `MAX_VISIBLE_SESSIONS` applies + * per group. Without it, one busy project buries every other project and + * worktree below the fold. + */ + +const HOUR = 60 * 60 * 1000; +const NOW = Date.UTC(2026, 3, 22, 10, 0, 0); + +function makeItem(overrides: Partial & { id: string }) { + return { + kind: 'chat', + title: `Session ${overrides.id}`, + latestMessageAt: NOW - HOUR, + ...overrides, + } satisfies MobileConversationItem; +} + +/** `count` unpinned rows in one project bucket, newest first. */ +function makeProjectItems( + count: number, + projectKey = 'p1', + startIndex = 0 +): MobileConversationItem[] { + return Array.from({ length: count }, (_, index) => + makeItem({ + id: `${projectKey}-${startIndex + index}`, + projectKey, + projectLabel: projectKey, + latestMessageAt: NOW - (startIndex + index) * HOUR, + }) + ); +} + +let container: HTMLDivElement; +let root: Root; +let store: ReturnType; + +beforeEach(async () => { + await initI18n(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + store = createStore(); +}); + +afterEach(() => { + flushSync(() => root.unmount()); + container.remove(); +}); + +function render( + chats: MobileConversationItem[], + props: Partial> = {} +) { + flushSync(() => { + root.render( + + + + ); + }); +} + +function rows(): HTMLElement[] { + return Array.from(container.querySelectorAll('.mobile-project-conversation-row')); +} + +function titles(): string[] { + return rows().map((row) => row.textContent?.trim() ?? ''); +} + +function toggles(): HTMLButtonElement[] { + return Array.from( + container.querySelectorAll('[data-chat-list-preview-toggle]') + ); +} + +function toggleLabels(): string[] { + return toggles().map((button) => button.textContent?.trim() ?? ''); +} + +describe('mobile chat list group preview cap', () => { + it('renders every row when the caller does not opt in', () => { + // The in-project list drills into one project deliberately: there is + // nothing else on that page for a cap to make room for. + render(makeProjectItems(9, 'p1'), { capGroupPreviews: false }); + expect(rows()).toHaveLength(9); + expect(toggles()).toHaveLength(0); + }); + + + it('previews five top-level rows per bucket and offers the rest', () => { + render([...makeProjectItems(9, 'p1'), ...makeProjectItems(2, 'p2')]); + // p1 is capped; p2 is under the cap and gets no toggle at all. + expect(titles()).toEqual([ + 'Session p1-0', + 'Session p1-1', + 'Session p1-2', + 'Session p1-3', + 'Session p1-4', + 'Session p2-0', + 'Session p2-1', + ]); + expect(toggleLabels()).toEqual(['Show all (9)']); + }); + + it('leaves a bucket exactly at the cap untouched', () => { + // A toggle whose tap would change nothing must not appear. + render(makeProjectItems(MOBILE_CHAT_PREVIEW_MAX_ROOTS, 'p1')); + expect(rows()).toHaveLength(MOBILE_CHAT_PREVIEW_MAX_ROOTS); + expect(toggles()).toHaveLength(0); + }); + + it('expands and re-collapses only the bucket that was tapped', () => { + render([...makeProjectItems(9, 'p1'), ...makeProjectItems(8, 'p2')]); + expect(toggleLabels()).toEqual(['Show all (9)', 'Show all (8)']); + + act(() => { + toggles()[0]!.click(); + }); + expect(titles().filter((title) => title.includes('p1-'))).toHaveLength(9); + // The untapped bucket keeps its preview. + expect(titles().filter((title) => title.includes('p2-'))).toHaveLength(5); + expect(toggleLabels()).toEqual(['Show less', 'Show all (8)']); + expect(toggles()[0]!.getAttribute('aria-expanded')).toBe('true'); + + act(() => { + toggles()[0]!.click(); + }); + expect(titles().filter((title) => title.includes('p1-'))).toHaveLength(5); + expect(toggleLabels()).toEqual(['Show all (9)', 'Show all (8)']); + expect(toggles()[0]!.getAttribute('aria-expanded')).toBe('false'); + }); + + it('counts top-level rows, so a preview never splits an opener', () => { + // Four standalone rows plus an opener with three opened Sessions is five + // TOP-LEVEL rows: nothing overflows, and all eight rows render. Counting + // raw rows instead would cut inside the opener's group. + const chats = [ + ...makeProjectItems(4, 'p1'), + makeItem({ + id: 'opener', + projectKey: 'p1', + projectLabel: 'p1', + latestMessageAt: NOW - 10 * HOUR, + }), + ...['a', 'b', 'c'].map((suffix, index) => + makeItem({ + id: `opened-${suffix}`, + projectKey: 'p1', + projectLabel: 'p1', + openedBySessionId: 'opener', + openedByRowSessionId: 'opener', + latestMessageAt: NOW - (11 + index) * HOUR, + }) + ), + ]; + render(chats); + expect(rows()).toHaveLength(8); + expect(toggles()).toHaveLength(0); + }); + + it('keeps a kept root together with the Sessions it opened', () => { + // Six top-level rows, the last of which opened two Sessions. The cap drops + // the sixth root entirely rather than showing it without its children. + const chats = [ + ...makeProjectItems(5, 'p1'), + makeItem({ + id: 'opener', + projectKey: 'p1', + projectLabel: 'p1', + latestMessageAt: NOW - 20 * HOUR, + }), + ...['a', 'b'].map((suffix, index) => + makeItem({ + id: `opened-${suffix}`, + projectKey: 'p1', + projectLabel: 'p1', + openedBySessionId: 'opener', + openedByRowSessionId: 'opener', + latestMessageAt: NOW - (21 + index) * HOUR, + }) + ), + ]; + render(chats); + expect(titles()).not.toContain('Session opener'); + expect(titles()).not.toContain('Session opened-a'); + expect(toggleLabels()).toEqual(['Show all (8)']); + + act(() => { + toggles()[0]!.click(); + }); + expect(titles().slice(5)).toEqual([ + 'Session opener', + 'Session opened-a', + 'Session opened-b', + ]); + }); + + it('truncates the pinned-first order rather than reshuffling it', () => { + // A pinned row outranks fresher unpinned ones, so it must survive the cap + // even though it is the stalest row in the bucket. + render([ + ...makeProjectItems(6, 'p1'), + makeItem({ + id: 'stale-pin', + isPinned: true, + projectKey: 'p1', + projectLabel: 'p1', + latestMessageAt: NOW - 99 * HOUR, + }), + ]); + // Pinned rows lift into their own bucket, which is itself capped. + expect(titles()).toEqual([ + 'Session stale-pin', + 'Session p1-0', + 'Session p1-1', + 'Session p1-2', + 'Session p1-3', + 'Session p1-4', + ]); + expect(toggleLabels()).toEqual(['Show all (6)']); + }); + + it('pulls the toggle back into view when a bucket collapses', () => { + // Collapsing removes rows ABOVE the toggle, so on a long bucket the tap + // target and everything around it jumps off the top of the viewport. + // jsdom has no layout, so the browser API is the only observable here. + const calls: Array = []; + const original = Element.prototype.scrollIntoView; + Element.prototype.scrollIntoView = function scrollIntoView(arg) { + calls.push(arg); + }; + try { + render(makeProjectItems(9, 'p1')); + act(() => { + toggles()[0]!.click(); + }); + // Expanding only appends rows below the toggle: nothing to correct. + expect(calls).toEqual([]); + + act(() => { + toggles()[0]!.click(); + }); + expect(calls).toEqual([{ block: 'nearest' }]); + } finally { + Element.prototype.scrollIntoView = original; + } + }); + + it('does not write the preview state into the shared opener-fold atom', () => { + // The fold atom is shared with the drawer sidebar; how many rows a mobile + // bucket previews is this surface's business alone. + render(makeProjectItems(9, 'p1')); + act(() => { + toggles()[0]!.click(); + }); + expect(store.get(sidebarCollapsedOpenedBySessionsAtom)).toEqual({}); + }); + + it('shows every row while multi-select is active', () => { + // "Select all" operates on every id in the list, so a capped surface would + // let the user confirm a permanent delete of rows it never showed. + vi.useFakeTimers(); + try { + const chats = makeProjectItems(9, 'p1'); + render(chats, { archived: true, onPermanentDelete: () => {} }); + expect(rows()).toHaveLength(MOBILE_CHAT_PREVIEW_MAX_ROOTS); + + // Long-press the first row: pointerdown, then let the injected clock run + // past the 500ms hold. No wall-clock sleep is involved. + act(() => { + rows()[0]!.dispatchEvent( + new MouseEvent('pointerdown', { bubbles: true, clientX: 8, clientY: 8 }) + ); + }); + act(() => { + vi.advanceTimersByTime(600); + }); + + expect(rows()).toHaveLength(chats.length); + expect(toggles()).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); +}); From f9fb6ed327fe6b853cb950c3d86997c676681c4e Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 3 Sep 2026 10:35:29 +0800 Subject: [PATCH 2/3] refactor(components): tighten preview-cap tests after ablation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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] --- .../components/mobile/mobile-chat-list.tsx | 24 +++++-- .../src/stories/MobileChatList.stories.tsx | 2 +- .../mobile-chat-list-preview-cap.test.tsx | 62 ++++++++++++------- 3 files changed, 59 insertions(+), 29 deletions(-) diff --git a/packages/components/src/components/mobile/mobile-chat-list.tsx b/packages/components/src/components/mobile/mobile-chat-list.tsx index 53c73fb1c..fe7a62797 100644 --- a/packages/components/src/components/mobile/mobile-chat-list.tsx +++ b/packages/components/src/components/mobile/mobile-chat-list.tsx @@ -518,11 +518,12 @@ export function MobileChatListCard({ intentional single-row archive case still works because that path only removes one item from the same key bucket. - "Show less" is the same shape of bulk removal: collapsing a 40-row - project back to five would otherwise run 35 simultaneous 0.4s height - exits. The preview state joins the key so that transition remounts - instead — and `initial={false}` means expanding adds its rows with no - enter animation either. */} + "Show less" is the same shape of bulk removal. Measured with the key + carrying only `archived`: collapsing a 14-row bucket leaves all 14 rows + in the DOM a frame later, animating out together for 400ms. Adding the + preview state to the key makes that transition a remount instead — the + same frame reports the 5 rows that remain — and `initial={false}` means + expanding adds its rows with no enter animation either. */} { const canvas = within(canvasElement); const [toggle] = await canvas.findAllByRole('button', { name: /Show all/i }); - if (toggle) await userEvent.click(toggle); + await userEvent.click(toggle!); }, }; diff --git a/packages/components/tests/mobile-chat-list-preview-cap.test.tsx b/packages/components/tests/mobile-chat-list-preview-cap.test.tsx index 2e3cb8a81..efb52df58 100644 --- a/packages/components/tests/mobile-chat-list-preview-cap.test.tsx +++ b/packages/components/tests/mobile-chat-list-preview-cap.test.tsx @@ -99,18 +99,33 @@ function toggleLabels(): string[] { } describe('mobile chat list group preview cap', () => { - it('renders every row when the caller does not opt in', () => { - // The in-project list drills into one project deliberately: there is - // nothing else on that page for a cap to make room for. + it('caps only when the caller opts in, independently of grouping', () => { + // Both directions matter, and neither follows from the other. The + // in-project list drills into one project deliberately, so it opts out and + // must render everything. And the cap must NOT be inferred from `groupBy`: + // the project page is only `none` because `chat-landing.tsx` pins it for + // heading reasons, so deriving the cap from grouping would silently switch + // it on there the day that page grows a date mode — hiding the very + // worktree rows the cap exists to expose. render(makeProjectItems(9, 'p1'), { capGroupPreviews: false }); expect(rows()).toHaveLength(9); expect(toggles()).toHaveLength(0); + + render(makeProjectItems(9, 'p1'), { groupBy: 'none' }); + expect(rows()).toHaveLength(MOBILE_CHAT_PREVIEW_MAX_ROOTS); + expect(toggleLabels()).toEqual(['Show all (9)']); }); - it('previews five top-level rows per bucket and offers the rest', () => { - render([...makeProjectItems(9, 'p1'), ...makeProjectItems(2, 'p2')]); - // p1 is capped; p2 is under the cap and gets no toggle at all. + it('previews five rows per overflowing bucket and leaves the rest alone', () => { + // Three boundary points in one list: over the cap (trimmed, toggle), + // exactly at it (untouched, no toggle — an off-by-one in the `>` would + // surface here and nowhere else), and under it (untouched, no toggle). + render([ + ...makeProjectItems(9, 'p1'), + ...makeProjectItems(MOBILE_CHAT_PREVIEW_MAX_ROOTS, 'p2'), + ...makeProjectItems(2, 'p3'), + ]); expect(titles()).toEqual([ 'Session p1-0', 'Session p1-1', @@ -119,17 +134,15 @@ describe('mobile chat list group preview cap', () => { 'Session p1-4', 'Session p2-0', 'Session p2-1', + 'Session p2-2', + 'Session p2-3', + 'Session p2-4', + 'Session p3-0', + 'Session p3-1', ]); expect(toggleLabels()).toEqual(['Show all (9)']); }); - it('leaves a bucket exactly at the cap untouched', () => { - // A toggle whose tap would change nothing must not appear. - render(makeProjectItems(MOBILE_CHAT_PREVIEW_MAX_ROOTS, 'p1')); - expect(rows()).toHaveLength(MOBILE_CHAT_PREVIEW_MAX_ROOTS); - expect(toggles()).toHaveLength(0); - }); - it('expands and re-collapses only the bucket that was tapped', () => { render([...makeProjectItems(9, 'p1'), ...makeProjectItems(8, 'p2')]); expect(toggleLabels()).toEqual(['Show all (9)', 'Show all (8)']); @@ -242,26 +255,31 @@ describe('mobile chat list group preview cap', () => { }); it('pulls the toggle back into view when a bucket collapses', () => { - // Collapsing removes rows ABOVE the toggle, so on a long bucket the tap - // target and everything around it jumps off the top of the viewport. - // jsdom has no layout, so the browser API is the only observable here. - const calls: Array = []; + // Measured in Chromium with `overflow-anchor: none` (Safari/iOS ships no + // scroll anchoring): collapsing a 14-row bucket drops the toggle from + // y=328 to y=-68, off the top of the viewport. With this correction it + // lands at y=0. jsdom has no layout, so what is observable here is WHICH + // element the list asks the browser to keep on screen. + let scrolled: { target: Element; options: unknown } | null = null; const original = Element.prototype.scrollIntoView; - Element.prototype.scrollIntoView = function scrollIntoView(arg) { - calls.push(arg); + Element.prototype.scrollIntoView = function scrollIntoView(options) { + scrolled = { target: this, options }; }; try { render(makeProjectItems(9, 'p1')); act(() => { toggles()[0]!.click(); }); - // Expanding only appends rows below the toggle: nothing to correct. - expect(calls).toEqual([]); + // Expanding only appends rows BELOW the toggle. Moving the viewport + // there would yank the list out from under the reader. + expect(scrolled).toBeNull(); act(() => { toggles()[0]!.click(); }); - expect(calls).toEqual([{ block: 'nearest' }]); + expect(scrolled).not.toBeNull(); + expect(scrolled!.target).toBe(toggles()[0]); + expect(scrolled!.options).toEqual({ block: 'nearest' }); } finally { Element.prototype.scrollIntoView = original; } From 32f9e7d7e0d7f8458f50c07918a0be3527b10373 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 18:18:07 +0800 Subject: [PATCH 3/3] refactor(components): drop a preview memo that could never hit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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] --- .../components/mobile/mobile-chat-list.tsx | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/components/src/components/mobile/mobile-chat-list.tsx b/packages/components/src/components/mobile/mobile-chat-list.tsx index fe7a62797..e78604f37 100644 --- a/packages/components/src/components/mobile/mobile-chat-list.tsx +++ b/packages/components/src/components/mobile/mobile-chat-list.tsx @@ -475,7 +475,8 @@ export function MobileChatListCard({ pinned-first / latest-activity order the bucket already promises — it truncates that order rather than reshuffling it. */ const showAll = preview?.showAll ?? false; - const capped = preview != null && !showAll; + const previewEnabled = preview != null; + const capped = previewEnabled && !showAll; const treeNodes = useMemo( () => buildOpenedBySessionTree(chats, { @@ -491,14 +492,20 @@ export function MobileChatListCard({ ); /* Gate the toggle on TOP-LEVEL rows, matching what the cap actually limits: five openers plus the Sessions they opened is not an overflowing bucket, - so it must not sprout a "Show all" the tap would not change. */ - const overflowsPreview = useMemo( - () => - preview != null && - countOpenedByTreeRoots(chats, CHAT_OPENED_BY_TREE_ACCESSORS) > - MOBILE_CHAT_PREVIEW_MAX_ROOTS, - [chats, preview] - ); + so it must not sprout a "Show all" the tap would not change. + + Deliberately NOT wrapped in `useMemo`. `MobileChatList` calls `groupChats` + in its render body, so every bucket receives a freshly built `chats` array + on every render — measured: a state-only re-render (tapping one bucket's + toggle) hands all buckets a new array. A memo keyed on `chats` therefore + cannot ever hit, and one that looks like a cache while caching nothing is + worse than the O(rows) scan it fails to avoid. `previewEnabled` exists for + the same reason: `preview` is a new object literal each render, so only the + one bit of it that matters may be read. */ + const overflowsPreview = + previewEnabled && + countOpenedByTreeRoots(chats, CHAT_OPENED_BY_TREE_ACCESSORS) > + MOBILE_CHAT_PREVIEW_MAX_ROOTS; return ( /* Flat list — no rounded card shell or inter-row dividers. Rows sit directly on the page canvas; `ConversationRow` supplies its