feat(webui): segment transcript storage
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { MessageBubble } from "@/components/MessageBubble";
|
||||
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
||||
import { normalizeActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
|
||||
@@ -10,9 +9,7 @@ interface ThreadMessagesProps {
|
||||
messages: UIMessage[];
|
||||
/** When true, agent turn still in flight — keeps activity timeline expanded. */
|
||||
isStreaming?: boolean;
|
||||
hiddenMessageCount?: number;
|
||||
hiddenUserMessageCount?: number;
|
||||
onLoadEarlier?: () => void;
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
forkBoundaryMessageCount?: number | null;
|
||||
@@ -66,9 +63,7 @@ export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
|
||||
export function ThreadMessages({
|
||||
messages,
|
||||
isStreaming = false,
|
||||
hiddenMessageCount = 0,
|
||||
hiddenUserMessageCount = 0,
|
||||
onLoadEarlier,
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
forkBoundaryMessageCount = null,
|
||||
@@ -90,20 +85,6 @@ export function ThreadMessages({
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
{hiddenMessageCount > 0 && onLoadEarlier ? (
|
||||
<div className="mb-4 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoadEarlier}
|
||||
className="rounded-full border border-border/60 bg-background/85 px-3 py-1.5 text-xs font-medium text-muted-foreground shadow-sm transition-colors hover:bg-muted/55 hover:text-foreground"
|
||||
>
|
||||
{t("thread.loadEarlier", {
|
||||
count: hiddenMessageCount,
|
||||
defaultValue: "Load earlier messages",
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{units.map((unit, index) => {
|
||||
const prev = units[index - 1];
|
||||
const marginTop =
|
||||
|
||||
@@ -250,6 +250,10 @@ export function ThreadShell({
|
||||
const {
|
||||
messages: historical,
|
||||
loading,
|
||||
loadingOlder,
|
||||
loadOlder,
|
||||
hasMoreBefore,
|
||||
userMessageOffset,
|
||||
hasPendingToolCalls,
|
||||
refresh: refreshHistory,
|
||||
version: historyVersion,
|
||||
@@ -415,6 +419,14 @@ export function ThreadShell({
|
||||
}
|
||||
if (cached && cached.length > 0) {
|
||||
const normalizedCached = projectWebuiThreadMessages(cached);
|
||||
if (
|
||||
normalizedHistory.length > normalizedCached.length
|
||||
&& !isStaleThreadSnapshot(prev, normalizedHistory)
|
||||
) {
|
||||
messageCacheRef.current.set(chatId, normalizedHistory);
|
||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||
return normalizedHistory;
|
||||
}
|
||||
if (isStaleThreadSnapshot(prev, normalizedCached)) return keepLiveMessages(prev);
|
||||
return normalizedCached;
|
||||
}
|
||||
@@ -752,6 +764,10 @@ export function ThreadShell({
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
forkBoundaryMessageCount={forkBoundaryMessageCount}
|
||||
hasMoreBefore={hasMoreBefore}
|
||||
loadingOlder={loadingOlder}
|
||||
userMessageOffset={userMessageOffset}
|
||||
onLoadOlder={loadOlder}
|
||||
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
|
||||
onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -12,6 +12,16 @@ import { deriveTitle } from "@/lib/format";
|
||||
import type { ChatSummary, UIMessage, WorkspaceScopePayload } from "@/lib/types";
|
||||
|
||||
const EMPTY_MESSAGES: UIMessage[] = [];
|
||||
const INITIAL_HISTORY_PAGE_LIMIT = 160;
|
||||
const OLDER_HISTORY_PAGE_LIMIT = 120;
|
||||
|
||||
function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] {
|
||||
return messages.map((m, idx) => ({
|
||||
...m,
|
||||
id: m.id ?? `hist-${idx}`,
|
||||
createdAt: typeof m.createdAt === "number" ? m.createdAt : Date.now(),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Sidebar state: fetches the full session list and exposes create / delete actions. */
|
||||
export function useSessions(): {
|
||||
@@ -129,14 +139,19 @@ export function useSessions(): {
|
||||
export function useSessionHistory(key: string | null): {
|
||||
messages: UIMessage[];
|
||||
loading: boolean;
|
||||
loadingOlder: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
loadOlder: () => Promise<void>;
|
||||
hasMoreBefore: boolean;
|
||||
userMessageOffset: number;
|
||||
version: number;
|
||||
forkBoundaryMessageCount: number | null;
|
||||
/** ``true`` when the replayed transcript ends with a trace row (turn still in flight). */
|
||||
hasPendingToolCalls: boolean;
|
||||
} {
|
||||
const { token } = useClient();
|
||||
const loadingOlderRef = useRef(false);
|
||||
const [refreshSeq, setRefreshSeq] = useState(0);
|
||||
const refresh = useCallback(() => {
|
||||
setRefreshSeq((value) => value + 1);
|
||||
@@ -145,17 +160,25 @@ export function useSessionHistory(key: string | null): {
|
||||
key: string | null;
|
||||
messages: UIMessage[];
|
||||
loading: boolean;
|
||||
loadingOlder: boolean;
|
||||
error: string | null;
|
||||
hasPendingToolCalls: boolean;
|
||||
forkBoundaryMessageCount: number | null;
|
||||
beforeCursor: string | null;
|
||||
hasMoreBefore: boolean;
|
||||
userMessageOffset: number;
|
||||
version: number;
|
||||
}>({
|
||||
key: null,
|
||||
messages: [],
|
||||
loading: false,
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: false,
|
||||
forkBoundaryMessageCount: null,
|
||||
beforeCursor: null,
|
||||
hasMoreBefore: false,
|
||||
userMessageOffset: 0,
|
||||
version: 0,
|
||||
});
|
||||
|
||||
@@ -165,9 +188,13 @@ export function useSessionHistory(key: string | null): {
|
||||
key: null,
|
||||
messages: [],
|
||||
loading: false,
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: false,
|
||||
forkBoundaryMessageCount: null,
|
||||
beforeCursor: null,
|
||||
hasMoreBefore: false,
|
||||
userMessageOffset: 0,
|
||||
version: 0,
|
||||
});
|
||||
return;
|
||||
@@ -176,37 +203,44 @@ export function useSessionHistory(key: string | null): {
|
||||
// Mark the new key as loading immediately so callers never see stale
|
||||
// messages from the previous session during the render right after a switch.
|
||||
setState((prev) => prev.key === key
|
||||
? { ...prev, loading: true, error: null }
|
||||
? { ...prev, loading: true, loadingOlder: false, error: null }
|
||||
: {
|
||||
key,
|
||||
messages: [],
|
||||
loading: true,
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: false,
|
||||
forkBoundaryMessageCount: null,
|
||||
beforeCursor: null,
|
||||
hasMoreBefore: false,
|
||||
userMessageOffset: 0,
|
||||
version: 0,
|
||||
});
|
||||
(async () => {
|
||||
try {
|
||||
const body = await fetchWebuiThread(token, key);
|
||||
const body = await fetchWebuiThread(token, key, {
|
||||
limit: INITIAL_HISTORY_PAGE_LIMIT,
|
||||
direction: "latest",
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (!body?.messages?.length) {
|
||||
setState((prev) => ({
|
||||
key,
|
||||
messages: [],
|
||||
loading: false,
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: false,
|
||||
forkBoundaryMessageCount: null,
|
||||
beforeCursor: null,
|
||||
hasMoreBefore: false,
|
||||
userMessageOffset: 0,
|
||||
version: prev.key === key ? prev.version + 1 : 1,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const ui: UIMessage[] = body.messages.map((m, idx) => ({
|
||||
...m,
|
||||
id: m.id ?? `hist-${idx}`,
|
||||
createdAt: typeof m.createdAt === "number" ? m.createdAt : Date.now(),
|
||||
}));
|
||||
const ui = persistedMessagesToUi(body.messages);
|
||||
const last = ui[ui.length - 1];
|
||||
const hasPending = last?.kind === "trace";
|
||||
const forkBoundary = typeof body.fork_boundary_message_count === "number"
|
||||
@@ -216,9 +250,13 @@ export function useSessionHistory(key: string | null): {
|
||||
key,
|
||||
messages: ui,
|
||||
loading: false,
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: hasPending,
|
||||
forkBoundaryMessageCount: forkBoundary,
|
||||
beforeCursor: body.page?.before_cursor ?? null,
|
||||
hasMoreBefore: body.page?.has_more_before === true,
|
||||
userMessageOffset: Math.max(0, body.page?.user_message_offset ?? 0),
|
||||
version: prev.key === key ? prev.version + 1 : 1,
|
||||
}));
|
||||
} catch (e) {
|
||||
@@ -228,9 +266,13 @@ export function useSessionHistory(key: string | null): {
|
||||
key,
|
||||
messages: [],
|
||||
loading: false,
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: false,
|
||||
forkBoundaryMessageCount: null,
|
||||
beforeCursor: null,
|
||||
hasMoreBefore: false,
|
||||
userMessageOffset: 0,
|
||||
version: prev.key === key ? prev.version + 1 : 1,
|
||||
}));
|
||||
} else {
|
||||
@@ -238,9 +280,13 @@ export function useSessionHistory(key: string | null): {
|
||||
key,
|
||||
messages: [],
|
||||
loading: false,
|
||||
loadingOlder: false,
|
||||
error: (e as Error).message,
|
||||
hasPendingToolCalls: false,
|
||||
forkBoundaryMessageCount: null,
|
||||
beforeCursor: null,
|
||||
hasMoreBefore: false,
|
||||
userMessageOffset: 0,
|
||||
version: prev.key === key ? prev.version : 0,
|
||||
}));
|
||||
}
|
||||
@@ -251,12 +297,78 @@ export function useSessionHistory(key: string | null): {
|
||||
};
|
||||
}, [key, token, refreshSeq]);
|
||||
|
||||
const loadOlder = useCallback(async () => {
|
||||
if (!key || loadingOlderRef.current) return;
|
||||
const before = state.key === key ? state.beforeCursor : null;
|
||||
if (!before || !state.hasMoreBefore) return;
|
||||
loadingOlderRef.current = true;
|
||||
setState((prev) => prev.key === key ? { ...prev, loadingOlder: true, error: null } : prev);
|
||||
try {
|
||||
const body = await fetchWebuiThread(token, key, {
|
||||
limit: OLDER_HISTORY_PAGE_LIMIT,
|
||||
before,
|
||||
});
|
||||
setState((prev) => {
|
||||
if (prev.key !== key) return prev;
|
||||
if (!body?.messages?.length) {
|
||||
return {
|
||||
...prev,
|
||||
loadingOlder: false,
|
||||
hasMoreBefore: false,
|
||||
beforeCursor: null,
|
||||
};
|
||||
}
|
||||
const older = persistedMessagesToUi(body.messages);
|
||||
const olderBoundary = typeof body.fork_boundary_message_count === "number"
|
||||
? Math.max(0, Math.min(body.fork_boundary_message_count, older.length))
|
||||
: null;
|
||||
const shiftedBoundary = prev.forkBoundaryMessageCount === null
|
||||
? null
|
||||
: prev.forkBoundaryMessageCount + older.length;
|
||||
const nextMessages = [...older, ...prev.messages];
|
||||
const last = nextMessages[nextMessages.length - 1];
|
||||
return {
|
||||
...prev,
|
||||
messages: nextMessages,
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: last?.kind === "trace",
|
||||
forkBoundaryMessageCount: olderBoundary ?? shiftedBoundary,
|
||||
beforeCursor: body.page?.before_cursor ?? null,
|
||||
hasMoreBefore: body.page?.has_more_before === true,
|
||||
userMessageOffset: Math.max(0, body.page?.user_message_offset ?? 0),
|
||||
version: prev.version + 1,
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
setState((prev) => prev.key === key
|
||||
? {
|
||||
...prev,
|
||||
loadingOlder: false,
|
||||
error: (e as Error).message,
|
||||
}
|
||||
: prev);
|
||||
} finally {
|
||||
loadingOlderRef.current = false;
|
||||
}
|
||||
}, [
|
||||
key,
|
||||
state.beforeCursor,
|
||||
state.hasMoreBefore,
|
||||
state.key,
|
||||
token,
|
||||
]);
|
||||
|
||||
if (!key) {
|
||||
return {
|
||||
messages: EMPTY_MESSAGES,
|
||||
loading: false,
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
refresh,
|
||||
loadOlder,
|
||||
hasMoreBefore: false,
|
||||
userMessageOffset: 0,
|
||||
version: 0,
|
||||
forkBoundaryMessageCount: null,
|
||||
hasPendingToolCalls: false,
|
||||
@@ -269,8 +381,12 @@ export function useSessionHistory(key: string | null): {
|
||||
return {
|
||||
messages: EMPTY_MESSAGES,
|
||||
loading: true,
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
refresh,
|
||||
loadOlder,
|
||||
hasMoreBefore: false,
|
||||
userMessageOffset: 0,
|
||||
version: 0,
|
||||
forkBoundaryMessageCount: null,
|
||||
hasPendingToolCalls: false,
|
||||
@@ -280,8 +396,12 @@ export function useSessionHistory(key: string | null): {
|
||||
return {
|
||||
messages: state.messages,
|
||||
loading: state.loading,
|
||||
loadingOlder: state.loadingOlder,
|
||||
error: state.error,
|
||||
refresh,
|
||||
loadOlder,
|
||||
hasMoreBefore: state.hasMoreBefore,
|
||||
userMessageOffset: state.userMessageOffset,
|
||||
version: state.version,
|
||||
forkBoundaryMessageCount: state.forkBoundaryMessageCount,
|
||||
hasPendingToolCalls: state.hasPendingToolCalls,
|
||||
|
||||
+16
-1
@@ -124,12 +124,27 @@ export async function listSessions(
|
||||
}
|
||||
|
||||
/** Disk-backed WebUI display thread snapshot (separate from agent session). */
|
||||
export interface FetchWebuiThreadOptions {
|
||||
limit?: number;
|
||||
direction?: "latest";
|
||||
before?: string | null;
|
||||
}
|
||||
|
||||
export async function fetchWebuiThread(
|
||||
token: string,
|
||||
key: string,
|
||||
optionsOrBase?: FetchWebuiThreadOptions | string,
|
||||
base: string = "",
|
||||
): Promise<WebuiThreadPersistedPayload | null> {
|
||||
const url = `${base}/api/sessions/${encodeURIComponent(key)}/webui-thread`;
|
||||
const options = typeof optionsOrBase === "string" ? undefined : optionsOrBase;
|
||||
const resolvedBase = typeof optionsOrBase === "string" ? optionsOrBase : base;
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
||||
if (options?.direction) params.set("direction", options.direction);
|
||||
if (options?.before) params.set("before", options.before);
|
||||
const query = params.toString();
|
||||
const suffix = query ? `?${query}` : "";
|
||||
const url = `${resolvedBase}/api/sessions/${encodeURIComponent(key)}/webui-thread${suffix}`;
|
||||
const res = await fetchWithTimeout(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
credentials: "same-origin",
|
||||
|
||||
@@ -857,12 +857,21 @@ export interface OutboundMcpPresetMention {
|
||||
}
|
||||
|
||||
/** Response shape for ``GET .../webui-thread`` (server-built transcript replay). */
|
||||
export interface WebuiThreadPagePayload {
|
||||
before_cursor?: string | null;
|
||||
has_more_before?: boolean;
|
||||
loaded_message_count?: number;
|
||||
total_known_message_count?: number;
|
||||
user_message_offset?: number;
|
||||
}
|
||||
|
||||
export interface WebuiThreadPersistedPayload {
|
||||
schemaVersion: number;
|
||||
sessionKey?: string;
|
||||
savedAt?: string;
|
||||
messages: UIMessage[];
|
||||
fork_boundary_message_count?: number;
|
||||
page?: WebuiThreadPagePayload;
|
||||
workspace_scope?: WorkspaceScopePayload;
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,21 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes pagination params when fetching a WebUI thread page", async () => {
|
||||
await fetchWebuiThread("tok", "websocket:chat-1", {
|
||||
limit: 120,
|
||||
before: "abc+/=",
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/sessions/websocket%3Achat-1/webui-thread?limit=120&before=abc%2B%2F%3D",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
credentials: "same-origin",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("percent-encodes websocket keys and paths when fetching file previews", async () => {
|
||||
await fetchFilePreview("tok", "websocket:chat-1", "/tmp/project/hook.py:12");
|
||||
|
||||
|
||||
@@ -725,16 +725,24 @@ describe("ThreadShell", () => {
|
||||
it("forks assistant replies using the global user message index rather than the visible window index", async () => {
|
||||
const client = makeClient();
|
||||
const onForkChat = vi.fn().mockResolvedValue("chat-fork");
|
||||
const rows = Array.from({ length: 165 }, (_, index) => [
|
||||
{ role: "user" as const, content: `question ${index}` },
|
||||
{ role: "assistant" as const, content: `answer ${index}` },
|
||||
]).flat();
|
||||
const rows = [
|
||||
{ role: "user" as const, content: "question 100" },
|
||||
{ role: "assistant" as const, content: "answer 100" },
|
||||
];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("websocket%3Along-chat/webui-thread")) {
|
||||
return httpJson(transcriptFromSimpleMessages(rows));
|
||||
return httpJson({
|
||||
...transcriptFromSimpleMessages(rows),
|
||||
page: {
|
||||
before_cursor: "before-question-100",
|
||||
has_more_before: true,
|
||||
loaded_message_count: 2,
|
||||
user_message_offset: 100,
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -143,7 +143,7 @@ describe("ThreadViewport", () => {
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 2400 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, value: 0 },
|
||||
scrollTop: { configurable: true, writable: true, value: 0 },
|
||||
});
|
||||
|
||||
act(() => {
|
||||
@@ -167,13 +167,13 @@ describe("ThreadViewport", () => {
|
||||
expect(screen.queryByText("message 139")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("message 140")).toBeInTheDocument();
|
||||
expect(screen.getByText("message 299")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Load earlier messages" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Load earlier messages" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads earlier history in fixed increments without rendering the whole transcript", () => {
|
||||
it("automatically expands earlier local history near the top", () => {
|
||||
const longMessages = makeLongMessages(300);
|
||||
|
||||
render(
|
||||
const { container } = render(
|
||||
<ThreadViewport
|
||||
messages={longMessages}
|
||||
isStreaming={false}
|
||||
@@ -181,7 +181,16 @@ describe("ThreadViewport", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load earlier messages" }));
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 2400 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, writable: true, value: 0 },
|
||||
});
|
||||
|
||||
act(() => {
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
const firstVisible =
|
||||
300 - INITIAL_HISTORY_WINDOW - HISTORY_WINDOW_INCREMENT;
|
||||
@@ -193,6 +202,33 @@ describe("ThreadViewport", () => {
|
||||
expect(screen.getByText("message 299")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("automatically requests older transcript pages near the top", () => {
|
||||
const onLoadOlder = vi.fn();
|
||||
|
||||
const { container } = render(
|
||||
<ThreadViewport
|
||||
messages={makeLongMessages(20)}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
hasMoreBefore
|
||||
onLoadOlder={onLoadOlder}
|
||||
/>,
|
||||
);
|
||||
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 1800 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, writable: true, value: 0 },
|
||||
});
|
||||
|
||||
act(() => {
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
expect(onLoadOlder).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders a prompt rail that jumps to user messages", async () => {
|
||||
const promptMessages = makeLongMessages(5);
|
||||
const { container } = render(
|
||||
|
||||
@@ -414,6 +414,65 @@ describe("useSessions", () => {
|
||||
expect(result.current.hasPendingToolCalls).toBe(false);
|
||||
});
|
||||
|
||||
it("loads older transcript pages before the current history", async () => {
|
||||
vi.mocked(api.fetchWebuiThread)
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
messages: [
|
||||
{ id: "u2", role: "user", content: "new question", createdAt: 2 },
|
||||
{ id: "a2", role: "assistant", content: "new answer", createdAt: 3 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: "cursor-2",
|
||||
has_more_before: true,
|
||||
loaded_message_count: 2,
|
||||
user_message_offset: 1,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
messages: [
|
||||
{ id: "u1", role: "user", content: "old question", createdAt: 0 },
|
||||
{ id: "a1", role: "assistant", content: "old answer", createdAt: 1 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: null,
|
||||
has_more_before: false,
|
||||
loaded_message_count: 2,
|
||||
user_message_offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessionHistory("websocket:paged"), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(api.fetchWebuiThread).toHaveBeenCalledWith("tok", "websocket:paged", {
|
||||
limit: 160,
|
||||
direction: "latest",
|
||||
});
|
||||
expect(result.current.hasMoreBefore).toBe(true);
|
||||
expect(result.current.userMessageOffset).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadOlder();
|
||||
});
|
||||
|
||||
expect(api.fetchWebuiThread).toHaveBeenLastCalledWith("tok", "websocket:paged", {
|
||||
limit: 120,
|
||||
before: "cursor-2",
|
||||
});
|
||||
expect(result.current.messages.map((message) => message.content)).toEqual([
|
||||
"old question",
|
||||
"old answer",
|
||||
"new question",
|
||||
"new answer",
|
||||
]);
|
||||
expect(result.current.hasMoreBefore).toBe(false);
|
||||
expect(result.current.userMessageOffset).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the session in the list when delete fails", async () => {
|
||||
vi.mocked(api.listSessions).mockResolvedValue([
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user