Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/happy-app/sources/components/ChatList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
}
Expand Down Expand Up @@ -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();
Expand Down
49 changes: 36 additions & 13 deletions packages/happy-app/sources/components/ChatList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -200,12 +222,12 @@ const OlderEnd = React.memo((props: { status: 'idle' | 'loading' | 'error' | 'lo
const safeArea = useSafeAreaInsets();
return (
<View>
<View style={{ height: props.topContentInset ?? headerHeight + safeArea.top + 32 }} />
<View style={{ height: 32, alignItems: 'center', justifyContent: 'center' }}>
{props.status === 'loading' && <ActivityIndicator size="small" />}
{props.status === 'error' && <RoundButton size="small" display="inverted" title={t('common.retry')} onPress={props.onAction} />}
{props.status === 'load-more' && <RoundButton size="small" display="inverted" title={t('common.loadMore')} onPress={props.onAction} />}
</View>
<View style={{ height: props.topContentInset ?? headerHeight + safeArea.top + 32 }} />
</View>
);
});
Expand Down Expand Up @@ -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<Promise<boolean> | 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<string | null | undefined>(undefined);
const invisibleOlderPagesRef = React.useRef(0);
const [olderSettled, setOlderSettled] = React.useState(0);
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
Loading