Chat refactoring

This commit is contained in:
2026-02-21 15:05:56 +00:00
parent fda5ea147a
commit af803236c8
25 changed files with 732 additions and 713 deletions
+64
View File
@@ -0,0 +1,64 @@
import { useRef } from 'react';
import { Paperclip, Image, Link, FileText } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
type AttachButtonProps = {
onAttachImage: (file: File) => void;
onAttachWebpage: () => void;
size?: 'sm' | 'md';
};
export function AttachButton({ onAttachImage, onAttachWebpage, size = 'sm' }: AttachButtonProps) {
const imageInputRef = useRef<HTMLInputElement>(null);
const sizeClasses = size === 'md' ? 'h-10 w-10' : 'h-7 w-7 md:h-9 md:w-9';
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={`shrink-0 ${sizeClasses} flex items-center justify-center rounded-lg text-duck-dark/40 hover:text-duck-dark/70 hover:bg-duck-dark/5 transition-colors cursor-pointer`}
>
<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={onAttachWebpage}>
<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 = '';
}}
/>
</>
);
}
@@ -0,0 +1,42 @@
import { Loader2, Image, Link, X } from 'lucide-react';
import type { Attachment } from './types';
type AttachmentListProps = {
attachments: Attachment[];
onRemove: (index: number) => void;
};
export function AttachmentList({ attachments, onRemove }: AttachmentListProps) {
if (attachments.length === 0) return null;
return (
<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 ? 'Loading...' : a.title}
</span>
<button
type="button"
onClick={() => onRemove(i)}
className="shrink-0 hover:text-duck-dark cursor-pointer"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
);
}
+195
View File
@@ -0,0 +1,195 @@
import { useState, useMemo, useRef, useEffect, type KeyboardEvent } from 'react';
import { Send } from 'lucide-react';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import type { ModelOption, Attachment } from './types';
import { ModelSelector } from './ModelSelector';
import { AttachmentList } from './AttachmentList';
import { AttachButton } from './AttachButton';
import { WebpageDialog } from './WebpageDialog';
type ChatLauncherProps = {
availableModels: ModelOption[];
selectedModel: string | null;
onModelChange: (modelId: string) => void;
onSubmit: (data: {
prompt: string;
model: string | null;
attachmentIds?: string[];
images?: { filename: string; dataUrl: string }[];
}) => void;
placeholder?: string;
};
export function ChatLauncher({
availableModels,
selectedModel,
onModelChange,
onSubmit,
placeholder = 'What do you want to work on now?',
}: ChatLauncherProps) {
const client = useClient();
const [input, setInput] = useState('');
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
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,
provider: 'pi-mono',
});
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);
formData.append('provider', 'pi-mono');
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 handleSubmit = () => {
const text = input.trim();
if (!text) return;
let prompt = text;
const attachmentIds: 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 });
}
attachmentIds.push(a.attachmentId);
}
onSubmit({
prompt,
model: selectedModel,
attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined,
images: images.length > 0 ? images : undefined,
});
// Reset form
setInput('');
setAttachments([]);
};
const handleKeyDown = (ev: KeyboardEvent<HTMLTextAreaElement>) => {
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
handleSubmit();
}
};
const handleImagePaste = (file: File) => {
handleAttachImage(file);
};
// Auto-resize textarea
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 160) + 'px';
}, [input]);
return (
<div className="p-4 pb-2 pt-1">
<AttachmentList attachments={attachments} onRemove={(i) => setAttachments((prev) => prev.filter((_, j) => j !== i))} />
<div className="flex items-end gap-2">
<AttachButton
size="md"
onAttachImage={handleAttachImage}
onAttachWebpage={() => setUrlDialogOpen(true)}
/>
<textarea
ref={textareaRef}
value={input}
onChange={(ev) => setInput(ev.target.value)}
onKeyDown={handleKeyDown}
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) handleImagePaste(file);
return;
}
}
}}
placeholder={placeholder}
rows={1}
className="flex-1 resize-none bg-transparent px-2 py-2 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none text-lg"
/>
<Button
onClick={handleSubmit}
disabled={!input.trim()}
size="icon"
className="shrink-0 h-10 w-10 bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
>
<Send className="h-4 w-4" />
</Button>
</div>
<ModelSelector
messages={[]}
availableModels={availableModels}
selectedModel={selectedModel}
onModelChange={onModelChange}
model={selectedModel}
isConnected={true}
isGenerating={false}
/>
<WebpageDialog
open={urlDialogOpen}
onOpenChange={setUrlDialogOpen}
onSubmit={handleAttachWebpage}
/>
</div>
);
}
@@ -0,0 +1,49 @@
import { MessageSquare } from 'lucide-react';
import { useChatSessions } from '@/state/useChatSessions';
export const ChatList = () => {
const { sessions } = useChatSessions();
if (sessions.length === 0) {
return (
<div className="h-full flex items-center justify-center text-duck-dark/30 dark:text-foreground/30 text-sm">
No sessions yet. Start a new chat!
</div>
);
}
return (
<div className="space-y-1.5 p-3">
{sessions.map((session) => (
<a
key={session.id}
href={`/chat/${session.id}`}
className="block p-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 hover:bg-duck-teal/5 dark:hover:bg-duck-teal/10 transition-colors group"
>
<div className="flex items-start gap-2 min-w-0">
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60 mt-0.5" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-duck-dark/80 dark:text-foreground/80 truncate">
{session.title}
</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
{new Date(session.createdAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</div>
{session.model && (
<div className="text-xs text-duck-teal/70 dark:text-duck-teal/60 truncate">
{session.model.includes('/') ? session.model.replace('/', ' - ') : session.model}
</div>
)}
</div>
</div>
</a>
))}
</div>
);
};
+274
View File
@@ -0,0 +1,274 @@
import type { KeyboardEvent } from 'react';
import { useRef, useEffect, useState } from 'react';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import { useVisiblePiModels } from '@/state/useModels';
import { usePi } from './usePi';
import { MessageList } from './MessageList';
import { InputArea } from './InputArea';
import type { Attachment } from './types';
import { useSlashCommands } from './useSlashCommands';
type EmbeddableChatProps = {
sessionId?: string;
initialModel?: string | null;
initialMessage?: {
text: string;
attachmentIds?: string[];
images?: { filename: string; dataUrl: string }[];
cwd?: { root?: string; path: string };
};
defaultInput?: string;
promptPrefix?: string;
className?: string;
cwd?: { root?: string; path: string };
autoSend?: boolean;
};
export const EmbeddableChat = ({
sessionId: initialSessionId,
initialModel,
initialMessage,
defaultInput = '',
promptPrefix,
className,
cwd,
autoSend = false,
}: EmbeddableChatProps) => {
const chat = usePi(initialSessionId, initialModel);
const availableModels = useVisiblePiModels();
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 [commandFeedback, setCommandFeedback] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
const bottomRef = useRef<HTMLDivElement | null>(null);
const userScrolledRef = useRef(false);
const slashCommandHandler = useSlashCommands({ sessionId });
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: 'pi-mono',
});
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', 'pi-mono');
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;
// Handle slash commands
if (text.startsWith('/')) {
const result = await slashCommandHandler.execute(text);
if (result.handled) {
setCommandFeedback(result.feedback);
setInput('');
if (textareaRef.current) textareaRef.current.style.height = 'auto';
return;
}
}
setCommandFeedback(null);
// Prepend metadata/attachment content to the prompt
let prompt = promptPrefix ? `${promptPrefix}\n\n${text}` : 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 initial message when provided
const initialSentRef = useRef(false);
useEffect(() => {
if (initialMessage && isConnected && !initialSentRef.current) {
initialSentRef.current = true;
if (initialModel) setSelectedModel(initialModel);
sendPrompt(
initialMessage.text,
initialMessage.attachmentIds,
initialMessage.images,
initialMessage.cwd,
);
}
}, [initialMessage, isConnected]);
// 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}
messages={messages}
availableModels={availableModels}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
model={model}
attachments={attachments}
onAttachWebpage={handleAttachWebpage}
onAttachImage={handleAttachImage}
onRemoveAttachment={handleRemoveAttachment}
/>
</div>
);
};
+253
View File
@@ -0,0 +1,253 @@
import type { KeyboardEvent, RefObject } from 'react';
import { useState, useRef } from 'react';
import { Loader2, Mic, Send, Square } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import type { ModelOption } from '@/state/useModels';
import type { ChatMessage, Attachment } from './types';
import { ModelSelector } from './ModelSelector';
import { AttachmentList } from './AttachmentList';
import { AttachButton } from './AttachButton';
import { WebpageDialog } from './WebpageDialog';
const blobToWav = async (blob: Blob): Promise<Blob> => {
const ctx = new AudioContext();
const buf = await ctx.decodeAudioData(await blob.arrayBuffer());
await ctx.close();
const samples = buf.getChannelData(0);
const len = samples.length;
const sr = buf.sampleRate;
const ab = new ArrayBuffer(44 + len * 2);
const v = new DataView(ab);
const s = (o: number, str: string) => {
for (let i = 0; i < str.length; i++) v.setUint8(o + i, str.charCodeAt(i));
};
s(0, 'RIFF');
v.setUint32(4, 36 + len * 2, true);
s(8, 'WAVE');
s(12, 'fmt ');
v.setUint32(16, 16, true);
v.setUint16(20, 1, true);
v.setUint16(22, 1, true);
v.setUint32(24, sr, true);
v.setUint32(28, sr * 2, true);
v.setUint16(32, 2, true);
v.setUint16(34, 16, true);
s(36, 'data');
v.setUint32(40, len * 2, true);
for (let i = 0; i < len; i++) {
const val = Math.max(-1, Math.min(1, samples[i]!));
v.setInt16(44 + i * 2, val < 0 ? val * 0x8000 : val * 0x7fff, true);
}
return new Blob([ab], { type: 'audio/wav' });
};
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>;
messages: ChatMessage[];
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,
messages,
availableModels,
selectedModel,
onModelChange,
model,
attachments,
onAttachWebpage,
onAttachImage,
onRemoveAttachment,
}: InputAreaProps) => {
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
const [recording, setRecording] = useState(false);
const [transcribing, setTranscribing] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const handleMicClick = async () => {
if (recording) {
const recorder = mediaRecorderRef.current;
if (!recorder) return;
setRecording(false);
try {
if (recorder.state === 'inactive') {
recorder.stream.getTracks().forEach((t) => t.stop());
return;
}
const blob = await new Promise<Blob>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Recording stop timed out')), 5000);
recorder.onstop = () => {
clearTimeout(timeout);
resolve(new Blob(chunksRef.current, { type: recorder.mimeType }));
chunksRef.current = [];
};
recorder.stop();
});
recorder.stream.getTracks().forEach((t) => t.stop());
if (blob.size === 0) {
toast.error('No audio was captured');
return;
}
setTranscribing(true);
try {
const wav = await blobToWav(blob);
const formData = new FormData();
formData.append('file', wav, 'recording.wav');
formData.append('temperature', '0.0');
formData.append('temperature_inc', '0.2');
formData.append('response_format', 'json');
const res = await fetch('http://macmini:8178/inference', { method: 'POST', body: formData });
if (!res.ok) throw new Error(`Whisper returned ${res.status}`);
const json = await res.json();
if (json.error) throw new Error(json.error);
const text = (json.text ?? '').trim();
if (text) onInputChange(input + (input.length > 0 ? ' ' : '') + text);
} finally {
setTranscribing(false);
}
} catch (err) {
recorder.stream?.getTracks().forEach((t) => t.stop());
chunksRef.current = [];
toast.error(err instanceof Error ? err.message : 'Recording failed');
}
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
mediaRecorderRef.current = recorder;
chunksRef.current = [];
recorder.ondataavailable = (ev) => {
if (ev.data.size > 0) chunksRef.current.push(ev.data);
};
recorder.start(250);
setRecording(true);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Could not access microphone');
}
};
return (
<div className="shrink-0 border-t border-duck-dark/10 bg-background/60 p-2 md:p-3">
{commandFeedback && (
<div className="mb-2 px-3 py-1.5 text-xs text-duck-teal bg-duck-teal/10 rounded-md">{commandFeedback}</div>
)}
<AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />
<div className="flex items-end gap-1 md:gap-2">
<AttachButton
onAttachImage={onAttachImage}
onAttachWebpage={() => setUrlDialogOpen(true)}
/>
<button
type="button"
disabled={transcribing}
onClick={handleMicClick}
className="relative shrink-0 h-7 w-7 md:h-9 md:w-9 flex items-center justify-center rounded-lg text-duck-dark/40 hover:text-duck-dark/70 hover:bg-duck-dark/5 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
{transcribing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : recording ? (
<>
<span className="absolute inset-0 rounded-lg animate-ping bg-red-400/30" />
<Square className="h-3.5 w-3.5 text-red-500" />
</>
) : (
<Mic className="h-4 w-4" />
)}
</button>
<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="min-w-0 flex-1 resize-none rounded-lg border border-duck-dark/20 bg-background/80 px-2 py-1.5 md:px-3 md:py-2 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
/>
{isGenerating ? (
<Button onClick={onStop} variant="destructive" size="icon" className="shrink-0 h-7 w-7 md:h-9 md:w-9 cursor-pointer">
<Square className="h-4 w-4" />
</Button>
) : (
<Button
onClick={onSend}
disabled={!input.trim() || !isConnected}
size="icon"
className="shrink-0 h-7 w-7 md:h-9 md:w-9 bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
>
<Send className="h-4 w-4" />
</Button>
)}
</div>
<ModelSelector
messages={messages}
availableModels={availableModels}
selectedModel={selectedModel}
onModelChange={onModelChange}
model={model}
isConnected={isConnected}
isGenerating={isGenerating}
/>
<WebpageDialog
open={urlDialogOpen}
onOpenChange={setUrlDialogOpen}
onSubmit={onAttachWebpage}
/>
</div>
);
};
+4 -2
View File
@@ -3,7 +3,6 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import type { ChatMessage } from './types';
import { MessageBubble, StreamingBubble } from './MessageBubble';
const MESSAGE_HEIGHT = 100; // Estimated height per message bubble
const OVERSCAN = 5;
export const MessageList = ({
@@ -28,8 +27,9 @@ export const MessageList = ({
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => scrollViewportRef.current,
estimateSize: () => MESSAGE_HEIGHT,
estimateSize: () => 150, // Initial estimate, will be measured
overscan: OVERSCAN,
measureElement: (element) => element.getBoundingClientRect().height,
});
return (
@@ -50,6 +50,8 @@ export const MessageList = ({
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className="absolute top-0 left-0 w-full px-4"
style={{
transform: `translateY(${virtualRow.start}px)`,
+105
View File
@@ -0,0 +1,105 @@
import { useMemo } from 'react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { ModelOption } from '@/state/useModels';
import type { ChatMessage } from './types';
const PROVIDER_DISPLAY: Record<string, string> = {
anthropic: 'Anthropic',
openai: 'OpenAI',
opencode: 'OpenCode Zen',
google: 'Google',
groq: 'Groq',
mistral: 'Mistral',
xai: 'xAI',
openrouter: 'OpenRouter',
huggingface: 'Hugging Face',
'github-copilot': 'GitHub Copilot',
minimax: 'MiniMax',
bedrock: 'Amazon Bedrock',
'google-vertex': 'Google Vertex AI',
'azure-openai': 'Azure OpenAI',
};
type ModelSelectorProps = {
messages: ChatMessage[];
availableModels: ModelOption[];
selectedModel: string | null;
onModelChange: (modelId: string) => void;
model: string | null;
isConnected: boolean;
isGenerating: boolean;
};
export function ModelSelector({
messages,
availableModels,
selectedModel,
onModelChange,
model,
isConnected,
isGenerating,
}: ModelSelectorProps) {
const providers = useMemo(
() => [...new Set(availableModels.map((m) => m.provider).filter(Boolean))] as string[],
[availableModels],
);
const activeProvider = availableModels.find((m) => m.id === selectedModel)?.provider ?? providers[0];
const providerModels = availableModels.filter((m) => m.provider === activeProvider);
const fallbackModelId = providerModels[0]?.id ?? null;
const handleProviderClick = (provider: string) => {
const firstModel = availableModels.find((m) => m.provider === provider);
if (firstModel) onModelChange(firstModel.id);
};
const displayName = (provider: string) => PROVIDER_DISPLAY[provider] ?? provider;
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">
{activeProvider ? displayName(activeProvider) : 'Pi'}
</span>
) : (
<div className="flex items-center gap-1 rounded-lg bg-background/60 p-1">
{providers.map((provider) => (
<button
key={provider}
onClick={() => handleProviderClick(provider)}
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
activeProvider === provider
? 'bg-background text-duck-dark shadow-sm'
: 'text-duck-dark/70 hover:text-duck-dark/90'
}`}
>
{displayName(provider)}
</button>
))}
</div>
)}
<div className="text-xs text-duck-dark/50">
{providerModels.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">
{providerModels.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span>{model ?? 'Pi'}</span>
)}
</div>
</div>
);
}
@@ -0,0 +1,55 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
type WebpageDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (url: string) => void;
};
export function WebpageDialog({ open, onOpenChange, onSubmit }: WebpageDialogProps) {
const [urlInput, setUrlInput] = useState('');
const handleSubmit = () => {
const url = urlInput.trim();
if (!url) return;
onSubmit(url);
setUrlInput('');
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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();
handleSubmit();
}
}}
placeholder="https://example.com"
className="flex-1 rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
autoFocus
/>
<Button
onClick={handleSubmit}
disabled={!urlInput.trim()}
className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
>
Attach
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+13 -13
View File
@@ -2,17 +2,17 @@ export { MessageList } from './MessageList';
export { MessageBubble, StreamingBubble } from './MessageBubble';
export { ToolActivity } from './ToolActivity';
export { QuestionActivity } from './QuestionActivity';
export { ModelSelector } from './ModelSelector';
export { InputArea } from './InputArea';
export { ChatLauncher } from './ChatLauncher';
export { AttachmentList } from './AttachmentList';
export { AttachButton } from './AttachButton';
export { WebpageDialog } from './WebpageDialog';
export { EmbeddableChat } from './EmbeddableChat';
export { usePi, type UsePiType } from './usePi';
export { ChatList } from './ChatList';
export { useSlashCommands } from './useSlashCommands';
export { useChatSessions, type UseChatSessionsType } from './useChatSessions';
export { useChatSession, type UseChatSessionType } from './useChatSession';
export type {
ChatMessage,
SessionEntry,
GroupEntry,
ServerMessage,
Message,
MessageCost,
ModelOption,
TaskInfo,
LegacyChatMessage,
LegacySessionEntry,
LegacyServerMessage,
} from './types';
export * from './types';
+10
View File
@@ -1,3 +1,7 @@
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 };
export type MessageCost = {
inputTokens: number;
outputTokens: number;
@@ -80,6 +84,12 @@ export type Message = {
isError?: boolean;
};
export type SlashCommand = {
command: string;
description?: string;
execute: (args: string, sessionId: string) => Promise<{ success: boolean; feedback: string }>;
};
// Legacy type aliases for backward compatibility during migration
// TODO: Remove after Phase 9 cleanup
@@ -0,0 +1,77 @@
import type { SessionEntry, ChatMessage, Message } from './types';
import { useClient } from 'hooks/useClient';
import { useQuery, useQueryClient } from '@tanstack/react-query';
type SessionWithMessages = {
id: string;
title: string;
model: string;
cwd: string;
groupSlug?: string | null;
createdAt: number;
updatedAt: number;
messageCount: number;
cost: {
inputTokens: number;
outputTokens: number;
totalUSD: number;
};
messages: Message[];
};
type UseChatSessionParams = {
sessionId: string | null | undefined;
};
export function useChatSession({ sessionId }: UseChatSessionParams) {
const client = useClient();
const queryClient = useQueryClient();
const { data: session, isLoading } = useQuery<SessionWithMessages | null>({
queryKey: ['PI_SESSION', sessionId],
enabled: !!sessionId,
queryFn: async () => {
if (!sessionId) return null;
const result = await client.get<{ session: SessionWithMessages }>(`/pi/sessions/${sessionId}`);
return result.session;
},
});
async function saveMessages(messages: ChatMessage[]) {
if (!sessionId) return;
await client.put(`/pi/sessions/${sessionId}/messages`, messages);
}
async function rename(title: string) {
if (!sessionId) return;
await client.patch(`/pi/sessions/${sessionId}`, { title });
queryClient.invalidateQueries({ queryKey: ['PI_SESSION', sessionId] });
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
}
async function deleteSession() {
if (!sessionId) return;
await client.delete(`/pi/sessions/${sessionId}`);
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
queryClient.removeQueries({ queryKey: ['PI_SESSION', sessionId] });
}
async function moveToGroup(groupSlug: string | null) {
if (!sessionId) return;
await client.post(`/pi/sessions/${sessionId}/move`, { groupSlug });
queryClient.invalidateQueries({ queryKey: ['PI_SESSION', sessionId] });
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
}
return {
session,
isLoading,
saveMessages,
rename,
delete: deleteSession,
moveToGroup,
};
}
export type UseChatSessionType = ReturnType<typeof useChatSession>;
@@ -0,0 +1,34 @@
import type { SessionEntry } from './types';
import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient';
import { useQuery } from '@tanstack/react-query';
type UseChatSessionsParams = {
cwd?: string;
};
export function useChatSessions({ cwd }: UseChatSessionsParams = {}) {
const client = useClient();
const { isAuthenticated } = useAuth();
const { data: sessions = [], isLoading } = useQuery<SessionEntry[]>({
queryKey: ['PI_SESSIONS', cwd],
enabled: isAuthenticated,
queryFn: async () => {
const result = await client.post<{ sessions: SessionEntry[] }>('/pi/sessions', cwd ? { cwd } : {});
return result.sessions;
},
});
function searchSessions(query: string) {
return client.get<{ results: SessionEntry[] }>(`/pi/sessions/search?q=${encodeURIComponent(query)}`);
}
return {
sessions,
isLoading,
searchSessions,
};
}
export type UseChatSessionsType = ReturnType<typeof useChatSessions>;
+287
View File
@@ -0,0 +1,287 @@
import { useState, useEffect, useRef } from 'react';
import { useChatWebSocket } from 'hooks/useChatWebSocket';
import { useChatSessions } from '@/state/useChatSessions';
import type { ChatMessage, ServerMessage, TaskInfo, Message } from './types';
const SAVE_DEBOUNCE_MS = 1000;
type ResourceChatStorage = {
load: () => Promise<{ sessionId: string | null; messages: ChatMessage[] }>;
save: (sessionId: string, messages: ChatMessage[]) => Promise<void>;
};
type UsePiOptions = {
replaceUrl?: boolean;
storage?: ResourceChatStorage;
resourceChatDir?: string;
taskInfo?: TaskInfo;
};
export function usePi(initialSessionId?: string, initialModel?: string | null, options?: UsePiOptions) {
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 [cwd, setCwd] = useState<string | null>(null);
const streamingRef = useRef('');
const rafRef = useRef<number | null>(null);
const sessionIdRef = useRef<string | null>(initialSessionId ?? null);
const saveTimerRef = useRef<number | null>(null);
const { getSession, saveMessages } = useChatSessions();
const token = localStorage.getItem('BEARER_TOKEN');
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/pi/chat/ws?token=${token}`;
function flushStreaming() {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
setStreamingText(streamingRef.current);
rafRef.current = null;
});
}
function commitStreaming() {
if (!streamingRef.current) return;
setMessages((prev) => [...prev, { role: 'assistant', text: streamingRef.current }]);
streamingRef.current = '';
setStreamingText('');
}
function handleMessage(data: unknown) {
const msg = data as ServerMessage;
switch (msg.type) {
case 'session:init':
sessionIdRef.current = msg.sessionId;
setSessionId(msg.sessionId);
setModel(msg.model);
setCwd(msg.cwd);
if (replaceUrl) window.history.replaceState(null, '', `/chat/${msg.sessionId}`);
break;
case 'assistant:delta':
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:start':
setMessages((prev) => [
...prev,
{
role: 'tool',
toolName: msg.toolName,
toolInput: msg.toolInput,
toolCallId: msg.toolCallId,
},
]);
break;
case 'tool:result':
setMessages((prev) =>
prev.map((m) =>
m.role === 'tool' && m.toolCallId === msg.toolCallId
? { ...m, output: msg.output, isError: msg.isError }
: m,
),
);
break;
case 'result':
commitStreaming();
setMessages((prev) => [
...prev,
{
role: 'result',
cost: msg.cost,
},
]);
setIsGenerating(false);
break;
case 'sync:messages':
sessionIdRef.current = msg.sessionId;
setSessionId(msg.sessionId);
// Convert Message[] to ChatMessage[]
const chatMessages = msg.messages.map((m): ChatMessage => {
if (m.role === 'user') {
return { role: 'user', text: m.text || '' };
} else if (m.role === 'assistant') {
return { role: 'assistant', text: m.text || '' };
} else if (m.role === 'tool') {
return {
role: 'tool',
toolName: m.toolName || '',
toolInput: m.toolInput || {},
toolCallId: m.toolCallId || '',
output: m.output,
isError: m.isError,
};
}
return { role: 'assistant', text: '' }; // Fallback
});
setMessages(chatMessages);
setIsGenerating(msg.isGenerating);
if (msg.streamingText) {
streamingRef.current = msg.streamingText;
flushStreaming();
}
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;
getSession(initialSessionId)
.then((data) => {
if (data.session?.messages && data.session.messages.length > 0) {
// Convert backend Message[] to ChatMessage[]
const chatMessages = data.session.messages.map((m: Message): ChatMessage => {
if (m.role === 'user') {
return { role: 'user', text: m.text || '' };
} else if (m.role === 'assistant') {
return { role: 'assistant', text: m.text || '' };
} else if (m.role === 'tool') {
return {
role: 'tool',
toolName: m.toolName || '',
toolInput: m.toolInput || {},
toolCallId: m.toolCallId || '',
output: m.output,
isError: m.isError,
};
}
return { role: 'assistant', text: '' }; // Fallback
});
setMessages(chatMessages);
}
})
.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);
};
}, []);
function sendPrompt(
text: string,
attachmentIds?: string[],
images?: { filename: string; dataUrl: string }[],
cwdParam?: { root?: string; path: string },
groupSlug?: string | null,
) {
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 } : {}),
...(cwdParam?.path ? { cwd: cwdParam.path } : {}),
...(groupSlug !== undefined ? { groupSlug } : {}),
...(attachmentIds?.length ? { attachmentIds } : {}),
...(imageData?.length ? { images: imageData } : {}),
...(resourceChatDir ? { resourceChatDir } : {}),
...(taskInfo ? { taskInfo } : {}),
});
}
function stopGeneration() {
send({ type: 'stop' });
}
return {
messages,
streamingText,
isConnected,
isGenerating,
sessionId,
model,
selectedModel,
cwd,
setSelectedModel,
sendPrompt,
stopGeneration,
};
}
export type UsePiType = ReturnType<typeof usePi>;
@@ -0,0 +1,49 @@
import { useChatSession } from './useChatSession';
export type SlashCommandResult = { handled: true; feedback: string } | { handled: false };
type UseSlashCommandsParams = {
sessionId: string | null;
};
export function useSlashCommands({ sessionId }: UseSlashCommandsParams) {
const { rename } = useChatSession({ sessionId });
const execute = async (input: string): Promise<SlashCommandResult> => {
const trimmed = input.trim();
if (!trimmed.startsWith('/')) return { handled: false };
if (!sessionId) return { handled: false };
const spaceIndex = trimmed.indexOf(' ');
const commandName = spaceIndex === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIndex);
const args = spaceIndex === -1 ? '' : trimmed.slice(spaceIndex + 1).trim();
try {
switch (commandName) {
case 'rename': {
if (!args.trim()) {
return { handled: true, feedback: 'Usage: /rename <new name>' };
}
await rename(args);
return { handled: true, feedback: `Session renamed to "${args}"` };
}
case 'help': {
const helpText = [
'Available commands:',
' /rename <name> - Rename the current session',
' /help - Show this help message',
].join('\n');
return { handled: true, feedback: helpText };
}
default:
return { handled: false };
}
} catch (error) {
return { handled: true, feedback: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` };
}
};
return { execute };
}