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:
@@ -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">— {opt.description}</span>}
|
||||
{opt.description && (
|
||||
<span className="text-muted-foreground ml-1.5">— {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)}
|
||||
|
||||
Reference in New Issue
Block a user