Initial commit
This commit is contained in:
463
src/components/conversations/sidebar-conversation-list.tsx
Normal file
463
src/components/conversations/sidebar-conversation-list.tsx
Normal file
@@ -0,0 +1,463 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type Ref,
|
||||
} from "react"
|
||||
import { toast } from "sonner"
|
||||
import { CheckCheck, ChevronRight, Download, Loader2, Plus } from "lucide-react"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { useTabContext } from "@/contexts/tab-context"
|
||||
import { useTaskContext } from "@/contexts/task-context"
|
||||
import {
|
||||
importLocalConversations,
|
||||
updateConversationTitle,
|
||||
updateConversationStatus,
|
||||
deleteConversation,
|
||||
} from "@/lib/tauri"
|
||||
import type { ConversationStatus, DbConversationSummary } from "@/lib/types"
|
||||
import { STATUS_ORDER, STATUS_LABELS, STATUS_COLORS } from "@/lib/types"
|
||||
import { SidebarConversationCard } from "./sidebar-conversation-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleTrigger,
|
||||
CollapsibleContent,
|
||||
} from "@/components/ui/collapsible"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
} 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
|
||||
}
|
||||
|
||||
export interface SidebarConversationListHandle {
|
||||
scrollToActive: () => void
|
||||
expandAll: () => void
|
||||
collapseAll: () => void
|
||||
}
|
||||
|
||||
export function SidebarConversationList({
|
||||
ref,
|
||||
}: {
|
||||
ref?: Ref<SidebarConversationListHandle>
|
||||
}) {
|
||||
const {
|
||||
folder,
|
||||
conversations,
|
||||
loading,
|
||||
refreshing,
|
||||
error,
|
||||
selectedConversation,
|
||||
folderId,
|
||||
refreshConversations,
|
||||
} = useFolderContext()
|
||||
|
||||
const { openTab, closeTab, openNewConversationTab } = useTabContext()
|
||||
const { addTask, updateTask } = useTaskContext()
|
||||
|
||||
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 scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToActive() {
|
||||
if (!selectedConversation) return
|
||||
const conv = conversations.find(
|
||||
(c) =>
|
||||
c.id === selectedConversation.id &&
|
||||
c.agent_type === selectedConversation.agentType
|
||||
)
|
||||
if (!conv) return
|
||||
const status = conv.status as ConversationStatus
|
||||
if (!groupExpanded[status]) {
|
||||
setGroupExpanded((prev) => ({ ...prev, [status]: true }))
|
||||
requestAnimationFrame(() => {
|
||||
const el = scrollContainerRef.current?.querySelector(
|
||||
`[data-conversation-id="${selectedConversation.id}"]`
|
||||
)
|
||||
el?.scrollIntoView({ block: "center", behavior: "smooth" })
|
||||
})
|
||||
} else {
|
||||
const el = scrollContainerRef.current?.querySelector(
|
||||
`[data-conversation-id="${selectedConversation.id}"]`
|
||||
)
|
||||
el?.scrollIntoView({ block: "center", behavior: "smooth" })
|
||||
}
|
||||
},
|
||||
expandAll() {
|
||||
setGroupExpanded({
|
||||
in_progress: true,
|
||||
pending_review: true,
|
||||
completed: true,
|
||||
cancelled: true,
|
||||
})
|
||||
},
|
||||
collapseAll() {
|
||||
setGroupExpanded({
|
||||
in_progress: false,
|
||||
pending_review: false,
|
||||
completed: false,
|
||||
cancelled: false,
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<ConversationStatus, DbConversationSummary[]>()
|
||||
for (const conv of conversations) {
|
||||
const status = conv.status as ConversationStatus
|
||||
const list = map.get(status)
|
||||
if (list) {
|
||||
list.push(conv)
|
||||
} else {
|
||||
map.set(status, [conv])
|
||||
}
|
||||
}
|
||||
for (const list of map.values()) {
|
||||
list.sort(compareByUpdatedAtDesc)
|
||||
}
|
||||
return map
|
||||
}, [conversations])
|
||||
|
||||
const reviewConversations = useMemo(
|
||||
() => grouped.get("pending_review") ?? [],
|
||||
[grouped]
|
||||
)
|
||||
const reviewConversationCount = reviewConversations.length
|
||||
|
||||
const toggleGroup = useCallback((status: ConversationStatus) => {
|
||||
setGroupExpanded((prev) => ({ ...prev, [status]: !prev[status] }))
|
||||
}, [])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: number, agentType: string) => {
|
||||
openTab(id, agentType as Parameters<typeof openTab>[1], false)
|
||||
},
|
||||
[openTab]
|
||||
)
|
||||
|
||||
const handleDoubleClick = useCallback(
|
||||
(id: number, agentType: string) => {
|
||||
openTab(id, agentType as Parameters<typeof openTab>[1], true)
|
||||
},
|
||||
[openTab]
|
||||
)
|
||||
|
||||
const handleRename = useCallback(
|
||||
async (id: number, newTitle: string) => {
|
||||
await updateConversationTitle(id, newTitle)
|
||||
refreshConversations()
|
||||
},
|
||||
[refreshConversations]
|
||||
)
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: number, agentType: string) => {
|
||||
await deleteConversation(id)
|
||||
closeTab(`conv-${agentType}-${id}`)
|
||||
refreshConversations()
|
||||
},
|
||||
[closeTab, refreshConversations]
|
||||
)
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
async (id: number, status: ConversationStatus) => {
|
||||
await updateConversationStatus(id, status)
|
||||
refreshConversations()
|
||||
},
|
||||
[refreshConversations]
|
||||
)
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
if (!folder) return
|
||||
openNewConversationTab("codex", folder.path)
|
||||
}, [folder, openNewConversationTab])
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (importing) return
|
||||
setImporting(true)
|
||||
const taskId = `import-${folderId}-${Date.now()}`
|
||||
addTask(taskId, "Importing local sessions")
|
||||
updateTask(taskId, { status: "running" })
|
||||
try {
|
||||
const result = await importLocalConversations(folderId)
|
||||
updateTask(taskId, { status: "completed" })
|
||||
refreshConversations()
|
||||
if (result.imported > 0) {
|
||||
toast.success(
|
||||
`Imported ${result.imported} session(s), skipped ${result.skipped}`
|
||||
)
|
||||
} else {
|
||||
toast.info(`No new sessions found (skipped ${result.skipped})`)
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
updateTask(taskId, { status: "failed", error: msg })
|
||||
toast.error(`Import failed: ${msg}`)
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}, [importing, folderId, addTask, updateTask, refreshConversations])
|
||||
|
||||
const handleCompleteAllReview = useCallback(async () => {
|
||||
if (completingReview || reviewConversationCount === 0) return
|
||||
setCompletingReview(true)
|
||||
try {
|
||||
await Promise.all(
|
||||
reviewConversations.map((conversation) =>
|
||||
updateConversationStatus(conversation.id, "completed")
|
||||
)
|
||||
)
|
||||
refreshConversations()
|
||||
toast.success(
|
||||
`Marked ${reviewConversationCount} review session(s) as completed`
|
||||
)
|
||||
setCompleteReviewOpen(false)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
toast.error(`Failed to complete review sessions: ${msg}`)
|
||||
} finally {
|
||||
setCompletingReview(false)
|
||||
}
|
||||
}, [
|
||||
completingReview,
|
||||
reviewConversationCount,
|
||||
reviewConversations,
|
||||
refreshConversations,
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{(loading || refreshing) && (
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<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">Error: {error}</p>
|
||||
</div>
|
||||
) : conversations.length === 0 ? (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-3 gap-3">
|
||||
<p className="text-muted-foreground text-xs text-center">
|
||||
No conversations found.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={importing}
|
||||
onClick={handleImport}
|
||||
>
|
||||
{importing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
{importing ? "Importing..." : "Import local sessions"}
|
||||
</Button>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={handleNewConversation}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Conversation
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem disabled={importing} onSelect={handleImport}>
|
||||
<Download className="h-4 w-4" />
|
||||
{importing ? "Importing..." : "Import local sessions"}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
) : (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={cn(
|
||||
"flex-1 min-h-0 overflow-y-auto px-1.5",
|
||||
"[&::-webkit-scrollbar]:w-1.5",
|
||||
"[&::-webkit-scrollbar-thumb]:rounded-full",
|
||||
"[&::-webkit-scrollbar-thumb]:bg-border"
|
||||
)}
|
||||
>
|
||||
{STATUS_ORDER.map((status) => {
|
||||
const items = grouped.get(status)
|
||||
if (!items || items.length === 0) return null
|
||||
const isOpen = groupExpanded[status]
|
||||
const groupHeader = (
|
||||
<CollapsibleTrigger className="sticky top-0 z-10 bg-sidebar 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>{STATUS_LABELS[status]}</span>
|
||||
<span className="ml-auto text-muted-foreground/60 tabular-nums">
|
||||
{items.length}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
return (
|
||||
<Collapsible
|
||||
key={status}
|
||||
open={isOpen}
|
||||
onOpenChange={() => toggleGroup(status)}
|
||||
>
|
||||
{status === "pending_review" ? (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
{groupHeader}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
disabled={
|
||||
reviewConversationCount === 0 || completingReview
|
||||
}
|
||||
onSelect={() => setCompleteReviewOpen(true)}
|
||||
>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
Complete all sessions
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
) : (
|
||||
groupHeader
|
||||
)}
|
||||
<CollapsibleContent>
|
||||
<div className="space-y-0.5 pb-1">
|
||||
{items.map((conversation) => {
|
||||
const isSelected =
|
||||
selectedConversation?.agentType ===
|
||||
conversation.agent_type &&
|
||||
selectedConversation?.id === conversation.id
|
||||
return (
|
||||
<SidebarConversationCard
|
||||
key={conversation.id}
|
||||
conversation={conversation}
|
||||
isSelected={isSelected}
|
||||
onSelect={handleSelect}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onRename={handleRename}
|
||||
onDelete={handleDelete}
|
||||
onStatusChange={handleStatusChange}
|
||||
onNewConversation={handleNewConversation}
|
||||
onImport={handleImport}
|
||||
importing={importing}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={handleNewConversation}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Conversation
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem disabled={importing} onSelect={handleImport}>
|
||||
<Download className="h-4 w-4" />
|
||||
{importing ? "Importing..." : "Import local sessions"}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)}
|
||||
<AlertDialog
|
||||
open={completeReviewOpen}
|
||||
onOpenChange={(open) =>
|
||||
!completingReview && setCompleteReviewOpen(open)
|
||||
}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Complete all review sessions?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will mark all {reviewConversationCount} session(s) in Review
|
||||
as completed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={completingReview}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={completingReview || reviewConversationCount === 0}
|
||||
onClick={handleCompleteAllReview}
|
||||
>
|
||||
{completingReview ? "Completing..." : "Confirm"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user