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

42
src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

26
src/App.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { useState } from 'react';
import { useGateway } from './hooks/useGateway';
import { Header } from './components/Header';
import { Sidebar } from './components/Sidebar';
import { Chat } from './components/Chat';
export default function App() {
const { status, messages, sessions, activeSession, isGenerating, sendMessage, abort, switchSession } = useGateway();
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="h-dvh flex bg-[#1e1e24] text-zinc-300 bg-[radial-gradient(ellipse_at_top,rgba(255,255,255,0.02),transparent_50%),radial_gradient(ellipse_at_bottom_right,rgba(99,102,241,0.04),transparent_50%)]">
<Sidebar
sessions={sessions}
activeSession={activeSession}
onSwitch={switchSession}
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
/>
<div className="flex-1 flex flex-col min-w-0">
<Header status={status} sessionKey={activeSession} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} />
<Chat messages={messages} isGenerating={isGenerating} status={status} onSend={sendMessage} onAbort={abort} />
</div>
</div>
);
}

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

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>
);
}

317
src/hooks/useGateway.ts Normal file
View File

@@ -0,0 +1,317 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { GatewayClient } from '../lib/gateway';
import { genIdempotencyKey } from '../lib/utils';
import type { ChatMessage, MessageBlock, ConnectionStatus, Session } from '../types';
function extractText(message: any): string {
if (!message) return '';
const content = message.content;
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.filter((b: any) => b.type === 'text' && typeof b.text === 'string')
.map((b: any) => b.text)
.join('\n');
}
return '';
}
export function useGateway() {
const clientRef = useRef<GatewayClient | null>(null);
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [sessions, setSessions] = useState<Session[]>([]);
const [activeSession, setActiveSession] = useState('agent:main:main');
const [isGenerating, setIsGenerating] = useState(false);
const messagesRef = useRef(messages);
messagesRef.current = messages;
const activeSessionRef = useRef(activeSession);
activeSessionRef.current = activeSession;
const currentRunIdRef = useRef<string | null>(null);
const [activeSessions, setActiveSessions] = useState<Set<string>>(new Set());
useEffect(() => {
const client = new GatewayClient();
clientRef.current = client;
client.onStatus((s) => {
setStatus(s);
if (s === 'connected') {
loadSessions();
loadHistory(activeSessionRef.current);
}
});
client.onEvent((event, payload) => {
if (event === 'agent') {
// Tool stream events
handleAgentEvent(payload);
return;
}
if (event !== 'chat') return;
const { state, runId, message, errorMessage, sessionKey: evtSession } = payload;
// Track active/inactive sessions globally
if (evtSession) {
if (state === 'delta') {
setActiveSessions(prev => {
if (prev.has(evtSession)) return prev;
const next = new Set(prev);
next.add(evtSession);
return next;
});
} else if (state === 'final' || state === 'error' || state === 'aborted') {
setActiveSessions(prev => {
if (!prev.has(evtSession)) return prev;
const next = new Set(prev);
next.delete(evtSession);
return next;
});
}
}
if (evtSession !== activeSessionRef.current) return;
if (state === 'delta') {
const text = extractText(message);
currentRunIdRef.current = runId;
setMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant' && last.isStreaming && last.runId === runId) {
// Update text block but preserve tool/thinking blocks
const updated = { ...last };
updated.content = text;
const nonTextBlocks = updated.blocks.filter(b => b.type !== 'text');
updated.blocks = [...nonTextBlocks, { type: 'text' as const, text }];
return [...prev.slice(0, -1), updated];
}
// Create new streaming message
const msg: ChatMessage = {
id: runId + '-' + Date.now(),
role: 'assistant',
content: text,
timestamp: Date.now(),
blocks: [{ type: 'text', text }],
isStreaming: true,
runId,
};
return [...prev, msg];
});
} else if (state === 'final') {
currentRunIdRef.current = null;
setIsGenerating(false);
// Reload full history to get the proper final messages with tool calls etc.
loadHistory(activeSessionRef.current);
} else if (state === 'error') {
currentRunIdRef.current = null;
setIsGenerating(false);
setMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant' && last.isStreaming && last.runId === runId) {
return [...prev.slice(0, -1), { ...last, isStreaming: false }];
}
return [...prev, {
id: 'error-' + Date.now(),
role: 'assistant' as const,
content: `Error: ${errorMessage || 'unknown error'}`,
timestamp: Date.now(),
blocks: [{ type: 'text' as const, text: `Error: ${errorMessage || 'unknown error'}` }],
}];
});
} else if (state === 'aborted') {
currentRunIdRef.current = null;
setIsGenerating(false);
setMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant' && last.isStreaming) {
return [...prev.slice(0, -1), { ...last, isStreaming: false }];
}
return prev;
});
}
});
client.connect();
return () => client.disconnect();
}, []);
const handleAgentEvent = useCallback((payload: any) => {
// Handle tool stream events from agent stream
if (payload?.stream !== 'tool') return;
const data = payload.data ?? {};
const phase = data.phase;
const toolCallId = data.toolCallId;
const name = data.name || 'tool';
if (!toolCallId) return;
setMessages(prev => {
const last = prev[prev.length - 1];
if (!last || last.role !== 'assistant' || !last.isStreaming) return prev;
const updated = { ...last, blocks: [...last.blocks] };
if (phase === 'start') {
updated.blocks.push({
type: 'tool_use' as const,
name,
input: data.args,
id: toolCallId,
});
} else if (phase === 'result') {
const result = typeof data.result === 'string' ? data.result : JSON.stringify(data.result, null, 2);
updated.blocks.push({
type: 'tool_result' as const,
content: result?.slice(0, 500) || '',
toolUseId: toolCallId,
name,
});
}
return [...prev.slice(0, -1), updated];
});
}, []);
const loadSessions = useCallback(async () => {
try {
const res = await clientRef.current?.send('sessions.list', {});
if (res?.sessions) {
setSessions(res.sessions.map((s: any) => ({
key: s.key || s.sessionKey,
label: s.label || s.key || s.sessionKey,
messageCount: s.messageCount,
totalTokens: s.totalTokens,
contextTokens: s.contextTokens,
inputTokens: s.inputTokens,
outputTokens: s.outputTokens,
})));
}
} catch {}
}, []);
const loadHistory = useCallback(async (sessionKey: string) => {
try {
const res = await clientRef.current?.send('chat.history', { sessionKey, limit: 100 });
if (res?.messages) {
const rawMsgs: any[] = res.messages;
const msgs: ChatMessage[] = rawMsgs.map((m: any, i: number) => {
const blocks: MessageBlock[] = [];
if (m.content) {
if (Array.isArray(m.content)) {
for (const block of m.content) {
if (block.type === 'text') blocks.push({ type: 'text', text: block.text });
else if (block.type === 'thinking') blocks.push({ type: 'thinking', text: block.thinking || block.text || '' });
// Anthropic format
else if (block.type === 'tool_use') blocks.push({ type: 'tool_use', name: block.name, input: block.input, id: block.id });
else if (block.type === 'tool_result') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.tool_use_id });
// OpenClaw gateway format (toolCall / toolResult)
else if (block.type === 'toolCall') blocks.push({ type: 'tool_use', name: block.name, input: block.arguments || block.input, id: block.id });
else if (block.type === 'toolResult') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.toolCallId || block.tool_use_id, name: block.name });
}
} else if (typeof m.content === 'string') {
blocks.push({ type: 'text', text: m.content });
}
}
// Map gateway roles to our simplified roles
const role: 'user' | 'assistant' = m.role === 'user' ? 'user' : 'assistant';
// toolResult role messages: convert text blocks to tool_result blocks, then merge into previous assistant
if (m.role === 'toolResult') {
const toolBlocks: MessageBlock[] = blocks.map(b => {
if (b.type === 'text') {
return { type: 'tool_result' as const, content: b.text, toolUseId: m.toolCallId };
}
return b;
});
return {
id: m.id || `hist-${i}`,
role: 'assistant' as const,
content: '',
timestamp: m.timestamp || Date.now(),
blocks: toolBlocks,
isToolResult: true,
};
}
return {
id: m.id || `hist-${i}`,
role,
content: blocks.filter(b => b.type === 'text').map(b => (b as any).text).join(''),
timestamp: m.timestamp || Date.now(),
blocks,
};
});
// Merge toolResult messages into their preceding assistant message
const merged: ChatMessage[] = [];
for (const msg of msgs) {
if ((msg as any).isToolResult && merged.length > 0 && merged[merged.length - 1].role === 'assistant') {
merged[merged.length - 1] = {
...merged[merged.length - 1],
blocks: [...merged[merged.length - 1].blocks, ...msg.blocks],
};
} else if ((msg as any).isToolResult) {
// Orphan toolResult — skip or show as assistant
// skip it
} else {
merged.push(msg);
}
}
setMessages(merged);
}
} catch {}
}, []);
const sendMessage = useCallback(async (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => {
const userMsg: ChatMessage = {
id: 'user-' + Date.now(),
role: 'user',
content: text,
timestamp: Date.now(),
blocks: [{ type: 'text', text }],
};
setMessages(prev => [...prev, userMsg]);
setIsGenerating(true);
try {
await clientRef.current?.send('chat.send', {
sessionKey: activeSessionRef.current,
message: text,
deliver: false,
idempotencyKey: genIdempotencyKey(),
...(attachments && attachments.length > 0 ? { attachments } : {}),
});
} catch (e) {
setIsGenerating(false);
}
}, []);
const abort = useCallback(async () => {
try {
await clientRef.current?.send('chat.abort', { sessionKey: activeSessionRef.current });
} catch {}
setIsGenerating(false);
}, []);
const switchSession = useCallback((key: string) => {
setActiveSession(key);
activeSessionRef.current = key;
setMessages([]);
loadHistory(key);
}, [loadHistory]);
// Periodic session refresh every 30s
useEffect(() => {
if (status !== 'connected') return;
const interval = setInterval(loadSessions, 30000);
return () => clearInterval(interval);
}, [status, loadSessions]);
// Merge active state into sessions
const enrichedSessions = sessions.map(s => ({
...s,
isActive: activeSessions.has(s.key),
}));
return { status, messages, sessions: enrichedSessions, activeSession, isGenerating, sendMessage, abort, switchSession, loadSessions };
}

86
src/index.css Normal file
View File

@@ -0,0 +1,86 @@
@import "tailwindcss";
@import "highlight.js/styles/base16/material-palenight.min.css";
* {
scrollbar-width: thin;
scrollbar-color: #52525b #27272a;
}
body {
margin: 0;
background: #1e1e24;
color: #d4d4d8;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@keyframes bounce-dot {
0%, 80%, 100% { transform: translateY(0); opacity: 0.45; }
40% { transform: translateY(-4px); opacity: 1; }
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
.pulse-dot {
animation: pulse-dot 2s ease-in-out infinite;
}
.bounce-dot {
animation: bounce-dot 0.9s infinite ease-in-out;
}
.bounce-dot:nth-child(1) { animation-delay: 0s; }
.bounce-dot:nth-child(2) { animation-delay: 0.12s; }
.bounce-dot:nth-child(3) { animation-delay: 0.24s; }
/* Markdown styles */
.markdown-body pre {
background: #1a1a20 !important;
border: 1px solid rgba(255,255,255,0.06);
border-radius: 12px;
padding: 16px;
overflow-x: auto;
margin: 8px 0;
font-size: 0.82em;
line-height: 1.6;
max-width: 100%;
box-sizing: border-box;
}
.markdown-body pre code {
white-space: pre;
word-break: normal;
overflow-wrap: normal;
}
.markdown-body code {
background: rgba(255,255,255,0.07);
padding: 2px 6px;
border-radius: 6px;
font-size: 0.85em;
}
/* Override highlight.js theme bg to match */
.hljs {
background: #1a1a20 !important;
}
.markdown-body p { margin: 4px 0; }
.markdown-body ul, .markdown-body ol { margin: 4px 0; padding-left: 20px; }
.markdown-body blockquote { border-left: 3px solid rgba(34,211,238,0.4); padding-left: 12px; margin: 8px 0; opacity: 0.8; }
.markdown-body h1, .markdown-body h2, .markdown-body h3 { margin: 12px 0 4px; }
.markdown-body a { color: #67e8f9; text-decoration: underline; }
.markdown-body table { border-collapse: collapse; margin: 8px 0; }
.markdown-body th, .markdown-body td { border: 1px solid rgba(255,255,255,0.08); padding: 6px 12px; }
.markdown-body th { background: rgba(255,255,255,0.04); }
.markdown-body img { max-width: 100%; border-radius: 8px; }

128
src/lib/gateway.ts Normal file
View File

@@ -0,0 +1,128 @@
import { genId } from './utils';
export type GatewayEventHandler = (event: string, payload: any) => void;
export type GatewayResponseHandler = (id: string, ok: boolean, payload: any) => void;
const WS_URL = import.meta.env.VITE_GATEWAY_WS_URL || `ws://${window.location.hostname}:18789`;
const AUTH_TOKEN = import.meta.env.VITE_GATEWAY_TOKEN || '';
export class GatewayClient {
private ws: WebSocket | null = null;
private pendingRequests = new Map<string, { resolve: (v: any) => void; reject: (e: any) => void }>();
private eventHandlers: GatewayEventHandler[] = [];
private _onStatus: (s: 'disconnected' | 'connecting' | 'connected') => void = () => {};
private reconnectTimer: any = null;
private connected = false;
onStatus(fn: (s: 'disconnected' | 'connecting' | 'connected') => void) {
this._onStatus = fn;
}
onEvent(fn: GatewayEventHandler) {
this.eventHandlers.push(fn);
return () => { this.eventHandlers = this.eventHandlers.filter(h => h !== fn); };
}
connect() {
if (this.ws) return;
this._onStatus('connecting');
this.ws = new WebSocket(WS_URL);
this.ws.onopen = () => { console.log('[GW] WS open'); };
this.ws.onmessage = (ev) => {
let msg: any;
try { msg = JSON.parse(ev.data); } catch { console.log('[GW] parse error', ev.data); return; }
console.log('[GW] msg:', msg.type, msg.event || msg.id || '', msg.ok);
if (msg.type === 'event') {
if (msg.event === 'connect.challenge') {
this.handleChallenge();
} else {
for (const h of this.eventHandlers) h(msg.event, msg.payload);
}
} else if (msg.type === 'res') {
const pending = this.pendingRequests.get(msg.id);
if (pending) {
this.pendingRequests.delete(msg.id);
if (msg.ok) pending.resolve(msg.payload);
else pending.reject(msg.payload || msg.error);
}
}
};
this.ws.onclose = (ev) => {
console.log('[GW] WS close:', ev.code, ev.reason);
this.ws = null;
this.connected = false;
this._onStatus('disconnected');
this.pendingRequests.forEach(p => p.reject(new Error('disconnected')));
this.pendingRequests.clear();
this.scheduleReconnect();
};
this.ws.onerror = (e) => { console.log('[GW] WS error', e); };
}
private handleChallenge() {
const id = genId('connect');
this.request(id, 'connect', {
minProtocol: 3,
maxProtocol: 3,
client: { id: 'webchat', version: '1.0.0', platform: 'web', mode: 'webchat' },
role: 'operator',
scopes: ['operator.read', 'operator.write'],
caps: [],
commands: [],
permissions: {},
auth: { token: AUTH_TOKEN },
locale: 'fr-FR',
userAgent: 'clawchat/1.0.0',
}).then((res) => {
console.log('[GW] connected!', res);
this.connected = true;
this._onStatus('connected');
}).catch((err) => {
console.log('[GW] connect failed:', err);
this.disconnect();
});
}
private scheduleReconnect() {
if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, 3000);
}
disconnect() {
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
if (this.ws) { this.ws.close(); this.ws = null; }
this.connected = false;
this._onStatus('disconnected');
}
request(id: string, method: string, params: any): Promise<any> {
return new Promise((resolve, reject) => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return reject(new Error('not connected'));
}
this.pendingRequests.set(id, { resolve, reject });
this.ws.send(JSON.stringify({ type: 'req', id, method, params }));
setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error('timeout'));
}
}, 30000);
});
}
async send(method: string, params: any): Promise<any> {
const id = genId('req');
return this.request(id, method, params);
}
get isConnected() { return this.connected; }
}

15
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,15 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
let counter = 0;
export function genId(prefix = 'req') {
return `${prefix}-${++counter}-${Date.now()}`;
}
export function genIdempotencyKey() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

36
src/types/index.ts Normal file
View File

@@ -0,0 +1,36 @@
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: number;
blocks: MessageBlock[];
isStreaming?: boolean;
runId?: string;
}
export type MessageBlock =
| { type: 'text'; text: string }
| { type: 'thinking'; text: string }
| { type: 'tool_use'; name: string; input: any; id?: string }
| { type: 'tool_result'; content: string; toolUseId?: string; name?: string };
export interface Session {
key: string;
label?: string;
messageCount?: number;
isActive?: boolean;
totalTokens?: number;
contextTokens?: number;
inputTokens?: number;
outputTokens?: number;
}
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected';
export interface GatewayState {
status: ConnectionStatus;
sessions: Session[];
activeSession: string;
messages: ChatMessage[];
isGenerating: boolean;
}