Remove assistant reply bubble styling (#1033)
Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Haze <hazeone@users.noreply.github.com>
This commit is contained in:
@@ -390,13 +390,13 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main text bubble */}
|
||||
{/* Main text */}
|
||||
{hasText && (
|
||||
<MessageBubble
|
||||
text={text}
|
||||
isUser={isUser}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
isUser ? (
|
||||
<UserMessageBubble text={text} />
|
||||
) : (
|
||||
<AssistantMarkdown text={text} isStreaming={isStreaming} />
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 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 (
|
||||
<div className="relative rounded-2xl px-4 py-3 bg-brand text-white shadow-sm">
|
||||
<p className="whitespace-pre-wrap break-words text-sm">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Assistant Markdown ──────────────────────────────────────────
|
||||
|
||||
function AssistantMarkdown({
|
||||
text,
|
||||
isUser,
|
||||
isStreaming,
|
||||
}: {
|
||||
text: string;
|
||||
isUser: boolean;
|
||||
isStreaming: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-2xl px-4 py-3',
|
||||
!isUser && 'w-full',
|
||||
isUser
|
||||
? 'bg-brand text-white shadow-sm'
|
||||
: 'bg-black/5 dark:bg-white/5 text-foreground',
|
||||
<div className="prose prose-sm dark:prose-invert w-full max-w-none break-words text-foreground">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[[rehypeKatex, { strict: false, throwOnError: false, output: 'html' }]]}
|
||||
components={{
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const isInline = !match && !className;
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono break-words break-all" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<pre className="bg-muted rounded-lg p-4 overflow-x-auto">
|
||||
<code className={cn('text-sm font-mono', className)} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
a({ href, children }) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-words break-all">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{normalizeLatexDelimiters(text)}
|
||||
</ReactMarkdown>
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-2 h-4 bg-foreground/50 animate-pulse ml-0.5" />
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap break-words text-sm">{text}</p>
|
||||
) : (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none break-words">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[[rehypeKatex, { strict: false, throwOnError: false, output: 'html' }]]}
|
||||
components={{
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const isInline = !match && !className;
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="bg-background/50 px-1.5 py-0.5 rounded text-sm font-mono break-words break-all" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<pre className="bg-background/50 rounded-lg p-4 overflow-x-auto">
|
||||
<code className={cn('text-sm font-mono', className)} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
a({ href, children }) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-words break-all">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{normalizeLatexDelimiters(text)}
|
||||
</ReactMarkdown>
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-2 h-4 bg-foreground/50 animate-pulse ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
149
tests/e2e/chat-assistant-markdown-plain.spec.ts
Normal file
149
tests/e2e/chat-assistant-markdown-plain.spec.ts
Normal file
@@ -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<string, unknown>)
|
||||
.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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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(<ChatMessage message={message} />);
|
||||
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(<ChatMessage message={message} />);
|
||||
const bubble = container.querySelector('.rounded-2xl.bg-brand');
|
||||
expect(bubble).not.toBeNull();
|
||||
expect(bubble).toHaveTextContent('Keep the prompt bubble.');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user