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; +}