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} />
<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
ref={textareaRef}
value={input}
@@ -92,7 +97,7 @@ export function ChatLauncher({
}}
placeholder={placeholder}
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
onClick={handleSubmit}
@@ -1,5 +1,6 @@
import { useRef } from 'react';
import { Paperclip, Image, Link, FileText } from 'lucide-react';
import { toast } from '@/components/ui/sonner';
import {
DropdownMenu,
DropdownMenuContent,
@@ -10,21 +11,61 @@ import {
type AttachButtonProps = {
onAttachImage: (file: File) => 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';
};
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 textInputRef = useRef<HTMLInputElement>(null);
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 (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<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" />
</button>
@@ -34,14 +75,13 @@ export function AttachButton({ onAttachImage, onAttachWebpage, size = 'sm' }: At
<Image className="mr-2 h-4 w-4" />
Image
</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" />
Text File
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<FileText className="mr-2 h-4 w-4" />
PDF
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer" onSelect={onAttachWebpage}>
<Link className="mr-2 h-4 w-4" />
Webpage URL
@@ -59,6 +99,17 @@ export function AttachButton({ onAttachImage, onAttachWebpage, size = 'sm' }: At
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" />
)}
<span className="truncate">
{a.type === 'image' ? a.filename : a.loading ? 'Loading...' : a.title}
</span>
<button
type="button"
onClick={() => onRemove(i)}
className="shrink-0 hover:text-duck-dark cursor-pointer"
>
<span className="truncate">{a.type === 'image' ? a.filename : a.loading ? 'Loading...' : a.title}</span>
<button type="button" onClick={() => onRemove(i)} className="shrink-0 hover:text-foreground cursor-pointer">
<X className="h-3 w-3" />
</button>
</span>
@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from '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 { useBackgroundTasks, useTaskDetail, type BackgroundTask } from '../useBackgroundTasks';
import { SubagentTrace } from './ToolActivity';
@@ -8,6 +10,14 @@ type BackgroundTaskTrayProps = {
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.
*
@@ -27,11 +37,11 @@ export const BackgroundTaskTray = ({ messages }: BackgroundTaskTrayProps) => {
if (tasks.length === 0) return null;
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)} />}
<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'}
</span>
{/* 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"
onClick={dismissFinished}
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" />
</button>
@@ -71,31 +82,29 @@ type TaskChipProps = {
const TaskChip = ({ task, active, onClick }: TaskChipProps) => {
const { status } = task;
const tone =
status === 'completed'
? 'text-green-600'
: status === 'failed'
? 'text-red-600'
: status === 'stopped'
? 'text-duck-dark/40'
: 'text-amber-500';
const tone = toneText[statusTone(status)];
return (
<button
type="button"
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
? 'border-duck-teal/40 bg-duck-teal/10 text-duck-dark/80'
: 'border-duck-dark/10 bg-background/60 text-duck-dark/60 hover:border-duck-dark/20 hover:text-duck-dark/80'
? 'border-duck-teal/40 bg-duck-teal/10 text-foreground'
: 'border-border bg-background/60 text-muted-foreground hover:border-foreground/20 hover:text-foreground'
}`}
>
{status === 'completed' ? (
<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 ? (
<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>
</button>
@@ -123,19 +132,21 @@ const TaskPanel = ({ task, onClose }: TaskPanelProps) => {
}, [data]);
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="min-w-0 flex-1">
<div className="truncate text-xs text-duck-dark/70">{task.description || 'Background task'}</div>
<div className="truncate text-[10px] text-duck-dark/40">
<div className="truncate text-xs text-foreground/80">{task.description || 'Background task'}</div>
<div className="truncate text-xs text-muted-foreground">
{task.taskType ?? 'task'} · {task.status ? (task.summary ?? task.status) : 'running…'}
</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
type="button"
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" />
</button>
@@ -147,7 +158,10 @@ const TaskPanel = ({ task, onClose }: TaskPanelProps) => {
const el = ev.currentTarget;
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} />
</div>
@@ -167,8 +181,10 @@ const TaskBody = ({ detail, isLoading }: TaskBodyProps) => {
if (detail.kind === 'log') {
return (
<>
{detail.truncated && <div className="mb-1 text-[10px] text-duck-dark/40"> earlier output trimmed</div>}
<pre className="whitespace-pre-wrap break-all rounded bg-gray-900 p-2 font-mono text-[11px] text-green-400">
{detail.truncated && <div className="mb-1 text-xs text-muted-foreground"> earlier output trimmed</div>}
{/* 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)'}
</pre>
</>
@@ -179,4 +195,6 @@ const TaskBody = ({ detail, isLoading }: TaskBodyProps) => {
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
type="button"
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"
>
{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);
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 && (
<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} />
<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} />
<textarea
@@ -75,7 +79,7 @@ export const InputArea = ({ manager }: InputAreaProps) => {
}}
placeholder="Type a message..."
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 ? (
<Button
@@ -128,14 +132,14 @@ const MicButton = ({ recording, transcribing, onToggle }: MicButtonProps) => (
type="button"
disabled={transcribing}
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 ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : recording ? (
<>
<span className="absolute inset-0 rounded-lg animate-ping bg-red-400/30" />
<Square className="h-3.5 w-3.5 text-red-500" />
<span className="absolute inset-0 rounded-lg animate-ping bg-destructive/30" />
<Square className="h-3.5 w-3.5 text-destructive" />
</>
) : (
<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 }) => (
<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">
<summary className="text-xs text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer select-none">
<details className="max-w-[85%] rounded-2xl bg-muted border border-border px-4 py-2 text-sm">
<summary className="text-xs text-muted-foreground hover:text-foreground cursor-pointer select-none">
{label}
</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}
</div>
</details>
@@ -103,7 +103,7 @@ const ReadAloudButton = ({ id, text }: { id: string; text: string }) => {
<button
type="button"
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 === '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">
{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
</div>
)}
@@ -79,7 +79,7 @@ export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) =
</div>
<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">
{q.options.map((opt) => {
@@ -94,17 +94,19 @@ export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) =
disabled={isDisabled}
className={`w-full text-left px-3 py-2 rounded-lg border text-sm transition-colors ${
isSelected
? 'border-duck-teal bg-duck-teal/10 text-duck-dark'
? 'border-duck-teal bg-duck-teal/10 text-foreground'
: isDisabled
? 'border-duck-dark/10 bg-duck-dark/5 text-duck-dark/40 cursor-not-allowed'
: 'border-duck-dark/15 hover:border-duck-teal/40 hover:bg-duck-teal/5 text-duck-dark cursor-pointer'
? 'border-border bg-muted text-muted-foreground cursor-not-allowed'
: 'border-border hover:border-duck-teal/40 hover:bg-duck-teal/5 text-foreground cursor-pointer'
}`}
>
<div className="flex items-center gap-2">
{isSelected && <Check className="h-3.5 w-3.5 text-duck-teal shrink-0" />}
<div>
<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>
</button>
@@ -126,7 +128,7 @@ export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) =
}
}}
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
onClick={handleSubmitOther}
@@ -38,7 +38,7 @@ export function WebpageDialog({ open, onOpenChange, onSubmit }: WebpageDialogPro
}
}}
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
/>
<Button
@@ -37,13 +37,13 @@ type DetailBarProps = {
function DetailBar({ sessionTitle, isConnected, isGenerating, onDisconnect }: DetailBarProps) {
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="flex-1 min-w-0 text-center text-sm font-medium text-duck-dark/70 dark:text-foreground/70 truncate px-3">
<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-foreground/80 truncate px-3">
{sessionTitle ?? 'New chat'}
</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 ? (
<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 ? (
<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"
onClick={onDisconnect}
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" />
</button>
@@ -138,7 +138,7 @@ export const ChatDetailPanel = () => {
if (!selected) {
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
</div>
);
@@ -46,12 +46,12 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<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>
</DialogHeader>
{/* 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>
@@ -88,7 +88,7 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
</div>
{/* 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 ? (
<div className="flex items-center gap-1">
<input
@@ -100,14 +100,20 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
if (ev.key === 'Escape') setCreating(false);
}}
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
</button>
</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
</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}>
{absCurrent}
</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
</button>
<button
@@ -36,7 +36,7 @@ export const PwdSelector = ({ value, onChange }: PwdSelectorProps) => {
<button
onClick={() => setOpen((o) => !o)}
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" />
<span className="truncate">{label}</span>
@@ -46,7 +46,7 @@ export const PwdSelector = ({ value, onChange }: PwdSelectorProps) => {
{open && (
<>
<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">
{pwds.map((p) => {
const selected = p.isDefault ? isDefaultActive : value === p.cwd;
@@ -55,7 +55,7 @@ export const PwdSelector = ({ value, onChange }: PwdSelectorProps) => {
key={p.cwd}
onClick={() => pick(p.isDefault ? null : 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'}`} />
<span className="min-w-0 flex-1 truncate">
@@ -71,11 +71,11 @@ export const PwdSelector = ({ value, onChange }: PwdSelectorProps) => {
setOpen(false);
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
</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
value={custom}
onChange={(ev) => setCustom(ev.target.value)}