Chat and Terminal as plugins
This commit is contained in:
@@ -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 './MessageList';
|
||||
import { InputArea } from './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,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 './EmbeddableChat';
|
||||
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,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,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,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">— {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,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,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,11 @@
|
||||
export { EmbeddableChat, type Attachment } from './EmbeddableChat';
|
||||
export { MessageList } from './MessageList';
|
||||
export { InputArea } from './InputArea';
|
||||
export { Settings } from './Settings';
|
||||
export { MessageBubble, StreamingBubble } from './MessageBubble';
|
||||
export { ToolActivity } from './ToolActivity';
|
||||
export { QuestionActivity } from './QuestionActivity';
|
||||
export { OpenCodeModelPicker } from './OpenCodeModelPicker';
|
||||
export { useClaude } from './useClaude';
|
||||
export { useOpenCode } from './useOpenCode';
|
||||
export type { ChatMessage, SessionEntry, ServerMessage, TaskInfo } from './types';
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './client';
|
||||
|
||||
export const plugin = {
|
||||
id: 'Chat',
|
||||
name: 'Chat',
|
||||
description: 'Embeddable chat UI',
|
||||
};
|
||||
@@ -3,12 +3,9 @@ import { X } from 'lucide-react';
|
||||
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { cardStyle } from '@/components/Card';
|
||||
import { useClaude } from '@/Screens/Dashboard/Chat/useClaude';
|
||||
import { useOpenCode } from '@/Screens/Dashboard/Chat/useOpenCode';
|
||||
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
||||
import { useClaude, useOpenCode, EmbeddableChat, type TaskInfo } from 'plugins/Chat/client';
|
||||
import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import type { TaskInfo } from '@/Screens/Dashboard/Chat/types';
|
||||
import type { TaskSummary } from '../state/useTasks';
|
||||
|
||||
const playDing = () => {
|
||||
|
||||
@@ -1,108 +1,10 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Terminal as XTerm } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { DashboardLayout } from '@/Screens/Dashboard/Layout';
|
||||
import { useMounted } from 'hooks/useMounted';
|
||||
import { TerminalView } from '../TerminalView';
|
||||
|
||||
export const Terminal = () => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const isMounted = useMounted();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// Deferred init for StrictMode compatibility
|
||||
const initTimeout = setTimeout(() => {
|
||||
const term = new XTerm({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: '#1a1a2e',
|
||||
foreground: '#e0e0e0',
|
||||
cursor: '#e0e0e0',
|
||||
selectionBackground: '#3a3a5e',
|
||||
},
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(container);
|
||||
fitAddon.fit();
|
||||
|
||||
termRef.current = term;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
// WebSocket connection
|
||||
const token = localStorage.getItem('BEARER_TOKEN');
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}/api/terminal/ws?token=${token}`);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
// Send initial size
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === 'output') {
|
||||
term.write(msg.data);
|
||||
} else if (msg.type === 'exit') {
|
||||
term.write('\r\n[Process exited]\r\n');
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
term.write('\r\n[Disconnected]\r\n');
|
||||
});
|
||||
|
||||
// Send terminal input to server
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'input', data }));
|
||||
}
|
||||
});
|
||||
|
||||
// ResizeObserver for auto-fitting
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
// Store observer for cleanup
|
||||
(container as any).__resizeObserver = resizeObserver;
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
clearTimeout(initTimeout);
|
||||
const resizeObserver = (container as any).__resizeObserver as ResizeObserver | undefined;
|
||||
resizeObserver?.disconnect();
|
||||
delete (container as any).__resizeObserver;
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
termRef.current?.dispose();
|
||||
termRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
};
|
||||
}, [isMounted]);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div ref={containerRef} className="h-full w-full p-2" style={{ backgroundColor: '#1a1a2e' }} />
|
||||
<TerminalView className="h-full w-full p-2" />
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Terminal as XTerm } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { useMounted } from 'hooks/useMounted';
|
||||
|
||||
type TerminalTheme = {
|
||||
background?: string;
|
||||
foreground?: string;
|
||||
cursor?: string;
|
||||
selectionBackground?: string;
|
||||
};
|
||||
|
||||
export type TerminalViewProps = {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
wsPath?: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
theme?: TerminalTheme;
|
||||
autoFocus?: boolean;
|
||||
onReady?: (term: XTerm) => void;
|
||||
onExit?: () => void;
|
||||
onDisconnect?: () => void;
|
||||
};
|
||||
|
||||
const DEFAULT_THEME: Required<TerminalTheme> = {
|
||||
background: '#1a1a2e',
|
||||
foreground: '#e0e0e0',
|
||||
cursor: '#e0e0e0',
|
||||
selectionBackground: '#3a3a5e',
|
||||
};
|
||||
|
||||
const buildWsUrl = (wsPath: string) => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
|
||||
const separator = wsPath.includes('?') ? '&' : '?';
|
||||
return `${protocol}//${window.location.host}${wsPath}${separator}token=${encodeURIComponent(token)}`;
|
||||
};
|
||||
|
||||
export const TerminalView = ({
|
||||
className,
|
||||
style,
|
||||
wsPath = '/api/terminal/ws',
|
||||
fontSize = 14,
|
||||
fontFamily = 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme,
|
||||
autoFocus = true,
|
||||
onReady,
|
||||
onExit,
|
||||
onDisconnect,
|
||||
}: TerminalViewProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const isMounted = useMounted();
|
||||
const onReadyRef = useRef<TerminalViewProps['onReady']>(onReady);
|
||||
const onExitRef = useRef<TerminalViewProps['onExit']>(onExit);
|
||||
const onDisconnectRef = useRef<TerminalViewProps['onDisconnect']>(onDisconnect);
|
||||
|
||||
onReadyRef.current = onReady;
|
||||
onExitRef.current = onExit;
|
||||
onDisconnectRef.current = onDisconnect;
|
||||
|
||||
const background = theme?.background ?? DEFAULT_THEME.background;
|
||||
const foreground = theme?.foreground ?? DEFAULT_THEME.foreground;
|
||||
const cursor = theme?.cursor ?? DEFAULT_THEME.cursor;
|
||||
const selectionBackground = theme?.selectionBackground ?? DEFAULT_THEME.selectionBackground;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let disposed = false;
|
||||
const initTimeout = setTimeout(() => {
|
||||
if (disposed) return;
|
||||
const term = new XTerm({
|
||||
cursorBlink: true,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
theme: {
|
||||
background,
|
||||
foreground,
|
||||
cursor,
|
||||
selectionBackground,
|
||||
},
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(container);
|
||||
fitAddon.fit();
|
||||
if (autoFocus) term.focus();
|
||||
|
||||
termRef.current = term;
|
||||
fitAddonRef.current = fitAddon;
|
||||
onReadyRef.current?.(term);
|
||||
|
||||
const ws = new WebSocket(buildWsUrl(wsPath));
|
||||
wsRef.current = ws;
|
||||
|
||||
const handleOpen = () => {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
};
|
||||
|
||||
const handleMessage = (ev: MessageEvent) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data as string);
|
||||
if (msg.type === 'output') {
|
||||
term.write(msg.data);
|
||||
} else if (msg.type === 'exit') {
|
||||
term.write('\r\n[Process exited]\r\n');
|
||||
onExitRef.current?.();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
term.write('\r\n[Disconnected]\r\n');
|
||||
onDisconnectRef.current?.();
|
||||
};
|
||||
|
||||
ws.addEventListener('open', handleOpen);
|
||||
ws.addEventListener('message', handleMessage);
|
||||
ws.addEventListener('close', handleClose);
|
||||
|
||||
const dataDisposable = term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'input', data }));
|
||||
}
|
||||
});
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
const cleanup = () => {
|
||||
dataDisposable.dispose();
|
||||
resizeObserver.disconnect();
|
||||
ws.removeEventListener('open', handleOpen);
|
||||
ws.removeEventListener('message', handleMessage);
|
||||
ws.removeEventListener('close', handleClose);
|
||||
};
|
||||
|
||||
(container as any).__terminalCleanup = cleanup;
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearTimeout(initTimeout);
|
||||
const cleanup = (container as any).__terminalCleanup as (() => void) | undefined;
|
||||
cleanup?.();
|
||||
delete (container as any).__terminalCleanup;
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
termRef.current?.dispose();
|
||||
termRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
};
|
||||
}, [
|
||||
isMounted,
|
||||
wsPath,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
background,
|
||||
foreground,
|
||||
cursor,
|
||||
selectionBackground,
|
||||
autoFocus,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
style={{ backgroundColor: background, ...style }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
export { Terminal as Screen } from './Screen';
|
||||
export { TerminalView } from './TerminalView';
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"./FileBrowser/server": "./FileBrowser/server/index.ts",
|
||||
"./Terminal": "./Terminal/index.ts",
|
||||
"./Terminal/client": "./Terminal/client/index.ts",
|
||||
"./Terminal/server": "./Terminal/server/index.ts"
|
||||
"./Terminal/server": "./Terminal/server/index.ts",
|
||||
"./Chat": "./Chat/index.ts",
|
||||
"./Chat/client": "./Chat/client/index.ts"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user