feat(web): infinite-scroll conversation events - #369
Conversation
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
This PR fixes the web client so it pages through a conversation's full event stream instead of silently truncating at the server's 200-event limit.
- Shared pagination helper —
fetchAllConversationEventsinapps/web/src/lib/agent-api.tswalks{offset, limit}pages for both the project-scoped and global event routes. - Bounded loop — stops on a short page as well as on
total, so a running conversation that appends events between requests cannot spin an unbounded request loop. - Tests — covers single-page, multi-page, short-page, missing
total, and global-route cases.
I verified the server contract (ConversationHandler.ListConversationEvents / GetGlobalConversationEvents return {items, total} and the repository orders by event_index ASC with positional OFFSET/LIMIT), ran the test file, and checked Biome on the changed files — all clean.
Kimi K2 (free via Pullfrog for OSS) | 𝕏
pikann
left a comment
There was a problem hiding this comment.
Thank you for your contribution! Please let me know if you are unable to make these updates, and I will gladly take over
|
Also, don't forget to commit using your GitHub account so you're added to the contributors list! |
Addresses the review on Paca-AI#369: rather than looping on the client to assemble a conversation's whole event stream, the view reads it as an infinite query. - Opening fetches the newest page only. The single-conversation read carries `event_count`, so the tail offset needs no extra round trip; without it the client asks for one row to learn the count. - Realtime carries `event_index`, and the window fetches only the pages past the ones it holds. A reader who has scrolled away from the newest event gets a count of what is waiting, and nothing is fetched until they return. - Scrolling up pages older events in, holding the reader's position. `useInfiniteQuery` does the paging in both directions, which is the same primitive the conversation and activity lists already use. Offsets are safe on a live stream because `event_index` is gapless: an index is an offset, so a page addresses the same events however many arrive later. Pages therefore stay contiguous, which `eventsToThreadMessages` requires — it carries state across the array it is given (open tool calls keyed by id, the assistant message being accumulated) and is only correct over an unbroken run. The transformer is unchanged, as are its tests. Both conversation scopes are covered: `ConversationView` serves the project and global routes, so the reader takes an optional `projectId` and picks the matching endpoint. Neither route loader pre-fetches event lists any more; each awaited a whole stream before rendering. Four details worth knowing: - The window and the realtime tail signal are keyed outside both ["projects", …] and ["global-chat", …]. Both realtime hooks invalidate those prefixes per event, and an invalidated infinite query refetches every page it holds — which is the cost this change exists to remove. The signal is also never fetched, so nothing can replace what was written into it. - `getNextPageParam` widens the end of the stream with the highest index realtime has reported, since a page's `total` is only as fresh as when it was fetched. - The query stays disabled until at least one event exists, so a conversation that is empty when opened starts fetching when realtime reports its first, and an empty page unambiguously means the end of the stream. - Older events are anchored by distance from the end of the content, the one quantity a prepend does not change. Message ids cannot be used — a message's id is the id of the first event in its group, so prepending renames the group it extends. The conversation viewport anchors turns at the bottom, which opens it on the newest message and keeps it pinned there while the reader is at the bottom. Run-start scrolling is off: it queues a scroll the viewport re-applies on every content resize, which would override paging. Server side: `event_count` on the single-conversation read (both scopes share `FindConversationByID`), typed as a pointer and omitted from list responses, which neither load nor need it; and `event_index` in the realtime payload, stringified to match the sibling publish. The event body is deliberately not published — the median event is ~5 kB but the tail reaches 780 kB, and it would fan out to every project member. `Thread` gains optional viewport slots and anchoring props. Controls that page history need the viewport's context to reach the scroll element, and the component is shared with the chat floats, which keep their current behaviour. Tests drive the hook against a growing stream through the HTTP client, so the query options are exercised rather than mocked over: tail-first open, paging older events without refetching what is held, appending on a realtime signal, counting without fetching while scrolled away, a conversation empty when opened, a burst that outruns one page, the count probe, and the global route. Two of them fail if the window or the signal is keyed back under an invalidated prefix. Scroll behaviour is reasoned from the viewport's implementation rather than covered by a test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the client-side loop with paging, per review: the view reads a conversation's events with `useInfiniteQuery`, the primitive the conversation and activity lists already use, rather than assembling the whole stream up front. - Opening fetches the newest page. The single-conversation read carries `event_count`, so the tail offset needs no extra round trip; without it the client asks for one row to learn the count. - Realtime carries `event_index`, and the view fetches only the pages past the ones it holds. A reader who has scrolled away from the newest event gets a count of what is waiting, and nothing is fetched until they return. - Scrolling up pages older events in, holding the reader's position. Offsets are safe on a live stream because `event_index` is gapless: an index is an offset, so a page addresses the same events however many arrive later. Pages therefore stay contiguous, which `eventsToThreadMessages` requires — it carries state across the array it is given (open tool calls keyed by id, the assistant message being accumulated) and is only correct over an unbroken run. The transformer is unchanged, as are its tests. Both conversation scopes are covered: `ConversationView` serves the project and global routes, so the reader takes an optional `projectId` and picks the matching endpoint. Neither route loader pre-fetches event lists any more; each awaited a whole stream before rendering. A page carries its own limit rather than only an offset. The last hop backwards is usually shorter than a full page, and requesting a full one there would refetch events the reader already holds. Realtime no longer refetches the conversation list and detail for every event. A persisted event carries an `event_index` and changes only the event stream; the conversation's own fields change on the lifecycle messages, which arrive separately and still invalidate as before. On a live instance a second carrying 12 events was producing 12 list requests and 12 detail requests alongside 2 event requests; the event side already coalesces, the rest was avoidable. A payload without an `event_index` is treated as a lifecycle message, so an API predating the field keeps the old behaviour. Three further details worth knowing: - The paged reader and the realtime tail signal are keyed outside both ["projects", …] and ["global-chat", …]. An invalidated infinite query refetches every page it holds, which is the cost this change exists to remove. The signal is also never fetched, so nothing can replace what was written into it. - `getNextPageParam` widens the end of the stream with the highest index realtime has reported, since a page's `total` is only as fresh as when it was fetched. The query stays disabled until at least one event exists, so a conversation that is empty when opened starts fetching when realtime reports its first, and an empty page unambiguously means the end of the stream. - Older events are anchored by distance from the end of the content, the one quantity a prepend does not change. Message ids cannot be used — a message's id is the id of the first event in its group, so prepending renames the group it extends. The conversation viewport anchors turns at the bottom, which opens it on the newest message and keeps it pinned there while the reader is at the bottom. Run-start scrolling is off: it queues a scroll the viewport re-applies on every content resize, which would override paging. Server side: `event_count` on the single-conversation read (both scopes share `FindConversationByID`), typed as a pointer and omitted from list responses, which neither load nor need it; and `event_index` in the realtime payload, stringified to match the sibling publish. The event body is deliberately not published — the median event is ~5 kB but the tail reaches 780 kB, and it would fan out to every project member. `Thread` gains optional viewport slots and anchoring props. Controls that page history need the viewport's context to reach the scroll element, and the component is shared with the chat floats, which keep their current behaviour. Tests drive the reader against a growing stream through the HTTP client, so the query options are exercised rather than mocked over: opening on the newest page, paging back without refetching or duplicating what is held, paging to the start, appending on a realtime signal, counting without fetching while scrolled away, a conversation empty when opened, a burst that outruns one page, the count probe, and the global route. Two of them fail if the reader or the signal is keyed back under an invalidated prefix, and two cover which realtime messages invalidate what. Scroll behaviour — opening at the newest message, pinning, holding position on a prepend — is reasoned from the viewport's implementation rather than covered by a test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
f5c468b to
53e137b
Compare
|
Thanks — you were right, and it's done: the client-side loop is gone. The view now pages with A few notes on what changed since your review:
One thing I want to be straight about: the scroll behaviour — opening at the newest message, staying pinned while at the bottom, and holding position when older events are prepended — is reasoned from |
|
Thanks for taking this into consideration, hope it's not getting too messy. This is the state I'm using locally and it's much easier to work with the agent conversations now for all the reasons listed above.
|
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
This run reviewed the complete rework that replaced the prior client-side pagination loop with a TanStack Query useInfiniteQuery window, added realtime tail signalling, and introduced load-older / jump-to-latest UI controls.
- Replaced the eager event fetch with an infinite-query window —
useConversationEventWindowanchors the initial fetch at the newest page, pages older events on demand, and catches up to live events only while the reader is following the tail. - Added scroll-affordance controls —
LoadOlderEventsrestores scroll position after prepending older messages, andTailFollowIndicatorlets readers jump back when new events arrive while they are scrolled away. - Reduced realtime refetch churn — persisted events now write a tail signal and invalidate only the event-stream key; lifecycle events still invalidate the conversations prefix.
- Added
event_countto the single-conversation read — both project and global detail routes use it to open on the tail without a probe. - Expanded test coverage — 69 unit tests cover window paging, realtime append/catch-up, empty-at-open, count probe, global route, and invalidation behavior.
Kimi K2 (free via Pullfrog for OSS) | 𝕏
|
Hi @Cha0os , Would you mind if I take it over from here to apply those changes, or would you prefer to update it yourself? |
Of course, go ahead. Thank you! |
- Updated conversation event loading to use keyset pagination instead of offset/limit. - Introduced ConversationEventWindow struct to encapsulate pagination parameters. - Modified ListConversationEvents method signatures across services and repositories to accept the new window parameter. - Implemented cursor-based navigation for conversation events, allowing clients to fetch events after or before a specific cursor. - Updated HTTP handlers to parse and validate new query parameters for event pagination. - Adjusted response structure to include next and previous cursors for seamless navigation through event streams. - Removed deprecated offset/limit handling from the API and internal services. - Added comprehensive tests to ensure correct behavior of new pagination logic and cursor handling.
There was a problem hiding this comment.
Important
The cursor-pagination refactor looks correct, but an existing E2E test still asserts the old offset/limit contract and will fail when the E2E suite runs.
Reviewed changes
This run reviewed the incremental refactor that replaced offset-based paging with cursor-based keyset pagination for conversation events and removed the now-unnecessary event_count field.
- Switched the events endpoint to cursor pagination —
parseConversationEventWindowQuerynow acceptsafter/before/limit;writeConversationEventWindowResponsereturnsnext_cursor/prev_cursor; the repository keyset-seeks byevent_index. - Removed
event_countfrom the conversation read — the newest page is fetched with no cursor, so the client no longer needs the conversation's length upfront. - Updated the client window hook —
useConversationEventWindowno longer takeseventCount, handles empty conversations via refetch on the first realtime event, and drives older-page loading with an IntersectionObserver sentinel inLoadOlderEvents. - Updated tests — handler unit tests cover the new query contract;
agent-api.test.tscovers cursor forwarding;use-conversation-event-window.test.tsxexercises the window through the HTTP client. - Verified Go build —
go build ./...succeeds;go test ./internal/transport/http/handler/... -run TestListConversationEventspasses.
⚠️ E2E offset-limit validation test is stale
services/api/test/e2e/conversation_pagination_test.go still expects the events endpoint to reject offset=-1 and offset=abc with 400. The new parser no longer reads offset, so those requests return 200 and the E2E suite (PACA_E2E=1 go test ./test/e2e/...) will fail.
Technical details
# Stale E2E offset validation
## Affected sites
- `services/api/test/e2e/conversation_pagination_test.go:665` — `TestE2EListConversationEvents_OffsetLimitValidation` asserts 400 for `offset=-1` and `offset=abc`, but the endpoint now ignores `offset`.
## Required outcome
- Update or remove the offset cases so the E2E suite passes under the cursor contract.
- Add cursor-boundary coverage if missing: malformed `after`/`before` rejected with 400, mutually exclusive after+before rejected, and cursors round-trip correctly.
## Suggested approach
- Replace the offset invalid cases with cursor invalid cases (`after=not-base64`, `before=garbage`).
- Keep the `limit` invalid cases — they still apply.
- Add a happy-path case confirming a bare request returns the newest page with `next_cursor`/`prev_cursor` populated correctly.Kimi K2 (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
This run reviewed the incremental test-only commit (293ce77d), which reworks the E2E coverage for the cursor-paginated conversation-events endpoint.
- Replaced the stale offset-limit E2E test —
TestE2EListConversationEvents_OffsetLimitValidationis nowTestE2EListConversationEventsWindow_LimitValidation, dropping the deadoffsetcases (the parser no longer readsoffset) and keeping thelimitinvalid cases, which still apply under the cursor contract. - Added cursor rejection coverage — the new suite asserts after+before together, unparsable
after, and unparsablebeforeare all rejected (400 /AGENT_CONVERSATION_EVENT_INVALID_CURSOR), matching the handler and repository behavior. - Added keyset-SQL E2E coverage —
TestE2EListConversationEventsWindow_CursorBaseddrives the realevent_index >/<queries and the reverse-then-flip-to-ascending ordering against a live database: newest-page open,beforepaging without duplication,afterforward paging, empty page past the tail, and a full backward traversal returning every event exactly once.
Verified against the implementation: parseConversationEventWindowQuery rejects limit out of [1,200] and mutual after+before; cursor decoding lives in the repository and maps ErrConversationEventInvalidCursor to AGENT_CONVERSATION_EVENT_INVALID_CURSOR — which is exactly why the cursor-rejection cases seed a real conversation (via createConversationAt) while the limit/mutual-exclusion cases don't need one. go vet ./test/e2e/... compiles clean, and the suite runs in CI via api-pr-ci.yml's test-e2e job (go test ./test/e2e/... with PACA_E2E=1). This fully closes the previously flagged stale-test concern.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
This incremental review covers commit 92c62ea1 (the delta since prior pullfrog review at 293ce77d), which lands two defensive fixes:
- Hardened the scroll-anchor correction —
LoadOlderEvents' ResizeObserver-drivenapply()inevent-window-controls.tsxnow bails while an older-page fetch is in flight, so a resize that isn't the prepend (the reader typing, or the agent streaming at the bottom) no longer yanks the reader's position. The anchor survives and the correction still runs once the prepend commits andisLoadingOlderflips false. - Made
ListChatMessagesfail loudly on a non-zerooffset—agent_service.gonow returns an explicit error instead of silently dropping the offset against the cursor-basedListConversationEvents. The method is unreached by any route (it only satisfies theagentdom.ChatSessionServiceinterface), so this is pure defense with no behavioral risk.
Both changes are well-reasoned and correctly scoped to the incremental delta.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Reworked per @pikann's review: the client-side loop is gone. The conversation view now reads its events with
useInfiniteQuery— the primitive the conversation and activity lists already use — instead of assembling the whole stream up front.Why it matters
Measured on a live instance (249 conversations):
raw_outputis 780 kB of it)persist_conversation_eventpublishes one realtime message per event, and the view re-read the stream on each one — a 622-event turn re-read it 622 times. Opening that conversation now costs one request (~1.1 MB) instead of four (~5 MB).Shape
event_counton the single-conversation read gives the tail offset with no extra round trip; without it the client asks for one row.ConversationViewserves the project and global routes, so the reader takes an optionalprojectId.Also in here: realtime stopped refetching the conversation itself per event
Both realtime hooks invalidated the whole
conversationsprefix for every message, so each event refetched the conversation list and the detail. From a live instance's API log:A persisted event carries an
event_indexand changes only the event stream; the conversation's own fields change on the lifecycle messages, which arrive separately and still invalidate exactly as before. A payload without anevent_indexis treated as lifecycle, so an API predating the field keeps today's behaviour.Four things a reviewer should look at
Keys sit outside
["projects", …]and["global-chat", …]. An invalidated infinite query refetches every page it holds — the cost this removes. The tail signal is also never fetched, so nothing can replace what was written into it. Two tests fail if either key moves back under a prefix.Paging a stream that is still growing.
getNextPageParamwidens the end with the highest index realtime has reported, because a page'stotalis only as fresh as when it was fetched. The query stays disabled until at least one event exists, so a conversation empty at open begins fetching when realtime reports its first — and an empty page then unambiguously means end-of-stream.A page carries its own limit. The last hop backwards is usually shorter than a full page; requesting a full one there refetched events already held, which showed up as duplicated messages at the start of a long conversation.
event_countis a pointer, omitted from list responses. Only the detail read loads it (both scopes shareFindConversationByID), so list pages neither compute nor report it.Verification
biome checkclean across 393 files;tsc -b && vite buildgreen.go build,go vet, and the api handler + repository tests pass.Not covered by tests: scroll behaviour — opening at the newest message, pinning while at the bottom, holding position on a prepend. It is reasoned from
ThreadPrimitive.Viewport's implementation and wants a look in a browser.Deliberately out of scope
raw_outputtruncation — 40% of all payload bytes (63 MB of 157 MB) and the reason a page's size is unpredictable (usually ~1.1 MB, occasionally 10 MB). A bigger lever than paging, and a separate change.🤖 Generated with Claude Code