fix: stabilize agent progress display

This commit is contained in:
2026-07-20 19:13:43 +08:00
parent 3c0271bae8
commit 72c8190a78
2 changed files with 134 additions and 32 deletions
@@ -157,7 +157,7 @@ export function AgentMessageProgress({
return null; return null;
} }
return <ProgressList key={running ? "running" : "settled"} progress={progress} running={running} />; return <ProgressList progress={progress} running={running} />;
} }
function AgentNestedBlock({ function AgentNestedBlock({
@@ -200,35 +200,35 @@ function AnimatedDetailItem({ children }: { children: ReactNode }) {
function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) { function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const visibleProgress = running const compact = running && !expanded;
? progress.slice(-STREAMING_PROGRESS_LIMIT) const visibleProgress = expanded
: expanded ? progress
? progress : running
? progress.slice(-STREAMING_PROGRESS_LIMIT)
: []; : [];
const listMode = running ? "streaming" : expanded ? "expanded" : "collapsed"; const listMode = expanded ? "expanded" : running ? "streaming" : "collapsed";
return ( return (
<div className="agent-panel-nested rounded-xl p-2.5"> <div className="agent-panel-nested rounded-xl p-2.5">
<button <button
type="button" type="button"
className="flex w-full items-center gap-2 text-left text-xs font-semibold text-slate-600 disabled:cursor-default" className="flex w-full items-center gap-2 text-left text-xs font-semibold text-slate-600"
aria-expanded={running || expanded} aria-expanded={expanded}
disabled={running}
onClick={() => setExpanded((current) => !current)} onClick={() => setExpanded((current) => !current)}
> >
<ListTree size={16} className="shrink-0 text-blue-700" aria-hidden="true" /> <ListTree size={16} className="shrink-0 text-blue-700" aria-hidden="true" />
<span></span> <span></span>
<span className="flex-1" /> <span className="flex-1" />
<span className="shrink-0 font-normal text-slate-400"> <span className="shrink-0 font-normal text-slate-400">
{running {expanded
? `最近 ${Math.min(progress.length, STREAMING_PROGRESS_LIMIT)}` ? `全部 ${progress.length}`
: expanded : running
? `全部 ${progress.length}` ? `最近 ${Math.min(progress.length, STREAMING_PROGRESS_LIMIT)}`
: `${progress.length}`} : `${progress.length}`}
</span> </span>
<ChevronDown <ChevronDown
size={14} size={14}
className={cn("shrink-0 text-slate-400 transition-transform", !running && expanded && "rotate-180")} className={cn("shrink-0 text-slate-400 transition-transform", expanded && "rotate-180")}
aria-hidden="true" aria-hidden="true"
/> />
</button> </button>
@@ -236,18 +236,21 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
{visibleProgress.length ? ( {visibleProgress.length ? (
<motion.ol <motion.ol
key={listMode} key={listMode}
aria-label={!running && expanded ? "全部执行进度" : "最近执行进度"} aria-label={expanded ? "全部执行进度" : "最近执行进度"}
className="mt-2 space-y-1.5 overflow-hidden" className="mt-2 space-y-1.5 overflow-hidden"
initial={{ height: 0, opacity: 0 }} initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }} animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }} exit={{ height: 0, opacity: 0 }}
transition={agentLayoutTransition} transition={agentLayoutTransition}
> >
<AnimatePresence initial={false} mode={expanded ? "sync" : "wait"}> <AnimatePresence initial={false} mode="popLayout">
{visibleProgress.map((item) => ( {visibleProgress.map((item) => (
<motion.li <motion.li
key={item.id} key={item.id}
className="grid h-12 grid-cols-[16px_minmax(0,1fr)_auto] items-start gap-2 overflow-hidden text-xs" className={cn(
"grid grid-cols-[16px_minmax(0,1fr)_auto] items-start gap-2 text-xs",
compact && "h-12 overflow-hidden"
)}
initial={{ opacity: 0, y: 4 }} initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -2 }} exit={{ opacity: 0, y: -2 }}
@@ -275,11 +278,23 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
} }
/> />
<span className="min-w-0"> <span className="min-w-0">
<span className="block truncate font-semibold text-slate-800" title={item.title}> <span
className={cn(
"block font-semibold text-slate-800",
compact ? "truncate" : "break-words"
)}
title={item.title}
>
{item.title} {item.title}
</span> </span>
{item.detail ? ( {item.detail ? (
<span className="block truncate leading-5 text-slate-500" title={item.detail}> <span
className={cn(
"block leading-5 text-slate-500",
compact ? "truncate" : "break-words"
)}
title={item.detail}
>
{item.detail} {item.detail}
</span> </span>
) : null} ) : null}
+100 -13
View File
@@ -7,6 +7,7 @@ const STREAM_CHUNK_DELAY_MS = 20;
let streamServer: Server; let streamServer: Server;
let streamServerUrl: string; let streamServerUrl: string;
let appendProgress: (() => void) | null = null;
let failProgress: (() => void) | null = null; let failProgress: (() => void) | null = null;
let releaseStream: (() => void) | null = null; let releaseStream: (() => void) | null = null;
@@ -121,18 +122,20 @@ test.beforeAll(async () => {
while (index < 3 && index < chunks.length) { while (index < 3 && index < chunks.length) {
sendNextTextChunk(); sendNextTextChunk();
} }
send({ appendProgress = () => {
type: "data-progress", send({
id: "assess-risk", type: "data-progress",
data: {
id: "assess-risk", id: "assess-risk",
phase: "analysis", data: {
status: "running", id: "assess-risk",
title: "正在判断溢流风险", phase: "analysis",
detail: "结合流量突变与液位趋势形成判断。", status: "running",
started_at: Date.now() title: "正在判断溢流风险",
} detail: "结合流量突变与液位趋势形成判断。",
}); started_at: Date.now()
}
});
};
failProgress = () => { failProgress = () => {
send({ send({
@@ -262,14 +265,65 @@ test("stream completion and the plan card remain positionally stable", async ({
await sendButton.click(); await sendButton.click();
const liveProgress = page.getByRole("list", { name: "最近执行进度" }); const liveProgress = page.getByRole("list", { name: "最近执行进度" });
await expect(liveProgress.locator("li")).toHaveCount(3);
await expect(liveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible();
await page.evaluate(() => {
const state = window as typeof window & {
__agentMinVisibleProgressRows?: number;
__agentProgressWindowSampleDone?: boolean;
};
const startedAt = performance.now();
state.__agentMinVisibleProgressRows = Number.POSITIVE_INFINITY;
state.__agentProgressWindowSampleDone = false;
const sample = () => {
const rows = document.querySelectorAll('ol[aria-label="最近执行进度"] > li').length;
state.__agentMinVisibleProgressRows = Math.min(state.__agentMinVisibleProgressRows ?? rows, rows);
if (performance.now() - startedAt < 400) {
requestAnimationFrame(sample);
} else {
state.__agentProgressWindowSampleDone = true;
}
};
requestAnimationFrame(sample);
});
triggerProgressAppend();
await expect(liveProgress.getByText("正在判断溢流风险", { exact: true })).toBeVisible(); await expect(liveProgress.getByText("正在判断溢流风险", { exact: true })).toBeVisible();
await expect(liveProgress.locator("li")).toHaveCount(3); await expect(liveProgress.locator("li")).toHaveCount(3);
await expect(liveProgress.getByText("正在分析运行上下文", { exact: true })).toHaveCount(0); await expect(liveProgress.getByText("正在分析运行上下文", { exact: true })).toHaveCount(0);
await expect.poll(() =>
page.evaluate(() =>
(window as typeof window & { __agentProgressWindowSampleDone?: boolean }).__agentProgressWindowSampleDone ?? false
)
).toBe(true);
expect(
await page.evaluate(() =>
(window as typeof window & { __agentMinVisibleProgressRows?: number }).__agentMinVisibleProgressRows
)
).toBeGreaterThanOrEqual(3);
const liveProgressRowHeights = await liveProgress.locator("li").evaluateAll((rows) => const liveProgressRowHeights = await liveProgress.locator("li").evaluateAll((rows) =>
rows.map((row) => row.getBoundingClientRect().height) rows.map((row) => row.getBoundingClientRect().height)
); );
expect(liveProgressRowHeights.every((height) => Math.abs(height - 48) <= 0.01)).toBe(true); expect(liveProgressRowHeights.every((height) => Math.abs(height - 48) <= 0.01)).toBe(true);
await expect(liveProgress.locator("li").first().locator("span.min-w-0 > span").first()).toHaveCSS(
"white-space",
"nowrap"
);
const progressToggle = page.getByRole("button", { name: /执行进度/ }).filter({ visible: true }).first();
await expect(progressToggle).toBeEnabled();
await progressToggle.click();
const expandedLiveProgress = page.getByRole("list", { name: "全部执行进度" });
await expect(expandedLiveProgress.locator("li")).toHaveCount(4);
await expect(expandedLiveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible();
await expect(expandedLiveProgress.getByText("正在核对降雨过程与汇水区响应关系", { exact: true })).toHaveCSS(
"white-space",
"normal"
);
await progressToggle.click();
await expect(page.getByRole("list", { name: "最近执行进度" }).locator("li")).toHaveCount(3);
await page.evaluate(() => { await page.evaluate(() => {
const state = window as typeof window & { __agentFailureMotionObserved?: boolean }; const state = window as typeof window & { __agentFailureMotionObserved?: boolean };
@@ -300,7 +354,6 @@ test("stream completion and the plan card remain positionally stable", async ({
await expect(page.getByText("分析完成", { exact: true }).filter({ visible: true }).first()).toBeVisible({ await expect(page.getByText("分析完成", { exact: true }).filter({ visible: true }).first()).toBeVisible({
timeout: 10_000 timeout: 10_000
}); });
const progressToggle = page.getByRole("button", { name: /执行进度/ }).filter({ visible: true }).first();
await expect(progressToggle).toContainText("共 4 条"); await expect(progressToggle).toContainText("共 4 条");
await expect(page.getByRole("list", { name: "最近执行进度" })).toHaveCount(0); await expect(page.getByRole("list", { name: "最近执行进度" })).toHaveCount(0);
await expect(page.getByText("溢流风险判断完成", { exact: true }).filter({ visible: true })).toHaveCount(0); await expect(page.getByText("溢流风险判断完成", { exact: true }).filter({ visible: true })).toHaveCount(0);
@@ -358,8 +411,32 @@ test("stream completion and the plan card remain positionally stable", async ({
).toBeLessThanOrEqual(2); ).toBeLessThanOrEqual(2);
await progressToggle.click(); await progressToggle.click();
await expect(page.getByRole("list", { name: "全部执行进度" }).locator("li")).toHaveCount(4); const expandedProgress = page.getByRole("list", { name: "全部执行进度" });
await expect(expandedProgress.locator("li")).toHaveCount(4);
await expect(page.getByText("正在分析运行上下文", { exact: true }).filter({ visible: true }).first()).toBeVisible(); await expect(page.getByText("正在分析运行上下文", { exact: true }).filter({ visible: true }).first()).toBeVisible();
const expandedLongTitle = expandedProgress.getByText("正在核对降雨过程与汇水区响应关系", { exact: true });
await expect(expandedLongTitle).toHaveCSS("white-space", "normal");
await expect(expandedProgress.locator("li").nth(1)).not.toHaveCSS("height", "48px");
await progressToggle.click();
await prompt.fill("验证展开状态在流结束后保持");
await sendButton.click();
const latestProgressToggle = page.getByRole("button", { name: /执行进度/ }).filter({ visible: true }).last();
await expect(latestProgressToggle).toContainText("最近 3 条");
triggerProgressAppend();
await expect(
page.getByRole("list", { name: "最近执行进度" }).getByText("正在判断溢流风险", { exact: true })
).toBeVisible();
await latestProgressToggle.click();
await expect(latestProgressToggle).toContainText("全部 4 条");
resumeStream();
await expect(latestProgressToggle).toContainText("全部 4 条", { timeout: 10_000 });
await expect(page.getByRole("list", { name: "全部执行进度" })).toBeVisible();
await expect(
page.getByRole("list", { name: "全部执行进度" }).getByText("溢流风险判断完成", { exact: true })
).toBeVisible();
}); });
function createLongMarkdownResponse() { function createLongMarkdownResponse() {
@@ -393,6 +470,16 @@ function resumeStream() {
resume(); resume();
} }
function triggerProgressAppend() {
if (!appendProgress) {
throw new Error("Progress append checkpoint was not reached");
}
const append = appendProgress;
appendProgress = null;
append();
}
function triggerProgressFailure() { function triggerProgressFailure() {
if (!failProgress) { if (!failProgress) {
throw new Error("Progress failure checkpoint was not reached"); throw new Error("Progress failure checkpoint was not reached");