Skip to content

Keep older timeline pages loaded while a thread streams - #3686

Merged
SawyerHood merged 3 commits into
mainfrom
bb/fix-content-flickering-issue-thr_i4ciaij4fu
Sep 15, 2026
Merged

SawyerHood merged 3 commits into
mainfrom
bb/fix-content-flickering-issue-thr_i4ciaij4fu

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

Human comments

What was wrong

Since #3325, mergeLoadedTimelineWithLatest replaces every loaded row whenever the latest page's historySnapshot changes, and the snapshot includes maxSeq, so each appended event unloads all older pages. When the previous conversation group is larger than the 1,500-event latest-page budget, it exists only on an older page. While a thread streams, each event removes it, the "Loading older messages" sentinel scrolls back into view, auto-load fetches it again, and the next event removes it again. The controller also discarded an older page still in flight whenever the snapshot moved. Separately, a page's older cursor used the group's first displayed row. Rows recorded after a request but displayed above its message, such as "Provider environment resolved", formed their own group with a higher sequence, so the first older request re-served the current group and merging it moved that row below the reply.

What changed

  • packages/client-core mergeLoadedTimelineWithLatest: when a new latest snapshot reaches the loaded tip, loaded older pages stay and only the rows the latest page covers are replaced. It still rebuilds from the latest page when the pages do not line up, when they share no rows and leave a gap, or when the server reports a later change to a row the page omitted. On a cursor tie it keeps the walk's cursor. prependOlderTimelineRows keeps the loaded order when an older page repeats loaded rows.
  • useThreadTimelineController: an in-flight older page is applied when its request cursor is still the loaded olderCursor, instead of requiring an unchanged snapshot.
  • apps/server pagination: new timelinePage.olderRowsSourceSeqEnd, the greatest sourceSeqEnd among rows the snapshot projected before the page's returned rows but omitted (null when none). That covers older conversation groups, owned groups a size cut left out, and the leaves a content cut dropped from the page's oldest group, so a command that completes before a content cut still triggers a rebuild. A row that keeps changing before a content cut therefore rebuilds on each refresh. Leading rows recorded after a group's message now join that group, and its cursor is the message row.
  • packages/server-contract: the field is optional in timelinePageMetadataSchema, matching the other snapshot fields. No daemon wire change, so HOST_DAEMON_PROTOCOL_VERSION is unchanged.
  • docs/timeline-pagination.md describes the new rule. This relaxes Fix timeline pagination with snapshot-bound conversation groups #3325's "a new latest snapshot replaces loaded rows" behavior for refreshes that only append to newer rows; a later change to a row the page omitted still replaces them.
  • One Fix timeline pagination with snapshot-bound conversation groups #3325 assertion changed in timeline-event-budget.test.ts. After a late append that sorts into the newest group, the merge must now equal the full canonical timeline rather than the latest page alone.

How you verified

  • New client-core merge tests: a streaming refresh keeps older pages, a tie keeps the walk cursor, a content-cut running turn keeps its head, a group starting right after the tip appends, no overlap with a gap rebuilds, a change to an omitted row rebuilds, and a repeated group keeps the loaded order. 5 of these fail against main's merge code.
  • New controller test: an in-flight older page applies across refreshes that advance the snapshot, and older rows survive later refreshes.
  • New server unit tests: the cursor anchors at the message row, and the rebuild value covers context-only older groups, owned groups a size cut left out, and rows before a content cut.
  • Endpoint tests: walked history stays equal to the canonical timeline after an append to the newest group. A late command completion reports a rebuild both in an older group and before a content cut in the newest group; after the rebuild and reloading older pages, the rows equal the canonical timeline. The content-cut case fails with the previous pagination code.
  • pnpm exec turbo run typecheck for @bb/server-contract, @bb/client-core, @bb/server and @bb/app, and earlier for @bb/cli and @bb/mobile.
  • pnpm exec turbo run test for @bb/client-core, @bb/server-contract, @bb/cli, and @bb/server -- test/services/threads test/provider-corpus test/routes. The provider corpus correctness walk skipped because no corpus was available.
  • App Vitest run directly for src/components/thread/timeline, src/views/thread-detail and src/hooks/queries, because the PWA icon check fails in this checkout.
  • Browser, isolated source instance with BB_FF_TIMELINE_WINDOW_EVENT_BUDGET=20 and real Claude Code turns sent from the composer. A MutationObserver recorded whether the previous assistant message stayed in the DOM while a follow-up streamed. With main's client merge it left and returned 8 times within about 4 seconds, and "Loading older messages" showed in 19 samples. With this change it never left over a 30-second turn.
  • Replayed a real affected thread's events through the server at each of 49 consecutive sequences, feeding both clients. With main's client the previous message dropped on 49 of 49 refreshes; with this change, 0. The rebuild value stayed null throughout.
  • The browser check ran before rebasing onto Speed up thread rendering while content streams #3591, and the replay before the omitted-rows change. Tests and typechecks were rerun after both.

🤖 Generated with Claude Code

AGENT GENERATED

SawyerHood and others added 3 commits September 14, 2026 16:38
Every appended event changes the latest page's history snapshot, and the
client replaced all loaded rows on any snapshot change. Messages that only
existed on an older page disappeared on each event and reloaded, which
flickered while a thread streamed.

Keep loaded older pages when the latest page reaches the loaded tip and
the server reports no later change to an older group through the new
timelinePage.olderGroupsSourceSeqEnd field. Apply in-flight older pages
whose cursor is still current, keep loaded order when an older page
repeats rows, and anchor a group's cursor at its message row.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
The latest page only reported changes to older conversation groups. When
it cut the content of its oldest group, rows before the cut kept their
loaded state, so a command that completed later stayed pending.

Rename the page field to olderRowsSourceSeqEnd and include every projected
row the page omitted before its returned rows: older groups, owned groups
a budget cut left out, and leaves a content cut dropped from the oldest
returned group.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@SawyerHood
SawyerHood merged commit 18d20f3 into main Sep 15, 2026
16 checks passed
@SawyerHood
SawyerHood deleted the bb/fix-content-flickering-issue-thr_i4ciaij4fu branch September 15, 2026 00:34
SawyerHood added a commit that referenced this pull request Sep 15, 2026
## Human comments

## What was wrong

When a turn finished, the thread timeline always folded its work into
one "Worked for" row and left only the final answer visible.
`groupCompletedTurnMessages` in `@bb/thread-view` ran for every
completed turn, and there was no provider declaration field or user
setting to turn it off. For agents that narrate their work as they go,
such as Claude Code, the narration and tool rows disappeared from view
as soon as the turn ended. #3144 reports this for Claude Code turns
shaped `text, tool, text, ...`: prose the user was reading while the
turn ran collapses into the summary row at completion.

## What changed

- **Provider default.** `bb.providers.register` accepts
`completedTurnDisplay: "collapse" | "flat"`, defaulting to `"collapse"`.
Host policy validates it and it is projected onto
`ProviderInfo.completedTurnDisplay`. Claude Code declares `"flat"`. The
field has no `experimental_` prefix, so an older server ignores it
instead of refusing to load the plugin. Plugin SDK 0.4.95.
- **User setting.** New general setting `providerCompletedTurnDisplay`,
a map of provider id to `collapse` or `flat`. A provider without an
entry uses its default.
- Settings → Providers has a "Collapse finished turns" section with one
switch per provider. Setting a switch to the provider's default removes
the entry.
- `bb settings completed-turns [provider-id] [collapse|flat|default]`
lists each provider's display and where it comes from, or sets one;
`default` removes the entry.
- **Server.** The timeline, turn-summary details, and conversation
outline routes resolve the display once per request (setting, then
provider default, then `collapse`) and pass it to the `@bb/thread-view`
builders. It is part of the timeline response cache key, the cursor
surface, and the outline projection key, so a setting change rebuilds at
the same sequence. A cursor minted under one display returns 400 under
the other. A flat build keeps full turn detail, so summary pages still
carry every finished-turn row.
- **Client.** Timeline responses include `completedTurnDisplay`.
`resolveLoadedTimelineSurfaceKey` (`@bb/client-core`) folds it into the
thread timeline controller's surface key. Since #3686 a refresh keeps
older loaded pages, and without this a display change left those pages
in the previous display until reload.
- **Docs.** `docs/configuration.md`, `docs/provider-plugin-api.md`,
`docs/timeline-pagination.md`, the bb-cli skill references, the
customization guide template, and the plugin-authoring references.
- No server/daemon wire change; `HOST_DAEMON_PROTOCOL_VERSION` is
unchanged.

## How you verified

- New tests:
- `packages/thread-view/test/completed-turn-summary-rendering.test.ts`:
a flat finished turn keeps the rows it had while running, including a
turn whose work summary detail would otherwise drop; a turn-details
request in flat mode returns the ungrouped rows. Removing the flat
branch or the full-detail override makes these fail.
-
`apps/server/test/public/public-thread-timeline-completed-turn-display.test.ts`:
Claude Code threads render flat by default and Codex threads collapse; a
setting change applies to the timeline, turn details, and outline at the
same `maxSeq`; with several loaded pages, switching the display replaces
the loaded pages and reloading older pages matches the canonical
timeline. Removing the display from the cache key, the outline key, or
the surface key makes these fail.
-
`apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx`:
loaded older rows are replaced when the display changes.
- Declaration validation, first-party registration defaults, `bb
settings completed-turns`, and the Providers switches.
- `pnpm exec turbo run typecheck` across the repo.
- `pnpm exec turbo run test` for `@bb/domain`, `@get-bb/plugin-sdk`,
`@bb/thread-view`, `@bb/server`, `@bb/cli`, `@bb/app`,
`@bb/client-core`, `@bb/db`, `@bb/sdk`, `@bb/server-contract`,
`@bb/provider-parity`, `@bb/templates`, `@bb/demo-server`,
`@bb/plugin-api-map`, `@bb/provider-bridge-protocol`, and
`bb-plugin-provider-claude-code`. Everything passed except two
`@bb/server` timeouts under parallel load (`public-thread-data`,
`timeline-in-turn-window`), which pass when run alone. On this machine
the `@bb/server` run also logs `ENOSPC` watcher errors from
`builtin-plugins.test.ts`, which happen on clean main too because the
machine's inotify watch budget is exhausted.
- `node .github/workflows/check-plugin-sdk-version.mjs` and `node
packages/plugin-sdk/scripts/check-npm-version-guard.mjs` pass.
- Provider corpus: row snapshots from the base commit and from this
branch are identical in row content for all 340 threads. The only
differences are `timelinePage.historySnapshot` and `olderCursor`, whose
cursor surface now includes the display.
- Manual, in a dev app: a real Claude Code turn with narration and two
file reads stayed flat when it finished. Switching Claude Code to
collapse in Settings → Providers folded it into "Worked for 2s",
expanding that row loaded its details, and switching back removed the
stored entry and survived a reload. `bb settings completed-turns` set
and cleared the same setting.

Fixes #3144
Fixes #3283

Related: #1656 reported the same disappear-on-completion effect for
replies to mid-turn messages.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

> AGENT GENERATED

Co-authored-by: Claude Code <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.

1 participant