mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 22:34:06 +00:00
feat(webui): polish native host experience
This commit is contained in:
parent
15c6abc991
commit
31722120b7
@ -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}
|
||||||
|
|||||||
@ -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",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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.")
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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" },
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user