From 7a7b196b2145c28b62c4cead66bbafdac39b7cd0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 12 May 2026 00:51:22 +0200 Subject: [PATCH] fix(site/src/pages/AgentsPage/components/Sidebar): keep subtitle in sync during streaming lifecycle (#25144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Agents sidebar shows the per-chat turn-end summary as a subtitle, but the cached `last_turn_summary` is not always in sync with the live status: - **Resuming a turn**: when a chat goes back to `running` or `pending`, the sidebar would keep displaying the previous turn-end label (e.g., "Chat is idle") while the agent is already streaming again. - **Stream end → next summary**: status flips to `waiting` synchronously, but the new `last_turn_summary` is generated by an async finalizer that calls the model. For a beat, the row still carries the previous turn's summary text, which would briefly flash before the new text arrives. This PR addresses both: 1. Override the subtitle with `{model} streaming…` while `chat.status` is `running` or `pending`, matching the same `isStreaming` definition `ChatPageContent` uses. Errors still take precedence so failure context is not hidden. 2. Capture the cached summary observed during streaming and suppress an exact match on the post-stream renders until the new summary lands. State is adjusted during render so the first post-stream paint already hides the stale string instead of flashing it for a frame. A 10s timeout safety net releases the suppression in the corner case where the regenerated summary equals the previous one byte-for-byte.
Decision log - **Frontend-only fix**: the server intentionally does not bump `updated_at` when it writes the new `last_turn_summary` (see `UpdateChatLastTurnSummary` in `coderd/database/queries/chats.sql`), and SSE delivers the status flip ahead of the new label. The sidebar knows the live status, so the rendering layer is the right place to mask the inconsistency. - **`isStreaming = running || pending`** mirrors `ChatPageContent.tsx` so subagents and queued continuations also flip to the live label. - **State, not refs**, for the suppression bookkeeping: the React Compiler rejects ref writes during render, and React's "adjust state during render" pattern is purpose-built for storing information from previous renders (state setters in render bail out and re-render synchronously before paint). - **10s timeout** is a safety net for the rare byte-for-byte equality case; the equality-based release handles every other shape of update. - Added Storybook stories covering both fixes (`ChatStreamingOverridesTurnSummary` and `StaleTurnSummaryAfterStreamingIsSuppressed`). All existing legacy subtitle stories still pass because they use terminal statuses.
> This PR was created by Coder Agents on behalf of @ibetitsmike. --- .../Sidebar/AgentsSidebar.stories.tsx | 117 ++++++++++++++++++ .../components/Sidebar/AgentsSidebar.tsx | 56 ++++++++- 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx index 53ace0e7b2..fdb461d169 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useEffect, useState } from "react"; import { useLocation } from "react-router"; import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; @@ -145,6 +146,122 @@ export const ChatWithTurnSummary: Story = { }, }; +/** + * While the chat is streaming again the cached last_turn_summary still + * holds the previous turn's text. The sidebar replaces it with a live + * "{model} streaming…" label so the status does not look stuck. + */ +export const ChatStreamingOverridesTurnSummary: Story = { + args: { + chats: [ + buildChat({ + id: "chat-streaming-running", + title: "Update workspace template", + status: "running", + last_turn_summary: "Added Docker and Terraform validation", + }), + buildChat({ + id: "chat-streaming-pending", + title: "Queued continuation", + status: "pending", + last_turn_summary: "Added Docker and Terraform validation", + }), + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.getAllByText("GPT-4o streaming…")).toHaveLength(2); + expect( + canvas.queryByText("Added Docker and Terraform validation"), + ).not.toBeInTheDocument(); + }, +}; + +/** + * After streaming ends the server flips status to "waiting" synchronously, + * but the new last_turn_summary is generated asynchronously and arrives a + * split second later. The previous turn's text would otherwise flash for + * that beat. The row remembers the summary observed while streaming and + * suppresses it on the post-stream render until the new summary lands. + */ +export const StaleTurnSummaryAfterStreamingIsSuppressed: Story = { + parameters: { + layout: "fullscreen", + user: MockUserOwner, + reactRouter: reactRouterParameters({ + location: { path: "/agents" }, + routing: agentsRouting, + }), + chromatic: { disableSnapshot: true }, + }, + render: (args) => { + const initialSummary = "Added Docker and Terraform validation"; + const freshSummary = "Validated provider configs and exited cleanly"; + const [phase, setPhase] = useState< + "streaming" | "stale-after-stream" | "fresh" + >("streaming"); + useEffect(() => { + if (phase !== "streaming") { + return; + } + const id = setTimeout(() => setPhase("stale-after-stream"), 50); + return () => clearTimeout(id); + }, [phase]); + const chats = [ + buildChat({ + id: "chat-stale-summary", + title: "Update workspace template", + status: phase === "streaming" ? "running" : "waiting", + last_turn_summary: phase === "fresh" ? freshSummary : initialSummary, + }), + ]; + return ( +
+ + +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Phase 1: chat is streaming, the cached summary is suppressed in + // favor of the live streaming label. + await expect(canvas.getByText("GPT-4o streaming…")).toBeInTheDocument(); + + // Phase 2: status flips to waiting while the previous summary is + // still cached server-side. The stale text must not flash; the + // fallback (model name) shows instead. + await waitFor(() => { + expect(canvas.getByTestId("flicker-harness")).toHaveAttribute( + "data-phase", + "stale-after-stream", + ); + }); + await expect( + canvas.queryByText("GPT-4o streaming…"), + ).not.toBeInTheDocument(); + await expect( + canvas.queryByText("Added Docker and Terraform validation"), + ).not.toBeInTheDocument(); + await expect(canvas.getByText("GPT-4o")).toBeInTheDocument(); + + // Phase 3: the async finalizer updates the summary. The new text + // is displayed, and the stale-suppression guard is cleared. + await userEvent.click(canvas.getByTestId("advance-to-fresh")); + await expect( + canvas.getByText("Validated provider configs and exited cleanly"), + ).toBeInTheDocument(); + }, +}; + export const ChatWithTurnSummaryAndError: Story = { args: { chats: [ diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx index 9d8e5f6a33..5b1237b564 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx @@ -500,7 +500,61 @@ const ChatTreeNode: FC = ({ chat, isChildNode }) => { ? chatErrorReasons[chat.id] || chat.last_error?.message || undefined : undefined; const lastTurnSummary = asNonEmptyString(chat.last_turn_summary); - const subtitle = errorReason || lastTurnSummary || modelName; + // While a turn is in flight the cached last_turn_summary still holds + // the previous turn's text. Surface a live "{model} streaming…" label + // instead so the sidebar does not look stuck on the old status. + const isStreaming = chat.status === "running" || chat.status === "pending"; + const streamingSubtitle = isStreaming ? `${modelName} streaming…` : undefined; + // Server-side, status flips to "waiting" synchronously when streaming + // ends, but the new last_turn_summary is written by an async finalizer + // (it calls the model to generate a label). For a beat after the stream + // stops, the chat row still carries the previous turn's summary text. + // Suppress that briefly-stale text: remember the cached summary observed + // while streaming, then hide an exact match on the post-stream renders + // until the new summary lands. A timeout-based release covers the rare + // case where the regenerated summary equals the previous one byte-for- + // byte (so the equality-based release would never fire). State is + // captured during render using the React "adjust state during render" + // pattern so the first post-stream paint already suppresses the stale + // string instead of flashing it for a frame. + const staleTurnSummaryReleaseMs = 10_000; + const [streamingSummary, setStreamingSummary] = useState( + isStreaming ? lastTurnSummary : undefined, + ); + const [suppressionExpired, setSuppressionExpired] = useState(false); + if (isStreaming) { + if (streamingSummary !== lastTurnSummary) { + setStreamingSummary(lastTurnSummary); + } + if (suppressionExpired) { + setSuppressionExpired(false); + } + } else if ( + streamingSummary !== undefined && + lastTurnSummary !== streamingSummary + ) { + setStreamingSummary(undefined); + if (suppressionExpired) { + setSuppressionExpired(false); + } + } + const isStaleTurnSummary = + !isStreaming && + lastTurnSummary !== undefined && + !suppressionExpired && + streamingSummary === lastTurnSummary; + useEffect(() => { + if (!isStaleTurnSummary) { + return; + } + const timeoutId = window.setTimeout(() => { + setSuppressionExpired(true); + }, staleTurnSummaryReleaseMs); + return () => window.clearTimeout(timeoutId); + }, [isStaleTurnSummary]); + const displayedTurnSummary = isStaleTurnSummary ? undefined : lastTurnSummary; + const subtitle = + errorReason || streamingSubtitle || displayedTurnSummary || modelName; const diffStatus = getChatDiffStatus(chat); const baseConfig = getStatusConfig(chat.status); const prConfig =