Skip to content

fix(site): split Working blocks at assistant text and scroll to their end on expand - #30656

Open
ibetitsmike wants to merge 19 commits into
mainfrom
mike/working-block-scroll-to-end
Open

ibetitsmike wants to merge 19 commits into
mainfrom
mike/working-block-scroll-to-end

Conversation

@ibetitsmike

@ibetitsmike ibetitsmike commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Expanding a collapsed Working block in the Agents chat kept the view where it was (#29354), so a block that expanded near the bottom of the view opened its steps below the fold, and the reader had to scroll to reach the latest one.

Expanding a Working block now brings its end into view. If the expanded block ends below the viewport, the transcript scrolls smoothly until the block's end sits at the bottom of the view; if it already ends in view, nothing moves. If a step then grows and pushes the end back out of view (an image loading), the view scrolls to the new end, until the reader scrolls or toggles a row, 2 seconds pass after its last scroll starts, or the block changes width because the view resized. When the block is the last row of the transcript, expanding it jumps to the end and resumes following new output. While a turn is anchored at the top of the view and one of its blocks expands in view, the view stays put and the anchor hold hands off to following once the reply fills the view, even with attached files below the block; if the expansion already used up the hold's tail spacer, it hands off at once. Collapsing is unchanged.

ToolCall.Root gets an opt-in revealEndOnExpand prop, set only by WorkingBlockDisclosure, so other tool rows keep their behavior. It passes { revealEnd } to the vendored message scroller's userLayoutIntent, whose toggle-commit MutationObserver calls the new revealItemEnd. When the reveal scrolls, it places the row and the tail spacer itself, so the existing resize pass (the anchor hold) runs only when the reveal does not scroll. The reveal records the row's size and an expiry, and the scroller's resize handler scrolls to the row's new end while that jump is still settling and the expiry has not passed. A change in the row's width ends the reveal. The jsdom MessageScroller layout harness moves from ToolCall.test.tsx to site/src/testHelpers/messageScrollerLayout.tsx so WorkingBlockDisclosure.test.tsx can reuse it.

Working blocks also split at assistant text now. Text the assistant wrote between steps streamed below the live block, then folded into it when the next tool call arrived. Every assistant text now closes the open block, and the next step opens a new block below it. That includes OpenAI narration and the lead-in before a search, which #30529 folded into the block. Files the agent attaches with attach_file do not close a block: they render below the block they were attached in, and the work after them stays in it. Before, attached files closed the block, so the reasoning before the final answer formed a block with no steps, which is dropped, and the reasoning showed on its own between the files and the answer.

groupWorkingBlocks splits each message row into alternating work and output pieces. Parsing records each content part's block index (ParsedMessageContent.partBlockIndices), so grouping can place each part without parsing the message again.

Splitting blocks at text puts one-line block rows and narration rows next to each other, so transcript rows now sit 12px apart instead of 8px, the gap the chat loading skeleton already uses. Steps inside an expanded block keep their 8px gap.

Text between two steps closes the first Working block, and the second step opens a new block below it

The attached file renders below the Working block, and the reasoning before the answer stays inside the block

The same dogfood chat with transcript rows 8px apart before and 12px apart after

Review notes

Deliberate choices from self-review (frontend-review FE1 to FE10 and coder-frontend-review):

  • behavior: "smooth" bypasses the reduced-motion rule in site/src/index.css (scroll-behavior: auto), as the existing chat scroll commands already do (submitChatTurn.ts, ConversationTimeline.tsx).
  • The last-row reveal jumps instead of gliding: following pins the end with an instant scroll on the next resize, which cut a smooth scroll short after one frame (measured in Chromium).
  • Following a growing block starts only after a reveal that scrolled, and ends 2 seconds after its last scroll starts, so later growth (a re-render, a late image) does not move a reader who stopped scrolling. A smooth glide uses part of that window. Each chase scroll renews the window, so images that load one after another stay followed. A width change ends it at once: a resize re-wraps the steps taller, and that is not growth the reader asked to follow.
  • Following a growing block stops on any scroll up, including one with no gesture (a focus move, find in page): the reveal only scrolls down, so a scroll up came from somewhere else. It only scrolls when growth pushes the end below the view, so it never pulls the view back up.
  • A block taller than the view scrolls its header above the top to show its end. Keyboard focus stays on the header button.
  • If rows below an expanded block, such as its attached files, already used up the anchor hold's tail spacer, the hold stays and the view does not follow the reply, as on main. Remote UAT hit this at 1440x560; at 1440x700 the view handed off. Handing off in that case would mean jumping to the end at the expand, which moves the block the reader just opened.
  • Collapsing a block that was revealed at the end of the chat can drop its header down the view: the content shrinks and the browser clamps scrollTop. main jumps the same way from the same scroll position; the collapse path is unchanged here.
  • The harness stubs requestAnimationFrame with setTimeout. jsdom runs frames on the global setInterval, and the existing live-label test fakes timers while a motion component requests a frame, which stalls every later frame in the file.
  • The test-only BlockRow stays separate from ToolCall.test.tsx's ToolRow: sharing them would add a render-prop abstraction for two callers.

Working block grouping:

  • Files attached in the middle of a turn no longer split a block. They render after the block's last step, so a block that attaches several files shows all of them below it.
  • File-reference parts stop closing blocks too. Only user messages carry them, and a user message already closes the block.
  • LabeledNarrationFolds and LeadInBeforeSearchFolds are now LabeledNarrationStaysVisible and LeadInBeforeSearchStaysVisible, so Pixel sees them as new stories.
  • frontend-review FE9 asks for story variants in named constants, while coder-frontend-review TS3 and TS4 ask for overrides at the call site and no single-use story constants, and that skill lets its checkpoints win. The two new single-use message variants in ConversationTimelineWorkingBlocks.stories.tsx stay inline, like the file's existing ones.
  • The mock attach_file result carries only name, the one field AttachFileLabel reads.
  • Reasoning at the start of a turn, before the first text, renders on its own above that text: the text closes the block, and completed reasoning with no steps stays visible, as main already shows for a turn with no tools. While that reasoning streams, a live Working block shows and dissolves when the text arrives.
  • When one render brings a live-only block's prompt page, its persisted step, and a cleared stream, the block keeps its scroller key by matching the start it recorded while live. The stream carries no reasoning timestamps, so a block that starts after text and streams reasoning before its first tool call can still remount in that render.

Row spacing:

  • The 12px gap applies to every transcript row, so Pixel shows a diff on every story that renders a chat transcript through ChatMessageScroller (the ConversationTimeline, ChatPageContent, AgentChatPageView, and AgentChatPage stories). The WorkingBlocks and LiveWorkingBlocks stories render a bare MessageScroller.Content with no gap, so they do not change.
  • The vendored scroller reads the computed row gap (getFlexGap in geometry.ts), so its spacer math follows the class.
Delivery record
  • Remote dogfood UAT through Coder Agents on an isolated dev deployment, desktop viewport, 8 scenarios on ccbb338f5cb. Round 1: FAIL on the collapse clamp above, which reproduces on main from the same scroll position; that scenario was reworded to record the clamp as an observation. Round 2 on the same SHA: PASS. That run did not test a turn split across history pages or non-desktop viewports; the later run below does. Between runs, unit tests and a Chromium probe story (not committed) that records every scrollTo call and the scroll position each frame cover the changes.
  • Nine new WorkingBlockDisclosure scroller tests (11 cases). Removing the reveal call, the prop, the !expanded guard, the in-view check, the anchored hand-off, the whole anchored branch, the spacer check, the growth chase, its settling-jump check, its in-view check, either height record, the clear on another jump, or the clear on a scroll up each fails at least one of them. The chase's growth check is only visible in a browser, where jsdom's instant scrollTo hides it: in the Chromium probe, removing it re-issued the smooth scroll on every resize during the glide (3 calls instead of 1 when a row below the block grew mid-glide).
  • Chromium probe on the final code: a block that grew 300 px mid-glide got a second smooth scroll at the growth and settled with its end 24 px above the bottom of the view; growth after the glide settled did the same; after focus() moved the view up to an earlier row, a later growth left the view there.
  • Codex round 1 on 715f799a8ed: two P2 findings (a cut-short smooth reveal at the last row; no hand-off when the expansion used up the anchor spacer). Both confirmed by execution and fixed in c7f98a67d99.
  • Codex round 2 on c7f98a67d99: one P2 finding (content that grows after the reveal, such as a lazy image, pushed the end back out of view). Confirmed with a failing test and fixed in 990cb3542af. Self-review then found that a scroll with no gesture left that chase armed; fixed in b928354858d and d7053a2de50.
  • Codex round 3 on d7053a2de50: no findings.
  • Rebased onto main at 99afdc3b0fb to pick up fix: fold web searches, answer reasoning, and OpenAI narration into working blocks #30529, which folds an answer's reasoning into the Working block it ends. The first commit now also exports isViewport from testHelpers/messageScrollerLayout.tsx, because the ToolCall.Scrollable tests from refactor(site/src/pages/AgentsPage/components/ChatElements/tools): add ToolCall.Scrollable #30648 use it and this PR moved it there. Commit SHAs above are from before the rebase; each rebased commit keeps its subject. Codex round 4 on the pre-rebase merge bde29b9a1cc: no findings. Codex round 5 on ab2fdeec307: no findings.
  • Self-review: coder-frontend-review (eight roles) and frontend-review (FE1 to FE10) over five rounds, the last on the Codex fixes, re-running each failing role after its fix until every role passed on d7053a2de50; findings fixed or justified above.
  • Working block grouping, f97f2a911d3 to 5f87b148d88: written and reviewed on a separate branch, then cherry-picked onto ab2fdeec307 with no conflicts and the same patch (git patch-id). coder-frontend-review ran 13 rounds until all eight roles passed, and frontend-review FE1 to FE10 passed apart from the FE9 note above. Red-green: letting attached files close the block fails both new attached-file tests, and the old live rule fails the live one.
  • On 5f87b148d88: tsc, biome, oxlint (including the design-system config), knip, the React Compiler check, and dpdm pass, and so do the unit tests in src/pages/AgentsPage, src/vendor, and src/testHelpers (2128 passed, 2 skipped) and the ChatConversation story tests (118).
  • A long dogfood chat with attached files, loaded read-only and rendered with the attached-file fix: the reasoning before the final answer is the last row inside its block, and the files and the answer follow the block.
  • Row gap, aac53217c4a: compared 8px, 12px, and 16px on the same read-only dogfood chat in Chromium at 1280 and 390 px widths. The story tests that render ChatMessageScroller pass (7 files, 215 tests), and coder-frontend-review (eight roles) and frontend-review FE1 to FE10 pass.
  • Review by @jaaydenh on aac53217c4a, fixed in 29a3bcb14b6: the growth chase had no end besides user input, and an expand forced up to four full transcript layouts. The chase now expires and ends on a width change. The reveal runs before the anchor hold, which it replaces when it scrolls, and the row's rect is read once, before the scroll's writes. Two new cases (the window passes, the block re-wraps at a new width) each fail with their check removed. The reorder changes no test outcome (29 pass either way); in a throwaway jsdom probe, an anchored expand made 21 getBoundingClientRect calls instead of 69. Not measured in a browser. coder-frontend-review passed after four rounds; the size check reuses SCROLL_POSITION_EPSILON, which upstream also applies to row positions. Site checks pass on the new head (unit 2130 passed, 2 skipped; ChatConversation stories 118).
  • Codex round 6 on 29a3bcb14b6: two P2 findings. When the newest loaded page opened with files the agent attached and later rows continued their block, the files rendered above a block labeled complete, and loading the older page moved the block above the files. Confirmed with a failing test and fixed in ffcae5a6b7e: the block on that page is partial and renders above the files from the start (story PaginatedFromAttachedFile). Declined: running the scroller tests in a real layout engine. They assert the rows the scroller publishes as visible, with the jsdom model main already uses, and the Chromium probe and remote UAT cover browser behavior.
  • Codex round 7 on ffcae5a6b7e: five style and structure findings. Fixed in acbd47dcc88: a hidden-entry assignment that nested a ternary, a nullish fallback, and a conditional, and a doc comment that restated a field name. Declined: extracting the row renderer into a component (the closure is on main, and the file goes from 795 to 798 lines), a named props type for the Transcript test helper (moved unchanged from ToolCall.test.tsx), and splitting a streamed row once rather than once per slice (the live row holds only the current step; in jsdom, a streamed update cost 0.04 ms for 3 sections and 1.8 ms for 50).
  • Codex round 8 on acbd47dcc88: one P2 finding. A live-only block that starts after text in the same stream remounted, collapsed, and lost focus when its prompt page, its persisted step, and a cleared stream arrived in one render. Confirmed with a failing test and fixed in 8bb9e94b76b. Codex round 9 on da079c0b460: no findings.
  • Remote UAT through Coder Agents on dogfood: an isolated dev deployment, real Anthropic and OpenAI models, desktop and 390 px viewports. Run 1 on 29a3bcb14b6: the reveal, the last row, the anchored turn, growth follow, grouping with real models, the row gap, collapse, and the mobile viewport pass, and the collapse clamp matches main. It also found gaps that main does not handle either: an anchored expand of a block with attached files below it never handed off to following (da079c0b460 fixes it while some tail spacer is left), an expand within about 30 ms of an older page starting to load loses the reveal, and growth after an expand that did not scroll is not followed. Part 2 on acbd47dcc88: with the page boundary at the attached files, the partial block renders above the files before and after the older page loads (the control, 29a3bcb14b6, reorders them); the reveal, growth follow, and real-model grouping pass, and the grouping refactor renders 4 web-search chats the same as ffcae5a6b7e. Part 3 on da079c0b460, with acbd47dcc88 as the control: an anchored expand of the live block with attached files below it hands off to following at 1440x700 (2 of 2 runs, control 0 of 1), and so does an expand of an earlier block of the turn (2 of 2, control 0 of 2); the reveal below the fold and the anchored expand of the last block pass. At 1440x560 the files used up the tail spacer before the expand, so the hold stayed, as on the control and main (see the review notes). Verdict on da079c0b460: PASS. Not tested: an OpenAI lead-in before a web search, OpenAI narration across three or more tool calls, and a real image that loads late inside a block (the growth checks injected it).
  • On da079c0b460: tsc, biome, oxlint (including the design-system config), knip, the React Compiler check, and dpdm pass, and so do the unit tests in src/pages/AgentsPage, src/vendor, and src/testHelpers (2134 passed, 2 skipped) and the ChatConversation story tests (119). coder-frontend-review (eight roles) and frontend-review FE1 to FE10 pass on each fix, with the failing roles re-run after their fixes.
  • CI on da079c0b460: the required checks pass. The Storybook (Pixel) job fails on one story this PR does not touch: the UserEngagementChart Loaded story's play function cannot find its "User engagement" group. fix(site/src): replace role="application" on recharts charts #30375 added that check to main with the wrong case, and fix: match chart accessible name in story #30907 fixed it on main after this run started. A re-run reuses this run's merge commit, so the job stays red until the next push.

Xum opened this PR on behalf of @ibetitsmike.

@ibetitsmike

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-15T23:46:48.524200Z da079c0 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 715f799a8e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread site/src/vendor/message-scroller/use-message-scroller-controller.ts Outdated
Comment thread site/src/vendor/message-scroller/use-message-scroller-controller.ts Outdated
@ibetitsmike

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c7f98a67d9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread site/src/vendor/message-scroller/use-message-scroller-controller.ts Outdated
@ibetitsmike

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: d7053a2de5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@linear-code

linear-code Bot commented Oct 13, 2026

Copy link
Copy Markdown

CODAGT-1114

@ibetitsmike
ibetitsmike marked this pull request as ready for review October 13, 2026 12:07
@ibetitsmike
ibetitsmike requested a review from a team as a code owner October 13, 2026 12:07
@pr-queue-manager
pr-queue-manager Bot requested a review from jaaydenh October 13, 2026 12:24
@ibetitsmike

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

Reviewed commit: bde29b9a1c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Expanding a Working block now brings the block's end into view. At the
end of the transcript it resumes following, so a live block keeps its
newest steps on screen. Other tool rows and every collapse keep holding
the view in place.

Moves the jsdom MessageScroller layout harness into a shared test helper.
…e Working block anchored handoff and collapse paths
userLayoutIntent now takes a required MessageScrollerUserLayoutIntentOptions,
the comments describe the last-row hand-off to following precisely, and the
revealEndOnExpand doc sits on its field.

The layout test helper exposes currentVisibleRows() and flushLayout() instead
of a mutable export, and BlockRow reuses ControlledDisclosure.
@ibetitsmike
ibetitsmike force-pushed the mike/working-block-scroll-to-end branch from bde29b9 to ab2fdee Compare October 14, 2026 00:59
@ibetitsmike

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

Reviewed commit: ab2fdeec30

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…king blocks at assistant text

Text the assistant writes between steps streamed below the live working
block and then folded into it once the next tool call arrived, because
text before a row's last tool counted as work. Every assistant text now
closes the open block, and the next work opens a new block below it, so
a row splits into alternating work and output pieces.
…rontend review findings

Parsing now records each content part's newest block index in
ParsedMessageContent.partBlockIndices, so grouping no longer parses
every message a second time.

The grouping helpers are simpler, and their names say what they hold:
RowSlice.pieces is now pieceRange, and the work piece ordinals are
workOrdinal and liveWorkOrdinal. Tests and stories keep to the behavior
each one names and reuse the working fixtures, and comments that
restated the code are gone.
…ing blocks open across attached files

Files the agent attached closed the open working block like text does.
The reasoning before the final answer then started a block of its own,
which has no steps and is dropped, so the thinking rendered loose under
the attachments.

Only text closes a block now. Attached files render below the block they
were attached in, and the work after them, including the answer's
reasoning, stays in it. A block also stays live while only attachments
follow its last step.
…ess review of the attached-file fix

The block field that records what closed a block is now closedByRowType,
since it holds a row type, and the comments around the attached-file
rule are shorter and state why files leave the block open.

The grouping tests build the attach_file step with one attachStep
helper, and the call helper accepts tool arguments, so the attach_file
call carries a path as the backend requires. Both working-block story
files share MockAttachedFileMessages, and the new
AttachedFilesRenderBelowLiveBlock story shows a live block that keeps
working while an attached file follows its last step.
@ibetitsmike ibetitsmike changed the title fix(site): scroll to the end of a Working block when it expands fix(site): split Working blocks at assistant text and scroll to their end on expand Oct 14, 2026
… transcript rows

Splitting Working blocks at assistant text leaves one-line block rows
and narration rows next to each other, and 8px apart they run together.
Space transcript rows 12px apart, the gap the chat loading skeleton
already uses. Steps inside an expanded Working block keep their 8px
gap, so they still read as one group.

@jaaydenh jaaydenh 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.

Two performance notes on the reveal chase in the vendored scroller, from reading the code (not measured in a browser).

Comment thread site/src/vendor/message-scroller/use-message-scroller-controller.ts Outdated
Comment thread site/src/vendor/message-scroller/use-message-scroller-controller.ts Outdated
A revealed row's growth kept scrolling its end back into view until the
reader scrolled, so a resize re-wrap minutes later pulled the view down
once per frame. The chase now ends 2s after its last scroll, and when the
row changes width.

The toggle observer ran the anchor hold and then the reveal, each a full
scroll pass, and read the row's height after the scroll's writes. The
reveal now runs first and the resize handler only runs when it did not
scroll, and the row's rect is read once, before the scroll.
@ibetitsmike
ibetitsmike requested a review from jaaydenh October 15, 2026 18:07
@ibetitsmike

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 29a3bcb14b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread site/src/testHelpers/messageScrollerLayout.tsx
Comment thread site/src/pages/AgentsPage/components/ChatConversation/workingBlockGrouping.ts Outdated
…rtial Working block above the files that open a page

A history page that started with a row of attached files, followed by
more of the block's work, rendered the files above a new block and
reported that block as complete. Loading the older page then moved the
files below the block and raised its step count and duration.

Until assistant text or a row that never folds loads, grouping now
treats the loaded rows as a possible continuation of a block from older
history. The first block opened in that state is partial, and it renders
above the leading file rows, where it stays once the older page loads.
The PaginatedFromAttachedFile story renders that page.
@ibetitsmike

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ffcae5a6b7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread site/src/testHelpers/messageScrollerLayout.tsx
Comment thread site/src/pages/AgentsPage/components/ChatConversation/workingBlockGrouping.ts Outdated
Comment thread site/src/pages/AgentsPage/components/ChatConversation/workingBlockGrouping.ts Outdated
…lify the hidden-entry fallback in Working block grouping

The fallback that assigns a hidden entry, such as a search result that
rendered no row, to a block combined a ternary, a nullish fallback, and
a conditional expression in one assignment. It now starts from the block
open after the previous row, and an explicit if assigns a search to the
block that the next row's first work joins, which is that same block
when one is open. Also drops a doc comment that only restated
DraftSlice.workOrdinal.
@ibetitsmike

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

💡 Codex Review

block.isLive && block.startedAt === Date.parse(identity.streamStartedAt)

P2 Badge Preserve later live blocks across simultaneous paging handoff

When a live-only block starts after earlier reasoning or text in the same stream, its startedAt is the later tool timestamp while identity.streamStartedAt remains the stream's initial timestamp. If older history is prepended in the same render that this block persists and the stream clears, the prepend changes its head/ordinal-derived live key, and it has no previously mapped durable piece key; this comparison therefore rejects the only continuation fallback. The disclosure remounts, collapses, and loses keyboard focus, so retain the individual block's live start in the identity instead of comparing it with the whole stream's start.

AGENTS.md reference: site/AGENTS.md:L11-L11

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…ve block that starts after text mounted across paging

When an older page, a live-only block's persisted step, and the cleared
stream arrive in one render, the block keeps its scroller item key only
if its persisted start matches the start recorded while it was live.
That check used the start of the whole stream, but since text closes a
block, a block that starts after text in the same stream starts at its
own tool call. The block remounted, collapsed, and lost focus. The live
block identity now records the block's own start.
…s attached files

While a turn is anchored, expanding its last block in view lets the
anchor hold hand off to following once the reply fills the view. That
applied only when the block was the scroller's last item, but attached
files now render below the block, so in a turn that attached files the
view never handed off and the answer streamed below the fold. The check
now applies to any row that expands in view inside the anchored turn.
@ibetitsmike

Copy link
Copy Markdown
Collaborator Author

Re the Codex finding Preserve later live blocks across simultaneous paging handoff: confirmed and fixed in 8bb9e94. A test reproduced it: a stream of reasoning, text, and then a tool call, with the live block expanded, followed by one render that brings the prompt page, the persisted step, and no stream. The block remounted and lost focus. The live block identity now records the block's own start, and the fallback compares the persisted block's start with it instead of with the stream's start. The new it.each row keeps an open live-only block that started after text mounted when its prompt page and persisted step arrive together fails without the fix, and the existing same-render case still passes.

Xum posted this reply on behalf of @ibetitsmike.

@ibetitsmike

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

Reviewed commit: da079c0b46

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ibetitsmike
ibetitsmike requested review from jaaydenh and removed request for jaaydenh October 16, 2026 00:10

@jaaydenh jaaydenh 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.

Accessibility pass on the reveal-on-expand and block-splitting changes. Three inline comments below, roughly in priority order: the first two are worth fixing before merge, the third is worth considering.

One more, smaller point with no single line to anchor it: splitting at every assistant text produces many more Working disclosures with near-identical accessible names (several Worked for 3s (1 step) buttons in one turn). Screen reader users who navigate by the button list cannot tell them apart. That pattern existed before, but this PR makes it much more common; adding some context to the name (for example a short step summary) would help.

The scroll findings follow from how browsers handle focus navigation and the CSSOM behavior option; I did not reproduce them in Chromium.

return true
}

if (!endsBelow) {

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.

When the expanded block is taller than the view, scrollToItemEnd aligns its end to the bottom and pushes the header button above the top while it keeps keyboard focus (the review notes call this out as intended). For keyboard users that has two costs:

  • The focus indicator leaves the screen right after Enter or Space.
  • The next Tab moves focus to the first step inside the block, at its top. The browser scrolls that step into view, the view jumps back up, and the upward scroll clears revealedItemRef. So for keyboard users the reveal is undone by their next keystroke.

Suggestion: never scroll past the header, i.e. scrollTop = min(endAlignedTop, headerTop - margin), which amounts to align: "start" when the block is taller than the view. Short blocks still show their end, and both the focused header and the next Tab target stay visible.

// recorded after the scroll.
const scrollToItemEnd = React.useCallback(
(item: HTMLElement, rect: DOMRect) => {
scrollToElement(item, { align: "end", behavior: "smooth" })

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.

behavior: "smooth" passed from JS overrides the scroll-behavior: auto !important rule under prefers-reduced-motion in index.css, so reduced-motion users still get the animation. The existing commands do the same, but this one differs: it is not a single user-requested scroll. The chase in handleResize calls it again on every growth during the 2s window, and each call renews the window, so a few images loading one after another produce repeated motion the user did not start (WCAG 2.3.3).

A one-line fix:

const behavior = window.matchMedia("(prefers-reduced-motion: reduce)").matches
  ? "auto"
  : "smooth"

The same helper could cover the existing scrollToMessage callers in a follow-up.

getStepCount(draft) > 0 ||
(draft.containsLiveRow &&
options.isTurnActive &&
draft.closedByRowType === undefined),

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.

Since text now closes the open block and a closed block with no steps is dropped here, a model that writes text, then reasons, then writes text (OpenAI narration, or reasoning at the start of a turn) shows a live, expandable Working button that unmounts when the text arrives. Before, that reasoning joined the already open block. If the user has focused or expanded that button, focus falls back to <body> and the content they opened collapses into an inline reasoning row. The remount case mentioned in the review notes (a block that starts after text and streams reasoning before its first tool call) loses focus the same way.

Options: keep the block while it is focused or expanded, or move focus to the reasoning row that replaces it.

@ibetitsmike
ibetitsmike added this pull request to stack #31026 October 19, 2026 14:37
ibetitsmike added a commit that referenced this pull request Oct 19, 2026
…ocks and list them under Attachments (#30788)

In `/agents` chats with **Collapse assistant steps** on, files the agent
attaches (`attach_file`, computer-use screenshots, the `propose_plan`
plan file) rendered as the answer after a Working block: the block
closed at the attachment, and a turn that kept working split into
several blocks. They now fold into the Working block of the step that
made them and show when it is expanded. Rows outside every block (an
uncollapsible `propose_plan` or `ask_user_question` row, a parked
running tool) hide their files, and an agent row left with nothing else
is not rendered. With the preference off, the transcript is unchanged,
except that download cards get the focus ring the text-preview button
beside them already has.

To keep those files reachable, the right panel's Summary tab gets a
collapsible **Attachments (N)** section, open by default. It lists the
files the agent attached in the loaded messages, newest first, as the
transcript's download cards (expired tile included); user uploads stay
in their messages. While older history is unloaded, the section says so
and offers **Load earlier messages**. Until the first message page
reaches the chat store, the header shows no count, so a chat with files
never flashes "(0)", and live messages do not make the list blink.

Frontend only: production code changes 366 lines; the rest is tests,
stories, and fixtures. This overlaps #30656, which renders attached
files below the Working block; this PR keeps them inside the block, as
the newer request asks, so whichever lands second reconciles
`workingBlockGrouping.ts` and `ConversationTimeline.tsx`.

**Collapse on.** The turn's files stay in the collapsed Working block,
and the Summary panel lists them under Attachments (3), newest first.

![Collapsed Working block beside the open Attachments
section](https://github.com/user-attachments/assets/5ed406b4-cca6-4e3e-8bd6-aaa46d69cbd0)

**Block expanded.** Each file follows the step that attached it.

![Expanded Working block with each file after its
step](https://github.com/user-attachments/assets/b3171de7-a04f-442b-bb28-2aab0e54e978)

**Expired file.** After `old.zip` expires, the panel shows the expired
tile in its place.

![Attachments section with an expired
tile](https://github.com/user-attachments/assets/400aa5ab-503f-436d-a20e-b06a3975806c)

Follow-ups, out of scope: split `ConversationTimeline.tsx` (795 lines
before, 805 now); keep focus on load-more buttons while they fetch
(`HistorySidebar`, `InboxPopover`, this one); show the fetch error in
both message pagers; share probe-learned expiry between the transcript
and the panel; flag evicted files newer than the newest linked one, such
as an evicted newest file (`deriveEvictedFileIds` cannot yet tell them
from files the chat has not linked yet); name the file on the expired
tile.

## Testing

- `pnpm lint`, the AgentsPage unit tests (122 files, 2133 tests), and
the story tests for the touched area (13 files, 261 tests) pass on
30007ac.
- New unit tests cover a step's attachment staying in its block, rows
outside blocks dropping files, `isAgentAttachmentOnlyRow`,
`getAgentAttachments` (agent files only, newest first), the loading gate
(a live message does not blank the list), and the load button. Each
fails when its behavior breaks.
- New stories: `ChatAttachments` (12 states), four `ChatSummaryPanel`
states, and three working-block states.
- Remote UAT round 4: **PASS** on 3e47d30, on a dev deployment built
from the branch. It checked expired files on both surfaces, mouse and
Enter downloads against the source files' sha256, no "(0)" on reload or
chat switch, one count change per live attachment, and the focus ring.
30007ac changes only a doc comment after it, and the screenshots
come from this round. Rounds 1 to 3 passed on earlier heads; round 3
covered Load earlier messages, rows outside blocks, and 18 real-model
runs on four models.

<details>
<summary>Review record</summary>

- 11 rounds of `coder-frontend-review` (8 global roles),
`frontend-deep-review` (8 project roles), and `frontend-review` (FE1 to
FE10), read-only. Round 9 ran all 17 roles on b53897c; rounds 10 and
11 re-checked the comment-only fixes on 3e47d30 and 30007ac.
- Final verdicts: every checkpoint PASS except FE5 and FE6 (justified
below). RP6's PASS requires the `ConversationTimeline.tsx` split
follow-up listed above.
- Test audit: PASS through b53897c. Every finding not listed below
was fixed.
- Codex: four rounds on 30007ac. Each of its five P2 findings was
declined in its thread with evidence: a spinner for the one-commit
hydration gap, the file name on the shared expired tile, the copy action
for a text-and-file message that chatd never stores, gating the load
button on hydration, and the `ChatAttachments` doc. Round 4 found no
major issues.

Justified FAILs:

- FE5, no error text for a failed page: the transcript's pager shows the
same retry-only state for the same query; follow-up.
- FE6 and SA6, the button disables while fetching and loses focus: the
repo's load-more pattern (`InboxPopover`, `HistorySidebar`); follow-up
for all three.

Dropped findings (finding: reason):

- RO6 stack split: the component is 94 lines, and the hiding needs the
panel, so the halves are not independent.
- RP6 and RO5, split `ConversationTimeline.tsx` now: the PR adds call
sites, a prop, and two flags; follow-up, and #30656 edits it.
- RO1 `SidebarSectionHeader`: a sidebar h2 with no aria-controls; the
section uses the Radix Collapsible in `SectionHeader`.
- RO1 `isFileRenderBlock`: inline `block.type` checks are the grouping
module's idiom.
- RO2 shared retry button: the two share only variant, size, and label.
- RO3 `every` helper: a one-line idiom with different predicates in two
modules.
- RO3 shared guard with `getMemberRow`: two conditions, and #30656
rewrites `getMemberRow`.
- RO3 `Omit` over `Pick`: the `Pick` reuses the exported
`EarlierMessagesProps` types.
- RO3 page-level spread: `AgentChatPageView` passes every prop
explicitly.
- RO4 move `isAgentAttachmentOnlyRow`: it takes the grouping module's
row type and sits beside `getRowBlocks`.
- RO5 transcript focus ring: intended; it matches the text-preview
button.
- SD1 inline single-use story constants, and repeated fixture values:
FE9 asks for named spread constants.
- SD2 `{ message }` wrapper: retyping moves the map into
`ConversationTimeline.tsx`, which #30656 edits.
- SA2 `cva` for `fullWidth`: one boolean in a pre-existing component.
- TN4 inline `type` imports: the repo's style for mixed imports.
- TN4 `ChatStoreHandle`: `ChatStore` is the exported type; the handle is
a file-local alias.
- TN4 named `FileCardProps`: the file types its components inline.
- TN4 `GetRowBlocksOptions`: named like `GroupWorkingBlocksOptions`.
- TN5 `AgentAttachment` beside `readAgentAttachmentText`: that one reads
user uploads, in another module.
- TN6 copy: the repo's paged empty state (`SessionTimeline.tsx`) and the
transcript's retry label.
- CD1 `getAgentAttachments` doc: it states the ordering and the upload
exclusion.
- CD1 trim exported docs: AGENTS.md requires docs on exported symbols.
- CD4 `isHydratingMessages` comment: pre-existing and consistent with
its source.
- CD5 consumers in the `EarlierMessagesProps` doc: the doc states the
contract.
- `splitRowBlocks` doc: the base text stays; a comment at the file
condition gives the reason.
- Hydration comment placement: it sits on the operand it explains.
- Deleted `NotVisible` comment: this PR made it false.
- TS1 and TS5 `NotVisible` assertions: pre-existing.
- TS7 panel story with `hasMoreMessages`: tsc enforces the forward;
`WithEarlierMessages` is the visual.
- TS7 streamed-file story: live rows share the `getRowBlocks` call.
- TS7 text-plus-file case and `ChatError` story: the grouping test fails
on the same break; no new state.
- TS7 raster image fixture: grouping treats every file part alike.
- TS7 text and a file in one message: chatd persists a file as its own
message; the unit test covers the branch.
- Play-function assertions and a collapse test: plays drive state, and
Radix owns collapsing.
- `useChatMessages` unit test: a moved derivation with no new logic.
- Drop `LoadingMessages` and `LiveMessageArriving`: each catches a
call-site break (test audit).
- A store per story, built in render: as `ChatPageContent.stories` does.
- `NotVisible` capture shows the section: it renders whatever
`isVisible` is.
- RP4 hoist the `.map` callback: a single-use wrapper (site/AGENTS.md).
- RP5 `useChatMessages` as a function: it owns two subscriptions that
three components share.
- RP8 pre-existing render helpers: unchanged; the split follow-up covers
them.
- Per-render derivations and a second subscription: pure derivations;
only the queries are gated.
- `useFileProbes()` before the branches: hooks cannot be conditional,
and the default context is safe.
- DF5 share probes with the panel: FileCard never probes; both read
`chat.files`, polled every 5 s while the chat runs.
- DF5 every loaded file evicted: a pre-existing `deriveEvictedFileIds`
gap; follow-up.
- DF5 empty before query data: the page shows its loading view then.
- Spinner before the store fills: a one-commit window, so it would
flash.
- Previous chat after a switch: the page remounts per chat with its own
store.
- `void onFetchMoreMessages()`: `fetchNextPage` reports errors in its
result, and the transcript calls it the same way.
- Later parts first within a message: later parts are newer.
- Up chevron when open: as `ChatSectionHeader` does.
- `messageHelpers.ts` imports `chatAttachments.ts`: the page already
loads it; dpdm finds no cycle.
- Live status on a live row whose file is hidden: intended.
- `m-0` on the empty state: `index.css` sets paragraph margins.
- FileCard's name omits "Download file", and the expired tile names no
file: pre-existing; naming the file is a follow-up.

UAT observations, not failures:

- A file in a message that also holds text after the tool call is listed
only under Attachments, as for any row outside a block. Round 3 first
reported it as a FAIL; only DB-seeded rows had that shape, and 18
real-model runs never did.
- An evicted newest file keeps its link until a newer linked file exists
(follow-up above).
- The panel scroller clips the bottom of a card's focus ring at its
bottom edge.
- The load button has no aria-busy, and after a failure two buttons read
"Retry loading earlier messages".
- One parallel step with two files renders both tool rows before both
cards.
- The transcript's expired tile is 112px wide beside 384px cards, and
its tooltip is mouse-only (pre-existing).

</details>

> Xum opened this PR on behalf of @ibetitsmike.

This branch has not been deployed

No deployments
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.

2 participants