Files
Jyotisha/frontend/src/components/agent-activity-status.tsx
T
Jesse_Chen 6b225a288c fix(web): keep later thinking below rectification tool steps
BUG-360: stream thinking and tool activity as an ordered trace so later CoT opens under 正在整理 instead of filling the first 思考 block.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 17:02:06 +08:00

180 lines
5.6 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { Check } from "lucide-react";
import type { OrbState } from "thinking-orbs";
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { activityCompletedSteps, activityElapsedLabel } from "@/lib/chat-message-view";
import type { AgentActivityTraceItem } from "@/lib/agent-activity-trace";
const labels = {
working: "正在处理任务…",
searching: "正在搜索相关信息…",
solving: "正在分析问题…",
listening: "正在聆听…",
composing: "正在组织回答…",
shaping: "正在生成结果…",
} as const satisfies Record<OrbState, string>;
const importThinkingOrb = () => import("thinking-orbs");
const ThinkingOrb = dynamic(async () => (await importThinkingOrb()).ThinkingOrb, {
loading: () => (
<span aria-hidden="true" style={{ display: "block", flex: "0 0 auto", height: 20, width: 20 }} />
),
ssr: false,
});
prefetchOnIdle(importThinkingOrb);
export type AgentActivityState = OrbState;
function ActivityElapsed({ startedAt }: Readonly<{ startedAt: number }>) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
const label = activityElapsedLabel(startedAt, now);
if (!label) return null;
return <span className="agent-activity-status__elapsed" aria-hidden="true">{label}</span>;
}
function MessageThinkingTrace({
text,
hasAnswer,
}: Readonly<{
text: string;
hasAnswer: boolean;
}>) {
const [userOpen, setUserOpen] = useState<boolean | null>(null);
const open = userOpen ?? !hasAnswer;
if (!text.trim()) return null;
return (
<details
className="message-thinking"
open={open}
onToggle={(event) => {
setUserOpen((event.currentTarget as HTMLDetailsElement).open);
}}
>
<summary></summary>
<div className="message-thinking-body">{text}</div>
</details>
);
}
export function AgentActivityStatus({
state,
label = labels[state],
startedAt,
completedTrail,
thinkingText,
activityTrace,
hasAnswer = false,
showLive = true,
}: Readonly<{
state: AgentActivityState;
label?: string;
startedAt?: number;
completedTrail?: string;
thinkingText?: string;
activityTrace?: readonly AgentActivityTraceItem[];
hasAnswer?: boolean;
showLive?: boolean;
}>) {
const completedSteps = activityCompletedSteps(completedTrail);
const live = showLive && !hasAnswer;
const trace = activityTrace ?? [];
if (trace.length > 0) {
return (
<div className="agent-thinking-panel agent-activity-status">
<ol className="agent-thinking-timeline">
{trace.map((item) => (
<TraceStep
key={item.id}
item={item}
state={state}
hasAnswer={hasAnswer}
/>
))}
</ol>
</div>
);
}
if (!live && completedSteps.length === 0 && !thinkingText?.trim()) return null;
return (
<div className="agent-thinking-panel agent-activity-status">
{(live || completedSteps.length > 0) && (
<ol className="agent-thinking-timeline">
{completedSteps.map((step) => (
<li className="agent-thinking-step is-done" key={step}>
<span className="agent-thinking-marker" aria-hidden="true">
<Check />
</span>
<span>{step}</span>
</li>
))}
{live ? (
<li className="agent-thinking-step is-live">
<span className="agent-thinking-marker is-live-marker">
<ThinkingOrb aria-hidden="true" state={state} size={20} />
</span>
<span className="agent-activity-status__live" role="status">
<span key={label} className="agent-activity-status__text">{label}</span>
{startedAt ? <ActivityElapsed key={startedAt} startedAt={startedAt} /> : null}
</span>
</li>
) : null}
</ol>
)}
{thinkingText ? <MessageThinkingTrace text={thinkingText} hasAnswer={hasAnswer} /> : null}
</div>
);
}
function TraceStep({
item,
state,
hasAnswer,
}: Readonly<{
item: AgentActivityTraceItem;
state: AgentActivityState;
hasAnswer: boolean;
}>) {
if (item.kind === "think") {
return (
<li className={`agent-thinking-step is-think is-${item.status}`}>
{item.text?.trim() ? (
<MessageThinkingTrace text={item.text} hasAnswer={hasAnswer || item.status === "done"} />
) : (
<>
<span className={`agent-thinking-marker${item.status === "live" ? " is-live-marker" : ""}`} aria-hidden="true">
{item.status === "live" ? <ThinkingOrb aria-hidden="true" state={state} size={20} /> : <Check />}
</span>
<span>{item.label}</span>
</>
)}
</li>
);
}
return (
<li className={`agent-thinking-step is-${item.status}`}>
<span className={`agent-thinking-marker${item.status === "live" ? " is-live-marker" : ""}`} aria-hidden="true">
{item.status === "live" ? <ThinkingOrb aria-hidden="true" state={state} size={20} /> : <Check />}
</span>
{item.status === "live" ? (
<span className="agent-activity-status__live" role="status">
<span key={item.label} className="agent-activity-status__text">{item.label}</span>
{item.startedAt ? <ActivityElapsed key={item.startedAt} startedAt={item.startedAt} /> : null}
</span>
) : (
<span>{item.label}</span>
)}
</li>
);
}