feat(webui): polish native host experience
This commit is contained in:
@@ -869,6 +869,39 @@ describe("AgentActivityCluster", () => {
|
||||
expect(screen.getByText("Target text was not found in angry-birds.html.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps permission errors readable for failed file edits", () => {
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages("", {
|
||||
id: "t2",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "write_file()",
|
||||
traces: ["write_file()"],
|
||||
fileEdits: [{
|
||||
call_id: "call-write",
|
||||
tool: "write_file",
|
||||
path: "/Users/renxubin/.nanobot/workspace/agent-research-video/composition.html",
|
||||
phase: "error",
|
||||
added: 0,
|
||||
deleted: 0,
|
||||
approximate: false,
|
||||
status: "error",
|
||||
error: "Error writing file: [Errno 13] Permission denied: '/Users/renxubin'",
|
||||
}],
|
||||
createdAt: 3,
|
||||
})}
|
||||
isTurnStreaming={false}
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /failed composition\.html/i }));
|
||||
|
||||
expect(screen.getByText("No permission to change this location.")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/\[Errno 13\]/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
|
||||
const restoreMotion = installReducedMotion();
|
||||
try {
|
||||
|
||||
@@ -891,9 +891,9 @@ describe("App layout", () => {
|
||||
expect(screen.queryByText("AI")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Current configuration")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Presets")).not.toBeInTheDocument();
|
||||
fireEvent.pointerDown(screen.getAllByRole("button", { name: /openai\/gpt-4o/ })[0]);
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Current configuration" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Add configuration" }));
|
||||
const modelDialog = screen.getByRole("dialog", { name: "New model configuration" });
|
||||
const modelDialog = await screen.findByRole("dialog", { name: "New model configuration" });
|
||||
expect(within(modelDialog).getByText("Save a provider and model as a one-click option.")).toBeInTheDocument();
|
||||
fireEvent.change(within(modelDialog).getByPlaceholderText("Fast writing"), {
|
||||
target: { value: "Fast writing" },
|
||||
|
||||
@@ -65,6 +65,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, "nanobotHost");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -89,6 +90,61 @@ describe("NanobotClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("can swap the socket factory when the runtime URL changes", () => {
|
||||
const browserFactory = vi.fn(
|
||||
(url: string) => new FakeSocket(`browser:${url}`) as unknown as WebSocket,
|
||||
);
|
||||
const hostFactory = vi.fn(
|
||||
(url: string) => new FakeSocket(`host:${url}`) as unknown as WebSocket,
|
||||
);
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: browserFactory,
|
||||
});
|
||||
|
||||
client.connect();
|
||||
expect(lastSocket().url).toBe("browser:ws://test");
|
||||
client.close();
|
||||
client.updateUrl("nanobot-host://engine/", hostFactory);
|
||||
client.connect();
|
||||
|
||||
expect(hostFactory).toHaveBeenCalledWith("nanobot-host://engine/");
|
||||
expect(lastSocket().url).toBe("host:nanobot-host://engine/");
|
||||
});
|
||||
|
||||
it("uses the host socket bridge for native host URLs", async () => {
|
||||
let socketEventHandler:
|
||||
| ((event: { id: string; type: "open" | "close" | "error"; message?: string }) => void)
|
||||
| null = null;
|
||||
const openSocket = vi.fn(async () => "host-socket-1");
|
||||
Object.defineProperty(window, "nanobotHost", {
|
||||
configurable: true,
|
||||
value: {
|
||||
openSocket,
|
||||
sendSocket: vi.fn(async () => undefined),
|
||||
closeSocket: vi.fn(async () => undefined),
|
||||
onSocketEvent: vi.fn((handler) => {
|
||||
socketEventHandler = handler;
|
||||
return vi.fn();
|
||||
}),
|
||||
},
|
||||
});
|
||||
const client = new NanobotClient({
|
||||
url: "nanobot-host://engine/",
|
||||
reconnect: false,
|
||||
});
|
||||
const status = vi.fn();
|
||||
client.onStatus(status);
|
||||
|
||||
client.connect();
|
||||
await Promise.resolve();
|
||||
socketEventHandler?.({ id: "host-socket-1", type: "open" });
|
||||
|
||||
expect(openSocket).toHaveBeenCalledWith("nanobot-host://engine/");
|
||||
expect(status).toHaveBeenLastCalledWith("open");
|
||||
});
|
||||
|
||||
it("buffers chat events while no chat handler is registered and replays on subscribe", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
|
||||
@@ -245,6 +245,40 @@ describe("SettingsView Apps catalog", () => {
|
||||
expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("can close the new configuration dialog without trapping the settings page", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||
if (url === "/api/settings/cli-apps") {
|
||||
return jsonResponse({ apps: [], installed_count: 0 });
|
||||
}
|
||||
if (url === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({ presets: [], installed_count: 0 });
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
|
||||
renderSettingsView({ initialSection: "models" });
|
||||
|
||||
const configurationButton = await screen.findByRole("button", { name: "Current configuration" });
|
||||
fireEvent.pointerDown(configurationButton!);
|
||||
fireEvent.click(await screen.findByText("Add configuration"));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "New model configuration" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("heading", { name: "New model configuration" })).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(document.body.style.pointerEvents).not.toBe("none");
|
||||
|
||||
fireEvent.pointerDown(configurationButton!);
|
||||
expect(await screen.findByText("Add configuration")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads provider models and lets users choose one without typing the id manually", async () => {
|
||||
const payload: SettingsPayload = {
|
||||
...settingsPayload(),
|
||||
|
||||
Reference in New Issue
Block a user