fix(webui): persist large sidebar ordering state

This commit is contained in:
Xubin Ren 2026-08-06 18:21:16 +08:00
parent 1d290614c9
commit c2fd41b44d
7 changed files with 110 additions and 33 deletions

View File

@ -81,6 +81,7 @@ from nanobot.webui.session_access import (
WebuiSessionAccess,
session_mentions_runtime_context,
)
from nanobot.webui.sidebar_state import write_webui_sidebar_state
from nanobot.webui.transcript import WEBUI_TRANSCRIPT_INCOMPLETE_KEY
from nanobot.webui.transcription_ws import webui_transcription_event
from nanobot.webui.websocket_logging import websockets_server_logger
@ -775,6 +776,30 @@ class WebSocketChannel(BaseChannel):
await self._send_event(connection, "attached", chat_id=cid)
await self._hydrate_after_subscribe(cid)
return
if t == "set_sidebar_state":
if connection not in self._webui_connections:
await self._send_event(connection, "error", detail="access_denied")
return
state = envelope.get("state")
if not isinstance(state, dict):
await self._send_event(
connection,
"error",
detail="invalid_sidebar_state",
)
return
try:
await asyncio.to_thread(
write_webui_sidebar_state,
cast(dict[str, Any], state),
)
except (OSError, ValueError):
await self._send_event(
connection,
"error",
detail="invalid_sidebar_state",
)
return
if t == "set_workspace_scope":
cid = envelope.get("chat_id")
if not _is_valid_chat_id(cid):

View File

@ -559,6 +559,34 @@ def test_only_bootstrap_tokens_mark_webui_connections(bus: MagicMock) -> None:
assert client_connection not in channel._webui_connections
@pytest.mark.asyncio
async def test_webui_persists_sidebar_state_larger_than_http_request_line(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
channel = _ch(bus)
conn = AsyncMock()
channel._webui_connections.add(conn)
session_order = [f"websocket:{index:04d}-{'x' * 48}" for index in range(160)]
envelope = {
"type": "set_sidebar_state",
"state": {
"session_order": session_order,
"view": {"sort": "manual"},
},
}
assert len(json.dumps(envelope).encode()) > 8_192
await channel._dispatch_envelope(conn, "webui-client", envelope)
saved = json.loads((tmp_path / "webui" / "sidebar-state.json").read_text(encoding="utf-8"))
assert saved["session_order"] == session_order
assert saved["view"]["sort"] == "manual"
conn.send.assert_not_awaited()
@pytest.mark.asyncio
async def test_client_cannot_self_assert_webui_quote_context(bus: MagicMock) -> None:
channel = _ch(bus)

View File

@ -1,10 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useClient } from "@/providers/ClientProvider";
import {
fetchSidebarState,
updateSidebarState as persistSidebarState,
} from "@/lib/api";
import { fetchSidebarState } from "@/lib/api";
import type { ChatSummary, SidebarStatePayload } from "@/lib/types";
export const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
@ -144,10 +141,9 @@ export function useSidebarState(
updater: (state: SidebarStatePayload) => SidebarStatePayload,
) => Promise<void>;
} {
const { token } = useClient();
const { client, token } = useClient();
const tokenRef = useRef(token);
const stateRef = useRef(DEFAULT_SIDEBAR_STATE);
const persistVersionRef = useRef(0);
const [state, setState] = useState<SidebarStatePayload>(DEFAULT_SIDEBAR_STATE);
const [loading, setLoading] = useState(true);
tokenRef.current = token;
@ -178,23 +174,11 @@ export function useSidebarState(
const update = useCallback(
async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => {
const next = normalizeSidebarState(updater(stateRef.current));
const version = persistVersionRef.current + 1;
persistVersionRef.current = version;
stateRef.current = next;
setState(next);
try {
const persisted = normalizeSidebarState(
await persistSidebarState(tokenRef.current, next),
);
if (persistVersionRef.current !== version) return;
stateRef.current = persisted;
setState(persisted);
} catch {
// Keep the optimistic UI state. Older gateways or transient auth expiry
// should not break the chat list; the next refresh can try again.
}
client.setSidebarState(next);
},
[],
[client],
);
const pruned = useMemo(() => {

View File

@ -6,6 +6,7 @@ import type {
OutboundMcpPresetMention,
OutboundMedia,
SessionMention,
SidebarStatePayload,
GoalStateWsPayload,
WorkspaceScopePayload,
} from "./types";
@ -870,6 +871,10 @@ export class NanobotClient {
});
}
setSidebarState(state: SidebarStatePayload): void {
this.queueSend({ type: "set_sidebar_state", state });
}
// -- internals ---------------------------------------------------------
private setStatus(status: ConnectionStatus): void {

View File

@ -1340,6 +1340,7 @@ export type Outbound =
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
| { type: "attach"; chat_id: string }
| { type: "set_sidebar_state"; state: SidebarStatePayload }
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
| { type: "transcribe_audio"; request_id: string; data_url: string; duration_ms?: number }
| {

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();
});

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",