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

@@ -13,18 +13,12 @@ import {
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Virtualizer, type VirtualizerHandle } from "virtua"
import {
CheckCheck,
ChevronRight,
Download,
Loader2,
Plus,
XCircle,
} from "lucide-react"
import { ChevronRight, Download, Loader2, Plus, XCircle } from "lucide-react"
import { useActiveFolder } from "@/contexts/active-folder-context"
import { useAppWorkspace } from "@/contexts/app-workspace-context"
import { useTabContext } from "@/contexts/tab-context"
import { useTaskContext } from "@/contexts/task-context"
import { useZoomLevel } from "@/hooks/use-appearance"
import {
importLocalConversations,
updateConversationTitle,
@@ -32,13 +26,12 @@ import {
deleteConversation,
} from "@/lib/api"
import type { ConversationStatus, DbConversationSummary } from "@/lib/types"
import { STATUS_ORDER, STATUS_COLORS } from "@/lib/types"
import {
loadFolderExpanded,
saveFolderExpanded,
type SidebarViewMode,
} from "@/lib/sidebar-view-mode-storage"
import { SidebarConversationCard } from "./sidebar-conversation-card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { ScrollArea } from "@/components/ui/scroll-area"
@@ -81,6 +74,23 @@ function compareByUpdatedAtDesc(
return right.id - left.id
}
function formatRelative(iso: string): string {
const ts = parseTimestamp(iso)
if (!ts) return ""
const diff = Math.max(0, Date.now() - ts)
const m = Math.floor(diff / 60000)
if (m < 1) return "now"
if (m < 60) return `${m}m`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h`
const d = Math.floor(h / 24)
if (d < 30) return `${d}d`
const mo = Math.floor(d / 30)
if (mo < 12) return `${mo}mo`
const y = Math.floor(mo / 12)
return `${y}y`
}
type FlatItem =
| {
type: "folder_header"
@@ -90,15 +100,9 @@ type FlatItem =
count: number
expanded: boolean
}
| {
type: "status_header"
status: ConversationStatus
count: number
parentFolderId?: number
}
| { type: "conversation"; conversation: DbConversationSummary }
const CARD_HEIGHT = 62
const CARD_HEIGHT_REM = 2
const FolderHeader = memo(function FolderHeader({
folderId,
@@ -128,31 +132,49 @@ const FolderHeader = memo(function FolderHeader({
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<button
data-folder-id={folderId}
onClick={() => onToggle(folderId)}
className={cn(
"flex items-center gap-1.5 w-full px-1.5 py-1.5 text-xs font-medium cursor-pointer transition-all",
"text-foreground hover:bg-accent/50 rounded-sm",
highlighted && "ring-2 ring-primary ring-offset-1"
)}
>
<ChevronRight
<div className="relative h-[2rem]">
<button
data-folder-id={folderId}
onClick={() => onToggle(folderId)}
className={cn(
"h-3.5 w-3.5 shrink-0 transition-transform text-muted-foreground",
expanded && "rotate-90"
"flex h-[1.9375rem] w-full items-center gap-[0.5rem] cursor-pointer outline-none",
"rounded-[0.4375rem] px-[0.625rem]",
"text-sidebar-foreground hover:bg-[color-mix(in_oklab,var(--sidebar-accent),var(--sidebar-foreground)_2%)]",
"transition-[background-color,color] duration-150",
highlighted && "ring-2 ring-sidebar-primary ring-offset-1"
)}
/>
<span className="truncate flex-1 text-left">{folderName}</span>
{branch && (
<span className="text-[10px] text-muted-foreground truncate max-w-[80px]">
{branch}
>
<span
className={cn(
"flex h-[0.75rem] w-[0.75rem] shrink-0 items-center justify-center text-muted-foreground/75",
"transition-transform duration-[180ms] [transition-timing-function:cubic-bezier(.3,.7,.3,1)]",
expanded ? "rotate-90" : "rotate-0"
)}
>
<ChevronRight className="h-[0.625rem] w-[0.625rem]" />
</span>
)}
<span className="text-muted-foreground/60 tabular-nums text-[10px]">
({count})
</span>
</button>
<div className="flex min-w-0 flex-1 items-center gap-[0.375rem]">
<span className="min-w-0 flex-shrink truncate text-left text-[0.875rem] font-semibold tracking-[-0.00625rem]">
{folderName}
</span>
{branch && (
<Badge
variant="outline"
className={cn(
"h-[1rem] max-w-[6.875rem] gap-0 px-[0.375rem] py-0",
"text-[0.6875rem] font-medium leading-none tracking-[0.0125rem]",
"border-sidebar-border text-muted-foreground/80"
)}
>
<span className="truncate">{branch}</span>
</Badge>
)}
</div>
<span className="shrink-0 text-[0.75rem] font-medium tabular-nums text-muted-foreground/70">
{count}
</span>
</button>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => onFocus(folderId)}>
@@ -174,96 +196,6 @@ const FolderHeader = memo(function FolderHeader({
)
})
const StatusHeader = memo(function StatusHeader({
status,
count,
isOpen,
onToggle,
tStatus,
}: {
status: ConversationStatus
count: number
isOpen: boolean
onToggle: (status: ConversationStatus) => void
tStatus: ReturnType<typeof useTranslations>
}) {
return (
<button
onClick={() => onToggle(status)}
className="flex items-center gap-1.5 w-full px-1.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 shrink-0 transition-transform",
isOpen && "rotate-90"
)}
/>
<span
className={cn("w-2 h-2 rounded-full shrink-0", STATUS_COLORS[status])}
/>
<span>{tStatus(status)}</span>
<span className="text-muted-foreground/60 tabular-nums">({count})</span>
</button>
)
})
const PendingReviewHeader = memo(function PendingReviewHeader({
count,
isOpen,
onToggle,
reviewConversationCount,
completingReview,
onCompleteReview,
tStatus,
t,
}: {
count: number
isOpen: boolean
onToggle: (status: ConversationStatus) => void
reviewConversationCount: number
completingReview: boolean
onCompleteReview: () => void
tStatus: ReturnType<typeof useTranslations>
t: ReturnType<typeof useTranslations>
}) {
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<button
onClick={() => onToggle("pending_review")}
className="flex items-center gap-1.5 w-full px-1.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 shrink-0 transition-transform",
isOpen && "rotate-90"
)}
/>
<span
className={cn(
"w-2 h-2 rounded-full shrink-0",
STATUS_COLORS.pending_review
)}
/>
<span>{tStatus("pending_review")}</span>
<span className="text-muted-foreground/60 tabular-nums">
({count})
</span>
</button>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
disabled={reviewConversationCount === 0 || completingReview}
onSelect={onCompleteReview}
>
<CheckCheck className="h-4 w-4" />
{t("completeAllSessions")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})
export interface SidebarConversationListHandle {
scrollToActive: () => void
expandAll: () => void
@@ -272,20 +204,19 @@ export interface SidebarConversationListHandle {
}
export interface SidebarConversationListProps {
viewMode?: SidebarViewMode
searchQuery?: string
showCompleted?: boolean
}
export function SidebarConversationList({
ref,
viewMode = "flat",
searchQuery = "",
showCompleted = true,
}: SidebarConversationListProps & {
ref?: Ref<SidebarConversationListHandle>
}) {
const t = useTranslations("Folder.sidebar")
const tStatus = useTranslations("Folder.statusLabels")
const tCommon = useTranslations("Folder.common")
const { zoomLevel } = useZoomLevel()
const cardHeightPx = (CARD_HEIGHT_REM * 16 * zoomLevel) / 100
const {
allFolders,
conversations,
@@ -325,22 +256,13 @@ export function SidebarConversationList({
}, [tabs, activeTabId])
const [importing, setImporting] = useState(false)
const [completeReviewOpen, setCompleteReviewOpen] = useState(false)
const [completingReview, setCompletingReview] = useState(false)
const [groupExpanded, setGroupExpanded] = useState<
Record<ConversationStatus, boolean>
>({
in_progress: true,
pending_review: true,
completed: false,
cancelled: false,
})
const [folderExpanded, setFolderExpanded] = useState<Record<number, boolean>>(
{}
)
const [highlightedFolder, setHighlightedFolder] = useState<number | null>(
null
)
const [scrollOffset, setScrollOffset] = useState(0)
const [removeConfirm, setRemoveConfirm] = useState<{
folderId: number
folderName: string
@@ -357,53 +279,25 @@ export function SidebarConversationList({
const virtualizerRef = useRef<VirtualizerHandle>(null)
const highlightTimerRef = useRef<number | null>(null)
const normalizedSearch = searchQuery.trim().toLowerCase()
const filteredConversations = useMemo(() => {
if (!normalizedSearch) return conversations
return conversations.filter((c) => {
const title = (c.title ?? "").toLowerCase()
return title.includes(normalizedSearch)
})
}, [conversations, normalizedSearch])
if (showCompleted) return conversations
return conversations.filter(
(c) => c.status !== "completed" && c.status !== "cancelled"
)
}, [conversations, showCompleted])
const byStatus = useMemo(() => {
const map = new Map<ConversationStatus, DbConversationSummary[]>()
const byFolder = useMemo(() => {
const map = new Map<number, DbConversationSummary[]>()
for (const conv of filteredConversations) {
const status = conv.status as ConversationStatus
const list = map.get(status)
const list = map.get(conv.folder_id)
if (list) list.push(conv)
else map.set(status, [conv])
else map.set(conv.folder_id, [conv])
}
for (const list of map.values()) list.sort(compareByUpdatedAtDesc)
return map
}, [filteredConversations])
const byFolder = useMemo(() => {
const map = new Map<
number,
Map<ConversationStatus, DbConversationSummary[]>
>()
for (const conv of filteredConversations) {
const folderId = conv.folder_id
let inner = map.get(folderId)
if (!inner) {
inner = new Map<ConversationStatus, DbConversationSummary[]>()
map.set(folderId, inner)
}
const status = conv.status as ConversationStatus
const list = inner.get(status)
if (list) list.push(conv)
else inner.set(status, [conv])
}
for (const inner of map.values()) {
for (const list of inner.values()) list.sort(compareByUpdatedAtDesc)
}
return map
}, [filteredConversations])
const orderedFolderIds = useMemo(() => {
// Show every folder in the workspace DB, even ones without conversations.
// Folders that only have orphan conversations still appear via byFolder.
const seen = new Set<number>()
const ids: number[] = []
for (const f of allFolders) {
@@ -423,91 +317,83 @@ export function SidebarConversationList({
const flatItems = useMemo<FlatItem[]>(() => {
const items: FlatItem[] = []
if (viewMode === "grouped") {
for (const folderId of orderedFolderIds) {
const inner = byFolder.get(folderId)
const totalCount = inner
? Array.from(inner.values()).reduce(
(sum, list) => sum + list.length,
0
)
: 0
const folderName = folderIndex.get(folderId)?.name ?? String(folderId)
const branch = branches.get(folderId) ?? null
const expanded = folderExpanded[folderId] ?? true
items.push({
type: "folder_header",
folderId,
folderName,
branch,
count: totalCount,
expanded,
})
if (!expanded || !inner) continue
for (const status of STATUS_ORDER) {
const list = inner.get(status)
if (!list || list.length === 0) continue
items.push({
type: "status_header",
status,
count: list.length,
parentFolderId: folderId,
})
if (groupExpanded[status]) {
for (const conv of list) {
items.push({ type: "conversation", conversation: conv })
}
}
}
}
} else {
for (const status of STATUS_ORDER) {
const list = byStatus.get(status)
if (!list || list.length === 0) continue
items.push({ type: "status_header", status, count: list.length })
if (groupExpanded[status]) {
for (const conv of list) {
items.push({ type: "conversation", conversation: conv })
}
}
for (const folderId of orderedFolderIds) {
const list = byFolder.get(folderId) ?? []
const folderName = folderIndex.get(folderId)?.name ?? String(folderId)
const branch = branches.get(folderId) ?? null
const expanded = folderExpanded[folderId] ?? true
items.push({
type: "folder_header",
folderId,
folderName,
branch,
count: list.length,
expanded,
})
if (!expanded) continue
for (const conv of list) {
items.push({ type: "conversation", conversation: conv })
}
}
return items
}, [
viewMode,
orderedFolderIds,
byFolder,
folderIndex,
branches,
folderExpanded,
byStatus,
groupExpanded,
])
}, [orderedFolderIds, byFolder, folderIndex, branches, folderExpanded])
const reviewConversations = useMemo(
() => byStatus.get("pending_review") ?? [],
[byStatus]
)
const reviewConversationCount = reviewConversations.length
const stickyState = useMemo<{
folder: Extract<FlatItem, { type: "folder_header" }> | null
pushOffset: number
}>(() => {
const vr = virtualizerRef.current
const startIdx = vr ? vr.findItemIndex(scrollOffset) : 0
let folderIdx = -1
for (let i = Math.min(startIdx, flatItems.length - 1); i >= 0; i--) {
if (flatItems[i]?.type === "folder_header") {
folderIdx = i
break
}
}
if (folderIdx < 0) {
return { folder: null, pushOffset: 0 }
}
const folder = flatItems[folderIdx] as Extract<
FlatItem,
{ type: "folder_header" }
>
let pushOffset = 0
if (vr) {
const stickyHeight = vr.getItemSize(folderIdx) || cardHeightPx
for (let i = folderIdx + 1; i < flatItems.length; i++) {
if (flatItems[i].type === "folder_header") {
const nextRelativeY = vr.getItemOffset(i) - scrollOffset
if (nextRelativeY < stickyHeight) {
pushOffset = Math.min(0, nextRelativeY - stickyHeight)
}
break
}
}
}
return { folder, pushOffset }
}, [scrollOffset, flatItems, cardHeightPx])
const stickyFolderItem = stickyState.folder
useImperativeHandle(ref, () => ({
scrollToActive() {
scrollToActiveRef.current()
},
expandAll() {
setGroupExpanded({
in_progress: true,
pending_review: true,
completed: true,
cancelled: true,
setFolderExpanded((prev) => {
const next: Record<number, boolean> = { ...prev }
for (const id of orderedFolderIds) next[id] = true
saveFolderExpanded(next)
return next
})
},
collapseAll() {
setGroupExpanded({
in_progress: false,
pending_review: false,
completed: false,
cancelled: false,
setFolderExpanded((prev) => {
const next: Record<number, boolean> = { ...prev }
for (const id of orderedFolderIds) next[id] = false
saveFolderExpanded(next)
return next
})
},
revealFolder(folderId: number) {
@@ -556,13 +442,7 @@ export function SidebarConversationList({
(c) => c.id === targetId && c.agent_type === targetAgent
)
if (!conv) return
const status = conv.status as ConversationStatus
if (!groupExpanded[status]) {
setGroupExpanded((prev) => ({ ...prev, [status]: true }))
pendingScrollRef.current = true
return
}
if (viewMode === "grouped" && !(folderExpanded[conv.folder_id] ?? true)) {
if (!(folderExpanded[conv.folder_id] ?? true)) {
setFolderExpanded((prev) => {
const next = { ...prev, [conv.folder_id]: true }
saveFolderExpanded(next)
@@ -589,18 +469,7 @@ export function SidebarConversationList({
pendingScrollRef.current = false
scrollToActiveRef.current()
}
}, [
selectedConversation,
flatItems,
conversations,
groupExpanded,
folderExpanded,
viewMode,
])
const toggleGroup = useCallback((status: ConversationStatus) => {
setGroupExpanded((prev) => ({ ...prev, [status]: !prev[status] }))
}, [])
}, [selectedConversation, flatItems, conversations, folderExpanded])
const toggleFolder = useCallback((folderId: number) => {
setFolderExpanded((prev) => {
@@ -655,11 +524,6 @@ export function SidebarConversationList({
}
}, [removeConfirm, closeTabsByFolder, removeFolderFromWorkspace, t])
const handleOpenCompleteReview = useCallback(
() => setCompleteReviewOpen(true),
[]
)
const handleSelect = useCallback(
(id: number, agentType: string) => {
const conv = conversations.find(
@@ -761,40 +625,8 @@ export function SidebarConversationList({
}
}, [importing, activeFolder, addTask, updateTask, refreshConversations, t])
const handleCompleteAllReview = useCallback(async () => {
if (completingReview || reviewConversationCount === 0) return
setCompletingReview(true)
try {
for (const conversation of reviewConversations) {
updateConversationLocal(conversation.id, { status: "completed" })
}
await Promise.all(
reviewConversations.map((conversation) =>
updateConversationStatus(conversation.id, "completed")
)
)
toast.success(
t("toasts.reviewCompleted", { count: reviewConversationCount })
)
setCompleteReviewOpen(false)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
toast.error(t("toasts.completeReviewFailed", { message: msg }))
refreshConversations()
} finally {
setCompletingReview(false)
}
}, [
completingReview,
reviewConversationCount,
reviewConversations,
refreshConversations,
updateConversationLocal,
t,
])
const emptyAfterSearch =
filteredConversations.length === 0 && normalizedSearch.length > 0
const emptyAfterFilter =
filteredConversations.length === 0 && conversations.length > 0
return (
<div className="relative flex flex-col flex-1 min-h-0">
@@ -856,7 +688,7 @@ export function SidebarConversationList({
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
) : emptyAfterSearch ? (
) : emptyAfterFilter ? (
<div className="flex-1 flex items-center justify-center px-3">
<p className="text-muted-foreground text-xs text-center">
{t("noMatchingConversations")}
@@ -865,12 +697,50 @@ export function SidebarConversationList({
) : (
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex-1 min-h-0">
<div className="flex-1 min-h-0 relative">
{stickyFolderItem && (
<div
className="absolute top-0 left-0 right-0 z-10"
style={{
transform: `translateY(${stickyState.pushOffset}px)`,
}}
>
<div
aria-hidden
className="absolute inset-0 right-[0.5rem] bg-sidebar"
/>
<div className="relative pl-[0.625rem] pr-[0.625rem]">
<FolderHeader
key={`sticky-${stickyFolderItem.folderId}`}
folderId={stickyFolderItem.folderId}
folderName={stickyFolderItem.folderName}
branch={stickyFolderItem.branch}
count={stickyFolderItem.count}
expanded={stickyFolderItem.expanded}
onToggle={toggleFolder}
onFocus={focusFolder}
onCloseFolderTabs={handleCloseFolderTabs}
onRemoveFromWorkspace={handleRemoveFolder}
highlighted={
highlightedFolder === stickyFolderItem.folderId
}
t={t}
/>
</div>
</div>
)}
<ScrollArea
className={cn("h-full min-h-0 px-2", "[overflow-anchor:none]")}
className={cn(
"h-full min-h-0 pl-[0.625rem] pr-[0.625rem] pt-[0.125rem] pb-[1.25rem]",
"[overflow-anchor:none]"
)}
>
<Virtualizer ref={virtualizerRef} itemSize={CARD_HEIGHT}>
{flatItems.map((item, index) => {
<Virtualizer
ref={virtualizerRef}
itemSize={cardHeightPx}
onScroll={setScrollOffset}
>
{flatItems.map((item) => {
if (item.type === "folder_header") {
return (
<FolderHeader
@@ -889,52 +759,16 @@ export function SidebarConversationList({
/>
)
}
const indented =
viewMode === "grouped" &&
(item.type === "status_header"
? item.parentFolderId != null
: true)
if (item.type === "status_header") {
const key = `status-${item.parentFolderId ?? "root"}-${item.status}-${index}`
const headerNode =
item.status === "pending_review" ? (
<PendingReviewHeader
key={key}
count={item.count}
isOpen={groupExpanded[item.status]}
onToggle={toggleGroup}
reviewConversationCount={reviewConversationCount}
completingReview={completingReview}
onCompleteReview={handleOpenCompleteReview}
tStatus={tStatus}
t={t}
/>
) : (
<StatusHeader
key={key}
status={item.status}
count={item.count}
isOpen={groupExpanded[item.status]}
onToggle={toggleGroup}
tStatus={tStatus}
/>
)
return indented ? (
<div key={key} className="pl-4">
{headerNode}
</div>
) : (
headerNode
)
}
const conv = item.conversation
const cardNode = (
return (
<SidebarConversationCard
key={`conv-${conv.id}`}
conversation={conv}
isSelected={
selectedConversation?.agentType === conv.agent_type &&
selectedConversation?.id === conv.id
}
timeLabel={formatRelative(conv.updated_at)}
onSelect={handleSelect}
onDoubleClick={handleDoubleClick}
onRename={handleRename}
@@ -945,13 +779,6 @@ export function SidebarConversationList({
importing={importing}
/>
)
return indented ? (
<div key={`conv-${conv.id}`} className="pl-4">
{cardNode}
</div>
) : (
<div key={`conv-${conv.id}`}>{cardNode}</div>
)
})}
</Virtualizer>
</ScrollArea>
@@ -976,34 +803,6 @@ export function SidebarConversationList({
</ContextMenuContent>
</ContextMenu>
)}
<AlertDialog
open={completeReviewOpen}
onOpenChange={(open) =>
!completingReview && setCompleteReviewOpen(open)
}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("completeAllReviewTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("completeAllReviewDescription", {
count: reviewConversationCount,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={completingReview}>
{tCommon("cancel")}
</AlertDialogCancel>
<AlertDialogAction
disabled={completingReview || reviewConversationCount === 0}
onClick={handleCompleteAllReview}
>
{completingReview ? t("completing") : tCommon("confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={removeConfirm !== null}