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"
|
"use client"
|
||||||
|
|
||||||
import { memo, useState, useCallback, useMemo } from "react"
|
import { memo, useState, useCallback } from "react"
|
||||||
import { formatDistanceToNow } from "date-fns"
|
import {
|
||||||
import { enUS, zhCN, zhTW } from "date-fns/locale"
|
Pencil,
|
||||||
import { GitBranch, Pencil, Trash2, Circle, Download, Plus } from "lucide-react"
|
Trash2,
|
||||||
import { useLocale, useTranslations } from "next-intl"
|
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 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 { cn } from "@/lib/utils"
|
||||||
import { AgentIcon } from "@/components/agent-icon"
|
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
@@ -38,10 +46,29 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
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 {
|
interface SidebarConversationCardProps {
|
||||||
conversation: DbConversationSummary
|
conversation: DbConversationSummary
|
||||||
isSelected: boolean
|
isSelected: boolean
|
||||||
|
timeLabel?: string
|
||||||
onSelect: (id: number, agentType: string) => void
|
onSelect: (id: number, agentType: string) => void
|
||||||
onDoubleClick?: (id: number, agentType: string) => void
|
onDoubleClick?: (id: number, agentType: string) => void
|
||||||
onRename: (id: number, newTitle: string) => Promise<void>
|
onRename: (id: number, newTitle: string) => Promise<void>
|
||||||
@@ -55,6 +82,7 @@ interface SidebarConversationCardProps {
|
|||||||
export const SidebarConversationCard = memo(function SidebarConversationCard({
|
export const SidebarConversationCard = memo(function SidebarConversationCard({
|
||||||
conversation,
|
conversation,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
timeLabel,
|
||||||
onSelect,
|
onSelect,
|
||||||
onDoubleClick,
|
onDoubleClick,
|
||||||
onRename,
|
onRename,
|
||||||
@@ -65,23 +93,12 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
|
|||||||
importing,
|
importing,
|
||||||
}: SidebarConversationCardProps) {
|
}: SidebarConversationCardProps) {
|
||||||
const t = useTranslations("Folder.conversationCard")
|
const t = useTranslations("Folder.conversationCard")
|
||||||
|
const tSidebar = useTranslations("Folder.sidebar")
|
||||||
const tStatus = useTranslations("Folder.statusLabels")
|
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 [renameOpen, setRenameOpen] = useState(false)
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
const [renameValue, setRenameValue] = useState("")
|
const [renameValue, setRenameValue] = useState("")
|
||||||
|
|
||||||
const timeAgo = useMemo(
|
|
||||||
() =>
|
|
||||||
formatDistanceToNow(new Date(conversation.updated_at), {
|
|
||||||
addSuffix: true,
|
|
||||||
locale: dateFnsLocale,
|
|
||||||
}),
|
|
||||||
[conversation.updated_at, dateFnsLocale]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
onSelect(conversation.id, conversation.agent_type)
|
onSelect(conversation.id, conversation.agent_type)
|
||||||
}, [onSelect, conversation.id, conversation.agent_type])
|
}, [onSelect, conversation.id, conversation.agent_type])
|
||||||
@@ -108,40 +125,83 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
|
|||||||
setDeleteOpen(false)
|
setDeleteOpen(false)
|
||||||
}, [conversation.id, conversation.agent_type, onDelete])
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<button
|
<div className="relative h-[2rem]">
|
||||||
data-conversation-id={conversation.id}
|
<button
|
||||||
onClick={handleClick}
|
data-conversation-id={conversation.id}
|
||||||
onDoubleClick={handleDblClick}
|
onClick={handleClick}
|
||||||
className={cn(
|
onDoubleClick={handleDblClick}
|
||||||
"w-full text-left px-3 py-2.5 mb-1 rounded-md transition-colors",
|
className={cn(
|
||||||
isSelected
|
"relative flex h-[1.9375rem] w-full items-center gap-[0.625rem] text-left outline-none",
|
||||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
"rounded-[0.375rem] text-sidebar-foreground",
|
||||||
: "hover:bg-sidebar-accent/50"
|
"transition-colors duration-[120ms]",
|
||||||
)}
|
"pr-[0.5rem] pl-[1.875rem]",
|
||||||
>
|
isSelected
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
? "bg-sidebar-primary/15"
|
||||||
<AgentIcon
|
: "hover:bg-[color-mix(in_oklab,var(--sidebar-accent),var(--sidebar-foreground)_2%)]"
|
||||||
agentType={conversation.agent_type}
|
)}
|
||||||
className="size-4 shrink-0"
|
>
|
||||||
|
<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")}
|
{conversation.title || t("untitledConversation")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
{isRunning ? (
|
||||||
<span>{timeAgo}</span>
|
<span
|
||||||
{conversation.git_branch && (
|
className={cn(
|
||||||
<span className="flex items-center gap-0.5 truncate">
|
"relative shrink-0 rounded-[0.1875rem] px-[0.375rem] py-px",
|
||||||
<GitBranch className="h-3 w-3 shrink-0" />
|
"text-[0.6875rem] font-semibold tracking-[0.01875rem]",
|
||||||
<span className="truncate">{conversation.git_branch}</span>
|
"bg-amber-500/10 text-amber-600 dark:bg-amber-400/15 dark:text-amber-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tSidebar("statusRunningBadge")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : isFailed ? (
|
||||||
</div>
|
<span
|
||||||
</button>
|
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>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
{onNewConversation && (
|
{onNewConversation && (
|
||||||
@@ -165,20 +225,20 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
|
|||||||
</ContextMenuSubTrigger>
|
</ContextMenuSubTrigger>
|
||||||
<ContextMenuSubContent>
|
<ContextMenuSubContent>
|
||||||
{STATUS_ORDER.filter((s) => s !== conversation.status).map(
|
{STATUS_ORDER.filter((s) => s !== conversation.status).map(
|
||||||
(s) => (
|
(s) => {
|
||||||
<ContextMenuItem
|
const StatusIcon = STATUS_ICONS[s]
|
||||||
key={s}
|
return (
|
||||||
onSelect={() => onStatusChange(conversation.id, s)}
|
<ContextMenuItem
|
||||||
>
|
key={s}
|
||||||
<span
|
onSelect={() => onStatusChange(conversation.id, s)}
|
||||||
className={cn(
|
>
|
||||||
"w-2 h-2 rounded-full shrink-0",
|
<StatusIcon
|
||||||
STATUS_COLORS[s]
|
className={cn("h-4 w-4", STATUS_ICON_COLORS[s])}
|
||||||
)}
|
/>
|
||||||
/>
|
{tStatus(s)}
|
||||||
{tStatus(s)}
|
</ContextMenuItem>
|
||||||
</ContextMenuItem>
|
)
|
||||||
)
|
}
|
||||||
)}
|
)}
|
||||||
</ContextMenuSubContent>
|
</ContextMenuSubContent>
|
||||||
</ContextMenuSub>
|
</ContextMenuSub>
|
||||||
|
|||||||
@@ -13,18 +13,12 @@ import {
|
|||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Virtualizer, type VirtualizerHandle } from "virtua"
|
import { Virtualizer, type VirtualizerHandle } from "virtua"
|
||||||
import {
|
import { ChevronRight, Download, Loader2, Plus, XCircle } from "lucide-react"
|
||||||
CheckCheck,
|
|
||||||
ChevronRight,
|
|
||||||
Download,
|
|
||||||
Loader2,
|
|
||||||
Plus,
|
|
||||||
XCircle,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { useActiveFolder } from "@/contexts/active-folder-context"
|
import { useActiveFolder } from "@/contexts/active-folder-context"
|
||||||
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
||||||
import { useTabContext } from "@/contexts/tab-context"
|
import { useTabContext } from "@/contexts/tab-context"
|
||||||
import { useTaskContext } from "@/contexts/task-context"
|
import { useTaskContext } from "@/contexts/task-context"
|
||||||
|
import { useZoomLevel } from "@/hooks/use-appearance"
|
||||||
import {
|
import {
|
||||||
importLocalConversations,
|
importLocalConversations,
|
||||||
updateConversationTitle,
|
updateConversationTitle,
|
||||||
@@ -32,13 +26,12 @@ import {
|
|||||||
deleteConversation,
|
deleteConversation,
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
import type { ConversationStatus, DbConversationSummary } from "@/lib/types"
|
import type { ConversationStatus, DbConversationSummary } from "@/lib/types"
|
||||||
import { STATUS_ORDER, STATUS_COLORS } from "@/lib/types"
|
|
||||||
import {
|
import {
|
||||||
loadFolderExpanded,
|
loadFolderExpanded,
|
||||||
saveFolderExpanded,
|
saveFolderExpanded,
|
||||||
type SidebarViewMode,
|
|
||||||
} from "@/lib/sidebar-view-mode-storage"
|
} from "@/lib/sidebar-view-mode-storage"
|
||||||
import { SidebarConversationCard } from "./sidebar-conversation-card"
|
import { SidebarConversationCard } from "./sidebar-conversation-card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
@@ -81,6 +74,23 @@ function compareByUpdatedAtDesc(
|
|||||||
return right.id - left.id
|
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 FlatItem =
|
||||||
| {
|
| {
|
||||||
type: "folder_header"
|
type: "folder_header"
|
||||||
@@ -90,15 +100,9 @@ type FlatItem =
|
|||||||
count: number
|
count: number
|
||||||
expanded: boolean
|
expanded: boolean
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
type: "status_header"
|
|
||||||
status: ConversationStatus
|
|
||||||
count: number
|
|
||||||
parentFolderId?: number
|
|
||||||
}
|
|
||||||
| { type: "conversation"; conversation: DbConversationSummary }
|
| { type: "conversation"; conversation: DbConversationSummary }
|
||||||
|
|
||||||
const CARD_HEIGHT = 62
|
const CARD_HEIGHT_REM = 2
|
||||||
|
|
||||||
const FolderHeader = memo(function FolderHeader({
|
const FolderHeader = memo(function FolderHeader({
|
||||||
folderId,
|
folderId,
|
||||||
@@ -128,31 +132,49 @@ const FolderHeader = memo(function FolderHeader({
|
|||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<button
|
<div className="relative h-[2rem]">
|
||||||
data-folder-id={folderId}
|
<button
|
||||||
onClick={() => onToggle(folderId)}
|
data-folder-id={folderId}
|
||||||
className={cn(
|
onClick={() => onToggle(folderId)}
|
||||||
"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
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-3.5 w-3.5 shrink-0 transition-transform text-muted-foreground",
|
"flex h-[1.9375rem] w-full items-center gap-[0.5rem] cursor-pointer outline-none",
|
||||||
expanded && "rotate-90"
|
"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>
|
<span
|
||||||
{branch && (
|
className={cn(
|
||||||
<span className="text-[10px] text-muted-foreground truncate max-w-[80px]">
|
"flex h-[0.75rem] w-[0.75rem] shrink-0 items-center justify-center text-muted-foreground/75",
|
||||||
{branch}
|
"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>
|
||||||
)}
|
<div className="flex min-w-0 flex-1 items-center gap-[0.375rem]">
|
||||||
<span className="text-muted-foreground/60 tabular-nums text-[10px]">
|
<span className="min-w-0 flex-shrink truncate text-left text-[0.875rem] font-semibold tracking-[-0.00625rem]">
|
||||||
({count})
|
{folderName}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
{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>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<ContextMenuItem onSelect={() => onFocus(folderId)}>
|
<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 {
|
export interface SidebarConversationListHandle {
|
||||||
scrollToActive: () => void
|
scrollToActive: () => void
|
||||||
expandAll: () => void
|
expandAll: () => void
|
||||||
@@ -272,20 +204,19 @@ export interface SidebarConversationListHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarConversationListProps {
|
export interface SidebarConversationListProps {
|
||||||
viewMode?: SidebarViewMode
|
showCompleted?: boolean
|
||||||
searchQuery?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarConversationList({
|
export function SidebarConversationList({
|
||||||
ref,
|
ref,
|
||||||
viewMode = "flat",
|
showCompleted = true,
|
||||||
searchQuery = "",
|
|
||||||
}: SidebarConversationListProps & {
|
}: SidebarConversationListProps & {
|
||||||
ref?: Ref<SidebarConversationListHandle>
|
ref?: Ref<SidebarConversationListHandle>
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("Folder.sidebar")
|
const t = useTranslations("Folder.sidebar")
|
||||||
const tStatus = useTranslations("Folder.statusLabels")
|
|
||||||
const tCommon = useTranslations("Folder.common")
|
const tCommon = useTranslations("Folder.common")
|
||||||
|
const { zoomLevel } = useZoomLevel()
|
||||||
|
const cardHeightPx = (CARD_HEIGHT_REM * 16 * zoomLevel) / 100
|
||||||
const {
|
const {
|
||||||
allFolders,
|
allFolders,
|
||||||
conversations,
|
conversations,
|
||||||
@@ -325,22 +256,13 @@ export function SidebarConversationList({
|
|||||||
}, [tabs, activeTabId])
|
}, [tabs, activeTabId])
|
||||||
|
|
||||||
const [importing, setImporting] = useState(false)
|
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 [folderExpanded, setFolderExpanded] = useState<Record<number, boolean>>(
|
||||||
{}
|
{}
|
||||||
)
|
)
|
||||||
const [highlightedFolder, setHighlightedFolder] = useState<number | null>(
|
const [highlightedFolder, setHighlightedFolder] = useState<number | null>(
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
|
const [scrollOffset, setScrollOffset] = useState(0)
|
||||||
const [removeConfirm, setRemoveConfirm] = useState<{
|
const [removeConfirm, setRemoveConfirm] = useState<{
|
||||||
folderId: number
|
folderId: number
|
||||||
folderName: string
|
folderName: string
|
||||||
@@ -357,53 +279,25 @@ export function SidebarConversationList({
|
|||||||
const virtualizerRef = useRef<VirtualizerHandle>(null)
|
const virtualizerRef = useRef<VirtualizerHandle>(null)
|
||||||
const highlightTimerRef = useRef<number | null>(null)
|
const highlightTimerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const normalizedSearch = searchQuery.trim().toLowerCase()
|
|
||||||
const filteredConversations = useMemo(() => {
|
const filteredConversations = useMemo(() => {
|
||||||
if (!normalizedSearch) return conversations
|
if (showCompleted) return conversations
|
||||||
return conversations.filter((c) => {
|
return conversations.filter(
|
||||||
const title = (c.title ?? "").toLowerCase()
|
(c) => c.status !== "completed" && c.status !== "cancelled"
|
||||||
return title.includes(normalizedSearch)
|
)
|
||||||
})
|
}, [conversations, showCompleted])
|
||||||
}, [conversations, normalizedSearch])
|
|
||||||
|
|
||||||
const byStatus = useMemo(() => {
|
const byFolder = useMemo(() => {
|
||||||
const map = new Map<ConversationStatus, DbConversationSummary[]>()
|
const map = new Map<number, DbConversationSummary[]>()
|
||||||
for (const conv of filteredConversations) {
|
for (const conv of filteredConversations) {
|
||||||
const status = conv.status as ConversationStatus
|
const list = map.get(conv.folder_id)
|
||||||
const list = map.get(status)
|
|
||||||
if (list) list.push(conv)
|
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)
|
for (const list of map.values()) list.sort(compareByUpdatedAtDesc)
|
||||||
return map
|
return map
|
||||||
}, [filteredConversations])
|
}, [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(() => {
|
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 seen = new Set<number>()
|
||||||
const ids: number[] = []
|
const ids: number[] = []
|
||||||
for (const f of allFolders) {
|
for (const f of allFolders) {
|
||||||
@@ -423,91 +317,83 @@ export function SidebarConversationList({
|
|||||||
|
|
||||||
const flatItems = useMemo<FlatItem[]>(() => {
|
const flatItems = useMemo<FlatItem[]>(() => {
|
||||||
const items: FlatItem[] = []
|
const items: FlatItem[] = []
|
||||||
if (viewMode === "grouped") {
|
for (const folderId of orderedFolderIds) {
|
||||||
for (const folderId of orderedFolderIds) {
|
const list = byFolder.get(folderId) ?? []
|
||||||
const inner = byFolder.get(folderId)
|
const folderName = folderIndex.get(folderId)?.name ?? String(folderId)
|
||||||
const totalCount = inner
|
const branch = branches.get(folderId) ?? null
|
||||||
? Array.from(inner.values()).reduce(
|
const expanded = folderExpanded[folderId] ?? true
|
||||||
(sum, list) => sum + list.length,
|
items.push({
|
||||||
0
|
type: "folder_header",
|
||||||
)
|
folderId,
|
||||||
: 0
|
folderName,
|
||||||
const folderName = folderIndex.get(folderId)?.name ?? String(folderId)
|
branch,
|
||||||
const branch = branches.get(folderId) ?? null
|
count: list.length,
|
||||||
const expanded = folderExpanded[folderId] ?? true
|
expanded,
|
||||||
items.push({
|
})
|
||||||
type: "folder_header",
|
if (!expanded) continue
|
||||||
folderId,
|
for (const conv of list) {
|
||||||
folderName,
|
items.push({ type: "conversation", conversation: conv })
|
||||||
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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}, [
|
}, [orderedFolderIds, byFolder, folderIndex, branches, folderExpanded])
|
||||||
viewMode,
|
|
||||||
orderedFolderIds,
|
|
||||||
byFolder,
|
|
||||||
folderIndex,
|
|
||||||
branches,
|
|
||||||
folderExpanded,
|
|
||||||
byStatus,
|
|
||||||
groupExpanded,
|
|
||||||
])
|
|
||||||
|
|
||||||
const reviewConversations = useMemo(
|
const stickyState = useMemo<{
|
||||||
() => byStatus.get("pending_review") ?? [],
|
folder: Extract<FlatItem, { type: "folder_header" }> | null
|
||||||
[byStatus]
|
pushOffset: number
|
||||||
)
|
}>(() => {
|
||||||
const reviewConversationCount = reviewConversations.length
|
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, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
scrollToActive() {
|
scrollToActive() {
|
||||||
scrollToActiveRef.current()
|
scrollToActiveRef.current()
|
||||||
},
|
},
|
||||||
expandAll() {
|
expandAll() {
|
||||||
setGroupExpanded({
|
setFolderExpanded((prev) => {
|
||||||
in_progress: true,
|
const next: Record<number, boolean> = { ...prev }
|
||||||
pending_review: true,
|
for (const id of orderedFolderIds) next[id] = true
|
||||||
completed: true,
|
saveFolderExpanded(next)
|
||||||
cancelled: true,
|
return next
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
collapseAll() {
|
collapseAll() {
|
||||||
setGroupExpanded({
|
setFolderExpanded((prev) => {
|
||||||
in_progress: false,
|
const next: Record<number, boolean> = { ...prev }
|
||||||
pending_review: false,
|
for (const id of orderedFolderIds) next[id] = false
|
||||||
completed: false,
|
saveFolderExpanded(next)
|
||||||
cancelled: false,
|
return next
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
revealFolder(folderId: number) {
|
revealFolder(folderId: number) {
|
||||||
@@ -556,13 +442,7 @@ export function SidebarConversationList({
|
|||||||
(c) => c.id === targetId && c.agent_type === targetAgent
|
(c) => c.id === targetId && c.agent_type === targetAgent
|
||||||
)
|
)
|
||||||
if (!conv) return
|
if (!conv) return
|
||||||
const status = conv.status as ConversationStatus
|
if (!(folderExpanded[conv.folder_id] ?? true)) {
|
||||||
if (!groupExpanded[status]) {
|
|
||||||
setGroupExpanded((prev) => ({ ...prev, [status]: true }))
|
|
||||||
pendingScrollRef.current = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (viewMode === "grouped" && !(folderExpanded[conv.folder_id] ?? true)) {
|
|
||||||
setFolderExpanded((prev) => {
|
setFolderExpanded((prev) => {
|
||||||
const next = { ...prev, [conv.folder_id]: true }
|
const next = { ...prev, [conv.folder_id]: true }
|
||||||
saveFolderExpanded(next)
|
saveFolderExpanded(next)
|
||||||
@@ -589,18 +469,7 @@ export function SidebarConversationList({
|
|||||||
pendingScrollRef.current = false
|
pendingScrollRef.current = false
|
||||||
scrollToActiveRef.current()
|
scrollToActiveRef.current()
|
||||||
}
|
}
|
||||||
}, [
|
}, [selectedConversation, flatItems, conversations, folderExpanded])
|
||||||
selectedConversation,
|
|
||||||
flatItems,
|
|
||||||
conversations,
|
|
||||||
groupExpanded,
|
|
||||||
folderExpanded,
|
|
||||||
viewMode,
|
|
||||||
])
|
|
||||||
|
|
||||||
const toggleGroup = useCallback((status: ConversationStatus) => {
|
|
||||||
setGroupExpanded((prev) => ({ ...prev, [status]: !prev[status] }))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const toggleFolder = useCallback((folderId: number) => {
|
const toggleFolder = useCallback((folderId: number) => {
|
||||||
setFolderExpanded((prev) => {
|
setFolderExpanded((prev) => {
|
||||||
@@ -655,11 +524,6 @@ export function SidebarConversationList({
|
|||||||
}
|
}
|
||||||
}, [removeConfirm, closeTabsByFolder, removeFolderFromWorkspace, t])
|
}, [removeConfirm, closeTabsByFolder, removeFolderFromWorkspace, t])
|
||||||
|
|
||||||
const handleOpenCompleteReview = useCallback(
|
|
||||||
() => setCompleteReviewOpen(true),
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(id: number, agentType: string) => {
|
(id: number, agentType: string) => {
|
||||||
const conv = conversations.find(
|
const conv = conversations.find(
|
||||||
@@ -761,40 +625,8 @@ export function SidebarConversationList({
|
|||||||
}
|
}
|
||||||
}, [importing, activeFolder, addTask, updateTask, refreshConversations, t])
|
}, [importing, activeFolder, addTask, updateTask, refreshConversations, t])
|
||||||
|
|
||||||
const handleCompleteAllReview = useCallback(async () => {
|
const emptyAfterFilter =
|
||||||
if (completingReview || reviewConversationCount === 0) return
|
filteredConversations.length === 0 && conversations.length > 0
|
||||||
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
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col flex-1 min-h-0">
|
<div className="relative flex flex-col flex-1 min-h-0">
|
||||||
@@ -856,7 +688,7 @@ export function SidebarConversationList({
|
|||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
) : emptyAfterSearch ? (
|
) : emptyAfterFilter ? (
|
||||||
<div className="flex-1 flex items-center justify-center px-3">
|
<div className="flex-1 flex items-center justify-center px-3">
|
||||||
<p className="text-muted-foreground text-xs text-center">
|
<p className="text-muted-foreground text-xs text-center">
|
||||||
{t("noMatchingConversations")}
|
{t("noMatchingConversations")}
|
||||||
@@ -865,12 +697,50 @@ export function SidebarConversationList({
|
|||||||
) : (
|
) : (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<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
|
<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}>
|
<Virtualizer
|
||||||
{flatItems.map((item, index) => {
|
ref={virtualizerRef}
|
||||||
|
itemSize={cardHeightPx}
|
||||||
|
onScroll={setScrollOffset}
|
||||||
|
>
|
||||||
|
{flatItems.map((item) => {
|
||||||
if (item.type === "folder_header") {
|
if (item.type === "folder_header") {
|
||||||
return (
|
return (
|
||||||
<FolderHeader
|
<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 conv = item.conversation
|
||||||
const cardNode = (
|
return (
|
||||||
<SidebarConversationCard
|
<SidebarConversationCard
|
||||||
|
key={`conv-${conv.id}`}
|
||||||
conversation={conv}
|
conversation={conv}
|
||||||
isSelected={
|
isSelected={
|
||||||
selectedConversation?.agentType === conv.agent_type &&
|
selectedConversation?.agentType === conv.agent_type &&
|
||||||
selectedConversation?.id === conv.id
|
selectedConversation?.id === conv.id
|
||||||
}
|
}
|
||||||
|
timeLabel={formatRelative(conv.updated_at)}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
onRename={handleRename}
|
onRename={handleRename}
|
||||||
@@ -945,13 +779,6 @@ export function SidebarConversationList({
|
|||||||
importing={importing}
|
importing={importing}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
return indented ? (
|
|
||||||
<div key={`conv-${conv.id}`} className="pl-4">
|
|
||||||
{cardNode}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div key={`conv-${conv.id}`}>{cardNode}</div>
|
|
||||||
)
|
|
||||||
})}
|
})}
|
||||||
</Virtualizer>
|
</Virtualizer>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
@@ -976,34 +803,6 @@ export function SidebarConversationList({
|
|||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</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
|
<AlertDialog
|
||||||
open={removeConfirm !== null}
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +1,26 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import {
|
import {
|
||||||
ChevronsDownUp,
|
ChevronsDownUp,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
Crosshair,
|
Crosshair,
|
||||||
FolderPlus,
|
EllipsisVertical,
|
||||||
FolderTree,
|
|
||||||
Plus,
|
|
||||||
Rows3,
|
|
||||||
Search,
|
|
||||||
X,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
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 { useSidebarContext } from "@/contexts/sidebar-context"
|
||||||
|
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
||||||
import {
|
import {
|
||||||
SidebarConversationList,
|
SidebarConversationList,
|
||||||
type SidebarConversationListHandle,
|
type SidebarConversationListHandle,
|
||||||
} from "@/components/conversations/sidebar-conversation-list"
|
} from "@/components/conversations/sidebar-conversation-list"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -31,165 +28,76 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
import { isDesktop, openFileDialog } from "@/lib/platform"
|
|
||||||
import {
|
import {
|
||||||
loadSidebarViewMode,
|
loadShowCompleted,
|
||||||
saveSidebarViewMode,
|
saveShowCompleted,
|
||||||
type SidebarViewMode,
|
|
||||||
} from "@/lib/sidebar-view-mode-storage"
|
} from "@/lib/sidebar-view-mode-storage"
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const t = useTranslations("Folder.sidebar")
|
const t = useTranslations("Folder.sidebar")
|
||||||
const { activeFolder } = useActiveFolder()
|
|
||||||
const { allFolders, conversations, openFolder } = useAppWorkspace()
|
|
||||||
const { openNewConversationTab } = useTabContext()
|
|
||||||
const { isOpen, toggle } = useSidebarContext()
|
const { isOpen, toggle } = useSidebarContext()
|
||||||
|
const { conversations } = useAppWorkspace()
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const listRef = useRef<SidebarConversationListHandle>(null)
|
const listRef = useRef<SidebarConversationListHandle>(null)
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<SidebarViewMode>("flat")
|
const [showCompleted, setShowCompleted] = useState(false)
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
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(() => {
|
useEffect(() => {
|
||||||
// Hydrate from localStorage after mount to keep SSR/CSR markup consistent.
|
// Hydrate from localStorage after mount to keep SSR/CSR markup consistent.
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setViewMode(loadSidebarViewMode())
|
setShowCompleted(loadShowCompleted())
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleSetViewMode = useCallback((mode: SidebarViewMode) => {
|
const handleSetShowCompleted = useCallback((value: boolean) => {
|
||||||
setViewMode(mode)
|
setShowCompleted(value)
|
||||||
saveSidebarViewMode(mode)
|
saveShowCompleted(value)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleToggleExpandAll = useCallback(() => {
|
||||||
|
if (allExpanded) {
|
||||||
|
listRef.current?.collapseAll()
|
||||||
|
setAllExpanded(false)
|
||||||
|
} else {
|
||||||
|
listRef.current?.expandAll()
|
||||||
|
setAllExpanded(true)
|
||||||
|
}
|
||||||
|
}, [allExpanded])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onReveal = (e: Event) => {
|
const onReveal = (e: Event) => {
|
||||||
const detail = (e as CustomEvent<{ folderId: number }>).detail
|
const detail = (e as CustomEvent<{ folderId: number }>).detail
|
||||||
if (!detail) return
|
if (!detail) return
|
||||||
if (viewMode !== "grouped") {
|
|
||||||
setViewMode("grouped")
|
|
||||||
saveSidebarViewMode("grouped")
|
|
||||||
}
|
|
||||||
listRef.current?.revealFolder(detail.folderId)
|
listRef.current?.revealFolder(detail.folderId)
|
||||||
}
|
}
|
||||||
window.addEventListener("sidebar:reveal-folder", onReveal)
|
window.addEventListener("sidebar:reveal-folder", onReveal)
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("sidebar:reveal-folder", onReveal)
|
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
|
if (!isOpen) return null
|
||||||
|
|
||||||
return (
|
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>
|
<TooltipProvider>
|
||||||
<div className="flex items-center justify-between border-b border-border px-3 py-2 gap-2">
|
<div className="flex h-10 shrink-0 items-center justify-between gap-2 pl-[1.25rem] pr-2">
|
||||||
<div className="flex items-center gap-2 min-w-0 text-[11px] text-muted-foreground tabular-nums">
|
<div className="flex min-w-0 items-baseline gap-[0.375rem]">
|
||||||
<span className="truncate">
|
<h2 className="truncate text-[0.875rem] font-bold tracking-[-0.00625rem] text-sidebar-foreground">
|
||||||
{t("statsLabel", {
|
{t("title")}
|
||||||
folders: allFolders.length,
|
</h2>
|
||||||
convos: conversations.length,
|
<span className="shrink-0 text-[0.75rem] tabular-nums text-muted-foreground/70">
|
||||||
})}
|
{t("conversationCountUnit", { count: visibleCount })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Tooltip>
|
<div className="flex items-center gap-0.5">
|
||||||
<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">
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -203,30 +111,43 @@ export function Sidebar() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6 shrink-0 text-muted-foreground"
|
className="h-6 w-6 shrink-0 text-muted-foreground"
|
||||||
onClick={() => listRef.current?.expandAll()}
|
onClick={handleToggleExpandAll}
|
||||||
title={t("expandAllGroups")}
|
title={
|
||||||
|
allExpanded ? t("collapseAllGroups") : t("expandAllGroups")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ChevronsUpDown className="h-3.5 w-3.5" />
|
{allExpanded ? (
|
||||||
</Button>
|
<ChevronsDownUp className="h-3.5 w-3.5" />
|
||||||
<Button
|
) : (
|
||||||
variant="ghost"
|
<ChevronsUpDown className="h-3.5 w-3.5" />
|
||||||
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" />
|
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -245,11 +166,7 @@ export function Sidebar() {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SidebarConversationList
|
<SidebarConversationList ref={listRef} showCompleted={showCompleted} />
|
||||||
ref={listRef}
|
|
||||||
viewMode={viewMode}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -834,8 +834,12 @@
|
|||||||
"statsLabel": "{folders} مجلدات · {convos} محادثة",
|
"statsLabel": "{folders} مجلدات · {convos} محادثة",
|
||||||
"openFolder": "فتح مجلد",
|
"openFolder": "فتح مجلد",
|
||||||
"searchPlaceholder": "بحث عن محادثات...",
|
"searchPlaceholder": "بحث عن محادثات...",
|
||||||
"viewFlat": "عرض مسطح",
|
"showCompleted": "عرض المحادثات المكتملة",
|
||||||
"viewGrouped": "تجميع حسب المجلد",
|
"moreOptions": "المزيد من الخيارات",
|
||||||
|
"statusRunningBadge": "قيد التشغيل",
|
||||||
|
"statusFailedBadge": "فشل",
|
||||||
|
"conversationCountUnit": "{count} محادثة",
|
||||||
|
"emptyFolderHint": "لا توجد محادثات",
|
||||||
"noMatchingConversations": "لا توجد محادثات مطابقة",
|
"noMatchingConversations": "لا توجد محادثات مطابقة",
|
||||||
"removeFolderConfirmTitle": "إزالة المجلد من مساحة العمل؟",
|
"removeFolderConfirmTitle": "إزالة المجلد من مساحة العمل؟",
|
||||||
"removeFolderConfirmDescription": "إزالة \"{name}\" من مساحة العمل؟ سيتم إغلاق علامات التبويب والمحطات المرتبطة.",
|
"removeFolderConfirmDescription": "إزالة \"{name}\" من مساحة العمل؟ سيتم إغلاق علامات التبويب والمحطات المرتبطة.",
|
||||||
|
|||||||
@@ -834,8 +834,12 @@
|
|||||||
"statsLabel": "{folders} Ordner · {convos} Konversationen",
|
"statsLabel": "{folders} Ordner · {convos} Konversationen",
|
||||||
"openFolder": "Ordner öffnen",
|
"openFolder": "Ordner öffnen",
|
||||||
"searchPlaceholder": "Konversationen suchen...",
|
"searchPlaceholder": "Konversationen suchen...",
|
||||||
"viewFlat": "Flache Ansicht",
|
"showCompleted": "Abgeschlossene Konversationen anzeigen",
|
||||||
"viewGrouped": "Nach Ordner gruppieren",
|
"moreOptions": "Weitere Optionen",
|
||||||
|
"statusRunningBadge": "Läuft",
|
||||||
|
"statusFailedBadge": "Fehlgeschlagen",
|
||||||
|
"conversationCountUnit": "{count, plural, one {# Konversation} other {# Konversationen}}",
|
||||||
|
"emptyFolderHint": "Keine Konversationen",
|
||||||
"noMatchingConversations": "Keine passenden Konversationen",
|
"noMatchingConversations": "Keine passenden Konversationen",
|
||||||
"removeFolderConfirmTitle": "Ordner aus Arbeitsbereich entfernen?",
|
"removeFolderConfirmTitle": "Ordner aus Arbeitsbereich entfernen?",
|
||||||
"removeFolderConfirmDescription": "\"{name}\" aus dem Arbeitsbereich entfernen? Zugehörige Tabs und Terminals werden geschlossen.",
|
"removeFolderConfirmDescription": "\"{name}\" aus dem Arbeitsbereich entfernen? Zugehörige Tabs und Terminals werden geschlossen.",
|
||||||
|
|||||||
@@ -834,8 +834,12 @@
|
|||||||
"statsLabel": "{folders} folders · {convos} conversations",
|
"statsLabel": "{folders} folders · {convos} conversations",
|
||||||
"openFolder": "Open Folder",
|
"openFolder": "Open Folder",
|
||||||
"searchPlaceholder": "Search conversations...",
|
"searchPlaceholder": "Search conversations...",
|
||||||
"viewFlat": "Flat view",
|
"showCompleted": "Show completed conversations",
|
||||||
"viewGrouped": "Grouped by folder",
|
"moreOptions": "More options",
|
||||||
|
"statusRunningBadge": "Running",
|
||||||
|
"statusFailedBadge": "Failed",
|
||||||
|
"conversationCountUnit": "{count, plural, one {# conversation} other {# conversations}}",
|
||||||
|
"emptyFolderHint": "No conversations",
|
||||||
"noMatchingConversations": "No matching conversations",
|
"noMatchingConversations": "No matching conversations",
|
||||||
"removeFolderConfirmTitle": "Remove folder from workspace?",
|
"removeFolderConfirmTitle": "Remove folder from workspace?",
|
||||||
"removeFolderConfirmDescription": "Remove \"{name}\" from the workspace? Its tabs and terminals will close.",
|
"removeFolderConfirmDescription": "Remove \"{name}\" from the workspace? Its tabs and terminals will close.",
|
||||||
|
|||||||
@@ -834,8 +834,12 @@
|
|||||||
"statsLabel": "{folders} carpetas · {convos} conversaciones",
|
"statsLabel": "{folders} carpetas · {convos} conversaciones",
|
||||||
"openFolder": "Abrir carpeta",
|
"openFolder": "Abrir carpeta",
|
||||||
"searchPlaceholder": "Buscar conversaciones...",
|
"searchPlaceholder": "Buscar conversaciones...",
|
||||||
"viewFlat": "Vista plana",
|
"showCompleted": "Mostrar conversaciones completadas",
|
||||||
"viewGrouped": "Agrupar por carpeta",
|
"moreOptions": "Más opciones",
|
||||||
|
"statusRunningBadge": "Ejecutando",
|
||||||
|
"statusFailedBadge": "Fallido",
|
||||||
|
"conversationCountUnit": "{count, plural, one {# conversación} other {# conversaciones}}",
|
||||||
|
"emptyFolderHint": "Sin conversaciones",
|
||||||
"noMatchingConversations": "No hay conversaciones coincidentes",
|
"noMatchingConversations": "No hay conversaciones coincidentes",
|
||||||
"removeFolderConfirmTitle": "¿Eliminar carpeta del espacio de trabajo?",
|
"removeFolderConfirmTitle": "¿Eliminar carpeta del espacio de trabajo?",
|
||||||
"removeFolderConfirmDescription": "¿Eliminar \"{name}\" del espacio de trabajo? Sus pestañas y terminales se cerrarán.",
|
"removeFolderConfirmDescription": "¿Eliminar \"{name}\" del espacio de trabajo? Sus pestañas y terminales se cerrarán.",
|
||||||
|
|||||||
@@ -834,8 +834,12 @@
|
|||||||
"statsLabel": "{folders} dossiers · {convos} conversations",
|
"statsLabel": "{folders} dossiers · {convos} conversations",
|
||||||
"openFolder": "Ouvrir le dossier",
|
"openFolder": "Ouvrir le dossier",
|
||||||
"searchPlaceholder": "Rechercher des conversations...",
|
"searchPlaceholder": "Rechercher des conversations...",
|
||||||
"viewFlat": "Vue à plat",
|
"showCompleted": "Afficher les conversations terminées",
|
||||||
"viewGrouped": "Grouper par dossier",
|
"moreOptions": "Plus d'options",
|
||||||
|
"statusRunningBadge": "En cours",
|
||||||
|
"statusFailedBadge": "Échec",
|
||||||
|
"conversationCountUnit": "{count, plural, one {# conversation} other {# conversations}}",
|
||||||
|
"emptyFolderHint": "Aucune conversation",
|
||||||
"noMatchingConversations": "Aucune conversation correspondante",
|
"noMatchingConversations": "Aucune conversation correspondante",
|
||||||
"removeFolderConfirmTitle": "Retirer le dossier de l'espace de travail ?",
|
"removeFolderConfirmTitle": "Retirer le dossier de l'espace de travail ?",
|
||||||
"removeFolderConfirmDescription": "Retirer \"{name}\" de l'espace de travail ? Les onglets et terminaux associés seront fermés.",
|
"removeFolderConfirmDescription": "Retirer \"{name}\" de l'espace de travail ? Les onglets et terminaux associés seront fermés.",
|
||||||
|
|||||||
@@ -834,8 +834,12 @@
|
|||||||
"statsLabel": "{folders} フォルダ · {convos} 会話",
|
"statsLabel": "{folders} フォルダ · {convos} 会話",
|
||||||
"openFolder": "フォルダを開く",
|
"openFolder": "フォルダを開く",
|
||||||
"searchPlaceholder": "会話を検索...",
|
"searchPlaceholder": "会話を検索...",
|
||||||
"viewFlat": "フラット表示",
|
"showCompleted": "完了した会話を表示",
|
||||||
"viewGrouped": "フォルダでグループ化",
|
"moreOptions": "その他のオプション",
|
||||||
|
"statusRunningBadge": "実行中",
|
||||||
|
"statusFailedBadge": "失敗",
|
||||||
|
"conversationCountUnit": "{count} 件",
|
||||||
|
"emptyFolderHint": "会話がありません",
|
||||||
"noMatchingConversations": "一致する会話がありません",
|
"noMatchingConversations": "一致する会話がありません",
|
||||||
"removeFolderConfirmTitle": "このフォルダをワークスペースから削除しますか?",
|
"removeFolderConfirmTitle": "このフォルダをワークスペースから削除しますか?",
|
||||||
"removeFolderConfirmDescription": "\"{name}\" をワークスペースから削除しますか?関連するタブとターミナルが閉じられます。",
|
"removeFolderConfirmDescription": "\"{name}\" をワークスペースから削除しますか?関連するタブとターミナルが閉じられます。",
|
||||||
|
|||||||
@@ -834,8 +834,12 @@
|
|||||||
"statsLabel": "{folders}개 폴더 · {convos}개 대화",
|
"statsLabel": "{folders}개 폴더 · {convos}개 대화",
|
||||||
"openFolder": "폴더 열기",
|
"openFolder": "폴더 열기",
|
||||||
"searchPlaceholder": "대화 검색...",
|
"searchPlaceholder": "대화 검색...",
|
||||||
"viewFlat": "평면 보기",
|
"showCompleted": "완료된 대화 표시",
|
||||||
"viewGrouped": "폴더별 그룹",
|
"moreOptions": "더 많은 옵션",
|
||||||
|
"statusRunningBadge": "실행 중",
|
||||||
|
"statusFailedBadge": "실패",
|
||||||
|
"conversationCountUnit": "{count}개",
|
||||||
|
"emptyFolderHint": "대화 없음",
|
||||||
"noMatchingConversations": "일치하는 대화가 없습니다",
|
"noMatchingConversations": "일치하는 대화가 없습니다",
|
||||||
"removeFolderConfirmTitle": "이 폴더를 워크스페이스에서 제거하시겠습니까?",
|
"removeFolderConfirmTitle": "이 폴더를 워크스페이스에서 제거하시겠습니까?",
|
||||||
"removeFolderConfirmDescription": "워크스페이스에서 \"{name}\"을(를) 제거하시겠습니까? 관련 탭과 터미널이 닫힙니다.",
|
"removeFolderConfirmDescription": "워크스페이스에서 \"{name}\"을(를) 제거하시겠습니까? 관련 탭과 터미널이 닫힙니다.",
|
||||||
|
|||||||
@@ -834,8 +834,12 @@
|
|||||||
"statsLabel": "{folders} pastas · {convos} conversas",
|
"statsLabel": "{folders} pastas · {convos} conversas",
|
||||||
"openFolder": "Abrir pasta",
|
"openFolder": "Abrir pasta",
|
||||||
"searchPlaceholder": "Buscar conversas...",
|
"searchPlaceholder": "Buscar conversas...",
|
||||||
"viewFlat": "Visualização plana",
|
"showCompleted": "Mostrar conversas concluídas",
|
||||||
"viewGrouped": "Agrupar por pasta",
|
"moreOptions": "Mais opções",
|
||||||
|
"statusRunningBadge": "Executando",
|
||||||
|
"statusFailedBadge": "Falhou",
|
||||||
|
"conversationCountUnit": "{count, plural, one {# conversa} other {# conversas}}",
|
||||||
|
"emptyFolderHint": "Sem conversas",
|
||||||
"noMatchingConversations": "Nenhuma conversa correspondente",
|
"noMatchingConversations": "Nenhuma conversa correspondente",
|
||||||
"removeFolderConfirmTitle": "Remover pasta do espaço de trabalho?",
|
"removeFolderConfirmTitle": "Remover pasta do espaço de trabalho?",
|
||||||
"removeFolderConfirmDescription": "Remover \"{name}\" do espaço de trabalho? As abas e terminais relacionados serão fechados.",
|
"removeFolderConfirmDescription": "Remover \"{name}\" do espaço de trabalho? As abas e terminais relacionados serão fechados.",
|
||||||
|
|||||||
@@ -834,8 +834,12 @@
|
|||||||
"statsLabel": "{folders} 个文件夹 · {convos} 个会话",
|
"statsLabel": "{folders} 个文件夹 · {convos} 个会话",
|
||||||
"openFolder": "打开文件夹",
|
"openFolder": "打开文件夹",
|
||||||
"searchPlaceholder": "搜索会话...",
|
"searchPlaceholder": "搜索会话...",
|
||||||
"viewFlat": "平铺视图",
|
"showCompleted": "显示已完成会话",
|
||||||
"viewGrouped": "按文件夹分组",
|
"moreOptions": "更多选项",
|
||||||
|
"statusRunningBadge": "运行中",
|
||||||
|
"statusFailedBadge": "失败",
|
||||||
|
"conversationCountUnit": "{count} 条",
|
||||||
|
"emptyFolderHint": "暂无会话",
|
||||||
"noMatchingConversations": "未找到匹配的会话",
|
"noMatchingConversations": "未找到匹配的会话",
|
||||||
"removeFolderConfirmTitle": "从工作区移除该文件夹?",
|
"removeFolderConfirmTitle": "从工作区移除该文件夹?",
|
||||||
"removeFolderConfirmDescription": "从工作区移除 \"{name}\"?其相关 Tab 与终端将会关闭。",
|
"removeFolderConfirmDescription": "从工作区移除 \"{name}\"?其相关 Tab 与终端将会关闭。",
|
||||||
|
|||||||
@@ -834,8 +834,12 @@
|
|||||||
"statsLabel": "{folders} 個資料夾 · {convos} 個對話",
|
"statsLabel": "{folders} 個資料夾 · {convos} 個對話",
|
||||||
"openFolder": "開啟資料夾",
|
"openFolder": "開啟資料夾",
|
||||||
"searchPlaceholder": "搜尋對話...",
|
"searchPlaceholder": "搜尋對話...",
|
||||||
"viewFlat": "平鋪視圖",
|
"showCompleted": "顯示已完成對話",
|
||||||
"viewGrouped": "依資料夾分組",
|
"moreOptions": "更多選項",
|
||||||
|
"statusRunningBadge": "運行中",
|
||||||
|
"statusFailedBadge": "失敗",
|
||||||
|
"conversationCountUnit": "{count} 條",
|
||||||
|
"emptyFolderHint": "暫無對話",
|
||||||
"noMatchingConversations": "找不到符合的對話",
|
"noMatchingConversations": "找不到符合的對話",
|
||||||
"removeFolderConfirmTitle": "從工作區移除此資料夾?",
|
"removeFolderConfirmTitle": "從工作區移除此資料夾?",
|
||||||
"removeFolderConfirmDescription": "從工作區移除 \"{name}\"?相關分頁與終端機將會關閉。",
|
"removeFolderConfirmDescription": "從工作區移除 \"{name}\"?相關分頁與終端機將會關閉。",
|
||||||
|
|||||||
@@ -1,29 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
export type SidebarViewMode = "flat" | "grouped"
|
|
||||||
|
|
||||||
const VIEW_MODE_KEY = "workspace:sidebar-view-mode"
|
|
||||||
const FOLDER_EXPANDED_KEY = "workspace:sidebar-folder-expanded"
|
const FOLDER_EXPANDED_KEY = "workspace:sidebar-folder-expanded"
|
||||||
|
const SHOW_COMPLETED_KEY = "workspace:sidebar-show-completed"
|
||||||
export function loadSidebarViewMode(): SidebarViewMode {
|
|
||||||
if (typeof window === "undefined") return "flat"
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(VIEW_MODE_KEY)
|
|
||||||
if (raw === "flat" || raw === "grouped") return raw
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
return "flat"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveSidebarViewMode(mode: SidebarViewMode): void {
|
|
||||||
if (typeof window === "undefined") return
|
|
||||||
try {
|
|
||||||
localStorage.setItem(VIEW_MODE_KEY, mode)
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadFolderExpanded(): Record<number, boolean> {
|
export function loadFolderExpanded(): Record<number, boolean> {
|
||||||
if (typeof window === "undefined") return {}
|
if (typeof window === "undefined") return {}
|
||||||
@@ -53,3 +31,23 @@ export function saveFolderExpanded(state: Record<number, boolean>): void {
|
|||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function loadShowCompleted(): boolean {
|
||||||
|
if (typeof window === "undefined") return false
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SHOW_COMPLETED_KEY)
|
||||||
|
if (raw === "true") return true
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveShowCompleted(value: boolean): void {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SHOW_COMPLETED_KEY, String(value))
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user