- Replace the branch badge with a compact count badge; recolor the folder name and badge to sidebar-primary on a tinted row background when expanded - Lighten the selected conversation item background so the expanded folder row stays the strongest signal - Add a "+" button on each folder header that reuses a single new- conversation tab across folders, disconnecting the old ACP session so the connection lifecycle reconnects against the target folder's working directory
313 lines
10 KiB
TypeScript
313 lines
10 KiB
TypeScript
"use client"
|
|
|
|
import { memo, useState, useCallback } from "react"
|
|
import {
|
|
Pencil,
|
|
Trash2,
|
|
Circle,
|
|
CircleAlert,
|
|
CircleCheck,
|
|
CircleDashed,
|
|
CircleX,
|
|
Download,
|
|
Plus,
|
|
type LucideIcon,
|
|
} from "lucide-react"
|
|
import { useTranslations } from "next-intl"
|
|
import type { DbConversationSummary, ConversationStatus } from "@/lib/types"
|
|
import { STATUS_ORDER } from "@/lib/types"
|
|
import { cn } from "@/lib/utils"
|
|
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"
|
|
import {
|
|
SidebarStatusIcon,
|
|
conversationStatusToBead,
|
|
} from "./sidebar-status-icon"
|
|
|
|
const STATUS_ICONS: Record<ConversationStatus, LucideIcon> = {
|
|
in_progress: CircleDashed,
|
|
pending_review: CircleAlert,
|
|
completed: CircleCheck,
|
|
cancelled: CircleX,
|
|
}
|
|
|
|
const STATUS_ICON_COLORS: Record<ConversationStatus, string> = {
|
|
in_progress: "text-blue-500",
|
|
pending_review: "text-orange-500",
|
|
completed: "text-green-500",
|
|
cancelled: "text-red-500",
|
|
}
|
|
|
|
interface SidebarConversationCardProps {
|
|
conversation: DbConversationSummary
|
|
isSelected: boolean
|
|
timeLabel?: string
|
|
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,
|
|
timeLabel,
|
|
onSelect,
|
|
onDoubleClick,
|
|
onRename,
|
|
onDelete,
|
|
onStatusChange,
|
|
onNewConversation,
|
|
onImport,
|
|
importing,
|
|
}: SidebarConversationCardProps) {
|
|
const t = useTranslations("Folder.conversationCard")
|
|
const tSidebar = useTranslations("Folder.sidebar")
|
|
const tStatus = useTranslations("Folder.statusLabels")
|
|
const [renameOpen, setRenameOpen] = useState(false)
|
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
|
const [renameValue, setRenameValue] = useState("")
|
|
|
|
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])
|
|
|
|
const status = conversation.status as ConversationStatus
|
|
const beadStatus = conversationStatusToBead(conversation.status)
|
|
const isRunning = status === "in_progress"
|
|
const isFailed = status === "cancelled"
|
|
|
|
return (
|
|
<>
|
|
<ContextMenu>
|
|
<ContextMenuTrigger asChild>
|
|
<div className="relative h-[2rem]">
|
|
<button
|
|
data-conversation-id={conversation.id}
|
|
onClick={handleClick}
|
|
onDoubleClick={handleDblClick}
|
|
className={cn(
|
|
"relative flex h-[1.9375rem] w-full items-center gap-[0.625rem] text-left outline-none",
|
|
"rounded-[0.375rem] text-sidebar-foreground",
|
|
"transition-colors duration-[120ms]",
|
|
"pr-[0.5rem] pl-7",
|
|
isSelected
|
|
? "bg-sidebar-primary/8"
|
|
: "hover:bg-[color-mix(in_oklab,var(--sidebar-accent),var(--sidebar-foreground)_2%)]"
|
|
)}
|
|
>
|
|
<span
|
|
aria-hidden
|
|
className="pointer-events-none absolute top-0 bottom-0 rounded-[0.125rem] bg-sidebar-primary/15"
|
|
style={{
|
|
left: "0.875rem",
|
|
width: "1px",
|
|
transform: "translateX(-50%)",
|
|
}}
|
|
/>
|
|
<SidebarStatusIcon status={beadStatus} />
|
|
|
|
<span
|
|
className={cn(
|
|
"relative min-w-0 flex-1 truncate text-[0.875rem]",
|
|
isSelected
|
|
? "font-semibold tracking-[-0.00625rem]"
|
|
: "font-normal"
|
|
)}
|
|
>
|
|
{conversation.title || t("untitledConversation")}
|
|
</span>
|
|
|
|
{isRunning ? (
|
|
<span
|
|
className={cn(
|
|
"relative shrink-0 rounded-[0.1875rem] px-[0.375rem] py-px",
|
|
"text-[0.6875rem] font-semibold tracking-[0.01875rem]",
|
|
"bg-amber-500/10 text-amber-600 dark:bg-amber-400/15 dark:text-amber-400"
|
|
)}
|
|
>
|
|
{tSidebar("statusRunningBadge")}
|
|
</span>
|
|
) : isFailed ? (
|
|
<span
|
|
className={cn(
|
|
"relative shrink-0 rounded-[0.1875rem] px-[0.375rem] py-px",
|
|
"text-[0.6875rem] font-semibold tracking-[0.01875rem]",
|
|
"bg-destructive/10 text-destructive"
|
|
)}
|
|
>
|
|
{tSidebar("statusFailedBadge")}
|
|
</span>
|
|
) : timeLabel ? (
|
|
<span
|
|
className={cn(
|
|
"relative shrink-0 tabular-nums",
|
|
"text-[0.71875rem]",
|
|
isSelected
|
|
? "font-medium text-muted-foreground"
|
|
: "font-normal text-muted-foreground/70"
|
|
)}
|
|
>
|
|
{timeLabel}
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
</div>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
{onNewConversation && (
|
|
<>
|
|
<ContextMenuItem onSelect={onNewConversation}>
|
|
<Plus className="h-4 w-4" />
|
|
{t("newConversation")}
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
</>
|
|
)}
|
|
<ContextMenuItem onSelect={handleRenameOpen}>
|
|
<Pencil className="h-4 w-4" />
|
|
{t("rename")}
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuSub>
|
|
<ContextMenuSubTrigger>
|
|
<Circle className="h-4 w-4" />
|
|
{t("status")}
|
|
</ContextMenuSubTrigger>
|
|
<ContextMenuSubContent>
|
|
{STATUS_ORDER.filter((s) => s !== conversation.status).map(
|
|
(s) => {
|
|
const StatusIcon = STATUS_ICONS[s]
|
|
return (
|
|
<ContextMenuItem
|
|
key={s}
|
|
onSelect={() => onStatusChange(conversation.id, s)}
|
|
>
|
|
<StatusIcon
|
|
className={cn("h-4 w-4", STATUS_ICON_COLORS[s])}
|
|
/>
|
|
{tStatus(s)}
|
|
</ContextMenuItem>
|
|
)
|
|
}
|
|
)}
|
|
</ContextMenuSubContent>
|
|
</ContextMenuSub>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem
|
|
variant="destructive"
|
|
onSelect={() => setDeleteOpen(true)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
{t("delete")}
|
|
</ContextMenuItem>
|
|
{onImport && (
|
|
<>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem disabled={importing} onSelect={onImport}>
|
|
<Download className="h-4 w-4" />
|
|
{importing ? t("importing") : t("importLocalSessions")}
|
|
</ContextMenuItem>
|
|
</>
|
|
)}
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
|
|
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t("renameConversation")}</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)}>
|
|
{t("cancel")}
|
|
</Button>
|
|
<Button onClick={handleRenameConfirm}>{t("save")}</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t("deleteConversationTitle")}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t("deleteConversationDescription", {
|
|
title: conversation.title || t("untitledConversation"),
|
|
})}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
|
<AlertDialogAction onClick={handleDeleteConfirm}>
|
|
{t("delete")}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
)
|
|
})
|