diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 0b209f347..a041c24d9 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -1947,9 +1947,10 @@ function Shell({ const sidebarProps = { sessions: regularSessions, - activeKey, + activeKey: view === "chat" ? activeKey : null, loading, - quickChatActive, + quickChatActive: view === "chat" && quickChatActive, + newChatActive: view === "chat" && activeKey === null, onOpenQuickChat, onNewChat, onSelect: onSelectChat, diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx index f2a33b476..98490e5a2 100644 --- a/webui/src/components/ChatList.tsx +++ b/webui/src/components/ChatList.tsx @@ -1,7 +1,6 @@ import { memo, useEffect, - useLayoutEffect, useMemo, useRef, useState, @@ -25,6 +24,10 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { + SIDEBAR_SELECTION_ITEM_CLASS, + SidebarSelectionHighlight, +} from "@/components/SidebarSelectionHighlight"; import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format"; import { COLLAPSED_CHATS_VISIBLE_COUNT, @@ -106,9 +109,6 @@ export const ChatList = memo(function ChatList({ const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS); const listContentRef = useRef(null); const activeRowRef = useRef(null); - const activeHighlightRef = useRef(null); - const activeHighlightSurfaceRef = useRef(null); - const highlightVisibleRef = useRef(false); const labels = useMemo(() => ({ pinned: t("chat.groups.pinned"), all: t("chat.groups.all"), @@ -163,74 +163,6 @@ export const ChatList = memo(function ChatList({ setVisibleLimit(INITIAL_VISIBLE_SESSIONS); }, [showArchived, sort]); - useLayoutEffect(() => { - let resetTransitionFrame: number | null = null; - - const updateHighlight = () => { - const content = listContentRef.current; - const row = activeRowRef.current; - const highlight = activeHighlightRef.current; - const surface = activeHighlightSurfaceRef.current; - - if (!highlight || !surface) return; - if (!content || !row) { - surface.style.opacity = "0"; - surface.style.transform = "scale(0.97)"; - highlightVisibleRef.current = false; - return; - } - - const shouldFloatIn = !highlightVisibleRef.current; - if (shouldFloatIn) { - highlight.style.transitionProperty = "none"; - } - - const contentRect = content.getBoundingClientRect(); - const rowRect = row.getBoundingClientRect(); - highlight.style.width = `${rowRect.width}px`; - highlight.style.height = `${rowRect.height}px`; - highlight.style.transform = `translate3d(${rowRect.left - contentRect.left}px, ${ - rowRect.top - contentRect.top - }px, 0)`; - - if (shouldFloatIn) { - void highlight.offsetWidth; - } - - surface.style.opacity = "1"; - surface.style.transform = "scale(1)"; - highlightVisibleRef.current = true; - - if (shouldFloatIn) { - resetTransitionFrame = window.requestAnimationFrame(() => { - highlight.style.removeProperty("transition-property"); - resetTransitionFrame = null; - }); - } - }; - - updateHighlight(); - - const resizeObserver = - typeof ResizeObserver === "undefined" - ? null - : new ResizeObserver(updateHighlight); - if (resizeObserver) { - if (listContentRef.current) resizeObserver.observe(listContentRef.current); - if (activeRowRef.current) resizeObserver.observe(activeRowRef.current); - } - window.addEventListener("resize", updateHighlight); - - return () => { - if (resetTransitionFrame !== null) { - window.cancelAnimationFrame(resetTransitionFrame); - } - activeHighlightRef.current?.style.removeProperty("transition-property"); - resizeObserver?.disconnect(); - window.removeEventListener("resize", updateHighlight); - }; - }, [activeKey, density, limitedGroups, showPreviews, showTimestamps]); - if (loading && sessions.length === 0) { return (
@@ -333,7 +265,8 @@ export const ChatList = memo(function ChatList({ ref={active ? activeRowRef : undefined} data-chat-row={s.key} className={cn( - "group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px] transition-colors", + "group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px]", + SIDEBAR_SELECTION_ITEM_CLASS, compact ? "min-h-7" : "min-h-8", active ? "text-sidebar-accent-foreground" @@ -475,18 +408,12 @@ export const ChatList = memo(function ChatList({
) : null} - ); diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index 309257c48..c918c5cf1 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -1,4 +1,9 @@ -import { useState, type ReactNode } from "react"; +import { + type ReactNode, + type RefObject, + useRef, + useState, +} from "react"; import { Archive, Brain, @@ -14,6 +19,10 @@ import { useTranslation } from "react-i18next"; import { ChatList } from "@/components/ChatList"; import { ConnectionBadge } from "@/components/ConnectionBadge"; +import { + SIDEBAR_SELECTION_ACTION_ITEM_CLASS, + SidebarSelectionHighlight, +} from "@/components/SidebarSelectionHighlight"; import { Button } from "@/components/ui/button"; import type { ChatSummary, @@ -26,6 +35,7 @@ interface SidebarProps { activeKey: string | null; loading: boolean; quickChatActive: boolean; + newChatActive: boolean; onOpenQuickChat: () => void; onNewChat: () => void; onSelect: (key: string) => void; @@ -85,6 +95,15 @@ export function Sidebar(props: SidebarProps) { const collapsed = Boolean(props.collapsed); const toggleLabel = t("thread.header.toggleSidebar"); const newChatShortcut = newChatShortcutLabel(); + const actionListRef = useRef(null); + const activeActionRef = useRef(null); + const activeActionId = props.quickChatActive + ? "quick-chat" + : props.newChatActive + ? "new-chat" + : props.activeUtility + ? `utility:${props.activeUtility}` + : null; return ( diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 0782f8112..1da7fb105 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -357,11 +357,24 @@ describe("App layout", () => { const quickChatButton = within(sidebar).getByRole("button", { name: "Quick Chat", }); + const newTopicButton = within(sidebar).getByRole("button", { + name: "New topic", + }); + const actionHighlight = within(sidebar).getByTestId( + "actions-selection-highlight", + ); fireEvent.click(quickChatButton); expect(window.location.hash).toBe("#/quick-chat"); expect(quickChatButton).toHaveAttribute("aria-current", "page"); + expect(newTopicButton).not.toHaveAttribute("aria-current"); + expect(quickChatButton).not.toHaveClass("bg-sidebar-accent"); + expect(quickChatButton).toHaveClass("transition-[width,padding,color]"); + expect(actionHighlight).toHaveAttribute("data-active-id", "quick-chat"); + expect( + within(sidebar).queryByTestId("actions-selection-highlight-surface"), + ).not.toBeInTheDocument(); await waitFor(() => expect(fetch).toHaveBeenCalledWith( expect.stringContaining( @@ -373,6 +386,14 @@ describe("App layout", () => { expect(createChatSpy).not.toHaveBeenCalled(); expect(document.title).toBe("Quick Chat · nanobot"); expect(screen.getByText("What's on your mind?")).toBeInTheDocument(); + + fireEvent.click(newTopicButton); + + expect(window.location.hash).toBe("#/new"); + expect(newTopicButton).toHaveAttribute("aria-current", "page"); + expect(quickChatButton).not.toHaveAttribute("aria-current"); + expect(actionHighlight).toHaveAttribute("data-active-id", "new-chat"); + expect(within(sidebar).queryAllByRole("button", { current: "page" })).toHaveLength(1); }); it("restores Quick Chat before it has a persisted session", async () => { @@ -2208,16 +2229,41 @@ describe("App layout", () => { expect(window.location.hash).toBe("#/settings"); const settingsNav = screen.getByRole("navigation", { name: "Settings sections" }); - fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" })); + const overviewButton = within(settingsNav).getByRole("button", { + name: "Overview", + exact: true, + }); + const modelsButton = within(settingsNav).getByRole("button", { + name: "Models", + exact: true, + }); + const settingsHighlight = within(settingsNav).getByTestId( + "settings-selection-highlight", + ); + + expect(overviewButton).toHaveAttribute("aria-current", "page"); + expect(overviewButton).not.toHaveClass("bg-sidebar-accent"); + expect(overviewButton).toHaveClass("transition-[color]"); + expect(settingsHighlight).toHaveAttribute("data-active-id", "overview"); + + fireEvent.click(modelsButton); expect(await screen.findByText("Model presets")).toBeInTheDocument(); expect(screen.queryByRole("heading", { name: "Models" })).not.toBeInTheDocument(); expect(window.location.hash).toBe("#/settings?section=models"); + expect(modelsButton).toHaveAttribute("aria-current", "page"); + expect(settingsHighlight).toHaveAttribute("data-active-id", "models"); - fireEvent.click(within(settingsNav).getByRole("button", { name: "Voice" })); + const voiceButton = within(settingsNav).getByRole("button", { + name: "Voice", + exact: true, + }); + fireEvent.click(voiceButton); expect(await screen.findByRole("heading", { name: "Voice input" })).toBeInTheDocument(); expect(window.location.hash).toBe("#/settings?section=voice"); + expect(voiceButton).toHaveAttribute("aria-current", "page"); + expect(settingsHighlight).toHaveAttribute("data-active-id", "voice"); }); it("transitions between Apps and Skills without replacing the sidebar", async () => { @@ -2243,6 +2289,11 @@ describe("App layout", () => { "aria-current", "page", ); + expect(within(sidebar).getByTestId("actions-selection-highlight")).toHaveAttribute( + "data-active-id", + "utility:apps", + ); + expect(within(sidebar).queryAllByRole("button", { current: "page" })).toHaveLength(1); expect(screen.getByTestId("settings-section-transition")).toHaveAttribute( "data-settings-section", "apps", @@ -2270,6 +2321,10 @@ describe("App layout", () => { "aria-current", "page", ); + expect(within(sidebar).getByTestId("actions-selection-highlight")).toHaveAttribute( + "data-active-id", + "utility:skills", + ); expect(document.title).toBe("Skills · nanobot"); }); diff --git a/webui/src/tests/chat-list.test.tsx b/webui/src/tests/chat-list.test.tsx index 9afcf10da..4b2ecb86a 100644 --- a/webui/src/tests/chat-list.test.tsx +++ b/webui/src/tests/chat-list.test.tsx @@ -220,7 +220,7 @@ describe("ChatList", () => { expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument(); }); - it("floats a borderless highlight in, then slides it between selected topics", () => { + it("positions one background highlight, then slides it between selected topics", () => { let revealFrame: FrameRequestCallback | null = null; vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { revealFrame = callback; @@ -259,14 +259,15 @@ describe("ChatList", () => { />, ); - const highlight = screen.getByTestId("active-chat-highlight"); - const surface = screen.getByTestId("active-chat-highlight-surface"); - expect(surface).toHaveClass( + const highlight = screen.getByTestId("sessions-selection-highlight"); + expect(highlight).toHaveClass( "bg-sidebar-foreground/[0.055]", - "transition-[opacity,transform]", + "transition-[transform,width,height]", "motion-reduce:transition-none", ); - expect(surface).toHaveStyle("opacity: 0; transform: scale(0.97)"); + expect(highlight).toHaveStyle("opacity: 0"); + expect(screen.queryByTestId("sessions-selection-highlight-surface")) + .not.toBeInTheDocument(); rerender( { const activeButton = screen.getByTitle("Active topic"); expect(activeButton).toHaveAttribute("aria-current", "page"); + expect(activeButton.parentElement).toHaveClass("transition-[color]"); + expect(activeButton.parentElement).not.toHaveClass("transition-colors"); expect(activeButton.parentElement).not.toHaveClass( "bg-sidebar-accent", "shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]", ); - expect(highlight).toHaveClass( - "transition-[transform,width,height]", - "motion-reduce:transition-none", - ); expect(highlight).toHaveStyle( - "width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); transition-property: none", + "width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1; transition-property: none", ); - expect(surface).toHaveStyle("opacity: 1; transform: scale(1)"); revealFrame?.(0); expect(highlight.style.transitionProperty).toBe("");