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,14 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import { memo, useState, useCallback, useMemo } from "react"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { enUS, zhCN, zhTW } from "date-fns/locale"
|
||||
import { GitBranch, Pencil, Trash2, Circle, Download, Plus } from "lucide-react"
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
import { memo, useState, useCallback } from "react"
|
||||
import {
|
||||
Pencil,
|
||||
Trash2,
|
||||
Circle,
|
||||
CircleAlert,
|
||||
CircleCheck,
|
||||
CircleDashed,
|
||||
CircleX,
|
||||
Download,
|
||||
Plus,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import type { DbConversationSummary, ConversationStatus } from "@/lib/types"
|
||||
import { STATUS_ORDER, STATUS_COLORS } from "@/lib/types"
|
||||
import { STATUS_ORDER } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { AgentIcon } from "@/components/agent-icon"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
@@ -38,10 +46,29 @@ import {
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
SidebarStatusIcon,
|
||||
conversationStatusToBead,
|
||||
} from "./sidebar-status-icon"
|
||||
|
||||
const STATUS_ICONS: Record<ConversationStatus, LucideIcon> = {
|
||||
in_progress: CircleDashed,
|
||||
pending_review: CircleAlert,
|
||||
completed: CircleCheck,
|
||||
cancelled: CircleX,
|
||||
}
|
||||
|
||||
const STATUS_ICON_COLORS: Record<ConversationStatus, string> = {
|
||||
in_progress: "text-blue-500",
|
||||
pending_review: "text-orange-500",
|
||||
completed: "text-green-500",
|
||||
cancelled: "text-red-500",
|
||||
}
|
||||
|
||||
interface SidebarConversationCardProps {
|
||||
conversation: DbConversationSummary
|
||||
isSelected: boolean
|
||||
timeLabel?: string
|
||||
onSelect: (id: number, agentType: string) => void
|
||||
onDoubleClick?: (id: number, agentType: string) => void
|
||||
onRename: (id: number, newTitle: string) => Promise<void>
|
||||
@@ -55,6 +82,7 @@ interface SidebarConversationCardProps {
|
||||
export const SidebarConversationCard = memo(function SidebarConversationCard({
|
||||
conversation,
|
||||
isSelected,
|
||||
timeLabel,
|
||||
onSelect,
|
||||
onDoubleClick,
|
||||
onRename,
|
||||
@@ -65,23 +93,12 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
|
||||
importing,
|
||||
}: SidebarConversationCardProps) {
|
||||
const t = useTranslations("Folder.conversationCard")
|
||||
const tSidebar = useTranslations("Folder.sidebar")
|
||||
const tStatus = useTranslations("Folder.statusLabels")
|
||||
const locale = useLocale()
|
||||
const dateFnsLocale =
|
||||
locale === "zh-CN" ? zhCN : locale === "zh-TW" ? zhTW : enUS
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [renameValue, setRenameValue] = useState("")
|
||||
|
||||
const timeAgo = useMemo(
|
||||
() =>
|
||||
formatDistanceToNow(new Date(conversation.updated_at), {
|
||||
addSuffix: true,
|
||||
locale: dateFnsLocale,
|
||||
}),
|
||||
[conversation.updated_at, dateFnsLocale]
|
||||
)
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect(conversation.id, conversation.agent_type)
|
||||
}, [onSelect, conversation.id, conversation.agent_type])
|
||||
@@ -108,40 +125,83 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
|
||||
setDeleteOpen(false)
|
||||
}, [conversation.id, conversation.agent_type, onDelete])
|
||||
|
||||
const status = conversation.status as ConversationStatus
|
||||
const beadStatus = conversationStatusToBead(conversation.status)
|
||||
const isRunning = status === "in_progress"
|
||||
const isFailed = status === "cancelled"
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<button
|
||||
data-conversation-id={conversation.id}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDblClick}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2.5 mb-1 rounded-md transition-colors",
|
||||
isSelected
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "hover:bg-sidebar-accent/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<AgentIcon
|
||||
agentType={conversation.agent_type}
|
||||
className="size-4 shrink-0"
|
||||
<div className="relative h-[2rem]">
|
||||
<button
|
||||
data-conversation-id={conversation.id}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDblClick}
|
||||
className={cn(
|
||||
"relative flex h-[1.9375rem] w-full items-center gap-[0.625rem] text-left outline-none",
|
||||
"rounded-[0.375rem] text-sidebar-foreground",
|
||||
"transition-colors duration-[120ms]",
|
||||
"pr-[0.5rem] pl-[1.875rem]",
|
||||
isSelected
|
||||
? "bg-sidebar-primary/15"
|
||||
: "hover:bg-[color-mix(in_oklab,var(--sidebar-accent),var(--sidebar-foreground)_2%)]"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute top-0 bottom-0 rounded-[0.125rem] bg-sidebar-primary/5"
|
||||
style={{ left: "0.9375rem", width: "0.125rem" }}
|
||||
/>
|
||||
<span className="text-sm font-medium truncate">
|
||||
<SidebarStatusIcon status={beadStatus} />
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"relative min-w-0 flex-1 truncate text-[0.875rem]",
|
||||
isSelected
|
||||
? "font-semibold tracking-[-0.00625rem]"
|
||||
: "font-normal"
|
||||
)}
|
||||
>
|
||||
{conversation.title || t("untitledConversation")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
||||
<span>{timeAgo}</span>
|
||||
{conversation.git_branch && (
|
||||
<span className="flex items-center gap-0.5 truncate">
|
||||
<GitBranch className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{conversation.git_branch}</span>
|
||||
|
||||
{isRunning ? (
|
||||
<span
|
||||
className={cn(
|
||||
"relative shrink-0 rounded-[0.1875rem] px-[0.375rem] py-px",
|
||||
"text-[0.6875rem] font-semibold tracking-[0.01875rem]",
|
||||
"bg-amber-500/10 text-amber-600 dark:bg-amber-400/15 dark:text-amber-400"
|
||||
)}
|
||||
>
|
||||
{tSidebar("statusRunningBadge")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
) : isFailed ? (
|
||||
<span
|
||||
className={cn(
|
||||
"relative shrink-0 rounded-[0.1875rem] px-[0.375rem] py-px",
|
||||
"text-[0.6875rem] font-semibold tracking-[0.01875rem]",
|
||||
"bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{tSidebar("statusFailedBadge")}
|
||||
</span>
|
||||
) : timeLabel ? (
|
||||
<span
|
||||
className={cn(
|
||||
"relative shrink-0 tabular-nums",
|
||||
"text-[0.71875rem]",
|
||||
isSelected
|
||||
? "font-medium text-muted-foreground"
|
||||
: "font-normal text-muted-foreground/70"
|
||||
)}
|
||||
>
|
||||
{timeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{onNewConversation && (
|
||||
@@ -165,20 +225,20 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
{STATUS_ORDER.filter((s) => s !== conversation.status).map(
|
||||
(s) => (
|
||||
<ContextMenuItem
|
||||
key={s}
|
||||
onSelect={() => onStatusChange(conversation.id, s)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full shrink-0",
|
||||
STATUS_COLORS[s]
|
||||
)}
|
||||
/>
|
||||
{tStatus(s)}
|
||||
</ContextMenuItem>
|
||||
)
|
||||
(s) => {
|
||||
const StatusIcon = STATUS_ICONS[s]
|
||||
return (
|
||||
<ContextMenuItem
|
||||
key={s}
|
||||
onSelect={() => onStatusChange(conversation.id, s)}
|
||||
>
|
||||
<StatusIcon
|
||||
className={cn("h-4 w-4", STATUS_ICON_COLORS[s])}
|
||||
/>
|
||||
{tStatus(s)}
|
||||
</ContextMenuItem>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
|
||||
@@ -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}
|
||||
|
||||
180
src/components/conversations/sidebar-status-icon.tsx
Normal file
180
src/components/conversations/sidebar-status-icon.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type SidebarBeadStatus = "done" | "active" | "running" | "failed"
|
||||
|
||||
interface SidebarStatusIconProps {
|
||||
status: SidebarBeadStatus
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SidebarStatusIcon({
|
||||
status,
|
||||
className,
|
||||
}: SidebarStatusIconProps) {
|
||||
if (status === "running") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute top-1/2",
|
||||
"flex items-center justify-center",
|
||||
"text-amber-600 dark:text-amber-400",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
left: "0.625rem",
|
||||
width: "0.75rem",
|
||||
height: "0.75rem",
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
<svg
|
||||
width="0.75rem"
|
||||
height="0.75rem"
|
||||
viewBox="0 0 12 12"
|
||||
className="absolute inset-0"
|
||||
>
|
||||
<circle
|
||||
cx="6"
|
||||
cy="6"
|
||||
r="5.4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
opacity="0.18"
|
||||
/>
|
||||
</svg>
|
||||
<svg width="0.625rem" height="0.625rem" viewBox="0 0 10 10">
|
||||
<circle
|
||||
cx="5"
|
||||
cy="5"
|
||||
r="3.6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
opacity="0.28"
|
||||
/>
|
||||
<path
|
||||
d="M5 1.4 A 3.6 3.6 0 1 1 1.4 5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 5 5"
|
||||
to="360 5 5"
|
||||
dur="1.1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === "failed") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute top-1/2",
|
||||
"flex items-center justify-center",
|
||||
"text-destructive",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
left: "0.6875rem",
|
||||
width: "0.625rem",
|
||||
height: "0.625rem",
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
<svg width="0.625rem" height="0.625rem" viewBox="0 0 10 10">
|
||||
<circle
|
||||
cx="5"
|
||||
cy="5"
|
||||
r="3.8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<path
|
||||
d="M3.5 3.5L6.5 6.5M6.5 3.5L3.5 6.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === "active") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute top-1/2",
|
||||
"flex items-center justify-center",
|
||||
"text-sidebar-primary",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
left: "0.6875rem",
|
||||
width: "0.625rem",
|
||||
height: "0.625rem",
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
<svg width="0.625rem" height="0.625rem" viewBox="0 0 10 10">
|
||||
<circle
|
||||
cx="5"
|
||||
cy="5"
|
||||
r="3.8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
opacity="0.35"
|
||||
/>
|
||||
<circle cx="5" cy="5" r="2" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute top-1/2 rounded-full bg-sidebar-primary/40",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
left: "0.8125rem",
|
||||
width: "0.375rem",
|
||||
height: "0.375rem",
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function conversationStatusToBead(status: string): SidebarBeadStatus {
|
||||
switch (status) {
|
||||
case "in_progress":
|
||||
return "running"
|
||||
case "pending_review":
|
||||
return "active"
|
||||
case "cancelled":
|
||||
return "failed"
|
||||
case "completed":
|
||||
default:
|
||||
return "done"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user