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
+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([
{