authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-10 23:50:31-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-10 23:50:31-07:00
log8fc253605e6d203c3654dbb1ce22fa1fd6fa0767
treeaa56fc3addb110a55bb3a46fdcdaee3418fc1aac
parent57aee3e19f1910f3323f06384bb2a1d02b79e369
signature Signed by PGP key B5690EEEBB952194

perf(web): format minimap previews only when opened (#11181)


3 files changed, 159 insertions(+), 53 deletions(-)

apps/web/src/components/chat/MessagesTimeline.tsx+12-53
......@@ -4,6 +4,11 @@ import {
44 getQuestionAnswerText,
55 hasQuestionAnswer,
66} from "@t3tools/client-runtime/work-log/user-input";
7import {
8 deriveTimelineMinimapItems,
9 resolveTimelineMinimapPreview,
10 type TimelineMinimapItem,
11} from "./timelineMinimapItems";
712import {
813 type AssistantCitation,
914 type EnvironmentId,
......@@ -918,13 +923,6 @@ function getItemType(item: MessagesTimelineRow) {
918923 return item.kind === "message" ? `message:${item.message.role}` : item.kind;
919924}
920925
921interface TimelineMinimapItem {
922 readonly id: string;
923 readonly rowIndex: number;
924 readonly userText: string | null;
925 readonly assistantText: string | null;
926}
927
928926interface TimelinePositionState {
929927 readonly contentLength?: number;
930928 readonly scroll?: number;
......@@ -933,51 +931,6 @@ interface TimelinePositionState {
933931 readonly sizeAtIndex?: (index: number) => number | undefined;
934932}
935933
936function deriveTimelineMinimapItems(
937 rows: ReadonlyArray<MessagesTimelineRow>,
938): TimelineMinimapItem[] {
939 const items: TimelineMinimapItem[] = [];
940 for (let index = 0; index < rows.length; index += 1) {
941 const row = rows[index];
942 if (row?.kind !== "message" || row.message.role !== "user") {
943 continue;
944 }
945
946 items.push({
947 id: row.id,
948 rowIndex: index,
949 userText: compactMinimapPreview(row.message.text),
950 assistantText: compactMinimapPreview(resolveFinalAssistantTextForTurn(rows, index)),
951 });
952 }
953 return items;
954}
955
956function resolveFinalAssistantTextForTurn(
957 rows: ReadonlyArray<MessagesTimelineRow>,
958 userRowIndex: number,
959) {
960 let finalAssistantText: string | null = null;
961 for (let index = userRowIndex + 1; index < rows.length; index += 1) {
962 const row = rows[index];
963 if (row?.kind !== "message") {
964 continue;
965 }
966 if (row.message.role === "user") {
967 break;
968 }
969 if (row.message.role === "assistant") {
970 finalAssistantText = row.message.text ?? null;
971 }
972 }
973 return finalAssistantText;
974}
975
976function compactMinimapPreview(text: string | null | undefined) {
977 const compact = text?.replace(/\s+/g, " ").trim() ?? "";
978 return compact.length > 0 ? compact : null;
979}
980
981934function resolveTimelineRowTop(state: TimelinePositionState, rowIndex: number) {
982935 const top = state.positionAtIndex?.(rowIndex);
983936 return typeof top === "number" && Number.isFinite(top) ? top : null;
......@@ -1011,7 +964,13 @@ function TimelineMinimap({
1011964
1012965 const resolvedActiveIndex =
1013966 activeIndex !== null && activeIndex < items.length ? activeIndex : null;
1014 const activeItem = resolvedActiveIndex === null ? null : (items[resolvedActiveIndex] ?? null);
967 const activeItem = useMemo(
968 () =>
969 resolveTimelineMinimapPreview(
970 resolvedActiveIndex === null ? null : (items[resolvedActiveIndex] ?? null),
971 ),
972 [items, resolvedActiveIndex],
973 );
1015974 const activeTopPercent =
1016975 resolvedActiveIndex === null
1017976 ? 0
apps/web/src/components/chat/timelineMinimapItems.test.ts created+81
......@@ -0,0 +1,81 @@
1import { describe, expect, it } from "vite-plus/test";
2import { MessageId } from "@t3tools/contracts";
3import type { MessagesTimelineRow } from "./MessagesTimeline.logic";
4import { deriveTimelineMinimapItems, resolveTimelineMinimapPreview } from "./timelineMinimapItems";
5import type { ChatMessage } from "../../types";
6
7function rows(
8 entries: ReadonlyArray<readonly ["user" | "assistant", string]>,
9): MessagesTimelineRow[] {
10 const messages: ChatMessage[] = entries.map(([role, text], index) => ({
11 id: MessageId.make(`message-${index}`),
12 role,
13 text,
14 streaming: false,
15 turnId: null,
16 createdAt: new Date(index * 1000).toISOString(),
17 updatedAt: new Date(index * 1000).toISOString(),
18 }));
19 return messages.map((message) => ({
20 kind: "message",
21 id: message.id,
22 createdAt: message.createdAt,
23 message,
24 durationStart: message.createdAt,
25 showAssistantMeta: false,
26 showAssistantCopyButton: false,
27 assistantCopyStreaming: false,
28 }));
29}
30
31describe("timeline minimap previews", () => {
32 it("previews the last assistant response before the next prompt and retains jump targets", () => {
33 const source = rows([
34 ["user", " Inspect\n this "],
35 ["assistant", "Working"],
36 ["assistant", " Done\t now "],
37 ["user", "Next"],
38 ["assistant", "Second answer"],
39 ]);
40 const items = deriveTimelineMinimapItems(source);
41 expect(items).toHaveLength(2);
42 expect(resolveTimelineMinimapPreview(items[0]!)).toEqual({
43 ...items[0],
44 userText: "Inspect this",
45 assistantText: "Done now",
46 });
47 expect(source[items[0]!.rowIndex]!.id).toBe(items[0]!.id);
48 expect(resolveTimelineMinimapPreview(items[1]!)?.assistantText).toBe("Second answer");
49 expect(items[0]?.assistantText).toBe(" Done\t now ");
50 });
51
52 it("handles an unanswered prompt, empty responses, and a closed preview", () => {
53 const items = deriveTimelineMinimapItems(
54 rows([
55 ["user", "First"],
56 ["assistant", " \n\t"],
57 ["user", "Next"],
58 ]),
59 );
60 expect(items.map((item) => resolveTimelineMinimapPreview(item)?.assistantText)).toEqual([
61 null,
62 null,
63 ]);
64 expect(resolveTimelineMinimapPreview(null)).toBeNull();
65 });
66
67 it("shows fresh streaming text without changing the jump target", () => {
68 const first = deriveTimelineMinimapItems(
69 rows([
70 ["user", "Explain"],
71 ["assistant", "First"],
72 ]),
73 )[0]!;
74 const next = { ...first, assistantText: "First\n second" };
75 expect(resolveTimelineMinimapPreview(next)).toEqual({
76 ...first,
77 assistantText: "First second",
78 });
79 expect(resolveTimelineMinimapPreview(first)?.assistantText).toBe("First");
80 });
81});
apps/web/src/components/chat/timelineMinimapItems.ts created+66
......@@ -0,0 +1,66 @@
1import type { MessagesTimelineRow } from "./MessagesTimeline.logic";
2
3export interface TimelineMinimapItem {
4 readonly id: string;
5 readonly rowIndex: number;
6 readonly userText: string | null;
7 readonly assistantText: string | null;
8}
9
10/** Keep full source text untouched until a minimap preview is opened. */
11export function deriveTimelineMinimapItems(
12 rows: ReadonlyArray<MessagesTimelineRow>,
13): TimelineMinimapItem[] {
14 const items: TimelineMinimapItem[] = [];
15 for (let index = 0; index < rows.length; index += 1) {
16 const row = rows[index];
17 if (row?.kind !== "message" || row.message.role !== "user") {
18 continue;
19 }
20
21 items.push({
22 id: row.id,
23 rowIndex: index,
24 userText: row.message.text,
25 assistantText: resolveFinalAssistantTextForTurn(rows, index),
26 });
27 }
28 return items;
29}
30
31function resolveFinalAssistantTextForTurn(
32 rows: ReadonlyArray<MessagesTimelineRow>,
33 userRowIndex: number,
34) {
35 let finalAssistantText: string | null = null;
36 for (let index = userRowIndex + 1; index < rows.length; index += 1) {
37 const row = rows[index];
38 if (row?.kind !== "message") {
39 continue;
40 }
41 if (row.message.role === "user") {
42 break;
43 }
44 if (row.message.role === "assistant") {
45 finalAssistantText = row.message.text ?? null;
46 }
47 }
48 return finalAssistantText;
49}
50
51function compactMinimapPreview(text: string | null | undefined) {
52 const compact = text?.replace(/\s+/g, " ").trim() ?? "";
53 return compact.length > 0 ? compact : null;
54}
55
56export function resolveTimelineMinimapPreview(
57 item: TimelineMinimapItem | null,
58): TimelineMinimapItem | null {
59 return item === null
60 ? null
61 : {
62 ...item,
63 userText: compactMinimapPreview(item.userText),
64 assistantText: compactMinimapPreview(item.assistantText),
65 };
66}