From 29de7e15c295ae61a7f7cedaf08d04647fdcedfe Mon Sep 17 00:00:00 2001 From: Haze <709547807@qq.com> Date: Wed, 27 May 2026 14:38:45 +0800 Subject: [PATCH] feat(chat): optimize chat and light model (#1074) --- .../file-preview/MarkdownPreview.tsx | 2 +- src/components/layout/TitleBar.tsx | 4 +- src/components/ui/button.tsx | 4 +- src/pages/Chat/ChatInput.tsx | 6 +- src/pages/Chat/ChatMessage.tsx | 31 +++-- src/pages/Chat/ChatToolbar.tsx | 12 +- src/pages/Chat/index.tsx | 7 +- tests/e2e/chat-code-block-wrap.spec.ts | 130 ++++++++++++++++++ tests/unit/chat-message.test.tsx | 16 +++ 9 files changed, 188 insertions(+), 24 deletions(-) create mode 100644 tests/e2e/chat-code-block-wrap.spec.ts diff --git a/src/components/file-preview/MarkdownPreview.tsx b/src/components/file-preview/MarkdownPreview.tsx index 41a9906..c136d1a 100644 --- a/src/components/file-preview/MarkdownPreview.tsx +++ b/src/components/file-preview/MarkdownPreview.tsx @@ -108,7 +108,7 @@ export default function MarkdownPreview({ source, className }: MarkdownPreviewPr ); }, pre: ({ children }) => ( -
+            
               {children}
             
), diff --git a/src/components/layout/TitleBar.tsx b/src/components/layout/TitleBar.tsx index 555f344..c1ae5b0 100644 --- a/src/components/layout/TitleBar.tsx +++ b/src/components/layout/TitleBar.tsx @@ -60,14 +60,14 @@ function WindowsTitleBar() {
diff --git a/tests/e2e/chat-code-block-wrap.spec.ts b/tests/e2e/chat-code-block-wrap.spec.ts new file mode 100644 index 0000000..0ddf216 --- /dev/null +++ b/tests/e2e/chat-code-block-wrap.spec.ts @@ -0,0 +1,130 @@ +import { closeElectronApp, expect, getStableWindow, installIpcMocks, test } from './fixtures/electron'; + +// Regression: assistant code blocks used to set only `overflow-x-auto`, which +// hid long log lines (gateway diagnostics, file paths, etc.) behind a +// horizontal scroll that the chat viewport often clipped on narrower windows. +// The fenced `
` must now soft-wrap so the full line is visible without
+// requiring horizontal scrolling.
+
+const SESSION_KEY = 'agent:main:main';
+
+const LONG_LOG_LINE = 'config change requires channel reload (wecom) — deferring until 2 operation(s), 1 reply(ies), 1 embedded run(s) complete';
+const LONG_PATH = '/Users/guoyuliang/.openclaw/agents/main/sessions/6a9f6ff8-91e7-4532-bfe0-4393e6aa120d.jsonl';
+
+function stableStringify(value: unknown): string {
+  if (value == null || typeof value !== 'object') return JSON.stringify(value);
+  if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`;
+  const entries = Object.entries(value as Record)
+    .sort(([left], [right]) => left.localeCompare(right))
+    .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`);
+  return `{${entries.join(',')}}`;
+}
+
+const seededHistory = [
+  {
+    role: 'user',
+    content: [{ type: 'text', text: 'Show me the gateway log line.' }],
+    timestamp: Date.now(),
+  },
+  {
+    role: 'assistant',
+    content: [{
+      type: 'text',
+      text: [
+        'Here is the relevant log entry:',
+        '',
+        '```',
+        LONG_LOG_LINE,
+        LONG_PATH,
+        '```',
+      ].join('\n'),
+    }],
+    timestamp: Date.now(),
+  },
+];
+
+test.describe('ClawX chat code block wrapping', () => {
+  test('soft-wraps long lines inside fenced code blocks instead of overflowing', async ({ launchElectronApp }) => {
+    const app = await launchElectronApp({ skipSetup: true });
+
+    try {
+      await installIpcMocks(app, {
+        gatewayStatus: { state: 'running', port: 18789, pid: 12345 },
+        gatewayRpc: {
+          [stableStringify(['sessions.list', {}])]: {
+            success: true,
+            result: { sessions: [{ key: SESSION_KEY, displayName: 'main' }] },
+          },
+          [stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
+            success: true,
+            result: { messages: seededHistory },
+          },
+          [stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
+            success: true,
+            result: { messages: seededHistory },
+          },
+        },
+        hostApi: {
+          [stableStringify(['/api/gateway/status', 'GET'])]: {
+            ok: true,
+            data: { status: 200, ok: true, json: { state: 'running', port: 18789, pid: 12345 } },
+          },
+          [stableStringify(['/api/agents', 'GET'])]: {
+            ok: true,
+            data: {
+              status: 200,
+              ok: true,
+              json: { success: true, agents: [{ id: 'main', name: 'main' }] },
+            },
+          },
+        },
+      });
+
+      const page = await getStableWindow(app);
+      try {
+        await page.reload();
+      } catch (error) {
+        if (!String(error).includes('ERR_FILE_NOT_FOUND')) {
+          throw error;
+        }
+      }
+
+      await expect(page.getByTestId('main-layout')).toBeVisible();
+
+      // Constrain the viewport so the long line cannot fit on a single visual
+      // row; without wrapping, this would force horizontal overflow.
+      await page.setViewportSize({ width: 720, height: 800 });
+
+      const assistantProse = page.locator('.prose').filter({ hasText: 'Here is the relevant log entry' }).first();
+      await expect(assistantProse).toBeVisible({ timeout: 30_000 });
+
+      const codeBlock = assistantProse.locator('pre').first();
+      await expect(codeBlock).toBeVisible();
+
+      const metrics = await codeBlock.evaluate((el) => {
+        const style = window.getComputedStyle(el);
+        return {
+          whiteSpace: style.whiteSpace,
+          overflowWrap: style.overflowWrap || (style as unknown as { wordWrap: string }).wordWrap,
+          scrollWidth: el.scrollWidth,
+          clientWidth: el.clientWidth,
+        };
+      });
+
+      // `whitespace-pre-wrap` collapses to `pre-wrap`; `break-words` collapses
+      // to `overflow-wrap: break-word`. Together they make long log lines wrap
+      // softly while still preserving the leading whitespace of source code.
+      expect(metrics.whiteSpace).toBe('pre-wrap');
+      expect(metrics.overflowWrap).toBe('break-word');
+
+      // Wrapping must keep the rendered content within the viewport — i.e. no
+      // horizontal scroll needed for plain log lines.
+      expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth + 1);
+
+      await expect(codeBlock).toContainText(LONG_LOG_LINE);
+      await expect(codeBlock).toContainText(LONG_PATH);
+    } finally {
+      await closeElectronApp(app);
+    }
+  });
+});
diff --git a/tests/unit/chat-message.test.tsx b/tests/unit/chat-message.test.tsx
index e225b0f..15c0e01 100644
--- a/tests/unit/chat-message.test.tsx
+++ b/tests/unit/chat-message.test.tsx
@@ -376,6 +376,22 @@ describe('ChatMessage word wrapping', () => {
     expect(inlineCode).not.toBeNull();
     expect(inlineCode?.classList.contains('break-all')).toBe(true);
   });
+
+  // Regression: fenced code blocks used to set only `overflow-x-auto`, which
+  // hid long log lines / paths behind a horizontal scroll that the chat
+  // viewport often clipped. Long lines must now wrap inside the bubble.
+  it('wraps fenced code block contents instead of overflowing horizontally', () => {
+    const longLine = 'config change requires channel reload (wecom) — deferring until 2 operation(s), 1 reply(ies), 1 embedded run(s) complete';
+    const message: RawMessage = {
+      role: 'assistant',
+      content: ['Gateway log:', '', '```', longLine, '```'].join('\n'),
+    };
+    const { container } = render();
+    const codeBlock = container.querySelector('.prose pre');
+    expect(codeBlock).not.toBeNull();
+    expect(codeBlock?.classList.contains('whitespace-pre-wrap')).toBe(true);
+    expect(codeBlock?.classList.contains('break-words')).toBe(true);
+  });
 });
 
 describe('ChatMessage reply styling', () => {