first
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user