Compare commits

...
6 changed files with 107 additions and 179 deletions
+19 -136
View File
@@ -8,7 +8,7 @@ import {
useState,
type ReactNode,
} from "react";
import { Eye, EyeOff, Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
import { Eye, EyeOff, Moon, ShieldCheck, Sun, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { channelUiPresentation } from "@/channel-plugins/registry";
import { Sidebar } from "@/components/Sidebar";
@@ -79,6 +79,7 @@ import {
} from "@/lib/api";
import {
createRuntimeHost,
isNativeRuntime,
toRuntimeSurface,
} from "@/lib/runtime";
import { projectNameFromPath, scopeWithAccessMode } from "@/lib/workspace";
@@ -475,39 +476,12 @@ function isBootstrapAuthRequired(error: unknown): boolean {
}
function HostChrome({
onToggleSidebar,
onSidebarPreviewEnter,
onSidebarPreviewLeave,
sidebarOpen = true,
rightAction,
}: {
onToggleSidebar?: () => void;
onSidebarPreviewEnter?: () => void;
onSidebarPreviewLeave?: () => void;
sidebarOpen?: boolean;
rightAction?: ReactNode;
}) {
const { t } = useTranslation();
return (
<header className="host-drag-region pointer-events-none absolute inset-x-0 top-0 z-40 h-11 bg-transparent text-foreground/90">
{onToggleSidebar ? (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleSidebar")}
data-testid="host-sidebar-toggle"
onClick={onToggleSidebar}
onFocus={!sidebarOpen ? onSidebarPreviewEnter : undefined}
onBlur={!sidebarOpen ? onSidebarPreviewLeave : undefined}
onMouseEnter={!sidebarOpen ? onSidebarPreviewEnter : undefined}
onMouseLeave={!sidebarOpen ? onSidebarPreviewLeave : undefined}
className="host-no-drag pointer-events-auto absolute left-[88px] top-[8px] h-7 w-7 rounded-lg bg-transparent text-muted-foreground/85 shadow-none hover:bg-transparent hover:text-foreground"
>
<PanelLeft className="h-[15px] w-[15px]" strokeWidth={1.75} />
</Button>
) : null}
{rightAction ? (
<div className="host-no-drag pointer-events-auto absolute right-3 top-2">
{rightAction}
@@ -805,6 +779,14 @@ function formatPairingExpiry(seconds: number | null | undefined): string {
return `${Math.ceil(seconds / 60)} min`;
}
function resolveRuntimeSurface(
surface: RuntimeSurface | null | undefined,
fallback: RuntimeSurface,
): RuntimeSurface {
if (isNativeRuntime(surface)) return "native";
return surface ? toRuntimeSurface(surface) : fallback;
}
export default function App() {
const { t } = useTranslation();
const [state, setState] = useState<BootState>({ status: "loading" });
@@ -814,9 +796,7 @@ export default function App() {
async (client: NanobotClient, fallbackSurface: RuntimeSurface) => {
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)
: fallbackSurface;
const runtimeSurface = resolveRuntimeSurface(boot.runtime_surface, fallbackSurface);
const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities);
const tokenExpiresAt = boot.expires_in
? bootstrapTokenExpiresAt(boot.expires_in)
@@ -854,7 +834,7 @@ export default function App() {
if (cancelled) return;
if (secret) saveSecret(secret);
const url = deriveWsUrl(boot.ws_path, boot.token, boot.ws_url);
const runtimeSurface = toRuntimeSurface(boot.runtime_surface);
const runtimeSurface = resolveRuntimeSurface(boot.runtime_surface, "browser");
const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities);
const client = new NanobotClient({
url,
@@ -1055,7 +1035,6 @@ function Shell({
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
const [hostSidebarOpen, setHostSidebarOpen] =
useState<boolean>(readSidebarOpen);
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
const mobileWorkbench = useMediaQuery("(max-width: 767px)");
@@ -1112,10 +1091,9 @@ function Shell({
const activeChatIdRef = useRef<string | null>(null);
const pendingCreatedSessionKeyRef = useRef<string | null>(null);
const temporarySessionsRef = useRef<Record<string, ChatSummary>>({});
const hostSidebarPreviewCloseTimerRef = useRef<number | null>(null);
const effectiveRuntimeSurface =
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface;
const showHostChrome = effectiveRuntimeSurface === "native";
const showHostChrome = isNativeRuntime(effectiveRuntimeSurface);
const showMainSidebar = view !== "settings";
const activeTemporarySession = activeKey ? temporarySessions[activeKey] ?? null : null;
const temporaryChatId = activeTemporarySession?.chatId ?? null;
@@ -1453,74 +1431,13 @@ function Shell({
});
}, [client, loading, sessions]);
const clearHostSidebarPreviewCloseTimer = useCallback(() => {
if (hostSidebarPreviewCloseTimerRef.current === null) return;
window.clearTimeout(hostSidebarPreviewCloseTimerRef.current);
hostSidebarPreviewCloseTimerRef.current = null;
const closeHostSidebar = useCallback(() => {
setHostSidebarOpen(false);
}, []);
const closeHostSidebarPreview = useCallback(() => {
clearHostSidebarPreviewCloseTimer();
setHostSidebarPreviewOpen(false);
}, [clearHostSidebarPreviewCloseTimer]);
const openHostSidebarPreview = useCallback(() => {
if (!showHostChrome || !showMainSidebar || hostSidebarOpen) return;
clearHostSidebarPreviewCloseTimer();
setHostSidebarPreviewOpen(true);
}, [
clearHostSidebarPreviewCloseTimer,
hostSidebarOpen,
showHostChrome,
showMainSidebar,
]);
const scheduleHostSidebarPreviewClose = useCallback(() => {
clearHostSidebarPreviewCloseTimer();
if (!showHostChrome || !showMainSidebar || hostSidebarOpen) {
setHostSidebarPreviewOpen(false);
return;
}
hostSidebarPreviewCloseTimerRef.current = window.setTimeout(() => {
setHostSidebarPreviewOpen(false);
hostSidebarPreviewCloseTimerRef.current = null;
}, 160);
}, [
clearHostSidebarPreviewCloseTimer,
hostSidebarOpen,
showHostChrome,
showMainSidebar,
]);
useEffect(() => {
return () => clearHostSidebarPreviewCloseTimer();
}, [clearHostSidebarPreviewCloseTimer]);
useEffect(() => {
if (!showHostChrome || !showMainSidebar || hostSidebarOpen) {
closeHostSidebarPreview();
}
}, [
closeHostSidebarPreview,
hostSidebarOpen,
showHostChrome,
showMainSidebar,
]);
const closeHostSidebar = useCallback(() => {
closeHostSidebarPreview();
setHostSidebarOpen(false);
}, [closeHostSidebarPreview]);
const openHostSidebar = useCallback(() => {
closeHostSidebarPreview();
setHostSidebarOpen(true);
}, [closeHostSidebarPreview]);
const toggleHostSidebar = useCallback(() => {
closeHostSidebarPreview();
setHostSidebarOpen((v) => !v);
}, [closeHostSidebarPreview]);
}, []);
const closeMobileSidebar = useCallback(() => {
setMobileSidebarOpen(false);
@@ -1531,12 +1448,11 @@ function Shell({
typeof window !== "undefined" &&
window.matchMedia("(min-width: 1024px)").matches;
if (isNativeHost) {
closeHostSidebarPreview();
setHostSidebarOpen((v) => !v);
} else {
setMobileSidebarOpen((v) => !v);
}
}, [closeHostSidebarPreview]);
}, []);
const applyWorkspaceScope = useCallback(
(scope: WorkspaceScopePayload) => {
@@ -2574,13 +2490,7 @@ function Shell({
archivedCount: sidebarArchivedTabKeys.length,
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
};
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
const showHostSidebarPreview =
showMainSidebar && hostSidebarCollapsed && hostSidebarPreviewOpen;
const hostSidebarFlowWidth = showHostChrome
? (hostSidebarOpen ? SIDEBAR_WIDTH : 0)
: (hostSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH);
const renderHostSidebarFlowContent = !showHostChrome || hostSidebarOpen;
const hostSidebarFlowWidth = hostSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH;
useEffect(() => {
document.documentElement.classList.toggle("native-host", showHostChrome);
@@ -2599,10 +2509,6 @@ function Shell({
>
{showHostChrome ? (
<HostChrome
onToggleSidebar={showMainSidebar ? toggleHostSidebar : undefined}
onSidebarPreviewEnter={openHostSidebarPreview}
onSidebarPreviewLeave={scheduleHostSidebarPreviewClose}
sidebarOpen={hostSidebarOpen}
rightAction={
view === "chat" ? undefined : (
<Button
@@ -2640,7 +2546,6 @@ function Shell({
width: hostSidebarFlowWidth,
}}
>
{renderHostSidebarFlowContent ? (
<div
className={cn(
"absolute inset-y-0 left-0 h-full w-full overflow-hidden",
@@ -2651,27 +2556,7 @@ function Shell({
>
<Sidebar
{...sidebarProps}
collapsed={!showHostChrome && !hostSidebarOpen}
hostChromeInset={showHostChrome}
onCollapse={closeHostSidebar}
onExpand={openHostSidebar}
/>
</div>
) : null}
</aside>
) : null}
{showHostSidebarPreview ? (
<aside
data-testid="host-sidebar-preview"
className="absolute inset-y-0 left-0 z-30 hidden overflow-hidden lg:block animate-in fade-in-0 slide-in-from-left-2 duration-150"
style={{ width: SIDEBAR_WIDTH }}
onMouseEnter={openHostSidebarPreview}
onMouseLeave={scheduleHostSidebarPreviewClose}
>
<div className="h-full w-full overflow-hidden host-sidebar-glass shadow-2xl">
<Sidebar
{...sidebarProps}
collapsed={!hostSidebarOpen}
hostChromeInset={showHostChrome}
onCollapse={closeHostSidebar}
onExpand={openHostSidebar}
@@ -2782,7 +2667,6 @@ function Shell({
theme={theme}
onToggleTheme={toggle}
hideSidebarToggleForHostChrome
hostChromeTitleInset={hostSidebarCollapsed}
hideHeader={false}
workspaceScope={activeWorkspaceScope}
workspaceDefaultScope={workspaces?.default_scope ?? null}
@@ -2820,7 +2704,6 @@ function Shell({
onToggleTheme={toggle}
hideSidebarToggle={!context.active}
hideSidebarToggleForHostChrome={context.active}
hostChromeTitleInset={hostSidebarCollapsed}
hideThemeButton={!context.active}
hideHeaderTitle
inlineHandle={workbenchPaneSessions.length > 1}
+7 -5
View File
@@ -130,13 +130,14 @@ export function Sidebar(props: SidebarProps) {
)}
>
<div
data-testid="sidebar-brand-row"
className={cn(
"flex items-center px-3 pb-2.5",
props.hostChromeInset ? "pt-[2.85rem]" : "pt-3",
"flex items-start px-3 pb-2.5 pt-3",
collapsed ? "w-14 justify-start" : "justify-between",
)}
>
<button
data-testid="sidebar-brand-mark"
type="button"
aria-label={collapsed ? toggleLabel : undefined}
aria-hidden={collapsed ? undefined : true}
@@ -144,7 +145,8 @@ export function Sidebar(props: SidebarProps) {
onClick={collapsed ? props.onExpand : undefined}
tabIndex={collapsed ? 0 : -1}
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-xl transition-colors",
"host-no-drag flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-xl transition-colors",
props.hostChromeInset && "mt-5",
collapsed
? "-ml-0.5 hover:bg-sidebar-accent/75"
: "pointer-events-none -ml-0.5",
@@ -157,13 +159,13 @@ export function Sidebar(props: SidebarProps) {
draggable={false}
/>
</button>
{!collapsed && !props.hostChromeInset && (
{!collapsed && (
<Button
variant="ghost"
size="icon"
aria-label={t("sidebar.collapse")}
onClick={props.onCollapse}
className="h-7 w-7 rounded-lg text-muted-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
className="host-no-drag mt-1 h-7 w-7 rounded-lg text-muted-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
>
<Menu className="h-3.5 w-3.5" />
</Button>
@@ -76,13 +76,16 @@ export function SettingsSidebar({
<aside
className={cn(
"flex w-full shrink-0 flex-col bg-settings-surface px-3 pb-2 lg:w-[17rem] lg:px-3 lg:pb-4",
hostChromeInset ? "pt-[4.25rem] lg:pt-[4.25rem]" : "pt-4 lg:pt-4",
hostChromeInset ? "pt-10 lg:pt-10" : "pt-4 lg:pt-4",
)}
>
<button
type="button"
onClick={onBackToChat}
className="touch-target mb-2 inline-flex w-fit items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:mb-3"
className={cn(
"touch-target mb-2 inline-flex w-fit items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:mb-3",
hostChromeInset && "-ml-1",
)}
>
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
{t("settings.backToChat")}
@@ -21,7 +21,6 @@ interface ThreadHeaderProps {
onToggleTheme: () => void;
hideSidebarToggleForHostChrome?: boolean;
hideSidebarToggle?: boolean;
hostChromeTitleInset?: boolean;
hideThemeButton?: boolean;
hideTitle?: boolean;
actions?: ReactNode;
@@ -41,7 +40,6 @@ export function ThreadHeader({
onToggleTheme,
hideSidebarToggleForHostChrome = false,
hideSidebarToggle = false,
hostChromeTitleInset = false,
hideThemeButton = false,
hideTitle = false,
actions,
@@ -60,7 +58,6 @@ export function ThreadHeader({
className={cn(
"relative z-30 flex items-center justify-between gap-3 px-3 py-1",
minimal && "h-11",
!minimal && hostChromeTitleInset && "lg:pl-[128px]",
)}
>
<div className="relative flex min-w-0 items-center gap-2">
@@ -343,7 +343,6 @@ interface ThreadShellProps {
onToggleTheme?: () => void;
hideSidebarToggleForHostChrome?: boolean;
hideSidebarToggle?: boolean;
hostChromeTitleInset?: boolean;
hideThemeButton?: boolean;
hideHeaderTitle?: boolean;
inlineHandle?: boolean;
@@ -643,7 +642,6 @@ export function ThreadShell({
onToggleTheme = () => {},
hideSidebarToggleForHostChrome = false,
hideSidebarToggle = false,
hostChromeTitleInset = false,
hideThemeButton = false,
hideHeaderTitle = false,
inlineHandle = false,
@@ -1624,7 +1622,6 @@ export function ThreadShell({
onToggleTheme={onToggleTheme}
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
hideSidebarToggle={hideSidebarToggle}
hostChromeTitleInset={hostChromeTitleInset}
hideThemeButton={hideThemeButton}
hideTitle={hideHeaderTitle}
actions={headerActions}
+68 -22
View File
@@ -310,6 +310,7 @@ describe("App layout", () => {
sessionUpdateHandlers.clear();
sidebarStateUpdateHandlers.clear();
window.history.replaceState(null, "", "/");
Reflect.deleteProperty(window, "nanobotHost");
setNavigatorPlatform("Linux x86_64");
localStorage.removeItem("nanobot-webui.sidebar");
localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
@@ -335,6 +336,7 @@ describe("App layout", () => {
afterEach(() => {
cleanup();
Reflect.deleteProperty(window, "nanobotHost");
vi.useRealTimers();
vi.unstubAllGlobals();
});
@@ -454,6 +456,9 @@ describe("App layout", () => {
const main = container.querySelector("main");
expect(main).toBeInTheDocument();
expect(main).not.toHaveAttribute("style");
expect(screen.getByTestId("sidebar-brand-row")).toHaveClass("pt-3");
expect(screen.getByTestId("sidebar-brand-mark")).not.toHaveClass("mt-5");
expect(screen.getByRole("button", { name: "Collapse sidebar" })).toHaveClass("mt-1");
const asideClassNames = Array.from(container.querySelectorAll("aside")).map(
(el) => el.className,
@@ -476,6 +481,9 @@ describe("App layout", () => {
expect(
screen.getByRole("navigation", { name: "Settings sections" }),
).toBeInTheDocument();
const backButton = screen.getByRole("button", { name: "Back to chat" });
expect(backButton.closest("aside")).toHaveClass("pt-4", "lg:pt-4");
expect(backButton).not.toHaveClass("-ml-1");
expect(container.querySelectorAll("main")).toHaveLength(1);
expect(screen.getByRole("heading", { level: 1, name: "Settings" })).toBeInTheDocument();
});
@@ -1609,7 +1617,7 @@ describe("App layout", () => {
expect(document.title).toBe("自动任务 · nanobot");
});
it("fully collapses the native host sidebar and previews it on hover", async () => {
it("uses the shared sidebar controls and rail on the native host", async () => {
mockSessions = [
{
key: "websocket:chat-a",
@@ -1632,36 +1640,74 @@ describe("App layout", () => {
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const flowSidebar = screen.getByTestId("host-sidebar-flow");
const toggle = screen.getByTestId("host-sidebar-toggle");
expect(flowSidebar).toHaveStyle({ width: "272px" });
expect(screen.getByTestId("sidebar-brand-row")).toHaveClass("pt-3");
expect(screen.getByTestId("sidebar-brand-mark")).toHaveClass("mt-5");
expect(screen.getByRole("button", { name: "Collapse sidebar" })).toHaveClass("mt-1");
expect(screen.queryByTestId("host-sidebar-toggle")).not.toBeInTheDocument();
expect(
screen.getByRole("navigation", { name: "Sidebar navigation" }),
).toBeInTheDocument();
fireEvent.click(toggle);
await waitFor(() => expect(flowSidebar).toHaveStyle({ width: "0px" }));
fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" }));
await waitFor(() => expect(flowSidebar).toHaveStyle({ width: "56px" }));
expect(
screen.queryByRole("navigation", { name: "Sidebar navigation" }),
).not.toBeInTheDocument();
fireEvent.mouseEnter(toggle);
const previewSidebar = await screen.findByTestId("host-sidebar-preview");
expect(flowSidebar).toHaveStyle({ width: "0px" });
expect(previewSidebar).toHaveStyle({ width: "272px" });
expect(
within(previewSidebar).getByRole("navigation", {
name: "Sidebar navigation",
}),
screen.getByRole("navigation", { name: "Sidebar navigation" }),
).toBeInTheDocument();
fireEvent.click(toggle);
await waitFor(() =>
expect(screen.queryByTestId("host-sidebar-preview")).not.toBeInTheDocument(),
fireEvent.click(
within(screen.getByRole("navigation", { name: "Sidebar navigation" }))
.getByRole("button", { name: "Toggle sidebar" }),
);
expect(flowSidebar).toHaveStyle({ width: "272px" });
expect(
screen.getByRole("navigation", { name: "Sidebar navigation" }),
).toBeInTheDocument();
await waitFor(() => expect(flowSidebar).toHaveStyle({ width: "272px" }));
});
it("aligns native settings navigation below the titlebar without extra top padding", async () => {
vi.mocked(fetchBootstrap).mockResolvedValue({
token: "tok",
api_token: "api-tok",
ws_path: "/",
expires_in: 300,
runtime_surface: "native",
});
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
const backButton = await screen.findByRole("button", { name: "Back to chat" });
expect(backButton.closest("aside")).toHaveClass("pt-10", "lg:pt-10");
expect(backButton.closest("aside")).not.toHaveClass("pt-[4.25rem]");
expect(backButton).toHaveClass("-ml-1");
});
it("uses native chrome when the host bridge overrides browser gateway metadata", async () => {
Reflect.set(window, "nanobotHost", { pickFolder: vi.fn() });
vi.mocked(fetchBootstrap).mockResolvedValue({
token: "tok",
api_token: "api-tok",
ws_path: "/",
expires_in: 300,
runtime_surface: "browser",
});
mockFetchRoutes({
"/api/settings": {
...baseSettingsPayload(),
surface: "browser",
runtime_surface: "browser",
},
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
await waitFor(() => {
expect(screen.getByTestId("sidebar-brand-mark")).toHaveClass("mt-5");
});
expect(document.documentElement).toHaveClass("native-host");
});
it("switches to the next session when deleting the active chat", async () => {