) {
+ const messageList = event.currentTarget;
+ const distanceFromBottom =
+ messageList.scrollHeight -
+ messageList.scrollTop -
+ messageList.clientHeight;
+ shouldFollowLatestRef.current =
+ distanceFromBottom <= CHAT_SCROLL_BOTTOM_THRESHOLD;
+ onScroll(event);
+ }
const conversationLabel = directCodex
? '陶泥儿项目对话'
: '项目总控 Agent 纯聊天';
@@ -143,7 +172,7 @@ export function SupervisorChatOnlyView({
ref={messagesRef}
className="message-list supervisor-chat-only-message-list"
aria-label={directCodex ? '陶泥儿消息' : '项目总控消息'}
- onScroll={onScroll}
+ onScroll={handleScroll}
>
{hiddenConversationCount > 0 ? (
) : null}
{visibleMessages.map((message, index) => (
-
- {projectSupervisorVisibleConversationText(
- message.text,
- message.role,
- )}
-
+
+
))}
{transientReply ? (
-
- {transientReply}
-
+
+
) : null}
{running && !transientReply ? (
markdown recovered;
+}
+
+describe('ChatMarkdownMessage', () => {
+ it('渲染 assistant 的 GFM 内容与代码块', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector('h2')?.textContent).toBe('标题');
+ expect(container.querySelectorAll('li')).toHaveLength(2);
+ expect(container.querySelector('pre code')?.textContent).toContain(
+ 'const answer = 42;',
+ );
+ });
+
+ it('为嵌套无序列表保留逐层缩进', () => {
+ const { container } = render(
+ ,
+ );
+
+ const lists = container.querySelectorAll('ul');
+ expect(lists).toHaveLength(3);
+ expect(lists[0]?.className).toContain('pl-0');
+ expect(lists[1]?.className).toContain('pl-4');
+ expect(lists[2]?.className).toContain('pl-4');
+ });
+
+ it('为四到六级标题提供递进的字号与字重', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector('h4')?.className).toContain('text-sm');
+ expect(container.querySelector('h4')?.className).toContain('font-semibold');
+ expect(container.querySelector('h5')?.className).toContain('font-medium');
+ expect(container.querySelector('h6')?.className).toContain('text-xs');
+ expect(container.querySelector('h6')?.className).toContain('tracking-wide');
+ });
+
+ it('有序列表不添加无序列表短横线,列表项段落与文本同行', () => {
+ const { container } = render(
+ ,
+ );
+
+ const items = container.querySelectorAll('ol > li');
+ expect(items[0]?.textContent?.trim()).toBe('第一件事');
+ expect(items[1]?.textContent?.trim()).toContain('第二件事');
+ expect(items[0]?.querySelector('p')?.className ?? '').toContain('inline');
+ expect(items[0]?.className).toContain('whitespace-normal');
+ expect(items[0]?.textContent).not.toContain('-');
+ });
+
+ it('保留 Markdown 有序列表的起始编号', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector('ol')?.getAttribute('start')).toBe('3');
+ expect(container.querySelectorAll('ol > li')).toHaveLength(2);
+ });
+
+ it('为列表项后续段落保留段落间距', () => {
+ const { container } = render(
+ ,
+ );
+
+ const paragraphs = container.querySelectorAll('ul > li p');
+ expect(paragraphs).toHaveLength(2);
+ expect(paragraphs[0]?.className).toContain('inline');
+ expect(paragraphs[1]?.className).toContain('mt-2');
+ expect(paragraphs[1]?.className).not.toContain('inline');
+ });
+
+ it('不会把 react-markdown 的 node 元数据泄漏到代码节点', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(
+ container.querySelector('pre code')?.getAttribute('node'),
+ ).toBeNull();
+ });
+
+ it('无语言标记的多行围栏代码仍使用代码块样式', () => {
+ const { container } = render(
+ ,
+ );
+
+ const code = container.querySelector('pre code');
+ expect(code?.className).toContain('whitespace-pre');
+ expect(code?.className).not.toContain('rounded');
+ });
+
+ it('保留行内代码中的 HTML 字面量', () => {
+ const { container } = render(
+ `'} />,
+ );
+
+ expect(container.querySelector('code')?.textContent).toBe('');
+ });
+
+ it('保留围栏代码中的 HTML 字面量而不双重转义', () => {
+ const { container } = render(
+
\n```'}
+ />,
+ );
+
+ expect(container.querySelector('pre code')?.textContent).toContain(
+ '',
+ );
+ expect(container.querySelector('pre code')?.textContent).not.toContain(
+ '<div',
+ );
+ });
+
+ it('累计文本变化后可从 Markdown 错误回退中恢复', () => {
+ const consoleError = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+ try {
+ const { container, rerender } = render(
+
+
+ ,
+ );
+
+ expect(container.textContent).toBe('partial');
+
+ rerender(
+
+
+ ,
+ );
+
+ expect(container.textContent).toBe('markdown recovered');
+ } finally {
+ consoleError.mockRestore();
+ }
+ });
+
+ it('保留流式 assistant 文本并标记 streaming 状态', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.textContent).toContain('正在生成');
+ expect(container.textContent).toContain('结果');
+ });
+
+ it('用户消息保持纯文本,不解析 Markdown', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector('strong')).toBeNull();
+ expect(container.querySelector('code')).toBeNull();
+ expect(container.textContent).toContain('**不要解析**');
+ expect(container.textContent).toContain('`/read game`');
+ });
+
+ it('不输出原始 HTML、可点击链接或图片节点', () => {
+ const { container } = render(
+ alert(1)\n\n[外链](https://example.com) '
+ }
+ />,
+ );
+
+ expect(container.querySelector('script')).toBeNull();
+ expect(container.querySelector('a')).toBeNull();
+ expect(container.querySelector('img')).toBeNull();
+ expect(container.textContent).toContain('外链');
+ expect(container.textContent).toContain('图片:示意图');
+ });
+
+ it('支持表格并允许窄视口横向滚动', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelectorAll('table th')).toHaveLength(2);
+ expect(container.querySelector('div.overflow-x-auto')).toBeTruthy();
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-assets.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-assets.suite.ts
index 1df0e257a..ef3335a59 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/project-assets.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/project-assets.suite.ts
@@ -923,7 +923,9 @@ export function registerProjectAssetTests() {
submitChat('/read game/index.html');
expect(await screen.findByText(/文件:game\/index\.html/)).not.toBeNull();
- expect(screen.getByText(/