| author | |
| committer | |
| log | 8fc253605e6d203c3654dbb1ce22fa1fd6fa0767 |
| tree | aa56fc3addb110a55bb3a46fdcdaee3418fc1aac |
| parent | 57aee3e19f1910f3323f06384bb2a1d02b79e369 |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 159 insertions(+), 53 deletions(-)
apps/web/src/components/chat/MessagesTimeline.tsx+12-53| ... | ... | @@ -4,6 +4,11 @@ import { |
| 4 | 4 | getQuestionAnswerText, |
| 5 | 5 | hasQuestionAnswer, |
| 6 | 6 | } from "@t3tools/client-runtime/work-log/user-input"; |
| 7 | import { | |
| 8 | deriveTimelineMinimapItems, | |
| 9 | resolveTimelineMinimapPreview, | |
| 10 | type TimelineMinimapItem, | |
| 11 | } from "./timelineMinimapItems"; | |
| 7 | 12 | import { |
| 8 | 13 | type AssistantCitation, |
| 9 | 14 | type EnvironmentId, |
| ... | ... | @@ -918,13 +923,6 @@ function getItemType(item: MessagesTimelineRow) { |
| 918 | 923 | return item.kind === "message" ? `message:${item.message.role}` : item.kind; |
| 919 | 924 | } |
| 920 | 925 | |
| 921 | interface TimelineMinimapItem { | |
| 922 | readonly id: string; | |
| 923 | readonly rowIndex: number; | |
| 924 | readonly userText: string | null; | |
| 925 | readonly assistantText: string | null; | |
| 926 | } | |
| 927 | ||
| 928 | 926 | interface TimelinePositionState { |
| 929 | 927 | readonly contentLength?: number; |
| 930 | 928 | readonly scroll?: number; |
| ... | ... | @@ -933,51 +931,6 @@ interface TimelinePositionState { |
| 933 | 931 | readonly sizeAtIndex?: (index: number) => number | undefined; |
| 934 | 932 | } |
| 935 | 933 | |
| 936 | function 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 | ||
| 956 | function 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 | ||
| 976 | function compactMinimapPreview(text: string | null | undefined) { | |
| 977 | const compact = text?.replace(/\s+/g, " ").trim() ?? ""; | |
| 978 | return compact.length > 0 ? compact : null; | |
| 979 | } | |
| 980 | ||
| 981 | 934 | function resolveTimelineRowTop(state: TimelinePositionState, rowIndex: number) { |
| 982 | 935 | const top = state.positionAtIndex?.(rowIndex); |
| 983 | 936 | return typeof top === "number" && Number.isFinite(top) ? top : null; |
| ... | ... | @@ -1011,7 +964,13 @@ function TimelineMinimap({ |
| 1011 | 964 | |
| 1012 | 965 | const resolvedActiveIndex = |
| 1013 | 966 | 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 | ); | |
| 1015 | 974 | const activeTopPercent = |
| 1016 | 975 | resolvedActiveIndex === null |
| 1017 | 976 | ? 0 |
apps/web/src/components/chat/timelineMinimapItems.test.ts created+81| ... | ... | @@ -0,0 +1,81 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import { MessageId } from "@t3tools/contracts"; | |
| 3 | import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; | |
| 4 | import { deriveTimelineMinimapItems, resolveTimelineMinimapPreview } from "./timelineMinimapItems"; | |
| 5 | import type { ChatMessage } from "../../types"; | |
| 6 | ||
| 7 | function 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 | ||
| 31 | describe("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 @@ |
| 1 | import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; | |
| 2 | ||
| 3 | export 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. */ | |
| 11 | export 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 | ||
| 31 | function 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 | ||
| 51 | function compactMinimapPreview(text: string | null | undefined) { | |
| 52 | const compact = text?.replace(/\s+/g, " ").trim() ?? ""; | |
| 53 | return compact.length > 0 ? compact : null; | |
| 54 | } | |
| 55 | ||
| 56 | export 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 | } |