merge: 合并 Agent 面板 UI 优化 PR #624

This commit is contained in:
ddcat666
2026-09-26 06:20:01 +08:00
10 changed files with 1003 additions and 126 deletions

View File

@@ -1,10 +1,10 @@
import { agentCanvasActions, agentCanvasActionLabel } from "@/lib/canvas/agent-canvas-actions";
import { Button } from "antd";
import { Tooltip } from "@/components/ui/base/tooltip";
import { useCallback, useEffect, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
import { ArrowLeft, ArrowUp, AtSign, Bookmark, CheckCircle2, ChevronDown, ChevronUp, CircleAlert, CircleDot, Clapperboard, Eye, HelpCircle, ImagePlus, Layers3, ListChecks, LoaderCircle, Palette, Pencil, Plus, RotateCcw, Shapes, Share2, ShoppingBag, Sparkles, Square, X, XCircle } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowLeft, ArrowUp, AtSign, Bookmark, CheckCircle2, ChevronDown, ChevronUp, CircleAlert, CircleDot, Clapperboard, Eye, HelpCircle, ImagePlus, Layers3, List, ListChecks, LoaderCircle, Palette, Pencil, Plus, RotateCcw, Shapes, Share2, ShoppingBag, Sparkles, Square, Wrench, X, XCircle } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
@@ -13,7 +13,8 @@ import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textare
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import type { Skill, SkillPreset } from "@/services/api/skills";
import { buildSkillMentionReferences } from "@/services/skill-runtime";
import { agentToolCategory, agentToolCategoryLabel, agentToolErrorClassLabel, agentToolStatus, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation";
import { agentToolCategory, agentToolCategoryLabel, agentToolErrorClassLabel, agentToolName, agentToolRetryLabel, agentToolStatus, friendlyAgentToolSummary, type AgentToolCategory } from "@/lib/canvas/agent-tool-presentation";
import { agentOperationCategory, agentOperationFailed, agentOperationSegmentLabel } from "@/lib/canvas/agent-operation-feed";
import { agentToolRetry, type AgentToolRetryAttempt } from "@/lib/canvas/agent-tool-retry";
export type CloudAgentChatAttachment = { id: string; name: string; url: string };
@@ -38,6 +39,12 @@ export type CloudAgentChatMessage = {
text: string;
streaming?: boolean;
reasoning?: boolean;
/**
* 本轮最终答复(服务端收尾闸门的结论)。false 表示这是过程正文——模型边做边说的那一段,
* 不是结论:运行还在继续,或者这次收尾被闸门拦下了。缺省按 true 处理(老后端没有这个字段)。
* 界面不再为它单独出角标(用户口径),但标记仍如实保存。
*/
final?: boolean;
planItems?: CloudAgentPlanItem[];
/** 运行已进入终态,但计划项仍未全部完成;用于历史恢复时停止显示 loading。 */
planTerminal?: boolean;
@@ -48,6 +55,34 @@ export type CloudAgentChatMessage = {
interjection?: "sent" | "undelivered";
};
/**
* 助手正文的收尾语义(工作项 A):final=false 是过程说明,true / 缺省是结论。
* 缺省按"结论"处理是为了兼容升级前的后端事件——那时所有正文都按最终正文展示。
*/
export function agentAssistantFinality(payload: Record<string, unknown>): boolean {
return payload.final !== false;
}
/**
* 服务端控制消息(例如运行时的收尾闸门 `completion_blocked`)在时间线里的表示:
* 它是服务端说明,不是用户发言,所以走 system 行——绝不能渲染成真人 user 气泡。
*/
export function agentControlMessage(id: string, text: string, meta?: string): CloudAgentChatMessage {
return { id, role: "system", text, meta };
}
/**
* 类别的图标语言:三档互不串味 —— 清单用 List(读到存在与规模)、画面用 Eye(图片字节真的
* 交给了模型)、写画布用 Pencil / 创建用 Plus;未登记的工具走中性的 Wrench。
*/
function agentToolCategoryIcon(category: AgentToolCategory) {
if (category === "vision") return <Eye className="size-3.5" />;
if (category === "read") return <List className="size-3.5" />;
if (category === "create") return <Plus className="size-3.5" />;
if (category === "operate") return <Pencil className="size-3.5" />;
return <Wrench className="size-3.5" />;
}
export type CloudAgentQuickAction = { label: string; prompt: string };
/**
@@ -175,8 +210,7 @@ export function AgentChatMessage({
}
return (
<div className={`flex items-start gap-3 ${isUser ? "justify-end" : "justify-start"}`}>
{!isUser ? <AgentTimelineMarker theme={theme} tone="agent" /> : null}
<div className={`agent-message-body min-w-0 text-sm leading-6 ${isUser ? "agent-message-user max-w-[82%] px-4 py-3 text-right" : "max-w-[calc(100%-36px)] flex-1 text-left"}`} style={{ color }}>
<div className={`agent-message-body min-w-0 text-sm leading-6 ${isUser ? "agent-message-user max-w-[82%] px-4 py-3 text-right" : "max-w-full flex-1 text-left"}`} style={{ color }}>
{item.interjection ? (
<span
className="mb-1 inline-flex items-center rounded-full px-1.5 py-[1px] text-[var(--fs-label)] leading-4"
@@ -419,95 +453,163 @@ export function AgentToolCard({
const visibleActions = actions.slice(0, isNodeRead && !readExpanded ? 1 : 8);
const collapsedReadNodeCount = isNodeRead ? Math.max(0, actions.length - visibleActions.length) : 0;
const isPlain = !actions.length && !state.isError;
const hasDetails = actions.length > 0 || state.isError;
const conciseError = text.length > 180 ? `${text.slice(0, 180)}…` : text;
const categoryIcon = category === "read" ? <Eye className="size-3.5" /> : category === "create" ? <Plus className="size-3.5" /> : <Pencil className="size-3.5" />;
const header = (
<div className="agent-tool-header">
<span className="agent-tool-category">
{categoryIcon}
<span>{categoryLabel}</span>
</span>
<span className="agent-tool-summary">{summary}</span>
{state.label !== "已完成" ? (
<span className="agent-tool-label" style={{ color: state.color }}>
{state.label}
</span>
) : null}
</div>
);
const detailBody = (
<>
{actions.length ? (
<div className="agent-tool-action-list">
{visibleActions.map((action) => (
<button
key={`${action.action}-${action.nodeId}`}
type="button"
data-agent-node-id={action.nodeId}
disabled={!onFocusNode}
onClick={() => onFocusNode?.(action.nodeId)}
aria-label={`在画布中定位${action.title}`}
className="agent-tool-action-link"
>
{agentCanvasActionLabel(action)}
</button>
))}
{isNodeRead && (collapsedReadNodeCount > 0 || readExpanded) ? (
<button type="button" className="agent-tool-more" aria-expanded={readExpanded} onClick={() => setReadExpanded((current) => !current)}>
{readExpanded ? `收起其余 ${Math.max(0, actions.length - 1)} 个节点` : `已折叠 ${collapsedReadNodeCount} 个节点,展开查看`}
</button>
) : null}
</div>
) : null}
{state.isError && text && text !== summary ? (
<span className="mt-1 block whitespace-pre-wrap break-words" style={{ color: theme.node.muted }}>
{conciseError}
</span>
) : null}
{state.isError && text.length > 180 ? (
<details className="mt-1" style={{ color: theme.node.muted }}>
<summary className="cursor-pointer">查看完整错误详情</summary>
<p className="whitespace-pre-wrap break-words">{text}</p>
</details>
) : null}
{state.isError && objectField(objectField(detail, "result"), "taskId") ? (
<span className="mt-1 block break-all" style={{ color: theme.node.muted }}>
任务 ID:{String(objectField(objectField(detail, "result"), "taskId"))}
</span>
) : null}
</>
);
if (hasDetails) {
const categoryIcon = agentToolCategoryIcon(category);
const retry = agentToolRetry(detail);
const attempts = objectField(detail, "retryAttempts");
if (retry && Array.isArray(attempts)) {
const label = agentToolRetryLabel(retry);
return (
<details data-agent-tool-card className={`agent-tool-details agent-tool-row--${category}${isPlain ? " agent-tool-row--plain" : ""}`} style={{ color: theme.node.text }}>
<summary className="agent-tool-summary-toggle agent-tool-row flex min-w-0 items-start gap-2.5 text-left">
<span className="agent-tool-status shrink-0" style={{ color: state.color }} aria-hidden="true">
{state.icon}
</span>
<div className="min-w-0 flex-1 break-words text-xs leading-5" style={{ color: state.isError ? state.color : theme.node.muted }}>
{header}
</div>
<ChevronDown className="agent-tool-chevron mt-1 size-3.5 shrink-0" aria-hidden="true" />
<details data-agent-tool-retry className="min-w-0 flex-1 text-xs leading-5" style={{ color: theme.node.muted }}>
<summary className="cursor-pointer rounded-sm focus-visible:outline focus-visible:outline-2" style={{ outlineColor: theme.node.muted }}>
{label} · {retry.attempt}/{retry.maxAttempts} 次尝试未通过
</summary>
<div className="agent-tool-detail-body ml-6 break-words text-xs leading-5" style={{ color: theme.node.muted }}>
{detailBody}
</div>
<ol className="mt-2 space-y-1 pl-4" aria-label="自动纠正详情">
{(attempts as AgentToolRetryAttempt[]).map((attempt, index) => (
<li key={attempt.id} className="whitespace-pre-wrap break-words">
第 {index + 1} 次:{attempt.text}
</li>
))}
</ol>
</details>
);
}
return (
<div data-agent-tool-card className="agent-tool-row agent-tool-row--plain flex min-w-0 flex-1 items-start gap-2.5 text-left" style={{ color: theme.node.text }}>
<div data-agent-tool-card className={`agent-tool-row agent-tool-row--${category}${isPlain ? " agent-tool-row--plain" : ""} flex min-w-0 flex-1 items-start gap-2.5 text-left`} style={{ color: theme.node.text }}>
<span className="agent-tool-status shrink-0" style={{ color: state.color }} aria-hidden="true">
{state.icon}
</span>
<div className="min-w-0 flex-1 break-words text-xs leading-5" style={{ color: state.isError ? state.color : theme.node.muted }}>
{header}
<div className="agent-tool-header">
<span className="agent-tool-category">
{categoryIcon}
<span>{categoryLabel}</span>
</span>
<span className="agent-tool-summary">{summary}</span>
{state.label !== "已完成" ? (
<span className="agent-tool-label" style={{ color: state.color }}>
{state.label}
</span>
) : null}
</div>
{actions.length ? (
<div className="agent-tool-action-list">
{visibleActions.map((action) => (
<button
key={`${action.action}-${action.nodeId}`}
type="button"
data-agent-node-id={action.nodeId}
disabled={!onFocusNode}
onClick={() => onFocusNode?.(action.nodeId)}
aria-label={`在画布中定位${action.title}`}
className="agent-tool-action-link"
>
{agentCanvasActionLabel(action)}
</button>
))}
{isNodeRead && (collapsedReadNodeCount > 0 || readExpanded) ? (
<button type="button" className="agent-tool-more" aria-expanded={readExpanded} onClick={() => setReadExpanded((current) => !current)}>
{readExpanded ? `收起其余 ${Math.max(0, actions.length - 1)} 个节点` : `另有 ${collapsedReadNodeCount} 个节点只是清单(未查看画面),展开查看`}
</button>
) : null}
</div>
) : null}
{state.isError && text && text !== summary ? (
<span className="mt-1 block whitespace-pre-wrap break-words" style={{ color: theme.node.muted }}>
{conciseError}
</span>
) : null}
{state.isError && text.length > 180 ? (
<details className="mt-1" style={{ color: theme.node.muted }}>
<summary className="cursor-pointer">查看完整错误详情</summary>
<p className="whitespace-pre-wrap break-words">{text}</p>
</details>
) : null}
{state.isError && objectField(objectField(detail, "result"), "taskId") ? (
<span className="mt-1 block break-all" style={{ color: theme.node.muted }}>
任务 ID:{String(objectField(objectField(detail, "result"), "taskId"))}
</span>
) : null}
</div>
{state.label === "已完成" ? <span className="sr-only">已完成</span> : null}
</div>
);
}
/**
* Agent 的操作记录(连续的 `role === "tool"` 消息)折成一行:报最新一步,点击展开完整记录。
*
* 语义要点:折叠态整段只有这一行可见,所以**类别徽标必须在折叠行上**(清单 / 查看画面 /
* 修改画布),否则默认状态看不出刚才到底是读了清单还是真看了画面。
*
* 展开状态用「用户覆盖 + 失败时默认展开」两段决定 —— 失败的操作不能被折进一行里看不见,
* 但用户手动收起之后就不再自动弹开(跑动中新步骤只会追加,不会重置状态)。
* `live` 由面板按"这一段是不是对话末尾且在跑"给出:只有还在推进时才流光。
*/
export function AgentOperationFeed({
items,
theme,
references = [],
onFocusNode,
live = false,
}: {
items: CloudAgentChatMessage[];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
references?: CanvasResourceReference[];
onFocusNode?: (nodeId: string) => void;
live?: boolean;
}) {
const [userExpanded, setUserExpanded] = useState<boolean | null>(null);
const reducedMotion = useReducedMotion();
const listId = useId();
const latest = items[items.length - 1];
if (!latest) return null;
const category = agentOperationCategory(latest);
const categoryLabel = agentToolCategoryLabel(agentToolName(latest.title || "工具执行", latest.detail), category);
const label = agentOperationSegmentLabel(items);
const failed = items.some((item) => agentOperationFailed(item));
const expanded = userExpanded ?? failed;
const shimmering = live && !failed;
return (
<div className={`agent-operation-feed${expanded ? " is-open" : ""}${failed ? " is-failed" : ""}${shimmering ? " is-live" : ""}`} data-agent-operation-feed data-agent-category={category}>
<button
type="button"
className="agent-operation-toggle"
aria-expanded={expanded}
aria-controls={listId}
// aria-label 会顶掉可见文字,所以类别与最新一步必须自己报出来。
aria-label={`${expanded ? "收起" : "展开"} ${items.length} 步${categoryLabel}记录,最新一步:${label}`}
onClick={() => setUserExpanded(!expanded)}
>
<span className="agent-tool-category">
{agentToolCategoryIcon(category)}
<span>{categoryLabel}</span>
</span>
{/* 换步时旧文案高模糊淡出、新文案从下方上浮(先快后慢的非线性曲线)。 */}
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={label}
className="agent-operation-latest"
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 12, filter: "blur(6px)" }}
animate={reducedMotion ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -8, filter: "blur(8px)" }}
transition={reducedMotion ? { duration: 0 } : { duration: 0.34, ease: [0.16, 1, 0.3, 1] }}
>
{label}
</motion.span>
</AnimatePresence>
{items.length > 1 ? <span className="agent-operation-count">{items.length} 步</span> : null}
<ChevronDown className="agent-operation-chevron" aria-hidden="true" />
</button>
{expanded ? (
<div id={listId} className="agent-operation-list">
{items.map((item) => (
<AgentChatMessage key={item.id} item={item} theme={theme} references={references} onFocusNode={onFocusNode} />
))}
</div>
) : null}
</div>
);
}
export function AgentWorkingMessage({ theme, label = WORKING_TEXT }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; label?: string }) {
@@ -1126,12 +1228,17 @@ export function AgentPanelTabs<T extends string>({
);
}
function AgentTimelineMarker({ theme, tone, icon }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; tone: "agent" | "muted" | "tool" | "approval" | "error"; icon?: ReactNode }) {
const color = tone === "error" ? "#ef4444" : tone === "approval" ? "#f97316" : tone === "tool" ? "#4f7cff" : tone === "agent" ? theme.accent.primary : theme.node.muted;
/**
* 时间线标记:图标由调用方给(系统行给 Sparkles、错误与审批给 CircleAlert…),不再有默认
* 的模型品牌 glyph —— 那个圆环标志会被读成"这条是模型/OpenAI 出品",与它实际表达的
* "这是谁的一行"无关。
*/
function AgentTimelineMarker({ theme, tone, icon }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; tone: "agent" | "muted" | "approval" | "error"; icon: ReactNode }) {
const color = tone === "error" ? "#ef4444" : tone === "approval" ? "#f97316" : tone === "agent" ? theme.accent.primary : theme.node.muted;
return (
<span className="relative flex w-6 shrink-0 self-stretch justify-center" aria-hidden="true">
<span className="agent-timeline-marker relative" aria-hidden="true">
<span className="relative grid size-6 place-items-center rounded-full" style={{ background: tone === "agent" ? theme.accent.primarySoft : theme.node.fill, color }}>
{icon || <span className="size-3 opacity-90" style={{ background: color, WebkitMask: "url(/icons/openai.svg) center / contain no-repeat", mask: "url(/icons/openai.svg) center / contain no-repeat" }} />}
{icon}
</span>
</span>
);
@@ -1189,10 +1296,6 @@ function toolCardState(title: string, text: string, detail?: unknown) {
return { label: "处理中", color: "#64748b", softBg: "rgba(100,116,139,.04)", icon: <CircleDot className="size-4" />, isError: false };
}
function agentToolName(title: string, detail?: unknown) {
return String(objectField(detail, "toolName") || objectField(detail, "name") || objectField(detail, "tool") || title);
}
function objectField(value: unknown, key: string) {
return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
}

View File

@@ -19,6 +19,7 @@ import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { agentErrorPresentation, agentSubmissionErrorTitle } from "@/lib/canvas/agent-error-presentation";
import { cancelAgentRun, getAgentCapabilities, getAgentProfile, getAgentRun, createAgentRun, decideAgentApproval, sendAgentInterjection, sendAgentMessage, subscribeAgentEvents, updateAgentProfile, type AgentEvent, type AgentPermissionMode, type AgentProfileScope, type AgentProfileView, type AgentReasoningMode, type AgentRun } from "@/services/api/agent";
import { agentApprovalPresentation } from "@/lib/canvas/agent-approval-presentation";
import { buildAgentFeedSegments } from "@/lib/canvas/agent-operation-feed";
import { agentApprovalMatchesSettings, agentImageApproval } from "@/lib/canvas/agent-media-approval";
import type { AgentMediaSettings } from "@/services/api/agent";
import { CanvasAgentImageApprovalSettings } from "./canvas-agent-image-approval-settings";
@@ -32,7 +33,7 @@ import { getActiveUserScope } from "@/lib/user-scope";
import { applyAgentCanvasPatches, refreshCanvasAfterAgent, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { createAgentCanvasSync } from "@/services/agent-canvas-sync";
import { buildSkillMentionReferences, resolveSkillMentions } from "@/services/skill-runtime";
import { AgentChatComposer, AgentChatMessage, AgentPlanBar, AgentQuestionBar, AgentSceneCapsules, AgentWorkingMessage, AGENT_SCENE_DEFS, type AgentSceneBucket, type CloudAgentChatMessage, type CloudAgentPlanItem } from "./canvas-cloud-agent-chat-ui";
import { AGENT_SCENE_DEFS, AgentChatComposer, AgentChatMessage, AgentOperationFeed, AgentPlanBar, AgentQuestionBar, AgentSceneCapsules, AgentWorkingMessage, type AgentSceneBucket, type CloudAgentChatMessage, type CloudAgentPlanItem } from "./canvas-cloud-agent-chat-ui";
import { CanvasAgentSkillLibraryModal } from "./canvas-agent-skill-library-modal";
import { CanvasCloudAgentSettings, agentPermissionLabel, agentPermissionMenuItems, agentPermissionVisual, type AgentContextKey } from "./canvas-cloud-agent-settings";
import { useAgentPanelLayout } from "./use-agent-panel-layout";
@@ -1243,6 +1244,9 @@ function AgentConversation({
const contentRef = useRef<HTMLDivElement>(null);
const followRef = useRef(true);
const lastUserId = messages.findLast((item) => item.role === "user")?.id;
// 连续的工具记录折成一行(只报最新一步),计划/提问载体由输入区上方的固定条渲染。
const segments = useMemo(() => buildAgentFeedSegments(messages), [messages]);
const lastMessage = messages.at(-1);
// 自己发送时恢复跟随;阅读旧消息时不让流式输出抢走滚动位置。
useLayoutEffect(() => {
@@ -1271,9 +1275,14 @@ function AgentConversation({
}}>
{!messages.length ? <AgentWelcome appearance={appearance} nodeCount={nodeCount} onChooseSkill={onChooseSkill} onDraftPrompt={onDraftPrompt} /> : null}
<div ref={contentRef} className="agent-conversation-messages">
{messages.map((item) => (
<AgentChatMessage key={item.id} item={item} theme={theme} references={references} onFocusNode={onFocusNode} isStreaming={busy && !approval && item.streaming === true && item === messages.at(-1)} />
))}
{segments.map((segment, index) =>
segment.kind === "operations" ? (
// 只有"对话末尾那一段 + 还在跑"才流光:历史段落留在静态态,任务完成即停。
<AgentOperationFeed key={segment.key} items={segment.items} theme={theme} references={references} onFocusNode={onFocusNode} live={busy && index === segments.length - 1} />
) : (
<AgentChatMessage key={segment.key} item={segment.item} theme={theme} references={references} onFocusNode={onFocusNode} isStreaming={busy && !approval && segment.item.streaming === true && segment.item === lastMessage} />
),
)}
{approval ? <ApprovalCard key={approval.approvalId} approval={approval} theme={theme} submitting={approvalSubmitting} onFocusNode={onFocusNode} onReasonChange={onApprovalReasonChange} onApprove={onApprove} onReject={onReject} /> : null}
{busy && !approval ? (
<AgentWorkingMessage theme={theme} label="正在处理当前画布" />

View File

@@ -388,6 +388,21 @@
scroll-padding-block: var(--space-5);
}
/*
* 过程内容的公共轴:正文的左边缘就是这条线,过程标记(星标、工具左轨、时间线标记)
* 一律挂在轴上居中。轴由星标几何推导,不是拍出来的数:星标中心 = 思考缩进 + 摘要内边距
* + 图标半宽;工具左轨用 -0.5px 让 1px 的线居中在轴上。改任一项,三处会一起跟着走。
*/
.agent-conversation-messages {
--agent-axis: 22px;
--agent-thought-icon: 16px;
--agent-thought-gap: 9px;
--agent-summary-inset: 4px;
--agent-timeline-marker: 24px;
--agent-activity-inset: 8px;
padding-left: var(--agent-axis);
}
.agent-conversation-messages > * + * {
margin-top: var(--space-4);
}
@@ -669,6 +684,11 @@
.agent-context-panel-foot { display: grid; }
.agent-welcome-actions button { padding-inline: var(--space-2); }
/* 窄面板把公共轴收细,星标与左轨仍压在同一条线上,不吃正文宽度。 */
.agent-conversation-messages {
--agent-axis: 16px;
--agent-activity-inset: var(--space-1);
}
}
@media (max-width: 639px) {
@@ -838,6 +858,14 @@
--agent-tool-accent: color-mix(in srgb, var(--foreground) 62%, transparent);
}
/*
* 看图(canvas_inspect_image):唯一会把图片字节真交给模型的档位,用主题强调色把它与
* "读清单 / 写画布"分开。取色走面板注入的 --agent-accent,不写字面值。
*/
.agent-tool-row--vision {
--agent-tool-accent: var(--agent-accent, var(--foreground));
}
.agent-tool-header {
display: flex;
min-width: 0;
@@ -933,6 +961,155 @@
.agent-tool-action-link { transition: none; }
}
/*
* 操作记录折叠行:一轮运行动辄十几条工具记录,逐条铺开会把正文挤出可视区。
* 默认只报最新一步,点击展开完整记录;失败时整行转红并默认展开(见 AgentOperationFeed)。
* 类别徽标复用工具卡的 chip(.agent-tool-category),所以这里要给出 accent 默认值。
*/
.agent-operation-feed {
--agent-tool-accent: color-mix(in srgb, var(--foreground) 62%, transparent);
/* 层级第二档:左轨与正文左边缘同轴(见 .agent-conversation-messages 的公共轴),
轨道把"一连串动作"拢成一条活动流,内容落在轨的右侧。 */
margin-left: -0.5px;
border-left: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
padding-left: var(--agent-activity-inset, 8px);
min-width: 0;
}
/* 看图是唯一的"画面"档,给它主题强调色,和清单/写画布区分开(不写字面颜色)。 */
.agent-operation-feed[data-agent-category="vision"] {
--agent-tool-accent: var(--agent-accent, var(--foreground));
}
.agent-operation-toggle {
display: flex;
min-width: 0;
max-width: 100%;
align-items: center;
gap: var(--space-2);
border: 0;
border-radius: 9px;
background: transparent;
padding: 4px var(--space-2) 4px var(--space-1);
color: color-mix(in srgb, var(--agent-ink, currentColor) 72%, transparent);
font-size: var(--fs-caption);
line-height: 18px;
text-align: left;
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease;
}
.agent-operation-toggle:hover {
background: var(--agent-surface-soft, color-mix(in srgb, var(--foreground) 4%, transparent));
color: var(--agent-ink, var(--foreground));
}
.agent-operation-toggle:focus-visible {
outline: 2px solid var(--agent-focus-ring, var(--foreground));
outline-offset: 1px;
}
.agent-operation-latest {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/*
* 流光:把文字本身当渐变蒙版,让一道高光扫过。底色与高光都从 --agent-ink 派生——不能用
* currentColor,因为开启 background-clip 后 color 是 transparent,会自指成空。
* 动画只挂在 .is-live 上:面板按"末尾段 + 还在跑"下发,任务完成流光立即停。
*/
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
.agent-operation-latest {
background-image: linear-gradient(
100deg,
color-mix(in srgb, var(--agent-operation-ink, var(--agent-ink, currentColor)) 74%, transparent) 0%,
color-mix(in srgb, var(--agent-operation-ink, var(--agent-ink, currentColor)) 74%, transparent) 42%,
var(--agent-operation-ink, var(--agent-ink, currentColor)) 50%,
color-mix(in srgb, var(--agent-operation-ink, var(--agent-ink, currentColor)) 74%, transparent) 58%,
color-mix(in srgb, var(--agent-operation-ink, var(--agent-ink, currentColor)) 74%, transparent) 100%
);
background-position: 100% 0;
background-size: 240% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.agent-operation-feed.is-live .agent-operation-latest {
animation: agent-operation-sheen 3.6s linear infinite;
}
}
/* 失败/被拒的一步:折叠行跟工具卡同一套红,不用主题色盖掉语义。 */
.agent-operation-feed.is-failed .agent-operation-latest {
--agent-operation-ink: #dc2626;
}
.agent-operation-count {
flex: 0 0 auto;
border-radius: 999px;
background: var(--agent-surface-soft, color-mix(in srgb, var(--foreground) 6%, transparent));
padding: 1px var(--space-2);
color: color-mix(in srgb, var(--agent-ink, currentColor) 62%, transparent);
font-size: var(--fs-tiny);
line-height: 16px;
font-variant-numeric: tabular-nums;
}
.agent-operation-chevron {
width: 14px;
height: 14px;
flex: 0 0 auto;
opacity: 0.55;
transition: transform 180ms ease;
}
.agent-operation-feed.is-open .agent-operation-chevron {
transform: rotate(180deg);
}
.agent-operation-list {
margin-top: 2px;
}
.agent-operation-list > * + * {
margin-top: var(--space-1);
}
/*
* 展开后的每一步不再重复状态图标(勾/叉):状态与错误已经由卡片文字与折叠行颜色表达,
* 一列彩色勾只会把"记录"读成"成绩单"。失败那一步的文字仍是红的(AgentToolCard 内联色)。
*/
.agent-operation-list .agent-tool-status {
display: none;
}
@keyframes agent-operation-sheen {
from {
background-position: 130% 0;
}
to {
background-position: -30% 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* 与 .is-live 规则同特异性,否则关不掉动画。 */
.agent-operation-feed.is-live .agent-operation-latest {
animation: none;
background-position: 0% 0;
}
.agent-operation-toggle,
.agent-operation-chevron {
transition: none;
}
}
.agent-composer-prompt-scroll {
min-height: 60px;
max-height: min(240px, 34vh);
@@ -1143,9 +1320,35 @@
* 比工具日志更安静、比正文更有层次。使用当前画布主题的 accent,避免
* 固定紫色的 AI 默认样式,同时把实时状态、摘要内容和展开动作分开。
*/
/*
* 信息层级:正文 / 工具调用 / 模型思考 三段只靠「位置 + 字号 + 对比度」分层,
* 不靠圆底图标或品牌 glyph。
*
* 正文(结论) 从公共轴开始、14px、满对比 —— 主角
* 工具调用 左轨压在公共轴上(活动流),内容在轨右侧,12px、次对比
* 模型思考 星标居中在公共轴上,12px/11px、最淡 —— 过程里的旁白
*/
.agent-reasoning {
min-width: 0;
color: color-mix(in srgb, var(--foreground) 64%, transparent);
/* 星标挂进旁注沟槽:中心落在公共轴上(正文左边缘)。 */
margin-left: calc(-1 * (var(--agent-thought-icon, 16px) / 2 + var(--agent-summary-inset, 4px)));
color: color-mix(in srgb, var(--foreground) 58%, transparent);
}
/*
* 时间线标记列(系统行 / 错误行 / 审批卡 / 上下文整理):宽度与挂点都走公共轴 token。
*/
.agent-timeline-marker {
display: flex;
width: var(--agent-timeline-marker, 24px);
flex: 0 0 var(--agent-timeline-marker, 24px);
align-self: stretch;
justify-content: center;
}
/* 只有对话流里的标记向左挂半格:中心与星标、工具左轨共用同一条公共轴。 */
.agent-conversation-messages .agent-timeline-marker {
margin-left: calc(-1 * (var(--agent-timeline-marker, 24px) / 2));
}
.agent-reasoning-card {
@@ -1166,11 +1369,12 @@
display: flex;
min-width: 0;
align-items: center;
gap: 9px;
gap: var(--agent-thought-gap, 9px);
cursor: pointer;
list-style: none;
padding: 6px 4px;
color: color-mix(in srgb, var(--foreground) 72%, transparent);
/* 内边距参与公共轴推导:改它,左轨会跟着一起挪(见 .agent-conversation-messages)。 */
padding: 6px var(--agent-summary-inset, 4px);
color: color-mix(in srgb, var(--foreground) 66%, transparent);
user-select: none;
}
@@ -1182,16 +1386,17 @@
background: color-mix(in srgb, var(--agent-reasoning-accent) 4%, transparent);
}
/* 只留那颗星:圆底是上一版的装饰,去掉后与时间线标记同一种"无底图标"语言。 */
.agent-reasoning-icon {
display: grid;
width: 25px;
height: 25px;
flex: 0 0 25px;
width: var(--agent-thought-icon, 16px);
height: var(--agent-thought-icon, 16px);
flex: 0 0 var(--agent-thought-icon, 16px);
place-items: center;
border: 0;
border-radius: 50%;
background: color-mix(in srgb, var(--agent-reasoning-accent) 10%, transparent);
color: var(--agent-reasoning-accent);
border-radius: 0;
background: transparent;
color: color-mix(in srgb, var(--agent-reasoning-accent) 82%, var(--foreground));
}
.agent-reasoning-copy {
@@ -1205,9 +1410,9 @@
.agent-reasoning-title {
min-width: 0;
overflow: hidden;
color: color-mix(in srgb, var(--foreground) 78%, transparent);
color: color-mix(in srgb, var(--foreground) 72%, transparent);
font-size: 12px;
font-weight: 650;
font-weight: 600;
line-height: 17px;
text-overflow: ellipsis;
white-space: nowrap;
@@ -1216,7 +1421,7 @@
.agent-reasoning-subtitle {
min-width: 0;
overflow: hidden;
color: color-mix(in srgb, var(--foreground) 43%, transparent);
color: color-mix(in srgb, var(--foreground) 38%, transparent);
font-size: 11px;
line-height: 16px;
text-overflow: ellipsis;
@@ -1277,13 +1482,14 @@
gap: 0;
overflow: auto;
border-top: 0;
padding: 4px 10px 10px 38px;
/* 与摘要的标题左对齐:摘要内边距 + 星标列 + 摘要 gap。 */
padding: 4px 10px 10px calc(var(--agent-summary-inset, 4px) + var(--agent-thought-icon, 16px) + var(--agent-thought-gap, 9px));
scrollbar-gutter: stable;
}
.agent-reasoning-text {
min-width: 0;
color: color-mix(in srgb, var(--foreground) 58%, transparent);
color: color-mix(in srgb, var(--foreground) 54%, transparent);
font-size: 12px;
line-height: 1.75;
white-space: pre-wrap;