feat(webui): polish native host experience

This commit is contained in:
Xubin Ren 2026-05-31 17:00:42 +08:00
parent 15c6abc991
commit 31722120b7
14 changed files with 316 additions and 60 deletions

View File

@ -218,7 +218,7 @@ function HostChrome({
)} )}
</Button> </Button>
) : ( ) : (
<div aria-hidden className="h-8 w-8" /> <div aria-hidden className="host-no-drag pointer-events-none h-8 w-8" />
)} )}
</header> </header>
); );
@ -252,7 +252,19 @@ export default function App() {
refreshed.token, refreshed.token,
refreshed.ws_url, 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); const tokenExpiresAt = bootstrapTokenExpiresAt(refreshed.expires_in);
if (refreshedHost.socketFactory) {
client.updateUrl(refreshedUrl, refreshedHost.socketFactory);
} else {
client.updateUrl(refreshedUrl);
}
setState((current) => setState((current) =>
current.status === "ready" && current.client === client current.status === "ready" && current.client === client
? { ? {
@ -260,10 +272,7 @@ export default function App() {
token: refreshed.token, token: refreshed.token,
tokenExpiresAt, tokenExpiresAt,
modelName: refreshed.model_name ?? current.modelName, modelName: refreshed.model_name ?? current.modelName,
runtimeSurface: runtimeSurface: refreshedSurface,
refreshed.runtime_surface
? toRuntimeSurface(refreshed.runtime_surface)
: current.runtimeSurface,
} }
: current, : current,
); );
@ -307,8 +316,16 @@ export default function App() {
try { try {
const boot = await fetchBootstrap("", bootstrapSecretRef.current); const boot = await fetchBootstrap("", bootstrapSecretRef.current);
const url = deriveWsUrl(boot.ws_path, boot.token, boot.ws_url); 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); const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in);
client.updateUrl(url); if (runtimeHost.socketFactory) {
client.updateUrl(url, runtimeHost.socketFactory);
} else {
client.updateUrl(url);
}
setState((current) => setState((current) =>
current.status === "ready" && current.client === client current.status === "ready" && current.client === client
? { ? {
@ -316,9 +333,7 @@ export default function App() {
token: boot.token, token: boot.token,
tokenExpiresAt, tokenExpiresAt,
modelName: boot.model_name ?? current.modelName, modelName: boot.model_name ?? current.modelName,
runtimeSurface: boot.runtime_surface runtimeSurface,
? toRuntimeSurface(boot.runtime_surface)
: current.runtimeSurface,
} }
: current, : current,
); );
@ -1058,12 +1073,19 @@ function Shell({
const showHostChrome = isNativeHostSetupSurface; const showHostChrome = isNativeHostSetupSurface;
const showMainSidebar = view !== "settings"; const showMainSidebar = view !== "settings";
useEffect(() => {
document.documentElement.classList.toggle("native-host", showHostChrome);
return () => {
document.documentElement.classList.remove("native-host");
};
}, [showHostChrome]);
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<div <div
className={cn( className={cn(
"relative h-full w-full overflow-hidden", "relative h-full w-full overflow-hidden",
showHostChrome && "bg-sidebar", showHostChrome && "host-window-shell",
)} )}
> >
{showHostChrome ? ( {showHostChrome ? (
@ -1071,7 +1093,6 @@ function Shell({
onToggleSidebar={showMainSidebar ? toggleSidebar : undefined} onToggleSidebar={showMainSidebar ? toggleSidebar : undefined}
theme={theme} theme={theme}
onToggleTheme={toggle} onToggleTheme={toggle}
showThemeButton={view !== "chat"}
/> />
) : null} ) : null}
<div <div
@ -1092,8 +1113,10 @@ function Shell({
> >
<div <div
className={cn( className={cn(
"absolute inset-y-0 left-0 h-full w-full overflow-hidden bg-sidebar", "absolute inset-y-0 left-0 h-full w-full overflow-hidden",
!showHostChrome && "shadow-inner-right", showHostChrome
? "host-sidebar-glass"
: "bg-sidebar shadow-inner-right",
)} )}
> >
<Sidebar <Sidebar
@ -1138,13 +1161,12 @@ function Shell({
titleOverrides={sidebarState.title_overrides} titleOverrides={sidebarState.title_overrides}
onSelect={onSelectSearchResult} onSelect={onSelectSearchResult}
/> />
<main <main
className={cn( className={cn(
"relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background", "relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background",
showHostChrome && showHostChrome && "border-l border-border/55",
"rounded-l-[28px] shadow-[-18px_0_32px_-30px_rgb(0_0_0/0.45)] dark:shadow-[-18px_0_32px_-30px_rgb(0_0_0/0.85)]", )}
)} >
>
<div <div
className={cn( className={cn(
"absolute inset-0 flex flex-col", "absolute inset-0 flex flex-col",
@ -1161,6 +1183,7 @@ function Shell({
theme={theme} theme={theme}
onToggleTheme={toggle} onToggleTheme={toggle}
hideSidebarToggleForHostChrome hideSidebarToggleForHostChrome
hideThemeButton={showHostChrome}
hideHeader={false} hideHeader={false}
workspaceScope={activeWorkspaceScope} workspaceScope={activeWorkspaceScope}
workspaceDefaultScope={workspaces?.default_scope ?? null} workspaceDefaultScope={workspaces?.default_scope ?? null}

View File

@ -67,7 +67,8 @@ export function Sidebar(props: SidebarProps) {
ref={props.containActionMenus ? setMenuPortalContainer : undefined} ref={props.containActionMenus ? setMenuPortalContainer : undefined}
aria-label={t("sidebar.navigation")} aria-label={t("sidebar.navigation")}
className={cn( className={cn(
"flex h-full w-full min-w-0 flex-col bg-sidebar text-sidebar-foreground", "flex h-full w-full min-w-0 flex-col text-sidebar-foreground",
props.hostChromeInset ? "bg-transparent" : "bg-sidebar",
!props.hostChromeInset && "border-r border-sidebar-border/60", !props.hostChromeInset && "border-r border-sidebar-border/60",
)} )}
> >

View File

@ -4277,7 +4277,7 @@ function ProviderPicker({
const disabled = providers.length === 0; const disabled = providers.length === 0;
return ( return (
<DropdownMenu> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild disabled={disabled}> <DropdownMenuTrigger asChild disabled={disabled}>
<Button <Button
type="button" type="button"
@ -5098,11 +5098,12 @@ function ModelPresetPicker({
const selectedPreset = presets.find((preset) => preset.name === value) ?? presets[0] ?? null; const selectedPreset = presets.find((preset) => preset.name === value) ?? presets[0] ?? null;
return ( return (
<DropdownMenu> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild disabled={!presets.length}> <DropdownMenuTrigger asChild disabled={!presets.length}>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
aria-label={tx("settings.rows.currentModel", "Current configuration")}
disabled={!presets.length} disabled={!presets.length}
className={cn( className={cn(
"h-12 w-[min(430px,72vw)] justify-between rounded-full border-input bg-background px-3.5 text-[13px] font-normal shadow-none", "h-12 w-[min(430px,72vw)] justify-between rounded-full border-input bg-background px-3.5 text-[13px] font-normal shadow-none",
@ -5155,7 +5156,9 @@ function ModelPresetPicker({
})} })}
<div className="mt-1 border-t border-border/55 pt-1"> <div className="mt-1 border-t border-border/55 pt-1">
<DropdownMenuItem <DropdownMenuItem
onSelect={onCreateConfiguration} onSelect={() => {
window.setTimeout(onCreateConfiguration, 0);
}}
className={cn( className={cn(
"flex cursor-default items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] font-medium", "flex cursor-default items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] font-medium",
"text-foreground focus:bg-muted/85 focus:text-foreground", "text-foreground focus:bg-muted/85 focus:text-foreground",

View File

@ -10,6 +10,7 @@ interface ThreadHeaderProps {
theme: "light" | "dark"; theme: "light" | "dark";
onToggleTheme: () => void; onToggleTheme: () => void;
hideSidebarToggleForHostChrome?: boolean; hideSidebarToggleForHostChrome?: boolean;
hideThemeButton?: boolean;
minimal?: boolean; minimal?: boolean;
} }
@ -19,6 +20,7 @@ export function ThreadHeader({
theme, theme,
onToggleTheme, onToggleTheme,
hideSidebarToggleForHostChrome = false, hideSidebarToggleForHostChrome = false,
hideThemeButton = false,
minimal = false, minimal = false,
}: ThreadHeaderProps) { }: ThreadHeaderProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -37,12 +39,14 @@ export function ThreadHeader({
> >
<Menu className="h-3.5 w-3.5" /> <Menu className="h-3.5 w-3.5" />
</Button> </Button>
<ThemeButton {!hideThemeButton ? (
theme={theme} <ThemeButton
onToggleTheme={onToggleTheme} theme={theme}
label={t("thread.header.toggleTheme")} onToggleTheme={onToggleTheme}
className="ml-auto" label={t("thread.header.toggleTheme")}
/> className="ml-auto"
/>
) : null}
</div> </div>
); );
} }
@ -67,12 +71,14 @@ export function ThreadHeader({
</div> </div>
</div> </div>
<ThemeButton {!hideThemeButton ? (
theme={theme} <ThemeButton
onToggleTheme={onToggleTheme} theme={theme}
label={t("thread.header.toggleTheme")} onToggleTheme={onToggleTheme}
className="ml-auto shrink-0" label={t("thread.header.toggleTheme")}
/> className="ml-auto shrink-0"
/>
) : null}
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" /> <div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
</div> </div>
@ -97,7 +103,7 @@ function ThemeButton({
aria-label={label} aria-label={label}
onClick={onToggleTheme} onClick={onToggleTheme}
className={cn( 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, className,
)} )}
> >

View File

@ -62,6 +62,7 @@ interface ThreadShellProps {
theme?: "light" | "dark"; theme?: "light" | "dark";
onToggleTheme?: () => void; onToggleTheme?: () => void;
hideSidebarToggleForHostChrome?: boolean; hideSidebarToggleForHostChrome?: boolean;
hideThemeButton?: boolean;
hideHeader?: boolean; hideHeader?: boolean;
workspaceScope?: WorkspaceScopePayload | null; workspaceScope?: WorkspaceScopePayload | null;
workspaceDefaultScope?: WorkspaceScopePayload | null; workspaceDefaultScope?: WorkspaceScopePayload | null;
@ -142,6 +143,7 @@ export function ThreadShell({
theme = "light", theme = "light",
onToggleTheme = () => {}, onToggleTheme = () => {},
hideSidebarToggleForHostChrome = false, hideSidebarToggleForHostChrome = false,
hideThemeButton = false,
hideHeader = false, hideHeader = false,
workspaceScope = null, workspaceScope = null,
workspaceDefaultScope = null, workspaceDefaultScope = null,
@ -567,6 +569,7 @@ export function ThreadShell({
theme={theme} theme={theme}
onToggleTheme={onToggleTheme} onToggleTheme={onToggleTheme}
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome} hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
hideThemeButton={hideThemeButton}
minimal={!session && !loading} minimal={!session && !loading}
/> />
) : null} ) : null}

View File

@ -237,7 +237,7 @@ export function ThreadViewport({
<div <div
ref={scrollRef} ref={scrollRef}
className={cn( className={cn(
"absolute inset-0 overflow-y-auto scroll-auto scrollbar-thin", "thread-viewport-scrollbar absolute inset-0 overflow-y-auto scroll-auto scrollbar-thin",
"[&::-webkit-scrollbar]:w-1.5", "[&::-webkit-scrollbar]:w-1.5",
"[&::-webkit-scrollbar-thumb]:rounded-full", "[&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30", "[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",

View File

@ -38,6 +38,7 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
const editing = edit.status === "editing"; const editing = edit.status === "editing";
const failed = edit.status === "error"; const failed = edit.status === "error";
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit); const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
const rawFailureDetail = failed ? cleanFileEditError(edit.error) : "";
const failureDetail = failed const failureDetail = failed
? formatFileEditError(edit.error) ? formatFileEditError(edit.error)
|| t("message.fileEditFailedFallback", { defaultValue: "File change was not applied." }) || t("message.fileEditFailedFallback", { defaultValue: "File change was not applied." })
@ -67,8 +68,8 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
active={editing} active={editing}
tone={failed ? "error" : editing ? "active" : "success"} tone={failed ? "error" : editing ? "active" : "success"}
className="text-xs" className="text-xs"
contentClassName="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3" contentClassName={failed ? "min-w-0" : "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3"}
title={failureDetail || edit.absolute_path || edit.path} title={rawFailureDetail || edit.absolute_path || edit.path}
label={edit.pending && !edit.path label={edit.pending && !edit.path
? t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" }) ? t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })
: ( : (
@ -82,13 +83,15 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
testId="activity-file-reference" testId="activity-file-reference"
/> />
)} )}
detail={failed ? ( detail={null}
<span className="min-w-0 truncate text-[11px] leading-4 text-destructive/75"> aside={hasCountedDiff ? <DiffPair added={edit.added} deleted={edit.deleted} /> : null}
>
{failed ? (
<span className="block max-w-[42rem] truncate text-[11px] leading-4 text-destructive/75">
{failureDetail} {failureDetail}
</span> </span>
) : null} ) : null}
aside={hasCountedDiff ? <DiffPair added={edit.added} deleted={edit.deleted} /> : null} </ActivityStep>
/>
); );
} }
@ -96,14 +99,23 @@ export function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "delet
return edit.added > 0 || edit.deleted > 0; return edit.added > 0 || edit.deleted > 0;
} }
function formatFileEditError(error?: string): string { function cleanFileEditError(error?: string): string {
const firstLine = (error || "").replace(/\s+/g, " ").trim(); const firstLine = (error || "").replace(/\s+/g, " ").trim();
if (!firstLine) return ""; if (!firstLine) return "";
const cleaned = firstLine return firstLine
.replace(/^Error applying patch:\s*/i, "") .replace(/^Error applying patch:\s*/i, "")
.replace(/^Error writing file:\s*/i, "") .replace(/^Error writing file:\s*/i, "")
.replace(/^Error editing file:\s*/i, "") .replace(/^Error editing file:\s*/i, "")
.replace(/^Error:\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 return cleaned
.replace(/^old_text not found in (.+)$/i, "Target text was not found in $1.") .replace(/^old_text not found in (.+)$/i, "Target text was not found in $1.")

View File

@ -89,6 +89,75 @@
-webkit-app-region: no-drag; -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 { .shadow-inner-right {
box-shadow: inset -9px 0 6px -1px rgb(0 0 0 / 0.02); box-shadow: inset -9px 0 6px -1px rgb(0 0 0 / 0.02);
} }

View File

@ -9,11 +9,20 @@ import type {
GoalStateWsPayload, GoalStateWsPayload,
WorkspaceScopePayload, WorkspaceScopePayload,
} from "./types"; } from "./types";
import { createHostWebSocket } from "./runtime";
/** WebSocket readyState constants, referenced by value to stay portable /** WebSocket readyState constants, referenced by value to stay portable
* across runtimes that don't expose a global ``WebSocket`` (tests, SSR). */ * across runtimes that don't expose a global ``WebSocket`` (tests, SSR). */
const WS_OPEN = 1; const WS_OPEN = 1;
const WS_CLOSING = 2; 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``. /** Inbound WebSocket ``console.log`` / parse-failure ``console.warn``.
* *
@ -129,7 +138,7 @@ export class NanobotClient {
private reconnectTimer: ReturnType<typeof setTimeout> | null = null; private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private readonly shouldReconnect: boolean; private readonly shouldReconnect: boolean;
private readonly maxBackoffMs: number; private readonly maxBackoffMs: number;
private readonly socketFactory: (url: string) => WebSocket; private socketFactory: (url: string) => WebSocket;
private currentUrl: string; private currentUrl: string;
private status_: ConnectionStatus = "idle"; private status_: ConnectionStatus = "idle";
private readyChatId: string | null = null; private readyChatId: string | null = null;
@ -140,8 +149,7 @@ export class NanobotClient {
constructor(private options: NanobotClientOptions) { constructor(private options: NanobotClientOptions) {
this.shouldReconnect = options.reconnect ?? true; this.shouldReconnect = options.reconnect ?? true;
this.maxBackoffMs = options.maxBackoffMs ?? 15_000; this.maxBackoffMs = options.maxBackoffMs ?? 15_000;
this.socketFactory = this.socketFactory = options.socketFactory ?? createDefaultSocket;
options.socketFactory ?? ((url) => new WebSocket(url));
this.currentUrl = options.url; this.currentUrl = options.url;
} }
@ -154,8 +162,11 @@ export class NanobotClient {
} }
/** Swap the URL (e.g. after fetching a fresh token) then reconnect. */ /** 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; this.currentUrl = url;
if (socketFactory) {
this.socketFactory = socketFactory;
}
} }
onStatus(handler: StatusHandler): Unsubscribe { onStatus(handler: StatusHandler): Unsubscribe {

View File

@ -51,6 +51,11 @@ type HostSocketBridge = Required<Pick<
"closeSocket" | "onSocketEvent" | "openSocket" | "sendSocket" "closeSocket" | "onSocketEvent" | "openSocket" | "sendSocket"
>>; >>;
const HOST_WS_CONNECTING = 0;
const HOST_WS_OPEN = 1;
const HOST_WS_CLOSING = 2;
const HOST_WS_CLOSED = 3;
declare global { declare global {
interface Window { interface Window {
nanobotHost?: NanobotHostApi; nanobotHost?: NanobotHostApi;
@ -122,7 +127,7 @@ class HostWebSocket {
onerror: ((this: WebSocket, ev: Event) => unknown) | null = null; onerror: ((this: WebSocket, ev: Event) => unknown) | null = null;
onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null = null; onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null = null;
onopen: ((this: WebSocket, ev: Event) => unknown) | null = null; onopen: ((this: WebSocket, ev: Event) => unknown) | null = null;
readyState: number = WebSocket.CONNECTING; readyState: number = HOST_WS_CONNECTING;
readonly url: string; readonly url: string;
private id: string | null = null; private id: string | null = null;
@ -140,7 +145,7 @@ class HostWebSocket {
this.id = id; this.id = id;
}, },
() => { () => {
this.readyState = WebSocket.CLOSED; this.readyState = HOST_WS_CLOSED;
this.onerror?.call(this as unknown as WebSocket, new Event("error")); this.onerror?.call(this as unknown as WebSocket, new Event("error"));
this.onclose?.call(this as unknown as WebSocket, closeEvent()); this.onclose?.call(this as unknown as WebSocket, closeEvent());
this.unsubscribe(); this.unsubscribe();
@ -149,14 +154,14 @@ class HostWebSocket {
} }
close(): void { close(): void {
if (this.readyState === WebSocket.CLOSING || this.readyState === WebSocket.CLOSED) { if (this.readyState === HOST_WS_CLOSING || this.readyState === HOST_WS_CLOSED) {
return; return;
} }
this.readyState = WebSocket.CLOSING; this.readyState = HOST_WS_CLOSING;
if (this.id) { if (this.id) {
void this.api.closeSocket(this.id); void this.api.closeSocket(this.id);
} else { } else {
this.readyState = WebSocket.CLOSED; this.readyState = HOST_WS_CLOSED;
this.unsubscribe(); this.unsubscribe();
} }
} }
@ -165,7 +170,7 @@ class HostWebSocket {
if (typeof data !== "string") { if (typeof data !== "string") {
throw new Error("Host WebSocket bridge only supports text frames"); 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); void this.api.sendSocket(this.id, data);
return; return;
} }
@ -175,7 +180,7 @@ class HostWebSocket {
private handleEvent(event: HostSocketEvent): void { private handleEvent(event: HostSocketEvent): void {
if (!this.id || event.id !== this.id) return; if (!this.id || event.id !== this.id) return;
if (event.type === "open") { if (event.type === "open") {
this.readyState = WebSocket.OPEN; this.readyState = HOST_WS_OPEN;
this.onopen?.call(this as unknown as WebSocket, new Event("open")); this.onopen?.call(this as unknown as WebSocket, new Event("open"));
while (this.queued.length > 0 && this.id) { while (this.queued.length > 0 && this.id) {
const data = this.queued.shift(); const data = this.queued.shift();
@ -194,7 +199,7 @@ class HostWebSocket {
this.onerror?.call(this as unknown as WebSocket, new Event("error")); this.onerror?.call(this as unknown as WebSocket, new Event("error"));
return; return;
} }
this.readyState = WebSocket.CLOSED; this.readyState = HOST_WS_CLOSED;
this.onclose?.call( this.onclose?.call(
this as unknown as WebSocket, this as unknown as WebSocket,
closeEvent(event.code, event.reason), closeEvent(event.code, event.reason),

View File

@ -869,6 +869,39 @@ describe("AgentActivityCluster", () => {
expect(screen.getByText("Target text was not found in angry-birds.html.")).toBeInTheDocument(); 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 () => { it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
const restoreMotion = installReducedMotion(); const restoreMotion = installReducedMotion();
try { try {

View File

@ -891,9 +891,9 @@ describe("App layout", () => {
expect(screen.queryByText("AI")).not.toBeInTheDocument(); expect(screen.queryByText("AI")).not.toBeInTheDocument();
expect(screen.getByText("Current configuration")).toBeInTheDocument(); expect(screen.getByText("Current configuration")).toBeInTheDocument();
expect(screen.queryByText("Presets")).not.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" })); 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(); expect(within(modelDialog).getByText("Save a provider and model as a one-click option.")).toBeInTheDocument();
fireEvent.change(within(modelDialog).getByPlaceholderText("Fast writing"), { fireEvent.change(within(modelDialog).getByPlaceholderText("Fast writing"), {
target: { value: "Fast writing" }, target: { value: "Fast writing" },

View File

@ -65,6 +65,7 @@ beforeEach(() => {
}); });
afterEach(() => { afterEach(() => {
Reflect.deleteProperty(window, "nanobotHost");
vi.useRealTimers(); 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", () => { it("buffers chat events while no chat handler is registered and replays on subscribe", () => {
const client = new NanobotClient({ const client = new NanobotClient({
url: "ws://test", url: "ws://test",

View File

@ -245,6 +245,40 @@ describe("SettingsView Apps catalog", () => {
expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument(); 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 () => { it("loads provider models and lets users choose one without typing the id manually", async () => {
const payload: SettingsPayload = { const payload: SettingsPayload = {
...settingsPayload(), ...settingsPayload(),