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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user