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:
Haze
2026-05-18 16:33:38 +08:00
committed by GitHub
parent 8df7eee0c1
commit 8af070a7a0
3 changed files with 236 additions and 61 deletions

View File

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

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

View File

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