mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 21:49:37 +00:00
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:
parent
bf3e792a6a
commit
6e5a41fd9a
@ -331,6 +331,7 @@ export default function AgentChatPage() {
|
|||||||
testId="main-message-list"
|
testId="main-message-list"
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
thread={thread}
|
thread={thread}
|
||||||
|
enableConversationOutline
|
||||||
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
|
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
|
||||||
hasMoreHistory={hasMoreHistory}
|
hasMoreHistory={hasMoreHistory}
|
||||||
loadMoreHistory={loadMoreHistory}
|
loadMoreHistory={loadMoreHistory}
|
||||||
|
|||||||
@ -333,6 +333,7 @@ export default function ChatPage() {
|
|||||||
testId="main-message-list"
|
testId="main-message-list"
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
thread={thread}
|
thread={thread}
|
||||||
|
enableConversationOutline
|
||||||
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
|
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
|
||||||
hasMoreHistory={hasMoreHistory}
|
hasMoreHistory={hasMoreHistory}
|
||||||
loadMoreHistory={loadMoreHistory}
|
loadMoreHistory={loadMoreHistory}
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -27,6 +27,10 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { extractArtifactsFromThread } from "@/core/artifacts/utils";
|
import { extractArtifactsFromThread } from "@/core/artifacts/utils";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import {
|
||||||
|
buildConversationChapters,
|
||||||
|
CONVERSATION_OUTLINE_MIN_TURNS,
|
||||||
|
} from "@/core/messages/conversation-outline";
|
||||||
import {
|
import {
|
||||||
deriveAssistantTurnUsageState,
|
deriveAssistantTurnUsageState,
|
||||||
deriveStableMessageGroups,
|
deriveStableMessageGroups,
|
||||||
@ -83,6 +87,7 @@ import { CopyButton } from "../copy-button";
|
|||||||
import { useMaybeSidecar } from "../sidecar/context";
|
import { useMaybeSidecar } from "../sidecar/context";
|
||||||
import { Tooltip } from "../tooltip";
|
import { Tooltip } from "../tooltip";
|
||||||
|
|
||||||
|
import { ConversationOutline } from "./conversation-outline";
|
||||||
import {
|
import {
|
||||||
HumanInputCard,
|
HumanInputCard,
|
||||||
type HumanInputSubmitResult,
|
type HumanInputSubmitResult,
|
||||||
@ -97,7 +102,10 @@ import {
|
|||||||
import { RunActivity, RunDuration } from "./run-duration";
|
import { RunActivity, RunDuration } from "./run-duration";
|
||||||
import { MessageListSkeleton } from "./skeleton";
|
import { MessageListSkeleton } from "./skeleton";
|
||||||
import { SubtaskCard } from "./subtask-card";
|
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_TOKEN_DEBUG_STEPS: TokenDebugStep[] = [];
|
||||||
const EMPTY_ARTIFACT_PATHS: readonly string[] = [];
|
const EMPTY_ARTIFACT_PATHS: readonly string[] = [];
|
||||||
@ -287,6 +295,7 @@ export function MessageList({
|
|||||||
canEdit = false,
|
canEdit = false,
|
||||||
canBranch = false,
|
canBranch = false,
|
||||||
enableSidecarActions = true,
|
enableSidecarActions = true,
|
||||||
|
enableConversationOutline = false,
|
||||||
sidecarSurface = false,
|
sidecarSurface = false,
|
||||||
initialScroll = "smooth",
|
initialScroll = "smooth",
|
||||||
resizeScroll = "smooth",
|
resizeScroll = "smooth",
|
||||||
@ -320,6 +329,7 @@ export function MessageList({
|
|||||||
canEdit?: boolean;
|
canEdit?: boolean;
|
||||||
canBranch?: boolean;
|
canBranch?: boolean;
|
||||||
enableSidecarActions?: boolean;
|
enableSidecarActions?: boolean;
|
||||||
|
enableConversationOutline?: boolean;
|
||||||
sidecarSurface?: boolean;
|
sidecarSurface?: boolean;
|
||||||
initialScroll?: ConversationProps["initial"];
|
initialScroll?: ConversationProps["initial"];
|
||||||
resizeScroll?: ConversationProps["resize"];
|
resizeScroll?: ConversationProps["resize"];
|
||||||
@ -330,6 +340,60 @@ export function MessageList({
|
|||||||
useState<SelectionToolbarState | null>(null);
|
useState<SelectionToolbarState | null>(null);
|
||||||
const messages = thread.messages;
|
const messages = thread.messages;
|
||||||
const groupedMessages = useStableMessageGroups(messages, thread.isLoading);
|
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 browserView = useMaybeBrowserView();
|
||||||
const pushBrowserFrame = browserView?.pushFrame;
|
const pushBrowserFrame = browserView?.pushFrame;
|
||||||
const messageCount = messages.length;
|
const messageCount = messages.length;
|
||||||
@ -1020,8 +1084,12 @@ export function MessageList({
|
|||||||
loadMore={loadMoreHistory}
|
loadMore={loadMoreHistory}
|
||||||
/>
|
/>
|
||||||
<VirtualMessageList
|
<VirtualMessageList
|
||||||
|
ref={virtualMessageListRef}
|
||||||
groups={groupedMessages}
|
groups={groupedMessages}
|
||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
|
onActiveGroupChange={
|
||||||
|
conversationOutlineEnabled ? handleActiveGroupChange : undefined
|
||||||
|
}
|
||||||
renderGroup={(group, groupIndex) => {
|
renderGroup={(group, groupIndex) => {
|
||||||
const turnUsageMessages =
|
const turnUsageMessages =
|
||||||
turnUsageMessagesByGroupIndex[groupIndex];
|
turnUsageMessagesByGroupIndex[groupIndex];
|
||||||
@ -1359,6 +1427,13 @@ export function MessageList({
|
|||||||
<div style={{ height: `${paddingBottom}px` }} />
|
<div style={{ height: `${paddingBottom}px` }} />
|
||||||
</ConversationContent>
|
</ConversationContent>
|
||||||
</Conversation>
|
</Conversation>
|
||||||
|
{conversationOutlineEnabled && (
|
||||||
|
<ConversationOutline
|
||||||
|
chapters={chapters}
|
||||||
|
activeChapterId={activeChapterId}
|
||||||
|
onChapterSelect={handleChapterSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{selectionToolbar && sidecar && (
|
{selectionToolbar && sidecar && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@ -6,9 +6,11 @@ import {
|
|||||||
type Range,
|
type Range,
|
||||||
} from "@tanstack/react-virtual";
|
} from "@tanstack/react-virtual";
|
||||||
import {
|
import {
|
||||||
|
forwardRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useMemo,
|
|
||||||
useRef,
|
useRef,
|
||||||
type Key,
|
type Key,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@ -19,8 +21,29 @@ import type { MessageGroup } from "@/core/messages/utils";
|
|||||||
|
|
||||||
const VIRTUALIZATION_THRESHOLD = 60;
|
const VIRTUALIZATION_THRESHOLD = 60;
|
||||||
const ESTIMATED_ROW_HEIGHT = 176;
|
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 (
|
return (
|
||||||
group?.id ??
|
group?.id ??
|
||||||
group?.messages.find((message) => message.id)?.id ??
|
group?.messages.find((message) => message.id)?.id ??
|
||||||
@ -28,16 +51,16 @@ function groupKey(group: MessageGroup | undefined, index: number) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VirtualMessageList({
|
export const VirtualMessageList = forwardRef<
|
||||||
groups,
|
VirtualMessageListHandle,
|
||||||
isLoading,
|
VirtualMessageListProps
|
||||||
renderGroup,
|
>(function VirtualMessageList(
|
||||||
}: {
|
{ groups, isLoading, renderGroup, onActiveGroupChange },
|
||||||
groups: readonly MessageGroup[];
|
ref,
|
||||||
isLoading: boolean;
|
) {
|
||||||
renderGroup: (group: MessageGroup, index: number) => ReactNode;
|
const { isAtBottom, scrollRef, scrollToBottom, stopScroll } =
|
||||||
}) {
|
useStickToBottomContext();
|
||||||
const { isAtBottom, scrollRef, scrollToBottom } = useStickToBottomContext();
|
const listRef = useRef<HTMLDivElement | null>(null);
|
||||||
const activeIndex = isLoading ? groups.length - 1 : -1;
|
const activeIndex = isLoading ? groups.length - 1 : -1;
|
||||||
const getItemKey = useCallback(
|
const getItemKey = useCallback(
|
||||||
(index: number) => groupKey(groups[index], index),
|
(index: number) => groupKey(groups[index], index),
|
||||||
@ -64,13 +87,142 @@ export function VirtualMessageList({
|
|||||||
});
|
});
|
||||||
const virtualItems = virtualizer.getVirtualItems();
|
const virtualItems = virtualizer.getVirtualItems();
|
||||||
const shouldVirtualize = groups.length >= VIRTUALIZATION_THRESHOLD;
|
const shouldVirtualize = groups.length >= VIRTUALIZATION_THRESHOLD;
|
||||||
|
const firstVirtualIndex = virtualItems[0]?.index ?? -1;
|
||||||
|
const lastVirtualIndex = virtualItems.at(-1)?.index ?? -1;
|
||||||
const positionedInitialVirtualWindowRef = useRef(false);
|
const positionedInitialVirtualWindowRef = useRef(false);
|
||||||
const previousCountRef = useRef(groups.length);
|
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>(
|
const anchorRef = useRef<{ key: Key; viewportOffset: number } | undefined>(
|
||||||
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(() => {
|
useLayoutEffect(() => {
|
||||||
let settleFrame: number | undefined;
|
let settleFrame: number | undefined;
|
||||||
if (
|
if (
|
||||||
@ -144,22 +296,21 @@ export function VirtualMessageList({
|
|||||||
}
|
}
|
||||||
}, [getItemKey, groups, virtualItems, virtualizer]);
|
}, [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) {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={listRef}
|
||||||
className="relative w-full"
|
className="relative w-full"
|
||||||
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
||||||
>
|
>
|
||||||
@ -171,6 +322,7 @@ export function VirtualMessageList({
|
|||||||
key={virtualRow.key}
|
key={virtualRow.key}
|
||||||
ref={virtualizer.measureElement}
|
ref={virtualizer.measureElement}
|
||||||
data-index={virtualRow.index}
|
data-index={virtualRow.index}
|
||||||
|
data-message-group-index={virtualRow.index}
|
||||||
className="absolute top-0 left-0 w-full pb-8"
|
className="absolute top-0 left-0 w-full pb-8"
|
||||||
style={{ transform: `translateY(${virtualRow.start}px)` }}
|
style={{ transform: `translateY(${virtualRow.start}px)` }}
|
||||||
>
|
>
|
||||||
@ -180,4 +332,4 @@ export function VirtualMessageList({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@ -620,6 +620,8 @@ export const enUS: Translations = {
|
|||||||
branchFailed: "Failed to branch conversation.",
|
branchFailed: "Failed to branch conversation.",
|
||||||
streamReplayGap:
|
streamReplayGap:
|
||||||
"Some live updates expired. The conversation was restored from saved state.",
|
"Some live updates expired. The conversation was restored from saved state.",
|
||||||
|
outlineLabel: "Conversation outline",
|
||||||
|
outlineAttachmentFallback: "Image or file message",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Chats
|
// Chats
|
||||||
|
|||||||
@ -506,6 +506,8 @@ export interface Translations {
|
|||||||
branchCreated: string;
|
branchCreated: string;
|
||||||
branchFailed: string;
|
branchFailed: string;
|
||||||
streamReplayGap: string;
|
streamReplayGap: string;
|
||||||
|
outlineLabel: string;
|
||||||
|
outlineAttachmentFallback: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Chats
|
// Chats
|
||||||
|
|||||||
@ -592,6 +592,8 @@ export const zhCN: Translations = {
|
|||||||
branchCreated: "已创建分叉对话",
|
branchCreated: "已创建分叉对话",
|
||||||
branchFailed: "创建分叉对话失败。",
|
branchFailed: "创建分叉对话失败。",
|
||||||
streamReplayGap: "部分实时更新已过期,已从持久化状态恢复对话。",
|
streamReplayGap: "部分实时更新已过期,已从持久化状态恢复对话。",
|
||||||
|
outlineLabel: "对话章节",
|
||||||
|
outlineAttachmentFallback: "图片或文件消息",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Chats
|
// Chats
|
||||||
|
|||||||
54
frontend/src/core/messages/conversation-outline.ts
Normal file
54
frontend/src/core/messages/conversation-outline.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -94,6 +94,52 @@ test.describe("Thread history", () => {
|
|||||||
).toBeVisible({ timeout: 15_000 });
|
).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 ({
|
test("keeps a thousand-turn history DOM bounded while preserving navigation", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@ -126,20 +172,47 @@ test.describe("Thread history", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const conversation = page.getByRole("log");
|
const conversation = page.getByRole("log");
|
||||||
const scroller = conversation.locator(":scope > div").first();
|
|
||||||
await expect
|
await expect
|
||||||
.poll(() => conversation.locator("[data-index]").count())
|
.poll(() => conversation.locator("[data-index]").count())
|
||||||
.toBeLessThan(60);
|
.toBeLessThan(60);
|
||||||
|
|
||||||
await scroller.dispatchEvent("wheel", { deltaY: -1_000 });
|
const outlineTrigger = page.getByTestId("conversation-outline-trigger");
|
||||||
await scroller.evaluate((element) => {
|
await expect(outlineTrigger).toBeVisible();
|
||||||
element.scrollTop = 0;
|
await outlineTrigger.click();
|
||||||
element.dispatchEvent(new Event("scroll"));
|
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({
|
const targetAnswer = conversation.getByText("Long history answer 0", {
|
||||||
timeout: 15_000,
|
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);
|
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 ({
|
test("keeps rendered messages ordered when the latest history page advances", async ({
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
106
frontend/tests/unit/core/messages/conversation-outline.test.ts
Normal file
106
frontend/tests/unit/core/messages/conversation-outline.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user