mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 17:38:35 +00:00
fix(webui): persist large sidebar ordering state
This commit is contained in:
parent
1d290614c9
commit
c2fd41b44d
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 }
|
||||
| {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user