From 80bdc31ecf643d51010f0378a874612ab6b3b60a Mon Sep 17 00:00:00 2001 From: AlphaCat Date: Sun, 11 Oct 2026 21:46:33 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E5=88=B7=E6=96=B0=E9=97=AA=E7=83=81=E5=B9=B6?= =?UTF-8?q?=E5=8A=A0=E5=85=A5=E6=95=B0=E5=AD=97=E8=BD=AE=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/chat/rolling-stat-text.test.mjs | 73 +++++++++++++++++ .../conversation-stats-hook.test.mjs | 75 ++++++++++++++++++ .../components/chat/ConversationStatsBar.tsx | 10 ++- .../src/components/chat/RollingStatText.tsx | 46 +++++++++++ .../lib/trajectory/useConversationStats.ts | 30 ++++++- crates/agent-ui/src/styles/animations.css | 33 ++++++++ .../composer-stats-rolling-digits.png | Bin 0 -> 22816 bytes 7 files changed, 265 insertions(+), 2 deletions(-) create mode 100644 crates/agent-gui/test/chat/rolling-stat-text.test.mjs create mode 100644 crates/agent-ui/src/components/chat/RollingStatText.tsx create mode 100644 docs/screenshots/composer-stats-rolling-digits.png diff --git a/crates/agent-gui/test/chat/rolling-stat-text.test.mjs b/crates/agent-gui/test/chat/rolling-stat-text.test.mjs new file mode 100644 index 000000000..8ab9c3ba0 --- /dev/null +++ b/crates/agent-gui/test/chat/rolling-stat-text.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { createDomTestEnv } from "../helpers/dom-test-env.mjs"; + +const env = await createDomTestEnv(); +const { React, act, createRoot } = env; +const { RollingStatText } = env.loadModule("@liveagent/ui/components/chat/RollingStatText.tsx"); + +async function mount(text) { + const container = env.dom.window.document.createElement("div"); + env.dom.window.document.body.appendChild(container); + const root = createRoot(container); + const update = async (value) => { + await act(async () => root.render(React.createElement(RollingStatText, { text: value }))); + }; + await update(text); + return { + container, + update, + unmount: async () => { + await act(async () => root.unmount()); + container.remove(); + }, + }; +} + +test("数字更新保留标签和未变位的节点,仅数字条改变位置", async () => { + const probe = await mount("Context 12.3% · ↑1.2M"); + try { + const labels = [...probe.container.querySelectorAll("[data-stat-label]")]; + const digits = [...probe.container.querySelectorAll("[data-current-digit]")]; + const positions = digits.map((digit) => digit.style.transform); + assert.equal(probe.container.textContent, "Context 12.3% · ↑1.2M"); + await probe.update("Context 12.4% · ↑1.2M"); + assert.equal(probe.container.textContent, "Context 12.4% · ↑1.2M"); + const next = [...probe.container.querySelectorAll("[data-current-digit]")]; + for (let i = 0; i < next.length; i += 1) { + assert.equal(next[i], digits[i], "数字位更新不能重新挂载"); + assert.equal(next[i].style.transform === positions[i], i !== 2); + } + assert.deepEqual([...probe.container.querySelectorAll("[data-stat-label]")], labels); + assert.equal(next[2].style.transform, "translateY(-4em)"); + assert.equal(probe.container.querySelectorAll("[data-digit]").length, 50); + } finally { + await probe.unmount(); + } +}); + +test("跨位数和单位变化保持右侧数字位身份,数字下降也正确", async () => { + const probe = await mount("Time 9s"); + try { + const ones = probe.container.querySelector("[data-current-digit]"); + await probe.update("Time 10s"); + const digits = [...probe.container.querySelectorAll("[data-current-digit]")]; + assert.equal(digits[1], ones); + assert.equal(probe.container.textContent, "Time 10s"); + await probe.update("Time 2m"); + assert.equal(probe.container.querySelector("[data-current-digit]"), ones); + assert.equal(ones.style.transform, "translateY(-2em)"); + assert.equal(probe.container.textContent, "Time 2m"); + await probe.update("上下文 0.0%"); + assert.equal(probe.container.textContent, "上下文 0.0%"); + } finally { + await probe.unmount(); + } +}); + +test("减少动态偏好禁用数字位移过渡,样式只过渡 transform", () => { + const css = readFileSync(new URL("../../../agent-ui/src/styles/animations.css", import.meta.url), "utf8"); + assert.match(css, /\.stat-digit-ready\s*\{\s*transition: transform var\(--ui-duration-160ms\) var\(--ease-ui-enter\);/); + assert.match(css, /@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.stat-digit-ready\s*\{\s*transition: none;/); +}); diff --git a/crates/agent-gui/test/trajectory/conversation-stats-hook.test.mjs b/crates/agent-gui/test/trajectory/conversation-stats-hook.test.mjs index 523b597be..7d5384bcd 100644 --- a/crates/agent-gui/test/trajectory/conversation-stats-hook.test.mjs +++ b/crates/agent-gui/test/trajectory/conversation-stats-hook.test.mjs @@ -108,6 +108,81 @@ async function drain(rounds = 8) { } } +test("同会话刷新等待期间保留读数,完成后允许历史缩减,切会话不串数据", async () => { + clearConversationStatsCache(); + let release; + let delayed = false; + let pages = [turnEvents(1), turnEvents(2)]; + const host = { + loadWindow: async () => { + if (delayed) await new Promise((resolve) => { release = resolve; }); + return { + eventsJson: JSON.stringify(pages.flat()), + truncated: false, + oldestSegmentIndex: 0, + returnedSegmentCount: 1, + totalSegmentCount: 1, + hasMoreBefore: false, + }; + }, + }; + const probe = mountHook({ conversationId: "refresh-stable", host, liveEvents: [], enabled: true }); + try { + await probe.mount(); + await drain(); + assert.equal(probe.seen.current.stats.turns, 2); + delayed = true; + pages = [turnEvents(1)]; + await probe.update({ authoritativeRevision: 1 }); + assert.equal(probe.seen.current.stats?.turns, 2, "请求返回前不能变成空白或部分读数"); + await act(async () => { release(); }); + await drain(); + assert.equal(probe.seen.current.stats.turns, 1, "新数据必须允许历史截断后的下降"); + pages = []; + await probe.update({ conversationId: "other-empty" }); + assert.equal(probe.seen.current.stats, null, "切会话不能保留旧会话读数"); + await act(async () => { release(); }); + await drain(); + await probe.update({ enabled: false }); + assert.equal(probe.seen.current.stats, null, "禁用必须清空展示"); + } finally { + await probe.unmount(); + } +}); + +test("刷新失败保留上次读数,成功返回空历史时才清空", async () => { + clearConversationStatsCache(); + let fail = false; + let empty = false; + const original = createFakeHost([turnEvents(1)]).host; + const host = { + loadWindow: async (...args) => { + if (fail) throw new Error("测试刷新失败"); + const payload = await original.loadWindow(...args); + return empty ? { ...payload, eventsJson: "[]" } : payload; + }, + }; + const probe = mountHook({ conversationId: "refresh-error", host, liveEvents: [], enabled: true }); + const warn = console.warn; + try { + await probe.mount(); + await drain(); + fail = true; + console.warn = () => {}; + await probe.update({ authoritativeRevision: 1 }); + await drain(); + assert.equal(probe.seen.current.stats.turns, 1, "请求失败不能清空旧读数"); + fail = false; + empty = true; + await probe.update({ authoritativeRevision: 2 }); + await drain(); + assert.equal(probe.seen.current.stats, null, "成功的空历史不能残留旧读数"); + } finally { + console.warn = warn; + await probe.unmount(); + } +}); + test("首窗读数立即可用,后台分页把更早的段补齐", async () => { clearConversationStatsCache(); const pages = [turnEvents(1), turnEvents(2), turnEvents(3)]; diff --git a/crates/agent-ui/src/components/chat/ConversationStatsBar.tsx b/crates/agent-ui/src/components/chat/ConversationStatsBar.tsx index 196d5e052..503d621e7 100644 --- a/crates/agent-ui/src/components/chat/ConversationStatsBar.tsx +++ b/crates/agent-ui/src/components/chat/ConversationStatsBar.tsx @@ -16,6 +16,7 @@ import { } from "../../lib/trajectory/stats"; import { ConfirmActionPopover } from "../ui/confirm-action-popover"; import { LabelTooltip } from "../ui/label-tooltip"; +import { RollingStatText } from "./RollingStatText"; /** 心跳与 hook 的重建节流同频(docs/design/composer-context-stats-bar.md §4.2)。 */ const HEARTBEAT_MS = 1_000; @@ -374,7 +375,14 @@ export function ConversationStatsBar(props: { {index > 0 ? ( ))} diff --git a/crates/agent-ui/src/components/chat/RollingStatText.tsx b/crates/agent-ui/src/components/chat/RollingStatText.tsx new file mode 100644 index 000000000..86d3628e9 --- /dev/null +++ b/crates/agent-ui/src/components/chat/RollingStatText.tsx @@ -0,0 +1,46 @@ +import { useEffect, useState } from "react"; + +const DIGITS = Array.from({ length: 10 }, (_, digit) => digit); + +/** 单位、标点和标签留在原位;每一位数字独立滚动,右侧位数身份保持稳定。 */ +function RollingDigit({ digit }: { digit: string }) { + const [ready, setReady] = useState(false); + useEffect(() => setReady(true), []); + return ( +