refactor(sidebar): streamline conversation list with completed filter and expand toggle
Replace flat/grouped view toggle with a show-completed filter dropdown, add expand/collapse-all control, extract status icon component, and simplify the sidebar header.
This commit is contained in:
@@ -1,29 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import {
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Crosshair,
|
||||
FolderPlus,
|
||||
FolderTree,
|
||||
Plus,
|
||||
Rows3,
|
||||
Search,
|
||||
X,
|
||||
EllipsisVertical,
|
||||
} from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { useActiveFolder } from "@/contexts/active-folder-context"
|
||||
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
||||
import { useTabContext } from "@/contexts/tab-context"
|
||||
import { useSidebarContext } from "@/contexts/sidebar-context"
|
||||
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
||||
import {
|
||||
SidebarConversationList,
|
||||
type SidebarConversationListHandle,
|
||||
} from "@/components/conversations/sidebar-conversation-list"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -31,165 +28,76 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { isDesktop, openFileDialog } from "@/lib/platform"
|
||||
import {
|
||||
loadSidebarViewMode,
|
||||
saveSidebarViewMode,
|
||||
type SidebarViewMode,
|
||||
loadShowCompleted,
|
||||
saveShowCompleted,
|
||||
} from "@/lib/sidebar-view-mode-storage"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function Sidebar() {
|
||||
const t = useTranslations("Folder.sidebar")
|
||||
const { activeFolder } = useActiveFolder()
|
||||
const { allFolders, conversations, openFolder } = useAppWorkspace()
|
||||
const { openNewConversationTab } = useTabContext()
|
||||
const { isOpen, toggle } = useSidebarContext()
|
||||
const { conversations } = useAppWorkspace()
|
||||
const isMobile = useIsMobile()
|
||||
const listRef = useRef<SidebarConversationListHandle>(null)
|
||||
|
||||
const [viewMode, setViewMode] = useState<SidebarViewMode>("flat")
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [showCompleted, setShowCompleted] = useState(false)
|
||||
const [allExpanded, setAllExpanded] = useState(true)
|
||||
|
||||
const visibleCount = useMemo(() => {
|
||||
if (showCompleted) return conversations.length
|
||||
return conversations.filter(
|
||||
(c) => c.status !== "completed" && c.status !== "cancelled"
|
||||
).length
|
||||
}, [conversations, showCompleted])
|
||||
|
||||
useEffect(() => {
|
||||
// Hydrate from localStorage after mount to keep SSR/CSR markup consistent.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setViewMode(loadSidebarViewMode())
|
||||
setShowCompleted(loadShowCompleted())
|
||||
}, [])
|
||||
|
||||
const handleSetViewMode = useCallback((mode: SidebarViewMode) => {
|
||||
setViewMode(mode)
|
||||
saveSidebarViewMode(mode)
|
||||
const handleSetShowCompleted = useCallback((value: boolean) => {
|
||||
setShowCompleted(value)
|
||||
saveShowCompleted(value)
|
||||
}, [])
|
||||
|
||||
const handleToggleExpandAll = useCallback(() => {
|
||||
if (allExpanded) {
|
||||
listRef.current?.collapseAll()
|
||||
setAllExpanded(false)
|
||||
} else {
|
||||
listRef.current?.expandAll()
|
||||
setAllExpanded(true)
|
||||
}
|
||||
}, [allExpanded])
|
||||
|
||||
useEffect(() => {
|
||||
const onReveal = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ folderId: number }>).detail
|
||||
if (!detail) return
|
||||
if (viewMode !== "grouped") {
|
||||
setViewMode("grouped")
|
||||
saveSidebarViewMode("grouped")
|
||||
}
|
||||
listRef.current?.revealFolder(detail.folderId)
|
||||
}
|
||||
window.addEventListener("sidebar:reveal-folder", onReveal)
|
||||
return () => {
|
||||
window.removeEventListener("sidebar:reveal-folder", onReveal)
|
||||
}
|
||||
}, [viewMode])
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
if (!activeFolder) return
|
||||
openNewConversationTab(activeFolder.id, activeFolder.path)
|
||||
}, [activeFolder, openNewConversationTab])
|
||||
|
||||
const handleOpenFolder = useCallback(async () => {
|
||||
try {
|
||||
if (!isDesktop()) {
|
||||
toast.error(t("toasts.openFolderFailed"))
|
||||
return
|
||||
}
|
||||
const result = await openFileDialog({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
})
|
||||
if (!result) return
|
||||
const selected = Array.isArray(result) ? result[0] : result
|
||||
const detail = await openFolder(selected)
|
||||
toast.success(t("toasts.folderOpened", { name: detail.name }))
|
||||
} catch (err) {
|
||||
console.error("[Sidebar] open folder failed:", err)
|
||||
toast.error(t("toasts.openFolderFailed"))
|
||||
}
|
||||
}, [openFolder, t])
|
||||
}, [])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<aside className="group/sidebar flex h-full min-h-0 flex-col overflow-hidden bg-sidebar text-sidebar-foreground select-none">
|
||||
<aside className="flex h-full min-h-0 flex-col overflow-hidden bg-sidebar text-sidebar-foreground select-none">
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-2 gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0 text-[11px] text-muted-foreground tabular-nums">
|
||||
<span className="truncate">
|
||||
{t("statsLabel", {
|
||||
folders: allFolders.length,
|
||||
convos: conversations.length,
|
||||
})}
|
||||
<div className="flex h-10 shrink-0 items-center justify-between gap-2 pl-[1.25rem] pr-2">
|
||||
<div className="flex min-w-0 items-baseline gap-[0.375rem]">
|
||||
<h2 className="truncate text-[0.875rem] font-bold tracking-[-0.00625rem] text-sidebar-foreground">
|
||||
{t("title")}
|
||||
</h2>
|
||||
<span className="shrink-0 text-[0.75rem] tabular-nums text-muted-foreground/70">
|
||||
{t("conversationCountUnit", { count: visibleCount })}
|
||||
</span>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground"
|
||||
onClick={handleOpenFolder}
|
||||
>
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t("openFolder")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="h-7 pl-6 pr-6 text-xs"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground rounded-sm p-0.5"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-7 w-7 shrink-0 text-muted-foreground",
|
||||
viewMode === "flat" && "bg-accent text-foreground"
|
||||
)}
|
||||
onClick={() => handleSetViewMode("flat")}
|
||||
>
|
||||
<Rows3 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t("viewFlat")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-7 w-7 shrink-0 text-muted-foreground",
|
||||
viewMode === "grouped" && "bg-accent text-foreground"
|
||||
)}
|
||||
onClick={() => handleSetViewMode("grouped")}
|
||||
>
|
||||
<FolderTree className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t("viewGrouped")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-b border-border px-2 h-7">
|
||||
<h2 className="text-xs font-bold text-muted-foreground truncate">
|
||||
{t("title")}
|
||||
</h2>
|
||||
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover/sidebar:opacity-100">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -203,30 +111,43 @@ export function Sidebar() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground"
|
||||
onClick={() => listRef.current?.expandAll()}
|
||||
title={t("expandAllGroups")}
|
||||
onClick={handleToggleExpandAll}
|
||||
title={
|
||||
allExpanded ? t("collapseAllGroups") : t("expandAllGroups")
|
||||
}
|
||||
>
|
||||
<ChevronsUpDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground"
|
||||
onClick={() => listRef.current?.collapseAll()}
|
||||
title={t("collapseAllGroups")}
|
||||
>
|
||||
<ChevronsDownUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground"
|
||||
onClick={handleNewConversation}
|
||||
disabled={!activeFolder}
|
||||
title={t("newConversation")}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{allExpanded ? (
|
||||
<ChevronsDownUp className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronsUpDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground"
|
||||
>
|
||||
<EllipsisVertical className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t("moreOptions")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showCompleted}
|
||||
onCheckedChange={handleSetShowCompleted}
|
||||
>
|
||||
{t("showCompleted")}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
@@ -245,11 +166,7 @@ export function Sidebar() {
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SidebarConversationList
|
||||
ref={listRef}
|
||||
viewMode={viewMode}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
<SidebarConversationList ref={listRef} showCompleted={showCompleted} />
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user