Files
codeg/src/components/conversations/sidebar-conversation-card.tsx
xintaofei 72b8817bb2 refactor(sidebar): highlight expanded folder and add per-folder new-conversation button
- 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
2026-04-22 01:01:18 +08:00

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