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..f7c40aa71206 --- /dev/null +++ b/apps/web/src/components/chat/timelineMinimapItems.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vite-plus/test"; +import { MessageId } from "@t3tools/contracts"; +import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; +import { deriveTimelineMinimapItems, resolveTimelineMinimapPreview } from "./timelineMinimapItems"; +import type { ChatMessage } from "../../types"; + +function rows( + entries: ReadonlyArray, +): MessagesTimelineRow[] { + 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 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", () => { + 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), + }; +}