This commit is contained in:
2026-02-16 19:34:35 +00:00
commit 9ab0940ca4
784 changed files with 41710 additions and 0 deletions
@@ -0,0 +1,230 @@
import type { KeyboardEvent, RefObject } from 'react';
import { useState, useRef } from 'react';
import { FileText, Image, Link, Loader2, Paperclip, Send, Square, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { ModelOption } from '@/state/useModels';
import type { ChatMessage } from '../types';
import type { Attachment } from './index';
import { Settings } from './Settings';
type InputAreaProps = {
input: string;
onInputChange: (value: string) => void;
onKeyDown: (ev: KeyboardEvent<HTMLTextAreaElement>) => void;
onSend: () => void;
onStop: () => void;
isGenerating: boolean;
isConnected: boolean;
commandFeedback: string | null;
textareaRef: RefObject<HTMLTextAreaElement | null>;
provider: 'claude' | 'opencode';
messages: ChatMessage[];
onProviderChange?: (provider: 'claude' | 'opencode') => void;
availableModels: ModelOption[];
selectedModel: string | null;
onModelChange: (modelId: string) => void;
model: string | null;
attachments: Attachment[];
onAttachWebpage: (url: string) => void;
onAttachImage: (file: File) => void;
onRemoveAttachment: (index: number) => void;
};
export const InputArea = ({
input,
onInputChange,
onKeyDown,
onSend,
onStop,
isGenerating,
isConnected,
commandFeedback,
textareaRef,
provider,
messages,
onProviderChange,
availableModels,
selectedModel,
onModelChange,
model,
attachments,
onAttachWebpage,
onAttachImage,
onRemoveAttachment,
}: InputAreaProps) => {
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
const [urlInput, setUrlInput] = useState('');
const imageInputRef = useRef<HTMLInputElement>(null);
const handleUrlSubmit = () => {
const url = urlInput.trim();
if (!url) return;
onAttachWebpage(url);
setUrlInput('');
setUrlDialogOpen(false);
};
return (
<div className="shrink-0 border-t border-duck-dark/10 bg-white/60 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>
)}
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{attachments.map((a, i) => (
<span
key={i}
className="relative inline-flex items-center gap-1 px-2 py-1 text-xs bg-duck-teal/10 text-duck-teal rounded-md max-w-[240px] group"
>
{a.loading ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
) : a.type === 'image' && a.dataUrl ? (
<img src={a.dataUrl} alt={a.filename} className="h-8 w-8 shrink-0 rounded object-cover" />
) : a.type === 'image' ? (
<Image className="h-3 w-3 shrink-0" />
) : (
<Link className="h-3 w-3 shrink-0" />
)}
<span className="truncate">{a.type === 'image' ? a.filename : a.loading ? a.url : a.title || a.url}</span>
<button
type="button"
onClick={() => onRemoveAttachment(i)}
className="shrink-0 hover:text-duck-dark cursor-pointer"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
<div className="flex items-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="shrink-0 h-9 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"
>
<Paperclip className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" className="z-[800]">
<DropdownMenuItem className="cursor-pointer" onSelect={() => imageInputRef.current?.click()}>
<Image className="mr-2 h-4 w-4" />
Image
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<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={() => setUrlDialogOpen(true)}>
<Link className="mr-2 h-4 w-4" />
Webpage URL
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(ev) => {
const file = ev.target.files?.[0];
if (file) onAttachImage(file);
ev.target.value = '';
}}
/>
<textarea
ref={textareaRef}
value={input}
onChange={(ev) => onInputChange(ev.target.value)}
onKeyDown={onKeyDown}
onPaste={(ev) => {
const items = ev.clipboardData?.items;
if (!items) return;
for (const item of Array.from(items)) {
if (item.type.startsWith('image/')) {
ev.preventDefault();
const file = item.getAsFile();
if (file) onAttachImage(file);
return;
}
}
}}
placeholder="Type a message..."
rows={1}
className="flex-1 resize-none rounded-lg border border-duck-dark/20 bg-white/80 px-3 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"
/>
{isGenerating ? (
<Button onClick={onStop} variant="destructive" size="icon" className="shrink-0 h-9 w-9 cursor-pointer">
<Square className="h-4 w-4" />
</Button>
) : (
<Button
onClick={onSend}
disabled={!input.trim() || !isConnected}
size="icon"
className="shrink-0 h-9 w-9 bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
>
<Send className="h-4 w-4" />
</Button>
)}
</div>
<Settings
provider={provider}
messages={messages}
onProviderChange={onProviderChange}
availableModels={availableModels}
selectedModel={selectedModel}
onModelChange={onModelChange}
model={model}
isConnected={isConnected}
isGenerating={isGenerating}
/>
<Dialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen}>
<DialogContent className="sm:max-w-md z-[700]">
<DialogHeader>
<DialogTitle>Attach Webpage</DialogTitle>
<DialogDescription>Enter a URL to scrape and attach as context.</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<input
type="url"
value={urlInput}
onChange={(ev) => setUrlInput(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
handleUrlSubmit();
}
}}
placeholder="https://example.com"
className="flex-1 rounded-lg border border-duck-dark/20 bg-white 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"
autoFocus
/>
<Button
onClick={handleUrlSubmit}
disabled={!urlInput.trim()}
className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
>
Attach
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
};
@@ -0,0 +1,52 @@
import type { RefObject } from 'react';
import { ArrowDown } from 'lucide-react';
import type { ChatMessage } from '../types';
import { MessageBubble, StreamingBubble } from '../MessageBubble';
type MessageListProps = {
messages: ChatMessage[];
streamingText: string;
isGenerating: boolean;
showJumpToBottom: boolean;
onJumpToBottom: () => void;
onQuestionAnswer?: (text: string) => void;
scrollViewportRef: RefObject<HTMLDivElement | null>;
bottomRef: RefObject<HTMLDivElement | null>;
};
export const MessageList = ({
messages,
streamingText,
isGenerating,
showJumpToBottom,
onJumpToBottom,
onQuestionAnswer,
scrollViewportRef,
bottomRef,
}: MessageListProps) => (
<div className="flex-1 min-h-0 relative">
<div ref={scrollViewportRef} className="h-full overflow-y-auto">
<div className="p-4 space-y-3">
{messages.length === 0 && !isGenerating && (
<div className="flex items-center justify-center h-full min-h-[200px] text-duck-dark/30 text-sm">
Send a message to start
</div>
)}
{messages.map((msg, i) => (
<MessageBubble key={i} message={msg} onAnswer={onQuestionAnswer} />
))}
{isGenerating && <StreamingBubble text={streamingText} />}
<div ref={bottomRef} />
</div>
</div>
{showJumpToBottom && (
<button
onClick={onJumpToBottom}
className="absolute bottom-2 left-1/2 -translate-x-1/2 bg-duck-teal text-white rounded-full p-1.5 shadow-lg hover:bg-duck-teal/90 transition-colors cursor-pointer"
>
<ArrowDown className="h-4 w-4" />
</button>
)}
</div>
);
@@ -0,0 +1,64 @@
import { Link } from 'react-router';
import { ArrowLeft, Archive, Trash2, Maximize2, Minimize2 } from 'lucide-react';
type SessionBarProps = {
listPath: string;
provider: 'claude' | 'opencode';
sessionTitle: string | undefined;
isConnected: boolean;
isGenerating: boolean;
fullscreen: boolean;
onArchive: (() => void) | undefined;
onDelete: () => void;
onToggleFullscreen: () => void;
};
export const SessionBar = ({
listPath,
provider,
sessionTitle,
isConnected,
isGenerating,
fullscreen,
onArchive,
onDelete,
onToggleFullscreen,
}: SessionBarProps) => (
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 bg-white/60">
<div className="flex items-center gap-1">
<Link to={listPath} className="p-1 text-duck-dark/40 hover:text-duck-dark transition-colors">
<ArrowLeft className="h-4 w-4" />
</Link>
{provider === 'claude' && onArchive && (
<button
onClick={onArchive}
className="p-1 text-duck-dark/40 hover:text-duck-teal transition-colors cursor-pointer"
>
<Archive className="h-4 w-4" />
</button>
)}
<button onClick={onDelete} className="p-1 text-duck-dark/40 hover:text-red-500 transition-colors cursor-pointer">
<Trash2 className="h-4 w-4" />
</button>
</div>
<div className="flex-1 min-w-0 text-center text-sm font-medium text-duck-dark/70 truncate px-3">
{sessionTitle ?? 'New chat'}
</div>
<div className="flex items-center gap-2 text-xs text-duck-dark/50">
{!isConnected ? (
<span className="inline-block h-2 w-2 rounded-full bg-red-500" />
) : 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-green-500" />
)}
<span>{!isConnected ? 'Disconnected' : isGenerating ? 'Working...' : ''}</span>
<button
onClick={onToggleFullscreen}
className="p-1 text-duck-dark/40 hover:text-duck-dark transition-colors cursor-pointer"
>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
</div>
</div>
);
@@ -0,0 +1,87 @@
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useAuth } from 'hooks/useAuth';
import type { ModelOption } from '@/state/useModels';
import type { ChatMessage } from '../types';
import { OpenCodeModelPicker } from '../OpenCodeModelPicker';
type SettingsProps = {
provider: 'claude' | 'opencode';
messages: ChatMessage[];
onProviderChange?: (provider: 'claude' | 'opencode') => void;
availableModels: ModelOption[];
selectedModel: string | null;
onModelChange: (modelId: string) => void;
model: string | null;
isConnected: boolean;
isGenerating: boolean;
};
export const Settings = ({
provider,
messages,
onProviderChange,
availableModels,
selectedModel,
onModelChange,
model,
isConnected,
isGenerating,
}: SettingsProps) => {
const { user } = useAuth();
const fallbackModelId = availableModels[0]?.id ?? null;
return (
<div className="flex items-center justify-between mt-2">
{messages.length > 0 ? (
<span className="rounded-md bg-duck-dark/80 px-3 py-1 text-xs font-medium text-white">
{provider === 'claude' ? 'Claude' : 'OpenCode'}
</span>
) : (
<div className="flex items-center gap-1 rounded-lg bg-white/60 p-1">
{(['claude', 'opencode'] as const).map((value) => (
<button
key={value}
onClick={() => onProviderChange?.(value)}
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${
provider === value ? 'bg-white text-duck-dark shadow-sm' : 'text-duck-dark/70 hover:text-duck-dark/90'
} ${!onProviderChange ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
>
{value === 'claude' ? 'Claude' : 'OpenCode'}
</button>
))}
</div>
)}
<div className="text-xs text-duck-dark/50">
{availableModels.length > 0 && provider === 'opencode' ? (
<OpenCodeModelPicker
models={availableModels}
selectedModel={selectedModel ?? fallbackModelId}
onSelect={onModelChange}
isConnected={isConnected}
isGenerating={isGenerating}
/>
) : availableModels.length > 0 ? (
<Select
value={selectedModel ?? fallbackModelId ?? undefined}
onValueChange={(v) => onModelChange(v)}
disabled={isGenerating || !isConnected}
>
<SelectTrigger className="h-auto border-0 bg-transparent p-0 text-xs text-duck-dark/50 shadow-none focus:ring-0 gap-1 cursor-pointer">
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[800]" side="top">
{availableModels.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span>{model ?? (provider === 'claude' ? 'Claude' : 'OpenCode')}</span>
)}
</div>
</div>
);
};
@@ -0,0 +1,122 @@
import { useRef, useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router';
import { useSessions } from '@/state/useSessions';
import type { ModelOption } from '@/state/useModels';
import { useOpenCodeSessions } from '@/state/useOpenCodeSessions';
import type { useClaude } from '../useClaude';
import { useSlashCommands } from '@/state/useSlashCommands';
import { Card } from '@/components/Card';
import { SessionBar } from './SessionBar';
import { EmbeddableChat } from '../EmbeddableChat';
export type { Attachment } from '../EmbeddableChat';
type ChatPanelProps = {
chat: ReturnType<typeof useClaude>;
provider?: 'claude' | 'opencode';
availableModels?: ModelOption[];
onProviderChange?: (provider: 'claude' | 'opencode') => void;
};
export const ChatPanel = ({ chat, provider = 'claude', availableModels = [], onProviderChange }: ChatPanelProps) => {
const { isConnected, isGenerating, sessionId, setSelectedModel, sendPrompt } = chat;
const location = useLocation();
const navigate = useNavigate();
const [commandFeedback, setCommandFeedback] = useState<string | null>(null);
const [fullscreen, setFullscreen] = useState(false);
const initialSentRef = useRef(false);
const claudeSessions = useSessions();
const opencodeSessions = useOpenCodeSessions();
const { archiveSession, deleteSession } =
provider === 'claude'
? claudeSessions
: { archiveSession: undefined, deleteSession: opencodeSessions.deleteSession };
const sessions = provider === 'claude' ? claudeSessions.sessions : opencodeSessions.sessions;
const slashCommands = useSlashCommands({ sessionId });
const sessionTitle = sessionId ? sessions.find((s) => s.id === sessionId)?.title : undefined;
const listPath = '/chat';
// Capture prefill input from location.state (one-time, before first render completes)
const locationState = location.state as {
initialMessage?: string;
prefillInput?: string;
model?: string;
cwd?: { root?: string; path: string };
attachmentIds?: string[];
images?: { filename: string; dataUrl: string }[];
} | null;
const initialPrefill = useRef(locationState?.prefillInput ?? '');
const handleBeforeSend = async (text: string) => {
if (text.startsWith('/')) {
const result = await slashCommands.execute(text);
if (result.handled) {
setCommandFeedback(result.feedback);
return true;
}
}
setCommandFeedback(null);
return false;
};
// Auto-send initial message from Home launcher
useEffect(() => {
const state = location.state as typeof locationState;
if (!state || initialSentRef.current) return;
if (state.prefillInput) {
initialSentRef.current = true;
window.history.replaceState({}, '', location.pathname);
return;
}
if (!state.initialMessage || !isConnected) return;
initialSentRef.current = true;
if (state.model) setSelectedModel(state.model);
sendPrompt(state.initialMessage, state.attachmentIds, state.images, state.cwd);
// Clear the location state so refresh doesn't re-send
window.history.replaceState({}, '', location.pathname);
}, [isConnected, location.state]);
return (
<Card
className={`flex flex-col overflow-hidden transition-all duration-200 rounded-none border-0 md:rounded-xl md:border-2 ${
fullscreen ? 'fixed inset-0 m-auto z-50 w-[90vw] h-[calc(90vh-4.5rem)]' : 'h-full'
}`}
>
<SessionBar
listPath={listPath}
provider={provider}
sessionTitle={sessionTitle}
isConnected={isConnected}
isGenerating={isGenerating}
fullscreen={fullscreen}
onArchive={
archiveSession && sessionId
? async () => {
await archiveSession(sessionId);
navigate(listPath);
}
: undefined
}
onDelete={async () => {
if (!sessionId) return;
await deleteSession(sessionId);
navigate(listPath);
}}
onToggleFullscreen={() => setFullscreen((f) => !f)}
/>
<EmbeddableChat
chat={chat}
provider={provider}
availableModels={availableModels}
onProviderChange={onProviderChange}
onBeforeSend={handleBeforeSend}
commandFeedback={commandFeedback}
defaultInput={initialPrefill.current}
className="flex-1 min-h-0"
/>
</Card>
);
};
@@ -0,0 +1,254 @@
import type { KeyboardEvent } from 'react';
import { useRef, useEffect, useState } from 'react';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import type { ModelOption } from '@/state/useModels';
import type { useClaude } from './useClaude';
import { MessageList } from './ChatPanel/MessageList';
import { InputArea } from './ChatPanel/InputArea';
export type Attachment =
| { type: 'webpage'; url: string; title: string; content: string; attachmentId: string; loading?: boolean }
| { type: 'image'; filename: string; dataUrl: string; attachmentId: string; loading?: boolean };
type EmbeddableChatProps = {
chat: ReturnType<typeof useClaude>;
provider?: 'claude' | 'opencode';
availableModels?: ModelOption[];
onProviderChange?: (provider: 'claude' | 'opencode') => void;
onBeforeSend?: (text: string) => boolean | Promise<boolean>;
commandFeedback?: string | null;
defaultInput?: string;
className?: string;
cwd?: { root?: string; path: string };
autoSend?: boolean;
};
export const EmbeddableChat = ({
chat,
provider = 'claude',
availableModels = [],
onProviderChange,
onBeforeSend,
commandFeedback = null,
defaultInput = '',
className,
cwd,
autoSend = false,
}: EmbeddableChatProps) => {
const {
messages,
streamingText,
isConnected,
isGenerating,
sessionId,
model,
selectedModel,
setSelectedModel,
sendPrompt,
stopGeneration,
} = chat;
const client = useClient();
const [input, setInput] = useState(defaultInput);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [showJumpToBottom, setShowJumpToBottom] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
const bottomRef = useRef<HTMLDivElement | null>(null);
const userScrolledRef = useRef(false);
const handleAttachWebpage = async (url: string) => {
const idx = attachments.length;
setAttachments((prev) => [
...prev,
{ type: 'webpage', url, title: '', content: '', attachmentId: '', loading: true },
]);
try {
const res = await client.post<{ url: string; title: string; content: string; attachmentId: string }>('/scrape', {
url,
sessionId: sessionId ?? undefined,
provider,
});
setAttachments((prev) =>
prev.map((a, i) =>
i === idx
? { ...a, title: res.title, content: res.content, attachmentId: res.attachmentId, loading: false }
: a,
),
);
} catch {
setAttachments((prev) => prev.filter((_, i) => i !== idx));
toast.error('Failed to scrape webpage');
}
};
const handleAttachImage = async (file: File) => {
const idx = attachments.length;
setAttachments((prev) => [
...prev,
{ type: 'image', filename: file.name, dataUrl: '', attachmentId: '', loading: true },
]);
try {
const formData = new FormData();
formData.append('file', file);
if (sessionId) formData.append('sessionId', sessionId);
formData.append('provider', provider);
const res = await client.post<{ filename: string; dataUrl: string; attachmentId: string }>('/upload', formData);
setAttachments((prev) =>
prev.map((a, i) =>
i === idx ? { ...a, dataUrl: res.dataUrl, attachmentId: res.attachmentId, loading: false } : a,
),
);
} catch {
setAttachments((prev) => prev.filter((_, i) => i !== idx));
toast.error('Failed to upload image');
}
};
const handleRemoveAttachment = (index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index));
};
const handleSend = async () => {
const text = input.trim();
if (!text || isGenerating) return;
if (onBeforeSend) {
const handled = await onBeforeSend(text);
if (handled) {
setInput('');
if (textareaRef.current) textareaRef.current.style.height = 'auto';
return;
}
}
// Prepend attachment content to the prompt
let prompt = text;
const ids: string[] = [];
const images: { filename: string; dataUrl: string }[] = [];
for (const a of attachments) {
if (a.loading) continue;
if (a.type === 'webpage' && a.content) {
prompt = `[Attached webpage: ${a.url}]\n${a.content}\n\n${prompt}`;
} else if (a.type === 'image' && a.dataUrl) {
prompt = `[Attached image: ${a.filename}]\n\n${prompt}`;
images.push({ filename: a.filename, dataUrl: a.dataUrl });
}
ids.push(a.attachmentId);
}
// On first message (no sessionId), include attachmentIds so server can relocate tmp files
const cwdForFirst = !sessionId ? cwd : undefined;
sendPrompt(
prompt,
!sessionId && ids.length > 0 ? ids : undefined,
images.length > 0 ? images : undefined,
cwdForFirst,
);
setAttachments([]);
setInput('');
userScrolledRef.current = false;
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
};
const handleKeyDown = (ev: KeyboardEvent<HTMLTextAreaElement>) => {
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
handleSend();
}
};
// Auto-resize textarea
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
}, [input]);
// Auto-scroll to bottom on new messages
useEffect(() => {
if (!userScrolledRef.current) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [messages, streamingText]);
// Detect user scrolling up
useEffect(() => {
const viewport = scrollViewportRef.current;
if (!viewport) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = viewport;
const atBottom = scrollHeight - scrollTop - clientHeight < 60;
userScrolledRef.current = !atBottom;
setShowJumpToBottom(!atBottom);
};
viewport.addEventListener('scroll', handleScroll);
return () => viewport.removeEventListener('scroll', handleScroll);
}, []);
const jumpToBottom = () => {
userScrolledRef.current = false;
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
};
// Focus textarea on mount
useEffect(() => {
textareaRef.current?.focus();
}, []);
// Auto-send first message when autoSend is enabled
const autoSentRef = useRef(false);
useEffect(() => {
if (autoSend && isConnected && !messages.length && input.trim() && !autoSentRef.current) {
autoSentRef.current = true;
handleSend();
}
}, [autoSend, isConnected, messages.length, input]);
return (
<div className={`flex flex-col ${className ?? ''}`}>
<MessageList
messages={messages}
streamingText={streamingText}
isGenerating={isGenerating}
showJumpToBottom={showJumpToBottom}
onJumpToBottom={jumpToBottom}
onQuestionAnswer={(text) => sendPrompt(text)}
scrollViewportRef={scrollViewportRef}
bottomRef={bottomRef}
/>
<InputArea
input={input}
onInputChange={setInput}
onKeyDown={handleKeyDown}
onSend={handleSend}
onStop={stopGeneration}
isGenerating={isGenerating}
isConnected={isConnected}
commandFeedback={commandFeedback}
textareaRef={textareaRef}
provider={provider}
messages={messages}
onProviderChange={onProviderChange}
availableModels={availableModels}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
model={model}
attachments={attachments}
onAttachWebpage={handleAttachWebpage}
onAttachImage={handleAttachImage}
onRemoveAttachment={handleRemoveAttachment}
/>
</div>
);
};
@@ -0,0 +1,94 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import type { ChatMessage } from './types';
import { ToolActivity } from './ToolActivity';
import { QuestionActivity } from './QuestionActivity';
type MessageBubbleProps = {
message: ChatMessage;
onAnswer?: (text: string) => void;
};
export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
const text = formatText(message.text);
switch (message.role) {
case 'user':
return (
<div className="flex justify-end">
<div className="max-w-[80%] rounded-2xl rounded-tr-sm bg-duck-yellow/10 border border-duck-yellow/20 px-4 py-2.5 text-sm text-duck-dark">
{message.images?.map((img, i) => (
<img key={i} src={img.dataUrl} alt={img.filename} className="max-w-full max-h-64 rounded-lg mb-2" />
))}
<div className="whitespace-pre-wrap">{text}</div>
</div>
</div>
);
case 'assistant':
if (!text) return null;
return (
<div className="flex justify-start">
<div className="max-w-[85%] rounded-2xl rounded-tl-sm bg-white/80 border border-duck-dark/10 px-4 py-2.5 text-sm text-duck-dark prose prose-sm max-w-none prose-pre:bg-gray-900 prose-pre:text-green-400 prose-code:text-duck-teal prose-code:before:content-none prose-code:after:content-none">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{text}
</ReactMarkdown>
</div>
</div>
);
case 'tool':
if (message.toolName === 'question' && onAnswer) {
return <QuestionActivity message={message} onAnswer={onAnswer} />;
}
return <ToolActivity message={message} />;
case 'result':
return (
<div className="flex justify-center py-1">
<span className="text-xs text-duck-dark/40">
Done · ${message.costUsd.toFixed(3)} · {(message.durationMs / 1000).toFixed(1)}s · {message.numTurns} turn
{message.numTurns !== 1 ? 's' : ''}
{message.isError ? ' (with errors)' : ''}
</span>
</div>
);
case 'error':
return (
<div className="flex justify-start">
<div className="max-w-[80%] rounded-2xl bg-red-50 border border-red-200 px-4 py-2.5 text-sm text-red-700">
{text}
</div>
</div>
);
}
};
function formatText(value: unknown): string {
if (typeof value === 'string') return value;
if (value == null) return '';
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
type StreamingBubbleProps = {
text: string;
};
export const StreamingBubble = ({ text }: StreamingBubbleProps) => {
if (!text) return null;
return (
<div className="flex justify-start">
<div className="max-w-[85%] rounded-2xl rounded-tl-sm bg-white/80 border border-duck-dark/10 px-4 py-2.5 text-sm text-duck-dark prose prose-sm max-w-none prose-pre:bg-gray-900 prose-pre:text-green-400 prose-code:text-duck-teal prose-code:before:content-none prose-code:after:content-none">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{text}
</ReactMarkdown>
<span className="inline-block w-2 h-4 bg-duck-teal/60 animate-pulse ml-0.5 align-middle" />
</div>
</div>
);
};
@@ -0,0 +1,110 @@
import { useMemo, useState } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { ModelOption } from '@/state/useModels';
import { useRecentModels } from '@/state/useRecentModels';
type OpenCodeModelPickerProps = {
models: ModelOption[];
selectedModel: string | null;
onSelect: (modelId: string) => void;
isConnected: boolean;
isGenerating: boolean;
};
export const OpenCodeModelPicker = ({
models,
selectedModel,
onSelect,
isConnected,
isGenerating,
}: OpenCodeModelPickerProps) => {
const [open, setOpen] = useState(false);
const { recents, addRecent } = useRecentModels();
const selected = models.find((m) => m.id === selectedModel);
const groupedByProvider = useMemo(() => {
const groups: Record<string, ModelOption[]> = {};
for (const m of models) {
const provider = m.provider ?? 'Other';
if (!groups[provider]) groups[provider] = [];
groups[provider].push(m);
}
return Object.entries(groups)
.sort(([a], [b]) => a.localeCompare(b))
.map(([provider, items]) => ({
provider,
models: items.sort((a, b) => a.name.localeCompare(b.name)),
}));
}, [models]);
const handleSelect = (modelId: string) => {
const model = models.find((m) => m.id === modelId);
if (model) {
onSelect(model.id);
addRecent(model);
}
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
aria-expanded={open}
disabled={isGenerating || !isConnected}
className="h-auto border-0 bg-transparent p-0 text-xs text-duck-dark/50 shadow-none focus:ring-0 gap-1 cursor-pointer hover:bg-transparent hover:text-duck-dark/70"
>
{selected ? (
<>
{selected.name}
{selected.provider && <span className="hidden md:inline"> ({selected.provider})</span>}
</>
) : (
'select model'
)}
<ChevronsUpDown className="h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="z-[800] w-[320px] p-0" align="end" side="top">
<Command>
<CommandInput placeholder="Search models..." />
<CommandList className="max-h-[400px]">
<CommandEmpty>No models found.</CommandEmpty>
{recents.length > 0 && (
<CommandGroup heading="Recent">
{recents.map((m) => (
<CommandItem
key={`recent-${m.id}`}
value={`${m.name} ${m.provider ?? ''}`}
onSelect={() => handleSelect(m.id)}
>
<Check className={`mr-2 h-4 w-4 ${selectedModel === m.id ? 'opacity-100' : 'opacity-0'}`} />
<span className="truncate font-bold">{m.name}</span>
{m.provider && <span className="ml-1 text-xs text-muted-foreground">({m.provider})</span>}
</CommandItem>
))}
</CommandGroup>
)}
{groupedByProvider.map(({ provider, models: providerModels }) => (
<CommandGroup key={provider} heading={provider}>
{providerModels.map((m) => (
<CommandItem key={m.id} value={`${m.name} ${m.provider ?? ''}`} onSelect={() => handleSelect(m.id)}>
<Check className={`mr-2 h-4 w-4 ${selectedModel === m.id ? 'opacity-100' : 'opacity-0'}`} />
<span className="truncate font-bold">{m.name}</span>
{m.provider && <span className="ml-1 text-xs text-muted-foreground">({m.provider})</span>}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
@@ -0,0 +1,164 @@
import { useState } from 'react';
import { MessageCircleQuestion, Check } from 'lucide-react';
import type { ChatMessage } from './types';
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
type QuestionOption = {
label: string;
description: string;
};
type Question = {
question: string;
header: string;
multiple: boolean;
options: QuestionOption[];
};
type QuestionActivityProps = {
message: ToolMessage;
onAnswer: (text: string) => void;
};
export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) => {
const [selectedOptions, setSelectedOptions] = useState<Set<string>>(new Set());
const [otherText, setOtherText] = useState('');
const [answered, setAnswered] = useState(false);
const [answeredText, setAnsweredText] = useState('');
const input = message.toolInput as { questions?: Question[] };
const questions = input.questions;
if (!questions || questions.length === 0) return null;
const pending = message.output === undefined;
const handleSelect = (question: Question, label: string) => {
if (answered || !pending) return;
if (question.multiple) {
setSelectedOptions((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
} else {
const text = label;
setAnswered(true);
setAnsweredText(text);
onAnswer(text);
}
};
const handleSubmitMultiple = () => {
if (selectedOptions.size === 0 || answered || !pending) return;
const text = Array.from(selectedOptions).join(', ');
setAnswered(true);
setAnsweredText(text);
onAnswer(text);
};
const handleSubmitOther = () => {
const text = otherText.trim();
if (!text || answered || !pending) return;
setAnswered(true);
setAnsweredText(text);
onAnswer(text);
};
const isDisabled = answered || !pending;
return (
<div className="my-1 space-y-3">
{questions.map((q, qi) => (
<div key={qi} className="rounded-xl border border-duck-teal/20 bg-white/90 overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 bg-duck-teal/5 border-b border-duck-teal/10">
<MessageCircleQuestion className="h-4 w-4 text-duck-teal shrink-0" />
<span className="text-xs font-medium text-duck-teal uppercase tracking-wider">{q.header}</span>
</div>
<div className="px-4 py-3 space-y-3">
<p className="text-sm text-duck-dark font-medium">{q.question}</p>
<div className="space-y-1.5">
{q.options.map((opt) => {
const isSelected = answered
? answeredText === opt.label || answeredText.split(', ').includes(opt.label)
: selectedOptions.has(opt.label);
return (
<button
key={opt.label}
onClick={() => handleSelect(q, opt.label)}
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'
: 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'
}`}
>
<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>}
</div>
</div>
</button>
);
})}
</div>
{/* "Other" free-text option */}
{!isDisabled && (
<div className="flex gap-2">
<input
type="text"
value={otherText}
onChange={(ev) => setOtherText(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
handleSubmitOther();
}
}}
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"
/>
<button
onClick={handleSubmitOther}
disabled={!otherText.trim()}
className="px-3 py-1.5 rounded-lg bg-duck-teal text-white text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed hover:bg-duck-teal/90 transition-colors cursor-pointer"
>
Send
</button>
</div>
)}
{/* Submit button for multi-select */}
{q.multiple && !isDisabled && (
<button
onClick={handleSubmitMultiple}
disabled={selectedOptions.size === 0}
className="px-4 py-1.5 rounded-lg bg-duck-teal text-white text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed hover:bg-duck-teal/90 transition-colors cursor-pointer"
>
Submit ({selectedOptions.size} selected)
</button>
)}
{/* Answered indicator */}
{isDisabled && answeredText && (
<div className="flex items-center gap-1.5 text-xs text-duck-teal">
<Check className="h-3 w-3" />
<span>Answered: {answeredText}</span>
</div>
)}
</div>
</div>
))}
</div>
);
};
@@ -0,0 +1,105 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router';
import { Plus, MessageSquare, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/Card';
import { useSessions } from '@/state/useSessions';
import { useOpenCodeSessions } from '@/state/useOpenCodeSessions';
type Filter = 'all' | 'claude' | 'opencode';
export const SessionList = () => {
const [filter, setFilter] = useState<Filter>('all');
const claude = useSessions();
const opencode = useOpenCodeSessions();
const merged = useMemo(
() => [...claude.sessions, ...opencode.sessions].sort((a, b) => b.createdAt - a.createdAt),
[claude.sessions, opencode.sessions],
);
const filtered = filter === 'all' ? merged : merged.filter((s) => s.provider === filter);
const handleDelete = (id: string, provider: 'claude' | 'opencode') => {
if (provider === 'claude') claude.deleteSession(id);
else opencode.deleteSession(id);
};
return (
<div className="flex flex-col h-full items-center p-4 md:p-6">
<Card className="w-full max-w-2xl flex flex-col gap-4 h-full p-4 md:p-6 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-duck-dark/80">Sessions</h2>
<Button asChild className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer gap-2">
<Link to="/chat/new">
<Plus className="h-4 w-4" />
New Chat
</Link>
</Button>
</div>
{/* Radio filter */}
<div className="flex items-center gap-1 rounded-lg bg-white/60 p-1">
{(['all', 'claude', 'opencode'] as const).map((value) => (
<button
key={value}
onClick={() => setFilter(value)}
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors cursor-pointer ${
filter === value ? 'bg-white text-duck-dark shadow-sm' : 'text-duck-dark/70 hover:text-duck-dark/90'
}`}
>
{value === 'all' ? 'All' : value === 'claude' ? 'Claude' : 'OpenCode'}
</button>
))}
</div>
{/* Session list */}
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
{filtered.length === 0 && (
<div className="text-center py-16 text-duck-dark/30 text-sm">No sessions yet. Start a new chat!</div>
)}
{filtered.map((session) => (
<div
key={`${session.provider}-${session.id}`}
className="group flex items-center gap-3 rounded-lg border border-duck-dark/10 bg-white/80 hover:bg-white/90 transition-colors"
>
<Link
to={session.provider === 'claude' ? `/chat/${session.id}` : `/chat/opencode/${session.id}`}
className="flex-1 flex items-center gap-3 px-4 py-3 min-w-0"
>
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-duck-dark/80 truncate">{session.title}</div>
<div className="text-xs text-duck-dark/40">
{new Date(session.createdAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
<span
className={`ml-2 text-xs font-medium ${
session.provider === 'claude' ? 'text-duck-teal' : 'text-duck-orange'
}`}
>
{session.provider === 'claude' ? 'Claude' : 'OpenCode'}
</span>
<span className="ml-2 font-mono text-duck-dark/25">{session.id.slice(0, 8)}</span>
</div>
</div>
</Link>
<button
onClick={() => handleDelete(session.id, session.provider)}
className="shrink-0 p-2 mr-2 text-duck-dark/20 hover:text-red-500 md:opacity-0 md:group-hover:opacity-100 transition-opacity cursor-pointer"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
</Card>
</div>
);
};
@@ -0,0 +1,138 @@
import { useState } from 'react';
import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight } from 'lucide-react';
import type { ChatMessage } from './types';
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
type ToolActivityProps = {
message: ToolMessage;
};
const toolIcons: Record<string, typeof FileText> = {
Read: FileText,
Edit: Pencil,
Write: Pencil,
Bash: Terminal,
Grep: Search,
Glob: Search,
WebFetch: Globe,
WebSearch: Globe,
};
function getToolSummary(toolName: string, toolInput: Record<string, unknown>): string {
switch (toolName) {
case 'Read':
case 'Edit':
case 'Write':
return (toolInput.file_path as string) ?? '';
case 'Bash':
return truncate((toolInput.command as string) ?? '', 80);
case 'Grep':
case 'Glob':
return (toolInput.pattern as string) ?? '';
case 'WebFetch':
return (toolInput.url as string) ?? '';
case 'WebSearch':
return (toolInput.query as string) ?? '';
default:
return (
Object.values(toolInput)
.find((v) => typeof v === 'string')
?.toString() ?? ''
);
}
}
function truncate(str: string, max: number): string {
return str.length > max ? str.slice(0, max) + '...' : str;
}
export const ToolActivity = ({ message }: ToolActivityProps) => {
const [open, setOpen] = useState(false);
const Icon = toolIcons[message.toolName] ?? Wrench;
const summary = getToolSummary(message.toolName, message.toolInput);
const pending = message.output === undefined;
const isError = message.isError === true;
return (
<div className="my-1">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2 w-full text-left px-3 py-1.5 rounded-md hover:bg-duck-dark/5 transition-colors cursor-pointer text-sm"
>
<ChevronRight className={`h-3 w-3 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`} />
<Icon className="h-4 w-4 shrink-0 text-duck-teal" />
<span className="font-medium text-duck-dark/80">{message.toolName}</span>
<span className="text-duck-dark/50 truncate flex-1 font-mono text-xs">{summary}</span>
<span className="shrink-0">
{pending && <span className="inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />}
{!pending && !isError && <span className="text-green-600 text-xs">done</span>}
{!pending && isError && <span className="text-red-600 text-xs">error</span>}
</span>
</button>
{open && (
<div className="ml-7 mt-1 space-y-2 text-xs">
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto">
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider mb-1">Input</div>
{message.toolName === 'Bash' ? (
<pre className="bg-gray-900 text-green-400 p-2 rounded font-mono whitespace-pre-wrap break-all">
{(message.toolInput.command as string) ?? JSON.stringify(message.toolInput, null, 2)}
</pre>
) : (
<pre className="font-mono whitespace-pre-wrap break-all text-duck-dark/70">
{Object.entries(message.toolInput)
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
.join('\n')}
</pre>
)}
</div>
{message.output !== undefined && (
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto">
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider mb-1">Output</div>
<ToolOutput toolName={message.toolName} output={message.output} isError={isError} />
</div>
)}
</div>
)}
</div>
);
};
type ToolOutputProps = {
toolName: string;
output: string;
isError: boolean;
};
const ToolOutput = ({ toolName, output, isError }: ToolOutputProps) => {
const [expanded, setExpanded] = useState(false);
const maxLines = 20;
const lines = output.split('\n');
const needsTruncation = lines.length > maxLines;
const displayText = expanded ? output : lines.slice(0, maxLines).join('\n');
const isBash = toolName === 'Bash';
return (
<>
<pre
className={`font-mono whitespace-pre-wrap break-all p-2 rounded ${
isBash ? 'bg-gray-900 text-green-400' : isError ? 'bg-red-50 text-red-700' : 'text-duck-dark/70'
}`}
>
{displayText}
</pre>
{needsTruncation && (
<button
onClick={() => setExpanded(!expanded)}
className="text-duck-teal hover:underline text-[11px] mt-1 cursor-pointer"
>
{expanded ? 'Show less' : `Show more (${lines.length - maxLines} more lines)`}
</button>
)}
</>
);
};
@@ -0,0 +1,95 @@
import { useState } from 'react';
import { useParams } from 'react-router';
import { useQueryClient } from '@tanstack/react-query';
import { DashboardLayout } from '../Layout';
import { useClaude } from './useClaude';
import { useOpenCode } from './useOpenCode';
import { ChatPanel } from './ChatPanel';
import { SessionList } from './SessionList';
import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels';
import type { SessionEntry } from './types';
export const ClaudeSessions = () => {
return (
<DashboardLayout>
<SessionList />
</DashboardLayout>
);
};
export const ClaudeChat = () => {
const { sessionId } = useParams<{ sessionId: string }>();
const queryClient = useQueryClient();
const sessions = queryClient.getQueryData<SessionEntry[]>(['SESSIONS']);
const sessionModel = sessions?.find((s) => s.id === sessionId)?.model;
const claude = useClaude(sessionId, sessionModel);
const claudeModels = useVisibleClaudeModels();
return (
<DashboardLayout mobileFull>
<div className="flex items-center justify-center h-full md:p-4">
<div className="flex flex-col w-full h-full md:w-3/4 md:h-3/4 lg:w-1/2 lg:h-1/2">
<ChatPanel chat={claude} provider="claude" availableModels={claudeModels} />
</div>
</div>
</DashboardLayout>
);
};
export const OpenCodeChat = () => {
const { sessionId } = useParams<{ sessionId: string }>();
const queryClient = useQueryClient();
const sessions = queryClient.getQueryData<SessionEntry[]>(['OC_SESSIONS']);
const sessionModel = sessions?.find((s) => s.id === sessionId)?.model;
const opencode = useOpenCode(sessionId, sessionModel);
const openCodeModels = useVisibleOpenCodeModels();
return (
<DashboardLayout mobileFull>
<div className="flex items-center justify-center h-full md:p-4">
<div className="flex flex-col w-full h-full md:w-3/4 md:h-3/4 lg:w-1/2 lg:h-1/2">
<ChatPanel chat={opencode} provider="opencode" availableModels={openCodeModels} />
</div>
</div>
</DashboardLayout>
);
};
const ClaudeNewChatInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode') => void }) => {
const claude = useClaude();
const claudeModels = useVisibleClaudeModels();
return (
<ChatPanel chat={claude} provider="claude" availableModels={claudeModels} onProviderChange={onProviderChange} />
);
};
const OpenCodeNewChatInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode') => void }) => {
const opencode = useOpenCode();
const openCodeModels = useVisibleOpenCodeModels();
return (
<ChatPanel
chat={opencode}
provider="opencode"
availableModels={openCodeModels}
onProviderChange={onProviderChange}
/>
);
};
export const NewChat = () => {
const [provider, setProvider] = useState<'claude' | 'opencode'>('claude');
return (
<DashboardLayout mobileFull>
<div className="flex items-center justify-center h-full md:p-4">
<div className="flex flex-col w-full h-full md:w-3/4 md:h-3/4 lg:w-1/2 lg:h-1/2">
{provider === 'claude' ? (
<ClaudeNewChatInner key="claude" onProviderChange={setProvider} />
) : (
<OpenCodeNewChatInner key="opencode" onProviderChange={setProvider} />
)}
</div>
</div>
</DashboardLayout>
);
};
@@ -0,0 +1,38 @@
export type SessionEntry = {
id: string;
title: string;
createdAt: number;
provider: 'claude' | 'opencode';
model?: string | null;
};
export type ChatMessage =
| { role: 'user'; text: string; images?: { filename: string; dataUrl: string }[] }
| { role: 'assistant'; text: string }
| {
role: 'tool';
toolName: string;
toolInput: Record<string, unknown>;
toolUseId: string;
output?: string;
isError?: boolean;
}
| { role: 'result'; costUsd: number; durationMs: number; numTurns: number; isError: boolean }
| { role: 'error'; text: string };
export type TaskInfo = {
taskName: string;
taskDirName: string;
entryName: string;
entryType: 'file' | 'directory';
};
export type ServerMessage =
| { type: 'session:init'; sessionId: string; model: string }
| { type: 'assistant:text'; text: string }
| { type: 'assistant:partial'; text: string }
| { type: 'tool:use'; toolName: string; toolInput: Record<string, unknown>; toolUseId: string }
| { type: 'tool:result'; toolUseId: string; output: string; isError: boolean }
| { type: 'result'; costUsd: number; durationMs: number; numTurns: number; isError: boolean }
| { type: 'error'; message: string }
| { type: 'stopped' };
@@ -0,0 +1,227 @@
import { useState, useEffect, useRef } from 'react';
import { useChatWebSocket } from 'hooks/useChatWebSocket';
import { useSessions } from '@/state/useSessions';
import type { ChatMessage, ServerMessage, TaskInfo } from './types';
const SAVE_DEBOUNCE_MS = 1000;
type ResourceChatStorage = {
load: () => Promise<{ sessionId: string | null; messages: ChatMessage[] }>;
save: (sessionId: string, messages: ChatMessage[]) => Promise<void>;
};
type UseClaudeOptions = {
replaceUrl?: boolean;
storage?: ResourceChatStorage;
resourceChatDir?: string;
taskInfo?: TaskInfo;
};
export const useClaude = (initialSessionId?: string, initialModel?: string | null, options?: UseClaudeOptions) => {
const { replaceUrl = true, storage, resourceChatDir, taskInfo } = options ?? {};
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streamingText, setStreamingText] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(initialSessionId ?? null);
const [model, setModel] = useState<string | null>(null);
const [selectedModel, setSelectedModel] = useState<string | null>(initialModel ?? null);
const streamingRef = useRef('');
const rafRef = useRef<number | null>(null);
const sessionIdRef = useRef<string | null>(initialSessionId ?? null);
const saveTimerRef = useRef<number | null>(null);
const { getMessages, saveMessages } = useSessions();
const token = localStorage.getItem('BEARER_TOKEN');
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/harness/claudecode/ws?token=${token}`;
const flushStreaming = () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
setStreamingText(streamingRef.current);
rafRef.current = null;
});
};
const commitStreaming = () => {
if (!streamingRef.current) return;
setMessages((prev) => [...prev, { role: 'assistant', text: streamingRef.current }]);
streamingRef.current = '';
setStreamingText('');
};
const handleMessage = (data: unknown) => {
const msg = data as ServerMessage;
switch (msg.type) {
case 'session:init':
sessionIdRef.current = msg.sessionId;
setSessionId(msg.sessionId);
setModel(msg.model);
if (replaceUrl) window.history.replaceState(null, '', `/chat/${msg.sessionId}`);
break;
case 'assistant:partial':
streamingRef.current += msg.text;
flushStreaming();
break;
case 'assistant:text':
if (streamingRef.current) {
commitStreaming();
} else {
setMessages((prev) => [...prev, { role: 'assistant', text: msg.text }]);
}
break;
case 'tool:use':
setMessages((prev) => [
...prev,
{ role: 'tool', toolName: msg.toolName, toolInput: msg.toolInput, toolUseId: msg.toolUseId },
]);
break;
case 'tool:result':
setMessages((prev) =>
prev.map((m) =>
m.role === 'tool' && m.toolUseId === msg.toolUseId ? { ...m, output: msg.output, isError: msg.isError } : m,
),
);
break;
case 'result':
commitStreaming();
setMessages((prev) => [
...prev,
{
role: 'result',
costUsd: msg.costUsd,
durationMs: msg.durationMs,
numTurns: msg.numTurns,
isError: msg.isError,
},
]);
setIsGenerating(false);
break;
case 'error':
commitStreaming();
setMessages((prev) => [...prev, { role: 'error', text: msg.message }]);
setIsGenerating(false);
break;
case 'stopped':
commitStreaming();
setIsGenerating(false);
break;
}
};
const { isConnected, send } = useChatWebSocket({ url: wsUrl, onMessage: handleMessage });
// Load messages from server on mount when resuming a session
useEffect(() => {
if (storage) {
storage
.load()
.then(({ sessionId: sid, messages: msgs }) => {
if (sid) {
sessionIdRef.current = sid;
setSessionId(sid);
}
if (msgs.length > 0) setMessages(msgs);
})
.catch(() => {});
return;
}
if (!initialSessionId) return;
getMessages(initialSessionId)
.then((data) => {
if (Array.isArray(data) && data.length > 0) setMessages(data);
})
.catch(() => {});
}, [initialSessionId]);
// Debounced save messages to server
useEffect(() => {
if (!sessionIdRef.current || messages.length === 0) return;
if (saveTimerRef.current !== null) clearTimeout(saveTimerRef.current);
const sid = sessionIdRef.current;
const snapshot = messages;
saveTimerRef.current = window.setTimeout(() => {
if (storage) {
storage.save(sid, snapshot).catch(() => {});
} else {
saveMessages(sid, snapshot).catch(() => {});
}
saveTimerRef.current = null;
}, SAVE_DEBOUNCE_MS);
return () => {
if (saveTimerRef.current !== null) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
};
}, [messages]);
// Clean up RAF on unmount
useEffect(() => {
return () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
};
}, []);
const sendPrompt = (
text: string,
attachmentIds?: string[],
images?: { filename: string; dataUrl: string }[],
cwd?: { root?: string; path: string },
) => {
setMessages((prev) => [...prev, { role: 'user', text, ...(images?.length ? { images } : {}) }]);
setIsGenerating(true);
streamingRef.current = '';
setStreamingText('');
// Parse dataUrls into { mediaType, data } for the server
const imageData = images
?.map((img) => {
const match = img.dataUrl.match(/^data:([^;]+);base64,(.+)$/);
return match ? { mediaType: match[1], data: match[2] } : null;
})
.filter((x): x is { mediaType: string; data: string } => x !== null);
send({
type: 'chat',
prompt: text,
sessionId: sessionIdRef.current,
...(selectedModel ? { model: selectedModel } : {}),
...(cwd ? { cwd } : {}),
...(attachmentIds?.length ? { attachmentIds } : {}),
...(imageData?.length ? { images: imageData } : {}),
...(resourceChatDir ? { resourceChatDir } : {}),
...(taskInfo ? { taskInfo } : {}),
});
};
const stopGeneration = () => {
send({ type: 'stop' });
};
return {
messages,
streamingText,
isConnected,
isGenerating,
sessionId,
model,
selectedModel,
setSelectedModel,
sendPrompt,
stopGeneration,
};
};
@@ -0,0 +1,225 @@
import { useState, useEffect, useRef } from 'react';
import { useChatWebSocket } from 'hooks/useChatWebSocket';
import { useSettings } from '@/state/useSettings';
import { useVisibleOpenCodeModels } from '@/state/useModels';
import { useOpenCodeSessions } from '@/state/useOpenCodeSessions';
import type { ChatMessage, ServerMessage, TaskInfo } from './types';
type UseOpenCodeOptions = {
replaceUrl?: boolean;
taskInfo?: TaskInfo;
};
export const useOpenCode = (initialSessionId?: string, initialModel?: string | null, options?: UseOpenCodeOptions) => {
const { replaceUrl = true, taskInfo } = options ?? {};
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streamingText, setStreamingText] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(initialSessionId ?? null);
const [model, setModel] = useState<string | null>(null);
const [selectedModel, setSelectedModel] = useState<string | null>(initialModel ?? null);
const streamingRef = useRef('');
const rafRef = useRef<number | null>(null);
const sessionIdRef = useRef<string | null>(initialSessionId ?? null);
const selectedModelRef = useRef<string | null>(initialModel ?? null);
const updateSelectedModel = (value: string | null) => {
selectedModelRef.current = value;
setSelectedModel(value);
};
const { getMessages } = useOpenCodeSessions();
const { settings } = useSettings();
const openCodeModels = useVisibleOpenCodeModels();
const token = localStorage.getItem('BEARER_TOKEN');
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/harness/opencode/ws?token=${token}`;
const flushStreaming = () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
setStreamingText(streamingRef.current);
rafRef.current = null;
});
};
const commitStreaming = () => {
// Cancel any pending RAF to prevent stale reads of cleared streamingRef
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
if (!streamingRef.current) return;
setMessages((prev) => [...prev, { role: 'assistant', text: streamingRef.current }]);
streamingRef.current = '';
setStreamingText('');
};
const handleMessage = (data: unknown) => {
const msg = data as ServerMessage;
switch (msg.type) {
case 'session:init':
sessionIdRef.current = msg.sessionId;
setSessionId(msg.sessionId);
setModel(msg.model);
if (replaceUrl) window.history.replaceState(null, '', `/chat/opencode/${msg.sessionId}`);
break;
case 'assistant:partial':
streamingRef.current += msg.text;
flushStreaming();
break;
case 'assistant:text':
// Server sends the final complete text — discard streaming and use this instead
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
streamingRef.current = '';
setStreamingText('');
setMessages((prev) => [...prev, { role: 'assistant', text: msg.text }]);
break;
case 'tool:use':
commitStreaming();
setMessages((prev) => {
const existing = prev.find((m) => m.role === 'tool' && m.toolUseId === msg.toolUseId);
if (existing) {
// Update input (running event sends actual input after pending)
return prev.map((m) =>
m.role === 'tool' && m.toolUseId === msg.toolUseId
? { ...m, toolName: msg.toolName, toolInput: msg.toolInput }
: m,
);
}
return [
...prev,
{ role: 'tool', toolName: msg.toolName, toolInput: msg.toolInput, toolUseId: msg.toolUseId },
];
});
break;
case 'tool:result':
setMessages((prev) =>
prev.map((m) =>
m.role === 'tool' && m.toolUseId === msg.toolUseId ? { ...m, output: msg.output, isError: msg.isError } : m,
),
);
break;
case 'result':
commitStreaming();
setMessages((prev) => [
...prev,
{
role: 'result',
costUsd: msg.costUsd,
durationMs: msg.durationMs,
numTurns: msg.numTurns,
isError: msg.isError,
},
]);
setIsGenerating(false);
break;
case 'error':
commitStreaming();
setMessages((prev) => [...prev, { role: 'error', text: msg.message }]);
setIsGenerating(false);
break;
case 'stopped':
commitStreaming();
setIsGenerating(false);
break;
}
};
const { isConnected, send } = useChatWebSocket({ url: wsUrl, onMessage: handleMessage });
// Load messages from OpenCode on mount when resuming a session
useEffect(() => {
if (!initialSessionId) return;
getMessages(initialSessionId)
.then((data) => {
if (Array.isArray(data) && data.length > 0) setMessages(data);
})
.catch(() => {});
}, [initialSessionId]);
useEffect(() => {
selectedModelRef.current = selectedModel;
}, [selectedModel]);
// Seed default model for OpenCode if none selected
useEffect(() => {
if (selectedModel) return;
if (settings.chat.defaultProvider !== 'opencode' || !settings.chat.defaultModel) return;
if (!openCodeModels.some((m) => m.id === settings.chat.defaultModel)) return;
updateSelectedModel(settings.chat.defaultModel);
}, [openCodeModels, selectedModel, settings.chat.defaultModel, settings.chat.defaultProvider]);
// Clean up RAF on unmount
useEffect(() => {
return () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
};
}, []);
const sendPrompt = (text: string, attachmentIds?: string[], images?: { filename: string; dataUrl: string }[]) => {
setMessages((prev) => [...prev, { role: 'user', text, ...(images?.length ? { images } : {}) }]);
setIsGenerating(true);
streamingRef.current = '';
setStreamingText('');
// Parse dataUrls into { mediaType, data } for the server
const imageData = images
?.map((img) => {
const match = img.dataUrl.match(/^data:([^;]+);base64,(.+)$/);
return match ? { mediaType: match[1], data: match[2] } : null;
})
.filter((x): x is { mediaType: string; data: string } => x !== null);
const modelId = selectedModelRef.current;
const selectedOption = modelId ? openCodeModels.find((m) => m.id === modelId) : undefined;
const payload = {
type: 'chat',
prompt: text,
sessionId: sessionIdRef.current,
...(modelId
? {
model: {
modelID: modelId,
...(selectedOption?.providerId ? { providerID: selectedOption.providerId } : {}),
},
}
: {}),
...(attachmentIds?.length ? { attachmentIds } : {}),
...(imageData?.length ? { images: imageData } : {}),
...(taskInfo ? { taskInfo } : {}),
};
console.log('[opencode-ui] ws send', payload);
send(payload);
};
const stopGeneration = () => {
send({ type: 'stop' });
};
return {
messages,
streamingText,
isConnected,
isGenerating,
sessionId,
model,
selectedModel,
setSelectedModel: updateSelectedModel,
sendPrompt,
stopGeneration,
};
};