feat(webui): segment transcript storage
This commit is contained in:
@@ -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