Initial commit
This commit is contained in:
387
src/components/conversations/conversation-detail-panel.tsx
Normal file
387
src/components/conversations/conversation-detail-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
179
src/components/conversations/search-command-dialog.tsx
Normal file
179
src/components/conversations/search-command-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
242
src/components/conversations/sidebar-conversation-card.tsx
Normal file
242
src/components/conversations/sidebar-conversation-card.tsx
Normal 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 “
|
||||
{conversation.title || "Untitled conversation"}”. This
|
||||
action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
})
|
||||
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