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"
import { memo, useState, useCallback, useMemo } from "react"
import { formatDistanceToNow } from "date-fns"
import { enUS, zhCN, zhTW } from "date-fns/locale"
import { GitBranch, Pencil, Trash2, Circle, Download, Plus } from "lucide-react"
import { useLocale, useTranslations } from "next-intl"
import { memo, useState, useCallback } from "react"
import {
Pencil,
Trash2,
Circle,
CircleAlert,
CircleCheck,
CircleDashed,
CircleX,
Download,
Plus,
type LucideIcon,
} from "lucide-react"
import { useTranslations } from "next-intl"
import type { DbConversationSummary, ConversationStatus } from "@/lib/types"
import { STATUS_ORDER, STATUS_COLORS } from "@/lib/types"
import { STATUS_ORDER } from "@/lib/types"
import { cn } from "@/lib/utils"
import { AgentIcon } from "@/components/agent-icon"
import {
ContextMenu,
ContextMenuTrigger,
@@ -38,10 +46,29 @@ import {
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
SidebarStatusIcon,
conversationStatusToBead,
} from "./sidebar-status-icon"
const STATUS_ICONS: Record<ConversationStatus, LucideIcon> = {
in_progress: CircleDashed,
pending_review: CircleAlert,
completed: CircleCheck,
cancelled: CircleX,
}
const STATUS_ICON_COLORS: Record<ConversationStatus, string> = {
in_progress: "text-blue-500",
pending_review: "text-orange-500",
completed: "text-green-500",
cancelled: "text-red-500",
}
interface SidebarConversationCardProps {
conversation: DbConversationSummary
isSelected: boolean
timeLabel?: string
onSelect: (id: number, agentType: string) => void
onDoubleClick?: (id: number, agentType: string) => void
onRename: (id: number, newTitle: string) => Promise<void>
@@ -55,6 +82,7 @@ interface SidebarConversationCardProps {
export const SidebarConversationCard = memo(function SidebarConversationCard({
conversation,
isSelected,
timeLabel,
onSelect,
onDoubleClick,
onRename,
@@ -65,23 +93,12 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
importing,
}: SidebarConversationCardProps) {
const t = useTranslations("Folder.conversationCard")
const tSidebar = useTranslations("Folder.sidebar")
const tStatus = useTranslations("Folder.statusLabels")
const locale = useLocale()
const dateFnsLocale =
locale === "zh-CN" ? zhCN : locale === "zh-TW" ? zhTW : enUS
const [renameOpen, setRenameOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const [renameValue, setRenameValue] = useState("")
const timeAgo = useMemo(
() =>
formatDistanceToNow(new Date(conversation.updated_at), {
addSuffix: true,
locale: dateFnsLocale,
}),
[conversation.updated_at, dateFnsLocale]
)
const handleClick = useCallback(() => {
onSelect(conversation.id, conversation.agent_type)
}, [onSelect, conversation.id, conversation.agent_type])
@@ -108,40 +125,83 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
setDeleteOpen(false)
}, [conversation.id, conversation.agent_type, onDelete])
const status = conversation.status as ConversationStatus
const beadStatus = conversationStatusToBead(conversation.status)
const isRunning = status === "in_progress"
const isFailed = status === "cancelled"
return (
<>
<ContextMenu>
<ContextMenuTrigger asChild>
<button
data-conversation-id={conversation.id}
onClick={handleClick}
onDoubleClick={handleDblClick}
className={cn(
"w-full text-left px-3 py-2.5 mb-1 rounded-md transition-colors",
isSelected
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "hover:bg-sidebar-accent/50"
)}
>
<div className="flex items-center gap-1.5 min-w-0">
<AgentIcon
agentType={conversation.agent_type}
className="size-4 shrink-0"
<div className="relative h-[2rem]">
<button
data-conversation-id={conversation.id}
onClick={handleClick}
onDoubleClick={handleDblClick}
className={cn(
"relative flex h-[1.9375rem] w-full items-center gap-[0.625rem] text-left outline-none",
"rounded-[0.375rem] text-sidebar-foreground",
"transition-colors duration-[120ms]",
"pr-[0.5rem] pl-[1.875rem]",
isSelected
? "bg-sidebar-primary/15"
: "hover:bg-[color-mix(in_oklab,var(--sidebar-accent),var(--sidebar-foreground)_2%)]"
)}
>
<span
aria-hidden
className="pointer-events-none absolute top-0 bottom-0 rounded-[0.125rem] bg-sidebar-primary/5"
style={{ left: "0.9375rem", width: "0.125rem" }}
/>
<span className="text-sm font-medium truncate">
<SidebarStatusIcon status={beadStatus} />
<span
className={cn(
"relative min-w-0 flex-1 truncate text-[0.875rem]",
isSelected
? "font-semibold tracking-[-0.00625rem]"
: "font-normal"
)}
>
{conversation.title || t("untitledConversation")}
</span>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
<span>{timeAgo}</span>
{conversation.git_branch && (
<span className="flex items-center gap-0.5 truncate">
<GitBranch className="h-3 w-3 shrink-0" />
<span className="truncate">{conversation.git_branch}</span>
{isRunning ? (
<span
className={cn(
"relative shrink-0 rounded-[0.1875rem] px-[0.375rem] py-px",
"text-[0.6875rem] font-semibold tracking-[0.01875rem]",
"bg-amber-500/10 text-amber-600 dark:bg-amber-400/15 dark:text-amber-400"
)}
>
{tSidebar("statusRunningBadge")}
</span>
)}
</div>
</button>
) : isFailed ? (
<span
className={cn(
"relative shrink-0 rounded-[0.1875rem] px-[0.375rem] py-px",
"text-[0.6875rem] font-semibold tracking-[0.01875rem]",
"bg-destructive/10 text-destructive"
)}
>
{tSidebar("statusFailedBadge")}
</span>
) : timeLabel ? (
<span
className={cn(
"relative shrink-0 tabular-nums",
"text-[0.71875rem]",
isSelected
? "font-medium text-muted-foreground"
: "font-normal text-muted-foreground/70"
)}
>
{timeLabel}
</span>
) : null}
</button>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{onNewConversation && (
@@ -165,20 +225,20 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
</ContextMenuSubTrigger>
<ContextMenuSubContent>
{STATUS_ORDER.filter((s) => s !== conversation.status).map(
(s) => (
<ContextMenuItem
key={s}
onSelect={() => onStatusChange(conversation.id, s)}
>
<span
className={cn(
"w-2 h-2 rounded-full shrink-0",
STATUS_COLORS[s]
)}
/>
{tStatus(s)}
</ContextMenuItem>
)
(s) => {
const StatusIcon = STATUS_ICONS[s]
return (
<ContextMenuItem
key={s}
onSelect={() => onStatusChange(conversation.id, s)}
>
<StatusIcon
className={cn("h-4 w-4", STATUS_ICON_COLORS[s])}
/>
{tStatus(s)}
</ContextMenuItem>
)
}
)}
</ContextMenuSubContent>
</ContextMenuSub>

View File

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

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"
import { useCallback, useEffect, useRef, useState } from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
ChevronsDownUp,
ChevronsUpDown,
Crosshair,
FolderPlus,
FolderTree,
Plus,
Rows3,
Search,
X,
EllipsisVertical,
} from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { useActiveFolder } from "@/contexts/active-folder-context"
import { useAppWorkspace } from "@/contexts/app-workspace-context"
import { useTabContext } from "@/contexts/tab-context"
import { useSidebarContext } from "@/contexts/sidebar-context"
import { useAppWorkspace } from "@/contexts/app-workspace-context"
import {
SidebarConversationList,
type SidebarConversationListHandle,
} from "@/components/conversations/sidebar-conversation-list"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Tooltip,
TooltipContent,
@@ -31,165 +28,76 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useIsMobile } from "@/hooks/use-mobile"
import { isDesktop, openFileDialog } from "@/lib/platform"
import {
loadSidebarViewMode,
saveSidebarViewMode,
type SidebarViewMode,
loadShowCompleted,
saveShowCompleted,
} from "@/lib/sidebar-view-mode-storage"
import { cn } from "@/lib/utils"
export function Sidebar() {
const t = useTranslations("Folder.sidebar")
const { activeFolder } = useActiveFolder()
const { allFolders, conversations, openFolder } = useAppWorkspace()
const { openNewConversationTab } = useTabContext()
const { isOpen, toggle } = useSidebarContext()
const { conversations } = useAppWorkspace()
const isMobile = useIsMobile()
const listRef = useRef<SidebarConversationListHandle>(null)
const [viewMode, setViewMode] = useState<SidebarViewMode>("flat")
const [searchQuery, setSearchQuery] = useState("")
const [showCompleted, setShowCompleted] = useState(false)
const [allExpanded, setAllExpanded] = useState(true)
const visibleCount = useMemo(() => {
if (showCompleted) return conversations.length
return conversations.filter(
(c) => c.status !== "completed" && c.status !== "cancelled"
).length
}, [conversations, showCompleted])
useEffect(() => {
// Hydrate from localStorage after mount to keep SSR/CSR markup consistent.
// eslint-disable-next-line react-hooks/set-state-in-effect
setViewMode(loadSidebarViewMode())
setShowCompleted(loadShowCompleted())
}, [])
const handleSetViewMode = useCallback((mode: SidebarViewMode) => {
setViewMode(mode)
saveSidebarViewMode(mode)
const handleSetShowCompleted = useCallback((value: boolean) => {
setShowCompleted(value)
saveShowCompleted(value)
}, [])
const handleToggleExpandAll = useCallback(() => {
if (allExpanded) {
listRef.current?.collapseAll()
setAllExpanded(false)
} else {
listRef.current?.expandAll()
setAllExpanded(true)
}
}, [allExpanded])
useEffect(() => {
const onReveal = (e: Event) => {
const detail = (e as CustomEvent<{ folderId: number }>).detail
if (!detail) return
if (viewMode !== "grouped") {
setViewMode("grouped")
saveSidebarViewMode("grouped")
}
listRef.current?.revealFolder(detail.folderId)
}
window.addEventListener("sidebar:reveal-folder", onReveal)
return () => {
window.removeEventListener("sidebar:reveal-folder", onReveal)
}
}, [viewMode])
const handleNewConversation = useCallback(() => {
if (!activeFolder) return
openNewConversationTab(activeFolder.id, activeFolder.path)
}, [activeFolder, openNewConversationTab])
const handleOpenFolder = useCallback(async () => {
try {
if (!isDesktop()) {
toast.error(t("toasts.openFolderFailed"))
return
}
const result = await openFileDialog({
directory: true,
multiple: false,
})
if (!result) return
const selected = Array.isArray(result) ? result[0] : result
const detail = await openFolder(selected)
toast.success(t("toasts.folderOpened", { name: detail.name }))
} catch (err) {
console.error("[Sidebar] open folder failed:", err)
toast.error(t("toasts.openFolderFailed"))
}
}, [openFolder, t])
}, [])
if (!isOpen) return null
return (
<aside className="group/sidebar flex h-full min-h-0 flex-col overflow-hidden bg-sidebar text-sidebar-foreground select-none">
<aside className="flex h-full min-h-0 flex-col overflow-hidden bg-sidebar text-sidebar-foreground select-none">
<TooltipProvider>
<div className="flex items-center justify-between border-b border-border px-3 py-2 gap-2">
<div className="flex items-center gap-2 min-w-0 text-[11px] text-muted-foreground tabular-nums">
<span className="truncate">
{t("statsLabel", {
folders: allFolders.length,
convos: conversations.length,
})}
<div className="flex h-10 shrink-0 items-center justify-between gap-2 pl-[1.25rem] pr-2">
<div className="flex min-w-0 items-baseline gap-[0.375rem]">
<h2 className="truncate text-[0.875rem] font-bold tracking-[-0.00625rem] text-sidebar-foreground">
{t("title")}
</h2>
<span className="shrink-0 text-[0.75rem] tabular-nums text-muted-foreground/70">
{t("conversationCountUnit", { count: visibleCount })}
</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground"
onClick={handleOpenFolder}
>
<FolderPlus className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("openFolder")}</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
<div className="relative flex-1 min-w-0">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground pointer-events-none" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t("searchPlaceholder")}
className="h-7 pl-6 pr-6 text-xs"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground rounded-sm p-0.5"
>
<X className="h-3 w-3" />
</button>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 shrink-0 text-muted-foreground",
viewMode === "flat" && "bg-accent text-foreground"
)}
onClick={() => handleSetViewMode("flat")}
>
<Rows3 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("viewFlat")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 shrink-0 text-muted-foreground",
viewMode === "grouped" && "bg-accent text-foreground"
)}
onClick={() => handleSetViewMode("grouped")}
>
<FolderTree className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("viewGrouped")}</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center justify-between border-b border-border px-2 h-7">
<h2 className="text-xs font-bold text-muted-foreground truncate">
{t("title")}
</h2>
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover/sidebar:opacity-100">
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
@@ -203,30 +111,43 @@ export function Sidebar() {
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground"
onClick={() => listRef.current?.expandAll()}
title={t("expandAllGroups")}
onClick={handleToggleExpandAll}
title={
allExpanded ? t("collapseAllGroups") : t("expandAllGroups")
}
>
<ChevronsUpDown className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground"
onClick={() => listRef.current?.collapseAll()}
title={t("collapseAllGroups")}
>
<ChevronsDownUp className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground"
onClick={handleNewConversation}
disabled={!activeFolder}
title={t("newConversation")}
>
<Plus className="h-3.5 w-3.5" />
{allExpanded ? (
<ChevronsDownUp className="h-3.5 w-3.5" />
) : (
<ChevronsUpDown className="h-3.5 w-3.5" />
)}
</Button>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground"
>
<EllipsisVertical className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">
{t("moreOptions")}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuCheckboxItem
checked={showCompleted}
onCheckedChange={handleSetShowCompleted}
>
{t("showCompleted")}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</TooltipProvider>
@@ -245,11 +166,7 @@ export function Sidebar() {
: undefined
}
>
<SidebarConversationList
ref={listRef}
viewMode={viewMode}
searchQuery={searchQuery}
/>
<SidebarConversationList ref={listRef} showCompleted={showCompleted} />
</div>
</aside>
)

View File

@@ -834,8 +834,12 @@
"statsLabel": "{folders} مجلدات · {convos} محادثة",
"openFolder": "فتح مجلد",
"searchPlaceholder": "بحث عن محادثات...",
"viewFlat": "عرض مسطح",
"viewGrouped": "تجميع حسب المجلد",
"showCompleted": "عرض المحادثات المكتملة",
"moreOptions": "المزيد من الخيارات",
"statusRunningBadge": "قيد التشغيل",
"statusFailedBadge": "فشل",
"conversationCountUnit": "{count} محادثة",
"emptyFolderHint": "لا توجد محادثات",
"noMatchingConversations": "لا توجد محادثات مطابقة",
"removeFolderConfirmTitle": "إزالة المجلد من مساحة العمل؟",
"removeFolderConfirmDescription": "إزالة \"{name}\" من مساحة العمل؟ سيتم إغلاق علامات التبويب والمحطات المرتبطة.",

View File

@@ -834,8 +834,12 @@
"statsLabel": "{folders} Ordner · {convos} Konversationen",
"openFolder": "Ordner öffnen",
"searchPlaceholder": "Konversationen suchen...",
"viewFlat": "Flache Ansicht",
"viewGrouped": "Nach Ordner gruppieren",
"showCompleted": "Abgeschlossene Konversationen anzeigen",
"moreOptions": "Weitere Optionen",
"statusRunningBadge": "Läuft",
"statusFailedBadge": "Fehlgeschlagen",
"conversationCountUnit": "{count, plural, one {# Konversation} other {# Konversationen}}",
"emptyFolderHint": "Keine Konversationen",
"noMatchingConversations": "Keine passenden Konversationen",
"removeFolderConfirmTitle": "Ordner aus Arbeitsbereich entfernen?",
"removeFolderConfirmDescription": "\"{name}\" aus dem Arbeitsbereich entfernen? Zugehörige Tabs und Terminals werden geschlossen.",

View File

@@ -834,8 +834,12 @@
"statsLabel": "{folders} folders · {convos} conversations",
"openFolder": "Open Folder",
"searchPlaceholder": "Search conversations...",
"viewFlat": "Flat view",
"viewGrouped": "Grouped by folder",
"showCompleted": "Show completed conversations",
"moreOptions": "More options",
"statusRunningBadge": "Running",
"statusFailedBadge": "Failed",
"conversationCountUnit": "{count, plural, one {# conversation} other {# conversations}}",
"emptyFolderHint": "No conversations",
"noMatchingConversations": "No matching conversations",
"removeFolderConfirmTitle": "Remove folder from workspace?",
"removeFolderConfirmDescription": "Remove \"{name}\" from the workspace? Its tabs and terminals will close.",

View File

@@ -834,8 +834,12 @@
"statsLabel": "{folders} carpetas · {convos} conversaciones",
"openFolder": "Abrir carpeta",
"searchPlaceholder": "Buscar conversaciones...",
"viewFlat": "Vista plana",
"viewGrouped": "Agrupar por carpeta",
"showCompleted": "Mostrar conversaciones completadas",
"moreOptions": "Más opciones",
"statusRunningBadge": "Ejecutando",
"statusFailedBadge": "Fallido",
"conversationCountUnit": "{count, plural, one {# conversación} other {# conversaciones}}",
"emptyFolderHint": "Sin conversaciones",
"noMatchingConversations": "No hay conversaciones coincidentes",
"removeFolderConfirmTitle": "¿Eliminar carpeta del espacio de trabajo?",
"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",
"openFolder": "Ouvrir le dossier",
"searchPlaceholder": "Rechercher des conversations...",
"viewFlat": "Vue à plat",
"viewGrouped": "Grouper par dossier",
"showCompleted": "Afficher les conversations terminées",
"moreOptions": "Plus d'options",
"statusRunningBadge": "En cours",
"statusFailedBadge": "Échec",
"conversationCountUnit": "{count, plural, one {# conversation} other {# conversations}}",
"emptyFolderHint": "Aucune conversation",
"noMatchingConversations": "Aucune conversation correspondante",
"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.",

View File

@@ -834,8 +834,12 @@
"statsLabel": "{folders} フォルダ · {convos} 会話",
"openFolder": "フォルダを開く",
"searchPlaceholder": "会話を検索...",
"viewFlat": "フラット表示",
"viewGrouped": "フォルダでグループ化",
"showCompleted": "完了した会話を表示",
"moreOptions": "その他のオプション",
"statusRunningBadge": "実行中",
"statusFailedBadge": "失敗",
"conversationCountUnit": "{count} 件",
"emptyFolderHint": "会話がありません",
"noMatchingConversations": "一致する会話がありません",
"removeFolderConfirmTitle": "このフォルダをワークスペースから削除しますか?",
"removeFolderConfirmDescription": "\"{name}\" をワークスペースから削除しますか?関連するタブとターミナルが閉じられます。",

View File

@@ -834,8 +834,12 @@
"statsLabel": "{folders}개 폴더 · {convos}개 대화",
"openFolder": "폴더 열기",
"searchPlaceholder": "대화 검색...",
"viewFlat": "평면 보기",
"viewGrouped": "폴더별 그룹",
"showCompleted": "완료된 대화 표시",
"moreOptions": "더 많은 옵션",
"statusRunningBadge": "실행 중",
"statusFailedBadge": "실패",
"conversationCountUnit": "{count}개",
"emptyFolderHint": "대화 없음",
"noMatchingConversations": "일치하는 대화가 없습니다",
"removeFolderConfirmTitle": "이 폴더를 워크스페이스에서 제거하시겠습니까?",
"removeFolderConfirmDescription": "워크스페이스에서 \"{name}\"을(를) 제거하시겠습니까? 관련 탭과 터미널이 닫힙니다.",

View File

@@ -834,8 +834,12 @@
"statsLabel": "{folders} pastas · {convos} conversas",
"openFolder": "Abrir pasta",
"searchPlaceholder": "Buscar conversas...",
"viewFlat": "Visualização plana",
"viewGrouped": "Agrupar por pasta",
"showCompleted": "Mostrar conversas concluídas",
"moreOptions": "Mais opções",
"statusRunningBadge": "Executando",
"statusFailedBadge": "Falhou",
"conversationCountUnit": "{count, plural, one {# conversa} other {# conversas}}",
"emptyFolderHint": "Sem conversas",
"noMatchingConversations": "Nenhuma conversa correspondente",
"removeFolderConfirmTitle": "Remover pasta do espaço de trabalho?",
"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} 个会话",
"openFolder": "打开文件夹",
"searchPlaceholder": "搜索会话...",
"viewFlat": "平铺视图",
"viewGrouped": "按文件夹分组",
"showCompleted": "显示已完成会话",
"moreOptions": "更多选项",
"statusRunningBadge": "运行中",
"statusFailedBadge": "失败",
"conversationCountUnit": "{count} 条",
"emptyFolderHint": "暂无会话",
"noMatchingConversations": "未找到匹配的会话",
"removeFolderConfirmTitle": "从工作区移除该文件夹?",
"removeFolderConfirmDescription": "从工作区移除 \"{name}\"?其相关 Tab 与终端将会关闭。",

View File

@@ -834,8 +834,12 @@
"statsLabel": "{folders} 個資料夾 · {convos} 個對話",
"openFolder": "開啟資料夾",
"searchPlaceholder": "搜尋對話...",
"viewFlat": "平鋪視圖",
"viewGrouped": "依資料夾分組",
"showCompleted": "顯示已完成對話",
"moreOptions": "更多選項",
"statusRunningBadge": "運行中",
"statusFailedBadge": "失敗",
"conversationCountUnit": "{count} 條",
"emptyFolderHint": "暫無對話",
"noMatchingConversations": "找不到符合的對話",
"removeFolderConfirmTitle": "從工作區移除此資料夾?",
"removeFolderConfirmDescription": "從工作區移除 \"{name}\"?相關分頁與終端機將會關閉。",

View File

@@ -1,29 +1,7 @@
"use client"
export type SidebarViewMode = "flat" | "grouped"
const VIEW_MODE_KEY = "workspace:sidebar-view-mode"
const FOLDER_EXPANDED_KEY = "workspace:sidebar-folder-expanded"
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 */
}
}
const SHOW_COMPLETED_KEY = "workspace:sidebar-show-completed"
export function loadFolderExpanded(): Record<number, boolean> {
if (typeof window === "undefined") return {}
@@ -53,3 +31,23 @@ export function saveFolderExpanded(state: Record<number, boolean>): void {
/* 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 */
}
}