diff --git a/packages/happy-app/sources/components/ChatList.test.ts b/packages/happy-app/sources/components/ChatList.test.ts index b3a7e8fdbb..829826fcea 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 1a89358054..df2d5bf9b6 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;