refactor(sidebar): streamline conversation list with completed filter and expand toggle

Replace flat/grouped view toggle with a show-completed filter dropdown,
add expand/collapse-all control, extract status icon component, and
simplify the sidebar header.
This commit is contained in:
xintaofei
2026-04-21 17:07:40 +08:00
parent 58b48e2bfe
commit f0d530e1cb
15 changed files with 650 additions and 656 deletions

View File

@@ -1,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>

View File

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

View 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"
}
}

View File

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

View File

@@ -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}\" من مساحة العمل؟ سيتم إغلاق علامات التبويب والمحطات المرتبطة.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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}\" をワークスペースから削除しますか?関連するタブとターミナルが閉じられます。",

View File

@@ -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}\"을(를) 제거하시겠습니까? 관련 탭과 터미널이 닫힙니다.",

View File

@@ -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.",

View File

@@ -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 与终端将会关闭。",

View File

@@ -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}\"?相關分頁與終端機將會關閉。",

View File

@@ -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 */
}
}