| 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,6 +4,11 @@ import { |
| 4 | getQuestionAnswerText, | 4 | getQuestionAnswerText, |
| 5 | hasQuestionAnswer, | 5 | hasQuestionAnswer, |
| 6 | } from "@t3tools/client-runtime/work-log/user-input"; | 6 | } from "@t3tools/client-runtime/work-log/user-input"; |
| 7 | import { | ||
| 8 | deriveTimelineMinimapItems, | ||
| 9 | resolveTimelineMinimapPreview, | ||
| 10 | type TimelineMinimapItem, | ||
| 11 | } from "./timelineMinimapItems"; | ||
| 7 | import { | 12 | import { |
| 8 | type AssistantCitation, | 13 | type AssistantCitation, |
| 9 | type EnvironmentId, | 14 | type EnvironmentId, |
| ... | @@ -918,13 +923,6 @@ function getItemType(item: MessagesTimelineRow) { | ... | @@ -918,13 +923,6 @@ function getItemType(item: MessagesTimelineRow) { |
| 918 | return item.kind === "message" ? `message:${item.message.role}` : item.kind; | 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 | interface TimelinePositionState { | 926 | interface TimelinePositionState { |
| 929 | readonly contentLength?: number; | 927 | readonly contentLength?: number; |
| 930 | readonly scroll?: number; | 928 | readonly scroll?: number; |
| ... | @@ -933,51 +931,6 @@ interface TimelinePositionState { | ... | @@ -933,51 +931,6 @@ interface TimelinePositionState { |
| 933 | readonly sizeAtIndex?: (index: number) => number | undefined; | 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 | function resolveTimelineRowTop(state: TimelinePositionState, rowIndex: number) { | 934 | function resolveTimelineRowTop(state: TimelinePositionState, rowIndex: number) { |
| 982 | const top = state.positionAtIndex?.(rowIndex); | 935 | const top = state.positionAtIndex?.(rowIndex); |
| 983 | return typeof top === "number" && Number.isFinite(top) ? top : null; | 936 | return typeof top === "number" && Number.isFinite(top) ? top : null; |
| ... | @@ -1011,7 +964,13 @@ function TimelineMinimap({ | ... | @@ -1011,7 +964,13 @@ function TimelineMinimap({ |
| 1011 | 964 | ||
| 1012 | const resolvedActiveIndex = | 965 | const resolvedActiveIndex = |
| 1013 | activeIndex !== null && activeIndex < items.length ? activeIndex : null; | 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 | const activeTopPercent = | 974 | const activeTopPercent = |
| 1016 | resolvedActiveIndex === null | 975 | resolvedActiveIndex === null |
| 1017 | ? 0 | 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 | } | ||