fix the dark-mode palette, the task tray, and the dead attach menu items

--duck-dark inverts to near-white in dark mode, so every place the chat used
it as a text or border colour was drawing light-on-light: the composer's own
text, the question prompts, the launcher textarea, the session detail bar, the
"send a message to start" placeholder. all of it moves to the semantic tokens,
along with the raw red-500s, which had no dark story at all.

the background task tray had seven labels below the 12px floor, including the
live log itself, and hardcoded green-600/red-600/amber-500 where the shared
tones exist. its detail panel was capped at a flat max-h-64 while the composer
it docks in is shrink-0 and the transcript above is flex-1 — so in a short
panel an open task could leave almost no conversation visible. capped against
the viewport too.

a failed background task drew the same CircleSlash as a stopped one: the two
outcomes you most need to tell apart were one glyph.

the attach menu offered four things and did two — "Text File" and "PDF" had no
onSelect at all. text files now inline into the composer as a fenced block
with the filename, size-capped and rejected if they turn out to be binary. PDF
is removed rather than faked: nothing in the platform extracts PDF text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 23:28:59 +00:00
co-authored by Claude Opus 5
parent 306def14f3
commit 9daf42036d
13 changed files with 161 additions and 78 deletions
@@ -72,7 +72,12 @@ export function ChatLauncher({
<AttachmentList attachments={attachments} onRemove={removeAttachment} /> <AttachmentList attachments={attachments} onRemove={removeAttachment} />
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<AttachButton size="md" onAttachImage={attachImage} onAttachWebpage={() => setUrlDialogOpen(true)} /> <AttachButton
size="md"
onAttachImage={attachImage}
onAttachWebpage={() => setUrlDialogOpen(true)}
onAttachText={(text) => setInput((prev) => (prev.trim() ? `${prev.replace(/\s+$/, '')}\n\n${text}` : text))}
/>
<textarea <textarea
ref={textareaRef} ref={textareaRef}
value={input} value={input}
@@ -92,7 +97,7 @@ export function ChatLauncher({
}} }}
placeholder={placeholder} placeholder={placeholder}
rows={1} rows={1}
className="flex-1 resize-none bg-transparent px-2 py-2 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none text-lg" className="flex-1 resize-none bg-transparent px-2 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none text-lg"
/> />
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
@@ -1,5 +1,6 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { Paperclip, Image, Link, FileText } from 'lucide-react'; import { Paperclip, Image, Link, FileText } from 'lucide-react';
import { toast } from '@/components/ui/sonner';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -10,21 +11,61 @@ import {
type AttachButtonProps = { type AttachButtonProps = {
onAttachImage: (file: File) => void; onAttachImage: (file: File) => void;
onAttachWebpage: () => void; onAttachWebpage: () => void;
/** Text files are inlined into the composer rather than sent as an attachment — see below. */
onAttachText: (text: string) => void;
size?: 'sm' | 'md'; size?: 'sm' | 'md';
}; };
export function AttachButton({ onAttachImage, onAttachWebpage, size = 'sm' }: AttachButtonProps) { // Big enough for a config file or a stack trace, small enough that inlining it does not blow up the turn.
const MAX_TEXT_BYTES = 256 * 1024;
const TEXT_ACCEPT =
'.txt,.md,.markdown,.json,.jsonl,.csv,.tsv,.log,.yaml,.yml,.toml,.ini,.env,.xml,.html,.css,.scss,' +
'.js,.jsx,.ts,.tsx,.py,.rb,.go,.rs,.java,.c,.h,.cpp,.sh,.bash,.zsh,.sql,.diff,.patch,text/*';
const NUL = String.fromCharCode(0);
export function AttachButton({ onAttachImage, onAttachWebpage, onAttachText, size = 'sm' }: AttachButtonProps) {
const imageInputRef = useRef<HTMLInputElement>(null); const imageInputRef = useRef<HTMLInputElement>(null);
const textInputRef = useRef<HTMLInputElement>(null);
const sizeClasses = size === 'md' ? 'h-10 w-10' : 'h-7 w-7 md:h-9 md:w-9'; const sizeClasses = size === 'md' ? 'h-10 w-10' : 'h-7 w-7 md:h-9 md:w-9';
/**
* Inlined into the composer as a fenced block rather than carried as an attachment. The attachment
* wire format only knows `image` and `webpage`, and a text file has no representation an agent reads
* better than the text itself — fencing it keeps the filename attached to the content, which is the
* only thing that would otherwise be lost.
*/
const handleTextFile = async (file: File) => {
if (file.size > MAX_TEXT_BYTES) {
toast.error(`${file.name} is ${Math.round(file.size / 1024)} KB — too large to inline (limit 256 KB).`);
return;
}
try {
const text = await file.text();
// A NUL byte means this is not text, whatever the extension claimed.
if (text.includes(NUL)) {
toast.error(`${file.name} looks binary, not text.`);
return;
}
// A longer fence when the file contains one of its own, or the block closes early and the rest of
// the file renders as prose.
const fence = text.includes('```') ? '````' : '```';
onAttachText(`${fence} ${file.name}\n${text.replace(/\s+$/, '')}\n${fence}`);
} catch (err) {
toast.error(`Could not read ${file.name}: ${err instanceof Error ? err.message : 'unknown error'}`);
}
};
return ( return (
<> <>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="button" type="button"
className={`shrink-0 ${sizeClasses} flex items-center justify-center rounded-lg text-duck-dark/40 hover:text-duck-dark/70 hover:bg-duck-dark/5 transition-colors cursor-pointer`} aria-label="Attach"
className={`shrink-0 ${sizeClasses} flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-pointer`}
> >
<Paperclip className="h-4 w-4" /> <Paperclip className="h-4 w-4" />
</button> </button>
@@ -34,14 +75,13 @@ export function AttachButton({ onAttachImage, onAttachWebpage, size = 'sm' }: At
<Image className="mr-2 h-4 w-4" /> <Image className="mr-2 h-4 w-4" />
Image Image
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer"> {/* "PDF" used to sit beside this one. Both were inert — no onSelect at all — so the menu offered
four things and did two. There is no PDF text extraction anywhere in the platform, client or
server, so that entry could not be made honest without building one first. */}
<DropdownMenuItem className="cursor-pointer" onSelect={() => textInputRef.current?.click()}>
<FileText className="mr-2 h-4 w-4" /> <FileText className="mr-2 h-4 w-4" />
Text File Text File
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<FileText className="mr-2 h-4 w-4" />
PDF
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer" onSelect={onAttachWebpage}> <DropdownMenuItem className="cursor-pointer" onSelect={onAttachWebpage}>
<Link className="mr-2 h-4 w-4" /> <Link className="mr-2 h-4 w-4" />
Webpage URL Webpage URL
@@ -59,6 +99,17 @@ export function AttachButton({ onAttachImage, onAttachWebpage, size = 'sm' }: At
ev.target.value = ''; ev.target.value = '';
}} }}
/> />
<input
ref={textInputRef}
type="file"
accept={TEXT_ACCEPT}
className="hidden"
onChange={(ev) => {
const file = ev.target.files?.[0];
if (file) handleTextFile(file);
ev.target.value = '';
}}
/>
</> </>
); );
} }
@@ -25,14 +25,8 @@ export function AttachmentList({ attachments, onRemove }: AttachmentListProps) {
) : ( ) : (
<Link className="h-3 w-3 shrink-0" /> <Link className="h-3 w-3 shrink-0" />
)} )}
<span className="truncate"> <span className="truncate">{a.type === 'image' ? a.filename : a.loading ? 'Loading...' : a.title}</span>
{a.type === 'image' ? a.filename : a.loading ? 'Loading...' : a.title} <button type="button" onClick={() => onRemove(i)} className="shrink-0 hover:text-foreground cursor-pointer">
</span>
<button
type="button"
onClick={() => onRemove(i)}
className="shrink-0 hover:text-duck-dark cursor-pointer"
>
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
</span> </span>
@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Check, ChevronDown, CircleSlash, Loader2, X } from 'lucide-react'; import { Check, ChevronDown, CircleSlash, Loader2, X } from 'lucide-react';
import type { Tone } from '@/components/Data';
import { toneText } from '@/components/Data';
import type { ChatMessage } from '../types'; import type { ChatMessage } from '../types';
import { useBackgroundTasks, useTaskDetail, type BackgroundTask } from '../useBackgroundTasks'; import { useBackgroundTasks, useTaskDetail, type BackgroundTask } from '../useBackgroundTasks';
import { SubagentTrace } from './ToolActivity'; import { SubagentTrace } from './ToolActivity';
@@ -8,6 +10,14 @@ type BackgroundTaskTrayProps = {
messages: ChatMessage[]; messages: ChatMessage[];
}; };
/** Same vocabulary as the transcript's own task rows — a completed task must not be green here and grey there. */
function statusTone(status: BackgroundTask['status']): Tone {
if (status === 'completed') return 'success';
if (status === 'failed') return 'danger';
if (status === 'stopped') return 'neutral';
return 'warning';
}
/** /**
* The background tasks of this conversation, docked above the input. * The background tasks of this conversation, docked above the input.
* *
@@ -27,11 +37,11 @@ export const BackgroundTaskTray = ({ messages }: BackgroundTaskTrayProps) => {
if (tasks.length === 0) return null; if (tasks.length === 0) return null;
return ( return (
<div className="mb-2 rounded-lg border border-duck-dark/10 bg-duck-dark/[0.03]"> <div className="mb-2 rounded-lg border border-border bg-muted/40">
{open && <TaskPanel task={open} onClose={() => setOpenId(null)} />} {open && <TaskPanel task={open} onClose={() => setOpenId(null)} />}
<div className="flex items-center gap-2 px-2 py-2"> <div className="flex items-center gap-2 px-2 py-2">
<span className="shrink-0 text-[10px] uppercase tracking-wider text-duck-dark/40"> <span className="shrink-0 text-xs uppercase tracking-wider text-muted-foreground">
{running.length > 0 ? `${running.length} running` : 'Background'} {running.length > 0 ? `${running.length} running` : 'Background'}
</span> </span>
{/* pb-1.5 keeps the scrollbar clear of the chips; the matching -mb-1.5 hides that pad from layout, {/* pb-1.5 keeps the scrollbar clear of the chips; the matching -mb-1.5 hides that pad from layout,
@@ -51,7 +61,8 @@ export const BackgroundTaskTray = ({ messages }: BackgroundTaskTrayProps) => {
type="button" type="button"
onClick={dismissFinished} onClick={dismissFinished}
title="Clear finished" title="Clear finished"
className="shrink-0 rounded p-1 text-duck-dark/30 transition-colors hover:bg-duck-dark/5 hover:text-duck-dark/60 cursor-pointer" aria-label="Clear finished tasks"
className="shrink-0 cursor-pointer rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</button> </button>
@@ -71,31 +82,29 @@ type TaskChipProps = {
const TaskChip = ({ task, active, onClick }: TaskChipProps) => { const TaskChip = ({ task, active, onClick }: TaskChipProps) => {
const { status } = task; const { status } = task;
const tone = const tone = toneText[statusTone(status)];
status === 'completed'
? 'text-green-600'
: status === 'failed'
? 'text-red-600'
: status === 'stopped'
? 'text-duck-dark/40'
: 'text-amber-500';
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className={`flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors cursor-pointer ${ aria-expanded={active}
className={`flex shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors ${
active active
? 'border-duck-teal/40 bg-duck-teal/10 text-duck-dark/80' ? 'border-duck-teal/40 bg-duck-teal/10 text-foreground'
: 'border-duck-dark/10 bg-background/60 text-duck-dark/60 hover:border-duck-dark/20 hover:text-duck-dark/80' : 'border-border bg-background/60 text-muted-foreground hover:border-foreground/20 hover:text-foreground'
}`} }`}
> >
{status === 'completed' ? ( {status === 'completed' ? (
<Check className={`h-3 w-3 ${tone}`} /> <Check className={`h-3 w-3 ${tone}`} />
) : status === 'failed' ? (
// Was a CircleSlash, the same icon as "stopped" — the two outcomes you most need to tell apart
// were the one glyph.
<X className={`h-3 w-3 ${tone}`} />
) : status ? ( ) : status ? (
<CircleSlash className={`h-3 w-3 ${tone}`} /> <CircleSlash className={`h-3 w-3 ${tone}`} />
) : ( ) : (
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-amber-400" /> <span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-warning" />
)} )}
<span className="max-w-[14rem] truncate">{task.description || 'Background task'}</span> <span className="max-w-[14rem] truncate">{task.description || 'Background task'}</span>
</button> </button>
@@ -123,19 +132,21 @@ const TaskPanel = ({ task, onClose }: TaskPanelProps) => {
}, [data]); }, [data]);
return ( return (
<div className="border-b border-duck-dark/10"> <div className="border-b border-border">
<div className="flex items-center gap-2 px-2 py-1.5"> <div className="flex items-center gap-2 px-2 py-1.5">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-xs text-duck-dark/70">{task.description || 'Background task'}</div> <div className="truncate text-xs text-foreground/80">{task.description || 'Background task'}</div>
<div className="truncate text-[10px] text-duck-dark/40"> <div className="truncate text-xs text-muted-foreground">
{task.taskType ?? 'task'} · {task.status ? (task.summary ?? task.status) : 'running…'} {task.taskType ?? 'task'} · {task.status ? (task.summary ?? task.status) : 'running…'}
</div> </div>
</div> </div>
{running && <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-amber-500" />} {running && <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-warning" />}
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="shrink-0 rounded p-1 text-duck-dark/30 transition-colors hover:bg-duck-dark/5 hover:text-duck-dark/60 cursor-pointer" title="Collapse"
aria-label="Collapse task detail"
className="shrink-0 cursor-pointer rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
> >
<ChevronDown className="h-3.5 w-3.5" /> <ChevronDown className="h-3.5 w-3.5" />
</button> </button>
@@ -147,7 +158,10 @@ const TaskPanel = ({ task, onClose }: TaskPanelProps) => {
const el = ev.currentTarget; const el = ev.currentTarget;
pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40; pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}} }}
className="max-h-64 overflow-y-auto border-t border-duck-dark/10 px-2 py-1.5" // The composer is `shrink-0` and the transcript above it is `flex-1 min-h-0`, so every pixel this
// panel takes comes out of the transcript — a flat max-h-64 could leave almost no conversation
// visible in a short panel. Capped against the viewport as well as in absolute terms.
className="max-h-[min(16rem,25vh)] overflow-y-auto border-t border-border px-2 py-1.5"
> >
<TaskBody detail={data} isLoading={isLoading} /> <TaskBody detail={data} isLoading={isLoading} />
</div> </div>
@@ -167,8 +181,10 @@ const TaskBody = ({ detail, isLoading }: TaskBodyProps) => {
if (detail.kind === 'log') { if (detail.kind === 'log') {
return ( return (
<> <>
{detail.truncated && <div className="mb-1 text-[10px] text-duck-dark/40"> earlier output trimmed</div>} {detail.truncated && <div className="mb-1 text-xs text-muted-foreground"> earlier output trimmed</div>}
<pre className="whitespace-pre-wrap break-all rounded bg-gray-900 p-2 font-mono text-[11px] text-green-400"> {/* The terminal look is deliberate and stays; break-words rather than break-all, which split
words mid-character and made paths unreadable. */}
<pre className="whitespace-pre-wrap break-words rounded bg-gray-900 p-2 font-mono text-xs text-green-400">
{detail.text || '(no output yet)'} {detail.text || '(no output yet)'}
</pre> </pre>
</> </>
@@ -179,4 +195,6 @@ const TaskBody = ({ detail, isLoading }: TaskBodyProps) => {
return <SubagentTrace messages={detail.messages} />; return <SubagentTrace messages={detail.messages} />;
}; };
const Placeholder = ({ text }: { text: string }) => <div className="px-1 py-2 text-xs text-duck-dark/40">{text}</div>; const Placeholder = ({ text }: { text: string }) => (
<div className="px-1 py-2 text-xs text-muted-foreground">{text}</div>
);
@@ -19,7 +19,7 @@ export const CopyButton = ({ text, className = '' }: CopyButtonProps) => {
<button <button
type="button" type="button"
onClick={handleCopy} onClick={handleCopy}
className={`p-1 rounded text-duck-dark dark:text-white opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity cursor-pointer ${className}`} className={`p-1 rounded text-foreground opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity cursor-pointer ${className}`}
title="Copy" title="Copy"
> >
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />} {copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
@@ -43,7 +43,7 @@ export const InputArea = ({ manager }: InputAreaProps) => {
const [urlDialogOpen, setUrlDialogOpen] = useState(false); const [urlDialogOpen, setUrlDialogOpen] = useState(false);
return ( return (
<div className="shrink-0 border-t border-duck-dark/10 bg-background/60 p-2 md:p-3"> <div className="shrink-0 border-t border-border bg-background/60 p-2 md:p-3">
{commandFeedback && ( {commandFeedback && (
<div className="mb-2 px-3 py-1.5 text-xs text-duck-teal bg-duck-teal/10 rounded-md">{commandFeedback}</div> <div className="mb-2 px-3 py-1.5 text-xs text-duck-teal bg-duck-teal/10 rounded-md">{commandFeedback}</div>
)} )}
@@ -53,7 +53,11 @@ export const InputArea = ({ manager }: InputAreaProps) => {
<AttachmentList attachments={attachments} onRemove={removeAttachment} /> <AttachmentList attachments={attachments} onRemove={removeAttachment} />
<div className="flex items-end gap-1 md:gap-2"> <div className="flex items-end gap-1 md:gap-2">
<AttachButton onAttachImage={attachImage} onAttachWebpage={() => setUrlDialogOpen(true)} /> <AttachButton
onAttachImage={attachImage}
onAttachWebpage={() => setUrlDialogOpen(true)}
onAttachText={appendToInput}
/>
<MicButton recording={recording} transcribing={transcribing} onToggle={toggleRecording} /> <MicButton recording={recording} transcribing={transcribing} onToggle={toggleRecording} />
<textarea <textarea
@@ -75,7 +79,7 @@ export const InputArea = ({ manager }: InputAreaProps) => {
}} }}
placeholder="Type a message..." placeholder="Type a message..."
rows={1} rows={1}
className="min-w-0 flex-1 resize-none rounded-lg border border-duck-dark/20 bg-background/80 px-2 py-1.5 md:px-3 md:py-2 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50" className="min-w-0 flex-1 resize-none rounded-lg border border-input bg-background/80 px-2 py-1.5 md:px-3 md:py-2 text-base md:text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
/> />
{isGenerating ? ( {isGenerating ? (
<Button <Button
@@ -128,14 +132,14 @@ const MicButton = ({ recording, transcribing, onToggle }: MicButtonProps) => (
type="button" type="button"
disabled={transcribing} disabled={transcribing}
onClick={onToggle} onClick={onToggle}
className="relative shrink-0 h-7 w-7 md:h-9 md:w-9 flex items-center justify-center rounded-lg text-duck-dark/40 hover:text-duck-dark/70 hover:bg-duck-dark/5 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed" className="relative shrink-0 h-7 w-7 md:h-9 md:w-9 flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
> >
{transcribing ? ( {transcribing ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : recording ? ( ) : recording ? (
<> <>
<span className="absolute inset-0 rounded-lg animate-ping bg-red-400/30" /> <span className="absolute inset-0 rounded-lg animate-ping bg-destructive/30" />
<Square className="h-3.5 w-3.5 text-red-500" /> <Square className="h-3.5 w-3.5 text-destructive" />
</> </>
) : ( ) : (
<Mic className="h-4 w-4" /> <Mic className="h-4 w-4" />
@@ -51,11 +51,11 @@ const FRONTMATTER_RE = /^<frontmatter>([\s\S]*?)<\/frontmatter>\s*/;
const CollapsibleBlock = ({ label, content }: { label: string; content: string }) => ( const CollapsibleBlock = ({ label, content }: { label: string; content: string }) => (
<div className="flex justify-center"> <div className="flex justify-center">
<details className="max-w-[85%] rounded-2xl bg-duck-dark/5 border border-duck-dark/10 px-4 py-2 text-sm"> <details className="max-w-[85%] rounded-2xl bg-muted border border-border px-4 py-2 text-sm">
<summary className="text-xs text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer select-none"> <summary className="text-xs text-muted-foreground hover:text-foreground cursor-pointer select-none">
{label} {label}
</summary> </summary>
<div className="mt-1.5 text-xs text-duck-dark/60 whitespace-pre-wrap border-l-2 border-duck-dark/15 pl-2 max-h-48 overflow-y-auto"> <div className="mt-1.5 text-xs text-muted-foreground whitespace-pre-wrap border-l-2 border-border pl-2 max-h-48 overflow-y-auto">
{content} {content}
</div> </div>
</details> </details>
@@ -103,7 +103,7 @@ const ReadAloudButton = ({ id, text }: { id: string; text: string }) => {
<button <button
type="button" type="button"
onClick={handleClick} onClick={handleClick}
className="p-1 rounded text-duck-dark dark:text-white opacity-60 hover:opacity-100 transition-opacity cursor-pointer" className="p-1 rounded text-foreground opacity-60 hover:opacity-100 transition-opacity cursor-pointer"
> >
{state === 'loading' && <Loader2 className="h-3.5 w-3.5 animate-spin" />} {state === 'loading' && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{state === 'playing' && <Square className="h-3.5 w-3.5" />} {state === 'playing' && <Square className="h-3.5 w-3.5" />}
@@ -64,7 +64,7 @@ export const MessageList = ({ manager }: MessageListProps) => {
)} )}
<div ref={scrollViewportRef} className="h-full overflow-y-auto"> <div ref={scrollViewportRef} className="h-full overflow-y-auto">
{messages.length === 0 && !isGenerating && ( {messages.length === 0 && !isGenerating && (
<div className="flex items-center justify-center h-full min-h-[200px] text-duck-dark/30 text-sm"> <div className="flex items-center justify-center h-full min-h-[200px] text-muted-foreground text-sm">
Send a message to start Send a message to start
</div> </div>
)} )}
@@ -79,7 +79,7 @@ export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) =
</div> </div>
<div className="px-4 py-3 space-y-3"> <div className="px-4 py-3 space-y-3">
<p className="text-sm text-duck-dark font-medium">{q.question}</p> <p className="text-sm text-foreground font-medium">{q.question}</p>
<div className="space-y-1.5"> <div className="space-y-1.5">
{q.options.map((opt) => { {q.options.map((opt) => {
@@ -94,17 +94,19 @@ export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) =
disabled={isDisabled} disabled={isDisabled}
className={`w-full text-left px-3 py-2 rounded-lg border text-sm transition-colors ${ className={`w-full text-left px-3 py-2 rounded-lg border text-sm transition-colors ${
isSelected isSelected
? 'border-duck-teal bg-duck-teal/10 text-duck-dark' ? 'border-duck-teal bg-duck-teal/10 text-foreground'
: isDisabled : isDisabled
? 'border-duck-dark/10 bg-duck-dark/5 text-duck-dark/40 cursor-not-allowed' ? 'border-border bg-muted text-muted-foreground cursor-not-allowed'
: 'border-duck-dark/15 hover:border-duck-teal/40 hover:bg-duck-teal/5 text-duck-dark cursor-pointer' : 'border-border hover:border-duck-teal/40 hover:bg-duck-teal/5 text-foreground cursor-pointer'
}`} }`}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isSelected && <Check className="h-3.5 w-3.5 text-duck-teal shrink-0" />} {isSelected && <Check className="h-3.5 w-3.5 text-duck-teal shrink-0" />}
<div> <div>
<span className="font-medium">{opt.label}</span> <span className="font-medium">{opt.label}</span>
{opt.description && <span className="text-duck-dark/50 ml-1.5">&mdash; {opt.description}</span>} {opt.description && (
<span className="text-muted-foreground ml-1.5">&mdash; {opt.description}</span>
)}
</div> </div>
</div> </div>
</button> </button>
@@ -126,7 +128,7 @@ export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) =
} }
}} }}
placeholder="Other..." placeholder="Other..."
className="flex-1 px-3 py-1.5 rounded-lg border border-duck-dark/15 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:border-duck-teal/40" className="flex-1 px-3 py-1.5 rounded-lg border border-border text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-duck-teal/40"
/> />
<button <button
onClick={handleSubmitOther} onClick={handleSubmitOther}
@@ -38,7 +38,7 @@ export function WebpageDialog({ open, onOpenChange, onSubmit }: WebpageDialogPro
} }
}} }}
placeholder="https://example.com" placeholder="https://example.com"
className="flex-1 rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50" className="flex-1 rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
autoFocus autoFocus
/> />
<Button <Button
@@ -37,13 +37,13 @@ type DetailBarProps = {
function DetailBar({ sessionTitle, isConnected, isGenerating, onDisconnect }: DetailBarProps) { function DetailBar({ sessionTitle, isConnected, isGenerating, onDisconnect }: DetailBarProps) {
return ( return (
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60"> <div className="shrink-0 flex items-center px-4 py-2 border-b border-border bg-background/60">
<div className="flex-1 min-w-0 text-center text-sm font-medium text-duck-dark/70 dark:text-foreground/70 truncate px-3"> <div className="flex-1 min-w-0 text-center text-sm font-medium text-foreground/80 truncate px-3">
{sessionTitle ?? 'New chat'} {sessionTitle ?? 'New chat'}
</div> </div>
<div className="flex items-center gap-2 text-xs text-duck-dark/50 dark:text-foreground/50"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
{!isConnected ? ( {!isConnected ? (
<span className="inline-block h-2 w-2 rounded-full bg-red-500" /> <span className="inline-block h-2 w-2 rounded-full bg-destructive" />
) : isGenerating ? ( ) : isGenerating ? (
<span className="inline-block h-2 w-2 rounded-full bg-duck-orange animate-pulse" /> <span className="inline-block h-2 w-2 rounded-full bg-duck-orange animate-pulse" />
) : ( ) : (
@@ -55,7 +55,7 @@ function DetailBar({ sessionTitle, isConnected, isGenerating, onDisconnect }: De
type="button" type="button"
onClick={onDisconnect} onClick={onDisconnect}
title="End session — kills any running turn and releases it so it can be resumed elsewhere" title="End session — kills any running turn and releases it so it can be resumed elsewhere"
className="ml-1 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 hover:text-red-500 transition-colors cursor-pointer" className="ml-1 p-1 rounded hover:bg-muted hover:text-destructive transition-colors cursor-pointer"
> >
<Unplug className="h-3.5 w-3.5" /> <Unplug className="h-3.5 w-3.5" />
</button> </button>
@@ -138,7 +138,7 @@ export const ChatDetailPanel = () => {
if (!selected) { if (!selected) {
return ( return (
<div className="h-full flex items-center justify-center text-duck-dark/30 dark:text-foreground/30 text-sm"> <div className="h-full flex items-center justify-center text-muted-foreground text-sm">
Select a session to view Select a session to view
</div> </div>
); );
@@ -46,12 +46,12 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
return ( return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="flex h-[70vh] max-w-2xl flex-col gap-0 p-0"> <DialogContent className="flex h-[70vh] max-w-2xl flex-col gap-0 p-0">
<DialogHeader className="border-b border-duck-dark/10 dark:border-foreground/10 px-4 py-3"> <DialogHeader className="border-b border-border px-4 py-3">
<DialogTitle className="text-sm">Choose a working directory</DialogTitle> <DialogTitle className="text-sm">Choose a working directory</DialogTitle>
</DialogHeader> </DialogHeader>
{/* Breadcrumb */} {/* Breadcrumb */}
<div className="flex flex-wrap items-center gap-1 border-b border-duck-dark/10 dark:border-foreground/10 px-4 py-2 text-xs text-duck-dark/60 dark:text-foreground/60"> <div className="flex flex-wrap items-center gap-1 border-b border-border px-4 py-2 text-xs text-muted-foreground">
<button onClick={() => goto(-1)} className="hover:text-duck-teal cursor-pointer"> <button onClick={() => goto(-1)} className="hover:text-duck-teal cursor-pointer">
~ ~
</button> </button>
@@ -88,7 +88,7 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex flex-col gap-2 border-t border-duck-dark/10 dark:border-foreground/10 px-4 py-3"> <div className="flex flex-col gap-2 border-t border-border px-4 py-3">
{creating ? ( {creating ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<input <input
@@ -100,14 +100,20 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
if (ev.key === 'Escape') setCreating(false); if (ev.key === 'Escape') setCreating(false);
}} }}
placeholder="New folder name" placeholder="New folder name"
className="min-w-0 flex-1 rounded border border-duck-dark/15 dark:border-foreground/15 bg-transparent px-2 py-1 text-xs outline-none" className="min-w-0 flex-1 rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
/> />
<button onClick={createFolder} className="rounded px-2 py-1 text-xs text-duck-teal hover:bg-duck-teal/10 cursor-pointer"> <button
onClick={createFolder}
className="rounded px-2 py-1 text-xs text-duck-teal hover:bg-duck-teal/10 cursor-pointer"
>
Create Create
</button> </button>
</div> </div>
) : ( ) : (
<button onClick={() => setCreating(true)} className="flex items-center gap-1.5 self-start text-xs opacity-60 hover:opacity-100 cursor-pointer"> <button
onClick={() => setCreating(true)}
className="flex items-center gap-1.5 self-start text-xs opacity-60 hover:opacity-100 cursor-pointer"
>
<FolderPlus className="h-3.5 w-3.5" /> New folder <FolderPlus className="h-3.5 w-3.5" /> New folder
</button> </button>
)} )}
@@ -115,7 +121,10 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
<span className="min-w-0 flex-1 truncate text-xs opacity-60" title={absCurrent}> <span className="min-w-0 flex-1 truncate text-xs opacity-60" title={absCurrent}>
{absCurrent} {absCurrent}
</span> </span>
<button onClick={onClose} className="rounded-md px-3 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer"> <button
onClick={onClose}
className="rounded-md px-3 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer"
>
Cancel Cancel
</button> </button>
<button <button
@@ -36,7 +36,7 @@ export const PwdSelector = ({ value, onChange }: PwdSelectorProps) => {
<button <button
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
title={isDefaultActive ? (defaultCwd ?? 'Default') : value!} title={isDefaultActive ? (defaultCwd ?? 'Default') : value!}
className="flex items-center gap-1.5 max-w-[13rem] rounded-md border border-duck-dark/10 dark:border-foreground/10 px-2 py-1 text-xs text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 cursor-pointer transition-colors" className="flex items-center gap-1.5 max-w-[13rem] rounded-md border border-border px-2 py-1 text-xs text-foreground/80 hover:bg-muted cursor-pointer transition-colors"
> >
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-duck-teal/70" /> <FolderOpen className="h-3.5 w-3.5 shrink-0 text-duck-teal/70" />
<span className="truncate">{label}</span> <span className="truncate">{label}</span>
@@ -46,7 +46,7 @@ export const PwdSelector = ({ value, onChange }: PwdSelectorProps) => {
{open && ( {open && (
<> <>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} /> <div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute left-0 z-20 mt-1 w-80 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-background shadow-lg"> <div className="absolute left-0 z-20 mt-1 w-80 rounded-md border border-border bg-background shadow-lg">
<div className="max-h-72 overflow-y-auto py-1"> <div className="max-h-72 overflow-y-auto py-1">
{pwds.map((p) => { {pwds.map((p) => {
const selected = p.isDefault ? isDefaultActive : value === p.cwd; const selected = p.isDefault ? isDefaultActive : value === p.cwd;
@@ -55,7 +55,7 @@ export const PwdSelector = ({ value, onChange }: PwdSelectorProps) => {
key={p.cwd} key={p.cwd}
onClick={() => pick(p.isDefault ? null : p.cwd)} onClick={() => pick(p.isDefault ? null : p.cwd)}
title={p.cwd} title={p.cwd}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs cursor-pointer hover:bg-duck-dark/5 dark:hover:bg-foreground/5 ${selected ? 'text-duck-teal' : 'text-duck-dark/70 dark:text-foreground/70'}`} className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs cursor-pointer hover:bg-muted ${selected ? 'text-duck-teal' : 'text-foreground/80'}`}
> >
<Check className={`h-3.5 w-3.5 shrink-0 ${selected ? 'opacity-100' : 'opacity-0'}`} /> <Check className={`h-3.5 w-3.5 shrink-0 ${selected ? 'opacity-100' : 'opacity-0'}`} />
<span className="min-w-0 flex-1 truncate"> <span className="min-w-0 flex-1 truncate">
@@ -71,11 +71,11 @@ export const PwdSelector = ({ value, onChange }: PwdSelectorProps) => {
setOpen(false); setOpen(false);
setBrowse(true); setBrowse(true);
}} }}
className="flex w-full items-center gap-2 border-t border-duck-dark/10 dark:border-foreground/10 px-3 py-2 text-left text-xs text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 cursor-pointer" className="flex w-full items-center gap-2 border-t border-border px-3 py-2 text-left text-xs text-foreground/80 hover:bg-muted cursor-pointer"
> >
<FolderSearch className="h-3.5 w-3.5 shrink-0 text-duck-teal/70" /> Browse <FolderSearch className="h-3.5 w-3.5 shrink-0 text-duck-teal/70" /> Browse
</button> </button>
<div className="flex items-center gap-1 border-t border-duck-dark/10 dark:border-foreground/10 p-2"> <div className="flex items-center gap-1 border-t border-border p-2">
<input <input
value={custom} value={custom}
onChange={(ev) => setCustom(ev.target.value)} onChange={(ev) => setCustom(ev.target.value)}