+ {threadSearchOpen && (
+
+ stepThreadSearch(1)}
+ onPrevious={() => stepThreadSearch(-1)}
+ onClose={closeThreadSearch}
+ />
+
+ )}
{/* Messages */}
0}
- isWorking={isWorking}
+ rows={timelineRows}
activeTurnInProgress={isWorking || !latestTurnSettled}
activeTurnStartedAt={activeWorkStartedAt}
scrollContainer={messagesScrollElement}
- timelineEntries={timelineEntries}
- completionDividerBeforeEntryId={completionDividerBeforeEntryId}
completionSummary={completionSummary}
turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
nowIso={nowIso}
@@ -3657,6 +3837,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
resolvedTheme={resolvedTheme}
timestampFormat={timestampFormat}
workspaceRoot={activeProject?.cwd ?? undefined}
+ activeSearchRowId={activeThreadSearchRowId}
+ matchedSearchRowIds={matchedThreadSearchRowIds}
+ searchQuery={deferredThreadSearchQuery}
/>
diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
index dee42a8586..1bb6dea073 100644
--- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
+++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
@@ -1,5 +1,10 @@
+import { MessageId } from "@t3tools/contracts";
import { describe, expect, it } from "vitest";
-import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic";
+import {
+ buildTimelineRows,
+ computeMessageDurationStart,
+ normalizeCompactToolLabel,
+} from "./MessagesTimeline.logic";
describe("computeMessageDurationStart", () => {
it("returns message createdAt when there is no preceding user message", () => {
@@ -143,3 +148,147 @@ describe("normalizeCompactToolLabel", () => {
expect(normalizeCompactToolLabel("Read file completed")).toBe("Read file");
});
});
+
+describe("buildTimelineRows", () => {
+ it("groups adjacent work entries, preserves plans, and appends the working row", () => {
+ const rows = buildTimelineRows({
+ timelineEntries: [
+ {
+ id: "message-1",
+ kind: "message",
+ createdAt: "2026-01-01T00:00:00Z",
+ message: {
+ id: MessageId.makeUnsafe("message-1"),
+ role: "user",
+ text: "hello",
+ createdAt: "2026-01-01T00:00:00Z",
+ streaming: false,
+ },
+ },
+ {
+ id: "work-1",
+ kind: "work",
+ createdAt: "2026-01-01T00:00:01Z",
+ entry: {
+ id: "work-1",
+ createdAt: "2026-01-01T00:00:01Z",
+ label: "Ran command",
+ tone: "tool",
+ },
+ },
+ {
+ id: "work-2",
+ kind: "work",
+ createdAt: "2026-01-01T00:00:02Z",
+ entry: {
+ id: "work-2",
+ createdAt: "2026-01-01T00:00:02Z",
+ label: "Updated file",
+ tone: "info",
+ },
+ },
+ {
+ id: "plan-1",
+ kind: "proposed-plan",
+ createdAt: "2026-01-01T00:00:03Z",
+ proposedPlan: {
+ id: "plan-1" as never,
+ turnId: null,
+ planMarkdown: "1. Ship it",
+ implementedAt: null,
+ implementationThreadId: null,
+ createdAt: "2026-01-01T00:00:03Z",
+ updatedAt: "2026-01-01T00:00:03Z",
+ },
+ },
+ ],
+ completionDividerBeforeEntryId: null,
+ isWorking: true,
+ activeTurnStartedAt: "2026-01-01T00:00:04Z",
+ });
+
+ expect(rows).toEqual([
+ {
+ kind: "message",
+ id: "message-1",
+ createdAt: "2026-01-01T00:00:00Z",
+ message: {
+ id: MessageId.makeUnsafe("message-1"),
+ role: "user",
+ text: "hello",
+ createdAt: "2026-01-01T00:00:00Z",
+ streaming: false,
+ },
+ durationStart: "2026-01-01T00:00:00Z",
+ showCompletionDivider: false,
+ },
+ {
+ kind: "work",
+ id: "work-1",
+ createdAt: "2026-01-01T00:00:01Z",
+ groupedEntries: [
+ {
+ id: "work-1",
+ createdAt: "2026-01-01T00:00:01Z",
+ label: "Ran command",
+ tone: "tool",
+ },
+ {
+ id: "work-2",
+ createdAt: "2026-01-01T00:00:02Z",
+ label: "Updated file",
+ tone: "info",
+ },
+ ],
+ },
+ {
+ kind: "proposed-plan",
+ id: "plan-1",
+ createdAt: "2026-01-01T00:00:03Z",
+ proposedPlan: {
+ id: "plan-1" as never,
+ turnId: null,
+ planMarkdown: "1. Ship it",
+ implementedAt: null,
+ implementationThreadId: null,
+ createdAt: "2026-01-01T00:00:03Z",
+ updatedAt: "2026-01-01T00:00:03Z",
+ },
+ },
+ {
+ kind: "working",
+ id: "working-indicator-row",
+ createdAt: "2026-01-01T00:00:04Z",
+ },
+ ]);
+ });
+
+ it("marks the matching assistant row with the completion divider", () => {
+ const rows = buildTimelineRows({
+ timelineEntries: [
+ {
+ id: "assistant-1",
+ kind: "message",
+ createdAt: "2026-01-01T00:00:00Z",
+ message: {
+ id: MessageId.makeUnsafe("assistant-1"),
+ role: "assistant",
+ text: "Done",
+ createdAt: "2026-01-01T00:00:00Z",
+ completedAt: "2026-01-01T00:00:05Z",
+ streaming: false,
+ },
+ },
+ ],
+ completionDividerBeforeEntryId: "assistant-1",
+ isWorking: false,
+ activeTurnStartedAt: null,
+ });
+
+ expect(rows[0]).toMatchObject({
+ kind: "message",
+ id: "assistant-1",
+ showCompletionDivider: true,
+ });
+ });
+});
diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts
index 726d61888e..4875907063 100644
--- a/apps/web/src/components/chat/MessagesTimeline.logic.ts
+++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts
@@ -1,3 +1,5 @@
+import type { TimelineEntry } from "../../session-logic";
+
export interface TimelineDurationMessage {
id: string;
role: "user" | "assistant" | "system";
@@ -27,3 +29,141 @@ export function computeMessageDurationStart(
export function normalizeCompactToolLabel(value: string): string {
return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
}
+
+type TimelineMessage = Extract
["message"];
+type TimelineProposedPlan = Extract["proposedPlan"];
+export type TimelineWorkEntry = Extract["entry"];
+
+function capitalizePhrase(value: string): string {
+ const trimmed = value.trim();
+ if (trimmed.length === 0) {
+ return value;
+ }
+ return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`;
+}
+
+export function renderableWorkEntryHeading(workEntry: TimelineWorkEntry): string {
+ if (!workEntry.toolTitle) {
+ return capitalizePhrase(normalizeCompactToolLabel(workEntry.label));
+ }
+ return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle));
+}
+
+export function renderableWorkEntryPreview(
+ workEntry: Pick,
+): string | null {
+ if (workEntry.command) return workEntry.command;
+ if (workEntry.detail) return workEntry.detail;
+ if ((workEntry.changedFiles?.length ?? 0) === 0) return null;
+ const [firstPath] = workEntry.changedFiles ?? [];
+ if (!firstPath) return null;
+ return workEntry.changedFiles!.length === 1
+ ? firstPath
+ : `${firstPath} +${workEntry.changedFiles!.length - 1} more`;
+}
+
+export function renderableWorkEntryChangedFiles(
+ workEntry: Pick,
+): string[] {
+ const changedFiles = workEntry.changedFiles ?? [];
+ if (changedFiles.length === 0) {
+ return [];
+ }
+ if (!workEntry.command && !workEntry.detail) {
+ return [];
+ }
+ return changedFiles.slice(0, 4);
+}
+
+export type TimelineRow =
+ | {
+ kind: "work";
+ id: string;
+ createdAt: string;
+ groupedEntries: TimelineWorkEntry[];
+ }
+ | {
+ kind: "message";
+ id: string;
+ createdAt: string;
+ message: TimelineMessage;
+ durationStart: string;
+ showCompletionDivider: boolean;
+ }
+ | {
+ kind: "proposed-plan";
+ id: string;
+ createdAt: string;
+ proposedPlan: TimelineProposedPlan;
+ }
+ | { kind: "working"; id: string; createdAt: string | null };
+
+export function buildTimelineRows(input: {
+ timelineEntries: ReadonlyArray;
+ completionDividerBeforeEntryId: string | null;
+ isWorking: boolean;
+ activeTurnStartedAt: string | null;
+}): TimelineRow[] {
+ const nextRows: TimelineRow[] = [];
+ const durationStartByMessageId = computeMessageDurationStart(
+ input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),
+ );
+
+ for (let index = 0; index < input.timelineEntries.length; index += 1) {
+ const timelineEntry = input.timelineEntries[index];
+ if (!timelineEntry) {
+ continue;
+ }
+
+ if (timelineEntry.kind === "work") {
+ const groupedEntries = [timelineEntry.entry];
+ let cursor = index + 1;
+ while (cursor < input.timelineEntries.length) {
+ const nextEntry = input.timelineEntries[cursor];
+ if (!nextEntry || nextEntry.kind !== "work") break;
+ groupedEntries.push(nextEntry.entry);
+ cursor += 1;
+ }
+ nextRows.push({
+ kind: "work",
+ id: timelineEntry.id,
+ createdAt: timelineEntry.createdAt,
+ groupedEntries,
+ });
+ index = cursor - 1;
+ continue;
+ }
+
+ if (timelineEntry.kind === "proposed-plan") {
+ nextRows.push({
+ kind: "proposed-plan",
+ id: timelineEntry.id,
+ createdAt: timelineEntry.createdAt,
+ proposedPlan: timelineEntry.proposedPlan,
+ });
+ continue;
+ }
+
+ nextRows.push({
+ kind: "message",
+ id: timelineEntry.id,
+ createdAt: timelineEntry.createdAt,
+ message: timelineEntry.message,
+ durationStart:
+ durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt,
+ showCompletionDivider:
+ timelineEntry.message.role === "assistant" &&
+ input.completionDividerBeforeEntryId === timelineEntry.id,
+ });
+ }
+
+ if (input.isWorking) {
+ nextRows.push({
+ kind: "working",
+ id: "working-indicator-row",
+ createdAt: input.activeTurnStartedAt,
+ });
+ }
+
+ return nextRows;
+}
diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx
index 692438c74a..b03851a2ca 100644
--- a/apps/web/src/components/chat/MessagesTimeline.test.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx
@@ -1,6 +1,14 @@
import { MessageId } from "@t3tools/contracts";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeAll, describe, expect, it, vi } from "vitest";
+import { buildTimelineRows } from "./MessagesTimeline.logic";
+
+vi.mock("../../hooks/useTheme", () => ({
+ useTheme: () => ({
+ theme: "light",
+ resolvedTheme: "light",
+ }),
+}));
function matchMedia() {
return {
@@ -45,36 +53,39 @@ beforeAll(() => {
describe("MessagesTimeline", () => {
it("renders inline terminal labels with the composer chip UI", async () => {
const { MessagesTimeline } = await import("./MessagesTimeline");
+ const rows = buildTimelineRows({
+ timelineEntries: [
+ {
+ id: "entry-1",
+ kind: "message",
+ createdAt: "2026-03-17T19:12:28.000Z",
+ message: {
+ id: MessageId.makeUnsafe("message-2"),
+ role: "user",
+ text: [
+ "yoo what's @terminal-1:1-5 mean",
+ "",
+ "",
+ "- Terminal 1 lines 1-5:",
+ " 1 | julius@mac effect-http-ws-cli % bun i",
+ " 2 | bun install v1.3.9 (cf6cdbbb)",
+ "",
+ ].join("\n"),
+ createdAt: "2026-03-17T19:12:28.000Z",
+ streaming: false,
+ },
+ },
+ ],
+ completionDividerBeforeEntryId: null,
+ isWorking: false,
+ activeTurnStartedAt: null,
+ });
const markup = renderToStaticMarkup(
",
- "- Terminal 1 lines 1-5:",
- " 1 | julius@mac effect-http-ws-cli % bun i",
- " 2 | bun install v1.3.9 (cf6cdbbb)",
- "",
- ].join("\n"),
- createdAt: "2026-03-17T19:12:28.000Z",
- streaming: false,
- },
- },
- ]}
- completionDividerBeforeEntryId={null}
completionSummary={null}
turnDiffSummaryByAssistantMessageId={new Map()}
nowIso="2026-03-17T19:12:30.000Z"
@@ -89,6 +100,9 @@ describe("MessagesTimeline", () => {
resolvedTheme="light"
timestampFormat="locale"
workspaceRoot={undefined}
+ activeSearchRowId={null}
+ matchedSearchRowIds={new Set()}
+ searchQuery=""
/>,
);
@@ -97,29 +111,90 @@ describe("MessagesTimeline", () => {
expect(markup).toContain("yoo what's ");
});
- it("renders context compaction entries in the normal work log", async () => {
+ it("highlights rendered terminal chip labels during search", async () => {
const { MessagesTimeline } = await import("./MessagesTimeline");
+ const rows = buildTimelineRows({
+ timelineEntries: [
+ {
+ id: "entry-1",
+ kind: "message",
+ createdAt: "2026-03-17T19:12:28.000Z",
+ message: {
+ id: MessageId.makeUnsafe("message-chip-search"),
+ role: "user",
+ text: [
+ "check this @terminal-1:1-5",
+ "",
+ "",
+ "- Terminal 1 lines 1-5:",
+ " 1 | echoed output",
+ "",
+ ].join("\n"),
+ createdAt: "2026-03-17T19:12:28.000Z",
+ streaming: false,
+ },
+ },
+ ],
+ completionDividerBeforeEntryId: null,
+ isWorking: false,
+ activeTurnStartedAt: null,
+ });
const markup = renderToStaticMarkup(
{}}
+ onOpenTurnDiff={() => {}}
+ revertTurnCountByUserMessageId={new Map()}
+ onRevertUserMessage={() => {}}
+ isRevertingCheckpoint={false}
+ onImageExpand={() => {}}
+ markdownCwd={undefined}
+ resolvedTheme="light"
+ timestampFormat="locale"
+ workspaceRoot={undefined}
+ activeSearchRowId="entry-1"
+ matchedSearchRowIds={new Set(["entry-1"])}
+ searchQuery="Terminal 1 lines 1-5"
+ />,
+ );
+
+ expect(markup).toContain("Terminal 1 lines 1-5");
+ expect(markup).toContain('data-thread-search-highlight="active"');
+ });
+
+ it("renders context compaction entries in the normal work log", async () => {
+ const { MessagesTimeline } = await import("./MessagesTimeline");
+ const rows = buildTimelineRows({
+ timelineEntries: [
+ {
+ id: "entry-1",
+ kind: "work",
+ createdAt: "2026-03-17T19:12:28.000Z",
+ entry: {
+ id: "work-1",
createdAt: "2026-03-17T19:12:28.000Z",
- entry: {
- id: "work-1",
- createdAt: "2026-03-17T19:12:28.000Z",
- label: "Context compacted",
- tone: "info",
- },
+ label: "Context compacted",
+ tone: "info",
},
- ]}
- completionDividerBeforeEntryId={null}
+ },
+ ],
+ completionDividerBeforeEntryId: null,
+ isWorking: false,
+ activeTurnStartedAt: null,
+ });
+ const markup = renderToStaticMarkup(
+ {
resolvedTheme="light"
timestampFormat="locale"
workspaceRoot={undefined}
+ activeSearchRowId={null}
+ matchedSearchRowIds={new Set()}
+ searchQuery=""
/>,
);
expect(markup).toContain("Context compacted");
expect(markup).toContain("Work log");
});
+
+ it("renders active inline search highlights without row-level emphasis", async () => {
+ const { MessagesTimeline } = await import("./MessagesTimeline");
+ const rows = buildTimelineRows({
+ timelineEntries: [
+ {
+ id: "message-1",
+ kind: "message",
+ createdAt: "2026-03-17T19:12:28.000Z",
+ message: {
+ id: MessageId.makeUnsafe("message-1"),
+ role: "user",
+ text: "Search target",
+ createdAt: "2026-03-17T19:12:28.000Z",
+ streaming: false,
+ },
+ },
+ ],
+ completionDividerBeforeEntryId: null,
+ isWorking: false,
+ activeTurnStartedAt: null,
+ });
+ const markup = renderToStaticMarkup(
+ {}}
+ onOpenTurnDiff={() => {}}
+ revertTurnCountByUserMessageId={new Map()}
+ onRevertUserMessage={() => {}}
+ isRevertingCheckpoint={false}
+ onImageExpand={() => {}}
+ markdownCwd={undefined}
+ resolvedTheme="light"
+ timestampFormat="locale"
+ workspaceRoot={undefined}
+ activeSearchRowId="message-1"
+ matchedSearchRowIds={new Set(["message-1"])}
+ searchQuery="Search"
+ />,
+ );
+
+ expect(markup).toContain('data-timeline-row-id="message-1"');
+ expect(markup).toContain('data-search-match-state="active"');
+ expect(markup).toContain('data-thread-search-highlight="active"');
+ expect(markup).toContain(" {
+ const { MessagesTimeline } = await import("./MessagesTimeline");
+ const rows = buildTimelineRows({
+ timelineEntries: [
+ {
+ id: "work-entry-1",
+ kind: "work",
+ createdAt: "2026-03-17T19:12:28.000Z",
+ entry: {
+ id: "work-1",
+ createdAt: "2026-03-17T19:12:28.000Z",
+ label: "Seeded hidden match",
+ tone: "info",
+ },
+ },
+ ...Array.from({ length: 6 }, (_, index) => ({
+ id: `work-entry-${index + 2}`,
+ kind: "work" as const,
+ createdAt: `2026-03-17T19:12:${String(29 + index).padStart(2, "0")}.000Z`,
+ entry: {
+ id: `work-${index + 2}`,
+ createdAt: `2026-03-17T19:12:${String(29 + index).padStart(2, "0")}.000Z`,
+ label: `Visible filler ${index + 1}`,
+ tone: "info" as const,
+ },
+ })),
+ ],
+ completionDividerBeforeEntryId: null,
+ isWorking: false,
+ activeTurnStartedAt: null,
+ });
+ const markup = renderToStaticMarkup(
+ {}}
+ onOpenTurnDiff={() => {}}
+ revertTurnCountByUserMessageId={new Map()}
+ onRevertUserMessage={() => {}}
+ isRevertingCheckpoint={false}
+ onImageExpand={() => {}}
+ markdownCwd={undefined}
+ resolvedTheme="light"
+ timestampFormat="locale"
+ workspaceRoot={undefined}
+ activeSearchRowId="work-entry-1"
+ matchedSearchRowIds={new Set(["work-entry-1"])}
+ searchQuery="Seeded"
+ />,
+ );
+
+ expect(markup).toContain("Seeded hidden match");
+ expect(markup).toContain('data-thread-search-highlight="active"');
+ expect(markup).not.toContain("Show 1 more");
+ });
+
+ it("renders assistant markdown search highlights", async () => {
+ const { MessagesTimeline } = await import("./MessagesTimeline");
+ const rows = buildTimelineRows({
+ timelineEntries: [
+ {
+ id: "assistant-row-1",
+ kind: "message",
+ createdAt: "2026-03-17T19:12:28.000Z",
+ message: {
+ id: MessageId.makeUnsafe("assistant-message-1"),
+ role: "assistant",
+ text: "The **highlight** should also appear in assistant markdown.",
+ createdAt: "2026-03-17T19:12:28.000Z",
+ streaming: false,
+ },
+ },
+ ],
+ completionDividerBeforeEntryId: null,
+ isWorking: false,
+ activeTurnStartedAt: null,
+ });
+ const markup = renderToStaticMarkup(
+ {}}
+ onOpenTurnDiff={() => {}}
+ revertTurnCountByUserMessageId={new Map()}
+ onRevertUserMessage={() => {}}
+ isRevertingCheckpoint={false}
+ onImageExpand={() => {}}
+ markdownCwd={undefined}
+ resolvedTheme="light"
+ timestampFormat="locale"
+ workspaceRoot={undefined}
+ activeSearchRowId="assistant-row-1"
+ matchedSearchRowIds={new Set(["assistant-row-1"])}
+ searchQuery="highlight"
+ />,
+ );
+
+ expect(markup).toContain('data-timeline-row-id="assistant-row-1"');
+ expect(markup).toContain('data-thread-search-highlight="active"');
+ expect(markup).toContain("highlight<");
+ });
});
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index f3174030ef..848505d4e3 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -14,7 +14,7 @@ import {
type VirtualItem,
useVirtualizer,
} from "@tanstack/react-virtual";
-import { deriveTimelineEntries, formatElapsed } from "../../session-logic";
+import { formatElapsed } from "../../session-logic";
import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll";
import { type TurnDiffSummary } from "../../types";
import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";
@@ -41,7 +41,13 @@ import { ProposedPlanCard } from "./ProposedPlanCard";
import { ChangedFilesTree } from "./ChangedFilesTree";
import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
import { MessageCopyButton } from "./MessageCopyButton";
-import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic";
+import {
+ renderableWorkEntryChangedFiles,
+ renderableWorkEntryHeading,
+ renderableWorkEntryPreview,
+ type TimelineRow,
+ type TimelineWorkEntry,
+} from "./MessagesTimeline.logic";
import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
import {
deriveDisplayedUserMessageState,
@@ -55,18 +61,16 @@ import {
formatInlineTerminalContextLabel,
textContainsInlineTerminalContextLabels,
} from "./userMessageTerminalContexts";
+import { renderHighlightedText } from "./threadSearchHighlight";
const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;
interface MessagesTimelineProps {
- hasMessages: boolean;
- isWorking: boolean;
+ rows: ReadonlyArray;
activeTurnInProgress: boolean;
activeTurnStartedAt: string | null;
scrollContainer: HTMLDivElement | null;
- timelineEntries: ReturnType;
- completionDividerBeforeEntryId: string | null;
completionSummary: string | null;
turnDiffSummaryByAssistantMessageId: Map;
nowIso: string;
@@ -81,16 +85,16 @@ interface MessagesTimelineProps {
resolvedTheme: "light" | "dark";
timestampFormat: TimestampFormat;
workspaceRoot: string | undefined;
+ activeSearchRowId: string | null;
+ matchedSearchRowIds: ReadonlySet;
+ searchQuery: string;
}
export const MessagesTimeline = memo(function MessagesTimeline({
- hasMessages,
- isWorking,
+ rows,
activeTurnInProgress,
activeTurnStartedAt,
scrollContainer,
- timelineEntries,
- completionDividerBeforeEntryId,
completionSummary,
turnDiffSummaryByAssistantMessageId,
nowIso,
@@ -105,9 +109,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({
resolvedTheme,
timestampFormat,
workspaceRoot,
+ activeSearchRowId,
+ matchedSearchRowIds,
+ searchQuery,
}: MessagesTimelineProps) {
const timelineRootRef = useRef(null);
const [timelineWidthPx, setTimelineWidthPx] = useState(null);
+ const isWorking = rows.some((row) => row.kind === "working");
+ const hasRows = rows.some((row) => row.kind !== "working");
useLayoutEffect(() => {
const timelineRoot = timelineRootRef.current;
@@ -132,72 +141,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
return () => {
observer.disconnect();
};
- }, [hasMessages, isWorking]);
-
- const rows = useMemo(() => {
- const nextRows: TimelineRow[] = [];
- const durationStartByMessageId = computeMessageDurationStart(
- timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),
- );
-
- for (let index = 0; index < timelineEntries.length; index += 1) {
- const timelineEntry = timelineEntries[index];
- if (!timelineEntry) {
- continue;
- }
-
- if (timelineEntry.kind === "work") {
- const groupedEntries = [timelineEntry.entry];
- let cursor = index + 1;
- while (cursor < timelineEntries.length) {
- const nextEntry = timelineEntries[cursor];
- if (!nextEntry || nextEntry.kind !== "work") break;
- groupedEntries.push(nextEntry.entry);
- cursor += 1;
- }
- nextRows.push({
- kind: "work",
- id: timelineEntry.id,
- createdAt: timelineEntry.createdAt,
- groupedEntries,
- });
- index = cursor - 1;
- continue;
- }
-
- if (timelineEntry.kind === "proposed-plan") {
- nextRows.push({
- kind: "proposed-plan",
- id: timelineEntry.id,
- createdAt: timelineEntry.createdAt,
- proposedPlan: timelineEntry.proposedPlan,
- });
- continue;
- }
-
- nextRows.push({
- kind: "message",
- id: timelineEntry.id,
- createdAt: timelineEntry.createdAt,
- message: timelineEntry.message,
- durationStart:
- durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt,
- showCompletionDivider:
- timelineEntry.message.role === "assistant" &&
- completionDividerBeforeEntryId === timelineEntry.id,
- });
- }
-
- if (isWorking) {
- nextRows.push({
- kind: "working",
- id: "working-indicator-row",
- createdAt: activeTurnStartedAt,
- });
- }
-
- return nextRows;
- }, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]);
+ }, [hasRows, isWorking]);
const firstUnvirtualizedRowIndex = useMemo(() => {
const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0);
@@ -291,6 +235,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({
};
}, []);
+ const rowIndexById = useMemo(
+ () => new Map(rows.map((row, index) => [row.id, index] as const)),
+ [rows],
+ );
+
const virtualRows = rowVirtualizer.getVirtualItems();
const nonVirtualizedRows = rows.slice(virtualizedRowCount);
const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState<
@@ -303,257 +252,328 @@ export const MessagesTimeline = memo(function MessagesTimeline({
}));
}, []);
- const renderRowContent = (row: TimelineRow) => (
-
- {row.kind === "work" &&
- (() => {
- const groupId = row.id;
- const groupedEntries = row.groupedEntries;
- const isExpanded = expandedWorkGroups[groupId] ?? false;
- const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
- const visibleEntries =
- hasOverflow && !isExpanded
- ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
- : groupedEntries;
- const hiddenCount = groupedEntries.length - visibleEntries.length;
- const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");
- const showHeader = hasOverflow || !onlyToolEntries;
- const groupLabel = onlyToolEntries ? "Tool calls" : "Work log";
-
- return (
-
- {showHeader && (
-
-
- {groupLabel} ({groupedEntries.length})
-
- {hasOverflow && (
-
- )}
-
- )}
-
- {visibleEntries.map((workEntry) => (
-
- ))}
-
-
- );
- })()}
-
- {row.kind === "message" &&
- row.message.role === "user" &&
- (() => {
- const userImages = row.message.attachments ?? [];
- const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text);
- const terminalContexts = displayedUserMessage.contexts;
- const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id);
- return (
-
-
- {userImages.length > 0 && (
-
- {userImages.map(
- (image: NonNullable
[number]) => (
-
- {image.previewUrl ? (
-
- ) : (
-
- {image.name}
-
- )}
-
- ),
- )}
-
- )}
- {(displayedUserMessage.visibleText.trim().length > 0 ||
- terminalContexts.length > 0) && (
-
- )}
-
-
- {displayedUserMessage.copyText && (
-
- )}
- {canRevertAgentWork && (
-