feat(frontend): add conversation outline navigation for long chats (#5025)

* feat(frontend): add conversation outline navigation for long chats

* fix(frontend): escape bottom lock before outline navigation
This commit is contained in:
Syt3s 2026-08-29 08:00:55 +08:00 committed by GitHub
parent bf3e792a6a
commit 6e5a41fd9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 709 additions and 33 deletions

View File

@ -331,6 +331,7 @@ export default function AgentChatPage() {
testId="main-message-list"
threadId={threadId}
thread={thread}
enableConversationOutline
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
hasMoreHistory={hasMoreHistory}
loadMoreHistory={loadMoreHistory}

View File

@ -333,6 +333,7 @@ export default function ChatPage() {
testId="main-message-list"
threadId={threadId}
thread={thread}
enableConversationOutline
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
hasMoreHistory={hasMoreHistory}
loadMoreHistory={loadMoreHistory}

View File

@ -0,0 +1,132 @@
"use client";
import { ListIcon } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useI18n } from "@/core/i18n/hooks";
import type { ConversationChapter } from "@/core/messages/conversation-outline";
import { cn } from "@/lib/utils";
const MAX_OUTLINE_TICKS = 24;
type ConversationOutlineProps = {
chapters: readonly ConversationChapter[];
activeChapterId: string | null;
onChapterSelect: (chapterId: string) => void;
};
type OutlineTick = {
id: string;
active: boolean;
};
function buildOutlineTicks(
chapters: readonly ConversationChapter[],
activeChapterIndex: number,
): OutlineTick[] {
const tickCount = Math.min(chapters.length, MAX_OUTLINE_TICKS);
return Array.from({ length: tickCount }, (_, tickIndex) => {
const startIndex = Math.floor((tickIndex * chapters.length) / tickCount);
const endIndex = Math.floor(
((tickIndex + 1) * chapters.length) / tickCount,
);
return {
id: chapters[startIndex]?.id ?? `tick:${tickIndex}`,
active: activeChapterIndex >= startIndex && activeChapterIndex < endIndex,
};
});
}
export function ConversationOutline({
chapters,
activeChapterId,
onChapterSelect,
}: ConversationOutlineProps): ReactNode {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const activeItemRef = useRef<HTMLDivElement | null>(null);
const activeChapterIndex = chapters.findIndex(
(chapter) => chapter.id === activeChapterId,
);
const outlineTicks = buildOutlineTicks(chapters, activeChapterIndex);
useEffect(() => {
if (!open) {
return;
}
activeItemRef.current?.scrollIntoView({ block: "nearest" });
}, [activeChapterId, open]);
return (
<div className="pointer-events-none absolute top-1/2 right-2 z-20 -translate-y-1/2 sm:right-3">
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
aria-label={t.conversation.outlineLabel}
className="bg-background/80 text-muted-foreground hover:text-foreground pointer-events-auto size-9 rounded-full border shadow-sm backdrop-blur-sm lg:h-auto lg:min-h-10 lg:w-7 lg:flex-col lg:gap-1 lg:px-1 lg:py-2"
data-testid="conversation-outline-trigger"
size="icon"
type="button"
variant="outline"
>
<ListIcon className="size-4 lg:hidden" />
<span
aria-hidden="true"
className="hidden max-h-52 w-full flex-col items-center gap-1 overflow-hidden lg:flex"
>
{outlineTicks.map((tick) => (
<span
key={tick.id}
className={cn(
"bg-muted-foreground/45 h-0.5 w-3 rounded-full transition-[width,background-color] motion-reduce:transition-none",
tick.active && "bg-foreground h-[3px] w-4",
)}
/>
))}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="center"
className="border-border/80 bg-popover/95 max-h-[min(72vh,36rem)] w-72 overflow-y-auto rounded-2xl p-2 shadow-xl backdrop-blur-sm sm:max-h-[min(80vh,40rem)]"
data-testid="conversation-outline-menu"
side="left"
sideOffset={8}
>
{chapters.map((chapter) => {
const active = chapter.id === activeChapterId;
return (
<DropdownMenuItem
key={chapter.id}
ref={active ? activeItemRef : undefined}
aria-current={active ? "location" : undefined}
className={cn(
"items-start rounded-lg px-3 py-2 text-[15px] leading-5 whitespace-normal",
active && "bg-accent text-accent-foreground",
)}
title={chapter.title}
onSelect={(event) => {
event.preventDefault();
onChapterSelect(chapter.id);
}}
>
<span className="line-clamp-2 min-w-0 leading-5">
{chapter.title}
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@ -27,6 +27,10 @@ import {
import { Button } from "@/components/ui/button";
import { extractArtifactsFromThread } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import {
buildConversationChapters,
CONVERSATION_OUTLINE_MIN_TURNS,
} from "@/core/messages/conversation-outline";
import {
deriveAssistantTurnUsageState,
deriveStableMessageGroups,
@ -83,6 +87,7 @@ import { CopyButton } from "../copy-button";
import { useMaybeSidecar } from "../sidecar/context";
import { Tooltip } from "../tooltip";
import { ConversationOutline } from "./conversation-outline";
import {
HumanInputCard,
type HumanInputSubmitResult,
@ -97,7 +102,10 @@ import {
import { RunActivity, RunDuration } from "./run-duration";
import { MessageListSkeleton } from "./skeleton";
import { SubtaskCard } from "./subtask-card";
import { VirtualMessageList } from "./virtual-message-list";
import {
VirtualMessageList,
type VirtualMessageListHandle,
} from "./virtual-message-list";
const EMPTY_TOKEN_DEBUG_STEPS: TokenDebugStep[] = [];
const EMPTY_ARTIFACT_PATHS: readonly string[] = [];
@ -287,6 +295,7 @@ export function MessageList({
canEdit = false,
canBranch = false,
enableSidecarActions = true,
enableConversationOutline = false,
sidecarSurface = false,
initialScroll = "smooth",
resizeScroll = "smooth",
@ -320,6 +329,7 @@ export function MessageList({
canEdit?: boolean;
canBranch?: boolean;
enableSidecarActions?: boolean;
enableConversationOutline?: boolean;
sidecarSurface?: boolean;
initialScroll?: ConversationProps["initial"];
resizeScroll?: ConversationProps["resize"];
@ -330,6 +340,60 @@ export function MessageList({
useState<SelectionToolbarState | null>(null);
const messages = thread.messages;
const groupedMessages = useStableMessageGroups(messages, thread.isLoading);
const chapters = useMemo(
() =>
buildConversationChapters(
groupedMessages,
t.conversation.outlineAttachmentFallback,
),
[groupedMessages, t.conversation.outlineAttachmentFallback],
);
const conversationOutlineEnabled =
enableConversationOutline &&
chapters.length >= CONVERSATION_OUTLINE_MIN_TURNS;
const virtualMessageListRef = useRef<VirtualMessageListHandle | null>(null);
const [activeChapter, setActiveChapter] = useState<{
threadId: string;
chapterId: string;
} | null>(null);
const activeChapterId =
activeChapter?.threadId === threadId &&
chapters.some((chapter) => chapter.id === activeChapter.chapterId)
? activeChapter.chapterId
: (chapters.at(-1)?.id ?? null);
const handleActiveGroupChange = useCallback(
(groupIndex: number) => {
let chapterId: string | undefined;
for (const chapter of chapters) {
if (chapter.groupIndex > groupIndex) {
break;
}
chapterId = chapter.id;
}
if (chapterId) {
setActiveChapter((current) =>
current?.threadId === threadId && current.chapterId === chapterId
? current
: { threadId, chapterId },
);
}
},
[chapters, threadId],
);
const handleChapterSelect = useCallback(
(chapterId: string) => {
const chapter = chapters.find((candidate) => candidate.id === chapterId);
if (!chapter) {
return;
}
setActiveChapter({ threadId, chapterId });
virtualMessageListRef.current?.scrollToGroup(chapter.groupIndex, {
align: "start",
behavior: "auto",
});
},
[chapters, threadId],
);
const browserView = useMaybeBrowserView();
const pushBrowserFrame = browserView?.pushFrame;
const messageCount = messages.length;
@ -1020,8 +1084,12 @@ export function MessageList({
loadMore={loadMoreHistory}
/>
<VirtualMessageList
ref={virtualMessageListRef}
groups={groupedMessages}
isLoading={thread.isLoading}
onActiveGroupChange={
conversationOutlineEnabled ? handleActiveGroupChange : undefined
}
renderGroup={(group, groupIndex) => {
const turnUsageMessages =
turnUsageMessagesByGroupIndex[groupIndex];
@ -1359,6 +1427,13 @@ export function MessageList({
<div style={{ height: `${paddingBottom}px` }} />
</ConversationContent>
</Conversation>
{conversationOutlineEnabled && (
<ConversationOutline
chapters={chapters}
activeChapterId={activeChapterId}
onChapterSelect={handleChapterSelect}
/>
)}
{selectionToolbar && sidecar && (
<div
className={cn(

View File

@ -6,9 +6,11 @@ import {
type Range,
} from "@tanstack/react-virtual";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
type Key,
type ReactNode,
@ -19,8 +21,29 @@ import type { MessageGroup } from "@/core/messages/utils";
const VIRTUALIZATION_THRESHOLD = 60;
const ESTIMATED_ROW_HEIGHT = 176;
const GROUP_START_OFFSET = 16;
const VIRTUAL_SCROLL_SETTLE_ATTEMPTS = 4;
const STATIC_SCROLL_SETTLE_ATTEMPTS = 2;
function groupKey(group: MessageGroup | undefined, index: number) {
type GroupAlignment = "start" | "center";
type ScrollToGroupOptions = {
behavior?: ScrollBehavior;
align?: GroupAlignment;
};
type VirtualMessageListProps = {
groups: readonly MessageGroup[];
isLoading: boolean;
renderGroup: (group: MessageGroup, index: number) => ReactNode;
onActiveGroupChange?: (groupIndex: number) => void;
};
export type VirtualMessageListHandle = {
scrollToGroup: (groupIndex: number, options?: ScrollToGroupOptions) => void;
};
function groupKey(group: MessageGroup | undefined, index: number): Key {
return (
group?.id ??
group?.messages.find((message) => message.id)?.id ??
@ -28,16 +51,16 @@ function groupKey(group: MessageGroup | undefined, index: number) {
);
}
export function VirtualMessageList({
groups,
isLoading,
renderGroup,
}: {
groups: readonly MessageGroup[];
isLoading: boolean;
renderGroup: (group: MessageGroup, index: number) => ReactNode;
}) {
const { isAtBottom, scrollRef, scrollToBottom } = useStickToBottomContext();
export const VirtualMessageList = forwardRef<
VirtualMessageListHandle,
VirtualMessageListProps
>(function VirtualMessageList(
{ groups, isLoading, renderGroup, onActiveGroupChange },
ref,
) {
const { isAtBottom, scrollRef, scrollToBottom, stopScroll } =
useStickToBottomContext();
const listRef = useRef<HTMLDivElement | null>(null);
const activeIndex = isLoading ? groups.length - 1 : -1;
const getItemKey = useCallback(
(index: number) => groupKey(groups[index], index),
@ -64,13 +87,142 @@ export function VirtualMessageList({
});
const virtualItems = virtualizer.getVirtualItems();
const shouldVirtualize = groups.length >= VIRTUALIZATION_THRESHOLD;
const firstVirtualIndex = virtualItems[0]?.index ?? -1;
const lastVirtualIndex = virtualItems.at(-1)?.index ?? -1;
const positionedInitialVirtualWindowRef = useRef(false);
const previousCountRef = useRef(groups.length);
const previousFirstKeyRef = useRef<string | undefined>(undefined);
const previousFirstKeyRef = useRef<Key | undefined>(undefined);
const previousActiveGroupRef = useRef(-1);
const anchorRef = useRef<{ key: Key; viewportOffset: number } | undefined>(
undefined,
);
const alignGroupToViewport = useCallback(
(
groupIndex: number,
align: GroupAlignment,
behavior: ScrollBehavior,
): boolean => {
const viewport = scrollRef.current;
const row = listRef.current?.querySelector<HTMLElement>(
`[data-message-group-index="${groupIndex}"]`,
);
if (!viewport || !row) {
return false;
}
const viewportRect = viewport.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
const top =
viewport.scrollTop +
rowRect.top -
viewportRect.top -
(align === "center"
? Math.max(0, (viewport.clientHeight - rowRect.height) / 2)
: GROUP_START_OFFSET);
viewport.scrollTo({ top: Math.max(0, top), behavior });
return true;
},
[scrollRef],
);
useImperativeHandle(
ref,
() => ({
scrollToGroup(groupIndex, options) {
if (groupIndex < 0 || groupIndex >= groups.length) {
return;
}
stopScroll();
const behavior = options?.behavior ?? "auto";
const align = options?.align ?? "start";
if (shouldVirtualize) {
virtualizer.scrollToIndex(groupIndex, { align, behavior });
}
const maxAttempts = shouldVirtualize
? VIRTUAL_SCROLL_SETTLE_ATTEMPTS
: STATIC_SCROLL_SETTLE_ATTEMPTS;
let attempt = 0;
const settleOnExactGroup = () => {
const exactBehavior = attempt === 0 ? behavior : "auto";
const aligned = alignGroupToViewport(
groupIndex,
align,
exactBehavior,
);
attempt += 1;
const needsAnotherAttempt = shouldVirtualize || !aligned;
if (attempt < maxAttempts && needsAnotherAttempt) {
requestAnimationFrame(settleOnExactGroup);
}
};
requestAnimationFrame(settleOnExactGroup);
},
}),
[
alignGroupToViewport,
groups.length,
shouldVirtualize,
stopScroll,
virtualizer,
],
);
useEffect(() => {
const viewport = scrollRef.current;
const list = listRef.current;
if (!viewport || !list || !onActiveGroupChange) {
return;
}
let animationFrame: number | undefined;
const updateActiveGroup = () => {
animationFrame = undefined;
const rows = list.querySelectorAll<HTMLElement>(
"[data-message-group-index]",
);
if (rows.length === 0) {
return;
}
const readingLine = viewport.getBoundingClientRect().top + 96;
let activeRow = rows[0];
for (const row of rows) {
if (row.getBoundingClientRect().top > readingLine) {
break;
}
activeRow = row;
}
const groupIndex = Number(activeRow?.dataset.messageGroupIndex);
if (
Number.isSafeInteger(groupIndex) &&
groupIndex !== previousActiveGroupRef.current
) {
previousActiveGroupRef.current = groupIndex;
onActiveGroupChange(groupIndex);
}
};
const scheduleUpdate = () => {
animationFrame ??= requestAnimationFrame(updateActiveGroup);
};
scheduleUpdate();
viewport.addEventListener("scroll", scheduleUpdate, { passive: true });
return () => {
viewport.removeEventListener("scroll", scheduleUpdate);
if (animationFrame !== undefined) {
cancelAnimationFrame(animationFrame);
}
};
}, [
firstVirtualIndex,
groups,
lastVirtualIndex,
onActiveGroupChange,
scrollRef,
]);
useLayoutEffect(() => {
let settleFrame: number | undefined;
if (
@ -144,22 +296,21 @@ export function VirtualMessageList({
}
}, [getItemKey, groups, virtualItems, virtualizer]);
const renderedAll = useMemo(
() =>
shouldVirtualize
? null
: groups.map((group, index) => (
<div key={getItemKey(index)}>{renderGroup(group, index)}</div>
)),
[getItemKey, groups, renderGroup, shouldVirtualize],
);
if (!shouldVirtualize) {
return <div className="flex flex-col gap-8">{renderedAll}</div>;
return (
<div ref={listRef} className="flex flex-col gap-8">
{groups.map((group, index) => (
<div key={getItemKey(index)} data-message-group-index={index}>
{renderGroup(group, index)}
</div>
))}
</div>
);
}
return (
<div
ref={listRef}
className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
@ -171,6 +322,7 @@ export function VirtualMessageList({
key={virtualRow.key}
ref={virtualizer.measureElement}
data-index={virtualRow.index}
data-message-group-index={virtualRow.index}
className="absolute top-0 left-0 w-full pb-8"
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
@ -180,4 +332,4 @@ export function VirtualMessageList({
})}
</div>
);
}
});

View File

@ -620,6 +620,8 @@ export const enUS: Translations = {
branchFailed: "Failed to branch conversation.",
streamReplayGap:
"Some live updates expired. The conversation was restored from saved state.",
outlineLabel: "Conversation outline",
outlineAttachmentFallback: "Image or file message",
},
// Chats

View File

@ -506,6 +506,8 @@ export interface Translations {
branchCreated: string;
branchFailed: string;
streamReplayGap: string;
outlineLabel: string;
outlineAttachmentFallback: string;
};
// Chats

View File

@ -592,6 +592,8 @@ export const zhCN: Translations = {
branchCreated: "已创建分叉对话",
branchFailed: "创建分叉对话失败。",
streamReplayGap: "部分实时更新已过期,已从持久化状态恢复对话。",
outlineLabel: "对话章节",
outlineAttachmentFallback: "图片或文件消息",
},
// Chats

View File

@ -0,0 +1,54 @@
import {
extractTextFromMessage,
stripUploadedFilesTag,
type MessageGroup,
} from "./utils";
export const CONVERSATION_OUTLINE_MIN_TURNS = 5;
export const CONVERSATION_CHAPTER_TITLE_MAX_LENGTH = 48;
export type ConversationChapter = {
id: string;
groupIndex: number;
title: string;
};
function normalizeChapterTitle(content: string, fallbackTitle: string): string {
const normalized = stripUploadedFilesTag(content).replace(/\s+/g, " ").trim();
if (!normalized) {
return fallbackTitle;
}
const characters = Array.from(normalized);
if (characters.length <= CONVERSATION_CHAPTER_TITLE_MAX_LENGTH) {
return normalized;
}
return `${characters
.slice(0, CONVERSATION_CHAPTER_TITLE_MAX_LENGTH)
.join("")}`;
}
export function buildConversationChapters(
groups: readonly MessageGroup[],
fallbackTitle: string,
): ConversationChapter[] {
const chapters: ConversationChapter[] = [];
groups.forEach((group, groupIndex) => {
if (group.type !== "human") {
return;
}
const message = group.messages[0];
chapters.push({
id: group.id ?? message?.id ?? `human-turn:${groupIndex}`,
groupIndex,
title: normalizeChapterTitle(
message ? extractTextFromMessage(message) : "",
fallbackTitle,
),
});
});
return chapters;
}

View File

@ -94,6 +94,52 @@ test.describe("Thread history", () => {
).toBeVisible({ timeout: 15_000 });
});
test("shows the conversation outline only at the long-chat threshold", async ({
page,
}) => {
const turns = (count: number, prefix: string) =>
Array.from({ length: count }, (_, turn) => [
{
type: "human",
id: `${prefix}-human-${turn}`,
content: `${prefix} question ${turn}`,
},
{
type: "ai",
id: `${prefix}-ai-${turn}`,
content: `${prefix} answer ${turn}`,
},
]).flat();
mockLangGraphAPI(page, {
threads: [
{
thread_id: MOCK_THREAD_ID,
title: "Four turns",
messages: turns(4, "Short"),
},
{
thread_id: MOCK_THREAD_ID_2,
title: "Five turns",
messages: turns(5, "Long"),
},
],
});
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
await expect(page.getByText("Short answer 3")).toBeVisible({
timeout: 15_000,
});
await expect(page.getByTestId("conversation-outline-trigger")).toBeHidden();
await page.goto(`/workspace/chats/${MOCK_THREAD_ID_2}`);
await expect(page.getByText("Long answer 4")).toBeVisible({
timeout: 15_000,
});
await expect(
page.getByTestId("conversation-outline-trigger"),
).toBeVisible();
});
test("keeps a thousand-turn history DOM bounded while preserving navigation", async ({
page,
}) => {
@ -126,20 +172,47 @@ test.describe("Thread history", () => {
});
const conversation = page.getByRole("log");
const scroller = conversation.locator(":scope > div").first();
await expect
.poll(() => conversation.locator("[data-index]").count())
.toBeLessThan(60);
await scroller.dispatchEvent("wheel", { deltaY: -1_000 });
await scroller.evaluate((element) => {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll"));
const outlineTrigger = page.getByTestId("conversation-outline-trigger");
await expect(outlineTrigger).toBeVisible();
await outlineTrigger.click();
const outlineMenu = page.getByTestId("conversation-outline-menu");
await outlineMenu
.getByText("Long history question 0", { exact: true })
.click();
const targetQuestion = conversation.getByText("Long history question 0", {
exact: true,
});
await expect(page.getByText("Long history question 0")).toBeVisible({
timeout: 15_000,
const targetAnswer = conversation.getByText("Long history answer 0", {
exact: true,
});
await expect(targetQuestion).toBeVisible({ timeout: 15_000 });
await expect
.poll(async () => {
const questionBox = await targetQuestion.boundingBox();
const conversationBox = await conversation.boundingBox();
if (!questionBox || !conversationBox) {
return Number.POSITIVE_INFINITY;
}
return questionBox.y - conversationBox.y;
})
.toBeLessThan(200);
const questionBox = await targetQuestion.boundingBox();
const answerBox = await targetAnswer.boundingBox();
expect(questionBox).not.toBeNull();
expect(answerBox).not.toBeNull();
expect(questionBox!.y).toBeLessThan(answerBox!.y);
expect(await conversation.locator("[data-index]").count()).toBeLessThan(60);
await expect(
outlineMenu
.getByText("Long history question 0", { exact: true })
.locator(".."),
).toHaveAttribute("aria-current", "location");
});
test("keeps rendered messages ordered when the latest history page advances", async ({

View File

@ -0,0 +1,76 @@
import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { ConversationOutline } from "@/components/workspace/messages/conversation-outline";
import { I18nContext } from "@/core/i18n/context";
import { enUS } from "@/core/i18n/locales/en-US";
import type { ConversationChapter } from "@/core/messages/conversation-outline";
const chapters: ConversationChapter[] = [
{ id: "human-1", groupIndex: 0, title: "First question" },
{ id: "human-2", groupIndex: 2, title: "Second question" },
];
function renderOutline(
props: Partial<React.ComponentProps<typeof ConversationOutline>> = {},
) {
return render(
<I18nContext.Provider
value={{ locale: "en-US", setLocale: () => undefined, t: enUS }}
>
<ConversationOutline
chapters={chapters}
activeChapterId="human-2"
onChapterSelect={() => undefined}
{...props}
/>
<div role="log">Conversation transcript</div>
</I18nContext.Provider>,
);
}
function openOutline() {
fireEvent.pointerDown(
screen.getByRole("button", { name: "Conversation outline" }),
{ button: 0, ctrlKey: false },
);
}
afterEach(cleanup);
afterEach(() => {
rs.restoreAllMocks();
});
describe("ConversationOutline", () => {
it("renders ordered chapters and exposes the active location", async () => {
renderOutline();
openOutline();
expect(await screen.findByText("First question")).toBeTruthy();
expect(screen.getByText("Second question")).toBeTruthy();
const menu = screen.getByTestId("conversation-outline-menu");
expect(menu.classList.contains("overflow-y-auto")).toBe(true);
expect(menu.classList.contains("max-h-[min(72vh,36rem)]")).toBe(true);
expect(menu.className).not.toContain(" h-[");
expect(menu.textContent).not.toContain("Conversation outline");
expect(
screen
.getByText("Second question")
.closest('[role="menuitem"]')
?.getAttribute("aria-current"),
).toBe("location");
});
it("selects a chapter", async () => {
const onChapterSelect = rs.fn();
renderOutline({ onChapterSelect });
openOutline();
fireEvent.click(await screen.findByText("First question"));
expect(onChapterSelect).toHaveBeenCalledWith("human-1");
expect(screen.getByTestId("conversation-outline-menu")).toBeTruthy();
expect(screen.getByRole("log")).toBeTruthy();
});
});

View File

@ -0,0 +1,106 @@
import type { Message } from "@langchain/langgraph-sdk";
import { describe, expect, it } from "@rstest/core";
import {
buildConversationChapters,
CONVERSATION_CHAPTER_TITLE_MAX_LENGTH,
CONVERSATION_OUTLINE_MIN_TURNS,
} from "@/core/messages/conversation-outline";
import { getMessageGroups } from "@/core/messages/utils";
function message(
type: Message["type"],
id: string | undefined,
content: Message["content"],
): Message {
return { type, id, content } as Message;
}
describe("conversation outline model", () => {
it("creates one ordered chapter per visible human turn", () => {
const groups = getMessageGroups([
message("human", "human-1", "First question"),
message("ai", "assistant-1", "First answer"),
message("human", "human-2", "Second question"),
{
...message("ai", "tool-call", ""),
tool_calls: [{ id: "call-1", name: "bash", args: {} }],
} as Message,
{
...message("tool", "tool-result", "done"),
tool_call_id: "call-1",
} as Message,
message("ai", "assistant-2", "Second answer"),
]);
expect(buildConversationChapters(groups, "Attachment")).toEqual([
{
id: "human-1",
groupIndex: 0,
title: "First question",
},
{
id: "human-2",
groupIndex: 2,
title: "Second question",
},
]);
});
it("normalizes whitespace and removes upload metadata", () => {
const groups = getMessageGroups([
message("human", "human-1", " First line\n\n second\tline "),
message(
"human",
"human-2",
"<uploaded_files>\n- report.pdf\n</uploaded_files>\n\nSummarize the report",
),
]);
expect(
buildConversationChapters(groups, "Attachment").map(
(chapter) => chapter.title,
),
).toEqual(["First line second line", "Summarize the report"]);
});
it("uses the localized fallback for attachment-only structured content", () => {
const groups = getMessageGroups([
message("human", "human-image", [
{ type: "image_url", image_url: { url: "data:image/png;base64,x" } },
]),
]);
expect(buildConversationChapters(groups, "图片或文件消息")[0]?.title).toBe(
"图片或文件消息",
);
});
it("truncates by Unicode code points without splitting emoji", () => {
const content = `${"问".repeat(CONVERSATION_CHAPTER_TITLE_MAX_LENGTH - 1)}😀结尾`;
const groups = getMessageGroups([message("human", "human-long", content)]);
const title = buildConversationChapters(groups, "Attachment")[0]?.title;
expect(Array.from(title ?? "")).toHaveLength(
CONVERSATION_CHAPTER_TITLE_MAX_LENGTH + 1,
);
expect(title?.endsWith("…")).toBe(true);
expect(title).not.toContain("<22>");
});
it("falls back to a deterministic group key when message ids are absent", () => {
const groups = getMessageGroups([
message("human", undefined, "Question"),
message("ai", undefined, "Answer"),
]);
expect(buildConversationChapters(groups, "Attachment")[0]?.id).toBe(
"human-turn:0",
);
});
it("exposes the approved long-conversation threshold", () => {
expect(CONVERSATION_OUTLINE_MIN_TURNS).toBe(5);
});
});