From 5f0d24a8755d6aa69be7c86be8aa80a27b8ae441 Mon Sep 17 00:00:00 2001 From: Hyunwoo Jung Date: Sat, 26 Sep 2026 23:08:14 +0900 Subject: [PATCH] fix(app): keep loading history through long turns Scrolling up a long chat could stop for good partway through one turn. The chat list stops paging after five pages that bring nothing new into view and waits for Load more. It judged "new" by the id of the oldest row, but a collapsed work group keeps its id while the older part of its turn arrives, so every page of a long turn counted as empty: a turn of more than about five hundred records paused before the prompt that opened it came into view. The oldest group's earliest member is now part of that identity, so a page that extends the turn counts as progress. Pages of rows that never render, and live rows at the newest end, still count against the budget as before. Getting past the pause was also harder than it looked on a phone. The footer placed the spinner / Retry / Load more slot first and the header spacer second, on the belief that the footer is drawn bottom-to-top. FlashList counter-flips the footer as a whole, so it reads top-to-bottom like any view, and the slot landed at the very top of the content: on a phone, behind the header and the status bar, where the one control that resumes paging can be neither seen nor tapped. The spacer now comes first, so the slot sits against the oldest row. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Happy --- .../sources/components/ChatList.test.ts | 28 +++++++++++ .../happy-app/sources/components/ChatList.tsx | 49 ++++++++++++++----- 2 files changed, 64 insertions(+), 13 deletions(-) diff --git a/packages/happy-app/sources/components/ChatList.test.ts b/packages/happy-app/sources/components/ChatList.test.ts index b3a7e8fdbb7..829826fcea3 100644 --- a/packages/happy-app/sources/components/ChatList.test.ts +++ b/packages/happy-app/sources/components/ChatList.test.ts @@ -228,6 +228,15 @@ describe('ChatList automatic history', () => { return [1, 3, 799].includes(seq) ? userMessage(`m${seq}`, seq) : agentMessage(`m${seq}`, seq); }); } + // A current turn (t1300/t1299) over one long agent turn: final answer + // t1298, then 1296 rows of work, opened by the prompt at seq 1 — thirteen + // pages of history for a single collapsed group. + function longTurn(count: number) { + return Array.from({ length: count }, (_, index) => { + const seq = 1300 - index; + return [1, 1299].includes(seq) ? userMessage(`t${seq}`, seq) : agentMessage(`t${seq}`, seq); + }); + } function thinking(count: number, from = 800) { return Array.from({ length: count }, (_, i) => ({ ...agentMessage(`thinking${from - i}`, from - i), isThinking: true })); } @@ -317,6 +326,25 @@ describe('ChatList automatic history', () => { expect(page.started()).toBe(2); }); + it('keeps paging through a turn whose work spans more pages than the invisible-page budget', async () => { + const renderer = open(longTurn(600)); + const page = pendingPage(); + layout(renderer); + expect(page.started()).toBe(1); + // Every page lands inside the same collapsed turn. The oldest row is + // still that turn's group, but the group now reaches further back — + // progress toward the turn's opener, not a page with nothing in it. + for (let count = 700; count <= 1200; count += 100) { + await settle(renderer, longTurn(count), true, page.finish); + } + expect(page.started()).toBe(7); + expect(renderer.root.findAllByType('RoundButton')).toHaveLength(0); + await settle(renderer, longTurn(1300), false, page.finish); + expect(messageIds(renderer)).toEqual(['t1300', 't1299', 't1298', 't1']); + expect(headerIds(renderer)).toEqual(['work-t1298']); + expect(page.started()).toBe(7); + }); + it('keeps a group the reader expanded open while its older members and opener arrive', async () => { const renderer = open(); const page = pendingPage(); diff --git a/packages/happy-app/sources/components/ChatList.tsx b/packages/happy-app/sources/components/ChatList.tsx index 1a893580544..df2d5bf9b6c 100644 --- a/packages/happy-app/sources/components/ChatList.tsx +++ b/packages/happy-app/sources/components/ChatList.tsx @@ -93,7 +93,7 @@ const WINDOW_PAGE = 60; * viewports of the oldest rendered message. */ const START_REACHED_VIEWPORTS = 1; -/** Stop automatic paging after this many pages without an older rendered boundary. */ +/** Stop automatic paging after this many pages that reach no further back (see oldestBoundary). */ const MAX_INVISIBLE_OLDER_PAGES = 5; // Visual gap between the button's bottom edge and the composer card's top // edge. scrollButtonInset is measured to the card itself, so this is exact. @@ -151,6 +151,25 @@ function windowEndForTurn(messages: Message[], desiredEnd: number): number { return end; } +/** + * Identity of the far end of the list, for judging whether a page of history + * took the reader any further back. + * + * Usually that is just the oldest row. The exception is a collapsed work group: + * it is keyed by the message its run completes at (see collectAgentWorkGroups), + * so it keeps its id while the older part of its turn is still arriving. A page + * that lands there adds no row, yet it does reach further back — and one turn of + * agent work can span many pages before the prompt that opened it arrives. The + * group's oldest member is therefore part of its identity. Expanding a group, or + * live rows arriving at the newest end, still leaves this unchanged. + */ +function oldestBoundary(items: readonly ListItem[]): string | null { + const oldest = items[items.length - 1]; + if (!oldest) return null; + if (oldest.type === 'work-header') return `${oldest.id}:${oldest.group.messages[0]?.id ?? ''}`; + return oldest.id; +} + export const ChatList = React.memo((props: { session: Session; active?: boolean; @@ -183,14 +202,17 @@ export const ChatList = React.memo((props: { /** * Renders past the oldest message: the older-history spinner, Retry after a - * failed page, or Load more after the invisible-page budget, then a spacer - * keeping it clear of the header bar. + * failed page, or Load more after the invisible-page budget, directly above the + * oldest message, with a spacer beyond it keeping it clear of the header bar. * * This is the list's *footer* because the list is inverted — the far end of - * the data is the top of the screen. The two children are in visual order - * bottom-to-top for the same reason: each cell is counter-flipped, so within - * this component layout reads normally, but its position relative to the - * conversation is mirrored. + * the data is the top of the screen. FlashList counter-flips the footer as a + * whole, as it does each cell, so inside this component layout reads + * top-to-bottom like any other view: the spacer comes first and sits under the + * header bar, and the status slot comes last, against the oldest message. The + * other way round, the slot ends up at the very top of the content, which on a + * phone is behind the header and the status bar — Load more and Retry would be + * the only way forward and could be neither seen nor tapped. * * The status slot is always mounted at a fixed height. It sits beyond every * row, so a height change here moves the whole conversation. @@ -200,12 +222,12 @@ const OlderEnd = React.memo((props: { status: 'idle' | 'loading' | 'error' | 'lo const safeArea = useSafeAreaInsets(); return ( + {props.status === 'loading' && } {props.status === 'error' && } {props.status === 'load-more' && } - ); }); @@ -665,9 +687,10 @@ const ChatListInternal = React.memo((props: { // page bumps the counter so the effect re-checks with fresh props. const pendingOlderRef = React.useRef | null>(null); // A settled page is assessed after its rows have had a chance to enter the - // window. Compare the oldest item's identity, not the row count: live rows - // and group expansion must not reset the budget. null means an empty list; - // undefined means no page is awaiting assessment. + // window. Compare the far end's identity (oldestBoundary), not the row + // count: live rows and group expansion must not reset the budget, while a + // page that extends the oldest collapsed turn must. null means an empty + // list; undefined means no page is awaiting assessment. const olderPageRef = React.useRef(undefined); const invisibleOlderPagesRef = React.useRef(0); const [olderSettled, setOlderSettled] = React.useState(0); @@ -699,7 +722,7 @@ const ChatListInternal = React.memo((props: { const pageBaseline = olderPageRef.current; if (pageBaseline !== undefined && pendingOlderRef.current === null) { olderPageRef.current = undefined; - if ((listItemsRef.current[listItemsRef.current.length - 1]?.id ?? null) !== pageBaseline) { + if (oldestBoundary(listItemsRef.current) !== pageBaseline) { invisibleOlderPagesRef.current = 0; setOlderBlocked(null); } else { @@ -723,7 +746,7 @@ const ChatListInternal = React.memo((props: { // scroll or store change, so it cannot spin. const request = sync.loadOlderMessages(sessionId); pendingOlderRef.current = request; - olderPageRef.current = listItemsRef.current[listItemsRef.current.length - 1]?.id ?? null; + olderPageRef.current = oldestBoundary(listItemsRef.current); request.then( (advanced) => { if (pendingOlderRef.current !== request) return;