From a71222607e36d1a6efde856d7996a9e88f0b3856 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:23:57 +0800 Subject: [PATCH] feat(webui): polish desktop chat experience --- webui/src/App.tsx | 189 ++++- webui/src/components/CodeBlock.tsx | 187 ++++- webui/src/components/FilePreviewPanel.tsx | 289 +++++++ webui/src/components/FileReferenceChip.tsx | 63 +- webui/src/components/MarkdownText.tsx | 11 +- webui/src/components/MarkdownTextRenderer.tsx | 50 +- webui/src/components/MessageBubble.tsx | 19 +- .../src/components/settings/SettingsView.tsx | 707 +++++++++++++----- .../thread/AgentActivityCluster.tsx | 19 +- webui/src/components/thread/PromptRail.tsx | 68 +- .../src/components/thread/ThreadComposer.tsx | 71 +- webui/src/components/thread/ThreadHeader.tsx | 9 +- .../src/components/thread/ThreadMessages.tsx | 15 +- webui/src/components/thread/ThreadShell.tsx | 219 +++++- .../src/components/thread/ThreadViewport.tsx | 3 + .../components/thread/WorkspaceControls.tsx | 4 +- .../thread/activity/FileEditRow.tsx | 24 +- .../thread/activity/ReasoningRow.tsx | 3 + webui/src/globals.css | 12 +- webui/src/hooks/useNanobotStream.ts | 155 +++- webui/src/i18n/locales/en/common.json | 39 + webui/src/i18n/locales/es/common.json | 39 + webui/src/i18n/locales/fr/common.json | 39 + webui/src/i18n/locales/id/common.json | 39 + webui/src/i18n/locales/ja/common.json | 39 + webui/src/i18n/locales/ko/common.json | 39 + webui/src/i18n/locales/vi/common.json | 39 + webui/src/i18n/locales/zh-CN/common.json | 39 + webui/src/i18n/locales/zh-TW/common.json | 39 + webui/src/lib/activity-timeline.ts | 79 +- webui/src/lib/api.ts | 29 + webui/src/lib/nanobot-client.ts | 2 + webui/src/lib/types.ts | 88 ++- webui/src/tests/api.test.ts | 25 + webui/src/tests/app-layout.test.tsx | 56 +- webui/src/tests/code-block.test.tsx | 19 + .../src/tests/markdown-text-renderer.test.tsx | 41 +- webui/src/tests/nanobot-client.test.ts | 18 + webui/src/tests/settings-view.test.tsx | 325 +++++++- webui/src/tests/thread-messages.test.tsx | 183 ++++- webui/src/tests/thread-shell.test.tsx | 79 +- webui/src/tests/thread-viewport.test.tsx | 36 + webui/src/tests/useNanobotStream.test.tsx | 7 +- 43 files changed, 3021 insertions(+), 434 deletions(-) create mode 100644 webui/src/components/FilePreviewPanel.tsx diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 8fa428a7b..4a104838d 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Menu, Moon, Sun } from "lucide-react"; +import { Moon, PanelLeft, Sun } from "lucide-react"; import { useTranslation } from "react-i18next"; import { DeleteConfirm } from "@/components/DeleteConfirm"; import { RenameChatDialog } from "@/components/RenameChatDialog"; @@ -264,11 +264,17 @@ function normalizeWorkspaceScope(scope: WorkspaceScopePayload): WorkspaceScopePa function HostChrome({ onToggleSidebar, + onSidebarPreviewEnter, + onSidebarPreviewLeave, + sidebarOpen = true, theme, onToggleTheme, showThemeButton = true, }: { onToggleSidebar?: () => void; + onSidebarPreviewEnter?: () => void; + onSidebarPreviewLeave?: () => void; + sidebarOpen?: boolean; theme: "light" | "dark"; onToggleTheme: () => void; showThemeButton?: boolean; @@ -276,21 +282,24 @@ function HostChrome({ const { t } = useTranslation(); return ( -
-
- {onToggleSidebar ? ( - - ) : null} -
+
+ {onToggleSidebar ? ( + + ) : null} {showThemeButton ? ( ) : ( -
+ null )}
); @@ -532,6 +541,7 @@ function Shell({ useState(initialRouteRef.current.settingsSection); const [hostSidebarOpen, setHostSidebarOpen] = useState(readSidebarOpen); + const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [sessionSearchOpen, setSessionSearchOpen] = useState(false); const [pendingDelete, setPendingDelete] = useState<{ @@ -560,6 +570,11 @@ function Shell({ useState>({}); const runningChatIdsRef = useRef>(new Set()); const activeChatIdRef = useRef(null); + const hostSidebarPreviewCloseTimerRef = useRef(null); + const effectiveRuntimeSurface = + settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface; + const showHostChrome = effectiveRuntimeSurface === "native"; + const showMainSidebar = view !== "settings"; const navigate = useCallback( (route: ShellRoute, options?: { replace?: boolean }) => { @@ -745,13 +760,74 @@ function Shell({ }); }, [client, loading, sessions]); - const closeHostSidebar = useCallback(() => { - setHostSidebarOpen(false); + const clearHostSidebarPreviewCloseTimer = useCallback(() => { + if (hostSidebarPreviewCloseTimerRef.current === null) return; + 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(() => { + closeHostSidebarPreview(); setHostSidebarOpen(true); - }, []); + }, [closeHostSidebarPreview]); + + const toggleHostSidebar = useCallback(() => { + closeHostSidebarPreview(); + setHostSidebarOpen((v) => !v); + }, [closeHostSidebarPreview]); const closeMobileSidebar = useCallback(() => { setMobileSidebarOpen(false); @@ -762,11 +838,12 @@ 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) => { @@ -1041,6 +1118,10 @@ function Shell({ setMobileSidebarOpen(false); }, [activeKey, navigate]); + const onOpenModelSettings = useCallback(() => { + onOpenSettings("models"); + }, [onOpenSettings]); + const onOpenApps = useCallback(() => { setSessionSearchOpen(false); navigate({ view: "apps", activeKey, settingsSection: "apps" }); @@ -1238,11 +1319,13 @@ function Shell({ archivedCount: sidebarState.archived_keys.length, defaultWorkspacePath: workspaces?.default_scope.project_path ?? null, }; - const effectiveRuntimeSurface = - settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface; - const isNativeHostSetupSurface = effectiveRuntimeSurface === "native"; - const showHostChrome = isNativeHostSetupSurface; - const showMainSidebar = view !== "settings"; + 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; useEffect(() => { document.documentElement.classList.toggle("native-host", showHostChrome); @@ -1261,7 +1344,10 @@ function Shell({ > {showHostChrome ? ( @@ -1274,25 +1360,47 @@ function Shell({ {/* Host sidebar: in normal flow, so the thread area width stays honest. */} {showMainSidebar ? ( + ) : null} + + {showHostSidebarPreview ? ( +