mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-10 02:31:27 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd48f79265 |
No files matched your search
@@ -66,6 +66,76 @@ async function openHistory(page: Page) {
|
||||
return stream;
|
||||
}
|
||||
|
||||
for (const placement of ["editor", "toolWindow"] as const) {
|
||||
test(`lets small upward wheel steps leave the end while replies stream in the ${placement}`, async ({ page }) => {
|
||||
const stream = await openHistory(page);
|
||||
await page.evaluate(
|
||||
async ({ id, placement }) => {
|
||||
const path = "/src/features/agents/store.ts";
|
||||
const store = await import(path);
|
||||
const state = store.useAgents.getState();
|
||||
store.useAgents.setState({
|
||||
tasks: new Map(state.tasks).set(id, { ...state.tasks.get(id)!, status: "running" }),
|
||||
});
|
||||
if (placement === "toolWindow") store.dockTask(id);
|
||||
},
|
||||
{ id: task.id, placement },
|
||||
);
|
||||
await expect(stream.getByText("Working…", { exact: true })).toBeVisible();
|
||||
await expect(stream.locator(":scope > *")).toHaveCount(80 * 3 + 1);
|
||||
await expect.poll(() => stream.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop)).toBeLessThan(2);
|
||||
const timer = await page.evaluate(async (id) => {
|
||||
const path = "/src/features/agents/store.ts";
|
||||
const store = await import(path);
|
||||
const el = [...document.querySelectorAll<HTMLElement>("[class*='_stream_']")].find((el) => el.offsetHeight)!;
|
||||
el.addEventListener(
|
||||
"wheel",
|
||||
() => {
|
||||
el.dataset.streamWheelStart = String(el.scrollTop);
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
);
|
||||
let chunks = 0;
|
||||
return window.setInterval(() => {
|
||||
const state = store.useAgents.getState();
|
||||
const entries = state.entries.get(id)!.slice();
|
||||
const last = entries.at(-1)!;
|
||||
if (last.type !== "agent") throw new Error("Expected a streaming reply");
|
||||
entries[entries.length - 1] = { ...last, text: `${last.text}\n\nStreaming chunk ${++chunks}` };
|
||||
store.useAgents.setState({ entries: new Map(state.entries).set(id, entries) });
|
||||
el.dataset.streamChunks = String(chunks);
|
||||
}, 40);
|
||||
}, task.id);
|
||||
const chunks = async () => Number(await stream.getAttribute("data-stream-chunks"));
|
||||
try {
|
||||
await stream.hover();
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const previous = await chunks();
|
||||
await page.mouse.wheel(0, -12);
|
||||
await expect.poll(chunks).toBeGreaterThan(previous);
|
||||
}
|
||||
const top = await stream.evaluate((el) => el.scrollTop);
|
||||
expect(Number(await stream.getAttribute("data-stream-wheel-start")) - top).toBeGreaterThan(80);
|
||||
const previous = await chunks();
|
||||
await expect.poll(chunks).toBeGreaterThan(previous + 3);
|
||||
expect(Math.abs((await stream.evaluate((el) => el.scrollTop)) - top)).toBeLessThanOrEqual(1);
|
||||
const jump = page.getByRole("button", { name: "Jump to Latest" });
|
||||
await expect(jump).toBeVisible();
|
||||
await jump.click();
|
||||
await expect
|
||||
.poll(() => stream.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
|
||||
.toBeLessThan(2);
|
||||
const resumed = await chunks();
|
||||
await expect.poll(chunks).toBeGreaterThan(resumed + 3);
|
||||
await expect
|
||||
.poll(() => stream.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
|
||||
.toBeLessThan(2);
|
||||
} finally {
|
||||
await page.evaluate((timer) => window.clearInterval(timer), timer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const selected of [false, true]) {
|
||||
test(`keeps long-chat scroll geometry stable while jumping through history (selected: ${selected})`, async ({
|
||||
page,
|
||||
|
||||
@@ -541,6 +541,15 @@ interface StreamProps {
|
||||
/** Entries rendered at a time when a conversation opens at its end. */
|
||||
const ENTRY_BATCH = 40;
|
||||
|
||||
function scrollsInside(target: EventTarget | null, root: HTMLElement): boolean {
|
||||
for (let el = target instanceof Element ? target : null; el && el !== root; el = el.parentElement) {
|
||||
if (el.scrollTop <= 0) continue;
|
||||
const overflow = getComputedStyle(el).overflowY;
|
||||
if (overflow === "auto" || overflow === "scroll") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, findFocus, onCloseFind }: StreamProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useSelectionBoundary(ref);
|
||||
@@ -567,6 +576,21 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
const markUserScroll = useCallback(() => {
|
||||
userScrolled.current = performance.now();
|
||||
}, []);
|
||||
const touchY = useRef<number | null>(null);
|
||||
const scrollingUp = useRef(false);
|
||||
// Release before the browser scrolls: a streamed chunk can arrive before its scroll event.
|
||||
const stopFollowing = useCallback(
|
||||
(target: EventTarget | null) => {
|
||||
const el = ref.current;
|
||||
if (!el || el.scrollHeight <= el.clientHeight || scrollsInside(target, el)) return;
|
||||
scrollingUp.current = true;
|
||||
if (!position.current.stick) return;
|
||||
position.current = { top: el.scrollTop, stick: false };
|
||||
positions.set(key, position.current);
|
||||
setAway(true);
|
||||
},
|
||||
[key, positions],
|
||||
);
|
||||
// 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);
|
||||
@@ -622,7 +646,7 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
/** A diagram that drew after the last streamed chunk grows the conversation: keep following. */
|
||||
const onDiagramDrawn = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (el && position.current.stick) el.scrollTop = el.scrollHeight;
|
||||
if (el && position.current.stick && !dragging.current) el.scrollTop = el.scrollHeight;
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
@@ -630,6 +654,7 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
const truncated = entries.length < previousLength.current;
|
||||
previousLength.current = entries.length;
|
||||
if ((truncated || entries.length === 0) && el.scrollHeight - el.scrollTop - el.clientHeight < 40) {
|
||||
scrollingUp.current = false;
|
||||
position.current = { top: el.scrollTop, stick: true };
|
||||
positions.set(key, position.current);
|
||||
setAway(false);
|
||||
@@ -638,8 +663,9 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || entries === null || !visible) return;
|
||||
if (position.current.stick) el.scrollTop = el.scrollHeight;
|
||||
else if (!restored.current) el.scrollTop = position.current.top;
|
||||
if (position.current.stick) {
|
||||
if (!dragging.current) el.scrollTop = el.scrollHeight;
|
||||
} else if (!restored.current) el.scrollTop = position.current.top;
|
||||
restored.current = true;
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
@@ -656,7 +682,7 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (position.current.stick) el.scrollTop = el.scrollHeight;
|
||||
if (position.current.stick && !dragging.current) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => {
|
||||
@@ -668,7 +694,8 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
const jumpToLatest = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
position.current = { top: el.scrollHeight, stick: true };
|
||||
scrollingUp.current = false;
|
||||
position.current = { top: el.scrollTop, stick: true };
|
||||
if (typeof el.scrollTo === "function") el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
else el.scrollTop = el.scrollHeight;
|
||||
setAway(false);
|
||||
@@ -715,28 +742,52 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
[task, turns, replies, times, lastIndex, busy, onOpenSubagent, onDiagramDrawn],
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={styles.streamArea}
|
||||
onWheel={markUserScroll}
|
||||
onTouchMove={markUserScroll}
|
||||
onKeyDown={markUserScroll}
|
||||
onPointerDown={markUserScroll}
|
||||
>
|
||||
<div className={styles.streamArea}>
|
||||
{finding && <FindBar streamRef={ref} focus={findFocus} onClose={onCloseFind} />}
|
||||
<div
|
||||
ref={ref}
|
||||
// The conversation is read and copied like a document; focusable so ⌘F reaches it.
|
||||
className={clsx(styles.stream, selectableText)}
|
||||
tabIndex={-1}
|
||||
onWheel={(e) => {
|
||||
markUserScroll();
|
||||
if (e.deltaY < 0 && !e.ctrlKey && !e.defaultPrevented) stopFollowing(e.target);
|
||||
else if (e.deltaY > 0 && !e.ctrlKey && !e.defaultPrevented) scrollingUp.current = false;
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
touchY.current = e.touches.length === 1 ? e.touches[0]!.clientY : null;
|
||||
}}
|
||||
onTouchMove={(e) => {
|
||||
markUserScroll();
|
||||
const y = e.touches.length === 1 ? e.touches[0]!.clientY : null;
|
||||
if (y !== null && touchY.current !== null && y > touchY.current) stopFollowing(e.target);
|
||||
else if (y !== null && touchY.current !== null && y < touchY.current) scrollingUp.current = false;
|
||||
touchY.current = y;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
markUserScroll();
|
||||
if (e.defaultPrevented || (e.target as Element).closest("input, textarea, [contenteditable]")) return;
|
||||
if (e.key === "ArrowUp" || e.key === "PageUp" || e.key === "Home" || (e.key === " " && e.shiftKey))
|
||||
stopFollowing(e.target);
|
||||
else if (e.key === "ArrowDown" || e.key === "PageDown" || e.key === "End" || (e.key === " " && !e.shiftKey))
|
||||
scrollingUp.current = false;
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
markUserScroll();
|
||||
scrollingUp.current = false;
|
||||
}}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
const laidOut = el.scrollHeight !== seenHeight.current;
|
||||
seenHeight.current = el.scrollHeight;
|
||||
const byUser = dragging.current || performance.now() - userScrolled.current < 500 || !laidOut;
|
||||
const stick = atEnd || (position.current.stick && !byUser);
|
||||
const movedDown = el.scrollTop > position.current.top;
|
||||
const stayedAtEnd = position.current.stick && el.scrollTop === position.current.top;
|
||||
const stick =
|
||||
(atEnd && !scrollingUp.current && (movedDown || stayedAtEnd)) || (position.current.stick && !byUser);
|
||||
// Still following: back to the end the offset was moved away from.
|
||||
if (stick && !atEnd) el.scrollTop = el.scrollHeight;
|
||||
if (stick && !atEnd && !dragging.current) el.scrollTop = el.scrollHeight;
|
||||
position.current = { top: el.scrollTop, stick };
|
||||
positions.set(key, position.current);
|
||||
setAway(!position.current.stick);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import type { AgentEntry, AgentTask } from "@/bindings";
|
||||
|
||||
@@ -73,7 +73,7 @@ it("redraws a diagram when the theme changes", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("follows the end when a diagram draws after the last update", async () => {
|
||||
it.each([false, true])("preserves scroll ownership when a diagram finishes (away: %s)", async (away) => {
|
||||
// The diagram is still rendering when the conversation arrives at its end.
|
||||
let draw!: () => void;
|
||||
mermaid.render.mockReset().mockImplementation(
|
||||
@@ -94,8 +94,16 @@ it("follows the end when a diagram draws after the last update", async () => {
|
||||
const scroller = block.closest<HTMLElement>("div[tabindex='-1']")!;
|
||||
Object.defineProperty(scroller, "scrollHeight", { value: 1000, configurable: true });
|
||||
expect(scroller.scrollTop).toBe(0);
|
||||
if (away) {
|
||||
Object.defineProperty(scroller, "clientHeight", { value: 400, configurable: true });
|
||||
scroller.scrollTop = 600;
|
||||
fireEvent.scroll(scroller);
|
||||
fireEvent.wheel(scroller, { deltaY: -4 });
|
||||
scroller.scrollTop = 588;
|
||||
fireEvent.scroll(scroller);
|
||||
}
|
||||
// The draw lands after the last streamed chunk: the viewport must follow it.
|
||||
await act(async () => draw());
|
||||
await waitFor(() => expect(block).toHaveAttribute("data-mermaid-state", "ok"));
|
||||
expect(scroller.scrollTop).toBe(1000);
|
||||
expect(scroller.scrollTop).toBe(away ? 588 : 1000);
|
||||
});
|
||||
@@ -307,6 +307,89 @@ describe("message tags", () => {
|
||||
});
|
||||
|
||||
describe("scrolling", () => {
|
||||
it.each(["wheel", "touch", "ArrowUp", "PageUp", "Home", "Shift+Space"])(
|
||||
"releases following before an upward %s scroll, even within the bottom threshold",
|
||||
async (method) => {
|
||||
useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const el = stream();
|
||||
let height = 1000;
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
if (method === "wheel") fireEvent.wheel(el, { deltaY: -4 });
|
||||
if (method === "touch") {
|
||||
fireEvent.touchStart(el, { touches: [{ clientY: 100 }] });
|
||||
fireEvent.touchMove(el, { touches: [{ clientY: 110 }] });
|
||||
}
|
||||
if (["ArrowUp", "PageUp", "Home"].includes(method)) fireEvent.keyDown(el, { key: method });
|
||||
if (method === "Shift+Space") fireEvent.keyDown(el, { key: " ", shiftKey: true });
|
||||
// A chunk can land before the browser dispatches the scroll event.
|
||||
height = 1004;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]) }));
|
||||
expect(el.scrollTop).toBe(600);
|
||||
el.scrollTop = 588;
|
||||
fireEvent.scroll(el);
|
||||
// A queued event at the same offset must not re-enable following near the bottom.
|
||||
fireEvent.scroll(el);
|
||||
el.scrollTop = 592;
|
||||
fireEvent.scroll(el);
|
||||
height = 1008;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "More streaming" }]]]) }));
|
||||
expect(el.scrollTop).toBe(592);
|
||||
expect(screen.getByRole("button", { name: "Jump to Latest" })).toBeInTheDocument();
|
||||
// Scrolling down to the end resumes following.
|
||||
fireEvent.wheel(el, { deltaY: 12 });
|
||||
el.scrollTop = 608;
|
||||
fireEvent.scroll(el);
|
||||
height = 1100;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Latest" }]]]) }));
|
||||
expect(el.scrollTop).toBe(1100);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not move the viewport while the scrollbar is held during streaming", () => {
|
||||
useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const el = stream();
|
||||
let height = 1000;
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
fireEvent.pointerDown(el);
|
||||
height = 1100;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]) }));
|
||||
expect(el.scrollTop).toBe(600);
|
||||
el.scrollTop = 588;
|
||||
fireEvent.scroll(el);
|
||||
fireEvent.pointerUp(window);
|
||||
height = 1200;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "More streaming" }]]]) }));
|
||||
expect(el.scrollTop).toBe(588);
|
||||
});
|
||||
|
||||
it("keeps following when an inner tool output consumes the upward wheel", () => {
|
||||
const transcript: AgentEntry[] = [tool("live", { status: "running", output: "Tool output" })];
|
||||
useAgents.setState({ entries: new Map([["A", transcript]]) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const el = stream();
|
||||
const output = screen.getByText("Tool output");
|
||||
let height = 1000;
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
output.style.overflowY = "auto";
|
||||
output.scrollTop = 100;
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
fireEvent.wheel(output, { deltaY: -4 });
|
||||
height = 1100;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [...transcript, { type: "agent", text: "Streaming" }]]]) }));
|
||||
expect(el.scrollTop).toBe(1100);
|
||||
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
|
||||
});
|
||||
|
||||
it("hides Jump to Latest and follows new replies after retracting the conversation", async () => {
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const el = stream();
|
||||
|
||||
Reference in new issue
Block a user