feat(chat): optimize chat and light model (#1074)
This commit is contained in:
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
130
tests/e2e/chat-code-block-wrap.spec.ts
Normal file
130
tests/e2e/chat-code-block-wrap.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user