diff --git a/src/pages/Chat/ChatMessage.tsx b/src/pages/Chat/ChatMessage.tsx index 1bee682..6c389a9 100644 --- a/src/pages/Chat/ChatMessage.tsx +++ b/src/pages/Chat/ChatMessage.tsx @@ -390,13 +390,13 @@ export const ChatMessage = memo(function ChatMessage({ )} - {/* Main text bubble */} + {/* Main text */} {hasText && ( - + isUser ? ( + + ) : ( + + ) )} {/* Images from content blocks — assistant messages (below text) */} @@ -555,70 +555,67 @@ function AssistantHoverBar({ text, timestamp }: { text: string; timestamp?: numb ); } -// ── Message Bubble ────────────────────────────────────────────── +// ── User Message Bubble ───────────────────────────────────────── -function MessageBubble({ +function UserMessageBubble({ + text, +}: { + text: string; +}) { + return ( +
+

{text}

+
+ ); +} + +// ── Assistant Markdown ────────────────────────────────────────── + +function AssistantMarkdown({ text, - isUser, isStreaming, }: { text: string; - isUser: boolean; isStreaming: boolean; }) { return ( -
+ + {children} + + ); + } + return ( +
+                
+                  {children}
+                
+              
+ ); + }, + a({ href, children }) { + return ( + + {children} + + ); + }, + }} + > + {normalizeLatexDelimiters(text)} +
+ {isStreaming && ( + )} - > - {isUser ? ( -

{text}

- ) : ( -
- - {children} - - ); - } - return ( -
-                    
-                      {children}
-                    
-                  
- ); - }, - a({ href, children }) { - return ( - - {children} - - ); - }, - }} - > - {normalizeLatexDelimiters(text)} -
- {isStreaming && ( - - )} -
- )} -
); } diff --git a/tests/e2e/chat-assistant-markdown-plain.spec.ts b/tests/e2e/chat-assistant-markdown-plain.spec.ts new file mode 100644 index 0000000..6533049 --- /dev/null +++ b/tests/e2e/chat-assistant-markdown-plain.spec.ts @@ -0,0 +1,149 @@ +import { mkdirSync, copyFileSync } from 'node:fs'; +import { dirname } from 'node:path'; + +import { closeElectronApp, expect, getStableWindow, installIpcMocks, test } from './fixtures/electron'; + +const SESSION_KEY = 'agent:main:main'; +const CLOUD_ARTIFACT_PATH = '/opt/cursor/artifacts/chat_assistant_plain_markdown.png'; + +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: 'Please render a Markdown reply plainly.' }], + timestamp: Date.now(), + }, + { + role: 'assistant', + content: [{ + type: 'text', + text: [ + '### Plain Markdown reply', + '', + 'This assistant reply should render as normal Markdown, not inside a gray rounded bubble.', + '', + '- Bold text: **works**', + '- Inline code: `worksToo()`', + ].join('\n'), + }], + timestamp: Date.now(), + }, +]; + +test.describe('ClawX assistant reply Markdown styling', () => { + test('renders assistant text as plain Markdown while keeping user prompts bubbled', async ({ launchElectronApp }, testInfo) => { + 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 }])]: { + success: true, + result: { messages: seededHistory }, + }, + [stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000 }])]: { + 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(); + + await page.evaluate(() => { + const root = document.documentElement; + root.classList.remove('dark'); + root.classList.add('light'); + }); + + const userBubble = page.locator('div.rounded-2xl.bg-brand').filter({ hasText: 'Please render a Markdown reply plainly.' }).first(); + await expect(userBubble).toBeVisible({ timeout: 30_000 }); + + const assistantProse = page.locator('.prose').filter({ hasText: 'Plain Markdown reply' }).first(); + await expect(assistantProse).toBeVisible({ timeout: 30_000 }); + await expect(assistantProse.locator('strong')).toHaveText('works'); + await expect(assistantProse.locator('code')).toHaveText('worksToo()'); + + const assistantStyles = await assistantProse.evaluate((el) => { + const style = window.getComputedStyle(el); + const parentStyle = el.parentElement ? window.getComputedStyle(el.parentElement) : null; + return { + backgroundColor: style.backgroundColor, + borderRadius: style.borderRadius, + paddingLeft: style.paddingLeft, + paddingTop: style.paddingTop, + parentBackgroundColor: parentStyle?.backgroundColor ?? '', + parentBorderRadius: parentStyle?.borderRadius ?? '', + }; + }); + + expect(assistantStyles.backgroundColor).toBe('rgba(0, 0, 0, 0)'); + expect(assistantStyles.borderRadius).toBe('0px'); + expect(assistantStyles.paddingLeft).toBe('0px'); + expect(assistantStyles.paddingTop).toBe('0px'); + expect(assistantStyles.parentBackgroundColor).toBe('rgba(0, 0, 0, 0)'); + expect(assistantStyles.parentBorderRadius).toBe('0px'); + + const screenshotPath = testInfo.outputPath('chat_assistant_plain_markdown.png'); + await assistantProse.screenshot({ path: screenshotPath }); + await testInfo.attach('chat_assistant_plain_markdown', { + path: screenshotPath, + contentType: 'image/png', + }); + + try { + mkdirSync(dirname(CLOUD_ARTIFACT_PATH), { recursive: true }); + copyFileSync(screenshotPath, CLOUD_ARTIFACT_PATH); + } catch { + // Cloud artifact directory is optional; ignore when unavailable (e.g. on CI runners). + } + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/tests/unit/chat-message.test.tsx b/tests/unit/chat-message.test.tsx index 701aa5a..7f13f1a 100644 --- a/tests/unit/chat-message.test.tsx +++ b/tests/unit/chat-message.test.tsx @@ -322,3 +322,32 @@ describe('ChatMessage word wrapping', () => { expect(inlineCode?.classList.contains('break-all')).toBe(true); }); }); + +describe('ChatMessage reply styling', () => { + it('renders assistant replies as plain Markdown without a rounded bubble wrapper', () => { + const message: RawMessage = { + role: 'assistant', + content: 'Direct Markdown reply with **bold** text.', + }; + + const { container } = render(); + const prose = container.querySelector('.prose'); + expect(prose).not.toBeNull(); + expect(prose?.classList.contains('rounded-2xl')).toBe(false); + expect(prose?.classList.contains('bg-black/5')).toBe(false); + expect(prose?.classList.contains('dark:bg-white/5')).toBe(false); + expect(prose?.parentElement?.classList.contains('rounded-2xl')).toBe(false); + }); + + it('keeps user messages in the blue rounded bubble', () => { + const message: RawMessage = { + role: 'user', + content: 'Keep the prompt bubble.', + }; + + const { container } = render(); + const bubble = container.querySelector('.rounded-2xl.bg-brand'); + expect(bubble).not.toBeNull(); + expect(bubble).toHaveTextContent('Keep the prompt bubble.'); + }); +});