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,387 @@
"use client"
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Plus, RefreshCw, X } from "lucide-react"
import { toast } from "sonner"
import { useFolderContext } from "@/contexts/folder-context"
import { useTabContext } from "@/contexts/tab-context"
import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle"
import { MessageListView } from "@/components/message/message-list-view"
import { ConversationShell } from "@/components/chat/conversation-shell"
import { WelcomeInputPanel } from "@/components/chat/welcome-input-panel"
import { updateConversationStatus } from "@/lib/tauri"
import { useDbMessageDetail } from "@/hooks/use-db-message-detail"
import type { AgentType, PromptDraft } from "@/lib/types"
import type { AdaptedMessage } from "@/lib/adapters/ai-elements-adapter"
import {
buildUserMessageTextPartsFromDraft,
extractUserResourcesFromDraft,
} from "@/lib/prompt-draft"
import { buildConversationDraftStorageKey } from "@/lib/message-input-draft"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
interface ExistingConversationViewProps {
tabId: string
conversationId: number
agentType: AgentType
isActive: boolean
reloadSignal: number
}
const ExistingConversationView = memo(function ExistingConversationView({
tabId,
conversationId,
agentType,
isActive,
reloadSignal,
}: ExistingConversationViewProps) {
const { refreshConversations, folder } = useFolderContext()
const contextKey = `conv-${agentType}-${conversationId}`
// Get external_id to resume existing agent session via LoadSessionRequest.
// Gate workingDir on loading so auto-connect waits for sessionId to resolve.
const {
detail,
loading: detailLoading,
error: detailError,
refetch: refetchConversationDetail,
} = useDbMessageDetail(conversationId)
const externalId = detail?.summary.external_id ?? undefined
const latestReloadSignal = useRef(reloadSignal)
const pendingReloadState = useRef<{
signal: number
sawLoading: boolean
} | null>(null)
const {
conn,
modeLoading,
configOptionsLoading,
handleFocus,
handleSend,
handleSetConfigOption,
handleCancel,
handleRespondPermission,
} = useConnectionLifecycle({
contextKey,
agentType,
isActive,
workingDir: detailLoading ? undefined : folder?.path,
sessionId: externalId,
})
const [pendingMessages, setPendingMessages] = useState<AdaptedMessage[]>([])
const [modeId, setModeId] = useState<string | null>(null)
const clearPending = useCallback(() => setPendingMessages([]), [])
const connectionModes = useMemo(
() => conn.modes?.available_modes ?? [],
[conn.modes?.available_modes]
)
const connectionConfigOptions = useMemo(
() => conn.configOptions ?? [],
[conn.configOptions]
)
const connectionCommands = useMemo(
() => conn.availableCommands ?? [],
[conn.availableCommands]
)
const selectedModeId = useMemo(() => {
if (connectionModes.length === 0) return null
if (modeId && connectionModes.some((mode) => mode.id === modeId)) {
return modeId
}
return conn.modes?.current_mode_id ?? connectionModes[0]?.id ?? null
}, [conn.modes?.current_mode_id, connectionModes, modeId])
// Track status transitions for updating conversation metadata
const prevStatusRef = useRef(conn.status)
const statusUpdatedRef = useRef(false)
// Wrap handleSend to update status
const handleSendWithPersist = useCallback(
(draft: PromptDraft, selectedModeId?: string | null) => {
setPendingMessages((prev) => [
...prev,
{
id: `pending-${Date.now()}`,
role: "user",
content: buildUserMessageTextPartsFromDraft(draft),
userResources: extractUserResourcesFromDraft(draft),
timestamp: new Date().toISOString(),
},
])
updateConversationStatus(conversationId, "in_progress")
.then(() => refreshConversations())
.catch((e) => console.error("[ExistingConv] update status:", e))
statusUpdatedRef.current = false
handleSend(draft, selectedModeId)
},
[conversationId, handleSend, refreshConversations]
)
// Update status on turn complete
useEffect(() => {
const prev = prevStatusRef.current
prevStatusRef.current = conn.status
if (prev === "prompting" && conn.status !== "prompting") {
// Mark as pending_review unless it's a terminal state
if (conn.status !== "disconnected" && conn.status !== "error") {
updateConversationStatus(conversationId, "pending_review")
.then(() => refreshConversations())
.catch((e: unknown) =>
console.error("[ExistingConv] update status:", e)
)
}
}
}, [conn.status, conversationId, refreshConversations])
// Update status on disconnect/error
useEffect(() => {
if (conn.status === "connected" || conn.status === "prompting") {
statusUpdatedRef.current = false
return
}
if (statusUpdatedRef.current) return
if (conn.status === "disconnected") {
statusUpdatedRef.current = true
updateConversationStatus(conversationId, "completed")
.then(() => {
setPendingMessages([])
refreshConversations()
})
.catch((e) => console.error("[ExistingConv] update status:", e))
} else if (conn.status === "error") {
statusUpdatedRef.current = true
updateConversationStatus(conversationId, "cancelled")
.then(() => {
setPendingMessages([])
refreshConversations()
})
.catch((e) => console.error("[ExistingConv] update status:", e))
}
}, [conn.status, conversationId, refreshConversations])
useEffect(() => {
if (reloadSignal === latestReloadSignal.current) return
latestReloadSignal.current = reloadSignal
pendingReloadState.current = {
signal: reloadSignal,
sawLoading: false,
}
refetchConversationDetail()
}, [reloadSignal, refetchConversationDetail])
useEffect(() => {
const pending = pendingReloadState.current
if (!pending) return
if (detailLoading) {
pending.sawLoading = true
return
}
if (!pending.sawLoading) return
pendingReloadState.current = null
if (detailError) {
toast.error(`会话重新加载失败:${detailError}`)
return
}
toast.success("当前会话已重新加载")
}, [detailLoading, detailError])
return (
<ConversationShell
status={conn.status}
defaultPath={folder?.path}
error={conn.error}
pendingPermission={conn.pendingPermission}
onFocus={handleFocus}
onSend={handleSendWithPersist}
onCancel={handleCancel}
onRespondPermission={handleRespondPermission}
modes={connectionModes}
configOptions={connectionConfigOptions}
modeLoading={modeLoading}
configOptionsLoading={configOptionsLoading}
selectedModeId={selectedModeId}
onModeChange={setModeId}
onConfigOptionChange={handleSetConfigOption}
availableCommands={connectionCommands}
attachmentTabId={tabId}
draftStorageKey={buildConversationDraftStorageKey(
agentType,
conversationId
)}
>
<MessageListView
conversationId={conversationId}
liveMessage={conn.liveMessage}
connStatus={conn.status}
pendingMessages={pendingMessages}
onPendingClear={clearPending}
isActive={isActive}
/>
</ConversationShell>
)
})
export function ConversationDetailPanel() {
const { folder, newConversation } = useFolderContext()
const { tabs, activeTabId, openNewConversationTab, closeTab } =
useTabContext()
const [reloadByTabId, setReloadByTabId] = useState<Record<string, number>>({})
const conversationTabs = useMemo(
() =>
tabs.filter((t) => t.kind === "conversation" && t.conversationId != null),
[tabs]
)
const newConvTabs = useMemo(
() => tabs.filter((t) => t.kind === "new_conversation"),
[tabs]
)
const hasNoTabs =
conversationTabs.length === 0 && newConvTabs.length === 0 && !activeTabId
const activeConversationTab = useMemo(
() =>
tabs.find(
(tab) =>
tab.id === activeTabId &&
tab.kind === "conversation" &&
tab.conversationId != null
) ?? null,
[tabs, activeTabId]
)
const canReloadActiveConversation = activeConversationTab != null
const handleReloadActiveConversation = useCallback(() => {
if (!activeConversationTab) return
setReloadByTabId((prev) => ({
...prev,
[activeConversationTab.id]: (prev[activeConversationTab.id] ?? 0) + 1,
}))
}, [activeConversationTab])
const handleNewConversation = useCallback(() => {
if (!folder) return
openNewConversationTab("codex", folder.path)
}, [folder, openNewConversationTab])
const handleCloseActiveTab = useCallback(() => {
if (!activeTabId) return
closeTab(activeTabId)
}, [activeTabId, closeTab])
// Ensure no-tab state is immediately bridged to a real new-conversation tab.
useEffect(() => {
if (!folder) return
if (hasNoTabs) {
openNewConversationTab(
newConversation?.agentType ?? "codex",
newConversation?.workingDir ?? folder.path
)
}
}, [
folder,
hasNoTabs,
newConversation?.agentType,
newConversation?.workingDir,
openNewConversationTab,
])
// Empty state: no tabs at all — show full-screen welcome
if (hasNoTabs) {
return (
<WelcomeInputPanel
defaultAgentType={newConversation?.agentType ?? "codex"}
workingDir={newConversation?.workingDir ?? folder?.path}
/>
)
}
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="relative h-full min-h-0 overflow-hidden">
{conversationTabs.map((tab) => {
const active = tab.id === activeTabId
return (
<div
key={tab.id}
className={
active
? "h-full"
: "absolute inset-0 invisible pointer-events-none"
}
>
<ExistingConversationView
tabId={tab.id}
conversationId={tab.conversationId!}
agentType={tab.agentType}
isActive={active}
reloadSignal={reloadByTabId[tab.id] ?? 0}
/>
</div>
)
})}
{newConvTabs.map((tab) => {
const active = tab.id === activeTabId
return (
<div
key={tab.id}
className={
active
? "h-full"
: "absolute inset-0 invisible pointer-events-none"
}
>
<WelcomeInputPanel
defaultAgentType={tab.agentType ?? "codex"}
workingDir={tab.workingDir ?? folder?.path}
tabId={tab.id}
isActive={active}
/>
</div>
)
})}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
disabled={!canReloadActiveConversation}
onSelect={handleReloadActiveConversation}
>
<RefreshCw className="h-4 w-4" />
</ContextMenuItem>
<ContextMenuItem
disabled={!folder?.path}
onSelect={handleNewConversation}
>
<Plus className="h-4 w-4" />
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
disabled={!activeTabId}
onSelect={handleCloseActiveTab}
>
<X className="h-4 w-4" />
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}

View File

@@ -0,0 +1,179 @@
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import { formatDistanceToNow } from "date-fns"
import { useFolderContext } from "@/contexts/folder-context"
import { useTabContext } from "@/contexts/tab-context"
import { listFolderConversations } from "@/lib/tauri"
import type {
AgentType,
ConversationStatus,
DbConversationSummary,
} from "@/lib/types"
import { AGENT_LABELS, STATUS_COLORS, compareAgentType } from "@/lib/types"
import { AgentIcon } from "@/components/agent-icon"
import {
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from "@/components/ui/command"
import { cn } from "@/lib/utils"
interface SearchCommandDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function SearchCommandDialog({
open,
onOpenChange,
}: SearchCommandDialogProps) {
const { folderId, conversations } = useFolderContext()
const { openTab } = useTabContext()
const [query, setQuery] = useState("")
const [agentFilter, setAgentFilter] = useState<AgentType | null>(null)
const [results, setResults] = useState<DbConversationSummary[]>([])
const [searching, setSearching] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
// Compute which agent types exist in current folder
const availableAgents = Array.from(
new Set(conversations.map((c) => c.agent_type))
).sort(compareAgentType)
const doSearch = useCallback(
async (q: string, agent: AgentType | null) => {
if (!q.trim() && !agent) {
setResults([])
setSearching(false)
return
}
setSearching(true)
try {
const data = await listFolderConversations({
folder_id: folderId,
search: q.trim() || null,
agent_type: agent,
})
setResults(data)
} catch {
setResults([])
} finally {
setSearching(false)
}
},
[folderId]
)
// Debounced search on query change
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
doSearch(query, agentFilter)
}, 300)
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [query, agentFilter, doSearch])
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setQuery("")
setAgentFilter(null)
setResults([])
}
}, [open])
const handleSelect = (conv: DbConversationSummary) => {
openTab(conv.id, conv.agent_type, true)
onOpenChange(false)
}
return (
<CommandDialog
title="Search conversations"
open={open}
onOpenChange={onOpenChange}
>
<CommandInput
placeholder="Search conversations..."
value={query}
onValueChange={setQuery}
/>
{availableAgents.length > 1 && (
<div className="flex items-center gap-1 px-3 py-2 border-b">
<button
onClick={() => setAgentFilter(null)}
className={cn(
"h-6 text-xs px-2 rounded-md transition-colors",
agentFilter === null
? "bg-secondary text-secondary-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
All
</button>
{availableAgents.map((at) => (
<button
key={at}
onClick={() => setAgentFilter(at)}
className={cn(
"flex items-center gap-1.5 h-6 text-xs px-2 rounded-md transition-colors",
agentFilter === at
? "bg-secondary text-secondary-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<AgentIcon agentType={at} className="w-3.5 h-3.5" />
{AGENT_LABELS[at]}
</button>
))}
</div>
)}
<CommandList className="min-h-96">
<CommandEmpty>
{searching
? "Searching..."
: !query.trim() && !agentFilter
? "键入搜索以查询会话"
: "No results found."}
</CommandEmpty>
{results.length > 0 && (
<CommandGroup>
{results.map((conv) => (
<CommandItem
key={conv.id}
value={`${conv.id}-${conv.title ?? ""}`}
onSelect={() => handleSelect(conv)}
>
<span
className={cn(
"w-2 h-2 rounded-full shrink-0",
STATUS_COLORS[conv.status as ConversationStatus] ??
"bg-gray-400"
)}
/>
<span className="flex-1 truncate">
{conv.title || "Untitled conversation"}
</span>
<span className="text-xs text-muted-foreground shrink-0">
{AGENT_LABELS[conv.agent_type]}
</span>
<span className="text-xs text-muted-foreground shrink-0">
{formatDistanceToNow(new Date(conv.created_at), {
addSuffix: true,
})}
</span>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</CommandDialog>
)
}

View File

@@ -0,0 +1,242 @@
"use client"
import { memo, useState, useCallback } from "react"
import { formatDistanceToNow } from "date-fns"
import { GitBranch, Pencil, Trash2, Circle, Download, Plus } from "lucide-react"
import type { DbConversationSummary, ConversationStatus } from "@/lib/types"
import { STATUS_COLORS, STATUS_LABELS } from "@/lib/types"
import { cn } from "@/lib/utils"
import { AgentIcon } from "@/components/agent-icon"
import {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuSub,
ContextMenuSubTrigger,
ContextMenuSubContent,
ContextMenuSeparator,
} from "@/components/ui/context-menu"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
const ALL_STATUSES: ConversationStatus[] = [
"in_progress",
"pending_review",
"completed",
"cancelled",
]
interface SidebarConversationCardProps {
conversation: DbConversationSummary
isSelected: boolean
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
onImport?: () => void
importing?: boolean
}
export const SidebarConversationCard = memo(function SidebarConversationCard({
conversation,
isSelected,
onSelect,
onDoubleClick,
onRename,
onDelete,
onStatusChange,
onNewConversation,
onImport,
importing,
}: SidebarConversationCardProps) {
const [renameOpen, setRenameOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const [renameValue, setRenameValue] = useState("")
const timeAgo = formatDistanceToNow(new Date(conversation.updated_at), {
addSuffix: true,
})
const handleClick = useCallback(() => {
onSelect(conversation.id, conversation.agent_type)
}, [onSelect, conversation.id, conversation.agent_type])
const handleDblClick = useCallback(() => {
onDoubleClick?.(conversation.id, conversation.agent_type)
}, [onDoubleClick, conversation.id, conversation.agent_type])
const handleRenameOpen = useCallback(() => {
setRenameValue(conversation.title || "")
setRenameOpen(true)
}, [conversation.title])
const handleRenameConfirm = useCallback(async () => {
const trimmed = renameValue.trim()
if (trimmed && trimmed !== conversation.title) {
await onRename(conversation.id, trimmed)
}
setRenameOpen(false)
}, [renameValue, conversation.id, conversation.title, onRename])
const handleDeleteConfirm = useCallback(async () => {
await onDelete(conversation.id, conversation.agent_type)
setDeleteOpen(false)
}, [conversation.id, conversation.agent_type, onDelete])
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 rounded-md transition-colors",
"hover:bg-sidebar-accent/50",
isSelected && "bg-sidebar-accent text-sidebar-accent-foreground"
)}
>
<div className="flex items-center gap-1.5 min-w-0">
<AgentIcon
agentType={conversation.agent_type}
className="size-4 shrink-0"
/>
<span className="text-sm font-medium truncate">
{conversation.title || "Untitled conversation"}
</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>
</span>
)}
</div>
</button>
</ContextMenuTrigger>
<ContextMenuContent>
{onNewConversation && (
<>
<ContextMenuItem onSelect={onNewConversation}>
<Plus className="h-4 w-4" />
New Conversation
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
<ContextMenuItem onSelect={handleRenameOpen}>
<Pencil className="h-4 w-4" />
Rename
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>
<Circle className="h-4 w-4" />
Status
</ContextMenuSubTrigger>
<ContextMenuSubContent>
{ALL_STATUSES.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]
)}
/>
{STATUS_LABELS[s]}
</ContextMenuItem>
)
)}
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
onSelect={() => setDeleteOpen(true)}
>
<Trash2 className="h-4 w-4" />
Delete
</ContextMenuItem>
{onImport && (
<>
<ContextMenuSeparator />
<ContextMenuItem disabled={importing} onSelect={onImport}>
<Download className="h-4 w-4" />
{importing ? "Importing..." : "Import local sessions"}
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename conversation</DialogTitle>
</DialogHeader>
<Input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing || e.key === "Process") return
if (e.key === "Enter") handleRenameConfirm()
}}
autoFocus
/>
<DialogFooter>
<Button variant="outline" onClick={() => setRenameOpen(false)}>
Cancel
</Button>
<Button onClick={handleRenameConfirm}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete conversation?</AlertDialogTitle>
<AlertDialogDescription>
This will delete &ldquo;
{conversation.title || "Untitled conversation"}&rdquo;. This
action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
})

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