feat(webui): add temporary chat mode

This commit is contained in:
Xubin Ren
2026-08-08 23:20:59 +08:00
parent 113e8d67ad
commit c9a6145878
41 changed files with 1500 additions and 119 deletions
+73
View File
@@ -14,6 +14,7 @@ const toggleThemeSpy = vi.fn();
const updateUrlSpy = vi.fn();
const attachSpy = vi.fn();
const setSidebarStateSpy = vi.fn();
const discardTemporaryChatSpy = vi.fn();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
let mockSessions: ChatSummary[] = [];
@@ -220,6 +221,7 @@ vi.mock("@/lib/nanobot-client", () => {
newChat = vi.fn();
attach = attachSpy;
setSidebarState = setSidebarStateSpy;
discardTemporaryChat = discardTemporaryChatSpy;
close = vi.fn();
updateUrl = updateUrlSpy;
updateMaxFrameBytes = vi.fn();
@@ -248,6 +250,7 @@ describe("App layout", () => {
toggleThemeSpy.mockReset();
attachSpy.mockReset();
setSidebarStateSpy.mockReset();
discardTemporaryChatSpy.mockReset();
runStatusHandlers.clear();
sessionUpdateHandlers.clear();
window.history.replaceState(null, "", "/");
@@ -384,6 +387,76 @@ describe("App layout", () => {
);
});
it("keeps a temporary chat while navigating and discards it on unmount", async () => {
const { unmount } = render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const temporaryButton = within(sidebar).getByRole("button", { name: "Temporary chat" });
fireEvent.click(temporaryButton);
expect(temporaryButton).toHaveAttribute("aria-current", "page");
expect(within(sidebar).getByTestId("actions-selection-highlight")).toHaveAttribute(
"data-active-id",
"temporary-chat",
);
expect(window.location.hash).toBe("#/temporary");
fireEvent.click(within(sidebar).getByRole("button", { name: "New topic" }));
expect(discardTemporaryChatSpy).not.toHaveBeenCalled();
fireEvent.click(temporaryButton);
expect(window.location.hash).toBe("#/temporary");
expect(temporaryButton).toHaveAttribute("aria-current", "page");
unmount();
await waitFor(() => expect(discardTemporaryChatSpy).toHaveBeenCalledOnce());
expect(discardTemporaryChatSpy.mock.calls[0][0]).toMatch(/^temporary-/);
});
it("clears a temporary chat explicitly without leaving it", async () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "Temporary chat" }));
fireEvent.click(await screen.findByRole("button", { name: "Clear temporary chat" }));
await waitFor(() => expect(discardTemporaryChatSpy).toHaveBeenCalledOnce());
expect(window.location.hash).toBe("#/temporary");
expect(within(sidebar).getByRole("button", { name: "Temporary chat" })).toHaveAttribute(
"aria-current",
"page",
);
});
it("starts temporary chat with restricted on-demand workspace controls", async () => {
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/workspaces": {
schema_version: 1,
default_access_mode: "full",
default_scope: {
project_path: "/tmp/workspace",
project_name: "workspace",
access_mode: "full",
restrict_to_workspace: false,
},
controls: { can_change_project: true, can_use_full_access: true },
},
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "Temporary chat" }));
expect(await screen.findByRole("button", { name: "Choose project" })).toBeInTheDocument();
expect(screen.queryByText("Full Access")).not.toBeInTheDocument();
});
it("restores the Settings route after a restart fallback hash", async () => {
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
+48
View File
@@ -71,6 +71,54 @@ afterEach(() => {
});
describe("NanobotClient", () => {
it("keeps temporary chats out of attachment and reconnect state", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const chatId = "temporary-test";
client.connect();
client.onChat(chatId, vi.fn());
client.sendMessage(chatId, "hello", undefined, { turnId: "turn-1" });
lastSocket().fakeOpen();
expect(lastSocket().sent.map((raw) => JSON.parse(raw))).toEqual([
{
type: "message",
chat_id: chatId,
content: "hello",
turn_id: "turn-1",
webui: true,
},
]);
client.discardTemporaryChat(chatId);
expect(JSON.parse(lastSocket().sent.at(-1) as string)).toEqual({
type: "discard_temporary_chat",
chat_id: chatId,
});
});
it("forgets temporary chats when the socket drops", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: true,
maxBackoffMs: 1,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.onChat("temporary-drop", vi.fn());
lastSocket().close();
await vi.advanceTimersByTimeAsync(1);
lastSocket().fakeOpen();
expect(lastSocket().sent).toEqual([]);
});
it("routes events to the matching chat handler", () => {
const client = new NanobotClient({
url: "ws://test",
+96
View File
@@ -1070,6 +1070,58 @@ describe("ThreadComposer", () => {
}));
});
it("keeps temporary-chat workspace controls on demand", async () => {
const user = userEvent.setup();
const onWorkspaceScopeChange = vi.fn();
const defaultScope = {
project_path: "/Users/test/.nanobot/workspace",
project_name: "workspace",
access_mode: "restricted" as const,
restrict_to_workspace: true,
};
const { rerender } = render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Ask anything..."
variant="hero"
compactWorkspaceControls
workspaceScope={defaultScope}
workspaceDefaultScope={defaultScope}
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
onWorkspaceScopeChange={onWorkspaceScopeChange}
/>,
);
expect(screen.queryByRole("button", {
name: "Workspace access mode: Default Permission",
})).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Choose project" }));
const input = await screen.findByLabelText("Paste path");
fireEvent.change(input, { target: { value: "relative/project" } });
fireEvent.click(screen.getByRole("button", { name: "Use Path" }));
expect(screen.getByRole("alert")).toHaveTextContent(
"Enter an absolute folder path on this machine.",
);
rerender(
<ThreadComposer
onSend={vi.fn()}
placeholder="Ask anything..."
variant="hero"
compactWorkspaceControls
workspaceConnected
workspaceScope={defaultScope}
workspaceDefaultScope={defaultScope}
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
onWorkspaceScopeChange={onWorkspaceScopeChange}
/>,
);
expect(screen.getByRole("button", {
name: "Workspace access mode: Default Permission",
})).toBeInTheDocument();
});
it("uses the native folder picker for project selection on native host", async () => {
const onWorkspaceScopeChange = vi.fn();
const pickFolder = vi.fn().mockResolvedValue("/Users/test/native-project");
@@ -2888,4 +2940,48 @@ describe("ThreadComposer", () => {
});
});
it("keeps temporary chat guidance in memory only", async () => {
const onSend = vi.fn();
const view = render(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
pendingQueueKey="temporary-private"
placeholder="Type your message..."
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "do not persist this" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(await screen.findByText("do not persist this")).toBeInTheDocument();
expect(
window.localStorage.getItem(
"nanobot.webui.composerQueuedGuidance.v1:temporary-private",
),
).toBeNull();
view.unmount();
render(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
pendingQueueKey="temporary-private"
placeholder="Type your message..."
/>,
);
await waitFor(() => {
expect(screen.queryByText("do not persist this")).not.toBeInTheDocument();
});
expect(
window.localStorage.getItem(
"nanobot.webui.composerQueuedGuidance.v1:temporary-private",
),
).toBeNull();
});
});
+36
View File
@@ -848,6 +848,42 @@ describe("ThreadShell", () => {
expect(screen.getByText("persist me across tabs")).toBeInTheDocument();
});
it("keeps temporary messages across navigation and drops them after clear", async () => {
const client = makeClient();
const view = (chatId: string, temporary: boolean) => wrap(
client,
<ThreadShell
session={session(chatId)}
title={temporary ? "Temporary chat" : "Regular chat"}
temporary={temporary}
onToggleSidebar={() => {}}
/>,
);
const { rerender } = render(view("temporary-live", true));
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "keep this only in memory" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expectSendMessageWithTurn(
client,
"temporary-live",
"keep this only in memory",
));
rerender(view("regular", false));
await waitFor(() => {
expect(screen.queryByText("keep this only in memory")).not.toBeInTheDocument();
});
rerender(view("temporary-live", true));
expect(screen.getByText("keep this only in memory")).toBeInTheDocument();
rerender(view("temporary-cleared", true));
await waitFor(() => {
expect(screen.queryByText("keep this only in memory")).not.toBeInTheDocument();
});
});
it("highlights sent skill references without skill metadata", async () => {
const client = makeClient();
render(wrap(