diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index 1e777efb9..8cf663a32 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -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} diff --git a/frontend/src/components/workspace/chats/chat-page.tsx b/frontend/src/components/workspace/chats/chat-page.tsx index 2551bdb91..5c00fea25 100644 --- a/frontend/src/components/workspace/chats/chat-page.tsx +++ b/frontend/src/components/workspace/chats/chat-page.tsx @@ -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} diff --git a/frontend/src/components/workspace/messages/conversation-outline.tsx b/frontend/src/components/workspace/messages/conversation-outline.tsx new file mode 100644 index 000000000..5b2730a5c --- /dev/null +++ b/frontend/src/components/workspace/messages/conversation-outline.tsx @@ -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(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 ( +
+ + + + + + {chapters.map((chapter) => { + const active = chapter.id === activeChapterId; + return ( + { + event.preventDefault(); + onChapterSelect(chapter.id); + }} + > + + {chapter.title} + + + ); + })} + + +
+ ); +} diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index d982ebc16..cba7b8a30 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -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(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(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} /> { const turnUsageMessages = turnUsageMessagesByGroupIndex[groupIndex]; @@ -1359,6 +1427,13 @@ export function MessageList({
+ {conversationOutlineEnabled && ( + + )} {selectionToolbar && sidecar && (
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(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(undefined); + const previousFirstKeyRef = useRef(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( + `[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( + "[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) => ( -
{renderGroup(group, index)}
- )), - [getItemKey, groups, renderGroup, shouldVirtualize], - ); - if (!shouldVirtualize) { - return
{renderedAll}
; + return ( +
+ {groups.map((group, index) => ( +
+ {renderGroup(group, index)} +
+ ))} +
+ ); } return (
@@ -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({ })}
); -} +}); diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index cbacd0ebc..33224af72 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -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 diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index acfefa25c..1afa7972d 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -506,6 +506,8 @@ export interface Translations { branchCreated: string; branchFailed: string; streamReplayGap: string; + outlineLabel: string; + outlineAttachmentFallback: string; }; // Chats diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index f845643ff..345f4bd3f 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -592,6 +592,8 @@ export const zhCN: Translations = { branchCreated: "已创建分叉对话", branchFailed: "创建分叉对话失败。", streamReplayGap: "部分实时更新已过期,已从持久化状态恢复对话。", + outlineLabel: "对话章节", + outlineAttachmentFallback: "图片或文件消息", }, // Chats diff --git a/frontend/src/core/messages/conversation-outline.ts b/frontend/src/core/messages/conversation-outline.ts new file mode 100644 index 000000000..11732f5d1 --- /dev/null +++ b/frontend/src/core/messages/conversation-outline.ts @@ -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; +} diff --git a/frontend/tests/e2e/thread-history.spec.ts b/frontend/tests/e2e/thread-history.spec.ts index 5076093fc..2c8f02406 100644 --- a/frontend/tests/e2e/thread-history.spec.ts +++ b/frontend/tests/e2e/thread-history.spec.ts @@ -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 ({ diff --git a/frontend/tests/unit/components/workspace/messages/conversation-outline.dom.test.tsx b/frontend/tests/unit/components/workspace/messages/conversation-outline.dom.test.tsx new file mode 100644 index 000000000..7eb3dbe44 --- /dev/null +++ b/frontend/tests/unit/components/workspace/messages/conversation-outline.dom.test.tsx @@ -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> = {}, +) { + return render( + undefined, t: enUS }} + > + undefined} + {...props} + /> +
Conversation transcript
+
, + ); +} + +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(); + }); +}); diff --git a/frontend/tests/unit/core/messages/conversation-outline.test.ts b/frontend/tests/unit/core/messages/conversation-outline.test.ts new file mode 100644 index 000000000..fcec480e2 --- /dev/null +++ b/frontend/tests/unit/core/messages/conversation-outline.test.ts @@ -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", + "\n- report.pdf\n\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("�"); + }); + + 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); + }); +});