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
@@ -28,7 +28,7 @@ export function AgentCollapsedRail({
aria-label={expandLabel}
title={expandLabel}
onClick={onExpand}
className="agent-rail-item surface-control group relative flex h-14 w-full items-center justify-center rounded-xl border transition-[background-color,transform] hover:bg-white active:scale-95"
className="agent-rail-item surface-control group relative flex h-14 w-full items-center justify-center rounded-xl border hover:bg-white"
>
<AgentPersona className="h-11 w-11" state={personaState} />
<span className="agent-panel-primary-icon absolute bottom-0.5 right-0.5 grid h-5 w-5 place-items-center rounded-md border-0! shadow-xs transition-transform group-hover:translate-x-0.5">
@@ -19,7 +19,7 @@ import {
X
} from "lucide-react";
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useStickToBottomContext } from "use-stick-to-bottom";
import { showMapNotice } from "@/features/map/core";
@@ -48,6 +48,7 @@ import {
} from "@/shared/ui/dropdown-menu";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import { StatusBadge, StatusDot, type StatusTone } from "@/shared/ui/status";
import type { AgentChatSessionSummary } from "../api/client";
import { AgentPersona } from "./agent-persona";
import {
@@ -70,6 +71,7 @@ import {
import type {
AgentApprovalMode,
AgentChatMessage,
AgentConnectionStatus,
AgentModelOption,
AgentPermissionReply,
AgentPermissionRequest,
@@ -78,10 +80,7 @@ import type {
AgentUiResult
} from "../types";
export type AgentCommandPanelPresentation =
| "desktop-dock"
| "desktop-floating"
| "mobile-sheet";
export type AgentCommandPanelPresentation = "desktop-dock" | "desktop-floating" | "mobile-sheet";
type AgentCommandPanelProps = {
presentation?: AgentCommandPanelPresentation;
@@ -92,6 +91,7 @@ type AgentCommandPanelProps = {
sessionHistoryLoading?: boolean;
activeSessionId?: string | null;
statusLabel?: string;
connectionStatus?: AgentConnectionStatus;
streaming?: boolean;
messages?: AgentChatMessage[];
streamRenderState?: AgentStreamRenderState;
@@ -142,6 +142,7 @@ export function AgentCommandPanel({
sessionHistoryLoading = false,
activeSessionId,
statusLabel = "Agent 后端连接中",
connectionStatus = "connecting",
streaming = false,
messages = [],
streamRenderState = {},
@@ -167,6 +168,12 @@ export function AgentCommandPanel({
const [historyOpen, setHistoryOpen] = useState(false);
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [scrollRequestId, setScrollRequestId] = useState(0);
const historyPanelId = useId();
const historyTriggerRef = useRef<HTMLButtonElement | null>(null);
const historyPanelRef = useRef<HTMLDivElement | null>(null);
const desktopFloating = presentation === "desktop-floating";
const floatingPresentation = desktopFloating || presentation === "mobile-sheet";
const hasConversation = messages.length > 0 || streaming;
const trimmedPrompt = prompt.trim();
const {
speechState,
@@ -202,6 +209,46 @@ export function AgentCommandPanel({
}
}, [historyOpen, onRefreshHistory]);
useEffect(() => {
if (!historyOpen) {
return;
}
const closeHistoryOnOutsidePointer = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (
historyPanelRef.current?.contains(target) ||
historyTriggerRef.current?.contains(target)
) {
return;
}
setHistoryOpen(false);
};
const closeHistoryOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") {
return;
}
event.preventDefault();
event.stopPropagation();
setHistoryOpen(false);
window.requestAnimationFrame(() => historyTriggerRef.current?.focus());
};
document.addEventListener("pointerdown", closeHistoryOnOutsidePointer, true);
document.addEventListener("keydown", closeHistoryOnEscape);
return () => {
document.removeEventListener("pointerdown", closeHistoryOnOutsidePointer, true);
document.removeEventListener("keydown", closeHistoryOnEscape);
};
}, [historyOpen]);
useEffect(() => {
stopSpeech();
}, [activeSessionId, stopSpeech]);
@@ -221,155 +268,217 @@ export function AgentCommandPanel({
setPrompt(nextPrompt);
});
};
const historyPanel = (
<AgentHistoryPanel
activeSessionId={activeSessionId}
loadingSessionId={loadingSessionId}
loading={sessionHistoryLoading}
sessions={sessionHistory}
onRefresh={onRefreshHistory}
onSelectSession={(nextSessionId) => {
setLoadingSessionId(nextSessionId);
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
.then(() => setHistoryOpen(false))
.finally(() => setLoadingSessionId(null));
}}
onRenameSession={onRenameHistorySession}
onDeleteSession={onDeleteHistorySession}
/>
);
const operationalBrief = (
<AgentOperationalBrief
messages={messages}
statusLabel={statusLabel}
streaming={streaming}
onSubmitCommand={submitPrompt}
/>
);
return (
<MotionConfig reducedMotion="user">
<aside
aria-label="Agent 命令面板"
className={cn(
"pointer-events-auto flex h-full min-h-0 w-full flex-col overflow-hidden",
"pointer-events-auto flex h-full min-h-0 w-full flex-col",
"agent-panel-shell",
presentation === "desktop-dock"
? "acrylic-panel rounded-r-2xl border-y border-r"
: presentation === "desktop-floating"
? "acrylic-panel rounded-2xl border"
: "rounded-none border-0 bg-transparent",
? "acrylic-panel overflow-hidden rounded-r-2xl border-y border-r"
: desktopFloating
? "acrylic-panel overflow-visible rounded-2xl border"
: "overflow-hidden rounded-none border-0 bg-transparent",
collapsing ? "agent-panel-collapse" : "agent-panel-enter"
)}
>
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent">
<header className="agent-panel-header">
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
<AgentPersona className="h-12 w-12" state={personaState} />
<div className="min-w-0 flex-1">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500">
<Agent
className={cn(
"relative flex h-full min-h-0 flex-col border-0 bg-transparent",
floatingPresentation && "gap-1 overflow-visible p-1"
)}
>
<div className="relative z-30 h-16 shrink-0">
<header
className={cn(
"agent-panel-header agent-panel-header-expandable inset-x-0 top-0 overflow-hidden",
floatingPresentation && "agent-panel-integrated-header",
presentation === "mobile-sheet" && "agent-panel-header-mobile",
historyOpen && "agent-panel-header-expanded"
)}
>
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
<AgentPersona className="h-12 w-12" state={personaState} />
<div className="min-w-0 flex-1">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500">
</p>
<StatusBadge
tone={agentConnectionMetadata[connectionStatus].tone}
icon={
<StatusDot
tone={agentConnectionMetadata[connectionStatus].tone}
activity={connectionStatus === "connecting" ? "live" : "static"}
/>
}
>
{agentConnectionMetadata[connectionStatus].label}
</StatusBadge>
</div>
<p
className="truncate text-sm font-semibold text-slate-950"
title={sessionTitle}
>
{sessionTitle}
</p>
<span className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-2 text-xs font-semibold text-slate-600">
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
getAgentConnectionClassName(statusLabel)
)}
/>
{getAgentConnectionLabel(statusLabel)}
</span>
</div>
<p
className="truncate text-sm font-semibold text-slate-950"
title={sessionTitle}
>
{sessionTitle}
</p>
</div>
</div>
</div>
<button
type="button"
aria-label="新建 Agent 对话"
title="新建 Agent 对话"
onClick={() => {
setHistoryOpen(false);
setPrompt("");
void Promise.resolve(onStartNewSession?.());
}}
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
>
<Plus size={17} aria-hidden="true" />
</button>
<button
type="button"
aria-label="打开 Agent 历史记录"
title="打开 Agent 历史记录"
onClick={() => setHistoryOpen((open) => !open)}
className={cn(
"agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition",
historyOpen && "border-blue-200 bg-blue-50 text-blue-700"
<button
type="button"
aria-label="新建 Agent 对话"
title="新建 Agent 对话"
onClick={() => {
setHistoryOpen(false);
setPrompt("");
void Promise.resolve(onStartNewSession?.());
}}
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
>
<Plus size={17} aria-hidden="true" />
</button>
<button
ref={historyTriggerRef}
type="button"
aria-label="打开 Agent 历史记录"
title="打开 Agent 历史记录"
aria-expanded={historyOpen}
aria-controls={historyPanelId}
onClick={() => setHistoryOpen((open) => !open)}
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
>
<History size={17} aria-hidden="true" />
</button>
{presentation === "mobile-sheet" ? (
<button
type="button"
aria-label="关闭 Agent 面板"
title="关闭 Agent 面板"
onClick={onCollapse}
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
>
<X size={17} aria-hidden="true" />
</button>
) : (
<button
type="button"
aria-label="折叠 Agent 面板"
title="折叠 Agent 面板"
onClick={onCollapse}
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
>
<ChevronLeft size={17} aria-hidden="true" />
</button>
)}
>
<History size={17} aria-hidden="true" />
</button>
{presentation === "mobile-sheet" ? (
<button
type="button"
aria-label="关闭 Agent 面板"
title="关闭 Agent 面板"
onClick={onCollapse}
className="group grid h-10 w-10 shrink-0 place-items-center rounded-full text-slate-500"
>
<span className="surface-control grid h-9 w-9 place-items-center rounded-full shadow-[inset_0_0_0_1px_rgba(148,163,184,0.26),0_4px_12px_rgba(15,33,55,0.10)] transition-[color,background-color,scale] group-active:scale-95 [@media(hover:hover)]:group-hover:bg-blue-50 [@media(hover:hover)]:group-hover:text-blue-700">
<X size={17} strokeWidth={2.25} aria-hidden="true" />
</span>
</button>
) : (
<button
type="button"
aria-label="折叠 Agent 面板"
title="折叠 Agent 面板"
onClick={onCollapse}
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
>
<ChevronLeft size={17} aria-hidden="true" />
</button>
)}
</div>
</div>
<AnimatePresence initial={false}>
{historyOpen ? (
<motion.div
ref={historyPanelRef}
key="history"
id={historyPanelId}
role="region"
aria-label="Agent 历史记录"
className="agent-panel-floating-acrylic agent-panel-history-extension"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
{historyPanel}
</motion.div>
) : null}
</AnimatePresence>
</header>
</div>
{floatingPresentation ? (
<AnimatePresence initial={false}>
{historyOpen ? (
<motion.div
key="history"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
<AgentHistoryPanel
activeSessionId={activeSessionId}
loadingSessionId={loadingSessionId}
loading={sessionHistoryLoading}
sessions={sessionHistory}
onRefresh={onRefreshHistory}
onSelectSession={(nextSessionId) => {
setLoadingSessionId(nextSessionId);
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
.then(() => setHistoryOpen(false))
.finally(() => setLoadingSessionId(null));
}}
onRenameSession={onRenameHistorySession}
onDeleteSession={onDeleteHistorySession}
/>
</motion.div>
) : null}
</AnimatePresence>
<AnimatePresence initial={false}>
{messages.length > 0 || streaming ? (
{hasConversation && !historyOpen ? (
<motion.div
key="operational-brief"
className="space-y-2 px-3 pb-3"
className="agent-panel-operational-float absolute z-20 overflow-hidden rounded-2xl"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
>
{operationalBrief}
</motion.div>
) : null}
</AnimatePresence>
) : (
<AnimatePresence initial={false}>
{hasConversation ? (
<motion.div
key="operational-brief"
aria-hidden={historyOpen}
className={cn(
"space-y-2 px-3 pb-3",
historyOpen && "invisible pointer-events-none"
)}
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
<AgentOperationalBrief
messages={messages}
statusLabel={statusLabel}
streaming={streaming}
onSubmitCommand={submitPrompt}
/>
{operationalBrief}
</motion.div>
) : null}
</AnimatePresence>
</header>
)}
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
<Conversation className="agent-panel-conversation min-h-0">
<Conversation
className={cn(
"agent-panel-conversation min-h-0",
floatingPresentation && "agent-panel-conversation-canvas"
)}
>
<ConversationContent
className={cn("gap-4 p-3", messages.length === 0 && "min-h-full")}
className={cn(
"gap-4 p-3",
messages.length === 0 && "min-h-full",
floatingPresentation && "agent-panel-conversation-content",
floatingPresentation &&
hasConversation &&
"agent-panel-conversation-content-has-brief",
floatingPresentation &&
!hasConversation &&
"agent-panel-conversation-content-empty"
)}
>
<AnimatePresence mode="popLayout" initial={false}>
{messages.length > 0 ? (
@@ -409,14 +518,32 @@ export function AgentCommandPanel({
activeSessionId={activeSessionId}
messages={messages}
scrollRequestId={scrollRequestId}
streaming={streaming}
/>
<ConversationScrollButton
className={cn(
"agent-panel-floating-acrylic agent-conversation-scroll-button",
floatingPresentation ? "bottom-[10.25rem] z-30" : "bottom-3"
)}
/>
<ConversationScrollButton className="bottom-3" />
</Conversation>
</AgentContent>
<div className="agent-panel-band relative border-t border-slate-200 p-3">
{messages.length === 0 && !streaming ? (
<div className="agent-panel-conversation pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2">
<div
className={cn(
"agent-panel-band relative",
floatingPresentation
? "agent-panel-floating-acrylic agent-panel-composer absolute bottom-3 z-20 rounded-2xl p-1"
: "border-t border-slate-200 p-3"
)}
>
{!hasConversation ? (
<div
className={cn(
"agent-panel-conversation pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2",
floatingPresentation && "bg-transparent"
)}
>
<Suggestions
aria-label="推荐问题"
role="group"
@@ -436,7 +563,7 @@ export function AgentCommandPanel({
</div>
) : null}
<PromptInput
className="agent-panel-control overflow-hidden rounded-2xl shadow-xs transition-[border-color,box-shadow] has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-ring [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:ring-0!"
className="agent-panel-control overflow-hidden rounded-2xl shadow-xs [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:ring-0!"
onSubmit={(message) => {
const nextPrompt = message.text.trim();
if (nextPrompt) {
@@ -448,7 +575,7 @@ export function AgentCommandPanel({
<PromptInputTextarea
value={prompt}
onChange={(event) => setPrompt(event.currentTarget.value)}
className="min-h-14 text-sm"
className={cn("text-sm", floatingPresentation ? "min-h-20" : "min-h-14")}
placeholder="输入调度问题,Agent 将通过后端会话流式响应"
/>
</PromptInputBody>
@@ -460,7 +587,7 @@ export function AgentCommandPanel({
onValueChange={onApprovalModeChange}
/>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<div className="flex shrink-0 items-center gap-2">
<AgentModelSelect
models={modelOptions}
value={selectedModel}
@@ -522,22 +649,22 @@ function VoiceInputButton({
aria-pressed={isListening}
title={label}
className={cn(
"group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border",
"group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border after:absolute after:-inset-1 after:content-['']",
"border-transparent bg-transparent text-blue-600 shadow-none",
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
"hover:bg-slate-50 hover:text-blue-700",
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25 focus-visible:ring-offset-1",
"disabled:cursor-not-allowed disabled:opacity-45",
isListening && "bg-red-50 text-red-600 hover:bg-red-100 hover:text-red-700"
isListening &&
"status-tone-danger bg-[var(--status-soft)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:brightness-95"
)}
disabled={disabled}
onClick={onClick}
whileTap={reduceMotion ? undefined : { scale: 0.94 }}
>
{isListening ? (
<motion.span
data-slot="voice-input-pulse"
className="pointer-events-none absolute inset-0 rounded-lg bg-red-400/20"
className="pointer-events-none absolute inset-0 rounded-lg bg-[var(--status-mark)] opacity-20"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.9 }}
animate={
@@ -564,7 +691,7 @@ function VoiceInputButton({
{VOICE_WAVE_BARS.map((bar) => (
<motion.span
key={bar.height}
className="w-px rounded-full bg-red-500"
className="w-px rounded-full bg-[var(--status-mark)]"
style={{ height: bar.height }}
animate={
reduceMotion ? { scaleY: 0.72 } : { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
@@ -623,7 +750,8 @@ function ApprovalModeSelect({
<Button
type="button"
variant="outline"
className="h-8 shrink-0 gap-1.5 rounded-md border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
size="sm"
className="shrink-0 gap-1.5 border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
disabled={disabled || !onValueChange}
aria-label="权限批准模式"
>
@@ -664,17 +792,24 @@ function ApprovalModeSelect({
<DropdownMenuItem
className={cn(
"mt-1 items-start rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
!requestApproval && "border-emerald-200 bg-emerald-50 focus:bg-emerald-50"
!requestApproval &&
"status-tone-success border-[var(--status-border)] bg-[var(--status-soft)] focus:bg-[var(--status-soft)]"
)}
onSelect={() => onValueChange?.("always")}
>
<ShieldCheck className="mt-0.5 text-emerald-600" aria-hidden="true" />
<ShieldCheck
className="status-tone-success mt-0.5 text-[var(--status-foreground)]"
aria-hidden="true"
/>
<span>
<span className="block text-sm font-semibold text-slate-800"></span>
<span className="block text-xs leading-5 text-slate-500"></span>
</span>
{!requestApproval ? (
<CheckCircle2 className="ml-auto mt-0.5 text-emerald-600" aria-hidden="true" />
<CheckCircle2
className="status-tone-success ml-auto mt-0.5 text-[var(--status-foreground)]"
aria-hidden="true"
/>
) : null}
</DropdownMenuItem>
</DropdownMenuContent>
@@ -684,29 +819,36 @@ function ApprovalModeSelect({
type AgentConversationScrollSnapshot = {
activeSessionId?: string | null;
lastMessageRevision: string;
lastUserMessageId: string | null;
scrollRequestId: number;
streaming: boolean;
};
function AgentConversationScrollManager({
activeSessionId,
messages,
scrollRequestId
scrollRequestId,
streaming
}: {
activeSessionId?: string | null;
messages: AgentChatMessage[];
scrollRequestId: number;
streaming: boolean;
}) {
const { scrollToBottom } = useStickToBottomContext();
const previousSnapshotRef = useRef<AgentConversationScrollSnapshot | null>(null);
const lastUserMessageId = getLastUserMessageId(messages);
const lastMessageRevision = getLastMessageRevision(messages);
const snapshot = useMemo<AgentConversationScrollSnapshot>(
() => ({
activeSessionId,
lastMessageRevision,
lastUserMessageId,
scrollRequestId
scrollRequestId,
streaming
}),
[activeSessionId, lastUserMessageId, scrollRequestId]
[activeSessionId, lastMessageRevision, lastUserMessageId, scrollRequestId, streaming]
);
useEffect(() => {
@@ -718,7 +860,10 @@ function AgentConversationScrollManager({
previousSnapshot.activeSessionId !== snapshot.activeSessionId ||
previousSnapshot.scrollRequestId !== snapshot.scrollRequestId ||
(Boolean(snapshot.lastUserMessageId) &&
previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId);
previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId) ||
(snapshot.streaming &&
previousSnapshot.lastMessageRevision !== snapshot.lastMessageRevision) ||
previousSnapshot.streaming !== snapshot.streaming;
if (!forceScroll) {
return;
@@ -738,6 +883,19 @@ function AgentConversationScrollManager({
return null;
}
function getLastMessageRevision(messages: AgentChatMessage[]) {
const lastMessage = messages.at(-1);
if (!lastMessage) {
return "";
}
const progressRevision =
lastMessage.progress?.map((item) => `${item.id}:${item.status}`).join("|") ?? "";
return `${lastMessage.id}:${lastMessage.content.length}:${progressRevision}`;
}
function getLastUserMessageId(messages: AgentChatMessage[]) {
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index].role === "user") {
@@ -771,7 +929,8 @@ function AgentModelSelect({
<Button
type="button"
variant="outline"
className="h-8 w-24 shrink-0 justify-between gap-1.5 rounded-md border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
size="sm"
className="w-24 shrink-0 justify-between gap-1.5 border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
disabled={!onValueChange}
aria-label="选择 Agent 模型"
>
@@ -826,13 +985,11 @@ function AgentModelSelect({
);
}
function getAgentConnectionLabel(statusLabel: string) {
return /失败|不可用|错误|降级/.test(statusLabel) ? "离线" : "在线";
}
function getAgentConnectionClassName(statusLabel: string) {
return /失败|不可用|错误|降级/.test(statusLabel) ? "bg-red-500" : "bg-emerald-500";
}
const agentConnectionMetadata = {
connecting: { label: "连接中", tone: "info" },
online: { label: "在线", tone: "success" },
offline: { label: "离线", tone: "danger" }
} satisfies Record<AgentConnectionStatus, { label: string; tone: StatusTone }>;
function ModelIcon({ model, size }: { model?: AgentModelOption; size: number }) {
return model?.icon === "bolt" ? <FastModelIcon size={size} /> : <ExpertModelIcon size={size} />;
@@ -911,7 +1068,7 @@ function AgentEmptyState({ compact = false }: { compact?: boolean }) {
<div className="flex items-center gap-4">
<div
className={cn(
"surface-well grid shrink-0 place-items-center rounded-xl shadow-[inset_0_0_0_1px_oklch(0.88_0.02_260)]",
"surface-control grid shrink-0 place-items-center rounded-xl shadow-[inset_0_0_0_1px_oklch(0.84_0.025_260)]",
compact ? "h-14 w-14" : "h-[72px] w-[72px]"
)}
>
@@ -921,7 +1078,7 @@ function AgentEmptyState({ compact = false }: { compact?: boolean }) {
<h2
id="agent-empty-state-title"
className={cn(
"text-balance font-semibold text-slate-900",
"text-balance font-semibold text-slate-950",
compact ? "text-base leading-6" : "text-lg leading-7"
)}
>
@@ -932,7 +1089,7 @@ function AgentEmptyState({ compact = false }: { compact?: boolean }) {
<p
className={cn(
"text-pretty text-left text-sm leading-6 text-slate-500",
"text-pretty text-left text-sm leading-6 text-[#334155]",
compact ? "mt-3" : "mt-5"
)}
>
@@ -942,7 +1099,7 @@ function AgentEmptyState({ compact = false }: { compact?: boolean }) {
<ul
className={cn(
"surface-well grid grid-cols-2 overflow-hidden rounded-xl",
"surface-control grid grid-cols-2 overflow-hidden rounded-xl shadow-[inset_0_0_0_1px_rgba(148,163,184,0.32)]",
compact && "hidden"
)}
aria-label="Agent 分析能力"
@@ -951,15 +1108,15 @@ function AgentEmptyState({ compact = false }: { compact?: boolean }) {
<li
key={label}
className={cn(
"flex min-h-[72px] items-center gap-3 px-3.5 py-3 text-left",
index % 2 === 0 && "border-r border-slate-200/70",
index < 2 && "border-b border-slate-200/70"
"flex min-h-20 items-center gap-3 px-3.5 py-3 text-left",
index % 2 === 0 && "border-r border-slate-300/80",
index < 2 && "border-b border-slate-300/80"
)}
>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-slate-100 text-slate-600">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-[#f9fbfe] text-[#334155] shadow-[inset_0_0_0_1px_rgba(148,163,184,0.28)]">
<Icon size={16} strokeWidth={1.8} aria-hidden="true" />
</span>
<span className="text-xs font-medium leading-5 text-slate-700">{label}</span>
<span className="text-sm font-semibold leading-5 text-[#1e293b]">{label}</span>
</li>
))}
</ul>
@@ -1021,9 +1178,10 @@ function BackendMessageList({
>
<MessageContent
className={cn(
message.role === "user" && "bg-blue-600 px-3 py-2 text-white",
message.role === "user" &&
"agent-panel-user-message rounded-2xl px-3 py-3 text-slate-800",
message.role === "assistant" &&
"agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700 shadow-xs"
"agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700"
)}
>
<div className="space-y-3">
@@ -1209,21 +1367,16 @@ function AgentSpeechMessage({
<motion.button
key="speech-selection-action"
type="button"
className="glass-transient pointer-events-auto inline-flex h-10 origin-bottom items-center gap-2 rounded-xl border px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25"
initial={{ opacity: 0, y: 8, scale: 0.95 }}
className="agent-panel-floating-acrylic agent-speech-selection-action pointer-events-auto inline-flex h-10 items-center gap-2 rounded-xl px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25"
initial={{ opacity: 0 }}
animate={{
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 0.15, ease: [0.16, 1, 0.3, 1] }
transition: { duration: 0.12, ease: [0.16, 1, 0.3, 1] }
}}
exit={{
opacity: 0,
y: 0,
scale: 0.95,
transition: { duration: 0.1, ease: [0.4, 0, 1, 1] }
transition: { duration: 0.08, ease: [0.4, 0, 1, 1] }
}}
whileTap={{ scale: 0.96 }}
onMouseDown={(event) => event.preventDefault()}
onClick={speakFromSelection}
>
@@ -1311,20 +1464,21 @@ function SpeechIconButton({
children: ReactNode;
}) {
return (
<button
<Button
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
size="icon-sm"
variant="ghost"
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500",
"transition-[color,background-color,transform] hover:bg-slate-100 active:scale-95 disabled:cursor-wait",
"shrink-0 text-slate-500 hover:bg-slate-100 disabled:cursor-wait",
tone === "primary" && "text-blue-600",
tone === "danger" && "text-red-600"
tone === "danger" && "status-tone-danger text-[var(--status-foreground)]"
)}
>
{children}
</button>
</Button>
);
}
@@ -1,8 +1,10 @@
import { Check, Loader2, Pencil, RefreshCw, Trash2, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { cn } from "@/shared/ui/cn";
import { StatusBadge, StatusDot } from "@/shared/ui/status";
import type { AgentChatSessionSummary } from "../api/client";
import {
agentLayoutTransition,
@@ -73,30 +75,35 @@ export function AgentHistoryPanel({
<div className="mb-2 flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-semibold text-slate-950"></p>
<p className="mt-0.5 text-xs text-slate-500">{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}</p>
<p className="mt-0.5 text-xs text-slate-500">
{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}
</p>
</div>
<button
<Button
type="button"
aria-label="刷新 Agent 历史记录"
title="刷新 Agent 历史记录"
className="grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition hover:bg-white hover:text-slate-950 disabled:cursor-not-allowed disabled:opacity-50"
disabled={loading}
size="icon"
variant="ghost"
disabled={loading && sessions.length === 0}
loading={loading && sessions.length > 0}
loadingLabel="正在刷新 Agent 历史记录"
onClick={() => void Promise.resolve(onRefresh?.())}
>
{loading ? (
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
) : (
<RefreshCw size={15} aria-hidden="true" />
)}
</button>
<RefreshCw size={15} aria-hidden="true" />
</Button>
</div>
<motion.div className="max-h-56 space-y-1 overflow-y-auto pr-1" variants={agentPanelListVariants} animate="animate">
<motion.div
className="max-h-56 space-y-1 overflow-y-auto pr-1"
variants={agentPanelListVariants}
animate="animate"
>
<AnimatePresence initial={false} mode="popLayout">
{loading && sessions.length === 0 ? (
<motion.div
key="history-loading"
className="flex items-center gap-2 rounded-xl bg-slate-50 px-3 py-3 text-xs font-semibold text-slate-500"
className="agent-history-state flex items-center gap-2 rounded-xl px-3 py-3 text-xs font-semibold text-slate-500"
initial="initial"
animate="animate"
exit="exit"
@@ -119,8 +126,10 @@ export function AgentHistoryPanel({
key={session.id}
layout
className={cn(
"grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition hover:bg-white",
active ? "border border-blue-200 bg-blue-50/80" : "border border-transparent bg-slate-50/80"
"agent-history-item grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition-colors",
active && !editing
? "agent-history-item-active border border-blue-200"
: "border border-transparent"
)}
initial="initial"
animate="animate"
@@ -130,8 +139,18 @@ export function AgentHistoryPanel({
>
<AnimatePresence initial={false} mode="popLayout">
{confirmingDelete ? (
<motion.div key="confirm-delete" className="min-w-0" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<span className="block truncate text-xs font-semibold text-red-700" title={session.title}>
<motion.div
key="confirm-delete"
className="min-w-0"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
<span
className="status-tone-danger block truncate text-xs font-semibold text-[var(--status-foreground)]"
title={session.title}
>
{session.title}
</span>
<span className="mt-0.5 block truncate text-xs text-slate-500">
@@ -153,7 +172,7 @@ export function AgentHistoryPanel({
>
<Input
autoFocus
className="h-8 rounded-lg bg-white text-xs"
className="agent-history-rename-input h-8 rounded-lg bg-white text-xs"
value={draftTitle}
disabled={saving}
onChange={(event) => setDraftTitle(event.currentTarget.value)}
@@ -176,7 +195,10 @@ export function AgentHistoryPanel({
variants={agentPanelItemVariants}
onClick={() => onSelectSession(session.id)}
>
<span className="block truncate text-xs font-semibold text-slate-900" title={session.title}>
<span
className="block truncate text-xs font-semibold text-slate-900"
title={session.title}
>
{session.title}
</span>
<span className="mt-0.5 block truncate text-xs text-slate-500">
@@ -185,79 +207,89 @@ export function AgentHistoryPanel({
</motion.button>
)}
</AnimatePresence>
<span className="flex items-center gap-1">
<span className="flex items-center gap-2">
{session.runStatus === "running" || session.isStreaming ? (
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
<StatusDot tone="info" activity="live" />
) : null}
{itemLoading || saving || deleting ? (
<Loader2 size={13} className="animate-spin text-blue-600" aria-hidden="true" />
<Loader2
size={13}
className="animate-spin text-blue-600"
aria-hidden="true"
/>
) : null}
{active && !editing && !confirmingDelete ? (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-semibold text-blue-700">
</span>
<StatusBadge tone="info"></StatusBadge>
) : null}
{confirmingDelete ? (
<>
<button
<Button
type="button"
className="h-7 rounded-lg bg-red-600 px-2 text-xs font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
disabled={deleting}
variant="destructive"
size="sm"
loading={deleting}
loadingLabel="正在删除对话"
onClick={() => confirmDelete(session.id)}
>
</button>
<button
</Button>
<Button
type="button"
aria-label="取消删除"
title="取消删除"
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white disabled:opacity-50"
size="icon-sm"
variant="ghost"
disabled={deleting}
onClick={() => setConfirmingDeleteSessionId(null)}
>
<X size={14} aria-hidden="true" />
</button>
</Button>
</>
) : editing ? (
<>
<button
<Button
type="button"
aria-label="保存对话主题"
title="保存对话主题"
className="grid h-7 w-7 place-items-center rounded-lg text-blue-600 transition hover:bg-white disabled:opacity-50"
disabled={saving}
size="icon-sm"
loading={saving}
loadingLabel="正在保存对话主题"
onClick={() => saveRename(session)}
>
<Check size={14} aria-hidden="true" />
</button>
<button
</Button>
<Button
type="button"
aria-label="取消重命名"
title="取消重命名"
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white disabled:opacity-50"
size="icon-sm"
variant="ghost"
disabled={saving}
onClick={cancelRename}
>
<X size={14} aria-hidden="true" />
</button>
</Button>
</>
) : (
<>
<button
<Button
type="button"
aria-label="重命名对话"
title="重命名对话"
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white hover:text-slate-950 disabled:opacity-50"
size="icon-sm"
variant="ghost"
disabled={itemLoading || deleting || !onRenameSession}
onClick={() => beginRename(session)}
>
<Pencil size={14} aria-hidden="true" />
</button>
<button
</Button>
<Button
type="button"
aria-label="删除对话"
title="删除对话"
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
size="icon-sm"
variant="ghost"
className="text-slate-500 hover:bg-red-50 hover:text-red-600"
disabled={itemLoading || deleting || !onDeleteSession}
onClick={() => {
cancelRename();
@@ -265,7 +297,7 @@ export function AgentHistoryPanel({
}}
>
<Trash2 size={14} aria-hidden="true" />
</button>
</Button>
</>
)}
</span>
@@ -273,7 +305,14 @@ export function AgentHistoryPanel({
);
})
) : (
<motion.div key="history-empty" className="rounded-xl bg-slate-50 px-3 py-3 text-xs text-slate-500" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<motion.div
key="history-empty"
className="agent-history-state rounded-xl px-3 py-3 text-xs text-slate-500"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
</motion.div>
)}
@@ -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}
>
@@ -1,15 +1,9 @@
import {
ClipboardCheck,
Crosshair,
MapPinned,
Radio,
Route
} from "lucide-react";
import { ClipboardCheck, Crosshair, MapPinned, Radio, Route } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import type {
AgentChatMessage
} from "../types";
import { Button } from "@/shared/ui/button";
import { StatusDot, type StatusTone } from "@/shared/ui/status";
import { countPendingAgentInputs } from "../message-state";
import type { AgentChatMessage } from "../types";
export function AgentOperationalBrief({
messages,
@@ -26,52 +20,71 @@ export function AgentOperationalBrief({
return (
<div className="agent-panel-control rounded-2xl p-3">
<div className="flex items-start gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full", brief.statusDotClassName)} />
<p className="truncate text-sm font-semibold text-slate-950">{brief.stateLabel}</p>
</div>
<p className="mt-1 truncate text-xs leading-5 text-slate-500" title={brief.task}>
{brief.task}
</p>
<div className="min-w-0">
<div className="flex items-center gap-2">
<StatusDot tone={brief.statusTone} activity={streaming ? "live" : "static"} size="md" />
<p className="truncate text-sm font-semibold text-slate-950">{brief.stateLabel}</p>
</div>
<p className="mt-1 truncate text-xs leading-5 text-slate-600" title={brief.task}>
{brief.task}
</p>
</div>
<dl className="mt-3 grid grid-cols-3 gap-2">
<AgentBriefMetric icon={<Radio size={13} aria-hidden="true" />} label="证据" value={brief.evidence} />
<AgentBriefMetric icon={<Route size={13} aria-hidden="true" />} label="计划" value={brief.plan} />
<AgentBriefMetric icon={<ClipboardCheck size={13} aria-hidden="true" />} label="确认" value={brief.confirmation} />
<AgentBriefMetric
icon={<Radio size={13} aria-hidden="true" />}
label="证据"
value={brief.evidence}
/>
<AgentBriefMetric
icon={<Route size={13} aria-hidden="true" />}
label="计划"
value={brief.plan}
/>
<AgentBriefMetric
icon={<ClipboardCheck size={13} aria-hidden="true" />}
label="确认"
value={brief.confirmation}
/>
</dl>
<div className="mt-3 grid grid-cols-2 gap-2">
<button
<Button
type="button"
className="agent-panel-primary-action flex h-9 items-center justify-center gap-1.5 rounded-xl px-3 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-50"
className="w-full text-xs"
disabled={streaming}
onClick={() => onSubmitCommand(brief.primaryCommand)}
>
<MapPinned size={14} aria-hidden="true" />
</button>
<button
</Button>
<Button
type="button"
className="surface-control flex h-9 items-center justify-center gap-1.5 rounded-xl border px-3 text-xs font-semibold text-slate-700 transition-colors hover:bg-blue-50 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
variant="outline"
className="w-full text-xs"
disabled={streaming}
onClick={() => onSubmitCommand(brief.secondaryCommand)}
>
<Crosshair size={14} aria-hidden="true" />
</button>
</Button>
</div>
</div>
);
}
function AgentBriefMetric({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
function AgentBriefMetric({
icon,
label,
value
}: {
icon: ReactNode;
label: string;
value: string;
}) {
return (
<div className="surface-reading min-w-0 rounded-xl border px-2 py-2">
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-500">
<div className="agent-brief-metric min-w-0 rounded-xl border px-2 py-2">
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-600">
<span className="text-blue-600">{icon}</span>
{label}
</dt>
@@ -82,26 +95,31 @@ function AgentBriefMetric({ icon, label, value }: { icon: ReactNode; label: stri
);
}
function createOperationalBrief(messages: AgentChatMessage[], statusLabel: string, streaming: boolean) {
const lastUserMessage = [...messages].reverse().find((message) => message.role === "user" && message.content.trim());
function createOperationalBrief(
messages: AgentChatMessage[],
statusLabel: string,
streaming: boolean
) {
const lastUserMessage = [...messages]
.reverse()
.find((message) => message.role === "user" && message.content.trim());
const progressItems = messages.flatMap((message) => message.progress ?? []);
const todoItems = messages.flatMap((message) => message.todos?.todos ?? []);
const permissionItems = messages.flatMap((message) => message.permissions ?? []);
const questionItems = messages.flatMap((message) => message.questions ?? []);
const latestProgress = progressItems.at(-1);
const activeTodo =
[...todoItems].reverse().find((todo) => todo.status === "in_progress") ??
[...todoItems].reverse().find((todo) => todo.status === "pending");
const pendingConfirmations =
permissionItems.filter((permission) => permission.status === "pending" || permission.status === "error").length +
questionItems.filter((question) => question.status === "pending" || question.status === "error").length;
const pendingConfirmations = countPendingAgentInputs(messages);
const completedSteps = progressItems.filter((item) => item.status === "completed").length;
const task = lastUserMessage?.content.trim() || "等待调度指令";
const evidence = latestProgress?.detail || latestProgress?.title || statusLabel;
const plan = activeTodo?.content || (completedSteps > 0 ? `${completedSteps} 项已完成` : "待生成");
const plan =
activeTodo?.content || (completedSteps > 0 ? `${completedSteps} 项已完成` : "待生成");
const confirmation = pendingConfirmations > 0 ? `${pendingConfirmations} 项待确认` : "无需确认";
const stateLabel = pendingConfirmations > 0 ? "等待人工确认" : streaming ? "Agent 分析中" : "分析完成";
const statusDotClassName = pendingConfirmations > 0 ? "bg-orange-500" : streaming ? "bg-blue-500" : "bg-emerald-500";
const stateLabel =
pendingConfirmations > 0 ? "等待人工确认" : streaming ? "Agent 分析中" : "分析完成";
const statusTone: StatusTone =
pendingConfirmations > 0 ? "warning" : streaming ? "info" : "success";
const promptContext = lastUserMessage?.content.trim() || "当前供水管网运行态势";
return {
@@ -110,7 +128,7 @@ function createOperationalBrief(messages: AgentChatMessage[], statusLabel: strin
plan,
confirmation,
stateLabel,
statusDotClassName,
statusTone,
primaryCommand: `请基于“${promptContext}”预览空间影响范围,并列出需要确认的操作。`,
secondaryCommand: `请检查“${promptContext}”的证据链,按数据来源、风险和不确定性汇总。`
};
@@ -2,6 +2,7 @@ import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "l
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import type { ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import { StatusIcon } from "@/shared/ui/status";
import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data";
import type { AgentUiResult } from "../types";
import type { UIEnvelope } from "../ui-envelope";
@@ -282,7 +283,7 @@ function TrustedPanelShell({
return (
<div className="agent-panel-nested rounded-xl p-3">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-950">
<span className="grid h-7 w-7 place-items-center rounded-lg bg-emerald-100 text-emerald-700">{icon}</span>
<StatusIcon tone="success" size="md">{icon}</StatusIcon>
{title}
</div>
{children}
+2
View File
@@ -1,5 +1,6 @@
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
export { AgentCommandPanel } from "./components/agent-command-panel";
export { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
export type { AgentCommandPanelPresentation } from "./components/agent-command-panel";
export { AgentPersona } from "./components/agent-persona";
export { createAgentApiClient } from "./api/client";
@@ -56,6 +57,7 @@ export type {
AgentApprovalMode,
AgentChatMessage,
AgentChatProgress,
AgentConnectionStatus,
AgentInteractionToolRef,
AgentModelOption,
AgentPermissionReply,
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
import type { AgentChatMessage } from "./types";
describe("Agent message state", () => {
it("counts pending and failed operator inputs from one shared rule", () => {
const messages = [
{
id: "assistant-1",
role: "assistant",
content: "",
permissions: [
{ status: "pending" },
{ status: "approved_once" }
],
questions: [
{ status: "error" },
{ status: "answered" }
]
}
] as AgentChatMessage[];
expect(countPendingAgentInputs(messages)).toBe(2);
expect(hasPendingAgentInput(messages)).toBe(true);
expect(hasPendingAgentInput([])).toBe(false);
});
});
+19
View File
@@ -0,0 +1,19 @@
import type { AgentChatMessage } from "./types";
export function countPendingAgentInputs(messages: AgentChatMessage[]) {
return messages.reduce((total, message) => {
const pendingPermissions =
message.permissions?.filter(
(permission) => permission.status === "pending" || permission.status === "error"
).length ?? 0;
const pendingQuestions =
message.questions?.filter(
(question) => question.status === "pending" || question.status === "error"
).length ?? 0;
return total + pendingPermissions + pendingQuestions;
}, 0);
}
export function hasPendingAgentInput(messages: AgentChatMessage[]) {
return countPendingAgentInputs(messages) > 0;
}
+2
View File
@@ -14,6 +14,8 @@ export type AgentStreamRenderMessageState = {
export type AgentStreamRenderState = Record<string, AgentStreamRenderMessageState>;
export type AgentConnectionStatus = "connecting" | "online" | "offline";
export type AgentChatProgress = {
id: string;
phase: string;