fix the chat's dark mode, dead air and overflow

the provider label was bg-duck-dark/80 text-white, and --duck-dark is
near-white in dark mode, so it was white on white for every session that
had already started. same class on the active provider tab.

the streaming bubble bailed on empty text, so the wait between send and
the first token — tens of seconds with thinking on — rendered nothing at
all. it now shows the bubble with pulsing dots.

also: tool status tones onto tone.ts instead of a third copy of the same
green/red/amber ternary, sub-12px labels up to text-xs, break-all to
break-words so shell commands stop breaking mid-identifier, and
break-words on the user bubble so a pasted url stays inside the pane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 23:09:47 +00:00
co-authored by Claude Opus 5
parent f4be4fd431
commit d7f5cd5443
4 changed files with 83 additions and 60 deletions
@@ -4,6 +4,8 @@ import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import { Volume2, Loader2, Square, Clock, Check, X, CircleSlash } from 'lucide-react';
import type { Tone } from '@/components/Data';
import { toneText } from '@/components/Data';
import type { ChatMessage } from '../types';
import { ToolActivity } from './ToolActivity';
import { QuestionActivity } from './QuestionActivity';
@@ -126,11 +128,12 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
<>
{metadata && <CollapsibleBlock label="Frontmatter" content={metadata} />}
<div className="flex justify-end">
<div className="max-w-[80%] rounded-2xl rounded-tr-sm bg-duck-yellow/10 border border-duck-yellow/20 px-4 py-2.5 text-sm text-duck-dark">
<div className="max-w-[80%] min-w-0 rounded-2xl rounded-tr-sm bg-duck-yellow/10 border border-duck-yellow/20 px-4 py-2.5 text-sm text-foreground">
{message.images?.map((img, i) => (
<img key={i} src={img.dataUrl} alt={img.filename} className="max-w-full max-h-64 rounded-lg mb-2" />
))}
<div className="whitespace-pre-wrap">{text}</div>
{/* break-words, or a pasted URL or base64 blob pushes the bubble past max-w and out of the pane. */}
<div className="whitespace-pre-wrap break-words">{text}</div>
</div>
</div>
</>
@@ -174,7 +177,7 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
case 'result':
return (
<div className="flex justify-center py-1">
<span className="text-xs text-duck-dark/40">
<span className="text-xs tabular-nums text-muted-foreground">
Done · ${message.cost.totalUSD.toFixed(3)} · {message.cost.inputTokens + message.cost.outputTokens} tokens
</span>
</div>
@@ -186,7 +189,7 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
case 'error':
return (
<div className="flex justify-start group">
<div className="relative max-w-[80%] rounded-2xl bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 px-4 py-2.5 text-sm text-red-700 dark:text-red-300">
<div className="relative max-w-[80%] min-w-0 rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive break-words">
{message.text}
<div className="flex justify-end -mb-1 -mr-1">
<CopyButton text={message.text} />
@@ -206,24 +209,18 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
const TaskActivity = ({ message }: { message: Extract<ChatMessage, { role: 'task' }> }) => {
const { status, summary, description, taskType } = message;
const Icon = status === 'completed' ? Check : status === 'failed' ? X : status === 'stopped' ? CircleSlash : Clock;
const tone =
status === 'completed'
? 'text-green-600'
: status === 'failed'
? 'text-red-600'
: status === 'stopped'
? 'text-duck-dark/50'
: 'text-amber-500';
const tone: Tone =
status === 'completed' ? 'success' : status === 'failed' ? 'danger' : status === 'stopped' ? 'neutral' : 'warning';
return (
<div className="my-1 flex items-start gap-2 px-3 py-1.5 rounded-md bg-duck-dark/5 text-sm">
<Icon className={`h-4 w-4 shrink-0 mt-0.5 ${tone} ${status ? '' : 'animate-pulse'}`} />
<div className="my-1 flex items-start gap-2 rounded-md bg-muted/50 px-3 py-1.5 text-sm">
<Icon className={`mt-0.5 h-4 w-4 shrink-0 ${toneText[tone]} ${status ? '' : 'animate-pulse'}`} />
<div className="min-w-0 flex-1">
<div className="text-duck-dark/80">
<div className="truncate text-foreground">
{description || 'Background task'}
{taskType && <span className="text-duck-dark/40 text-xs ml-2">{taskType}</span>}
{taskType && <span className="ml-2 text-xs text-muted-foreground">{taskType}</span>}
</div>
<div className="text-duck-dark/50 text-xs">
<div className="text-xs text-muted-foreground">
{status ? summary || `Task ${status}.` : 'Running in the background…'}
</div>
</div>
@@ -231,21 +228,44 @@ const TaskActivity = ({ message }: { message: Extract<ChatMessage, { role: 'task
);
};
/**
* The wait between pressing send and the first token — tens of seconds with thinking enabled — used to
* render nothing at all, because the streaming bubble bailed on empty text. The transcript looked frozen
* and the only evidence the turn was alive was the send button having become a stop button.
*/
const ThinkingDots = () => (
<span className="flex items-center gap-1 py-1" role="status" aria-label="Waiting for a response">
{[0, 150, 300].map((delay) => (
<span
key={delay}
className="h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground/60"
style={{ animationDelay: `${delay}ms` }}
/>
))}
</span>
);
type StreamingBubbleProps = {
text: string;
};
export const StreamingBubble = ({ text }: StreamingBubbleProps) => {
if (!text) return null;
return (
<div className="flex justify-start">
<div className="max-w-[85%] rounded-2xl rounded-tl-sm bg-muted/50 border border-border/50 px-4 py-2.5">
<div className="chat-md">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}>
{injectImages(text)}
</ReactMarkdown>
<span className="inline-block w-2 h-4 bg-duck-teal/60 animate-pulse ml-0.5 align-middle" />
</div>
<div
className="max-w-[85%] overflow-hidden rounded-2xl rounded-tl-sm bg-muted/50 border border-border/50 px-4 py-2.5"
aria-live="polite"
>
{text ? (
<div className="chat-md">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}>
{injectImages(text)}
</ReactMarkdown>
<span className="inline-block w-2 h-4 bg-duck-teal/60 animate-pulse ml-0.5 align-middle" />
</div>
) : (
<ThinkingDots />
)}
</div>
</div>
);
@@ -101,7 +101,8 @@ export const MessageList = ({ manager }: MessageListProps) => {
{showJumpToBottom && (
<button
onClick={jumpToBottom}
className="absolute bottom-2 left-1/2 -translate-x-1/2 bg-duck-teal text-white rounded-full p-1.5 shadow-lg hover:bg-duck-teal/90 transition-colors cursor-pointer"
aria-label="Jump to latest message"
className="absolute bottom-2 left-1/2 -translate-x-1/2 cursor-pointer rounded-full bg-primary p-1.5 text-primary-foreground shadow-lg transition-colors hover:bg-primary/90"
>
<ArrowDown className="h-4 w-4" />
</button>
@@ -90,21 +90,26 @@ export function ModelSelector({
};
return (
<div className="flex items-center justify-between mt-2">
<div className="mt-2 flex items-center justify-between gap-2">
{isLocked ? (
<span className="rounded-md bg-duck-dark/80 px-3 py-1 text-xs font-medium text-white">
// A static label, not an action — `bg-muted` rather than a filled accent. It used to be
// `bg-duck-dark/80 text-white`, and --duck-dark is near-white in dark mode, so this was white
// on white for every session that had already started, i.e. almost always.
<span className="shrink-0 truncate rounded-md bg-muted px-3 py-1 text-xs font-medium text-foreground">
{activeProvider ? displayName(activeProvider) : 'Chat'}
</span>
) : (
<div className="flex items-center gap-1 rounded-lg bg-background/60 p-1">
// Fifteen providers can be configured and the names are long ("Google Vertex AI"), so the strip
// scrolls rather than crushing the model select beside it.
<div className="flex min-w-0 items-center gap-1 overflow-x-auto rounded-lg bg-background/60 p-1">
{providers.map((provider) => (
<button
key={provider}
onClick={() => handleProviderClick(provider)}
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
className={`shrink-0 rounded-md px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
activeProvider === provider
? 'bg-duck-dark/80 text-white shadow-sm'
: 'text-duck-dark/70 hover:text-duck-dark/90'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{displayName(provider)}
@@ -112,7 +117,7 @@ export function ModelSelector({
))}
</div>
)}
<div className="flex items-center gap-2">
<div className="flex min-w-0 shrink-0 items-center gap-2">
{availableModels.find((m) => m.id === displayModel)?.reasoning && (
<ThinkingToggle
enabled={thinkingLevel === 'high'}
@@ -120,14 +125,14 @@ export function ModelSelector({
disabled={isGenerating}
/>
)}
<div className="text-xs text-duck-dark/50">
<div className="min-w-0 text-xs text-muted-foreground">
{providerModels.length > 0 ? (
<Select
value={displayModel ?? fallbackModelId ?? undefined}
onValueChange={(v) => !isLocked && onModelChange(v)}
disabled={isLocked}
>
<SelectTrigger className="h-auto border-0 bg-transparent p-0 text-xs text-duck-dark/50 shadow-none focus:ring-0 gap-1 cursor-pointer">
<SelectTrigger className="h-auto cursor-pointer gap-1 border-0 bg-transparent p-0 text-xs text-muted-foreground shadow-none focus:ring-0">
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[800]" side="top">
@@ -161,8 +166,8 @@ const ThinkingToggle = ({ enabled, onToggle, disabled }: ThinkingToggleProps) =>
onClick={onToggle}
disabled={disabled}
title={enabled ? 'Thinking enabled (click to disable)' : 'Thinking disabled (click to enable)'}
className={`rounded-md px-2 py-0.5 text-xs font-medium transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed ${
enabled ? 'bg-duck-teal/15 text-duck-teal' : 'text-duck-dark/40 hover:text-duck-dark/60'
className={`shrink-0 rounded-md px-2 py-0.5 text-xs font-medium transition-colors cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 ${
enabled ? 'bg-info/15 text-info' : 'text-muted-foreground hover:text-foreground'
}`}
>
{enabled ? 'think' : 'no think'}
@@ -69,29 +69,30 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
<div className="my-1">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2 w-full text-left px-3 py-1.5 rounded-md hover:bg-duck-dark/5 transition-colors cursor-pointer text-sm"
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted"
aria-expanded={open}
>
<ChevronRight className={`h-3 w-3 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`} />
<Icon className="h-4 w-4 shrink-0 text-duck-teal" />
<span className="font-medium text-duck-dark/80">{message.toolName}</span>
<span className="text-duck-dark/50 truncate flex-1 font-mono text-xs">{summary}</span>
<span className="shrink-0 flex items-center gap-2">
<span className="font-medium text-foreground">{message.toolName}</span>
<span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground">{summary}</span>
<span className="flex shrink-0 items-center gap-2">
{children.length > 0 && (
<span className="text-duck-dark/40 text-[10px]">
<span className="text-xs tabular-nums text-muted-foreground">
{children.length} step{children.length === 1 ? '' : 's'}
</span>
)}
{pending && <span className="inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />}
{!pending && !isError && <span className="text-green-600 text-xs">done</span>}
{!pending && isError && <span className="text-red-600 text-xs">error</span>}
{pending && <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-warning" />}
{!pending && !isError && <span className="text-xs text-success">done</span>}
{!pending && isError && <span className="text-xs text-destructive">error</span>}
</span>
</button>
{open && (
<div className="ml-7 mt-1 space-y-2 text-xs">
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto group/input">
<div className="rounded-md bg-muted/50 p-2 overflow-x-auto group/input">
<div className="flex items-center justify-between mb-1">
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider">Input</div>
<div className="text-xs uppercase tracking-wider text-muted-foreground">Input</div>
<CopyButton
text={
message.toolName === 'Bash'
@@ -104,11 +105,11 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
/>
</div>
{message.toolName === 'Bash' ? (
<pre className="bg-gray-900 text-green-400 p-2 rounded font-mono whitespace-pre-wrap break-all">
<pre className="bg-gray-900 text-green-400 p-2 rounded font-mono whitespace-pre-wrap break-words">
{(message.toolInput.command as string) ?? JSON.stringify(message.toolInput, null, 2)}
</pre>
) : (
<pre className="font-mono whitespace-pre-wrap break-all text-duck-dark/70">
<pre className="font-mono whitespace-pre-wrap break-words text-foreground/80">
{Object.entries(message.toolInput)
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
.join('\n')}
@@ -118,15 +119,15 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
{children.length > 0 && (
<div className="rounded-md border-l-2 border-duck-teal/30 pl-2">
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider mb-1">Subagent</div>
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-1">Subagent</div>
<SubagentTrace messages={children} />
</div>
)}
{message.output !== undefined && (
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto group/output">
<div className="rounded-md bg-muted/50 p-2 overflow-x-auto group/output">
<div className="flex items-center justify-between mb-1">
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider">Output</div>
<div className="text-xs uppercase tracking-wider text-muted-foreground">Output</div>
<CopyButton
text={message.output}
className="!opacity-0 group-hover/output:!opacity-60 hover:!opacity-100"
@@ -151,7 +152,7 @@ export const SubagentTrace = ({ messages }: { messages: ChatMessage[] }) => (
m.role === 'tool' ? (
<ToolActivity key={m.toolCallId || i} message={m} />
) : m.role === 'assistant' ? (
<div key={m.id ?? i} className="text-duck-dark/60 whitespace-pre-wrap px-3 py-1">
<div key={m.id ?? i} className="text-muted-foreground whitespace-pre-wrap px-3 py-1">
{m.text}
</div>
) : null,
@@ -177,12 +178,8 @@ const ToolOutput = ({ toolName, output, isError }: ToolOutputProps) => {
return (
<>
<pre
className={`font-mono whitespace-pre-wrap break-all p-2 rounded ${
isBash
? 'bg-gray-900 text-green-400'
: isError
? 'bg-red-50 dark:bg-red-950/50 text-red-700 dark:text-red-300'
: 'text-duck-dark/70'
className={`font-mono whitespace-pre-wrap break-words p-2 rounded ${
isBash ? 'bg-gray-900 text-green-400' : isError ? 'bg-destructive/10 text-destructive' : 'text-foreground/80'
}`}
>
{displayText}
@@ -190,7 +187,7 @@ const ToolOutput = ({ toolName, output, isError }: ToolOutputProps) => {
{needsTruncation && (
<button
onClick={() => setExpanded(!expanded)}
className="text-duck-teal hover:underline text-[11px] mt-1 cursor-pointer"
className="text-duck-teal hover:underline text-xs mt-1 cursor-pointer"
>
{expanded ? 'Show less' : `Show more (${lines.length - maxLines} more lines)`}
</button>