From 806d290802328576c32e5f77854a5e03b46336c0 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:57:48 -0700 Subject: [PATCH 1/3] perf(web): format minimap previews only when opened --- .../src/components/chat/MessagesTimeline.tsx | 65 +++------------ .../chat/timelineMinimapItems.test.ts | 79 +++++++++++++++++++ .../components/chat/timelineMinimapItems.ts | 66 ++++++++++++++++ 3 files changed, 157 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/components/chat/timelineMinimapItems.test.ts create mode 100644 apps/web/src/components/chat/timelineMinimapItems.ts diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 13c1e1dc61b3..41e3a0740a95 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -4,6 +4,11 @@ import { getQuestionAnswerText, hasQuestionAnswer, } from "@t3tools/client-runtime/work-log/user-input"; +import { + deriveTimelineMinimapItems, + resolveTimelineMinimapPreview, + type TimelineMinimapItem, +} from "./timelineMinimapItems"; import { type AssistantCitation, type EnvironmentId, @@ -918,13 +923,6 @@ function getItemType(item: MessagesTimelineRow) { return item.kind === "message" ? `message:${item.message.role}` : item.kind; } -interface TimelineMinimapItem { - readonly id: string; - readonly rowIndex: number; - readonly userText: string | null; - readonly assistantText: string | null; -} - interface TimelinePositionState { readonly contentLength?: number; readonly scroll?: number; @@ -933,51 +931,6 @@ interface TimelinePositionState { readonly sizeAtIndex?: (index: number) => number | undefined; } -function deriveTimelineMinimapItems( - rows: ReadonlyArray, -): TimelineMinimapItem[] { - const items: TimelineMinimapItem[] = []; - for (let index = 0; index < rows.length; index += 1) { - const row = rows[index]; - if (row?.kind !== "message" || row.message.role !== "user") { - continue; - } - - items.push({ - id: row.id, - rowIndex: index, - userText: compactMinimapPreview(row.message.text), - assistantText: compactMinimapPreview(resolveFinalAssistantTextForTurn(rows, index)), - }); - } - return items; -} - -function resolveFinalAssistantTextForTurn( - rows: ReadonlyArray, - userRowIndex: number, -) { - let finalAssistantText: string | null = null; - for (let index = userRowIndex + 1; index < rows.length; index += 1) { - const row = rows[index]; - if (row?.kind !== "message") { - continue; - } - if (row.message.role === "user") { - break; - } - if (row.message.role === "assistant") { - finalAssistantText = row.message.text ?? null; - } - } - return finalAssistantText; -} - -function compactMinimapPreview(text: string | null | undefined) { - const compact = text?.replace(/\s+/g, " ").trim() ?? ""; - return compact.length > 0 ? compact : null; -} - function resolveTimelineRowTop(state: TimelinePositionState, rowIndex: number) { const top = state.positionAtIndex?.(rowIndex); return typeof top === "number" && Number.isFinite(top) ? top : null; @@ -1011,7 +964,13 @@ function TimelineMinimap({ const resolvedActiveIndex = activeIndex !== null && activeIndex < items.length ? activeIndex : null; - const activeItem = resolvedActiveIndex === null ? null : (items[resolvedActiveIndex] ?? null); + const activeItem = useMemo( + () => + resolveTimelineMinimapPreview( + resolvedActiveIndex === null ? null : (items[resolvedActiveIndex] ?? null), + ), + [items, resolvedActiveIndex], + ); const activeTopPercent = resolvedActiveIndex === null ? 0 diff --git a/apps/web/src/components/chat/timelineMinimapItems.test.ts b/apps/web/src/components/chat/timelineMinimapItems.test.ts new file mode 100644 index 000000000000..39622635a6a2 --- /dev/null +++ b/apps/web/src/components/chat/timelineMinimapItems.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vite-plus/test"; +import { MessageId } from "@t3tools/contracts"; +import { deriveTimelineEntries } from "../../session-logic"; +import { deriveMessagesTimelineRows } from "./MessagesTimeline.logic"; +import { deriveTimelineMinimapItems, resolveTimelineMinimapPreview } from "./timelineMinimapItems"; +import type { ChatMessage } from "../../types"; + +function rows(entries: ReadonlyArray) { + const messages: ChatMessage[] = entries.map(([role, text], index) => ({ + id: MessageId.make(`message-${index}`), + role, + text, + streaming: false, + turnId: null, + createdAt: new Date(index * 1000).toISOString(), + updatedAt: new Date(index * 1000).toISOString(), + })); + return deriveMessagesTimelineRows({ + timelineEntries: deriveTimelineEntries(messages, [], []), + latestTurn: null, + runningTurnId: null, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); +} + +describe("timeline minimap previews", () => { + it("previews the last assistant response before the next prompt and retains jump targets", () => { + const source = rows([ + ["user", " Inspect\n this "], + ["assistant", "Working"], + ["assistant", " Done\t now "], + ["user", "Next"], + ["assistant", "Second answer"], + ]); + const items = deriveTimelineMinimapItems(source); + expect(items).toHaveLength(2); + expect(resolveTimelineMinimapPreview(items[0])).toEqual({ + ...items[0], + userText: "Inspect this", + assistantText: "Done now", + }); + expect(source[items[0]!.rowIndex]!.id).toBe(items[0]!.id); + expect(resolveTimelineMinimapPreview(items[1])?.assistantText).toBe("Second answer"); + expect(items[0]?.assistantText).toBe(" Done\t now "); + }); + + it("handles an unanswered prompt, empty responses, and a closed preview", () => { + const items = deriveTimelineMinimapItems( + rows([ + ["user", "First"], + ["assistant", " \n\t"], + ["user", "Next"], + ]), + ); + expect(items.map((item) => resolveTimelineMinimapPreview(item)?.assistantText)).toEqual([ + null, + null, + ]); + expect(resolveTimelineMinimapPreview(null)).toBeNull(); + }); + + it("shows fresh streaming text without changing the jump target", () => { + const first = deriveTimelineMinimapItems( + rows([ + ["user", "Explain"], + ["assistant", "First"], + ]), + )[0]!; + const next = { ...first, assistantText: "First\n second" }; + expect(resolveTimelineMinimapPreview(next)).toEqual({ + ...first, + assistantText: "First second", + }); + expect(resolveTimelineMinimapPreview(first)?.assistantText).toBe("First"); + }); +}); diff --git a/apps/web/src/components/chat/timelineMinimapItems.ts b/apps/web/src/components/chat/timelineMinimapItems.ts new file mode 100644 index 000000000000..0a37c686e780 --- /dev/null +++ b/apps/web/src/components/chat/timelineMinimapItems.ts @@ -0,0 +1,66 @@ +import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; + +export interface TimelineMinimapItem { + readonly id: string; + readonly rowIndex: number; + readonly userText: string | null; + readonly assistantText: string | null; +} + +/** Keep full source text untouched until a minimap preview is opened. */ +export function deriveTimelineMinimapItems( + rows: ReadonlyArray, +): TimelineMinimapItem[] { + const items: TimelineMinimapItem[] = []; + for (let index = 0; index < rows.length; index += 1) { + const row = rows[index]; + if (row?.kind !== "message" || row.message.role !== "user") { + continue; + } + + items.push({ + id: row.id, + rowIndex: index, + userText: row.message.text, + assistantText: resolveFinalAssistantTextForTurn(rows, index), + }); + } + return items; +} + +function resolveFinalAssistantTextForTurn( + rows: ReadonlyArray, + userRowIndex: number, +) { + let finalAssistantText: string | null = null; + for (let index = userRowIndex + 1; index < rows.length; index += 1) { + const row = rows[index]; + if (row?.kind !== "message") { + continue; + } + if (row.message.role === "user") { + break; + } + if (row.message.role === "assistant") { + finalAssistantText = row.message.text ?? null; + } + } + return finalAssistantText; +} + +function compactMinimapPreview(text: string | null | undefined) { + const compact = text?.replace(/\s+/g, " ").trim() ?? ""; + return compact.length > 0 ? compact : null; +} + +export function resolveTimelineMinimapPreview( + item: TimelineMinimapItem | null, +): TimelineMinimapItem | null { + return item === null + ? null + : { + ...item, + userText: compactMinimapPreview(item.userText), + assistantText: compactMinimapPreview(item.assistantText), + }; +} From 3f335323493be8bb9ea4d5228b22230d2ac198dc Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:04:05 -0700 Subject: [PATCH 2/3] test(web): narrow minimap fixture items after count assertion --- apps/web/src/components/chat/timelineMinimapItems.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/timelineMinimapItems.test.ts b/apps/web/src/components/chat/timelineMinimapItems.test.ts index 39622635a6a2..407e5e3e359c 100644 --- a/apps/web/src/components/chat/timelineMinimapItems.test.ts +++ b/apps/web/src/components/chat/timelineMinimapItems.test.ts @@ -37,13 +37,13 @@ describe("timeline minimap previews", () => { ]); const items = deriveTimelineMinimapItems(source); expect(items).toHaveLength(2); - expect(resolveTimelineMinimapPreview(items[0])).toEqual({ + expect(resolveTimelineMinimapPreview(items[0]!)).toEqual({ ...items[0], userText: "Inspect this", assistantText: "Done now", }); expect(source[items[0]!.rowIndex]!.id).toBe(items[0]!.id); - expect(resolveTimelineMinimapPreview(items[1])?.assistantText).toBe("Second answer"); + expect(resolveTimelineMinimapPreview(items[1]!)?.assistantText).toBe("Second answer"); expect(items[0]?.assistantText).toBe(" Done\t now "); }); From 3b9cf6eec966edd86aee57fdd74e46923e4c68b4 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:40:19 -0700 Subject: [PATCH 3/3] test(web): isolate minimap fixtures from timeline projection inputs --- .../chat/timelineMinimapItems.test.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/chat/timelineMinimapItems.test.ts b/apps/web/src/components/chat/timelineMinimapItems.test.ts index 407e5e3e359c..f7c40aa71206 100644 --- a/apps/web/src/components/chat/timelineMinimapItems.test.ts +++ b/apps/web/src/components/chat/timelineMinimapItems.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; import { MessageId } from "@t3tools/contracts"; -import { deriveTimelineEntries } from "../../session-logic"; -import { deriveMessagesTimelineRows } from "./MessagesTimeline.logic"; +import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; import { deriveTimelineMinimapItems, resolveTimelineMinimapPreview } from "./timelineMinimapItems"; import type { ChatMessage } from "../../types"; -function rows(entries: ReadonlyArray) { +function rows( + entries: ReadonlyArray, +): MessagesTimelineRow[] { const messages: ChatMessage[] = entries.map(([role, text], index) => ({ id: MessageId.make(`message-${index}`), role, @@ -15,15 +16,16 @@ function rows(entries: ReadonlyArray) { createdAt: new Date(index * 1000).toISOString(), updatedAt: new Date(index * 1000).toISOString(), })); - return deriveMessagesTimelineRows({ - timelineEntries: deriveTimelineEntries(messages, [], []), - latestTurn: null, - runningTurnId: null, - isWorking: false, - activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), - }); + return messages.map((message) => ({ + kind: "message", + id: message.id, + createdAt: message.createdAt, + message, + durationStart: message.createdAt, + showAssistantMeta: false, + showAssistantCopyButton: false, + assistantCopyStreaming: false, + })); } describe("timeline minimap previews", () => {