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 =