mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 01:48:53 +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,
|
WebuiSessionAccess,
|
||||||
session_mentions_runtime_context,
|
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.transcript import WEBUI_TRANSCRIPT_INCOMPLETE_KEY
|
||||||
from nanobot.webui.transcription_ws import webui_transcription_event
|
from nanobot.webui.transcription_ws import webui_transcription_event
|
||||||
from nanobot.webui.websocket_logging import websockets_server_logger
|
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._send_event(connection, "attached", chat_id=cid)
|
||||||
await self._hydrate_after_subscribe(cid)
|
await self._hydrate_after_subscribe(cid)
|
||||||
return
|
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":
|
if t == "set_workspace_scope":
|
||||||
cid = envelope.get("chat_id")
|
cid = envelope.get("chat_id")
|
||||||
if not _is_valid_chat_id(cid):
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_client_cannot_self_assert_webui_quote_context(bus: MagicMock) -> None:
|
async def test_client_cannot_self_assert_webui_quote_context(bus: MagicMock) -> None:
|
||||||
channel = _ch(bus)
|
channel = _ch(bus)
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
import {
|
import { fetchSidebarState } from "@/lib/api";
|
||||||
fetchSidebarState,
|
|
||||||
updateSidebarState as persistSidebarState,
|
|
||||||
} from "@/lib/api";
|
|
||||||
import type { ChatSummary, SidebarStatePayload } from "@/lib/types";
|
import type { ChatSummary, SidebarStatePayload } from "@/lib/types";
|
||||||
|
|
||||||
export const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
|
export const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
|
||||||
@ -144,10 +141,9 @@ export function useSidebarState(
|
|||||||
updater: (state: SidebarStatePayload) => SidebarStatePayload,
|
updater: (state: SidebarStatePayload) => SidebarStatePayload,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
} {
|
} {
|
||||||
const { token } = useClient();
|
const { client, token } = useClient();
|
||||||
const tokenRef = useRef(token);
|
const tokenRef = useRef(token);
|
||||||
const stateRef = useRef(DEFAULT_SIDEBAR_STATE);
|
const stateRef = useRef(DEFAULT_SIDEBAR_STATE);
|
||||||
const persistVersionRef = useRef(0);
|
|
||||||
const [state, setState] = useState<SidebarStatePayload>(DEFAULT_SIDEBAR_STATE);
|
const [state, setState] = useState<SidebarStatePayload>(DEFAULT_SIDEBAR_STATE);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
tokenRef.current = token;
|
tokenRef.current = token;
|
||||||
@ -178,23 +174,11 @@ export function useSidebarState(
|
|||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => {
|
async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => {
|
||||||
const next = normalizeSidebarState(updater(stateRef.current));
|
const next = normalizeSidebarState(updater(stateRef.current));
|
||||||
const version = persistVersionRef.current + 1;
|
|
||||||
persistVersionRef.current = version;
|
|
||||||
stateRef.current = next;
|
stateRef.current = next;
|
||||||
setState(next);
|
setState(next);
|
||||||
try {
|
client.setSidebarState(next);
|
||||||
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],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pruned = useMemo(() => {
|
const pruned = useMemo(() => {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type {
|
|||||||
OutboundMcpPresetMention,
|
OutboundMcpPresetMention,
|
||||||
OutboundMedia,
|
OutboundMedia,
|
||||||
SessionMention,
|
SessionMention,
|
||||||
|
SidebarStatePayload,
|
||||||
GoalStateWsPayload,
|
GoalStateWsPayload,
|
||||||
WorkspaceScopePayload,
|
WorkspaceScopePayload,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
@ -870,6 +871,10 @@ export class NanobotClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSidebarState(state: SidebarStatePayload): void {
|
||||||
|
this.queueSend({ type: "set_sidebar_state", state });
|
||||||
|
}
|
||||||
|
|
||||||
// -- internals ---------------------------------------------------------
|
// -- internals ---------------------------------------------------------
|
||||||
|
|
||||||
private setStatus(status: ConnectionStatus): void {
|
private setStatus(status: ConnectionStatus): void {
|
||||||
|
|||||||
@ -1340,6 +1340,7 @@ export type Outbound =
|
|||||||
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
|
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
|
||||||
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
|
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
|
||||||
| { type: "attach"; chat_id: string }
|
| { type: "attach"; chat_id: string }
|
||||||
|
| { type: "set_sidebar_state"; state: SidebarStatePayload }
|
||||||
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
|
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
|
||||||
| { type: "transcribe_audio"; request_id: string; data_url: string; duration_ms?: number }
|
| { 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 toggleThemeSpy = vi.fn();
|
||||||
const updateUrlSpy = vi.fn();
|
const updateUrlSpy = vi.fn();
|
||||||
const attachSpy = vi.fn();
|
const attachSpy = vi.fn();
|
||||||
|
const setSidebarStateSpy = vi.fn();
|
||||||
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
|
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
|
||||||
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
||||||
let mockSessions: ChatSummary[] = [];
|
let mockSessions: ChatSummary[] = [];
|
||||||
@ -218,6 +219,7 @@ vi.mock("@/lib/nanobot-client", () => {
|
|||||||
sendMessage = vi.fn();
|
sendMessage = vi.fn();
|
||||||
newChat = vi.fn();
|
newChat = vi.fn();
|
||||||
attach = attachSpy;
|
attach = attachSpy;
|
||||||
|
setSidebarState = setSidebarStateSpy;
|
||||||
close = vi.fn();
|
close = vi.fn();
|
||||||
updateUrl = updateUrlSpy;
|
updateUrl = updateUrlSpy;
|
||||||
updateMaxFrameBytes = vi.fn();
|
updateMaxFrameBytes = vi.fn();
|
||||||
@ -245,6 +247,7 @@ describe("App layout", () => {
|
|||||||
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
|
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
|
||||||
toggleThemeSpy.mockReset();
|
toggleThemeSpy.mockReset();
|
||||||
attachSpy.mockReset();
|
attachSpy.mockReset();
|
||||||
|
setSidebarStateSpy.mockReset();
|
||||||
runStatusHandlers.clear();
|
runStatusHandlers.clear();
|
||||||
sessionUpdateHandlers.clear();
|
sessionUpdateHandlers.clear();
|
||||||
window.history.replaceState(null, "", "/");
|
window.history.replaceState(null, "", "/");
|
||||||
@ -1403,13 +1406,6 @@ describe("App layout", () => {
|
|||||||
if (href === "/api/webui/sidebar-state") {
|
if (href === "/api/webui/sidebar-state") {
|
||||||
return { ok: true, json: async () => initialState };
|
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 };
|
return { ok: false, status: 404 };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -1429,12 +1425,11 @@ describe("App layout", () => {
|
|||||||
expect(within(sidebar).getByText("Archived")).toBeInTheDocument(),
|
expect(within(sidebar).getByText("Archived")).toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
expect(within(sidebar).getByRole("button", { name: /^First chat$/ })).toBeInTheDocument();
|
expect(within(sidebar).getByRole("button", { name: /^First chat$/ })).toBeInTheDocument();
|
||||||
const updateUrl = vi.mocked(fetch).mock.calls
|
expect(setSidebarStateSpy).toHaveBeenCalledWith(
|
||||||
.map(([url]) => String(url))
|
expect.objectContaining({
|
||||||
.find((url) => url.startsWith("/api/webui/sidebar-state/update?"));
|
view: expect.objectContaining({ show_archived: true }),
|
||||||
expect(updateUrl).toBeTruthy();
|
}),
|
||||||
const encoded = new URLSearchParams(updateUrl?.split("?", 2)[1]).get("state");
|
);
|
||||||
expect(JSON.parse(encoded ?? "{}").view.show_archived).toBe(true);
|
|
||||||
|
|
||||||
expect(within(sidebar).queryByRole("button", { name: "View" })).not.toBeInTheDocument();
|
expect(within(sidebar).queryByRole("button", { name: "View" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { NanobotClient } from "@/lib/nanobot-client";
|
import { NanobotClient } from "@/lib/nanobot-client";
|
||||||
|
import type { SidebarStatePayload } from "@/lib/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal fake WebSocket implementing the subset NanobotClient touches.
|
* Minimal fake WebSocket implementing the subset NanobotClient touches.
|
||||||
@ -935,6 +936,44 @@ describe("NanobotClient", () => {
|
|||||||
expect(client.hasUnsettledRun("chat-scope-control")).toBe(true);
|
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 () => {
|
it("does not correlate a new-chat scope rejection to an unrelated sent turn", async () => {
|
||||||
const client = new NanobotClient({
|
const client = new NanobotClient({
|
||||||
url: "ws://test",
|
url: "ws://test",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user