diff --git a/web/src/components/canvas/canvas-agent-chat-ui.tsx b/web/src/components/canvas/canvas-agent-chat-ui.tsx
index 35285788f..6e940c819 100644
--- a/web/src/components/canvas/canvas-agent-chat-ui.tsx
+++ b/web/src/components/canvas/canvas-agent-chat-ui.tsx
@@ -50,7 +50,69 @@ export function extractCanvasAgentQuickActions(text: string): CanvasAgentQuickAc
const WORKING_TEXT = "正在推演...";
-export function AgentChatMessage({ item, theme, user, isStreaming = false, retrying = false, onRejectTool, onApproveTool, onQuickAction, onRetry }: { item: CanvasAgentChatMessage; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: LocalUser | null; isStreaming?: boolean; retrying?: boolean; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void; onQuickAction?: (prompt: string) => void; onRetry?: () => void }) {
+// Agent 工作指示器:3×3 点阵对角波光。吸收自 boardui agent-thinking 的
+// wave 形态(不引 UI 资产,仅点阵节奏与降级规范);reduced-motion 下保持静态帧。
+const WORKING_DOTS = 3;
+const WORKING_DOTS_TICK_MS = 90;
+const WORKING_DOTS_MIN_OPACITY = 0.14;
+const WORKING_DOTS_PHASE_STEP = 1 / 8;
+const WORKING_DOTS_TRAIL = 0.32;
+
+function AgentWorkingDots() {
+ const reducedMotion = useReducedMotion();
+ const [phase, setPhase] = useState(0);
+ useEffect(() => {
+ if (reducedMotion) return;
+ const timer = window.setInterval(() => setPhase((value) => (value + WORKING_DOTS_PHASE_STEP) % 1), WORKING_DOTS_TICK_MS);
+ return () => window.clearInterval(timer);
+ }, [reducedMotion]);
+ return (
+
+ {Array.from({ length: WORKING_DOTS * WORKING_DOTS }, (_, index) => {
+ const col = index % WORKING_DOTS;
+ const row = Math.floor(index / WORKING_DOTS);
+ // 对角波相位 0..<1:波前扫过点阵后折返,读作光在格内往返。
+ const scalar = ((col + row) / (2 * (WORKING_DOTS - 1))) * (WORKING_DOTS / (WORKING_DOTS + 1));
+ const behind = (phase - scalar + 1) % 1;
+ const lit = Math.max(0, 1 - behind / WORKING_DOTS_TRAIL) ** 1.5;
+ const opacity = WORKING_DOTS_MIN_OPACITY + (1 - WORKING_DOTS_MIN_OPACITY) * lit;
+ return ;
+ })}
+
+ );
+}
+
+function AgentWorkingElapsed() {
+ const [elapsed, setElapsed] = useState(0);
+ useEffect(() => {
+ const started = performance.now();
+ const timer = window.setInterval(() => setElapsed(Math.floor((performance.now() - started) / 1000)), 500);
+ return () => window.clearInterval(timer);
+ }, []);
+ return {elapsed}s;
+}
+
+export function AgentChatMessage({
+ item,
+ theme,
+ user,
+ isStreaming = false,
+ retrying = false,
+ onRejectTool,
+ onApproveTool,
+ onQuickAction,
+ onRetry,
+}: {
+ item: CanvasAgentChatMessage;
+ theme: (typeof canvasThemes)[keyof typeof canvasThemes];
+ user: LocalUser | null;
+ isStreaming?: boolean;
+ retrying?: boolean;
+ onRejectTool?: (id: string) => void;
+ onApproveTool?: (id: string) => void;
+ onQuickAction?: (prompt: string) => void;
+ onRetry?: () => void;
+}) {
const isUser = item.role === "user";
const isSystem = item.role === "system";
const color = item.role === "error" ? "#dc2626" : item.role === "tool" ? "#2563eb" : theme.node.text;
@@ -78,7 +140,13 @@ export function AgentChatMessage({ item, theme, user, isStreaming = false, retry
{!isUser ?
: null}
- {item.role === "assistant" ?
{item.text} :
{item.text}
}
+ {item.role === "assistant" ? (
+
+ {item.text}
+
+ ) : (
+
{item.text}
+ )}
{item.role === "error" && onRetry ? (
:
} disabled={retrying} onClick={onRetry}>
{retrying ? "重试中" : "重试本轮"}
@@ -121,9 +189,13 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
确认工具调用
- 等待确认
+
+ 等待确认
+
+
+
+ {summary}
-
{summary}
{impact?.operationCount ? (
@@ -134,11 +206,27 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
0} theme={theme} />
0} theme={theme} />
- {impact.items.length ? {impact.items.map((item, index) =>
{item}
)}
: null}
+ {impact.items.length ? (
+
+ {impact.items.map((item, index) => (
+
+
+ {item}
+
+ ))}
+
+ ) : null}
{impact.warning ? {impact.warning}
: null}
) : null}
- {detail ? 技术详情
: null}
+ {detail ? (
+
+
+ 技术详情
+
+
+
+ ) : null}
{onReject || onApprove ? (
} onClick={() => onReject?.()}>
@@ -155,7 +243,16 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
}
function ImpactMetric({ label, value, attention = false, theme }: { label: string; value: number; attention?: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
- return
;
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
}
function agentImpactFromDetail(detail: unknown) {
@@ -187,7 +284,11 @@ export function AgentToolCard({ title, text, detail, theme }: { title: string; t
{state.label}
- {detail ?
详情 : null}
+ {detail ? (
+
+ 详情
+
+ ) : null}
{text}
@@ -200,20 +301,12 @@ export function AgentToolCard({ title, text, detail, theme }: { title: string; t
);
}
-export function AgentWorkingMessage({ theme }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
- const [length, setLength] = useState(1);
- useEffect(() => {
- const timer = window.setInterval(() => setLength((value) => (value >= WORKING_TEXT.length + 4 ? 1 : value + 1)), 120);
- return () => window.clearInterval(timer);
- }, [setLength]);
+export function AgentWorkingMessage({ theme, label = WORKING_TEXT }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; label?: string }) {
return (
-
-
-
-
- {WORKING_TEXT.slice(0, Math.min(length, WORKING_TEXT.length))}
-
-
+
);
}
@@ -275,11 +368,7 @@ export function AgentChatComposer({
const applySlashSkill = (skill: Skill) => {
const token = `@[skill:${skill.skill_id}] `;
- const next = slash
- ? `${prompt.slice(0, slash.start)}${token}${prompt.slice(slash.start + slash.query.length)}`
- : prompt
- ? `${prompt.replace(/\s+$/u, "")} ${token}`
- : token;
+ const next = slash ? `${prompt.slice(0, slash.start)}${token}${prompt.slice(slash.start + slash.query.length)}` : prompt ? `${prompt.replace(/\s+$/u, "")} ${token}` : token;
setSlash(null);
setSlashIndex(0);
onPromptChange(next);
@@ -321,20 +410,35 @@ export function AgentChatComposer({
return (
event.stopPropagation()}>
+ {sending && !reducedMotion ? (
+
+ ) : null}
{attachments.length ? (
{attachments.map((item) => (

{onRemoveAttachment ? (
-