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
@@ -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}
/>
+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}
+127 -7
View File
@@ -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
View File
@@ -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",
+9
View File
@@ -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;
}
+15
View File
@@ -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");
+13 -5
View File
@@ -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,
+41 -5
View File
@@ -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(
+59
View File
@@ -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([
{