feat(chat): 聊天滚动条美化 + Markdown 增强 — OverlayScrollbars/CodeHighlighter/代码复制/表格样式

This commit is contained in:
2026-06-02 21:19:50 +08:00
parent 26ecaadb26
commit ed97b30d51
10 changed files with 385 additions and 68 deletions

View File

@@ -0,0 +1,99 @@
import { screen } from "@testing-library/react";
import { describe, expect, mock, test } from "bun:test";
import { createElement } from "react";
import { CodeBlockWithCopy } from "../../../../src/web/consoles/workbench/components/chat/parts/CodeBlockWithCopy";
import { renderWithProviders } from "../../test-utils";
const mockWriteText = mock(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", {
configurable: true,
get: () => ({ writeText: mockWriteText }),
});
describe("CodeBlockWithCopy", () => {
test("block 模式渲染 CodeHighlighter 和语言标签", () => {
renderWithProviders(
createElement(CodeBlockWithCopy, {
block: true,
children: "const x = 1;",
lang: "typescript",
streamStatus: "done",
}),
);
expect(screen.getByText("typescript")).toBeTruthy();
});
test("block 模式渲染复制按钮", () => {
renderWithProviders(
createElement(CodeBlockWithCopy, {
block: true,
children: "hello world",
lang: "python",
streamStatus: "done",
}),
);
const copyBtn = screen.getByRole("button");
expect(copyBtn).toBeTruthy();
});
test("block 模式语言为空时显示 plaintext", () => {
renderWithProviders(
createElement(CodeBlockWithCopy, {
block: true,
children: "some code",
lang: "",
streamStatus: "done",
}),
);
expect(screen.getByText("plaintext")).toBeTruthy();
});
test("block 模式语言为 undefined 时显示 plaintext", () => {
renderWithProviders(
createElement(CodeBlockWithCopy, {
block: true,
children: "some code",
streamStatus: "done",
}),
);
expect(screen.getByText("plaintext")).toBeTruthy();
});
test("inline 模式返回 code 元素", () => {
const { container } = renderWithProviders(
createElement(CodeBlockWithCopy, {
block: false,
children: "inline code",
className: "language-ts",
}),
);
const code = container.querySelector("code.language-ts");
expect(code).toBeTruthy();
expect(code?.textContent).toBe("inline code");
});
test("点击复制按钮调用 clipboard.writeText", () => {
mockWriteText.mockClear();
renderWithProviders(
createElement(CodeBlockWithCopy, {
block: true,
children: "copy me",
lang: "javascript",
streamStatus: "done",
}),
);
const copyBtn = screen.getByRole("button");
copyBtn.click();
expect(mockWriteText).toHaveBeenCalledWith("copy me");
});
});

View File

@@ -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);
});
});