Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 12 additions & 53 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -933,51 +931,6 @@ interface TimelinePositionState {
readonly sizeAtIndex?: (index: number) => number | undefined;
}

function deriveTimelineMinimapItems(
rows: ReadonlyArray<MessagesTimelineRow>,
): 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<MessagesTimelineRow>,
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;
Expand Down Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions apps/web/src/components/chat/timelineMinimapItems.test.ts
Original file line number Diff line number Diff line change
@@ -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<readonly ["user" | "assistant", string]>,
): 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");
});
});
66 changes: 66 additions & 0 deletions apps/web/src/components/chat/timelineMinimapItems.ts
Original file line number Diff line number Diff line change
@@ -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<MessagesTimelineRow>,
): 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<MessagesTimelineRow>,
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),
};
}
Loading