From dea9cf1911a7fbe4493148c646637502e2d0e0a3 Mon Sep 17 00:00:00 2001 From: shine1i Date: Sun, 1 Feb 2026 07:50:53 +0100 Subject: [PATCH] refactor: migrate chat sidebar and UI components to modular sidebar framework, some minor UI tweaks (sidebar, lines) --- .../src/components/assistant-ui/thread.tsx | 2 +- frontend/src/components/ui/sidebar.tsx | 702 ++++++++++++++++++ frontend/src/features/chat/chat-page.tsx | 123 +-- .../src/features/chat/chat-settings-sheet.tsx | 31 +- frontend/src/features/chat/thread-sidebar.tsx | 150 ++-- frontend/src/hooks/use-mobile.ts | 19 + frontend/src/index.css | 31 + frontend/src/main.tsx | 7 + 8 files changed, 911 insertions(+), 154 deletions(-) create mode 100644 frontend/src/components/ui/sidebar.tsx create mode 100644 frontend/src/hooks/use-mobile.ts diff --git a/frontend/src/components/assistant-ui/thread.tsx b/frontend/src/components/assistant-ui/thread.tsx index d29967b445..6ec6022ff0 100644 --- a/frontend/src/components/assistant-ui/thread.tsx +++ b/frontend/src/components/assistant-ui/thread.tsx @@ -66,7 +66,7 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({ }} /> - + !thread.isEmpty}> {!hideComposer && } diff --git a/frontend/src/components/ui/sidebar.tsx b/frontend/src/components/ui/sidebar.tsx new file mode 100644 index 0000000000..6a4a6e5f89 --- /dev/null +++ b/frontend/src/components/ui/sidebar.tsx @@ -0,0 +1,702 @@ +"use client" + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { useIsMobile } from "@/hooks/use-mobile" +import { HugeiconsIcon } from "@hugeicons/react" +import { SidebarLeftIcon } from "@hugeicons/core-free-icons" + +const SIDEBAR_COOKIE_NAME = "sidebar_state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +type SidebarContextProps = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void +}) { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed" + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + +
+ {children} +
+
+ ) +} + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + dir, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) +} + +function SidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps) { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar() + + return ( + +
- {!settingsOpen && ( - - )} +
{view.mode === "single" ? ( @@ -256,10 +278,9 @@ export function ChatPage(): ReactElement { {/* inline settings panel on right */} setSettingsOpen(false)} params={inferenceParams} onParamsChange={setInferenceParams} /> - + ); } diff --git a/frontend/src/features/chat/chat-settings-sheet.tsx b/frontend/src/features/chat/chat-settings-sheet.tsx index 8fcc4cc042..0b006c4e78 100644 --- a/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/frontend/src/features/chat/chat-settings-sheet.tsx @@ -11,7 +11,6 @@ import { Slider } from "@/components/ui/slider"; import { Textarea } from "@/components/ui/textarea"; import { ArrowDown01Icon, - Cancel01Icon, Delete02Icon, EngineIcon, FloppyDiskIcon, @@ -21,7 +20,8 @@ import { } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { AnimatePresence, motion } from "motion/react"; -import { type ReactNode, useState } from "react"; +import type { ReactNode } from "react"; +import { useState } from "react"; // --- Types & defaults --- @@ -170,14 +170,12 @@ function CollapsibleSection({ interface ChatSettingsPanelProps { open: boolean; - onClose: () => void; params: InferenceParams; onParamsChange: (params: InferenceParams) => void; } export function ChatSettingsPanel({ open, - onClose, params, onParamsChange, }: ChatSettingsPanelProps) { @@ -219,15 +217,10 @@ export function ChatSettingsPanel({ } return ( - - {open && ( - + ); } diff --git a/frontend/src/features/chat/thread-sidebar.tsx b/frontend/src/features/chat/thread-sidebar.tsx index 8527704c7d..f1db67651c 100644 --- a/frontend/src/features/chat/thread-sidebar.tsx +++ b/frontend/src/features/chat/thread-sidebar.tsx @@ -1,14 +1,19 @@ import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + SidebarSeparator, +} from "@/components/ui/sidebar"; import { - ChatAdd01Icon, + ColumnInsertIcon, Delete02Icon, - Message02Icon, - MessageMultiple01Icon, + PencilEdit02Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { db, useLiveQuery } from "./db"; @@ -16,7 +21,7 @@ import type { ChatView, ThreadRecord } from "./types"; interface SidebarItem { type: "single" | "compare"; - id: string; // threadId for single, pairId for compare + id: string; title: string; createdAt: number; } @@ -69,7 +74,6 @@ export function ThreadSidebar({ [], ); const items = groupThreads(allThreads ?? []); - const activeId = view.mode === "single" ? view.threadId : view.pairId; function viewForItem(item: SidebarItem): ChatView { @@ -83,7 +87,10 @@ export function ThreadSidebar({ await db.messages.where("threadId").equals(item.id).delete(); await db.threads.delete(item.id); } else { - const paired = await db.threads.where("pairId").equals(item.id).toArray(); + const paired = await db.threads + .where("pairId") + .equals(item.id) + .toArray(); for (const t of paired) { await db.messages.where("threadId").equals(t.id).delete(); await db.threads.delete(t.id); @@ -96,74 +103,59 @@ export function ThreadSidebar({ return ( <> -
- - Chats - - - - - - - - - New Chat - - - - Compare Mode - - - -
-
- -
- {items.map((item) => ( -
onSelect(viewForItem(item))} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onSelect(viewForItem(item)); - } - }} - className={`group flex w-full cursor-pointer items-center gap-2 rounded-lg corner-squircle px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent ${ - activeId === item.id ? "bg-accent" : "" - }`} - > - {item.title} - -
- ))} - {items.length === 0 && ( -

- No threads yet -

- )} -
+ + Playground + + + + + + + + + New Chat + + + + + + Compare + + + + + + + + Your Chats + + + {items.map((item) => ( + + onSelect(viewForItem(item))} + > + {item.title} + + handleDelete(item)} + title="Delete" + > + + + + ))} + + {items.length === 0 && ( +

+ No threads yet +

+ )} +
+
+
); } diff --git a/frontend/src/hooks/use-mobile.ts b/frontend/src/hooks/use-mobile.ts new file mode 100644 index 0000000000..2b0fe1dfef --- /dev/null +++ b/frontend/src/hooks/use-mobile.ts @@ -0,0 +1,19 @@ +import * as React from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined) + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener("change", onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener("change", onChange) + }, []) + + return !!isMobile +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 53ae3aefea..3347ab0c1d 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -276,6 +276,37 @@ } } +/* Minimal scrollbar — thumb only, no track */ +* { + scrollbar-width: thin; + scrollbar-color: transparent transparent; +} +*:hover { + scrollbar-color: oklch(0.6 0 0 / 0.3) transparent; +} +.dark *:hover { + scrollbar-color: oklch(0.5 0 0 / 0.35) transparent; +} + +/* Webkit (Chrome, Safari, Edge) */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 9999px; +} +*:hover::-webkit-scrollbar-thumb { + background: oklch(0.6 0 0 / 0.3); +} +.dark *:hover::-webkit-scrollbar-thumb { + background: oklch(0.5 0 0 / 0.35); +} + /*---break---*/ @layer base { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index dc4ab59776..6b0b63b604 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,6 +1,13 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +if (!crypto.randomUUID) { + crypto.randomUUID = () => + "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => + (+c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4)))).toString(16), + ) as `${string}-${string}-${string}-${string}-${string}`; +} + import "./index.css"; import { App } from "./app/app";