дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
fix(agents): keep following a conversation's end unless the user scrolls away (#2269)
Only user scrolling (wheel, touch, keys, the scrollbar, Find) leaves the end of an agent conversation; offsets WebKit moves back on its own while rows lay out no longer stop it following and painting its history.
Цей коміт міститься в:
1 джерело
0e81c6aeb1
коміт
92b527ab0e
3 files changed
+59
-10
No files matched your search
@@ -491,17 +491,23 @@ const agentTabs: Scenario = {
|
||||
).__openideaPerf.runAction("agents.openSampleConversations", { tasks: 2, turns: 150 }),
|
||||
);
|
||||
const shown = (n: number) => `[data-editor-focus] span[title="Sample task ${n}"]`;
|
||||
// Each conversation shown until its earlier entries, painted in background batches, are all in.
|
||||
// Both conversations get their earlier entries painted in background batches, also the
|
||||
// first one, hidden behind the second as soon as it opened.
|
||||
await h.page.waitForFunction(
|
||||
() =>
|
||||
[...document.querySelectorAll("[data-message]")]
|
||||
.map((m) => m.parentElement!)
|
||||
.filter((stream, i, all) => all.indexOf(stream) === i && stream.textContent!.includes("Turn 0:")).length ===
|
||||
2,
|
||||
undefined,
|
||||
{ timeout: 30_000, polling: 200 },
|
||||
);
|
||||
// Rows painted while hidden are laid out the first time their conversation shows.
|
||||
for (const n of [1, 2]) {
|
||||
await h.page.getByRole("tab", { name: `Sample task ${n}` }).click();
|
||||
await h.page.waitForFunction(
|
||||
(selector) =>
|
||||
document.querySelector(selector)?.closest("[data-editor-focus]")?.textContent?.includes("Turn 0:"),
|
||||
shown(n),
|
||||
{ timeout: 30_000, polling: 200 },
|
||||
);
|
||||
await h.page.locator(shown(n)).waitFor();
|
||||
await h.page.waitForTimeout(300);
|
||||
}
|
||||
await h.page.waitForTimeout(500);
|
||||
// WebKit restyles a hidden conversation in full when it is shown again (Chromium keeps its
|
||||
// style): what bounds that there is the history's DOM, and rows skipping rendering off screen.
|
||||
const { conversationNodes, rowsAlwaysRendered } = await h.page.evaluate((selector) => {
|
||||
@@ -540,6 +546,8 @@ const agentTabs: Scenario = {
|
||||
const row = [...stream.children].find((r) => r.getBoundingClientRect().bottom > top + 200)!;
|
||||
const from = row.getBoundingClientRect().top;
|
||||
const scrolled = stream.scrollTop;
|
||||
// A wheel step: the user scrolls, so the conversation stops following its end.
|
||||
stream.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -120 }));
|
||||
stream.scrollTop -= 120;
|
||||
const by = scrolled - stream.scrollTop;
|
||||
await frame();
|
||||
|
||||
@@ -553,6 +553,12 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
// Keep a process run intact so prepending never replaces the anchored row.
|
||||
while (entries && start > 0 && isProcessEntry(entries[start]!) && isProcessEntry(entries[start - 1]!)) start--;
|
||||
const dragging = useRef(false);
|
||||
// When the user last scrolled the conversation (wheel, touch, keys, the scrollbar, Find):
|
||||
// only that leaves the end. WebKit also moves the offset back on its own while rows lay out.
|
||||
const userScrolled = useRef(-Infinity);
|
||||
const markUserScroll = useCallback(() => {
|
||||
userScrolled.current = performance.now();
|
||||
}, []);
|
||||
// Earlier entries are prepended above the view: remember where the first painted row is
|
||||
// so the content stays put and the scrollbar doesn't jump under the pointer.
|
||||
const anchor = useRef<{ el: Element; top: number } | null>(null);
|
||||
@@ -701,7 +707,13 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
[task, turns, replies, times, lastIndex, busy, onOpenSubagent, onDiagramDrawn],
|
||||
);
|
||||
return (
|
||||
<div className={styles.streamArea}>
|
||||
<div
|
||||
className={styles.streamArea}
|
||||
onWheel={markUserScroll}
|
||||
onTouchMove={markUserScroll}
|
||||
onKeyDown={markUserScroll}
|
||||
onPointerDown={markUserScroll}
|
||||
>
|
||||
{finding && <FindBar streamRef={ref} focus={findFocus} onClose={onCloseFind} />}
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -710,7 +722,12 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
tabIndex={-1}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
position.current = { top: el.scrollTop, stick: el.scrollHeight - el.scrollTop - el.clientHeight < 40 };
|
||||
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
const byUser = dragging.current || performance.now() - userScrolled.current < 500;
|
||||
const stick = atEnd || (position.current.stick && !byUser);
|
||||
// Still following: back to the end the offset was moved away from.
|
||||
if (stick && !atEnd) el.scrollTop = el.scrollHeight;
|
||||
position.current = { top: el.scrollTop, stick };
|
||||
positions.set(key, position.current);
|
||||
setAway(!position.current.stick);
|
||||
// Reached the top of what is painted: fetch the earlier entries, but only
|
||||
|
||||
@@ -305,6 +305,7 @@ describe("scrolling", () => {
|
||||
let height = 1000;
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
fireEvent.wheel(el);
|
||||
el.scrollTop = 100;
|
||||
fireEvent.scroll(el);
|
||||
expect(screen.getByRole("button", { name: "Jump to Latest" })).toBeInTheDocument();
|
||||
@@ -354,12 +355,34 @@ describe("scrolling", () => {
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("keeps following the end when the offset moves back without the user scrolling", async () => {
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
expect(await screen.findByText("Answer A")).toBeInTheDocument();
|
||||
const el = stream();
|
||||
let height = 1000;
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
// The conversation grew after it was scrolled to the end, or WebKit moved the offset back.
|
||||
height = 1300;
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
expect(el.scrollTop).toBe(1300);
|
||||
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
|
||||
// Scrolling up with the wheel leaves the end.
|
||||
fireEvent.wheel(el);
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
expect(el.scrollTop).toBe(600);
|
||||
expect(await screen.findByRole("button", { name: "Jump to Latest" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers one click back to the latest message after scrolling up", async () => {
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
expect(await screen.findByText("Answer A")).toBeInTheDocument();
|
||||
const el = stream();
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1000 });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
fireEvent.wheel(el);
|
||||
el.scrollTop = 100;
|
||||
fireEvent.scroll(el);
|
||||
const jump = await screen.findByRole("button", { name: "Jump to Latest" });
|
||||
@@ -784,6 +807,7 @@ describe("session state across tasks and hosts", () => {
|
||||
});
|
||||
const view = render(<SessionEditor input={input("A")} />);
|
||||
await screen.findByText("Answer A");
|
||||
fireEvent.wheel(stream());
|
||||
stream().scrollTop = stick ? height - viewport : 230;
|
||||
fireEvent.scroll(stream());
|
||||
expect(useAgents.getState().scrollPositions.get("A")).toEqual({ top: stick ? 1600 : 230, stick });
|
||||
|
||||
Reference in new issue
Заблокувати користувача