feat: refine workbench visuals and map controls

Unify the Agent history extension with the header acrylic surface, preserve the full conversation body, and consolidate shared control and status styling.

Restore map flow and SCADA controller behavior, remove obsolete rendering paths, and extend regression coverage. Button press coverage now releases outside the target so state assertions cannot accidentally toggle the control.
This commit is contained in:
2026-07-28 16:39:36 +08:00
parent ade88b4fbf
commit f4318a9b9c
96 changed files with 5480 additions and 1875 deletions
@@ -10,9 +10,16 @@ import {
type LucideIcon
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState, type ReactNode } from "react";
import { useId, useState, type ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import { Button } from "@/shared/ui/button";
import {
StatusBadge,
StatusDot,
StatusIcon as SemanticStatusIcon,
type StatusActivity,
type StatusTone
} from "@/shared/ui/status";
import {
agentEase,
agentLayoutTransition,
@@ -37,63 +44,64 @@ type QuestionStatus = AgentQuestionRequest["status"];
const STREAMING_PROGRESS_LIMIT = 3;
const progressStatusMeta: Record<ProgressStatus, { label: string; className: string; dotClassName: string }> = {
type AgentStatusMeta = {
label: string;
tone: StatusTone;
activity?: StatusActivity;
};
const progressStatusMeta: Record<ProgressStatus, AgentStatusMeta> = {
running: {
label: "进行中",
className: "bg-blue-100 text-blue-700",
dotClassName: "border-blue-500 bg-blue-100"
tone: "info",
activity: "live"
},
completed: {
label: "完成",
className: "bg-emerald-100 text-emerald-700",
dotClassName: "border-emerald-500 bg-emerald-100"
label: "已完成",
tone: "success"
},
error: {
label: "失败",
className: "bg-red-100 text-red-700",
dotClassName: "border-red-500 bg-red-100"
tone: "danger"
}
};
const todoStatusMeta: Record<TodoStatus, { label: string; className: string; dotClassName: string }> = {
const todoStatusMeta: Record<TodoStatus, AgentStatusMeta> = {
completed: {
label: "完成",
className: "bg-emerald-100 text-emerald-700",
dotClassName: "border-emerald-500 bg-emerald-500"
tone: "success"
},
in_progress: {
label: "进行中",
className: "bg-blue-100 text-blue-700",
dotClassName: "border-blue-500 bg-blue-100"
tone: "info",
activity: "live"
},
pending: {
label: "待办",
className: "bg-slate-100 text-slate-600",
dotClassName: "border-slate-300 bg-white"
tone: "neutral"
},
cancelled: {
label: "取消",
className: "bg-slate-200 text-slate-500",
dotClassName: "border-slate-300 bg-slate-200"
tone: "neutral"
}
};
const permissionStatusMeta: Record<PermissionStatus, { label: string; className: string }> = {
approved_always: { label: "已始终允许", className: "bg-emerald-100 text-emerald-700" },
approved_once: { label: "已允许一次", className: "bg-emerald-100 text-emerald-700" },
rejected: { label: "已拒绝", className: "bg-red-100 text-red-700" },
aborted: { label: "已中止", className: "bg-slate-200 text-slate-500" },
error: { label: "失败", className: "bg-red-100 text-red-700" },
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
pending: { label: "待确认", className: "bg-orange-100 text-orange-700" }
const permissionStatusMeta: Record<PermissionStatus, AgentStatusMeta> = {
approved_always: { label: "已始终允许", tone: "success" },
approved_once: { label: "已允许一次", tone: "success" },
rejected: { label: "已拒绝", tone: "danger" },
aborted: { label: "已中止", tone: "neutral" },
error: { label: "失败", tone: "danger" },
submitting: { label: "提交中", tone: "info", activity: "live" },
pending: { label: "待确认", tone: "warning" }
};
const questionStatusMeta: Record<QuestionStatus, { label: string; className: string }> = {
answered: { label: "已回答", className: "bg-emerald-100 text-emerald-700" },
rejected: { label: "已跳过", className: "bg-slate-200 text-slate-500" },
error: { label: "失败", className: "bg-red-100 text-red-700" },
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
pending: { label: "待回答", className: "bg-blue-100 text-blue-700" }
const questionStatusMeta: Record<QuestionStatus, AgentStatusMeta> = {
answered: { label: "已回答", tone: "success" },
rejected: { label: "已跳过", tone: "neutral" },
error: { label: "失败", tone: "danger" },
submitting: { label: "提交中", tone: "info", activity: "live" },
pending: { label: "待回答", tone: "info" }
};
export function AgentMessageDetails({
@@ -198,6 +206,7 @@ function AnimatedDetailItem({ children }: { children: ReactNode }) {
function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) {
const [expanded, setExpanded] = useState(false);
const listId = useId();
const compact = running && !expanded;
const visibleProgress = expanded
? progress
@@ -207,33 +216,47 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
const listMode = expanded ? "expanded" : running ? "streaming" : "collapsed";
return (
<div className="agent-panel-nested rounded-xl p-2.5">
<button
type="button"
className="flex w-full items-center gap-2 text-left text-xs font-semibold text-slate-600"
aria-expanded={expanded}
onClick={() => setExpanded((current) => !current)}
>
<ListTree size={16} className="shrink-0 text-blue-700" aria-hidden="true" />
<span>执行进度</span>
<span className="flex-1" />
<span className="shrink-0 font-normal text-slate-400">
{expanded
? `全部 ${progress.length} 条`
: running
? `最近 ${Math.min(progress.length, STREAMING_PROGRESS_LIMIT)} 条`
: `共 ${progress.length} 条`}
<div className="agent-panel-nested rounded-xl p-2.5" data-agent-progress>
<div className="flex min-h-8 items-center gap-2 px-0.5">
<ListTree size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-slate-700">执行进度</span>
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-slate-500">
{running ? (
<StatusDot tone="info" activity="live" aria-hidden="true" />
) : null}
<span>{running ? (expanded ? "全部" : "最近") : "步骤"}</span>
<span className="font-mono tabular-nums text-slate-600">
{expanded || !running
? progress.length
: Math.min(progress.length, STREAMING_PROGRESS_LIMIT)}
<span className="px-0.5 text-slate-300">/</span>
{progress.length}
</span>
</span>
<ChevronDown
size={14}
className={cn("shrink-0 text-slate-400 transition-transform", expanded && "rotate-180")}
aria-hidden="true"
/>
</button>
<button
type="button"
className={cn(
"-my-1 -mr-1.5 grid h-10 w-10 shrink-0 place-items-center rounded-md text-slate-400 transition-colors [@media(hover:hover)]:hover:text-blue-600",
expanded && "text-blue-500"
)}
aria-controls={listId}
aria-expanded={expanded}
aria-label={expanded ? "收起执行进度" : "展开执行进度"}
data-state={expanded ? "open" : "closed"}
onClick={() => setExpanded((current) => !current)}
>
<ChevronDown
size={14}
className={cn("transition-transform duration-150", expanded && "rotate-180")}
aria-hidden="true"
/>
</button>
</div>
<AnimatePresence initial={false} mode="wait">
{visibleProgress.length ? (
<motion.ol
key={listMode}
id={listId}
aria-label={expanded ? "全部执行进度" : "最近执行进度"}
className="mt-2 flex flex-col gap-1.5 overflow-hidden"
initial={{ height: 0, opacity: 0 }}
@@ -254,26 +277,11 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
exit={{ opacity: 0, y: -2 }}
transition={{ duration: 0.16, ease: agentEase }}
>
<motion.span
aria-hidden="true"
className={cn(
"mt-0.5 h-3.5 w-3.5 rounded-full border transition-colors duration-150",
progressStatusMeta[item.status].dotClassName
)}
animate={
item.status === "running"
? { opacity: [0.45, 1, 0.45], scale: 1 }
: item.status === "error"
? { opacity: [0.55, 1, 1], scale: [1, 1.28, 1] }
: { opacity: 1, scale: 1 }
}
transition={
item.status === "running"
? { duration: 1.2, ease: "easeInOut", repeat: Infinity }
: item.status === "error"
? { duration: 0.28, ease: agentEase }
: { duration: 0.14 }
}
<StatusDot
tone={progressStatusMeta[item.status].tone}
activity={progressStatusMeta[item.status].activity}
size="md"
className="mt-1"
/>
<span className="min-w-0">
<span
@@ -302,21 +310,18 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
key={item.status}
data-progress-status={item.status}
className={cn(
"min-w-12 rounded-full px-2 py-0.5 text-center font-semibold",
progressStatusMeta[item.status].className
"min-w-12 pt-0.5 text-right text-xs font-semibold",
item.status === "running"
? "text-blue-600"
: [
`status-tone-${progressStatusMeta[item.status].tone}`,
"text-[var(--status-foreground)]"
]
)}
initial={{ opacity: 0 }}
animate={
item.status === "error"
? { opacity: [0, 1, 1], x: [0, -2, 2, -1, 0] }
: { opacity: 1, x: 0 }
}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={
item.status === "error"
? { duration: 0.24, ease: agentEase }
: { duration: 0.14 }
}
transition={{ duration: 0.14 }}
>
{progressStatusMeta[item.status].label}
</motion.span>
@@ -333,7 +338,7 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
return (
<AgentNestedBlock icon={ListChecks} iconClassName="text-emerald-600" title="计划">
<AgentNestedBlock icon={ListChecks} iconClassName="status-tone-success text-[var(--status-foreground)]" title="计划">
<motion.ol className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{todoUpdate.todos.slice(0, 6).map((todo) => (
@@ -346,20 +351,24 @@ function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
<span
className={cn(
"mt-0.5 grid h-3.5 w-3.5 place-items-center rounded-full border",
todoStatusMeta[todo.status].dotClassName
)}
<SemanticStatusIcon
tone={todoStatusMeta[todo.status].tone}
activity={todoStatusMeta[todo.status].activity}
shape="circle"
size="xs"
className="mt-0.5"
>
{todo.status === "completed" ? <CheckCircle2 size={10} className="text-white" aria-hidden="true" /> : null}
</span>
{todo.status === "completed" ? <CheckCircle2 size={10} /> : null}
</SemanticStatusIcon>
<span className="min-w-0 truncate text-slate-700" title={todo.content}>
{todo.content}
</span>
<span className={cn("rounded-full px-2 py-0.5 font-semibold", todoStatusMeta[todo.status].className)}>
<StatusBadge
tone={todoStatusMeta[todo.status].tone}
activity={todoStatusMeta[todo.status].activity}
>
{todoStatusMeta[todo.status].label}
</span>
</StatusBadge>
</motion.li>
))}
</AnimatePresence>
@@ -376,7 +385,7 @@ function PermissionList({
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
}) {
return (
<AgentNestedBlock icon={LockKeyhole} iconClassName="text-orange-600" title="权限请求">
<AgentNestedBlock icon={LockKeyhole} iconClassName="status-tone-warning text-[var(--status-foreground)]" title="权限请求">
<motion.div className="flex flex-col gap-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{permissions.slice(-4).map((permission) => (
@@ -424,18 +433,22 @@ function PermissionRequestCard({
{target}
</p>
{permission.error ? (
<p className="mt-1 line-clamp-2 text-xs leading-4 text-red-600">{permission.error}</p>
<p className="status-tone-danger mt-1 line-clamp-2 text-xs leading-4 text-[var(--status-foreground)]">{permission.error}</p>
) : null}
</div>
<span className={cn("self-start rounded-full px-2 py-0.5 font-semibold", permissionStatusMeta[permission.status].className)}>
<StatusBadge
tone={permissionStatusMeta[permission.status].tone}
activity={permissionStatusMeta[permission.status].activity}
className="self-start"
>
{permissionStatusMeta[permission.status].label}
</span>
</StatusBadge>
</div>
<AnimatePresence initial={false}>
{actionable ? (
<motion.div
key="permission-actions"
className="mt-2 flex flex-wrap items-center gap-1.5"
className="mt-2 flex flex-wrap items-center gap-2"
initial="initial"
animate="animate"
exit="exit"
@@ -445,7 +458,7 @@ function PermissionRequestCard({
<Button
type="button"
size="sm"
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
className="h-8 px-2 text-xs"
disabled={disabled}
onClick={() => reply("once")}
>
@@ -457,7 +470,7 @@ function PermissionRequestCard({
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md border-blue-200 bg-blue-50 px-2 text-xs text-blue-700 hover:bg-blue-100"
className="h-8 border-blue-200 bg-blue-50 px-2 text-xs text-blue-700 hover:bg-blue-100"
disabled={disabled}
onClick={() => reply("always")}
>
@@ -469,7 +482,7 @@ function PermissionRequestCard({
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md border-red-200 bg-red-50 px-2 text-xs text-red-700 hover:bg-red-100"
className="status-tone-danger h-8 border-[var(--status-border)] bg-[var(--status-soft)] px-2 text-xs text-[var(--status-foreground)] [@media(hover:hover)]:hover:brightness-95"
disabled={disabled}
onClick={() => reply("reject")}
>
@@ -547,9 +560,12 @@ function QuestionRequestCard({
<span className="min-w-0 truncate font-semibold text-slate-800">
{request.questions[0]?.header || "问题"}
</span>
<span className={cn("rounded-full px-2 py-0.5 font-semibold", questionStatusMeta[request.status].className)}>
<StatusBadge
tone={questionStatusMeta[request.status].tone}
activity={questionStatusMeta[request.status].activity}
>
{questionStatusMeta[request.status].label}
</span>
</StatusBadge>
</div>
<div className="space-y-3">
@@ -568,7 +584,7 @@ function QuestionRequestCard({
</div>
{request.error ? (
<p className="mt-2 line-clamp-2 text-xs leading-4 text-red-600">{request.error}</p>
<p className="status-tone-danger mt-2 line-clamp-2 text-xs leading-4 text-[var(--status-foreground)]">{request.error}</p>
) : null}
{request.answers?.length ? (
<p className="mt-2 truncate text-slate-500" title={request.answers.map((answer) => answer.join("、")).join(";")}>
@@ -580,7 +596,7 @@ function QuestionRequestCard({
{actionable ? (
<motion.div
key="question-actions"
className="mt-2 flex flex-wrap items-center gap-1.5"
className="mt-2 flex flex-wrap items-center gap-2"
initial="initial"
animate="animate"
exit="exit"
@@ -590,7 +606,7 @@ function QuestionRequestCard({
<Button
type="button"
size="sm"
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
className="h-8 px-2 text-xs"
disabled={disabled || !canSubmit}
onClick={submit}
>
@@ -601,7 +617,7 @@ function QuestionRequestCard({
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md border-slate-200 bg-white px-2 text-xs text-slate-600 hover:bg-slate-50"
className="h-8 border-slate-200 bg-white px-2 text-xs text-slate-600 hover:bg-slate-50"
disabled={submitting || !onRejectQuestion}
onClick={reject}
>