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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user