Initial commit — ClawChat v1.0.0

This commit is contained in:
Nicolas Varrot
2026-02-11 00:48:43 +00:00
commit 1f8ff9ae0a
30 changed files with 7862 additions and 0 deletions

70
src/components/Chat.tsx Normal file
View File

@@ -0,0 +1,70 @@
import { useEffect, useRef } from 'react';
import { ChatMessageComponent } from './ChatMessage';
import { ChatInput } from './ChatInput';
import { TypingIndicator } from './TypingIndicator';
import type { ChatMessage, ConnectionStatus } from '../types';
import { Bot } from 'lucide-react';
interface Props {
messages: ChatMessage[];
isGenerating: boolean;
status: ConnectionStatus;
onSend: (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => void;
onAbort: () => void;
}
function hasVisibleContent(msg: ChatMessage): boolean {
if (msg.role === 'user') return true;
if (msg.blocks.length === 0) return !!msg.content;
// Show all assistant messages — tool-only ones render as compact inline
return msg.blocks.some(b =>
(b.type === 'text' && b.text.trim()) ||
b.type === 'thinking' ||
b.type === 'tool_use' ||
b.type === 'tool_result'
);
}
function hasStreamedText(messages: ChatMessage[]): boolean {
if (messages.length === 0) return false;
const last = messages[messages.length - 1];
if (last.role !== 'assistant') return false;
return last.blocks.some(b => b.type === 'text' && b.text.trim().length > 0) || (last.content?.trim().length > 0);
}
export function Chat({ messages, isGenerating, status, onSend, onAbort }: Props) {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, isGenerating]);
const showTyping = isGenerating && !hasStreamedText(messages);
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto py-4">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-[60vh] text-zinc-500">
<div className="relative mb-6">
<div className="absolute -inset-4 rounded-3xl bg-gradient-to-r from-cyan-400/10 via-indigo-500/10 to-violet-500/10 blur-2xl" />
<div className="relative flex h-16 w-16 items-center justify-center rounded-3xl border border-white/8 bg-zinc-800/40">
<Bot className="h-8 w-8 text-cyan-200" />
</div>
</div>
<div className="text-lg text-zinc-200 font-semibold">ClawChat</div>
<div className="text-sm mt-1 text-zinc-500">Envoie un message pour commencer</div>
</div>
)}
{messages.filter(hasVisibleContent).map(msg => (
<ChatMessageComponent key={msg.id} message={msg} />
))}
{showTyping && <TypingIndicator />}
<div ref={bottomRef} />
</div>
</div>
<ChatInput onSend={onSend} onAbort={onAbort} isGenerating={isGenerating} disabled={status !== 'connected'} />
</div>
);
}

View File

@@ -0,0 +1,266 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { Send, Square, Paperclip, X, FileText } from 'lucide-react';
interface FileAttachment {
id: string;
file: File;
base64: string; // raw base64 (no data: prefix)
mimeType: string;
preview?: string; // data url thumbnail for images
}
interface Props {
onSend: (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => void;
onAbort: () => void;
isGenerating: boolean;
disabled: boolean;
}
const MAX_BASE64_CHARS = 300 * 1024; // ~225KB real, well under 512KB WS limit (JSON overhead + base64 bloat)
const MAX_IMAGE_PIXELS = 1280; // Max dimension for resize
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
const base64 = dataUrl.split(',')[1] || '';
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function compressImage(file: File, maxBase64Chars: number): Promise<{ base64: string; mimeType: string }> {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let { width, height } = img;
// Downscale if needed
if (width > MAX_IMAGE_PIXELS || height > MAX_IMAGE_PIXELS) {
const ratio = Math.min(MAX_IMAGE_PIXELS / width, MAX_IMAGE_PIXELS / height);
width = Math.round(width * ratio);
height = Math.round(height * ratio);
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0, width, height);
// Try JPEG at decreasing quality until base64 length is under limit
for (let q = 0.85; q >= 0.2; q -= 0.05) {
const dataUrl = canvas.toDataURL('image/jpeg', q);
const b64 = dataUrl.split(',')[1] || '';
if (b64.length <= maxBase64Chars) {
return resolve({ base64: b64, mimeType: 'image/jpeg' });
}
}
// Last resort: further downscale
const scale = 0.5;
canvas.width = Math.round(width * scale);
canvas.height = Math.round(height * scale);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL('image/jpeg', 0.3);
resolve({ base64: dataUrl.split(',')[1] || '', mimeType: 'image/jpeg' });
};
img.onerror = reject;
img.src = url;
});
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
export function ChatInput({ onSend, onAbort, isGenerating, disabled }: Props) {
const [text, setText] = useState('');
const [files, setFiles] = useState<FileAttachment[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 200) + 'px';
}
}, [text]);
const addFiles = useCallback(async (fileList: FileList | File[]) => {
const newFiles: FileAttachment[] = [];
for (const file of Array.from(fileList)) {
if (file.size > 20 * 1024 * 1024) continue; // 20MB max
const isImage = file.type.startsWith('image/');
let base64: string;
let mimeType: string;
if (isImage) {
// Compress images to fit WS payload limit
const compressed = await compressImage(file, MAX_BASE64_CHARS);
base64 = compressed.base64;
mimeType = compressed.mimeType;
} else {
base64 = await fileToBase64(file);
mimeType = file.type || 'application/octet-stream';
}
newFiles.push({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
file,
base64,
mimeType,
preview: isImage ? `data:${mimeType};base64,${base64}` : undefined,
});
}
setFiles(prev => [...prev, ...newFiles]);
}, []);
const removeFile = useCallback((id: string) => {
setFiles(prev => prev.filter(f => f.id !== id));
}, []);
const handleSubmit = () => {
const trimmed = text.trim();
if ((!trimmed && files.length === 0) || disabled) return;
const attachments = files.length > 0 ? files.map(f => ({
mimeType: f.mimeType,
fileName: f.file.name,
content: f.base64,
})) : undefined;
onSend(trimmed || ' ', attachments);
setText('');
setFiles([]);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
const pastedFiles: File[] = [];
for (const item of Array.from(items)) {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) pastedFiles.push(file);
}
}
if (pastedFiles.length > 0) {
e.preventDefault();
addFiles(pastedFiles);
}
}, [addFiles]);
// Drag & drop handlers on the wrapper
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
if (e.dataTransfer.files.length > 0) {
addFiles(e.dataTransfer.files);
}
}, [addFiles]);
return (
<div
className="border-t border-white/8 bg-[#1a1a20]/60 backdrop-blur-xl p-4"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="max-w-4xl mx-auto">
<div className={`rounded-3xl border bg-[#232329]/40 p-3 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] transition-colors ${isDragOver ? 'border-cyan-400/40 bg-cyan-400/5' : 'border-white/8'}`}>
{/* File previews */}
{files.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3 px-1">
{files.map(f => (
<div key={f.id} className="group relative flex items-center gap-2 rounded-2xl border border-white/8 bg-zinc-800/50 px-3 py-2 text-xs text-zinc-400">
{f.preview ? (
<img src={f.preview} alt="" className="h-8 w-8 rounded-lg object-cover" />
) : (
<FileText size={16} className="text-zinc-500 shrink-0" />
)}
<div className="min-w-0 max-w-[120px]">
<div className="truncate text-zinc-300">{f.file.name}</div>
<div className="text-[10px] text-zinc-500">{formatSize(f.file.size)}</div>
</div>
<button
onClick={() => removeFile(f.id)}
className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full bg-zinc-700 border border-white/10 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-500/80"
>
<X size={10} className="text-zinc-200" />
</button>
</div>
))}
</div>
)}
<div className="flex items-end gap-3">
{/* File picker button */}
<button
onClick={() => fileInputRef.current?.click()}
disabled={disabled}
className="shrink-0 h-11 w-11 rounded-2xl border border-white/8 bg-zinc-800/30 flex items-center justify-center text-zinc-400 hover:text-cyan-300 hover:bg-white/5 transition-colors disabled:opacity-30"
title="Joindre un fichier"
>
<Paperclip size={18} />
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }}
accept="image/*,.pdf,.txt,.md,.json,.csv,.log,.py,.js,.ts,.tsx,.jsx,.html,.css,.yaml,.yml,.xml,.sql,.sh,.env,.toml"
/>
<textarea
ref={textareaRef}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder="Écris un message…"
disabled={disabled}
rows={1}
className="flex-1 bg-transparent resize-none rounded-2xl border border-white/8 bg-zinc-900/35 px-4 py-3 text-sm text-zinc-300 placeholder:text-zinc-500 outline-none focus:ring-2 focus:ring-cyan-400/30 transition-all max-h-[200px]"
/>
{isGenerating ? (
<button
onClick={onAbort}
className="shrink-0 h-11 px-4 rounded-2xl border border-red-500/20 bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors flex items-center gap-2"
>
<Square size={16} />
<span className="text-sm hidden sm:inline">Stop</span>
</button>
) : (
<button
onClick={handleSubmit}
disabled={(!text.trim() && files.length === 0) || disabled}
className="shrink-0 h-11 px-5 rounded-2xl bg-gradient-to-r from-cyan-500/80 via-indigo-500/70 to-violet-500/80 text-zinc-900 font-semibold text-sm hover:opacity-90 shadow-[0_8px_24px_rgba(34,211,238,0.1)] disabled:opacity-30 disabled:shadow-none transition-all flex items-center gap-2"
>
<Send size={16} />
<span className="hidden sm:inline">Envoyer</span>
</button>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,245 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import type { ChatMessage as ChatMessageType, MessageBlock } from '../types';
import { ThinkingBlock } from './ThinkingBlock';
import { ToolCall } from './ToolCall';
import { Bot, User, Wrench } from 'lucide-react';
// ChevronDown, ChevronRight, Wrench still used by InternalOnlyMessage
function formatTimestamp(ts: number): string {
const date = new Date(ts);
const now = new Date();
const time = date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
if (isToday) return time;
if (isYesterday) return `Hier ${time}`;
return `${date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })} ${time}`;
}
/** Guess a language hint from content patterns */
function guessLanguage(lines: string[]): string {
const joined = lines.join('\n');
if (/^import .+ from ['"]/.test(joined) || /^export (function|const|default|class|interface|type) /.test(joined) || /React\./.test(joined) || /<\w+[\s/>]/.test(joined) && /className=/.test(joined)) return 'tsx';
if (/^(import|export|const|let|var|function|class|interface|type) /.test(joined) || /=>\s*{/.test(joined) || /: (string|number|boolean|any)\b/.test(joined)) return 'typescript';
if (/^(use |fn |let mut |pub |impl |struct |enum |mod |crate::)/.test(joined) || /-> (Self|Result|Option|Vec|String|bool|i32|u32)/.test(joined)) return 'rust';
if (/^(def |class |import |from .+ import |if __name__)/.test(joined) || /self\.\w+/.test(joined) && !/this\./.test(joined)) return 'python';
if (/^\s*(server|location|upstream|proxy_pass|listen \d)/.test(joined)) return 'nginx';
if (/^\[.*\]\s*$/.test(lines[0] || '') && /=/.test(joined)) return 'ini';
if (/^(apiVersion|kind|metadata|spec):/.test(joined)) return 'yaml';
if (/^\{/.test(joined.trim()) && /\}$/.test(joined.trim())) return 'json';
if (/^#!\/(bin|usr)/.test(joined) || /^\s*(if \[|then|fi|echo |export |source )/.test(joined)) return 'bash';
if (/^(<!DOCTYPE|<html|<div|<head|<body)/.test(joined)) return 'html';
if (/^\.\w+\s*\{|^@(media|keyframes|import)/.test(joined)) return 'css';
if (/^(SELECT|INSERT|CREATE|ALTER|DROP|UPDATE) /i.test(joined)) return 'sql';
return '';
}
/** Detect if a block of lines looks like code */
function looksLikeCode(lines: string[]): boolean {
if (lines.length < 2) return false;
let codeSignals = 0;
const patterns = [
/^(import|export|const|let|var|function|class|interface|type|enum|struct|fn|pub|use|def|from|module|package|namespace)\s/,
/[{};]\s*$/,
/^\s*(if|else|for|while|return|match|switch|case|break|continue)\b/,
/^\s*(\/\/|#|\/\*|\*)/,
/[├└│┬─]──/,
/^\s+\w+\(.*\)/,
/^\s*<\/?[A-Z]\w*/,
/=>\s*[{(]/,
/\.\w+\(.*\)\s*[;,]?\s*$/,
];
for (const line of lines) {
for (const pat of patterns) {
if (pat.test(line)) { codeSignals++; break; }
}
}
return codeSignals / lines.length > 0.3;
}
/** Auto-wrap unformatted code/terminal output in fenced code blocks */
function autoFormatText(text: string): string {
// Already has code fences — leave as-is
if (text.includes('```')) return text;
const lines = text.split('\n');
// If most of the text looks like code, wrap the whole thing
const nonEmptyLines = lines.filter(l => l.trim());
if (nonEmptyLines.length >= 3 && looksLikeCode(nonEmptyLines)) {
const lang = guessLanguage(nonEmptyLines);
return '```' + lang + '\n' + text + '\n```';
}
// Otherwise, detect contiguous code blocks within prose
const result: string[] = [];
let codeBuffer: string[] = [];
const flushCode = () => {
if (codeBuffer.length >= 3 && looksLikeCode(codeBuffer)) {
const lang = guessLanguage(codeBuffer);
result.push('```' + lang);
result.push(...codeBuffer);
result.push('```');
} else {
result.push(...codeBuffer);
}
codeBuffer = [];
};
const isCodeLine = (line: string): boolean => {
return /^[\s]+(import|export|const|let|var|function|return|if|else|for)/.test(line)
|| /[{};]\s*$/.test(line)
|| /^\s*(\/\/|#)/.test(line)
|| /[├└│┬─]──/.test(line)
|| /^\s+\w+\(.*\)/.test(line);
};
for (const line of lines) {
if (isCodeLine(line) || (codeBuffer.length > 0 && (line.trim() === '' || /^\s{2,}/.test(line)))) {
codeBuffer.push(line);
} else {
flushCode();
result.push(line);
}
}
flushCode();
return result.join('\n');
}
function getTextBlocks(blocks: MessageBlock[]): MessageBlock[] {
return blocks.filter(b => b.type === 'text' && b.text.trim());
}
function getInternalBlocks(blocks: MessageBlock[]): MessageBlock[] {
return blocks.filter(b => b.type === 'thinking' || b.type === 'tool_use' || b.type === 'tool_result');
}
function renderTextBlocks(blocks: MessageBlock[]) {
return getTextBlocks(blocks).map((block, i) => (
<div key={`text-${i}`} className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
{autoFormatText((block as any).text)}
</ReactMarkdown>
</div>
));
}
function renderInternalBlocks(blocks: MessageBlock[]) {
const elements: React.ReactElement[] = [];
const internals = getInternalBlocks(blocks);
for (let i = 0; i < internals.length; i++) {
const block = internals[i];
if (block.type === 'thinking') {
elements.push(<ThinkingBlock key={`int-${i}`} text={block.text} />);
} else if (block.type === 'tool_use') {
const nextBlock = internals[i + 1];
const result = nextBlock?.type === 'tool_result' ? nextBlock.content : undefined;
elements.push(<ToolCall key={`int-${i}`} name={block.name} input={block.input} result={result} />);
if (result !== undefined) i++;
} else if (block.type === 'tool_result') {
elements.push(<ToolCall key={`int-${i}`} name={block.name || 'tool'} result={block.content} />);
}
}
return elements;
}
function InternalsSummary({ blocks }: { blocks: MessageBlock[] }) {
const internals = getInternalBlocks(blocks);
if (internals.length === 0) return null;
return (
<div className="mt-2 space-y-1">
{renderInternalBlocks(blocks)}
</div>
);
}
/** Message with ONLY internal blocks (no text for the user) */
function InternalOnlyMessage({ message }: { message: ChatMessageType }) {
return (
<div className="animate-fade-in flex gap-3 px-4 py-1">
<div className="shrink-0 mt-0.5 flex h-6 w-6 items-center justify-center rounded-xl border border-white/5 bg-zinc-800/30">
<Wrench className="h-3 w-3 text-zinc-500" />
</div>
<div className="min-w-0 flex-1">
<div className="space-y-1">
{renderInternalBlocks(message.blocks)}
</div>
{message.timestamp && (
<div className="mt-0.5 text-[10px] text-zinc-600">
{formatTimestamp(message.timestamp)}
</div>
)}
</div>
</div>
);
}
export function ChatMessageComponent({ message }: { message: ChatMessageType }) {
const isUser = message.role === 'user';
// Assistant message with no text content — only tool calls / thinking
if (!isUser && message.blocks.length > 0) {
const textBlocks = getTextBlocks(message.blocks);
const hasText = textBlocks.length > 0 || (message.isStreaming && message.content?.trim());
if (!hasText && !message.isStreaming) {
return <InternalOnlyMessage message={message} />;
}
}
return (
<div className={`animate-fade-in flex gap-3 px-4 py-2 ${isUser ? 'flex-row-reverse' : ''}`}>
{/* Avatar */}
<div className="shrink-0 mt-1 flex h-9 w-9 items-center justify-center rounded-2xl border border-white/8 bg-zinc-800/40">
{isUser
? <User className="h-4 w-4 text-violet-200" />
: <Bot className="h-4 w-4 text-cyan-200" />
}
</div>
{/* Bubble */}
<div className={`min-w-0 max-w-[80%] ${isUser ? 'text-right' : ''}`}>
<div className={`inline-block text-left rounded-3xl px-4 py-3 text-sm leading-relaxed border border-white/8 max-w-full overflow-hidden ${
isUser
? 'bg-gradient-to-b from-zinc-800/70 to-zinc-900/70 text-zinc-200'
: 'bg-zinc-800/40 text-zinc-300 shadow-[0_0_0_1px_rgba(255,255,255,0.03)]'
}`}>
{/* User-visible text */}
{message.blocks.length > 0 ? renderTextBlocks(message.blocks) : (
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
{autoFormatText(message.content)}
</ReactMarkdown>
</div>
)}
{/* Streaming dots */}
{message.isStreaming && (
<div className="flex gap-1 mt-2">
<span className="bounce-dot w-1.5 h-1.5 rounded-full bg-gradient-to-r from-cyan-300/80 to-violet-400/80 inline-block" />
<span className="bounce-dot w-1.5 h-1.5 rounded-full bg-gradient-to-r from-cyan-300/80 to-violet-400/80 inline-block" />
<span className="bounce-dot w-1.5 h-1.5 rounded-full bg-gradient-to-r from-cyan-300/80 to-violet-400/80 inline-block" />
</div>
)}
{/* Tool calls & thinking (inline) */}
{!isUser && <InternalsSummary blocks={message.blocks} />}
</div>
{message.timestamp && (
<div className={`mt-1 text-[11px] text-zinc-500 ${isUser ? 'text-right pr-2' : 'pl-2'}`}>
{formatTimestamp(message.timestamp)}
</div>
)}
</div>
</div>
);
}

70
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,70 @@
import { Menu, Bot, Sparkles } from 'lucide-react';
import type { ConnectionStatus, Session } from '../types';
interface Props {
status: ConnectionStatus;
sessionKey: string;
onToggleSidebar: () => void;
activeSessionData?: Session;
}
export function Header({ status, sessionKey, onToggleSidebar, activeSessionData }: Props) {
const sessionLabel = sessionKey.split(':').pop() || sessionKey;
return (
<>
<header className="h-14 border-b border-white/8 bg-[#232329]/90 backdrop-blur-xl flex items-center px-4 gap-3 shrink-0">
<button onClick={onToggleSidebar} className="lg:hidden p-2 rounded-2xl hover:bg-white/5 text-zinc-400 transition-colors">
<Menu size={20} />
</button>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex h-9 w-9 items-center justify-center rounded-2xl border border-white/8 bg-zinc-800/40">
<Bot className="h-4 w-4 text-cyan-200" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-zinc-300 text-sm tracking-wide">ClawChat</span>
<Sparkles className="h-3.5 w-3.5 text-cyan-300/60" />
</div>
<span className="text-xs text-zinc-500 truncate block">{sessionLabel}</span>
</div>
</div>
<div className="flex items-center gap-2 text-sm">
{status === 'connected' ? (
<div className="flex items-center gap-2 rounded-2xl border border-white/8 bg-zinc-800/30 px-3 py-1.5">
<span className="w-2 h-2 rounded-full bg-cyan-300/80 shadow-[0_0_12px_rgba(34,211,238,0.6)]" />
<span className="text-xs text-zinc-300 hidden sm:inline">Connecté</span>
</div>
) : status === 'connecting' ? (
<div className="flex items-center gap-2 rounded-2xl border border-white/8 bg-zinc-800/30 px-3 py-1.5">
<span className="w-2 h-2 rounded-full bg-yellow-400/80 pulse-dot" />
<span className="text-xs text-zinc-300 hidden sm:inline">Connexion</span>
</div>
) : (
<div className="flex items-center gap-2 rounded-2xl border border-white/8 bg-zinc-800/30 px-3 py-1.5">
<span className="w-2 h-2 rounded-full bg-red-400/80" />
<span className="text-xs text-zinc-300 hidden sm:inline">Déconnecté</span>
</div>
)}
</div>
</header>
{(() => {
const ctx = activeSessionData?.contextTokens;
const total = activeSessionData?.totalTokens || 0;
if (!ctx) return null;
const pct = Math.min(100, (total / ctx) * 100);
const barColor = pct > 95 ? 'bg-red-500' : pct > 80 ? 'bg-amber-500' : 'bg-gradient-to-r from-cyan-400 to-violet-500';
return (
<div className="px-4 py-1.5 bg-[#232329]/60 border-b border-white/8 flex items-center gap-3">
<div className="flex-1 h-[5px] rounded-full bg-white/5 overflow-hidden">
<div className={`h-full rounded-full transition-all duration-500 ${barColor}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-[11px] text-zinc-400 tabular-nums shrink-0 whitespace-nowrap">
{(total / 1000).toFixed(1)}k / {(ctx / 1000).toFixed(0)}k tokens
</span>
</div>
);
})()}
</>
);
}

View File

@@ -0,0 +1,91 @@
import { MessageSquare, X, Sparkles } from 'lucide-react';
import type { Session } from '../types';
interface Props {
sessions: Session[];
activeSession: string;
onSwitch: (key: string) => void;
open: boolean;
onClose: () => void;
}
export function Sidebar({ sessions, activeSession, onSwitch, open, onClose }: Props) {
return (
<>
{open && <div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 lg:hidden" onClick={onClose} />}
<aside className={`fixed lg:relative top-0 left-0 h-full w-72 bg-[#1e1e24]/95 border-r border-white/8 z-50 transform transition-transform lg:translate-x-0 ${open ? 'translate-x-0' : '-translate-x-full'} flex flex-col backdrop-blur-xl`}>
<div className="h-14 flex items-center justify-between px-4 border-b border-white/8">
<div className="flex items-center gap-2">
<div className="relative">
<div className="absolute -inset-1.5 rounded-xl bg-gradient-to-r from-cyan-400/15 to-violet-500/15 blur-lg" />
<div className="relative flex h-8 w-8 items-center justify-center rounded-xl border border-white/8 bg-zinc-800/50">
<Sparkles className="h-4 w-4 text-cyan-200" />
</div>
</div>
<span className="font-semibold text-sm text-zinc-200 tracking-wide">Sessions</span>
</div>
<button onClick={onClose} className="lg:hidden p-1.5 rounded-xl hover:bg-white/5 text-zinc-400 transition-colors">
<X size={16} />
</button>
</div>
<div className="flex-1 overflow-y-auto py-2 px-2">
{sessions.length === 0 && (
<div className="px-3 py-8 text-center text-zinc-500 text-sm">Aucune session</div>
)}
{sessions.map(s => {
const isActive = s.key === activeSession;
return (
<button
key={s.key}
onClick={() => { onSwitch(s.key); onClose(); }}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl text-left text-sm transition-all mb-1 ${
isActive
? 'bg-white/5 text-cyan-200 border border-white/8 shadow-[0_0_12px_rgba(34,211,238,0.08)]'
: s.isActive
? 'bg-violet-500/5 text-violet-200 border border-violet-500/15 shadow-[0_0_10px_rgba(168,85,247,0.06)]'
: 'text-zinc-400 hover:bg-white/5 border border-transparent'
}`}
>
<div className="relative">
<MessageSquare size={15} className={isActive ? 'text-cyan-300/70' : s.isActive ? 'text-violet-400/70' : ''} />
{s.isActive && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-violet-400 shadow-[0_0_8px_rgba(168,85,247,0.7)] animate-pulse" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
<span className="flex-1 truncate">{s.label || s.key}</span>
{s.messageCount != null && (
<span className={`text-[11px] px-2 py-0.5 rounded-full shrink-0 ${isActive ? 'bg-cyan-400/10 text-cyan-300' : 'bg-white/5 text-zinc-500'}`}>
{s.messageCount}
</span>
)}
</div>
{(() => {
if (!s.contextTokens) return null;
const pct = Math.min(100, ((s.totalTokens || 0) / s.contextTokens) * 100);
const barColor = pct > 95 ? 'bg-red-500' : pct > 80 ? 'bg-amber-500' : 'bg-gradient-to-r from-cyan-400 to-violet-500';
return (
<div className="flex items-center gap-1.5 mt-1">
<div className="flex-1 h-[3px] rounded-full bg-white/5 overflow-hidden">
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-[9px] text-zinc-500 tabular-nums shrink-0">{Math.round(pct)}%</span>
</div>
);
})()}
</div>
</button>
);
})}
</div>
{/* Footer glow dots */}
<div className="px-4 py-3 border-t border-white/8 flex items-center justify-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-violet-300/60 shadow-[0_0_10px_rgba(168,85,247,0.5)]" />
<span className="h-1.5 w-1.5 rounded-full bg-cyan-300/60 shadow-[0_0_10px_rgba(34,211,238,0.5)]" />
<span className="h-1.5 w-1.5 rounded-full bg-indigo-300/50 shadow-[0_0_10px_rgba(99,102,241,0.4)]" />
</div>
</aside>
</>
);
}

View File

@@ -0,0 +1,24 @@
import { useState } from 'react';
import { ChevronRight, ChevronDown, Brain } from 'lucide-react';
export function ThinkingBlock({ text }: { text: string }) {
const [open, setOpen] = useState(false);
return (
<div className="my-2">
<button
onClick={() => setOpen(!open)}
className="inline-flex items-center gap-1.5 rounded-2xl border border-white/8 bg-zinc-800/35 px-3 py-1.5 text-xs text-violet-300 hover:bg-white/5 transition-colors"
>
<Brain size={13} />
<span className="font-medium">Réflexion</span>
{open ? <ChevronDown size={12} className="ml-1 text-zinc-500" /> : <ChevronRight size={12} className="ml-1 text-zinc-500" />}
</button>
{open && (
<div className="mt-2 rounded-2xl border border-white/8 bg-zinc-800/25 p-3 text-sm italic text-zinc-400 whitespace-pre-wrap max-h-96 overflow-y-auto">
{text}
</div>
)}
</div>
);
}

202
src/components/ToolCall.tsx Normal file
View File

@@ -0,0 +1,202 @@
import { useState, useMemo } from 'react';
import { ChevronRight, ChevronDown, Terminal, Globe, Search, FileText, Wrench, Code, Database, Image, MessageSquare, Brain, Cpu } from 'lucide-react';
import hljs from 'highlight.js/lib/common';
type ToolColor = { border: string; bg: string; text: string; icon: string; glow: string; expandBorder: string; expandBg: string };
const toolColors: Record<string, ToolColor> = {
exec: { border: 'border-amber-500/30', bg: 'bg-amber-500/10', text: 'text-amber-300', icon: 'text-amber-400', glow: 'shadow-[0_0_8px_rgba(245,158,11,0.15)]', expandBorder: 'border-amber-500/20', expandBg: 'bg-amber-950/20' },
web_search: { border: 'border-emerald-500/30', bg: 'bg-emerald-500/10', text: 'text-emerald-300', icon: 'text-emerald-400', glow: 'shadow-[0_0_8px_rgba(16,185,129,0.15)]', expandBorder: 'border-emerald-500/20', expandBg: 'bg-emerald-950/20' },
web_fetch: { border: 'border-emerald-500/30', bg: 'bg-emerald-500/10', text: 'text-emerald-300', icon: 'text-emerald-400', glow: 'shadow-[0_0_8px_rgba(16,185,129,0.15)]', expandBorder: 'border-emerald-500/20', expandBg: 'bg-emerald-950/20' },
Read: { border: 'border-sky-500/30', bg: 'bg-sky-500/10', text: 'text-sky-300', icon: 'text-sky-400', glow: 'shadow-[0_0_8px_rgba(14,165,233,0.15)]', expandBorder: 'border-sky-500/20', expandBg: 'bg-sky-950/20' },
read: { border: 'border-sky-500/30', bg: 'bg-sky-500/10', text: 'text-sky-300', icon: 'text-sky-400', glow: 'shadow-[0_0_8px_rgba(14,165,233,0.15)]', expandBorder: 'border-sky-500/20', expandBg: 'bg-sky-950/20' },
Write: { border: 'border-violet-500/30', bg: 'bg-violet-500/10', text: 'text-violet-300', icon: 'text-violet-400', glow: 'shadow-[0_0_8px_rgba(139,92,246,0.15)]', expandBorder: 'border-violet-500/20', expandBg: 'bg-violet-950/20' },
write: { border: 'border-violet-500/30', bg: 'bg-violet-500/10', text: 'text-violet-300', icon: 'text-violet-400', glow: 'shadow-[0_0_8px_rgba(139,92,246,0.15)]', expandBorder: 'border-violet-500/20', expandBg: 'bg-violet-950/20' },
Edit: { border: 'border-violet-500/30', bg: 'bg-violet-500/10', text: 'text-violet-300', icon: 'text-violet-400', glow: 'shadow-[0_0_8px_rgba(139,92,246,0.15)]', expandBorder: 'border-violet-500/20', expandBg: 'bg-violet-950/20' },
edit: { border: 'border-violet-500/30', bg: 'bg-violet-500/10', text: 'text-violet-300', icon: 'text-violet-400', glow: 'shadow-[0_0_8px_rgba(139,92,246,0.15)]', expandBorder: 'border-violet-500/20', expandBg: 'bg-violet-950/20' },
browser: { border: 'border-cyan-500/30', bg: 'bg-cyan-500/10', text: 'text-cyan-300', icon: 'text-cyan-400', glow: 'shadow-[0_0_8px_rgba(6,182,212,0.15)]', expandBorder: 'border-cyan-500/20', expandBg: 'bg-cyan-950/20' },
image: { border: 'border-pink-500/30', bg: 'bg-pink-500/10', text: 'text-pink-300', icon: 'text-pink-400', glow: 'shadow-[0_0_8px_rgba(236,72,153,0.15)]', expandBorder: 'border-pink-500/20', expandBg: 'bg-pink-950/20' },
message: { border: 'border-indigo-500/30', bg: 'bg-indigo-500/10', text: 'text-indigo-300', icon: 'text-indigo-400', glow: 'shadow-[0_0_8px_rgba(99,102,241,0.15)]', expandBorder: 'border-indigo-500/20', expandBg: 'bg-indigo-950/20' },
memory_search: { border: 'border-rose-500/30', bg: 'bg-rose-500/10', text: 'text-rose-300', icon: 'text-rose-400', glow: 'shadow-[0_0_8px_rgba(244,63,94,0.15)]', expandBorder: 'border-rose-500/20', expandBg: 'bg-rose-950/20' },
memory_get: { border: 'border-rose-500/30', bg: 'bg-rose-500/10', text: 'text-rose-300', icon: 'text-rose-400', glow: 'shadow-[0_0_8px_rgba(244,63,94,0.15)]', expandBorder: 'border-rose-500/20', expandBg: 'bg-rose-950/20' },
cron: { border: 'border-orange-500/30', bg: 'bg-orange-500/10', text: 'text-orange-300', icon: 'text-orange-400', glow: 'shadow-[0_0_8px_rgba(249,115,22,0.15)]', expandBorder: 'border-orange-500/20', expandBg: 'bg-orange-950/20' },
sessions_spawn: { border: 'border-teal-500/30', bg: 'bg-teal-500/10', text: 'text-teal-300', icon: 'text-teal-400', glow: 'shadow-[0_0_8px_rgba(20,184,166,0.15)]', expandBorder: 'border-teal-500/20', expandBg: 'bg-teal-950/20' },
};
const defaultColor: ToolColor = { border: 'border-zinc-500/30', bg: 'bg-zinc-500/10', text: 'text-zinc-300', icon: 'text-zinc-400', glow: 'shadow-[0_0_8px_rgba(161,161,170,0.1)]', expandBorder: 'border-zinc-500/20', expandBg: 'bg-zinc-800/25' };
function getColor(name: string): ToolColor {
return toolColors[name] || defaultColor;
}
const toolIcons: Record<string, React.ReactNode> = {
exec: <Terminal size={13} />,
web_search: <Globe size={13} />,
web_fetch: <Globe size={13} />,
search: <Search size={13} />,
Read: <FileText size={13} />,
read: <FileText size={13} />,
Write: <Code size={13} />,
write: <Code size={13} />,
Edit: <Code size={13} />,
edit: <Code size={13} />,
browser: <Globe size={13} />,
image: <Image size={13} />,
message: <MessageSquare size={13} />,
database: <Database size={13} />,
memory_search: <Brain size={13} />,
memory_get: <Brain size={13} />,
cron: <Cpu size={13} />,
sessions_spawn: <Cpu size={13} />,
};
function getToolIcon(name: string) {
return toolIcons[name] || <Wrench size={13} />;
}
function truncateResult(result: string, maxLen = 120): string {
if (!result) return '';
return truncate(result, maxLen);
}
/** Check if text looks like structured content worth highlighting */
function isStructured(text: string): boolean {
const lines = text.split('\n');
if (lines.length < 2) return false;
const trimmed = text.trim();
// JSON
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) return true;
// Code patterns
const codePatterns = [/^(import|export|const|let|var|function|class|fn|pub|use|def|from)\s/, /[{};]\s*$/, /^\s*(if|else|for|while|return)\b/, /^\s*(\/\/|#|\/\*)/, /=>\s*[{(]/, /^\s*<\/?[A-Z]/];
let hits = 0;
for (const line of lines) {
for (const pat of codePatterns) {
if (pat.test(line)) { hits++; break; }
}
}
if (hits / lines.length > 0.2) return true;
// Terminal output (paths, errors, commands)
const termPatterns = [/^[/~]/, /^\s*\$\s/, /^[A-Z_]+=/, /error|warning|failed/i, /\.\w{1,4}:\d+/, /├|└|│/];
let termHits = 0;
for (const line of lines) {
for (const pat of termPatterns) {
if (pat.test(line)) { termHits++; break; }
}
}
return termHits / lines.length > 0.3;
}
/** Highlight code using highlight.js, returns HTML string or null */
function highlightCode(text: string): string | null {
if (!text || !isStructured(text)) return null;
try {
const result = hljs.highlightAuto(text);
return result.value;
} catch {
return null;
}
}
export function HighlightedPre({ text, className }: { text: string; className: string }) {
const highlighted = useMemo(() => highlightCode(text), [text]);
if (highlighted) {
return (
<pre className={className}>
<code className="hljs" dangerouslySetInnerHTML={{ __html: highlighted }} />
</pre>
);
}
return <pre className={className}>{text}</pre>;
}
function getContextHint(name: string, input: any): string | null {
if (!input || typeof input !== 'object') return null;
switch (name) {
case 'exec':
return input.command ? truncate(input.command, 60) : null;
case 'Read': case 'read':
case 'Write': case 'write':
case 'Edit': case 'edit':
return input.file_path || input.path || null;
case 'web_search':
return input.query ? truncate(input.query, 50) : null;
case 'web_fetch':
return input.url ? truncate(input.url, 60) : null;
case 'browser':
return input.action || null;
case 'message':
return input.action ? `${input.action}${input.target ? ' → ' + input.target : ''}` : null;
case 'memory_search':
return input.query ? truncate(input.query, 50) : null;
case 'memory_get':
return input.path || null;
case 'cron':
return input.action || null;
case 'sessions_spawn':
return input.task ? truncate(input.task, 50) : null;
case 'image':
return input.prompt ? truncate(input.prompt, 50) : null;
default:
return null;
}
}
function truncate(s: string, max: number): string {
const clean = s.replace(/\n/g, ' ').trim();
return clean.length <= max ? clean : clean.slice(0, max) + '…';
}
export function ToolCall({ name, input, result }: { name: string; input?: any; result?: string }) {
const [open, setOpen] = useState(false);
const c = getColor(name);
const inputStr = input ? (typeof input === 'string' ? input : JSON.stringify(input, null, 2)) : '';
const hint = getContextHint(name, input);
return (
<div className="my-2">
{/* Tool use badge */}
<button
onClick={() => setOpen(!open)}
className={`inline-flex items-center gap-1.5 rounded-2xl border ${c.border} ${c.bg} ${c.glow} px-3 py-1.5 text-xs ${c.text} hover:brightness-125 transition-all max-w-full`}
>
<span className={c.icon}>{getToolIcon(name)}</span>
<span className="font-mono font-semibold shrink-0">{name}</span>
{hint && <span className="opacity-60 truncate font-mono text-[11px]">{hint}</span>}
{open ? <ChevronDown size={12} className="ml-1 opacity-60" /> : <ChevronRight size={12} className="ml-1 opacity-60" />}
</button>
{/* Result summary (always visible if result exists) */}
{result && !open && (
<div className="mt-1 text-[11px] text-zinc-400 pl-2 truncate max-w-md">
{truncateResult(result)}
</div>
)}
{/* Expanded content */}
{open && (
<div className={`mt-2 rounded-2xl border ${c.expandBorder} ${c.expandBg} p-3 space-y-2`}>
{inputStr && (
<div>
<div className={`text-[11px] ${c.text} opacity-70 mb-1 font-medium`}>Paramètres</div>
<HighlightedPre
text={inputStr}
className="text-xs bg-[#1a1a20]/60 border border-white/5 p-2.5 rounded-xl overflow-x-auto text-zinc-300 font-mono"
/>
</div>
)}
{result && (
<div>
<div className={`text-[11px] ${c.text} opacity-70 mb-1 font-medium`}>Résultat</div>
<HighlightedPre
text={result}
className="text-xs bg-[#1a1a20]/60 border border-white/5 p-2.5 rounded-xl overflow-x-auto text-zinc-300 max-h-64 overflow-y-auto font-mono"
/>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { Bot } from 'lucide-react';
export function TypingIndicator() {
return (
<div className="animate-fade-in flex items-start gap-3 px-4 py-3">
<div className="shrink-0 flex h-9 w-9 items-center justify-center rounded-2xl border border-white/10 bg-zinc-900/60">
<Bot className="h-4 w-4 text-cyan-200" />
</div>
<div className="rounded-3xl border border-white/10 bg-zinc-900/55 px-4 py-3 shadow-[0_0_0_1px_rgba(255,255,255,0.03)]">
<div className="flex items-center gap-1.5">
<span className="bounce-dot h-2 w-2 rounded-full bg-gradient-to-r from-cyan-300/80 to-violet-400/80" />
<span className="bounce-dot h-2 w-2 rounded-full bg-gradient-to-r from-cyan-300/80 to-violet-400/80" />
<span className="bounce-dot h-2 w-2 rounded-full bg-gradient-to-r from-cyan-300/80 to-violet-400/80" />
<span className="ml-2 text-xs text-zinc-400">Thinking</span>
</div>
</div>
</div>
);
}