fix(agent): 切回看过的会话直接铺出历史,长内容不再从顶滚到底
- 事件流按会话缓存最近 30 条会话的已读事件,切回时直接用缓存渲染,再从最后一个 seq 之后补读 - 消息区跟随:一次多出一屏以上直接跳到底,流式小段增长才平滑跟随 (原来只有第一次 ResizeObserver 回调算首屏,事件补读回来时整段平滑滚动) Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
9f499ac7c2
commit
93a5341688
3 files changed
+78
-10
No files matched your search
@@ -1,7 +1,9 @@
|
|||||||
|
import type { AgentRunEvent } from "@umt/contracts";
|
||||||
import { createElement } from "react";
|
import { createElement } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { runGroup } from "~/api/agent.ts";
|
import { runGroup } from "~/api/agent.ts";
|
||||||
|
import { followBehavior } from "~/components/agentui/message-scroller.tsx";
|
||||||
import {
|
import {
|
||||||
agentListHref,
|
agentListHref,
|
||||||
agentRunHref,
|
agentRunHref,
|
||||||
@@ -16,6 +18,7 @@ import {
|
|||||||
} from "./agent-nav.ts";
|
} from "./agent-nav.ts";
|
||||||
import { CompactPager } from "./agent-session-list.tsx";
|
import { CompactPager } from "./agent-session-list.tsx";
|
||||||
import { isAgentPage, outletKey } from "./agent-store.ts";
|
import { isAgentPage, outletKey } from "./agent-store.ts";
|
||||||
|
import { cachedRunEvents, cacheRunEvents } from "./use-agent-stream.ts";
|
||||||
|
|
||||||
// 智能体页面切换的状态逻辑:整页重挂载 key、分栏与 URL、草稿、滚动策略、快捷键在智能体页面的行为。
|
// 智能体页面切换的状态逻辑:整页重挂载 key、分栏与 URL、草稿、滚动策略、快捷键在智能体页面的行为。
|
||||||
|
|
||||||
@@ -119,3 +122,31 @@ describe("输入草稿", () => {
|
|||||||
expect(readDraft(undefined)).toBe("");
|
expect(readDraft(undefined)).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("消息区滚动策略", () => {
|
||||||
|
it("流式的小段增长平滑跟随", () => {
|
||||||
|
expect(followBehavior({ gap: 48, viewport: 600, reduce: false })).toBe("smooth");
|
||||||
|
});
|
||||||
|
it("一次多出一屏以上(打开 / 切回会话铺开历史)直接跳到底", () => {
|
||||||
|
expect(followBehavior({ gap: 1400, viewport: 600, reduce: false })).toBe("auto");
|
||||||
|
});
|
||||||
|
it("减少动态效果时一律直接跳", () => {
|
||||||
|
expect(followBehavior({ gap: 48, viewport: 600, reduce: true })).toBe("auto");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("切回看过的会话:事件缓存", () => {
|
||||||
|
const ev = (seq: number) => ({ seq, at: "2026-10-06T00:00:00Z", type: "text_delta", delta: "x" }) as AgentRunEvent;
|
||||||
|
it("记住最近读到的事件,切回时直接可用", () => {
|
||||||
|
cacheRunEvents("live:a", [ev(0), ev(1)]);
|
||||||
|
expect(cachedRunEvents("live:a").map((e) => e.seq)).toEqual([0, 1]);
|
||||||
|
expect(cachedRunEvents("mock:a")).toEqual([]);
|
||||||
|
});
|
||||||
|
it("空列表不覆盖已有缓存;超过 30 条会话丢掉最早看的", () => {
|
||||||
|
cacheRunEvents("live:a", []);
|
||||||
|
expect(cachedRunEvents("live:a")).toHaveLength(2);
|
||||||
|
for (let i = 0; i < 30; i++) cacheRunEvents(`live:n${i}`, [ev(i)]);
|
||||||
|
expect(cachedRunEvents("live:a")).toEqual([]);
|
||||||
|
expect(cachedRunEvents("live:n29")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -284,20 +284,49 @@ export function foldEvents(events: AgentRunEvent[]): StreamItem[] {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最近看过的会话的事件(模块级,最多 30 条会话):切回看过的会话时直接用已读到的事件铺出来,
|
||||||
|
* 再从最后一个 seq 之后补读;不再先空一帧、再整段补读回来。
|
||||||
|
*/
|
||||||
|
const EVENT_CACHE_MAX = 30;
|
||||||
|
const eventCache = new Map<string, AgentRunEvent[]>();
|
||||||
|
|
||||||
|
export function cachedRunEvents(key: string): AgentRunEvent[] {
|
||||||
|
return eventCache.get(key) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cacheRunEvents(key: string, events: AgentRunEvent[]): void {
|
||||||
|
if (!key || !events.length) return;
|
||||||
|
eventCache.delete(key);
|
||||||
|
eventCache.set(key, events);
|
||||||
|
while (eventCache.size > EVENT_CACHE_MAX) {
|
||||||
|
const oldest = eventCache.keys().next().value;
|
||||||
|
if (oldest === undefined) break;
|
||||||
|
eventCache.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSeqOf = (events: AgentRunEvent[]) => events[events.length - 1]?.seq ?? -1;
|
||||||
|
|
||||||
export function useAgentStream(runId: string | undefined, active: boolean): AgentStreamState {
|
export function useAgentStream(runId: string | undefined, active: boolean): AgentStreamState {
|
||||||
const [events, setEvents] = useState<AgentRunEvent[]>([]);
|
// 演示数据与真实会话分开缓存
|
||||||
|
const cacheKey = runId ? `${agentMockEnabled() ? "mock" : "live"}:${runId}` : "";
|
||||||
|
const [events, setEvents] = useState<AgentRunEvent[]>(() => cachedRunEvents(cacheKey));
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [connected, setConnected] = useState<AgentStreamState["connected"]>("idle");
|
const [connected, setConnected] = useState<AgentStreamState["connected"]>("idle");
|
||||||
const [nonce, setNonce] = useState(0);
|
const [nonce, setNonce] = useState(0);
|
||||||
const lastSeq = useRef(-1);
|
const lastSeq = useRef(lastSeqOf(events));
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 换 run 时重置
|
// biome-ignore lint/correctness/useExhaustiveDependencies: 换 run 时重置(有缓存就从缓存接着读)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEvents([]);
|
const cached = cachedRunEvents(cacheKey);
|
||||||
|
setEvents(cached);
|
||||||
setError(null);
|
setError(null);
|
||||||
lastSeq.current = -1;
|
lastSeq.current = lastSeqOf(cached);
|
||||||
}, [runId]);
|
}, [runId]);
|
||||||
|
|
||||||
|
useEffect(() => cacheRunEvents(cacheKey, events), [cacheKey, events]);
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: nonce 变化 = 手动重试,重新建立补读
|
// biome-ignore lint/correctness/useExhaustiveDependencies: nonce 变化 = 手动重试,重新建立补读
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!runId) return;
|
if (!runId) return;
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ export interface MessageScrollerProps extends Omit<ComponentPropsWithRef<"div">,
|
|||||||
jumpLabel?: string;
|
jumpLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跟随内容增长时的滚动方式:流式输出的小段增长平滑跟随;一次多出一屏以上(打开 / 切回会话时历史铺开、
|
||||||
|
* 补读回一大段)直接跳到底,不播一段从上滚到底的动画;减少动态效果时一律直接跳。
|
||||||
|
*/
|
||||||
|
export function followBehavior(o: { gap: number; viewport: number; reduce: boolean }): ScrollBehavior {
|
||||||
|
return o.reduce || o.gap > o.viewport ? "auto" : "smooth";
|
||||||
|
}
|
||||||
|
|
||||||
function layoutMs(el: Element | null): number {
|
function layoutMs(el: Element | null): number {
|
||||||
if (!el) return 0;
|
if (!el) return 0;
|
||||||
const v = getComputedStyle(el).getPropertyValue("--duration-layout").trim();
|
const v = getComputedStyle(el).getPropertyValue("--duration-layout").trim();
|
||||||
@@ -113,12 +121,12 @@ export function MessageScroller({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const content = contentRef.current;
|
const content = contentRef.current;
|
||||||
if (!content || typeof ResizeObserver === "undefined") return;
|
if (!content || typeof ResizeObserver === "undefined") return;
|
||||||
// 首次铺满内容(回放历史)直接跳到底,之后的增长才平滑跟随
|
// 首次铺满内容、一次多出一屏以上直接跳到底,流式的小段增长才平滑跟随
|
||||||
let first = true;
|
|
||||||
const ro = new ResizeObserver(() => {
|
const ro = new ResizeObserver(() => {
|
||||||
if (!followOutput || !followingRef.current) return;
|
const vp = viewportRef.current;
|
||||||
scrollToEnd(reduce || first ? "auto" : "smooth");
|
if (!vp || !followOutput || !followingRef.current) return;
|
||||||
first = false;
|
const gap = vp.scrollHeight - vp.scrollTop - vp.clientHeight;
|
||||||
|
scrollToEnd(followBehavior({ gap, viewport: vp.clientHeight, reduce }));
|
||||||
});
|
});
|
||||||
ro.observe(content);
|
ro.observe(content);
|
||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
|
|||||||
Reference in new issue
Block a user