Skip to content

feat(conversation): add useHistoryWindow and ConversationEarlier - #122

Open
pratikbuilds wants to merge 2 commits into
conversation/04-messagefrom
conversation/06-history-window
Open

pratikbuilds wants to merge 2 commits into
conversation/04-messagefrom
conversation/06-history-window

Conversation

@pratikbuilds

Copy link
Copy Markdown
Collaborator

useHistoryWindow keeps a window of the latest items, adds earlier ones as the reader nears the top, freezes while the reader has scrolled away and trims back once they return to the end. It works with history the host pages in asynchronously, and the item in view stays put within a pixel as earlier items load. ConversationEarlier marks the top with a loading state and a retry when a page brings nothing. The doc comment records why this windows rather than virtualizes, and its ceiling.

Stacked on #121: review only this commit. Part of #94.

Notes for review

  • stalled only applies while the window starts at the first held item (the only place the host is asked); a failed or empty page never blocks revealing items already held above the window.
  • A page that settles while the chat is hidden under still lands (no unmount-only guard).
  • Retry keeps focus: the marker takes focus while loading, returns it to the button if the retry stalls again, and hands it to the log when the last page lands and the marker goes away.
  • The hook's own captureAnchor/restoreAnchor are covered by a cancel-out test (append + trim of equal height, so nothing resizes); it drifts 63px without them.

Verification

  • This commit passes bun run typecheck and bun run lint on its own (the stack is checked commit by commit).
  • At the top of the stack: bun run check, bun run build, bun run dep-guard, bun run contrast-test and bun run test:e2e (Playwright specs and the Storybook axe sweep in light and dark) pass.

Closes #100

@corbits-triage

corbits-triage Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Triage: Pass

No changes needed from you. A maintainer will review this pull request.

Stacked on #121: only this pull request's own changes were checked.

Notes:

  • added code matches a code pattern: src/hooks/use-history-window.test.tsx:356 Promise chain; use async/await with try/catch., src/hooks/use-history-window.ts:281 Promise chain; use async/await with try/catch., src/ui/conversation-earlier.tsx:132 Nested ternary; use if statements or a lookup.
  • added code matches a code pattern: e2e/conversation-helpers.ts:62 Invented fallback (an empty string, || [] or || 0); handle the missing value instead.

Triage will label.

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated multi-agent review: independent review + second-agent validation; only confirmed items listed. The hook itself is well-built and the windowing-ceiling doc is real; one verified race below.

Verified (executed harness running the real hook + real engine)

  • The render-phase trim reads the rAF-throttled context atBottom: an append committing between the reader's scroll-up and the engine's per-frame pass removes the first shown item while the reader is scrolled up — violating the plan's hard constraint "never removes items while the reader is scrolled up" (src/hooks/use-history-window.ts:156-162 vs src/ui/conversation.tsx:389-399; a deterministic ordering harness reproduces it, and a frame-first control run proves nothing is removed when ordering is safe). Fix shape: gate the trim on a fresh position signal — trim in the subscribeScroll listener when snapshot.distanceFromEnd <= threshold, or read the last snapshot's distance from a ref in the render-phase trim. The suggested regression test currently fails on this code.

Minor

  • e2e/history-window.spec.ts:3-51 duplicates e2e/conversation.spec.ts:3-44's harness helpers line-for-line — extract a shared e2e/ helper before the next PR copies it.
  • A sync throw from onLoadEarlier is settled then rethrown out of reachStart(), which runs inside the marker's IntersectionObserver callback → uncaught exception in an observer callback (use-history-window.ts:246-253). Document, or route the rethrow through settled state only.
  • nits: the hook harness differs from the plan's renderHook prescription without a stated deviation (add a PR-body note); conversation-earlier.tsx:92-101 hand-writes the ref-merge idiom.

@pratikbuilds

pratikbuilds commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Here's what happened to each point.

Fixed

1. A new message could remove the oldest one while the reader was scrolled up (the verified race)

  • The problem: this was slightly wider than the trim alone. A message arriving in the same frame the reader started scrolling up made the window slide, because its "at the bottom" signal was one frame old.
  • The fix: a new message is now added with nothing removed. A layout effect then trims the window as before, but it first checks whether the view has moved up since the last frame, and if so leaves everything in place.
  • Tests: a unit test, "an item that lands before the frame the reader left in removes nothing". In a real-browser run, the first message used to change every time and now stays put.

2. The two e2e specs had duplicate helpers (minor)

  • The fix: they now share e2e/conversation-helpers.ts.

3. A loader that throws crashed inside the observer (minor)

  • The fix: if onLoadEarlier throws, the window stalls the same way it does for a rejected promise, and the error is reported once with reportError.

Not changed, and why

  • The hand-written ref merge: the shared helper is private to another PR's file, so I left this one.
  • Not using renderHook: I'll add a note about this to the PR description.

Commit: fix(conversation): hold the history window until the view is read

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review · Request changes

Adds useHistoryWindow and ConversationEarlier. The window holds its tail while a scroll has not been read yet, except when the trim anchor disappears in the same update that grows the list.

Findings

  • src/hooks/use-history-window.ts:72 — A missing through id slides the window during render, before the scroll pass can freeze it. When start.kind === "latest" and start.through is gone, startIndex returns items.length - windowSize. The layout effect at :204-211 only skips a later setState; it does not undo that fallback, and the next snapshot freezes the slid start. With 300 items, a window of 120 trimmed through r299, and a scroll that has moved but not yet been read, replacing r299 and appending r300 in one update drops r180. The pinned-start path already keeps the first surviving id of the previous window (:78-80). through should do the same when it is missing, instead of jumping to the new tail.

useHistoryWindow keeps a window of the latest items, adds earlier ones
as the reader nears the top, freezes while the reader has scrolled away
and trims back once they return to the end. It works with history the
host pages in asynchronously, and the item in view stays put within a
pixel as earlier items load. ConversationEarlier marks the top with a
loading state and a retry when a page brings nothing. The doc comment
records why this windows rather than virtualizes, and its ceiling.
An item committed synchronously in the frame the reader scrolled up
in, before the conversation's scroll pass, slid or trimmed the window:
atBottom and the freeze were still a frame old, so the oldest item was
removed while the reader was scrolled up.

A new item now commits with nothing removed. A layout effect then trims
or slides the window as before, unless the view has moved towards the
top since the last scroll snapshot; the next pass then freezes it if
the scroll was the reader's. The snapshot subscription moves to a
layout effect so the one sent when the container mounts is not missed.

A loader that throws now stalls like one that rejects, and its error is
reported once instead of escaping the marker's observer callback. The
scroll helpers both conversation specs copied now live in one module.
@pratikbuilds
pratikbuilds force-pushed the conversation/06-history-window branch from 4dfa1be to 202d6df Compare October 28, 2026 08:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants