fix(webui): persist large sidebar ordering state

This commit is contained in:
Xubin Ren
2026-08-06 19:11:05 +08:00
parent 1d290614c9
commit c2fd41b44d
7 changed files with 110 additions and 33 deletions
+8 -13
View File
@@ -13,6 +13,7 @@ const getSessionAutomationsSpy = vi.fn<(key: string) => Promise<SessionAutomatio
const toggleThemeSpy = vi.fn();
const updateUrlSpy = vi.fn();
const attachSpy = vi.fn();
const setSidebarStateSpy = vi.fn();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
let mockSessions: ChatSummary[] = [];
@@ -218,6 +219,7 @@ vi.mock("@/lib/nanobot-client", () => {
sendMessage = vi.fn();
newChat = vi.fn();
attach = attachSpy;
setSidebarState = setSidebarStateSpy;
close = vi.fn();
updateUrl = updateUrlSpy;
updateMaxFrameBytes = vi.fn();
@@ -245,6 +247,7 @@ describe("App layout", () => {
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
toggleThemeSpy.mockReset();
attachSpy.mockReset();
setSidebarStateSpy.mockReset();
runStatusHandlers.clear();
sessionUpdateHandlers.clear();
window.history.replaceState(null, "", "/");
@@ -1403,13 +1406,6 @@ describe("App layout", () => {
if (href === "/api/webui/sidebar-state") {
return { ok: true, json: async () => initialState };
}
if (href.startsWith("/api/webui/sidebar-state/update?")) {
const encoded = new URLSearchParams(href.split("?", 2)[1]).get("state");
return {
ok: true,
json: async () => JSON.parse(encoded ?? "{}"),
};
}
return { ok: false, status: 404 };
}),
);
@@ -1429,12 +1425,11 @@ describe("App layout", () => {
expect(within(sidebar).getByText("Archived")).toBeInTheDocument(),
);
expect(within(sidebar).getByRole("button", { name: /^First chat$/ })).toBeInTheDocument();
const updateUrl = vi.mocked(fetch).mock.calls
.map(([url]) => String(url))
.find((url) => url.startsWith("/api/webui/sidebar-state/update?"));
expect(updateUrl).toBeTruthy();
const encoded = new URLSearchParams(updateUrl?.split("?", 2)[1]).get("state");
expect(JSON.parse(encoded ?? "{}").view.show_archived).toBe(true);
expect(setSidebarStateSpy).toHaveBeenCalledWith(
expect.objectContaining({
view: expect.objectContaining({ show_archived: true }),
}),
);
expect(within(sidebar).queryByRole("button", { name: "View" })).not.toBeInTheDocument();
});
+39
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NanobotClient } from "@/lib/nanobot-client";
import type { SidebarStatePayload } from "@/lib/types";
/**
* Minimal fake WebSocket implementing the subset NanobotClient touches.
@@ -935,6 +936,44 @@ describe("NanobotClient", () => {
expect(client.hasUnsettledRun("chat-scope-control")).toBe(true);
});
it("sends large sidebar ordering state outside the HTTP request line", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const sessionOrder = Array.from(
{ length: 160 },
(_, index) => `websocket:${index.toString().padStart(4, "0")}-${"x".repeat(48)}`,
);
const state: SidebarStatePayload = {
schema_version: 1,
pinned_keys: [],
archived_keys: [],
session_order: sessionOrder,
title_overrides: {},
project_name_overrides: {},
tags_by_key: {},
collapsed_groups: {},
view: {
density: "comfortable",
show_previews: false,
show_timestamps: false,
show_archived: false,
sort: "manual",
},
updated_at: null,
};
client.connect();
lastSocket().fakeOpen();
client.setSidebarState(state);
const [serialized] = lastSocket().sent;
expect(new TextEncoder().encode(serialized).byteLength).toBeGreaterThan(8_192);
expect(JSON.parse(serialized)).toEqual({ type: "set_sidebar_state", state });
});
it("does not correlate a new-chat scope rejection to an unrelated sent turn", async () => {
const client = new NanobotClient({
url: "ws://test",