import { fireEvent } from "@testing-library/react"; import { afterEach, describe, it } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import React from "react"; import { ChatPanel } from "../src/components/ChatPanel"; import { TooltipProvider } from "../src/components/ui/tooltip"; import { changeText, click, pressKey, render, screen, waitFor } from "./render"; const originalFetch = globalThis.fetch; let restoreScrollBox: (() => void) | null = null; type ChatPanelProps = React.ComponentProps; type StreamEvent = { event: string; data: string }; afterEach(() => { restoreScrollBox?.(); restoreScrollBox = null; globalThis.fetch = originalFetch; }); describe("ChatPanel auto scroll", () => { it("scrolls to the bottom on existing messages and after a streamed reply when follow is active", async () => { installChatFetch([ { event: "text_delta", data: "第一段回复" }, { event: "done", data: "{}" }, ]); const scrollBox = installChatScrollerLayoutMock({ clientHeight: 320, scrollHeight: 960, scrollTop: 0, }); renderChatPanel({ initialMessages: [ { id: "u1", role: "user", content: "第一条", tools: [] }, { id: "a1", role: "assistant", content: "第一条回复", tools: [] }, ], }); const scroller = chatScroller(); await waitFor(() => assert.equal(scroller.scrollTop, 640)); scrollBox.update({ scrollHeight: 1280 }); await changeText(screen.getByPlaceholderText(/写下想法/), "继续"); await pressKey(screen.getByPlaceholderText(/写下想法/), "Enter", { metaKey: true }); await waitFor(() => assert.equal(scroller.scrollTop, 960)); assert.ok(scrollBox.calls.length >= 2); const assistantBubbles = screen.getAllByLabelText("助手气泡"); assert.match(assistantBubbles.at(-1)?.textContent ?? "", /第一段回复/); assert.equal(screen.queryByRole("button", { name: "回到底部" }), null); }); it("pauses follow when the user scrolls up and restores with the arrow button", async () => { const promptStream = createControlledSseStream(); installChatFetch(promptStream.stream); const scrollBox = installChatScrollerLayoutMock({ clientHeight: 300, scrollHeight: 1000, scrollTop: 700, }); renderChatPanel({ initialMessages: [ { id: "u1", role: "user", content: "历史问题", tools: [] }, { id: "a1", role: "assistant", content: "历史回答", tools: [] }, ], }); const scroller = chatScroller(); scrollBox.update({ scrollHeight: 1300 }); await changeText(screen.getByPlaceholderText(/写下想法/), "继续展开"); await pressKey(screen.getByPlaceholderText(/写下想法/), "Enter", { metaKey: true }); await waitFor(() => assert.equal(scroller.scrollTop, 1000)); scroller.scrollTop = 120; fireEvent.scroll(scroller); const returnButton = await screen.findByRole("button", { name: "回到底部" }); assert.equal(returnButton.textContent, ""); scrollBox.update({ scrollHeight: 1500 }); promptStream.send({ event: "text_delta", data: "新的长回复" }); promptStream.send({ event: "done", data: "{}" }); promptStream.close(); await waitFor(() => { const assistantBubbles = screen.getAllByLabelText("助手气泡"); assert.match(assistantBubbles.at(-1)?.textContent ?? "", /新的长回复/); }); assert.equal(scroller.scrollTop, 120); assert.ok(screen.getByRole("button", { name: "回到底部" })); await click(screen.getByRole("button", { name: "回到底部" })); await waitFor(() => assert.equal(scroller.scrollTop, 1200)); assert.equal(screen.queryByRole("button", { name: "回到底部" }), null); }); it("restores follow when the user manually scrolls back near the bottom", async () => { const promptStream = createControlledSseStream(); installChatFetch(promptStream.stream); const scrollBox = installChatScrollerLayoutMock({ clientHeight: 300, scrollHeight: 1300, scrollTop: 1000, }); renderChatPanel({ initialMessages: [ { id: "u1", role: "user", content: "历史问题", tools: [] }, { id: "a1", role: "assistant", content: "历史回答", tools: [] }, ], }); const scroller = chatScroller(); await changeText(screen.getByPlaceholderText(/写下想法/), "继续展开"); await pressKey(screen.getByPlaceholderText(/写下想法/), "Enter", { metaKey: true }); await waitFor(() => assert.equal(scroller.scrollTop, 1000)); scroller.scrollTop = 200; fireEvent.scroll(scroller); assert.ok(await screen.findByRole("button", { name: "回到底部" })); scroller.scrollTop = 960; fireEvent.scroll(scroller); await waitFor(() => assert.equal(screen.queryByRole("button", { name: "回到底部" }), null)); scrollBox.update({ scrollHeight: 1600 }); promptStream.send({ event: "text_delta", data: "继续增长的回复" }); promptStream.close(); await waitFor(() => assert.equal(scroller.scrollTop, 1300)); }); it("keeps the return-to-bottom control as a themed icon-only button", () => { const css = readFileSync(resolve(import.meta.dirname, "../src/index.css"), "utf8"); assert.match(css, /\.chat-input-area\s*\{[\s\S]*position:\s*relative/); assert.match(css, /\.chat-scroll-bottom\s*\{/); assert.match(css, /\.chat-scroll-bottom[\s\S]*justify-content:\s*center/); assert.match(css, /\.chat-scroll-bottom-btn\s*\{/); assert.match(css, /\.chat-scroll-bottom-btn[\s\S]*border-radius:\s*999px/); assert.match(css, /\.chat-scroll-bottom-btn[\s\S]*var\(--app-surface\)/); assert.match(css, /\.chat-scroll-bottom-btn[\s\S]*box-shadow/); assert.match(css, /\.chat-scroll-bottom-btn:hover\s*\{/); assert.match(css, /\.chat-scroll-bottom-btn:focus-visible\s*\{/); }); }); function renderChatPanel(props: Partial = {}) { return render( , ); } function chatScroller(): HTMLDivElement { const element = document.querySelector(".chat-messages"); assert.ok(element instanceof HTMLElement); return element as HTMLDivElement; } function installChatScrollerLayoutMock( initialMetrics: { clientHeight: number; scrollHeight: number; scrollTop: number }, ) { const metrics = { ...initialMetrics }; const calls: number[] = []; const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight"); const originalScrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight"); const originalScrollTop = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTop"); const originalScrollTo = HTMLElement.prototype.scrollTo as ((...args: unknown[]) => void) | undefined; const isChatScroller = (element: Element) => element instanceof HTMLElement && element.classList.contains("chat-messages"); Object.defineProperty(HTMLElement.prototype, "clientHeight", { configurable: true, get() { if (isChatScroller(this)) return metrics.clientHeight; return originalClientHeight?.get?.call(this) ?? 0; }, }); Object.defineProperty(HTMLElement.prototype, "scrollHeight", { configurable: true, get() { if (isChatScroller(this)) return metrics.scrollHeight; return originalScrollHeight?.get?.call(this) ?? 0; }, }); Object.defineProperty(HTMLElement.prototype, "scrollTop", { configurable: true, get() { if (isChatScroller(this)) return metrics.scrollTop; return originalScrollTop?.get?.call(this) ?? 0; }, set(value: number) { if (isChatScroller(this)) { metrics.scrollTop = value; return; } originalScrollTop?.set?.call(this, value); }, }); HTMLElement.prototype.scrollTo = function scrollTo(options?: ScrollToOptions | number, y?: number) { if (!isChatScroller(this)) { originalScrollTo?.call(this, options, y); return; } const top = typeof options === "number" ? y ?? options : options?.top ?? metrics.scrollTop; const nextTop = clampScrollTop(Number(top), metrics); calls.push(nextTop); metrics.scrollTop = nextTop; }; const restore = () => { restoreDescriptor(HTMLElement.prototype, "clientHeight", originalClientHeight); restoreDescriptor(HTMLElement.prototype, "scrollHeight", originalScrollHeight); restoreDescriptor(HTMLElement.prototype, "scrollTop", originalScrollTop); if (originalScrollTo) HTMLElement.prototype.scrollTo = originalScrollTo as typeof HTMLElement.prototype.scrollTo; else Reflect.deleteProperty(HTMLElement.prototype, "scrollTo"); }; restoreScrollBox = restore; return { calls, metrics, restore, update(nextMetrics: Partial) { Object.assign(metrics, nextMetrics); }, }; } function clampScrollTop( top: number, metrics: { clientHeight: number; scrollHeight: number; scrollTop: number }, ) { if (!Number.isFinite(top)) return metrics.scrollTop; return Math.max(0, Math.min(top, Math.max(0, metrics.scrollHeight - metrics.clientHeight))); } function restoreDescriptor( prototype: HTMLElement, key: "clientHeight" | "scrollHeight" | "scrollTop", descriptor: PropertyDescriptor | undefined, ) { if (descriptor) Object.defineProperty(prototype, key, descriptor); else Reflect.deleteProperty(prototype, key); } function installChatFetch(eventsOrStream: StreamEvent[] | ReadableStream) { globalThis.fetch = (async (input) => { const url = typeof input === "string" ? input : input instanceof Request ? input.url : String(input); if (url.includes("/api/commands")) { return jsonResponse({ ok: true, items: [] }); } if (url.includes("/api/prompt")) { const body = Array.isArray(eventsOrStream) ? sseStream(eventsOrStream) : eventsOrStream; return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream" }, }); } return jsonResponse({ ok: true }); }) as typeof fetch; } function jsonResponse(body: unknown) { return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" }, }); } function sseStream(events: StreamEvent[]) { const encoder = new TextEncoder(); return new ReadableStream({ start(controller) { for (const item of events) { controller.enqueue(encoder.encode(`event: ${item.event}\ndata: ${item.data}\n\n`)); } controller.close(); }, }); } function createControlledSseStream() { const encoder = new TextEncoder(); let controller: ReadableStreamDefaultController | null = null; return { stream: new ReadableStream({ start(activeController) { controller = activeController; }, }), send(item: StreamEvent) { assert.ok(controller); controller.enqueue(encoder.encode(`event: ${item.event}\ndata: ${item.data}\n\n`)); }, close() { assert.ok(controller); controller.close(); }, }; }