1163 lines
37 KiB
TypeScript
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>
|
|
)
|
|
}
|