feat(webui): segment transcript storage

This commit is contained in:
Xubin Ren
2026-06-10 18:28:55 +08:00
parent 7186039be1
commit e168bb2754
15 changed files with 1029 additions and 94 deletions
+51 -15
View File
@@ -38,11 +38,16 @@ interface ThreadViewportProps {
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
forkBoundaryMessageCount?: number | null;
hasMoreBefore?: boolean;
loadingOlder?: boolean;
userMessageOffset?: number;
onLoadOlder?: () => Promise<void> | void;
onOpenFilePreview?: (path: string) => void;
onForkFromMessage?: (beforeUserIndex: number) => void;
}
const NEAR_BOTTOM_PX = 48;
const NEAR_TOP_PX = 96;
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
export const INITIAL_HISTORY_WINDOW = 160;
@@ -72,6 +77,10 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
cliApps = [],
mcpPresets = [],
forkBoundaryMessageCount = null,
hasMoreBefore = false,
loadingOlder = false,
userMessageOffset = 0,
onLoadOlder,
onOpenFilePreview,
onForkFromMessage,
}, ref) {
@@ -99,9 +108,10 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
);
const hiddenMessageCount = messages.length - visibleMessages.length;
const hiddenUserMessageCount =
hiddenMessageCount > 0
userMessageOffset
+ (hiddenMessageCount > 0
? messages.slice(0, hiddenMessageCount).filter((message) => message.role === "user").length
: 0;
: 0);
const visibleForkBoundaryMessageCount =
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
? forkBoundaryMessageCount - hiddenMessageCount
@@ -126,6 +136,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
} else if (el) {
el.scrollTo({ top: el.scrollHeight, behavior });
}
userReadingHistoryRef.current = false;
setAtBottom(true);
}, []);
@@ -159,10 +170,26 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
}
userReadingHistoryRef.current = true;
setAtBottom(false);
setVisibleMessageCount((count) =>
Math.min(messages.length, count + HISTORY_WINDOW_INCREMENT),
);
}, [messages.length]);
if (hiddenMessageCount > 0) {
setVisibleMessageCount((count) =>
Math.min(messages.length, count + HISTORY_WINDOW_INCREMENT),
);
return;
}
if (hasMoreBefore && onLoadOlder && !loadingOlder) {
setVisibleMessageCount((count) => count + HISTORY_WINDOW_INCREMENT);
void onLoadOlder();
}
}, [hasMoreBefore, hiddenMessageCount, loadingOlder, messages.length, onLoadOlder]);
const maybeLoadEarlierFromScroll = useCallback(() => {
const el = scrollRef.current;
if (!el || !hasMessages || pendingConversationScrollRef.current) return;
if (!userReadingHistoryRef.current) return;
if (el.scrollTop > NEAR_TOP_PX) return;
if (hiddenMessageCount <= 0 && !hasMoreBefore) return;
loadEarlierMessages();
}, [hasMessages, hasMoreBefore, hiddenMessageCount, loadEarlierMessages]);
const jumpToUserPrompt = useCallback((promptId: string) => {
const scrollEl = scrollRef.current;
@@ -218,8 +245,17 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
restoreScrollAfterPrependRef.current = null;
if (!el) return;
const delta = el.scrollHeight - pending.height;
el.scrollTop = pending.top + delta;
}, [visibleMessages.length]);
const nextTop = pending.top + delta;
try {
el.scrollTop = nextTop;
} catch {
try {
el.scrollTo?.({ top: nextTop, behavior: "auto" });
} catch {
// Test DOMs can expose read-only scrollTop; browsers keep this writable.
}
}
}, [visibleMessages.length, messages.length]);
useLayoutEffect(() => {
const promptId = pendingPromptJumpRef.current;
@@ -271,17 +307,19 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const el = scrollRef.current;
if (!el) return;
const onScroll = () => {
const onScroll = (allowHistoryLoad = true) => {
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
const near = distance < NEAR_BOTTOM_PX;
setAtBottom(near);
userReadingHistoryRef.current = !near;
if (allowHistoryLoad && !near) maybeLoadEarlierFromScroll();
};
onScroll();
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, []);
onScroll(false);
const handleScroll = () => onScroll(true);
el.addEventListener("scroll", handleScroll, { passive: true });
return () => el.removeEventListener("scroll", handleScroll);
}, [maybeLoadEarlierFromScroll]);
return (
<div className="relative flex min-h-0 flex-1 overflow-hidden">
@@ -302,9 +340,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
<ThreadMessages
messages={visibleMessages}
isStreaming={isStreaming}
hiddenMessageCount={hiddenMessageCount}
hiddenUserMessageCount={hiddenUserMessageCount}
onLoadEarlier={loadEarlierMessages}
cliApps={cliApps}
mcpPresets={mcpPresets}
forkBoundaryMessageCount={visibleForkBoundaryMessageCount}