feat(chat): 聊天滚动条美化 + Markdown 增强 — OverlayScrollbars/CodeHighlighter/代码复制/表格样式
This commit is contained in:
@@ -5,6 +5,14 @@ import { describe, expect, mock, test } from "bun:test";
|
||||
|
||||
import { useChatScroll } from "../../../src/web/consoles/workbench/components/chat/use-chat-scroll";
|
||||
|
||||
interface HookProps {
|
||||
loadingHistory: boolean;
|
||||
messages: UIMessage[];
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
status: string;
|
||||
viewportElement: HTMLDivElement | null;
|
||||
}
|
||||
|
||||
interface MockedElement {
|
||||
addEventListener: ReturnType<typeof mock>;
|
||||
clientHeight: number;
|
||||
@@ -14,12 +22,12 @@ interface MockedElement {
|
||||
scrollTop: number;
|
||||
}
|
||||
|
||||
function asHTMLElement(el: MockedElement): HTMLElement {
|
||||
return el as unknown as HTMLElement;
|
||||
function asRef(el: MockedElement): React.RefObject<HTMLDivElement | null> {
|
||||
return { current: el as unknown as HTMLDivElement };
|
||||
}
|
||||
|
||||
function asRef(el: MockedElement): React.RefObject<HTMLElement | null> {
|
||||
return { current: asHTMLElement(el) };
|
||||
function asViewport(el: MockedElement): HTMLDivElement {
|
||||
return el as unknown as HTMLDivElement;
|
||||
}
|
||||
|
||||
function createScrollElement(overrides: Partial<MockedElement> = {}): MockedElement {
|
||||
@@ -38,20 +46,14 @@ describe("useChatScroll", () => {
|
||||
test("在底部附近时自动滚动", () => {
|
||||
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 920 });
|
||||
const scrollRef = asRef(el);
|
||||
const viewportElement = asViewport(el);
|
||||
const messages: UIMessage[] = [{ id: "1", parts: [], role: "user" }] as UIMessage[];
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({
|
||||
messages,
|
||||
scrollRef,
|
||||
status,
|
||||
}: {
|
||||
messages: UIMessage[];
|
||||
scrollRef: React.RefObject<HTMLElement | null>;
|
||||
status: string;
|
||||
}) => useChatScroll({ messages, scrollRef, status }),
|
||||
({ loadingHistory, messages, scrollRef, status, viewportElement }: HookProps) =>
|
||||
useChatScroll({ loadingHistory, messages, scrollRef, status, viewportElement }),
|
||||
{
|
||||
initialProps: { messages, scrollRef, status: "streaming" },
|
||||
initialProps: { loadingHistory: false, messages, scrollRef, status: "streaming", viewportElement },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -60,7 +62,7 @@ describe("useChatScroll", () => {
|
||||
{ id: "2", parts: [], role: "assistant" },
|
||||
] as UIMessage[];
|
||||
|
||||
rerender({ messages: newMessages, scrollRef, status: "streaming" });
|
||||
rerender({ loadingHistory: false, messages: newMessages, scrollRef, status: "streaming", viewportElement });
|
||||
|
||||
expect(el.scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
@@ -68,20 +70,14 @@ describe("useChatScroll", () => {
|
||||
test("不在底部时不自动滚动", () => {
|
||||
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 200 });
|
||||
const scrollRef = asRef(el);
|
||||
const viewportElement = asViewport(el);
|
||||
const messages: UIMessage[] = [{ id: "1", parts: [], role: "user" }] as UIMessage[];
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({
|
||||
messages,
|
||||
scrollRef,
|
||||
status,
|
||||
}: {
|
||||
messages: UIMessage[];
|
||||
scrollRef: React.RefObject<HTMLElement | null>;
|
||||
status: string;
|
||||
}) => useChatScroll({ messages, scrollRef, status }),
|
||||
({ loadingHistory, messages, scrollRef, status, viewportElement }: HookProps) =>
|
||||
useChatScroll({ loadingHistory, messages, scrollRef, status, viewportElement }),
|
||||
{
|
||||
initialProps: { messages, scrollRef, status: "streaming" },
|
||||
initialProps: { loadingHistory: false, messages, scrollRef, status: "streaming", viewportElement },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -100,32 +96,36 @@ describe("useChatScroll", () => {
|
||||
{ id: "2", parts: [], role: "assistant" },
|
||||
] as UIMessage[];
|
||||
|
||||
rerender({ messages: newMessages, scrollRef, status: "streaming" });
|
||||
rerender({ loadingHistory: false, messages: newMessages, scrollRef, status: "streaming", viewportElement });
|
||||
|
||||
const scrollToCalls = el.scrollTo.mock.calls as unknown[][];
|
||||
expect(scrollToCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
test("scrollToBottom 使用 smooth 滚动", () => {
|
||||
test("scrollToBottom 使用 instant 滚动", () => {
|
||||
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 200 });
|
||||
const scrollRef = asRef(el);
|
||||
const viewportElement = asViewport(el);
|
||||
const messages: UIMessage[] = [];
|
||||
|
||||
const { result } = renderHook(() => useChatScroll({ messages, scrollRef, status: "ready" }));
|
||||
const { result } = renderHook(() => useChatScroll({ messages, scrollRef, status: "ready", viewportElement }));
|
||||
|
||||
act(() => {
|
||||
result.current.scrollToBottom();
|
||||
});
|
||||
|
||||
expect(el.scrollTo).toHaveBeenCalledWith({ behavior: "smooth", top: 1000 });
|
||||
expect(el.scrollTo).toHaveBeenCalledWith({ behavior: "instant", top: 1000 });
|
||||
});
|
||||
|
||||
test("流式时使用 instant 滚动", () => {
|
||||
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 920 });
|
||||
const scrollRef = asRef(el);
|
||||
const viewportElement = asViewport(el);
|
||||
const messages: UIMessage[] = [{ id: "1", parts: [], role: "user" }] as UIMessage[];
|
||||
|
||||
renderHook(() => useChatScroll({ messages, scrollRef, status: "streaming" }));
|
||||
renderHook(() =>
|
||||
useChatScroll({ loadingHistory: false, messages, scrollRef, status: "streaming", viewportElement }),
|
||||
);
|
||||
|
||||
const calls = el.scrollTo.mock.calls as unknown[][];
|
||||
const instantCall = calls.find((call) => {
|
||||
@@ -134,4 +134,57 @@ describe("useChatScroll", () => {
|
||||
});
|
||||
expect(instantCall).toBeTruthy();
|
||||
});
|
||||
|
||||
test("loadingHistory 从 true 变为 false 时强制 scrollToBottom", async () => {
|
||||
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 0 });
|
||||
const scrollRef = asRef(el);
|
||||
const viewportElement = asViewport(el);
|
||||
const messages: UIMessage[] = [{ id: "1", parts: [], role: "user" }] as UIMessage[];
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ loadingHistory, messages, scrollRef, status, viewportElement }: HookProps) =>
|
||||
useChatScroll({ loadingHistory, messages, scrollRef, status, viewportElement }),
|
||||
{
|
||||
initialProps: { loadingHistory: true, messages, scrollRef, status: "ready", viewportElement },
|
||||
},
|
||||
);
|
||||
|
||||
el.scrollTo.mockClear();
|
||||
|
||||
act(() => {
|
||||
rerender({ loadingHistory: false, messages, scrollRef, status: "ready", viewportElement });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
expect(el.scrollTo).toHaveBeenCalledWith({ behavior: "instant", top: el.scrollHeight });
|
||||
});
|
||||
|
||||
test("loadingHistory 不变时不触发强制滚动", () => {
|
||||
const el = createScrollElement({ scrollHeight: 1000, scrollTop: 920 });
|
||||
const scrollRef = asRef(el);
|
||||
const viewportElement = asViewport(el);
|
||||
const messages: UIMessage[] = [];
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ loadingHistory, messages, scrollRef, status, viewportElement }: HookProps) =>
|
||||
useChatScroll({ loadingHistory, messages, scrollRef, status, viewportElement }),
|
||||
{
|
||||
initialProps: { loadingHistory: false, messages, scrollRef, status: "ready", viewportElement },
|
||||
},
|
||||
);
|
||||
|
||||
el.scrollTo.mockClear();
|
||||
|
||||
const newMessages: UIMessage[] = [{ id: "1", parts: [], role: "user" }] as UIMessage[];
|
||||
rerender({ loadingHistory: false, messages: newMessages, scrollRef, status: "ready", viewportElement });
|
||||
|
||||
const instantCalls = el.scrollTo.mock.calls.filter((call: unknown[]) => {
|
||||
const opts = call[0] as Record<string, unknown> | undefined;
|
||||
return opts?.["behavior"] === "instant" && opts?.["top"] === el.scrollHeight;
|
||||
});
|
||||
expect(instantCalls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user