fix(webui): align native sidebar controls

This commit is contained in:
Xubin Ren
2026-09-03 13:38:21 +08:00
parent d81aa5a4ab
commit a841b10bc0
3 changed files with 35 additions and 166 deletions
+15 -139
View File
@@ -8,7 +8,7 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } 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 { useTranslation } from "react-i18next";
import { channelUiPresentation } from "@/channel-plugins/registry"; import { channelUiPresentation } from "@/channel-plugins/registry";
import { Sidebar } from "@/components/Sidebar"; import { Sidebar } from "@/components/Sidebar";
@@ -475,39 +475,12 @@ function isBootstrapAuthRequired(error: unknown): boolean {
} }
function HostChrome({ function HostChrome({
onToggleSidebar,
onSidebarPreviewEnter,
onSidebarPreviewLeave,
sidebarOpen = true,
rightAction, rightAction,
}: { }: {
onToggleSidebar?: () => void;
onSidebarPreviewEnter?: () => void;
onSidebarPreviewLeave?: () => void;
sidebarOpen?: boolean;
rightAction?: ReactNode; rightAction?: ReactNode;
}) { }) {
const { t } = useTranslation();
return ( return (
<header className="host-drag-region pointer-events-none absolute inset-x-0 top-0 z-40 h-11 bg-transparent text-foreground/90"> <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 ? ( {rightAction ? (
<div className="host-no-drag pointer-events-auto absolute right-3 top-2"> <div className="host-no-drag pointer-events-auto absolute right-3 top-2">
{rightAction} {rightAction}
@@ -1055,7 +1028,6 @@ function Shell({
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection); useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
const [hostSidebarOpen, setHostSidebarOpen] = const [hostSidebarOpen, setHostSidebarOpen] =
useState<boolean>(readSidebarOpen); useState<boolean>(readSidebarOpen);
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sessionSearchOpen, setSessionSearchOpen] = useState(false); const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
const mobileWorkbench = useMediaQuery("(max-width: 767px)"); const mobileWorkbench = useMediaQuery("(max-width: 767px)");
@@ -1112,7 +1084,6 @@ function Shell({
const activeChatIdRef = useRef<string | null>(null); const activeChatIdRef = useRef<string | null>(null);
const pendingCreatedSessionKeyRef = useRef<string | null>(null); const pendingCreatedSessionKeyRef = useRef<string | null>(null);
const temporarySessionsRef = useRef<Record<string, ChatSummary>>({}); const temporarySessionsRef = useRef<Record<string, ChatSummary>>({});
const hostSidebarPreviewCloseTimerRef = useRef<number | null>(null);
const effectiveRuntimeSurface = const effectiveRuntimeSurface =
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface; settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface;
const showHostChrome = effectiveRuntimeSurface === "native"; const showHostChrome = effectiveRuntimeSurface === "native";
@@ -1453,74 +1424,13 @@ function Shell({
}); });
}, [client, loading, sessions]); }, [client, loading, sessions]);
const clearHostSidebarPreviewCloseTimer = useCallback(() => { const closeHostSidebar = useCallback(() => {
if (hostSidebarPreviewCloseTimerRef.current === null) return; setHostSidebarOpen(false);
window.clearTimeout(hostSidebarPreviewCloseTimerRef.current);
hostSidebarPreviewCloseTimerRef.current = null;
}, []); }, []);
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(() => { const openHostSidebar = useCallback(() => {
closeHostSidebarPreview();
setHostSidebarOpen(true); setHostSidebarOpen(true);
}, [closeHostSidebarPreview]); }, []);
const toggleHostSidebar = useCallback(() => {
closeHostSidebarPreview();
setHostSidebarOpen((v) => !v);
}, [closeHostSidebarPreview]);
const closeMobileSidebar = useCallback(() => { const closeMobileSidebar = useCallback(() => {
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
@@ -1531,12 +1441,11 @@ function Shell({
typeof window !== "undefined" && typeof window !== "undefined" &&
window.matchMedia("(min-width: 1024px)").matches; window.matchMedia("(min-width: 1024px)").matches;
if (isNativeHost) { if (isNativeHost) {
closeHostSidebarPreview();
setHostSidebarOpen((v) => !v); setHostSidebarOpen((v) => !v);
} else { } else {
setMobileSidebarOpen((v) => !v); setMobileSidebarOpen((v) => !v);
} }
}, [closeHostSidebarPreview]); }, []);
const applyWorkspaceScope = useCallback( const applyWorkspaceScope = useCallback(
(scope: WorkspaceScopePayload) => { (scope: WorkspaceScopePayload) => {
@@ -2574,13 +2483,7 @@ function Shell({
archivedCount: sidebarArchivedTabKeys.length, archivedCount: sidebarArchivedTabKeys.length,
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null, defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
}; };
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen; const hostSidebarFlowWidth = hostSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH;
const showHostSidebarPreview =
showMainSidebar && hostSidebarCollapsed && hostSidebarPreviewOpen;
const hostSidebarFlowWidth = showHostChrome
? (hostSidebarOpen ? SIDEBAR_WIDTH : 0)
: (hostSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH);
const renderHostSidebarFlowContent = !showHostChrome || hostSidebarOpen;
useEffect(() => { useEffect(() => {
document.documentElement.classList.toggle("native-host", showHostChrome); document.documentElement.classList.toggle("native-host", showHostChrome);
@@ -2599,10 +2502,6 @@ function Shell({
> >
{showHostChrome ? ( {showHostChrome ? (
<HostChrome <HostChrome
onToggleSidebar={showMainSidebar ? toggleHostSidebar : undefined}
onSidebarPreviewEnter={openHostSidebarPreview}
onSidebarPreviewLeave={scheduleHostSidebarPreviewClose}
sidebarOpen={hostSidebarOpen}
rightAction={ rightAction={
view === "chat" ? undefined : ( view === "chat" ? undefined : (
<Button <Button
@@ -2640,38 +2539,17 @@ function Shell({
width: hostSidebarFlowWidth, width: hostSidebarFlowWidth,
}} }}
> >
{renderHostSidebarFlowContent ? ( <div
<div className={cn(
className={cn( "absolute inset-y-0 left-0 h-full w-full overflow-hidden",
"absolute inset-y-0 left-0 h-full w-full overflow-hidden", showHostChrome
showHostChrome ? "host-sidebar-glass"
? "host-sidebar-glass" : "bg-sidebar",
: "bg-sidebar", )}
)} >
>
<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 <Sidebar
{...sidebarProps} {...sidebarProps}
collapsed={!hostSidebarOpen}
hostChromeInset={showHostChrome} hostChromeInset={showHostChrome}
onCollapse={closeHostSidebar} onCollapse={closeHostSidebar}
onExpand={openHostSidebar} onExpand={openHostSidebar}
@@ -2782,7 +2660,6 @@ function Shell({
theme={theme} theme={theme}
onToggleTheme={toggle} onToggleTheme={toggle}
hideSidebarToggleForHostChrome hideSidebarToggleForHostChrome
hostChromeTitleInset={hostSidebarCollapsed}
hideHeader={false} hideHeader={false}
workspaceScope={activeWorkspaceScope} workspaceScope={activeWorkspaceScope}
workspaceDefaultScope={workspaces?.default_scope ?? null} workspaceDefaultScope={workspaces?.default_scope ?? null}
@@ -2820,7 +2697,6 @@ function Shell({
onToggleTheme={toggle} onToggleTheme={toggle}
hideSidebarToggle={!context.active} hideSidebarToggle={!context.active}
hideSidebarToggleForHostChrome={context.active} hideSidebarToggleForHostChrome={context.active}
hostChromeTitleInset={hostSidebarCollapsed}
hideThemeButton={!context.active} hideThemeButton={!context.active}
hideHeaderTitle hideHeaderTitle
inlineHandle={workbenchPaneSessions.length > 1} inlineHandle={workbenchPaneSessions.length > 1}
+7 -5
View File
@@ -130,13 +130,14 @@ export function Sidebar(props: SidebarProps) {
)} )}
> >
<div <div
data-testid="sidebar-brand-row"
className={cn( className={cn(
"flex items-center px-3 pb-2.5", "flex items-start px-3 pb-2.5 pt-3",
props.hostChromeInset ? "pt-[2.85rem]" : "pt-3",
collapsed ? "w-14 justify-start" : "justify-between", collapsed ? "w-14 justify-start" : "justify-between",
)} )}
> >
<button <button
data-testid="sidebar-brand-mark"
type="button" type="button"
aria-label={collapsed ? toggleLabel : undefined} aria-label={collapsed ? toggleLabel : undefined}
aria-hidden={collapsed ? undefined : true} aria-hidden={collapsed ? undefined : true}
@@ -144,7 +145,8 @@ export function Sidebar(props: SidebarProps) {
onClick={collapsed ? props.onExpand : undefined} onClick={collapsed ? props.onExpand : undefined}
tabIndex={collapsed ? 0 : -1} tabIndex={collapsed ? 0 : -1}
className={cn( 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 collapsed
? "-ml-0.5 hover:bg-sidebar-accent/75" ? "-ml-0.5 hover:bg-sidebar-accent/75"
: "pointer-events-none -ml-0.5", : "pointer-events-none -ml-0.5",
@@ -157,13 +159,13 @@ export function Sidebar(props: SidebarProps) {
draggable={false} draggable={false}
/> />
</button> </button>
{!collapsed && !props.hostChromeInset && ( {!collapsed && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label={t("sidebar.collapse")} aria-label={t("sidebar.collapse")}
onClick={props.onCollapse} 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 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" /> <Menu className="h-3.5 w-3.5" />
</Button> </Button>
+13 -22
View File
@@ -454,6 +454,8 @@ describe("App layout", () => {
const main = container.querySelector("main"); const main = container.querySelector("main");
expect(main).toBeInTheDocument(); expect(main).toBeInTheDocument();
expect(main).not.toHaveAttribute("style"); expect(main).not.toHaveAttribute("style");
expect(screen.getByTestId("sidebar-brand-row")).toHaveClass("pt-3");
expect(screen.getByTestId("sidebar-brand-mark")).not.toHaveClass("mt-5");
const asideClassNames = Array.from(container.querySelectorAll("aside")).map( const asideClassNames = Array.from(container.querySelectorAll("aside")).map(
(el) => el.className, (el) => el.className,
@@ -1609,7 +1611,7 @@ describe("App layout", () => {
expect(document.title).toBe("自动任务 · nanobot"); 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 = [ mockSessions = [
{ {
key: "websocket:chat-a", key: "websocket:chat-a",
@@ -1632,36 +1634,25 @@ describe("App layout", () => {
await waitFor(() => expect(connectSpy).toHaveBeenCalled()); await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const flowSidebar = screen.getByTestId("host-sidebar-flow"); const flowSidebar = screen.getByTestId("host-sidebar-flow");
const toggle = screen.getByTestId("host-sidebar-toggle");
expect(flowSidebar).toHaveStyle({ width: "272px" }); 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.queryByTestId("host-sidebar-toggle")).not.toBeInTheDocument();
expect( expect(
screen.getByRole("navigation", { name: "Sidebar navigation" }), screen.getByRole("navigation", { name: "Sidebar navigation" }),
).toBeInTheDocument(); ).toBeInTheDocument();
fireEvent.click(toggle); fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" }));
await waitFor(() => expect(flowSidebar).toHaveStyle({ width: "0px" })); await waitFor(() => expect(flowSidebar).toHaveStyle({ width: "56px" }));
expect( expect(
screen.queryByRole("navigation", { name: "Sidebar navigation" }), screen.getByRole("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",
}),
).toBeInTheDocument(); ).toBeInTheDocument();
fireEvent.click(toggle); fireEvent.click(
await waitFor(() => within(screen.getByRole("navigation", { name: "Sidebar navigation" }))
expect(screen.queryByTestId("host-sidebar-preview")).not.toBeInTheDocument(), .getByRole("button", { name: "Toggle sidebar" }),
); );
expect(flowSidebar).toHaveStyle({ width: "272px" }); await waitFor(() => expect(flowSidebar).toHaveStyle({ width: "272px" }));
expect(
screen.getByRole("navigation", { name: "Sidebar navigation" }),
).toBeInTheDocument();
}); });
it("switches to the next session when deleting the active chat", async () => { it("switches to the next session when deleting the active chat", async () => {