refactor(conversation-status): share sidebar status glyphs across menus, search, and manage dialog

This commit is contained in:
xintaofei
2026-04-23 11:35:38 +08:00
parent f9dfc64009
commit bcd457c823
6 changed files with 176 additions and 240 deletions

View File

@@ -61,10 +61,11 @@ import type {
import { import {
AGENT_LABELS, AGENT_LABELS,
ALL_AGENT_TYPES, ALL_AGENT_TYPES,
STATUS_COLORS, STATUS_ICON_COLORS,
STATUS_ORDER, STATUS_ORDER,
} from "@/lib/types" } from "@/lib/types"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ConversationStatusIcon } from "@/components/conversations/conversation-status-icon"
interface ConversationManageDialogProps { interface ConversationManageDialogProps {
open: boolean open: boolean
@@ -291,11 +292,9 @@ export function ConversationManageDialog({
{STATUS_ORDER.map((s) => ( {STATUS_ORDER.map((s) => (
<SelectItem key={s} value={s}> <SelectItem key={s} value={s}>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span <ConversationStatusIcon
className={cn( status={s}
"h-2 w-2 rounded-full", className={cn("h-4 w-4", STATUS_ICON_COLORS[s])}
STATUS_COLORS[s]
)}
/> />
{tStatus(s)} {tStatus(s)}
</span> </span>
@@ -388,11 +387,7 @@ export function ConversationManageDialog({
{formatRelative(conv.updated_at)} {formatRelative(conv.updated_at)}
</span> </span>
<span <span
className={cn( className="shrink-0 inline-flex"
"shrink-0 h-2 w-2 rounded-full",
STATUS_COLORS[conv.status as ConversationStatus] ??
"bg-gray-400"
)}
title={ title={
STATUS_ORDER.includes( STATUS_ORDER.includes(
conv.status as ConversationStatus conv.status as ConversationStatus
@@ -400,7 +395,17 @@ export function ConversationManageDialog({
? tStatus(conv.status as ConversationStatus) ? tStatus(conv.status as ConversationStatus)
: conv.status : conv.status
} }
>
<ConversationStatusIcon
status={conv.status as ConversationStatus}
className={cn(
"h-4 w-4",
STATUS_ICON_COLORS[
conv.status as ConversationStatus
] ?? "text-muted-foreground"
)}
/> />
</span>
</div> </div>
) )
}) })
@@ -434,11 +439,9 @@ export function ConversationManageDialog({
key={s} key={s}
onSelect={() => handleBulkStatus(s)} onSelect={() => handleBulkStatus(s)}
> >
<span <ConversationStatusIcon
className={cn( status={s}
"h-2 w-2 rounded-full mr-2", className={cn("h-4 w-4", STATUS_ICON_COLORS[s])}
STATUS_COLORS[s]
)}
/> />
{tStatus(s)} {tStatus(s)}
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -0,0 +1,105 @@
"use client"
import type { ConversationStatus } from "@/lib/types"
import { cn } from "@/lib/utils"
interface ConversationStatusIconProps {
status: ConversationStatus
className?: string
}
export function ConversationStatusIcon({
status,
className,
}: ConversationStatusIconProps) {
return (
<svg
className={cn("shrink-0", className)}
width="1em"
height="1em"
viewBox="0 0 10 10"
preserveAspectRatio="xMidYMid meet"
aria-hidden
>
{status === "in_progress" ? (
<>
<circle
cx="5"
cy="5"
r="3.8"
fill="none"
stroke="currentColor"
strokeWidth="1.1"
opacity="0.28"
/>
<path
d="M5 1.2 A 3.8 3.8 0 1 1 1.2 5"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
>
<animateTransform
attributeName="transform"
type="rotate"
from="0 5 5"
to="360 5 5"
dur="1.1s"
repeatCount="indefinite"
/>
</path>
</>
) : status === "pending_review" ? (
<>
<circle
cx="5"
cy="5"
r="3.9"
fill="none"
stroke="currentColor"
strokeWidth="1.1"
opacity="0.35"
/>
<circle cx="5" cy="5" r="2" fill="currentColor" />
</>
) : status === "cancelled" ? (
<>
<circle
cx="5"
cy="5"
r="3.9"
fill="none"
stroke="currentColor"
strokeWidth="1.1"
/>
<path
d="M3.4 3.4L6.6 6.6M6.6 3.4L3.4 6.6"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
</>
) : (
<>
<circle
cx="5"
cy="5"
r="3.9"
fill="none"
stroke="currentColor"
strokeWidth="1.1"
/>
<path
d="M3.2 5.1 L4.4 6.3 L6.9 3.6"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
)}
</svg>
)
}

View File

@@ -17,8 +17,9 @@ import type {
DbConversationSummary, DbConversationSummary,
} from "@/lib/types" } from "@/lib/types"
import { useFileTree, type FlatFileEntry } from "@/hooks/use-file-tree" import { useFileTree, type FlatFileEntry } from "@/hooks/use-file-tree"
import { AGENT_LABELS, STATUS_COLORS, compareAgentType } from "@/lib/types" import { AGENT_LABELS, STATUS_ICON_COLORS, compareAgentType } from "@/lib/types"
import { AgentIcon } from "@/components/agent-icon" import { AgentIcon } from "@/components/agent-icon"
import { ConversationStatusIcon } from "@/components/conversations/conversation-status-icon"
import { import {
CommandDialog, CommandDialog,
CommandInput, CommandInput,
@@ -268,11 +269,12 @@ export function SearchCommandDialog({
value={`${conv.id}-${conv.title ?? ""}`} value={`${conv.id}-${conv.title ?? ""}`}
onSelect={() => handleSelectConversation(conv)} onSelect={() => handleSelectConversation(conv)}
> >
<span <ConversationStatusIcon
status={conv.status as ConversationStatus}
className={cn( className={cn(
"w-2 h-2 rounded-full shrink-0", "h-4 w-4",
STATUS_COLORS[conv.status as ConversationStatus] ?? STATUS_ICON_COLORS[conv.status as ConversationStatus] ??
"bg-gray-400" "text-muted-foreground"
)} )}
/> />
<span className="flex-1 truncate"> <span className="flex-1 truncate">

View File

@@ -1,20 +1,10 @@
"use client" "use client"
import { memo, useState, useCallback } from "react" import { memo, useState, useCallback } from "react"
import { import { Pencil, Trash2, Circle, Plus } from "lucide-react"
Pencil,
Trash2,
Circle,
CircleAlert,
CircleCheck,
CircleDashed,
CircleX,
Plus,
type LucideIcon,
} from "lucide-react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import type { DbConversationSummary, ConversationStatus } from "@/lib/types" import type { DbConversationSummary, ConversationStatus } from "@/lib/types"
import { STATUS_ORDER } from "@/lib/types" import { STATUS_ICON_COLORS, STATUS_ORDER } from "@/lib/types"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { import {
ContextMenu, ContextMenu,
@@ -45,24 +35,8 @@ import {
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { import { ConversationStatusIcon } from "./conversation-status-icon"
SidebarStatusIcon, import { SidebarStatusIcon } from "./sidebar-status-icon"
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 { interface SidebarConversationCardProps {
conversation: DbConversationSummary conversation: DbConversationSummary
@@ -123,7 +97,6 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
}, [conversation.id, conversation.agent_type, onDelete]) }, [conversation.id, conversation.agent_type, onDelete])
const status = conversation.status as ConversationStatus const status = conversation.status as ConversationStatus
const beadStatus = conversationStatusToBead(conversation.status)
const isRunning = status === "in_progress" const isRunning = status === "in_progress"
const isFailed = status === "cancelled" const isFailed = status === "cancelled"
@@ -165,7 +138,7 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
transform: "translateX(-50%)", transform: "translateX(-50%)",
}} }}
/> />
<SidebarStatusIcon status={beadStatus} emphasized={isOpenInTab} /> <SidebarStatusIcon status={status} emphasized={isOpenInTab} />
<span <span
className={cn( className={cn(
@@ -238,20 +211,18 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
</ContextMenuSubTrigger> </ContextMenuSubTrigger>
<ContextMenuSubContent> <ContextMenuSubContent>
{STATUS_ORDER.filter((s) => s !== conversation.status).map( {STATUS_ORDER.filter((s) => s !== conversation.status).map(
(s) => { (s) => (
const StatusIcon = STATUS_ICONS[s]
return (
<ContextMenuItem <ContextMenuItem
key={s} key={s}
onSelect={() => onStatusChange(conversation.id, s)} onSelect={() => onStatusChange(conversation.id, s)}
> >
<StatusIcon <ConversationStatusIcon
status={s}
className={cn("h-4 w-4", STATUS_ICON_COLORS[s])} className={cn("h-4 w-4", STATUS_ICON_COLORS[s])}
/> />
{tStatus(s)} {tStatus(s)}
</ContextMenuItem> </ContextMenuItem>
) )
}
)} )}
</ContextMenuSubContent> </ContextMenuSubContent>
</ContextMenuSub> </ContextMenuSub>

View File

@@ -1,24 +1,29 @@
"use client" "use client"
import type { ConversationStatus } from "@/lib/types"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ConversationStatusIcon } from "./conversation-status-icon"
export type SidebarBeadStatus = "done" | "active" | "running" | "failed"
interface SidebarStatusIconProps { interface SidebarStatusIconProps {
status: SidebarBeadStatus status: ConversationStatus
emphasized?: boolean emphasized?: boolean
className?: string className?: string
} }
function IconFrame({ export function SidebarStatusIcon({
children, status,
colorClass, emphasized = false,
className, className,
}: { }: SidebarStatusIconProps) {
children: React.ReactNode const colorClass =
colorClass: string status === "completed"
className?: string ? emphasized
}) { ? "text-sidebar-primary/75"
: "text-sidebar-primary/40"
: emphasized
? "text-sidebar-primary"
: "text-sidebar-primary/65"
return ( return (
<div <div
className={cn( className={cn(
@@ -35,167 +40,10 @@ function IconFrame({
}} }}
aria-hidden aria-hidden
> >
{children} <ConversationStatusIcon
status={status}
className="h-[0.75rem] w-[0.75rem]"
/>
</div> </div>
) )
} }
export function SidebarStatusIcon({
status,
emphasized = false,
className,
}: SidebarStatusIconProps) {
if (status === "running") {
return (
<IconFrame
colorClass={
emphasized ? "text-sidebar-primary" : "text-sidebar-primary/65"
}
className={className}
>
<svg
width="0.75rem"
height="0.75rem"
viewBox="0 0 10 10"
preserveAspectRatio="xMidYMid meet"
>
<circle
cx="5"
cy="5"
r="3.8"
fill="none"
stroke="currentColor"
strokeWidth="1.1"
opacity="0.28"
/>
<path
d="M5 1.2 A 3.8 3.8 0 1 1 1.2 5"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
>
<animateTransform
attributeName="transform"
type="rotate"
from="0 5 5"
to="360 5 5"
dur="1.1s"
repeatCount="indefinite"
/>
</path>
</svg>
</IconFrame>
)
}
if (status === "failed") {
return (
<IconFrame
colorClass={
emphasized ? "text-sidebar-primary" : "text-sidebar-primary/65"
}
className={className}
>
<svg
width="0.75rem"
height="0.75rem"
viewBox="0 0 10 10"
preserveAspectRatio="xMidYMid meet"
>
<circle
cx="5"
cy="5"
r="3.9"
fill="none"
stroke="currentColor"
strokeWidth="1.1"
/>
<path
d="M3.4 3.4L6.6 6.6M6.6 3.4L3.4 6.6"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
</svg>
</IconFrame>
)
}
if (status === "active") {
return (
<IconFrame
colorClass={
emphasized ? "text-sidebar-primary" : "text-sidebar-primary/65"
}
className={className}
>
<svg
width="0.75rem"
height="0.75rem"
viewBox="0 0 10 10"
preserveAspectRatio="xMidYMid meet"
>
<circle
cx="5"
cy="5"
r="3.9"
fill="none"
stroke="currentColor"
strokeWidth="1.1"
opacity="0.35"
/>
<circle cx="5" cy="5" r="2" fill="currentColor" />
</svg>
</IconFrame>
)
}
return (
<IconFrame
colorClass={
emphasized ? "text-sidebar-primary/75" : "text-sidebar-primary/40"
}
className={className}
>
<svg
width="0.75rem"
height="0.75rem"
viewBox="0 0 10 10"
preserveAspectRatio="xMidYMid meet"
>
<circle
cx="5"
cy="5"
r="3.9"
fill="none"
stroke="currentColor"
strokeWidth="1.1"
/>
<path
d="M3.2 5.1 L4.4 6.3 L6.9 3.6"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</IconFrame>
)
}
export function conversationStatusToBead(status: string): SidebarBeadStatus {
switch (status) {
case "in_progress":
return "running"
case "pending_review":
return "active"
case "cancelled":
return "failed"
case "completed":
default:
return "done"
}
}

View File

@@ -228,6 +228,13 @@ export const STATUS_COLORS: Record<ConversationStatus, string> = {
cancelled: "bg-red-500", cancelled: "bg-red-500",
} }
export 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",
}
export const AGENT_DISPLAY_ORDER: AgentType[] = [ export const AGENT_DISPLAY_ORDER: AgentType[] = [
"codex", "codex",
"claude_code", "claude_code",