mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 17:38:35 +00:00
refactor(webui): reuse sidebar selection highlight
This commit is contained in:
parent
e1894d6f0b
commit
0cb7dd5cc9
@ -1901,8 +1901,9 @@ function Shell({
|
|||||||
|
|
||||||
const sidebarProps = {
|
const sidebarProps = {
|
||||||
sessions,
|
sessions,
|
||||||
activeKey,
|
activeKey: view === "chat" ? activeKey : null,
|
||||||
loading,
|
loading,
|
||||||
|
newChatActive: view === "chat" && activeKey === null,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
onSelect: onSelectChat,
|
onSelect: onSelectChat,
|
||||||
onRequestDelete,
|
onRequestDelete,
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@ -25,6 +24,10 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
SIDEBAR_SELECTION_ITEM_CLASS,
|
||||||
|
SidebarSelectionHighlight,
|
||||||
|
} from "@/components/SidebarSelectionHighlight";
|
||||||
import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format";
|
import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format";
|
||||||
import {
|
import {
|
||||||
COLLAPSED_CHATS_VISIBLE_COUNT,
|
COLLAPSED_CHATS_VISIBLE_COUNT,
|
||||||
@ -106,9 +109,6 @@ export const ChatList = memo(function ChatList({
|
|||||||
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
|
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
|
||||||
const listContentRef = useRef<HTMLDivElement>(null);
|
const listContentRef = useRef<HTMLDivElement>(null);
|
||||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||||
const activeHighlightRef = useRef<HTMLDivElement>(null);
|
|
||||||
const activeHighlightSurfaceRef = useRef<HTMLDivElement>(null);
|
|
||||||
const highlightVisibleRef = useRef(false);
|
|
||||||
const labels = useMemo<ChatGroupLabels>(() => ({
|
const labels = useMemo<ChatGroupLabels>(() => ({
|
||||||
pinned: t("chat.groups.pinned"),
|
pinned: t("chat.groups.pinned"),
|
||||||
all: t("chat.groups.all"),
|
all: t("chat.groups.all"),
|
||||||
@ -163,74 +163,6 @@ export const ChatList = memo(function ChatList({
|
|||||||
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
|
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
|
||||||
}, [showArchived, sort]);
|
}, [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) {
|
if (loading && sessions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-6 text-[12px] text-muted-foreground">
|
<div className="px-3 py-6 text-[12px] text-muted-foreground">
|
||||||
@ -333,7 +265,8 @@ export const ChatList = memo(function ChatList({
|
|||||||
ref={active ? activeRowRef : undefined}
|
ref={active ? activeRowRef : undefined}
|
||||||
data-chat-row={s.key}
|
data-chat-row={s.key}
|
||||||
className={cn(
|
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",
|
compact ? "min-h-7" : "min-h-8",
|
||||||
active
|
active
|
||||||
? "text-sidebar-accent-foreground"
|
? "text-sidebar-accent-foreground"
|
||||||
@ -475,18 +408,12 @@ export const ChatList = memo(function ChatList({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
<SidebarSelectionHighlight
|
||||||
ref={activeHighlightRef}
|
containerRef={listContentRef}
|
||||||
data-testid="active-chat-highlight"
|
targetRef={activeRowRef}
|
||||||
aria-hidden="true"
|
activeId={activeKey}
|
||||||
className="pointer-events-none absolute left-0 top-0 z-0 !mt-0 transition-[transform,width,height] duration-300 ease-out will-change-transform motion-reduce:transition-none"
|
scope="sessions"
|
||||||
>
|
/>
|
||||||
<div
|
|
||||||
ref={activeHighlightSurfaceRef}
|
|
||||||
data-testid="active-chat-highlight-surface"
|
|
||||||
className="h-full w-full scale-[0.97] rounded-xl bg-sidebar-foreground/[0.055] opacity-0 transition-[opacity,transform] duration-200 ease-out motion-reduce:transition-none dark:bg-white/[0.07]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
import {
|
||||||
|
type ReactNode,
|
||||||
|
type RefObject,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
Brain,
|
Brain,
|
||||||
@ -13,6 +18,10 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { ChatList } from "@/components/ChatList";
|
import { ChatList } from "@/components/ChatList";
|
||||||
import { ConnectionBadge } from "@/components/ConnectionBadge";
|
import { ConnectionBadge } from "@/components/ConnectionBadge";
|
||||||
|
import {
|
||||||
|
SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
|
||||||
|
SidebarSelectionHighlight,
|
||||||
|
} from "@/components/SidebarSelectionHighlight";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type {
|
import type {
|
||||||
ChatSummary,
|
ChatSummary,
|
||||||
@ -24,6 +33,7 @@ interface SidebarProps {
|
|||||||
sessions: ChatSummary[];
|
sessions: ChatSummary[];
|
||||||
activeKey: string | null;
|
activeKey: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
newChatActive: boolean;
|
||||||
onNewChat: () => void;
|
onNewChat: () => void;
|
||||||
onSelect: (key: string) => void;
|
onSelect: (key: string) => void;
|
||||||
onRequestDelete: (key: string, label: string) => void;
|
onRequestDelete: (key: string, label: string) => void;
|
||||||
@ -82,6 +92,13 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
const collapsed = Boolean(props.collapsed);
|
const collapsed = Boolean(props.collapsed);
|
||||||
const toggleLabel = t("thread.header.toggleSidebar");
|
const toggleLabel = t("thread.header.toggleSidebar");
|
||||||
const newChatShortcut = newChatShortcutLabel();
|
const newChatShortcut = newChatShortcutLabel();
|
||||||
|
const actionListRef = useRef<HTMLDivElement>(null);
|
||||||
|
const activeActionRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const activeActionId = props.newChatActive
|
||||||
|
? "new-chat"
|
||||||
|
: props.activeUtility
|
||||||
|
? `utility:${props.activeUtility}`
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
@ -134,8 +151,9 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
ref={actionListRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"space-y-1.5 px-2 pb-2",
|
"relative space-y-1.5 px-2 pb-2",
|
||||||
collapsed && "flex w-14 flex-col items-center px-0",
|
collapsed && "flex w-14 flex-col items-center px-0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -143,6 +161,8 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
label={t("sidebar.newChat")}
|
label={t("sidebar.newChat")}
|
||||||
onClick={props.onNewChat}
|
onClick={props.onNewChat}
|
||||||
|
active={props.newChatActive}
|
||||||
|
selectionRef={activeActionRef}
|
||||||
icon={<SquarePen className="h-4 w-4" />}
|
icon={<SquarePen className="h-4 w-4" />}
|
||||||
shortcut={newChatShortcut}
|
shortcut={newChatShortcut}
|
||||||
ariaKeyShortcuts="Meta+Shift+O Control+Shift+O"
|
ariaKeyShortcuts="Meta+Shift+O Control+Shift+O"
|
||||||
@ -159,6 +179,7 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
onClick={props.onOpenApps}
|
onClick={props.onOpenApps}
|
||||||
onIntent={props.onSettingsIntent}
|
onIntent={props.onSettingsIntent}
|
||||||
active={props.activeUtility === "apps"}
|
active={props.activeUtility === "apps"}
|
||||||
|
selectionRef={activeActionRef}
|
||||||
icon={<Blocks className="h-4 w-4" />}
|
icon={<Blocks className="h-4 w-4" />}
|
||||||
/>
|
/>
|
||||||
<SidebarActionButton
|
<SidebarActionButton
|
||||||
@ -167,6 +188,7 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
onClick={props.onOpenSkills}
|
onClick={props.onOpenSkills}
|
||||||
onIntent={props.onSettingsIntent}
|
onIntent={props.onSettingsIntent}
|
||||||
active={props.activeUtility === "skills"}
|
active={props.activeUtility === "skills"}
|
||||||
|
selectionRef={activeActionRef}
|
||||||
icon={<Brain className="h-4 w-4" />}
|
icon={<Brain className="h-4 w-4" />}
|
||||||
/>
|
/>
|
||||||
<SidebarActionButton
|
<SidebarActionButton
|
||||||
@ -175,6 +197,7 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
onClick={props.onOpenAutomations}
|
onClick={props.onOpenAutomations}
|
||||||
onIntent={props.onSettingsIntent}
|
onIntent={props.onSettingsIntent}
|
||||||
active={props.activeUtility === "automations"}
|
active={props.activeUtility === "automations"}
|
||||||
|
selectionRef={activeActionRef}
|
||||||
icon={<CalendarClock className="h-4 w-4" />}
|
icon={<CalendarClock className="h-4 w-4" />}
|
||||||
/>
|
/>
|
||||||
{props.archivedCount ? (
|
{props.archivedCount ? (
|
||||||
@ -185,6 +208,12 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
icon={<Archive className="h-4 w-4" />}
|
icon={<Archive className="h-4 w-4" />}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
<SidebarSelectionHighlight
|
||||||
|
containerRef={actionListRef}
|
||||||
|
targetRef={activeActionRef}
|
||||||
|
activeId={activeActionId}
|
||||||
|
scope="actions"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -255,6 +284,7 @@ function SidebarActionButton({
|
|||||||
shortcut,
|
shortcut,
|
||||||
ariaKeyShortcuts,
|
ariaKeyShortcuts,
|
||||||
onIntent,
|
onIntent,
|
||||||
|
selectionRef,
|
||||||
}: {
|
}: {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
@ -265,13 +295,15 @@ function SidebarActionButton({
|
|||||||
shortcut?: string;
|
shortcut?: string;
|
||||||
ariaKeyShortcuts?: string;
|
ariaKeyShortcuts?: string;
|
||||||
onIntent?: () => void;
|
onIntent?: () => void;
|
||||||
|
selectionRef?: RefObject<HTMLButtonElement>;
|
||||||
}) {
|
}) {
|
||||||
const title = shortcut ? `${label} (${shortcut})` : collapsed ? label : undefined;
|
const title = shortcut ? `${label} (${shortcut})` : collapsed ? label : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
ref={active ? selectionRef : undefined}
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant={null}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
aria-keyshortcuts={ariaKeyShortcuts}
|
aria-keyshortcuts={ariaKeyShortcuts}
|
||||||
@ -280,12 +312,14 @@ function SidebarActionButton({
|
|||||||
onFocus={onIntent}
|
onFocus={onIntent}
|
||||||
onPointerEnter={onIntent}
|
onPointerEnter={onIntent}
|
||||||
className={cn(
|
className={cn(
|
||||||
"touch-target group h-8 min-w-0 gap-2 overflow-hidden rounded-full font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
|
"touch-target group h-8 min-w-0 gap-2 overflow-hidden rounded-xl font-medium",
|
||||||
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out",
|
SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
|
||||||
collapsed
|
collapsed
|
||||||
? "w-9 justify-center gap-0 rounded-xl px-0"
|
? "w-9 justify-center gap-0 px-0"
|
||||||
: "w-full justify-start gap-2 px-3 text-[12.5px]",
|
: "w-full justify-start gap-2 px-3 text-[12.5px]",
|
||||||
active && "bg-sidebar-accent text-sidebar-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
|
active
|
||||||
|
? "text-sidebar-accent-foreground"
|
||||||
|
: "text-sidebar-foreground/85 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
90
webui/src/components/SidebarSelectionHighlight.tsx
Normal file
90
webui/src/components/SidebarSelectionHighlight.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import {
|
||||||
|
type RefObject,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
interface SidebarSelectionHighlightProps {
|
||||||
|
containerRef: RefObject<HTMLElement>;
|
||||||
|
targetRef: RefObject<HTMLElement>;
|
||||||
|
activeId: string | null;
|
||||||
|
scope: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SIDEBAR_SELECTION_ITEM_CLASS =
|
||||||
|
"relative z-[1] transition-[color] duration-150 ease-out motion-reduce:transition-none";
|
||||||
|
|
||||||
|
export const SIDEBAR_SELECTION_ACTION_ITEM_CLASS =
|
||||||
|
"relative z-[1] transition-[width,padding,color] [transition-duration:300ms,300ms,150ms] ease-out motion-reduce:transition-none";
|
||||||
|
|
||||||
|
export function SidebarSelectionHighlight({
|
||||||
|
containerRef,
|
||||||
|
targetRef,
|
||||||
|
activeId,
|
||||||
|
scope,
|
||||||
|
}: SidebarSelectionHighlightProps) {
|
||||||
|
const highlightRef = useRef<HTMLDivElement>(null);
|
||||||
|
const positionedRef = useRef(false);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const highlight = highlightRef.current;
|
||||||
|
const container = containerRef.current;
|
||||||
|
const target = targetRef.current;
|
||||||
|
let restoreTransitionFrame: number | null = null;
|
||||||
|
|
||||||
|
const position = () => {
|
||||||
|
if (!highlight) return;
|
||||||
|
if (!activeId || !container || !target) {
|
||||||
|
highlight.style.opacity = "0";
|
||||||
|
positionedRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstPosition = !positionedRef.current;
|
||||||
|
if (firstPosition) highlight.style.transitionProperty = "none";
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const targetRect = target.getBoundingClientRect();
|
||||||
|
highlight.style.width = `${targetRect.width}px`;
|
||||||
|
highlight.style.height = `${targetRect.height}px`;
|
||||||
|
highlight.style.transform = `translate3d(${targetRect.left - containerRect.left}px, ${
|
||||||
|
targetRect.top - containerRect.top
|
||||||
|
}px, 0)`;
|
||||||
|
highlight.style.opacity = "1";
|
||||||
|
positionedRef.current = true;
|
||||||
|
|
||||||
|
if (firstPosition) {
|
||||||
|
restoreTransitionFrame = window.requestAnimationFrame(() => {
|
||||||
|
highlight.style.removeProperty("transition-property");
|
||||||
|
restoreTransitionFrame = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
position();
|
||||||
|
const resizeObserver =
|
||||||
|
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(position);
|
||||||
|
if (container) resizeObserver?.observe(container);
|
||||||
|
if (target) resizeObserver?.observe(target);
|
||||||
|
window.addEventListener("resize", position);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (restoreTransitionFrame !== null) {
|
||||||
|
window.cancelAnimationFrame(restoreTransitionFrame);
|
||||||
|
}
|
||||||
|
highlight?.style.removeProperty("transition-property");
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
window.removeEventListener("resize", position);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={highlightRef}
|
||||||
|
data-testid={`${scope}-selection-highlight`}
|
||||||
|
data-active-id={activeId ?? undefined}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute left-0 top-0 z-0 !mt-0 rounded-xl bg-sidebar-foreground/[0.055] opacity-0 transition-[transform,width,height] duration-300 ease-out will-change-transform motion-reduce:transition-none dark:bg-white/[0.07]"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -65,6 +65,10 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { channelUiPresentation } from "@/channel-plugins/registry";
|
import { channelUiPresentation } from "@/channel-plugins/registry";
|
||||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||||
|
import {
|
||||||
|
SIDEBAR_SELECTION_ITEM_CLASS,
|
||||||
|
SidebarSelectionHighlight,
|
||||||
|
} from "@/components/SidebarSelectionHighlight";
|
||||||
import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings";
|
import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings";
|
||||||
import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap";
|
import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap";
|
||||||
import { ToggleButton } from "@/components/settings/ToggleButton";
|
import { ToggleButton } from "@/components/settings/ToggleButton";
|
||||||
@ -2497,6 +2501,8 @@ function SettingsSidebar({
|
|||||||
hostChromeInset?: boolean;
|
hostChromeInset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const desktopNavRef = useRef<HTMLDivElement>(null);
|
||||||
|
const activeNavItemRef = useRef<HTMLButtonElement>(null);
|
||||||
const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection)
|
const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection)
|
||||||
?? SETTINGS_NAV_ITEMS[0];
|
?? SETTINGS_NAV_ITEMS[0];
|
||||||
const ActiveIcon = activeItem.icon;
|
const ActiveIcon = activeItem.icon;
|
||||||
@ -2569,19 +2575,21 @@ function SettingsSidebar({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
<div className="hidden space-y-1 lg:block">
|
<div ref={desktopNavRef} className="relative hidden space-y-1 lg:block">
|
||||||
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
|
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
|
||||||
const active = key === activeSection;
|
const active = key === activeSection;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
ref={active ? activeNavItemRef : undefined}
|
||||||
key={key}
|
key={key}
|
||||||
type="button"
|
type="button"
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
onClick={() => onSelectSection(key)}
|
onClick={() => onSelectSection(key)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"touch-target flex h-9 w-full items-center gap-2 rounded-[10px] px-2.5 text-left text-[13px] font-medium transition-colors",
|
"touch-target flex h-9 w-full items-center gap-2 rounded-xl px-2.5 text-left text-[13px] font-medium",
|
||||||
|
SIDEBAR_SELECTION_ITEM_CLASS,
|
||||||
active
|
active
|
||||||
? "bg-sidebar-accent text-foreground"
|
? "text-sidebar-accent-foreground"
|
||||||
: "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground",
|
: "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -2592,6 +2600,12 @@ function SettingsSidebar({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<SidebarSelectionHighlight
|
||||||
|
containerRef={desktopNavRef}
|
||||||
|
targetRef={activeNavItemRef}
|
||||||
|
activeId={activeSection}
|
||||||
|
scope="settings"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@ -349,6 +349,22 @@ describe("App layout", () => {
|
|||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("highlights the blank new-topic destination immediately", async () => {
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||||
|
const newTopicButton = within(sidebar).getByRole("button", { name: "New topic" });
|
||||||
|
|
||||||
|
expect(newTopicButton).toHaveAttribute("aria-current", "page");
|
||||||
|
expect(newTopicButton).not.toHaveClass("bg-sidebar-accent");
|
||||||
|
expect(newTopicButton).toHaveClass("transition-[width,padding,color]");
|
||||||
|
expect(within(sidebar).getByTestId("actions-selection-highlight")).toHaveAttribute(
|
||||||
|
"data-active-id",
|
||||||
|
"new-chat",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("restores the Settings route after a restart fallback hash", async () => {
|
it("restores the Settings route after a restart fallback hash", async () => {
|
||||||
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
|
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
|
||||||
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
|
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
|
||||||
@ -2128,16 +2144,41 @@ describe("App layout", () => {
|
|||||||
expect(window.location.hash).toBe("#/settings");
|
expect(window.location.hash).toBe("#/settings");
|
||||||
|
|
||||||
const settingsNav = screen.getByRole("navigation", { name: "Settings sections" });
|
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(await screen.findByText("Model presets")).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("heading", { name: "Models" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("heading", { name: "Models" })).not.toBeInTheDocument();
|
||||||
expect(window.location.hash).toBe("#/settings?section=models");
|
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(await screen.findByRole("heading", { name: "Voice input" })).toBeInTheDocument();
|
||||||
expect(window.location.hash).toBe("#/settings?section=voice");
|
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 () => {
|
it("transitions between Apps and Skills without replacing the sidebar", async () => {
|
||||||
@ -2163,6 +2204,11 @@ describe("App layout", () => {
|
|||||||
"aria-current",
|
"aria-current",
|
||||||
"page",
|
"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(
|
expect(screen.getByTestId("settings-section-transition")).toHaveAttribute(
|
||||||
"data-settings-section",
|
"data-settings-section",
|
||||||
"apps",
|
"apps",
|
||||||
@ -2190,6 +2236,10 @@ describe("App layout", () => {
|
|||||||
"aria-current",
|
"aria-current",
|
||||||
"page",
|
"page",
|
||||||
);
|
);
|
||||||
|
expect(within(sidebar).getByTestId("actions-selection-highlight")).toHaveAttribute(
|
||||||
|
"data-active-id",
|
||||||
|
"utility:skills",
|
||||||
|
);
|
||||||
expect(document.title).toBe("Skills · nanobot");
|
expect(document.title).toBe("Skills · nanobot");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -220,7 +220,7 @@ describe("ChatList", () => {
|
|||||||
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
|
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;
|
let revealFrame: FrameRequestCallback | null = null;
|
||||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
||||||
revealFrame = callback;
|
revealFrame = callback;
|
||||||
@ -259,14 +259,15 @@ describe("ChatList", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const highlight = screen.getByTestId("active-chat-highlight");
|
const highlight = screen.getByTestId("sessions-selection-highlight");
|
||||||
const surface = screen.getByTestId("active-chat-highlight-surface");
|
expect(highlight).toHaveClass(
|
||||||
expect(surface).toHaveClass(
|
|
||||||
"bg-sidebar-foreground/[0.055]",
|
"bg-sidebar-foreground/[0.055]",
|
||||||
"transition-[opacity,transform]",
|
"transition-[transform,width,height]",
|
||||||
"motion-reduce:transition-none",
|
"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(
|
rerender(
|
||||||
<ChatList
|
<ChatList
|
||||||
@ -277,6 +278,8 @@ describe("ChatList", () => {
|
|||||||
|
|
||||||
const activeButton = screen.getByTitle("Active topic");
|
const activeButton = screen.getByTitle("Active topic");
|
||||||
expect(activeButton).toHaveAttribute("aria-current", "page");
|
expect(activeButton).toHaveAttribute("aria-current", "page");
|
||||||
|
expect(activeButton.parentElement).toHaveClass("transition-[color]");
|
||||||
|
expect(activeButton.parentElement).not.toHaveClass("transition-colors");
|
||||||
expect(activeButton.parentElement).not.toHaveClass(
|
expect(activeButton.parentElement).not.toHaveClass(
|
||||||
"bg-sidebar-accent",
|
"bg-sidebar-accent",
|
||||||
"shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
|
"shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
|
||||||
@ -286,9 +289,8 @@ describe("ChatList", () => {
|
|||||||
"motion-reduce:transition-none",
|
"motion-reduce:transition-none",
|
||||||
);
|
);
|
||||||
expect(highlight).toHaveStyle(
|
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);
|
revealFrame?.(0);
|
||||||
expect(highlight.style.transitionProperty).toBe("");
|
expect(highlight.style.transitionProperty).toBe("");
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user