Skip to content

Chat paging with Codex backtracks - #5

Merged
devswha merged 2 commits into
devswha:mainfrom
Yoonwoo-Ha:fix/chat-history-composer-windows
Sep 24, 2026
Merged

devswha merged 2 commits into
devswha:mainfrom
Yoonwoo-Ha:fix/chat-history-composer-windows

Conversation

@Yoonwoo-Ha

@Yoonwoo-Ha Yoonwoo-Ha commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Chat paging only. The four small changes that were bundled here are now separate PRs: #9 (Windows terminal font), #10 (per-window reload), #11 (resizable composer) and #12 (Claude reasoning effort).

  • Paged reads. A transcript is read one page at a time: at most 16MB and 50 prompts, and a page always starts where a turn starts, so no turn is split across pages. Before this, the whole file was re-read on every append. On a 400MB Codex rollout that took 1.1s and 1.7GB of memory, and every poll sent 3.4MB.
  • Scroll-up history. Scrolling near the top loads the page before and keeps the reader in place. A page never makes a poll read more than one page. Once older pages are loaded, the newest page is polled from a held start. When the newest page moves past that start, the chat fetches the turns in between with before + since, one page at a time, and then holds the new start.
  • Codex backtracks (0.156 paginated rollouts). A backtrack or fork starts a new file, and that file's history_base names what it continues as a (thread, ordinal, byte) cut. The rollout that continues is the one with exactly ordinal - first ordinal lines before the byte. That holds however many backtracks there were, and it was checked on a real three-rollout chain against Codex's own thread_history_1.sqlite. Turns discarded by a backtrack don't appear. A cut that no file holds shows less history rather than the wrong history.
  • Claude Code's <pasted_content> wrapper is removed, so the chat, like the TUI, shows only the pasted text.
  • An opened conversation starts at its end instead of smooth-scrolling there from the top.

API

GET /api/pane/conversation takes before, since and from, and returns a cursor (see ConversationResponse). A cursor from another file gets 409 history_changed. Bridges without paging send no cursor, so the chat shows their newest page only.

Testing

  • bun run typecheck passes and bun test passes 265/265.
  • New tests cover:
    • backtracks cutting into earlier and later rollouts
    • a chain with no holder, which isn't cached and is found once its file appears
    • pages that join without gaps or overlaps
    • a held start while the last turn grows, and the gap fill after the newest page moves past it
    • a newest page that stays within one window when no turn starts in it
  • Real Codex-Design session (4 rollouts, about 713MB):
    • the chain resolves to the same 4 files in 0.5s the first time and instantly from cache
    • pages take 55–220ms
    • a held start 2 pages back fills its gap in 2 pages
  • Headless Chrome against a mocked API:
    • scrolling up keeps the reader in place (no shift)
    • growth past the held start fills the gap once
    • all 170 turns show in order with no duplicates, and the beginning is marked
    • a refused cursor resets to the newest page
    • the chat opens at its end in one jump

🤖 Generated with Claude Code

@devswha devswha left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks. The paging design looks sound: cursors are checked against the file's inode, and thread ids and paths are validated. A few things before merge:

  1. Codex history can be wrong after more than one backtrack. historyChain (server/codex.ts) chooses the earlier rollout by name order and size alone. After a second backtrack to a turn before the first one, it can pick the intermediate rollout, so the chat shows the wrong earlier history and nothing errors. The result, including an empty chain from a failed lookup, is cached for the life of the process. Please resolve the chain from the rollout's own base reference and add a test with two or more backtracks.
  2. Held-start polling has no size limit. Once an older page is loaded, every 2s poll re-reads and re-parses everything from the held start, up to 64MB. When the growth passes MAX_PAGE_BYTES, the server returns 409 even though nothing changed, and a reader who has scrolled up loses their place. Separately, a large file with no turn markers makes pageBefore read 112MB on every poll.
  3. Please split the PR. The Windows font, per-window reload, resizable composer and Claude effort changes are small and independent. Merged on their own, they would leave paging to be reviewed by itself.

Minor: loadingOlder isn't reset when the pane changes, and the composer grip's aria-valuenow is wrong while the height is automatic. Also, #8 is now merged and touches the same .chat-view rule, so this branch needs a rebase.

YoonwooHa and others added 2 commits September 23, 2026 16:57
…ktracks

- Transcripts are read a page at a time instead of whole on every append: a
  400MB Codex rollout took 1.1s and 1.7GB per re-read and sent 3.4MB every
  poll. A page is at most 16MB and 50 prompts, and always starts where a turn
  starts, so pages never split a turn.
- Scrolling near the top loads the page before; the reader's place is kept,
  and once older pages show, the newest page is polled from the start it had
  then, so the two always meet. The top shows where the conversation begins.
  A cursor from a replaced transcript answers 409 and the chat starts over
  from the newest page.
- Codex 0.156 writes paginated rollouts: a backtrack or fork starts a new
  file whose session_meta.history_base names the earlier history by thread
  and byte offset. The chat followed only the new, nearly empty file; it now
  reads the whole chain, without the turns a backtrack discarded.
- Claude Code's <pasted_content> wrapper is unwrapped: the chat, like the
  TUI, shows only the pasted text.
- An opened conversation starts at its end instead of smooth-scrolling there
  from the top, and stays at its end when the view shrinks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…und every poll to a page

Review follow-ups:

- A thread gets one rollout per backtrack, and a later backtrack can cut
  into any of them. The chain took the latest earlier rollout by name and
  size, so after a second backtrack to before the first one it continued the
  wrong file. A cut names (thread, ordinal, byte); every record is one line
  and a rollout's ordinals start at its own first ordinal, so the rollout that
  continues is the one with exactly `ordinal - first` lines before the byte,
  ending a line there (checked on a three-rollout chain against Codex's
  thread_history_1.sqlite). A chain that stops at a cut no file holds shows
  less history, is not cached, and is looked up again next time.
- The newest page is read in one window even when no turn starts in it (it
  then starts mid-turn); only an older page, read once, reaches further back,
  a new chunk at a time.
- A held start no longer makes every poll read everything after it, and no
  longer answers 409 when the conversation outgrows a page: `from` answers
  the newest page once it has moved past the held start, and the chat
  fetches the turns in between with `before` + `since`, a page at a time,
  then holds the new start.
- The earlier-messages row keeps one height while loading, a pane switch
  resets the loading state and ignores a load still in flight, and the
  view-shrink pinning moved to the resizable composer, which needs it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Yoonwoo-Ha
Yoonwoo-Ha force-pushed the fix/chat-history-composer-windows branch from 31b0c6f to 8d5c8e0 Compare September 23, 2026 08:08
@Yoonwoo-Ha Yoonwoo-Ha changed the title Chat paging with Codex backtracks, resizable composer, per-window reload, Windows terminal font Chat paging with Codex backtracks Sep 23, 2026
@Yoonwoo-Ha

Copy link
Copy Markdown
Contributor Author

Thanks for the review. I've addressed all of it:

  1. Chain after several backtracks. A cut is (thread, ordinal, byte). The rollout that continues is the one whose first byte bytes hold exactly ordinal - first ordinal lines and end a line. I verified that rule against thread_history_1.sqlite on a real three-rollout chain. It never guesses from names or sizes. A chain that stops at a cut no file holds shows less history, isn't cached, and is looked up again next time. The new tests cover backtracks into earlier and later rollouts, and a missing holder that shows up later.
  2. Held-start polling. from now returns the newest page once that page has moved past the held start, so it no longer answers 409 and no longer re-reads from the held start. The chat fills the turns in between with before + since, one page at a time, and then holds the new start. The newest page reads one window even when no turn starts in it (it then starts mid-turn). Only older pages, which are read once, reach further back, a new chunk at a time.
  3. Split. The font, per-window reload, composer and effort changes are now Keep a monospace font sizing the terminal cells on Windows #9, Reopen each window's own pane on reload #10, Resizable composer, remembered per device #11 and Show the reasoning effort Claude Code records #12. This PR is paging only.

Minor: a pane switch now resets loadingOlder and ignores a load still in flight. The grip's aria-valuenow is fixed in #11. The branch is rebased on main after #8. The earlier-messages row also keeps one height while loading, so the reader no longer shifts by 16px when a load starts.

@devswha

devswha commented Sep 24, 2026

Copy link
Copy Markdown
Owner

Merged. Both earlier concerns are fixed: the chain is now verified against history_base, and polling reads at most the newest 16MB. Thanks.

One follow-up for a separate PR: cursors aren't tied to the earlier-rollout chain. The cursor id covers only the live file (conversation.ts:339). If the chain changes between requests, for example when an earlier rollout appears later (your own test allows this) or a cached chain is evicted and the parent has since been archived, old cursor positions point at different messages. parseCursor only checks the range, so it never returns 409. A throwaway test reproduced it: before:6254 returned prompts 0–19 instead of an empty page, and the gap fill then showed turns duplicated and out of order. Suggested fix: fold a fingerprint of the chain (paths plus byte ends) into the cursor, so a changed chain returns 409 and the chat reloads.

Minor: an unresolvable chain isn't cached, so every file change walks sessions/ again.

Yoonwoo-Ha added a commit to Yoonwoo-Ha/herdr-web-ui that referenced this pull request Sep 24, 2026
Follow-up to devswha#5's review. A cursor's id named only the live file, while its
offset counts from the start of the whole Codex chain. When the chain
changed between requests (an earlier rollout found later, or a parent
archived after its chain was evicted), an old cursor pointed at other turns:
`before` answered a page from elsewhere and the gap fill showed turns twice
and out of order, never a 409. The id now also carries a fingerprint of the
earlier rollouts (path, bytes used, file identity), so a cursor from another
chain answers 409 and the chat reloads; older pages are cached under the same
id, and the stream is identified before the cache is consulted.

A chain that stops at a cut no file holds is now remembered for 30s rather
than walked again on every append.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants