Initial commit

This commit is contained in:
xggz
2026-03-06 22:56:13 +08:00
commit 54d1097b41
273 changed files with 92457 additions and 0 deletions

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