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:
xintaofei
2026-04-21 17:07:40 +08:00
parent 58b48e2bfe
commit f0d530e1cb
15 changed files with 650 additions and 656 deletions

View File

@@ -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>
)