diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 1dec4a6f9..6119a79b1 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -218,7 +218,7 @@ function HostChrome({ )} ) : ( -
+
)} ); @@ -252,7 +252,19 @@ export default function App() { refreshed.token, refreshed.ws_url, ); + const refreshedSurface = refreshed.runtime_surface + ? toRuntimeSurface(refreshed.runtime_surface) + : runtimeSurface; + const refreshedHost = createRuntimeHost( + refreshedSurface, + refreshed.runtime_capabilities, + ); const tokenExpiresAt = bootstrapTokenExpiresAt(refreshed.expires_in); + if (refreshedHost.socketFactory) { + client.updateUrl(refreshedUrl, refreshedHost.socketFactory); + } else { + client.updateUrl(refreshedUrl); + } setState((current) => current.status === "ready" && current.client === client ? { @@ -260,10 +272,7 @@ export default function App() { token: refreshed.token, tokenExpiresAt, modelName: refreshed.model_name ?? current.modelName, - runtimeSurface: - refreshed.runtime_surface - ? toRuntimeSurface(refreshed.runtime_surface) - : current.runtimeSurface, + runtimeSurface: refreshedSurface, } : current, ); @@ -307,8 +316,16 @@ export default function App() { try { const boot = await fetchBootstrap("", bootstrapSecretRef.current); const url = deriveWsUrl(boot.ws_path, boot.token, boot.ws_url); + const runtimeSurface = boot.runtime_surface + ? toRuntimeSurface(boot.runtime_surface) + : state.runtimeSurface; + const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities); const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in); - client.updateUrl(url); + if (runtimeHost.socketFactory) { + client.updateUrl(url, runtimeHost.socketFactory); + } else { + client.updateUrl(url); + } setState((current) => current.status === "ready" && current.client === client ? { @@ -316,9 +333,7 @@ export default function App() { token: boot.token, tokenExpiresAt, modelName: boot.model_name ?? current.modelName, - runtimeSurface: boot.runtime_surface - ? toRuntimeSurface(boot.runtime_surface) - : current.runtimeSurface, + runtimeSurface, } : current, ); @@ -1058,12 +1073,19 @@ function Shell({ const showHostChrome = isNativeHostSetupSurface; const showMainSidebar = view !== "settings"; + useEffect(() => { + document.documentElement.classList.toggle("native-host", showHostChrome); + return () => { + document.documentElement.classList.remove("native-host"); + }; + }, [showHostChrome]); + return (
{showHostChrome ? ( @@ -1071,7 +1093,6 @@ function Shell({ onToggleSidebar={showMainSidebar ? toggleSidebar : undefined} theme={theme} onToggleTheme={toggle} - showThemeButton={view !== "chat"} /> ) : null}
-
+
diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 0fc92ae18..227737ae9 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -4277,7 +4277,7 @@ function ProviderPicker({ const disabled = providers.length === 0; return ( - + - + {!hideThemeButton ? ( + + ) : null}
); } @@ -67,12 +71,14 @@ export function ThreadHeader({
- + {!hideThemeButton ? ( + + ) : null}
@@ -97,7 +103,7 @@ function ThemeButton({ aria-label={label} onClick={onToggleTheme} className={cn( - "h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground", + "host-no-drag h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground", className, )} > diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 689a93d3f..315d4cef4 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -62,6 +62,7 @@ interface ThreadShellProps { theme?: "light" | "dark"; onToggleTheme?: () => void; hideSidebarToggleForHostChrome?: boolean; + hideThemeButton?: boolean; hideHeader?: boolean; workspaceScope?: WorkspaceScopePayload | null; workspaceDefaultScope?: WorkspaceScopePayload | null; @@ -142,6 +143,7 @@ export function ThreadShell({ theme = "light", onToggleTheme = () => {}, hideSidebarToggleForHostChrome = false, + hideThemeButton = false, hideHeader = false, workspaceScope = null, workspaceDefaultScope = null, @@ -567,6 +569,7 @@ export function ThreadShell({ theme={theme} onToggleTheme={onToggleTheme} hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome} + hideThemeButton={hideThemeButton} minimal={!session && !loading} /> ) : null} diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index 85444e16b..9c81b33b7 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -237,7 +237,7 @@ export function ThreadViewport({
)} - detail={failed ? ( - + detail={null} + aside={hasCountedDiff ? : null} + > + {failed ? ( + {failureDetail} ) : null} - aside={hasCountedDiff ? : null} - /> + ); } @@ -96,14 +99,23 @@ export function hasVisibleDiffStats(edit: Pick 0 || edit.deleted > 0; } -function formatFileEditError(error?: string): string { +function cleanFileEditError(error?: string): string { const firstLine = (error || "").replace(/\s+/g, " ").trim(); if (!firstLine) return ""; - const cleaned = firstLine + return firstLine .replace(/^Error applying patch:\s*/i, "") .replace(/^Error writing file:\s*/i, "") .replace(/^Error editing file:\s*/i, "") .replace(/^Error:\s*/i, ""); +} + +function formatFileEditError(error?: string): string { + const cleaned = cleanFileEditError(error); + if (!cleaned) return ""; + + if (/\bpermission denied\b/i.test(cleaned) || /\boperation not permitted\b/i.test(cleaned)) { + return "No permission to change this location."; + } return cleaned .replace(/^old_text not found in (.+)$/i, "Target text was not found in $1.") diff --git a/webui/src/globals.css b/webui/src/globals.css index 27d4b3e38..0afb9c334 100644 --- a/webui/src/globals.css +++ b/webui/src/globals.css @@ -89,6 +89,75 @@ -webkit-app-region: no-drag; } + html.native-host, + html.native-host body, + html.native-host #root { + background: transparent; + } + + html.native-host body { + overflow: hidden; + } + + .host-window-shell, + .host-sidebar-glass { + --host-glass-spot: hsl(var(--background) / 0.42); + --host-glass-start: hsl(var(--sidebar) / 0.5); + --host-glass-end: hsl(var(--sidebar) / 0.3); + background: + radial-gradient( + circle at 18% 0%, + var(--host-glass-spot), + transparent 34rem + ), + linear-gradient( + 180deg, + var(--host-glass-start), + var(--host-glass-end) + ); + background-attachment: fixed, fixed; + -webkit-backdrop-filter: saturate(185%) blur(34px); + backdrop-filter: saturate(185%) blur(34px); + } + + .host-sidebar-glass { + box-shadow: + inset -1px 0 0 hsl(var(--border) / 0.36), + inset 1px 0 0 hsl(var(--background) / 0.34), + 18px 0 44px -42px rgb(0 0 0 / 0.42); + } + + .dark .host-window-shell, + .dark .host-sidebar-glass { + --host-glass-spot: hsl(var(--foreground) / 0.06); + --host-glass-start: hsl(var(--sidebar) / 0.48); + --host-glass-end: hsl(var(--sidebar) / 0.3); + } + + .dark .host-sidebar-glass { + box-shadow: + inset -1px 0 0 hsl(var(--border) / 0.42), + inset 1px 0 0 hsl(var(--foreground) / 0.05), + 18px 0 46px -42px rgb(0 0 0 / 0.72); + } + + @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { + .host-sidebar-glass { + background: hsl(var(--sidebar) / 0.92); + } + } + + html.native-host * { + scrollbar-width: none; + scrollbar-gutter: auto !important; + } + + html.native-host *::-webkit-scrollbar { + display: none; + width: 0; + height: 0; + } + .shadow-inner-right { box-shadow: inset -9px 0 6px -1px rgb(0 0 0 / 0.02); } diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts index 2db278b30..a739afb2e 100644 --- a/webui/src/lib/nanobot-client.ts +++ b/webui/src/lib/nanobot-client.ts @@ -9,11 +9,20 @@ import type { GoalStateWsPayload, WorkspaceScopePayload, } from "./types"; +import { createHostWebSocket } from "./runtime"; /** WebSocket readyState constants, referenced by value to stay portable * across runtimes that don't expose a global ``WebSocket`` (tests, SSR). */ const WS_OPEN = 1; const WS_CLOSING = 2; +const HOST_SOCKET_URL_PREFIX = "nanobot-host://"; + +function createDefaultSocket(url: string): WebSocket { + if (url.startsWith(HOST_SOCKET_URL_PREFIX)) { + return createHostWebSocket(url); + } + return new WebSocket(url); +} /** Inbound WebSocket ``console.log`` / parse-failure ``console.warn``. * @@ -129,7 +138,7 @@ export class NanobotClient { private reconnectTimer: ReturnType | null = null; private readonly shouldReconnect: boolean; private readonly maxBackoffMs: number; - private readonly socketFactory: (url: string) => WebSocket; + private socketFactory: (url: string) => WebSocket; private currentUrl: string; private status_: ConnectionStatus = "idle"; private readyChatId: string | null = null; @@ -140,8 +149,7 @@ export class NanobotClient { constructor(private options: NanobotClientOptions) { this.shouldReconnect = options.reconnect ?? true; this.maxBackoffMs = options.maxBackoffMs ?? 15_000; - this.socketFactory = - options.socketFactory ?? ((url) => new WebSocket(url)); + this.socketFactory = options.socketFactory ?? createDefaultSocket; this.currentUrl = options.url; } @@ -154,8 +162,11 @@ export class NanobotClient { } /** Swap the URL (e.g. after fetching a fresh token) then reconnect. */ - updateUrl(url: string): void { + updateUrl(url: string, socketFactory?: (url: string) => WebSocket): void { this.currentUrl = url; + if (socketFactory) { + this.socketFactory = socketFactory; + } } onStatus(handler: StatusHandler): Unsubscribe { diff --git a/webui/src/lib/runtime.ts b/webui/src/lib/runtime.ts index a9c406eb3..e11fb70ad 100644 --- a/webui/src/lib/runtime.ts +++ b/webui/src/lib/runtime.ts @@ -51,6 +51,11 @@ type HostSocketBridge = Required>; +const HOST_WS_CONNECTING = 0; +const HOST_WS_OPEN = 1; +const HOST_WS_CLOSING = 2; +const HOST_WS_CLOSED = 3; + declare global { interface Window { nanobotHost?: NanobotHostApi; @@ -122,7 +127,7 @@ class HostWebSocket { onerror: ((this: WebSocket, ev: Event) => unknown) | null = null; onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null = null; onopen: ((this: WebSocket, ev: Event) => unknown) | null = null; - readyState: number = WebSocket.CONNECTING; + readyState: number = HOST_WS_CONNECTING; readonly url: string; private id: string | null = null; @@ -140,7 +145,7 @@ class HostWebSocket { this.id = id; }, () => { - this.readyState = WebSocket.CLOSED; + this.readyState = HOST_WS_CLOSED; this.onerror?.call(this as unknown as WebSocket, new Event("error")); this.onclose?.call(this as unknown as WebSocket, closeEvent()); this.unsubscribe(); @@ -149,14 +154,14 @@ class HostWebSocket { } close(): void { - if (this.readyState === WebSocket.CLOSING || this.readyState === WebSocket.CLOSED) { + if (this.readyState === HOST_WS_CLOSING || this.readyState === HOST_WS_CLOSED) { return; } - this.readyState = WebSocket.CLOSING; + this.readyState = HOST_WS_CLOSING; if (this.id) { void this.api.closeSocket(this.id); } else { - this.readyState = WebSocket.CLOSED; + this.readyState = HOST_WS_CLOSED; this.unsubscribe(); } } @@ -165,7 +170,7 @@ class HostWebSocket { if (typeof data !== "string") { throw new Error("Host WebSocket bridge only supports text frames"); } - if (this.readyState === WebSocket.OPEN && this.id) { + if (this.readyState === HOST_WS_OPEN && this.id) { void this.api.sendSocket(this.id, data); return; } @@ -175,7 +180,7 @@ class HostWebSocket { private handleEvent(event: HostSocketEvent): void { if (!this.id || event.id !== this.id) return; if (event.type === "open") { - this.readyState = WebSocket.OPEN; + this.readyState = HOST_WS_OPEN; this.onopen?.call(this as unknown as WebSocket, new Event("open")); while (this.queued.length > 0 && this.id) { const data = this.queued.shift(); @@ -194,7 +199,7 @@ class HostWebSocket { this.onerror?.call(this as unknown as WebSocket, new Event("error")); return; } - this.readyState = WebSocket.CLOSED; + this.readyState = HOST_WS_CLOSED; this.onclose?.call( this as unknown as WebSocket, closeEvent(event.code, event.reason), diff --git a/webui/src/tests/agent-activity-cluster.test.tsx b/webui/src/tests/agent-activity-cluster.test.tsx index 8de2e260e..5ebd0969f 100644 --- a/webui/src/tests/agent-activity-cluster.test.tsx +++ b/webui/src/tests/agent-activity-cluster.test.tsx @@ -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( + , + ); + + 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 { diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 4e378fc44..2e5f9ed75 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -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" }, diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts index 6a3df3de9..fdfad82c7 100644 --- a/webui/src/tests/nanobot-client.test.ts +++ b/webui/src/tests/nanobot-client.test.ts @@ -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", diff --git a/webui/src/tests/settings-view.test.tsx b/webui/src/tests/settings-view.test.tsx index c149235aa..cff71844a 100644 --- a/webui/src/tests/settings-view.test.tsx +++ b/webui/src/tests/settings-view.test.tsx @@ -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(),