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 =