From 5b07b62a9142f6af32c5272a5dafba359414f860 Mon Sep 17 00:00:00 2001 From: Renfei Zhou Date: Thu, 12 Nov 2026 20:52:42 +0000 Subject: [PATCH] feat(fork): hide Working and Thinking rows outside Normal display mode In Messages only, Messages + Dashboard and Chat + Replies, the timeline still drew the Working and Thinking rows under the last user message, in the main list and in the Replies sub-thread view. The agent's work is hidden in these modes and its reply may land in another view, so the rows sat apart from anything they described. The sidebar already shows that the thread is working. The row builder now drops rows of kind `working` and `thinking` when the existing `repliesChosen` flag is set, which is true exactly when the mode is not Normal. The filter runs before the pass that groups adjacent user messages, so a message and a steer after it are grouped as usual. Normal mode and the sidebar are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../components/chat/MessagesTimeline.logic.ts | 9 +++- .../src/components/chat/MessagesTimeline.tsx | 3 +- .../displayModeTimeline.browser.test.tsx | 51 ++++++++++++++++++- .../fork/displayMode/displayModeTimeline.ts | 11 ++++ 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index fbfb77151a30e..8fa11499978e9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -77,6 +77,7 @@ import { formatSearchToolLabel, } from "@t3tools/shared/toolActivity"; import { isWindowsAbsolutePath } from "@t3tools/shared/path"; +import { displayModeRows } from "../../fork/displayMode/displayModeTimeline"; function timelineEntryRunId(entry: TimelineEntry): RunId | null { if (entry.kind === "message") { @@ -1508,6 +1509,7 @@ export function deriveMessagesTimelineRows(input: { expandedAttemptIds?: ReadonlySet; expandedWorkGroupIds?: ReadonlySet; isWorking: boolean; + repliesChosen?: boolean; /** Native subagent work has no app run; runless entries form the live response. */ runlessWorkActive?: boolean; activeTurnStartedAt?: string | null; @@ -2261,8 +2263,11 @@ export function deriveMessagesTimelineRows(input: { } } - const result = attachTrailingToolGroupsToAssistant( - attachCreatedThreadSummaries(nextRows, timelineEntries, collapsedFoldMemberIds), + const result = displayModeRows( + attachTrailingToolGroupsToAssistant( + attachCreatedThreadSummaries(nextRows, timelineEntries, collapsedFoldMemberIds), + ), + input.repliesChosen === true, ); return result.map((row, index) => { if (timelineRowIsWorkLog(row) && timelineRowIsWorkLog(result[index + 1])) { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index ec894b479668c..78e79095072be 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -317,7 +317,7 @@ interface MessagesTimelineProps { findQuery?: string; activeFindMatch?: ThreadFindMatch | null; findNavigationId?: number; - /** The rows already hold only the replies to show, so zen keeps every one of them. */ + /** Rows hold only chosen replies, so zen keeps them all. No Working or Thinking row is drawn. */ repliesChosen?: boolean; } @@ -665,6 +665,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ expandedAttemptIds: visibleExpandedAttemptIds, expandedWorkGroupIds, isWorking, + repliesChosen, runlessWorkActive, activeTurnStartedAt, turnDiffSummaries, diff --git a/apps/web/src/fork/displayMode/displayModeTimeline.browser.test.tsx b/apps/web/src/fork/displayMode/displayModeTimeline.browser.test.tsx index 0dc712687d11c..72ca35f69067b 100644 --- a/apps/web/src/fork/displayMode/displayModeTimeline.browser.test.tsx +++ b/apps/web/src/fork/displayMode/displayModeTimeline.browser.test.tsx @@ -12,12 +12,13 @@ import { LegendListRef } from "@legendapp/list/react"; import { MESSAGES_ONLY_DISPLAY_MODE, NORMAL_DISPLAY_MODE } from "@t3tools/shared/displayModes"; import * as DateTime from "effect/DateTime"; import { createRef, type ComponentProps } from "react"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import "~/index.css"; import { render } from "../../../test/render"; import { MessagesTimeline } from "../../components/chat/MessagesTimeline"; +import { deriveMessagesTimelineRows } from "../../components/chat/MessagesTimeline.logic"; import { __setClientSettingsForTests } from "../../hooks/useSettings"; import { deriveTimelineEntriesFromVisibleTurnItems } from "../../session-logic"; import { applyDisplayMode } from "./displayModeTimeline"; @@ -132,12 +133,14 @@ function props(): Omit { async function renderTimeline( timelineEntries: TimelineProps["timelineEntries"], repliesChosen = false, + extra: Partial = {}, ) { const container = document.createElement("div"); container.style.cssText = "height: 900px; width: 800px; display: flex; flex-direction: column;"; return render( , @@ -180,6 +183,52 @@ describe("Messages only timeline", () => { expect(text).not.toContain("to the user"); }); + describe("while the agent works", () => { + const working = { isWorking: true, activeTurnStartedAt: time(0) }; + const asked = entriesOf([userMessage("work-ask", 0, "Please ship it.")]); + const liveRow = (container: HTMLElement, kind: "working" | "thinking") => + container.querySelector(`[data-timeline-row-kind="${kind}"]`); + + it("draws the Working and Thinking rows in Normal", async () => { + await using screen = await renderTimeline( + [...applyDisplayMode(NORMAL_DISPLAY_MODE, asked)], + false, + working, + ); + await vi.waitFor(() => { + expect(liveRow(screen.container, "working")).not.toBeNull(); + expect(liveRow(screen.container, "thinking")).not.toBeNull(); + }); + }); + + it("draws neither in a mode that chooses its replies", async () => { + await using screen = await renderTimeline( + [...applyDisplayMode(MESSAGES_ONLY_DISPLAY_MODE, asked)], + true, + working, + ); + await expect.element(screen.getByText("Please ship it.")).toBeVisible(); + expect(liveRow(screen.container, "working")).toBeNull(); + expect(liveRow(screen.container, "thinking")).toBeNull(); + }); + + it("groups a steer with the message before it once the Working row is gone", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: entriesOf([ + userMessage("steer-ask", 0, "Please ship it."), + userMessage("steer-more", 1, "Run the tests first.", { inputIntent: "steer" }), + ]), + isWorking: true, + repliesChosen: true, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(rows.map((row) => row.kind)).toEqual(["message", "message"]); + expect(rows[0]).toMatchObject({ groupedWithNext: true }); + expect(rows[1]).toMatchObject({ groupedWithPrevious: true }); + }); + }); + describe("in a finished turn with two sends", () => { const send = (id: string, second: number, text: string): OrchestrationV2TurnItem => ({ ...base(id, second), diff --git a/apps/web/src/fork/displayMode/displayModeTimeline.ts b/apps/web/src/fork/displayMode/displayModeTimeline.ts index 32d8a5df35691..8dd986085eec9 100644 --- a/apps/web/src/fork/displayMode/displayModeTimeline.ts +++ b/apps/web/src/fork/displayMode/displayModeTimeline.ts @@ -9,6 +9,7 @@ import { type VisibleCore, } from "@t3tools/shared/displayModes"; +import type { MessagesTimelineRow } from "../../components/chat/MessagesTimeline.logic"; import type { TimelineEntry } from "../../session-logic"; import type { SubThreadSummaries, SubThreadSummary } from "../subThreads/subThreadsState"; @@ -214,3 +215,13 @@ export function applyDisplayMode( ? entries : rows; } + +/** A mode that chooses its replies draws no Working or Thinking row. */ +export function displayModeRows( + rows: MessagesTimelineRow[], + repliesChosen: boolean, +): MessagesTimelineRow[] { + return repliesChosen + ? rows.filter((row) => row.kind !== "working" && row.kind !== "thinking") + : rows; +}