From c2fd41b44d172af2dc6cb16eb483e6cfa3461897 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:21:16 +0800 Subject: [PATCH] fix(webui): persist large sidebar ordering state --- nanobot/channels/websocket/runtime.py | 25 ++++++++++++ .../websocket/tests/test_websocket_channel.py | 28 +++++++++++++ webui/src/hooks/useSidebarState.ts | 24 ++---------- webui/src/lib/nanobot-client.ts | 5 +++ webui/src/lib/types.ts | 1 + webui/src/tests/app-layout.test.tsx | 21 ++++------ webui/src/tests/nanobot-client.test.ts | 39 +++++++++++++++++++ 7 files changed, 110 insertions(+), 33 deletions(-) diff --git a/nanobot/channels/websocket/runtime.py b/nanobot/channels/websocket/runtime.py index 60b3750c5..dc909ed72 100644 --- a/nanobot/channels/websocket/runtime.py +++ b/nanobot/channels/websocket/runtime.py @@ -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): diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index ee89bf906..2e265bd6b 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -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) diff --git a/webui/src/hooks/useSidebarState.ts b/webui/src/hooks/useSidebarState.ts index 603574c34..89ac3b04c 100644 --- a/webui/src/hooks/useSidebarState.ts +++ b/webui/src/hooks/useSidebarState.ts @@ -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; } { - 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(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(() => { diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts index b3fc7bb9a..db6324016 100644 --- a/webui/src/lib/nanobot-client.ts +++ b/webui/src/lib/nanobot-client.ts @@ -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 { diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index afd734c47..991e3209a 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -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 } | { diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 18bf49d6e..8ce7d818d 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -13,6 +13,7 @@ const getSessionAutomationsSpy = vi.fn<(key: string) => Promise 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(); }); diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts index 26a444a2e..58695f118 100644 --- a/webui/src/tests/nanobot-client.test.ts +++ b/webui/src/tests/nanobot-client.test.ts @@ -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",