Files
codeg/src/components/conversations/sidebar-conversation-list.tsx

1163 lines
37 KiB
TypeScript

"use client"
import {
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type Ref,
} from "react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Reorder } from "motion/react"
import type { OverlayScrollbarsComponentRef } from "overlayscrollbars-react"
import {
ChevronDown,
ChevronRight,
Download,
FolderOpen,
GitBranch,
ListChecks,
Loader2,
Palette,
Plus,
Rocket,
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,
openProjectBootWindow,
updateConversationTitle,
updateConversationStatus,
updateFolderColor,
deleteConversation,
} from "@/lib/api"
import { isDesktop, openFileDialog } from "@/lib/platform"
import type { ConversationStatus, DbConversationSummary } from "@/lib/types"
import {
loadFolderExpanded,
saveFolderExpanded,
type SidebarSortMode,
} from "@/lib/sidebar-view-mode-storage"
import { SidebarConversationCard } from "./sidebar-conversation-card"
import { ConversationManageDialog } from "./conversation-manage-dialog"
import { CloneDialog } from "@/components/layout/clone-dialog"
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
} from "@/components/ui/context-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { cn } from "@/lib/utils"
function parseTimestamp(value: string): number {
const timestamp = Date.parse(value)
return Number.isNaN(timestamp) ? 0 : timestamp
}
function compareByUpdatedAtDesc(
left: DbConversationSummary,
right: DbConversationSummary
): number {
const updatedDiff =
parseTimestamp(right.updated_at) - parseTimestamp(left.updated_at)
if (updatedDiff !== 0) return updatedDiff
const createdDiff =
parseTimestamp(right.created_at) - parseTimestamp(left.created_at)
if (createdDiff !== 0) return createdDiff
return right.id - left.id
}
function compareByCreatedAtDesc(
left: DbConversationSummary,
right: DbConversationSummary
): number {
const createdDiff =
parseTimestamp(right.created_at) - parseTimestamp(left.created_at)
if (createdDiff !== 0) return createdDiff
const updatedDiff =
parseTimestamp(right.updated_at) - parseTimestamp(left.updated_at)
if (updatedDiff !== 0) return updatedDiff
return right.id - left.id
}
// Sentinel stored in the DB that resolves to the current sidebar foreground
// color — the swatch then always reads as the folder name does, across themes.
const FOREGROUND_SWATCH = "foreground"
// Kept in sync with Rust-side `FOLDER_COLOR_PALETTE` in
// `src-tauri/src/db/service/folder_service.rs`. Nine well-separated hues
// spanning the color wheel (skipping the blue band that reads as muddy),
// plus a theme-aware neutral that tracks the sidebar text color.
const FOLDER_SWATCH_PALETTE = [
"#ef4444", // red
"#f97316", // orange
"#eab308", // yellow
"#84cc16", // lime
"#22c55e", // green
"#06b6d4", // cyan
"#8b5cf6", // violet
"#d946ef", // fuchsia
"#ec4899", // pink
FOREGROUND_SWATCH,
] as const
function resolveSwatchColor(swatch: string): string {
return swatch === FOREGROUND_SWATCH ? "var(--sidebar-foreground)" : swatch
}
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`
}
const FolderHeader = memo(function FolderHeader({
folderId,
folderName,
count,
expanded,
importing,
color,
onToggle,
onRemoveFromWorkspace,
onNewConversation,
onImport,
onManageConversations,
onChangeColor,
isDragging,
t,
}: {
folderId: number
folderName: string
count: number
expanded: boolean
importing: boolean
color: string
onToggle: (folderId: number) => void
onRemoveFromWorkspace: (folderId: number) => void
onNewConversation: (folderId: number) => void
onImport: (folderId: number) => void
onManageConversations: (folderId: number) => void
onChangeColor: (folderId: number, color: string) => void
isDragging?: boolean
t: ReturnType<typeof useTranslations>
}) {
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div className={cn("relative h-[2rem]", isDragging && "opacity-60")}>
<div
className={cn(
"group flex h-[1.9375rem] w-full items-center",
"rounded-full",
"transition-colors duration-150",
isDragging
? "cursor-grabbing"
: "cursor-grab hover:bg-[color-mix(in_oklab,var(--sidebar-accent),var(--sidebar-foreground)_2%)]"
)}
>
<button
data-folder-id={folderId}
onClick={() => onToggle(folderId)}
className={cn(
"relative flex h-full min-w-0 flex-1 items-center pr-[0.5rem] outline-none",
"text-sidebar-foreground",
isDragging ? "cursor-grabbing" : "cursor-grab"
)}
style={{ paddingLeft: "calc(var(--conv-rail-axis) + 0.875rem)" }}
>
<span
aria-hidden
className={cn(
"pointer-events-none absolute flex items-center justify-center text-muted-foreground/75"
)}
style={{
top: "50%",
left: "var(--conv-rail-axis)",
width: "0.75rem",
height: "0.75rem",
transform: "translate(-50%, -50%)",
}}
>
{expanded ? (
<ChevronDown className="h-[0.6875rem] w-[0.6875rem]" />
) : (
<ChevronRight className="h-[0.6875rem] w-[0.6875rem]" />
)}
</span>
<div className="flex min-w-0 flex-1 items-center gap-[0.5rem]">
<span
aria-hidden
className="inline-block h-[0.5rem] w-[0.5rem] shrink-0 rounded-[0.125rem]"
style={{ backgroundColor: resolveSwatchColor(color) }}
/>
<span
className={cn(
"min-w-0 flex-shrink truncate text-left text-[0.875rem] font-semibold tracking-[-0.00625rem]"
)}
>
{folderName}
</span>
<span
className={cn(
"inline-flex shrink-0 items-center justify-center",
"h-[0.9375rem] min-w-[1rem] rounded-[0.3125rem] px-[0.25rem]",
"text-[0.625rem] font-semibold leading-none tabular-nums",
"bg-[color-mix(in_oklab,var(--sidebar-accent),var(--sidebar-foreground)_6%)] text-muted-foreground/80"
)}
>
{count}
</span>
</div>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onNewConversation(folderId)
}}
title={t("newConversation")}
aria-label={t("newConversation")}
className={cn(
"mr-[0.25rem] flex h-[1.25rem] w-[1.25rem] shrink-0 items-center justify-center",
"rounded-[0.25rem] cursor-pointer outline-none text-muted-foreground/80",
"opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
"transition-opacity duration-150 hover:text-sidebar-foreground"
)}
>
<Plus className="h-[0.75rem] w-[0.75rem]" />
</button>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => onNewConversation(folderId)}>
<Plus className="h-4 w-4" />
{t("newConversation")}
</ContextMenuItem>
<ContextMenuItem
disabled={importing}
onSelect={() => onImport(folderId)}
>
<Download className="h-4 w-4" />
{importing ? t("importing") : t("importLocalSessions")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => onManageConversations(folderId)}>
<ListChecks className="h-4 w-4" />
{t("folderHeaderMenu.manageConversations")}
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>
<Palette className="h-4 w-4" />
{t("folderHeaderMenu.changeColor")}
</ContextMenuSubTrigger>
<ContextMenuSubContent className="min-w-[9rem] p-2">
<div className="grid grid-cols-10 gap-1">
{FOLDER_SWATCH_PALETTE.map((swatch) => {
const active = swatch.toLowerCase() === color.toLowerCase()
return (
<button
key={swatch}
type="button"
title={swatch}
aria-label={swatch}
onClick={() => onChangeColor(folderId, swatch)}
className={cn(
"h-[1.125rem] w-[1.125rem] cursor-pointer rounded-[0.25rem]",
"outline-none ring-offset-1 ring-offset-popover",
"transition-[box-shadow,transform] duration-100 hover:scale-110",
active && "ring-2 ring-foreground/60"
)}
style={{ backgroundColor: resolveSwatchColor(swatch) }}
/>
)
})}
</div>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
onSelect={() => onRemoveFromWorkspace(folderId)}
>
<XCircle className="h-4 w-4" />
{t("folderHeaderMenu.removeFromWorkspace")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})
interface FolderGroupItemProps {
folderId: number
folderName: string
conversations: DbConversationSummary[]
totalConversationCount: number
expanded: boolean
importing: boolean
reordering: boolean
dragging: boolean
sortMode: SidebarSortMode
selectedConversation: { id: number; agentType: string } | null
openTabConversationKeys: Set<string>
color: string
onToggle: (folderId: number) => void
onRemoveFromWorkspace: (folderId: number) => void
onNewConversationForFolder: (folderId: number) => void
onImport: (folderId: number) => void
onManageConversations: (folderId: number) => void
onChangeColor: (folderId: number, color: string) => void
onSelect: (id: number, agentType: string) => void
onDoubleClick: (id: number, agentType: string) => void
onRename: (id: number, newTitle: string) => Promise<void>
onDelete: (id: number, agentType: string) => Promise<void>
onStatusChange: (id: number, status: ConversationStatus) => Promise<void>
onNewConversation: () => void
onDragStart: (folderId: number) => void
onDragEnd: () => void
stackIndex: number
t: ReturnType<typeof useTranslations>
}
const DRAGGING_Z_INDEX = 10_000
function FolderGroupItem({
folderId,
folderName,
conversations,
totalConversationCount,
expanded,
importing,
reordering,
dragging,
sortMode,
selectedConversation,
openTabConversationKeys,
color,
onToggle,
onRemoveFromWorkspace,
onNewConversationForFolder,
onImport,
onManageConversations,
onChangeColor,
onSelect,
onDoubleClick,
onRename,
onDelete,
onStatusChange,
onNewConversation,
onDragStart,
onDragEnd,
stackIndex,
t,
}: FolderGroupItemProps) {
const justDraggedRef = useRef(false)
const handleToggle = useCallback(
(id: number) => {
if (justDraggedRef.current) {
justDraggedRef.current = false
return
}
onToggle(id)
},
[onToggle]
)
const handleDragStart = useCallback(() => {
justDraggedRef.current = true
onDragStart(folderId)
}, [folderId, onDragStart])
// Wrap Reorder.Item in a plain div that owns the zIndex. Framer's Reorder.Item
// internally overrides `style.zIndex` (forces 1 while dragging, "unset" at rest),
// so any zIndex set directly on the Item is discarded. `isolation: isolate`
// forces a real stacking context on each wrapper so earlier folders' sticky
// headers always paint above later folders' conversation rows when scrolled.
return (
<div
className="relative"
style={{
isolation: "isolate",
zIndex: dragging ? DRAGGING_Z_INDEX : stackIndex,
}}
>
<Reorder.Item
as="div"
value={folderId}
drag={reordering ? false : "y"}
dragMomentum={false}
layout="position"
onDragStart={handleDragStart}
onDragEnd={onDragEnd}
>
<div
className={cn(
"sticky top-0 z-20 bg-sidebar",
dragging && "shadow-sm"
)}
>
<FolderHeader
folderId={folderId}
folderName={folderName}
count={conversations.length}
expanded={expanded}
importing={importing}
color={color}
onToggle={handleToggle}
onRemoveFromWorkspace={onRemoveFromWorkspace}
onNewConversation={onNewConversationForFolder}
onImport={onImport}
onManageConversations={onManageConversations}
onChangeColor={onChangeColor}
isDragging={dragging}
t={t}
/>
</div>
{expanded &&
(conversations.length === 0 ? (
<div
className="py-[0.375rem] text-[0.75rem] text-muted-foreground/70"
style={{
paddingLeft: "calc(var(--conv-rail-axis) + 0.875rem)",
}}
>
{totalConversationCount === 0
? t("emptyFolderHint")
: t("noMatchingConversations")}
</div>
) : (
conversations.map((conv) => (
<SidebarConversationCard
key={`conv-${conv.agent_type}-${conv.id}`}
conversation={conv}
isSelected={
selectedConversation?.agentType === conv.agent_type &&
selectedConversation?.id === conv.id
}
isOpenInTab={openTabConversationKeys.has(
`${conv.agent_type}:${conv.id}`
)}
timeLabel={formatRelative(
sortMode === "updated" ? conv.updated_at : conv.created_at
)}
onSelect={onSelect}
onDoubleClick={onDoubleClick}
onRename={onRename}
onDelete={onDelete}
onStatusChange={onStatusChange}
onNewConversation={onNewConversation}
/>
))
))}
</Reorder.Item>
</div>
)
}
export interface SidebarConversationListHandle {
scrollToActive: () => void
expandAll: () => void
collapseAll: () => void
}
export interface SidebarConversationListProps {
showCompleted?: boolean
sortMode?: SidebarSortMode
}
export function SidebarConversationList({
ref,
showCompleted = true,
sortMode = "created",
}: SidebarConversationListProps & {
ref?: Ref<SidebarConversationListHandle>
}) {
const t = useTranslations("Folder.sidebar")
const tCommon = useTranslations("Folder.common")
const tFolderDropdown = useTranslations("Folder.folderNameDropdown")
useZoomLevel()
const {
folders,
allFolders,
conversations,
conversationsLoading: loading,
conversationsError: error,
refreshConversations,
updateConversationLocal,
removeFolderFromWorkspace,
reorderFolders,
openFolder,
refreshFolder,
} = useAppWorkspace()
const refreshing = loading
const { activeFolder } = useActiveFolder()
const {
openTab,
closeConversationTab,
closeTabsByFolder,
openNewConversationTab,
activeTabId,
tabs,
} = useTabContext()
const { addTask, updateTask } = useTaskContext()
const folderIndex = useMemo(() => {
const map = new Map<number, { name: string; path: string; color: string }>()
for (const f of allFolders)
map.set(f.id, { name: f.name, path: f.path, color: f.color })
return map
}, [allFolders])
const selectedConversation = useMemo(() => {
const activeTab = tabs.find((tab) => tab.id === activeTabId)
if (!activeTab || activeTab.conversationId == null) return null
return {
id: activeTab.conversationId,
agentType: activeTab.agentType,
}
}, [tabs, activeTabId])
const openTabConversationKeys = useMemo(() => {
const set = new Set<string>()
for (const tab of tabs) {
if (tab.conversationId != null) {
set.add(`${tab.agentType}:${tab.conversationId}`)
}
}
return set
}, [tabs])
const [importing, setImporting] = useState(false)
const [folderExpanded, setFolderExpanded] = useState<Record<number, boolean>>(
{}
)
const [removeConfirm, setRemoveConfirm] = useState<{
folderId: number
folderName: string
} | null>(null)
const [manageState, setManageState] = useState<{
folderId: number
folderName: string
} | null>(null)
const [cloneOpen, setCloneOpen] = useState(false)
const [browserOpen, setBrowserOpen] = useState(false)
const [dragging, setDragging] = useState<number | null>(null)
const [reordering, setReordering] = useState(false)
const [dragOrder, setDragOrder] = useState<number[] | null>(null)
const pendingOrderRef = useRef<number[] | null>(null)
useEffect(() => {
// Hydrate from localStorage after mount to keep SSR/CSR markup consistent.
setFolderExpanded(loadFolderExpanded())
}, [])
const handleChangeFolderColor = useCallback(
async (folderId: number, color: string) => {
try {
await updateFolderColor(folderId, color)
await refreshFolder(folderId)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
toast.error(t("toasts.changeFolderColorFailed", { message: msg }))
}
},
[refreshFolder, t]
)
const scrollRootRef = useRef<OverlayScrollbarsComponentRef>(null)
const scrollToActiveRef = useRef<() => void>(() => {})
const pendingScrollRef = useRef(false)
const filteredConversations = useMemo(() => {
if (showCompleted) return conversations
return conversations.filter(
(c) => c.status !== "completed" && c.status !== "cancelled"
)
}, [conversations, showCompleted])
const byFolder = useMemo(() => {
const map = new Map<number, DbConversationSummary[]>()
for (const conv of filteredConversations) {
const list = map.get(conv.folder_id)
if (list) list.push(conv)
else map.set(conv.folder_id, [conv])
}
const comparator =
sortMode === "updated" ? compareByUpdatedAtDesc : compareByCreatedAtDesc
for (const list of map.values()) list.sort(comparator)
return map
}, [filteredConversations, sortMode])
const folderTotalCounts = useMemo(() => {
const map = new Map<number, number>()
for (const conv of conversations) {
map.set(conv.folder_id, (map.get(conv.folder_id) ?? 0) + 1)
}
return map
}, [conversations])
const orderedFolderIds = useMemo(() => {
const folderIdSet = new Set(folders.map((f) => f.id))
// During drag we honour the optimistic order so sibling folders shift live
// as the user hovers over slots. We still filter/append against the source
// of truth so newly-added or -removed folders don't disappear mid-drag.
if (dragOrder) {
const seen = new Set<number>()
const ids: number[] = []
for (const id of dragOrder) {
if (folderIdSet.has(id) && !seen.has(id)) {
seen.add(id)
ids.push(id)
}
}
for (const f of folders) {
if (!seen.has(f.id)) {
seen.add(f.id)
ids.push(f.id)
}
}
return ids
}
const seen = new Set<number>()
const ids: number[] = []
for (const f of folders) {
if (!seen.has(f.id)) {
seen.add(f.id)
ids.push(f.id)
}
}
return ids
}, [folders, dragOrder])
useImperativeHandle(ref, () => ({
scrollToActive() {
scrollToActiveRef.current()
},
expandAll() {
setFolderExpanded((prev) => {
const next: Record<number, boolean> = { ...prev }
for (const id of orderedFolderIds) next[id] = true
saveFolderExpanded(next)
return next
})
},
collapseAll() {
setFolderExpanded((prev) => {
const next: Record<number, boolean> = { ...prev }
for (const id of orderedFolderIds) next[id] = false
saveFolderExpanded(next)
return next
})
},
}))
useEffect(() => {
scrollToActiveRef.current = () => {
if (!selectedConversation) return
const targetId = selectedConversation.id
const targetAgent = selectedConversation.agentType
const conv = conversations.find(
(c) => c.id === targetId && c.agent_type === targetAgent
)
if (!conv) return
if (!(folderExpanded[conv.folder_id] ?? true)) {
setFolderExpanded((prev) => {
const next = { ...prev, [conv.folder_id]: true }
saveFolderExpanded(next)
return next
})
pendingScrollRef.current = true
return
}
const root = scrollRootRef.current?.getElement()
if (!root) return
const selector = `[data-conv-key="${targetAgent}:${targetId}"]`
const el = root.querySelector(selector)
if (el instanceof HTMLElement) {
el.scrollIntoView({ block: "center", behavior: "smooth" })
}
}
if (pendingScrollRef.current) {
pendingScrollRef.current = false
scrollToActiveRef.current()
}
}, [selectedConversation, conversations, folderExpanded])
const toggleFolder = useCallback((folderId: number) => {
setFolderExpanded((prev) => {
const next = { ...prev, [folderId]: !(prev[folderId] ?? true) }
saveFolderExpanded(next)
return next
})
}, [])
const handleRemoveFolder = useCallback(
(folderId: number) => {
const name = folderIndex.get(folderId)?.name ?? String(folderId)
setRemoveConfirm({ folderId, folderName: name })
},
[folderIndex]
)
const handleManageConversations = useCallback(
(folderId: number) => {
const name = folderIndex.get(folderId)?.name ?? String(folderId)
setManageState({ folderId, folderName: name })
},
[folderIndex]
)
const handleRemoveFolderConfirm = useCallback(async () => {
if (!removeConfirm) return
const { folderId, folderName } = removeConfirm
try {
closeTabsByFolder(folderId)
await removeFolderFromWorkspace(folderId)
toast.success(t("toasts.folderRemoved", { name: folderName }))
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
toast.error(t("toasts.removeFolderFailed", { message: msg }))
} finally {
setRemoveConfirm(null)
}
}, [removeConfirm, closeTabsByFolder, removeFolderFromWorkspace, t])
const handleSelect = useCallback(
(id: number, agentType: string) => {
const conv = conversations.find(
(c) => c.id === id && c.agent_type === agentType
)
if (!conv) return
openTab(
conv.folder_id,
id,
agentType as Parameters<typeof openTab>[2],
false
)
},
[openTab, conversations]
)
const handleDoubleClick = useCallback(
(id: number, agentType: string) => {
const conv = conversations.find(
(c) => c.id === id && c.agent_type === agentType
)
if (!conv) return
openTab(
conv.folder_id,
id,
agentType as Parameters<typeof openTab>[2],
true
)
},
[openTab, conversations]
)
const handleRename = useCallback(
async (id: number, newTitle: string) => {
await updateConversationTitle(id, newTitle)
refreshConversations()
},
[refreshConversations]
)
const handleDelete = useCallback(
async (id: number, agentType: string) => {
const conv = conversations.find(
(c) => c.id === id && c.agent_type === agentType
)
await deleteConversation(id)
if (conv) {
closeConversationTab(
conv.folder_id,
id,
agentType as Parameters<typeof openTab>[2]
)
}
refreshConversations()
},
[closeConversationTab, refreshConversations, conversations]
)
const handleStatusChange = useCallback(
async (id: number, status: ConversationStatus) => {
updateConversationLocal(id, { status })
await updateConversationStatus(id, status)
},
[updateConversationLocal]
)
const handleNewConversation = useCallback(() => {
if (!activeFolder) return
openNewConversationTab(activeFolder.id, activeFolder.path)
}, [activeFolder, openNewConversationTab])
const handleNewConversationForFolder = useCallback(
(folderId: number) => {
const folder = folderIndex.get(folderId)
if (!folder) return
openNewConversationTab(folderId, folder.path)
},
[folderIndex, openNewConversationTab]
)
const handleImportForFolder = useCallback(
async (folderId: number) => {
if (importing) return
setImporting(true)
const taskId = `import-${folderId}-${Date.now()}`
addTask(taskId, t("importLocalSessions"))
updateTask(taskId, { status: "running" })
try {
const result = await importLocalConversations(folderId)
updateTask(taskId, { status: "completed" })
refreshConversations()
if (result.imported > 0) {
toast.success(
t("toasts.importedSessions", {
imported: result.imported,
skipped: result.skipped,
})
)
} else {
toast.info(
t("toasts.noNewSessionsFound", { skipped: result.skipped })
)
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
updateTask(taskId, { status: "failed", error: msg })
toast.error(t("toasts.importFailed", { message: msg }))
} finally {
setImporting(false)
}
},
[importing, addTask, updateTask, refreshConversations, t]
)
const persistReorder = useCallback(
async (order: number[]) => {
if (order.length === 0) return
setReordering(true)
try {
await reorderFolders(order)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
toast.error(t("toasts.reorderFoldersFailed", { message: msg }))
} finally {
setReordering(false)
}
},
[reorderFolders, t]
)
const handleReorder = useCallback((nextIds: number[]) => {
pendingOrderRef.current = nextIds
setDragOrder(nextIds)
}, [])
const handleDragStart = useCallback((folderId: number) => {
setDragging(folderId)
}, [])
const handleDragEnd = useCallback(async () => {
setDragging(null)
const order = pendingOrderRef.current
pendingOrderRef.current = null
if (!order) {
setDragOrder(null)
return
}
try {
await persistReorder(order)
} finally {
// Clear the optimistic override once the workspace context's folders
// have absorbed the new order (or on failure, the rollback in the
// context restores the original order).
setDragOrder(null)
}
}, [persistReorder])
const handleOpenFolderAction = useCallback(async () => {
if (isDesktop()) {
try {
const result = await openFileDialog({
directory: true,
multiple: false,
})
if (!result) return
const selected = Array.isArray(result) ? result[0] : result
await openFolder(selected)
} catch (err) {
console.error("[SidebarConversationList] failed to open folder:", err)
}
} else {
setBrowserOpen(true)
}
}, [openFolder])
const handleBrowserSelect = useCallback(
(path: string) => {
openFolder(path).catch((err) => {
console.error("[SidebarConversationList] failed to open folder:", err)
})
},
[openFolder]
)
const handleProjectBoot = useCallback(() => {
openProjectBootWindow().catch((err) => {
console.error(
"[SidebarConversationList] failed to open project boot:",
err
)
})
}, [])
const showEmptyWorkspaceActions =
folders.length === 0 && conversations.length === 0
return (
<div className="relative flex flex-col flex-1 min-h-0">
{(loading || refreshing) && (
<div className="absolute top-0 left-0 right-0 flex items-center justify-center py-1 z-10 pointer-events-none">
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
</div>
)}
{loading && !refreshing ? (
<div className="px-3 space-y-1.5 overflow-hidden">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-md" />
))}
</div>
) : error ? (
<div className="flex-1 flex items-center justify-center px-3">
<p className="text-destructive text-xs">
{t("error", { message: error })}
</p>
</div>
) : showEmptyWorkspaceActions ? (
<div className="flex-1 flex flex-col items-center justify-center px-3 gap-2">
<Button
variant="outline"
size="sm"
className="w-full max-w-[14rem] justify-start"
onClick={handleOpenFolderAction}
>
<FolderOpen className="h-3.5 w-3.5 mr-1.5" />
{tFolderDropdown("openFolder")}
</Button>
<Button
variant="outline"
size="sm"
className="w-full max-w-[14rem] justify-start"
onClick={() => setCloneOpen(true)}
>
<GitBranch className="h-3.5 w-3.5 mr-1.5" />
{tFolderDropdown("cloneRepository")}
</Button>
<Button
variant="outline"
size="sm"
className="w-full max-w-[14rem] justify-start"
onClick={handleProjectBoot}
>
<Rocket className="h-3.5 w-3.5 mr-1.5" />
{tFolderDropdown("projectBoot")}
</Button>
</div>
) : (
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex-1 min-h-0 relative">
<ScrollArea
ref={scrollRootRef}
className={cn(
"h-full min-h-0 px-1 pb-[1.25rem]",
"[overflow-anchor:none]"
)}
>
<Reorder.Group
as="div"
axis="y"
values={orderedFolderIds}
onReorder={handleReorder}
className="flex flex-col"
style={
{
"--conv-rail-axis": "0.875rem",
} as React.CSSProperties
}
>
{orderedFolderIds.map((folderId, index) => {
const folderName =
folderIndex.get(folderId)?.name ?? String(folderId)
const convs = byFolder.get(folderId) ?? []
const expanded = folderExpanded[folderId] ?? true
const convsWithKey = convs.map((conv) => ({
...conv,
}))
// Earlier folders get a higher stacking index so their
// sticky headers paint above later folders' conversation
// cards when scrolled. Framer's `layout` prop sets
// `will-change: transform`, which would otherwise trap
// each sticky inside its own Reorder.Item.
const stackIndex = orderedFolderIds.length - index
return (
<FolderGroupItem
key={folderId}
folderId={folderId}
folderName={folderName}
conversations={convsWithKey}
totalConversationCount={
folderTotalCounts.get(folderId) ?? 0
}
expanded={expanded}
importing={importing}
reordering={reordering}
dragging={dragging === folderId}
sortMode={sortMode}
selectedConversation={selectedConversation}
openTabConversationKeys={openTabConversationKeys}
color={folderIndex.get(folderId)?.color ?? "#22c55e"}
onToggle={toggleFolder}
onRemoveFromWorkspace={handleRemoveFolder}
onNewConversationForFolder={
handleNewConversationForFolder
}
onImport={handleImportForFolder}
onManageConversations={handleManageConversations}
onChangeColor={handleChangeFolderColor}
onSelect={handleSelect}
onDoubleClick={handleDoubleClick}
onRename={handleRename}
onDelete={handleDelete}
onStatusChange={handleStatusChange}
onNewConversation={handleNewConversation}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
stackIndex={stackIndex}
t={t}
/>
)
})}
</Reorder.Group>
</ScrollArea>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onSelect={handleNewConversation}
disabled={!activeFolder}
>
<Plus className="h-4 w-4" />
{t("newConversation")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={handleOpenFolderAction}>
<FolderOpen className="h-4 w-4" />
{tFolderDropdown("openFolder")}
</ContextMenuItem>
<ContextMenuItem onSelect={() => setCloneOpen(true)}>
<GitBranch className="h-4 w-4" />
{tFolderDropdown("cloneRepository")}
</ContextMenuItem>
<ContextMenuItem onSelect={handleProjectBoot}>
<Rocket className="h-4 w-4" />
{tFolderDropdown("projectBoot")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
<AlertDialog
open={removeConfirm !== null}
onOpenChange={(open) => !open && setRemoveConfirm(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("removeFolderConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("removeFolderConfirmDescription", {
name: removeConfirm?.folderName ?? "",
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleRemoveFolderConfirm}>
{tCommon("confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{manageState && (
<ConversationManageDialog
open
onOpenChange={(o) => !o && setManageState(null)}
folderId={manageState.folderId}
folderName={manageState.folderName}
/>
)}
<CloneDialog open={cloneOpen} onOpenChange={setCloneOpen} />
<DirectoryBrowserDialog
open={browserOpen}
onOpenChange={setBrowserOpen}
onSelect={handleBrowserSelect}
/>
</div>
)
}