feat(chat): optimize chat and light model (#1074)

This commit is contained in:
Haze
2026-05-27 14:38:45 +08:00
committed by GitHub
parent e97af800b3
commit 29de7e15c2
9 changed files with 188 additions and 24 deletions

View File

@@ -108,7 +108,7 @@ export default function MarkdownPreview({ source, className }: MarkdownPreviewPr
);
},
pre: ({ children }) => (
<pre className="overflow-x-auto rounded-lg bg-black/5 p-3 text-xs leading-relaxed dark:bg-white/10">
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-lg bg-black/5 p-3 text-xs leading-relaxed dark:bg-white/10">
{children}
</pre>
),

View File

@@ -60,14 +60,14 @@ function WindowsTitleBar() {
<div className="no-drag flex h-full">
<button
onClick={handleMinimize}
className="flex h-full w-11 items-center justify-center text-muted-foreground hover:bg-accent transition-colors"
className="flex h-full w-11 items-center justify-center text-muted-foreground hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10 transition-colors"
title="Minimize"
>
<Minus className="h-4 w-4" />
</button>
<button
onClick={handleMaximize}
className="flex h-full w-11 items-center justify-center text-muted-foreground hover:bg-accent transition-colors"
className="flex h-full w-11 items-center justify-center text-muted-foreground hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10 transition-colors"
title={maximized ? 'Restore' : 'Maximize'}
>
{maximized ? <Copy className="h-3.5 w-3.5" /> : <Square className="h-3.5 w-3.5" />}

View File

@@ -17,10 +17,10 @@ const buttonVariants = cva(
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
'border border-input bg-background hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
ghost: 'hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {

View File

@@ -46,7 +46,6 @@ interface ChatInputProps {
onStop?: () => void;
disabled?: boolean;
sending?: boolean;
isEmpty?: boolean;
}
// ── Helpers ──────────────────────────────────────────────────────
@@ -192,7 +191,7 @@ function readFileAsBase64(file: globalThis.File): Promise<string> {
// ── Component ────────────────────────────────────────────────────
export function ChatInput({ onSend, onStop, disabled = false, sending = false, isEmpty = false }: ChatInputProps) {
export function ChatInput({ onSend, onStop, disabled = false, sending = false }: ChatInputProps) {
const { t } = useTranslation('chat');
const [input, setInput] = useState('');
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
@@ -773,8 +772,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
return (
<div
className={cn(
"p-4 pb-6 w-full mx-auto transition-all duration-300",
isEmpty ? "max-w-3xl" : "max-w-4xl"
"p-4 pb-6 w-full mx-auto max-w-3xl"
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}

View File

@@ -343,10 +343,17 @@ export const ChatMessage = memo(function ChatMessage({
isUser ? 'flex-row-reverse' : 'flex-row',
)}
>
{/* Avatar */}
{/* Avatar — vertical center aligned with the first line of the reply.
The outer slot is sized to one prose-sm line (h-6 = 24px) so its
midpoint coincides with the first text line's midpoint; the 32px
avatar inside is centered within that slot and intentionally
overflows ±4px above/below the line, which mirrors how chat avatars
sit alongside a single line of text. */}
{!isUser && (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full mt-1 bg-black/5 dark:bg-white/5 text-foreground">
<Sparkles className="h-4 w-4" />
<div className="flex h-6 shrink-0 items-center">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-black/5 dark:bg-white/5 text-foreground">
<Sparkles className="h-4 w-4" />
</div>
</div>
)}
@@ -657,16 +664,24 @@ function AssistantMarkdown({
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}>
<code className="bg-black/5 dark:bg-white/5 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>
<code className={cn('text-sm font-mono', className)} {...props}>
{children}
</code>
);
},
pre({ children, ...props }) {
return (
<pre
className="bg-black/5 dark:bg-white/5 rounded-lg p-4 overflow-x-auto whitespace-pre-wrap break-words"
{...props}
>
{children}
</pre>
);
},

View File

@@ -55,7 +55,10 @@ export function ChatToolbar({
<Button
variant="ghost"
size="icon"
className={cn('h-8 w-8', browserActive && 'bg-foreground/10 text-foreground')}
className={cn(
'h-8 w-8 hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10',
browserActive && 'bg-foreground/10 text-foreground',
)}
onClick={() => (browserActive ? closePanel() : openBrowser())}
disabled={!currentAgent?.workspace}
aria-label={t('toolbar.workspace', '工作空间')}
@@ -74,7 +77,10 @@ export function ChatToolbar({
data-testid="chat-question-directory-toggle"
variant="ghost"
size="icon"
className={cn('h-8 w-8', questionDirectoryOpen && 'bg-foreground/10 text-foreground')}
className={cn(
'h-8 w-8 hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10',
questionDirectoryOpen && 'bg-foreground/10 text-foreground',
)}
onClick={onToggleQuestionDirectory}
disabled={!questionDirectoryAvailable}
aria-label={t('questionDirectory.title')}
@@ -92,7 +98,7 @@ export function ChatToolbar({
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
className="h-8 w-8 hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10"
onClick={() => refresh()}
disabled={loading}
aria-label={t('toolbar.refresh')}

View File

@@ -822,7 +822,7 @@ export function Chat() {
<div
ref={contentRef}
className={cn(
"mx-auto space-y-4 transition-all duration-300",
"mx-auto space-y-4",
isEmpty ? "w-full max-w-3xl" : "max-w-4xl",
)}
>
@@ -836,7 +836,7 @@ export function Chat() {
type="button"
onClick={() => void loadMoreHistory()}
disabled={loadingMoreHistory}
className="inline-flex items-center gap-2 rounded-full border border-border bg-background/80 px-3 py-1.5 text-xs text-muted-foreground shadow-sm transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-60"
className="inline-flex items-center gap-2 rounded-full border border-border bg-background/80 px-3 py-1.5 text-xs text-muted-foreground shadow-sm transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-60"
data-testid="chat-load-more-history"
>
{loadingMoreHistory && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
@@ -986,7 +986,7 @@ export function Chat() {
<button
type="button"
onClick={() => void scrollToBottom({ animation: 'smooth', ignoreEscapes: true })}
className="absolute bottom-4 right-4 z-20 inline-flex items-center gap-2 rounded-full border border-border bg-background/95 px-3 py-1.5 text-xs font-medium text-foreground shadow-lg shadow-black/10 backdrop-blur transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 dark:shadow-black/30"
className="absolute bottom-4 right-4 z-20 inline-flex items-center gap-2 rounded-full border border-border bg-background/95 px-3 py-1.5 text-xs font-medium text-foreground shadow-lg shadow-black/10 backdrop-blur transition-colors hover:bg-black/5 dark:hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 dark:shadow-black/30"
aria-label={t('scrollToLatest', '跳转到最新对话')}
title={t('scrollToLatest', '跳转到最新对话')}
data-testid="chat-scroll-to-latest"
@@ -1042,7 +1042,6 @@ export function Chat() {
onStop={abortRun}
disabled={!isGatewayRunning}
sending={inputRunActive}
isEmpty={isEmpty}
/>
</div>

View File

@@ -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 `<pre>` 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<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: '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);
}
});
});

View File

@@ -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(<ChatMessage message={message} />);
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', () => {