diff --git a/README.md b/README.md
index 79ea009..a4162fc 100644
--- a/README.md
+++ b/README.md
@@ -83,7 +83,10 @@ VITE_API_URL=http://localhost:3001
│ │ ├── index.ts # Main server
│ │ ├── providers.ts # AI SDK provider registry
│ │ ├── config.ts # Environment config
-│ │ └── types.ts # TypeScript interfaces
+│ │ ├── types.ts # TypeScript interfaces
+│ │ ├── /routes # API routes
+│ │ └── /lib # Utilities
+│ ├── /data # db.json, uploads
│ ├── /tests # Vitest tests
│ └── package.json
│
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 01cafb8..91d4c1a 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -1,137 +1,8 @@
-import { useEffect, useState } from 'react';
-import { Routes, Route, useParams, useNavigate } from 'react-router-dom';
-import { ChatContainer } from './components/chat/ChatContainer';
-import { InputArea } from './components/chat/InputArea';
-import { ModelSelector } from './components/chat/ModelSelector';
-import { Sidebar } from './components/Sidebar';
-import { Toast, useToast } from './components/ui/toast';
+import { useEffect } from 'react';
+import { Routes, Route } from 'react-router-dom';
+import { ChatView } from './components/chat/ChatView';
import { useConfigStore } from './store/config-store';
import { initializeToolRegistry } from './components/tools';
-import { useChatSession } from './hooks/useChatSession';
-
-function ChatView() {
- const [sidebarOpen, setSidebarOpen] = useState(true);
- const { id } = useParams();
- const navigate = useNavigate();
- const { toast, showToast, hideToast } = useToast();
-
- // Get all chat state and handlers from custom hook
- const {
- messages,
- input,
- handleInputChange,
- handleSubmit,
- isLoading,
- error,
- stop,
- handleNewChat,
- handleSelectChat,
- } = useChatSession();
-
- // Load chat when URL changes
- useEffect(() => {
- const loadChatById = async (chatId: string) => {
- try {
- await handleSelectChat(chatId);
- } catch (error) {
- showToast('Chat not found', 'error');
- navigate('/');
- }
- };
-
- if (id) {
- loadChatById(id);
- } else {
- handleNewChat();
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [id]);
-
- // Show toast on error
- useEffect(() => {
- if (error) {
- showToast(error.message, 'error');
- }
- }, [error, showToast]);
-
- // Navigation handlers (passed as props to children)
- const onSelectChat = (chatId: string) => {
- navigate(`/c/${chatId}`);
- };
-
- const onNewChat = () => {
- navigate('/');
- };
-
- const onDeleteCurrentChat = () => {
- navigate('/');
- };
-
- // Wrap handleSubmit to navigate to new chats
- const onSubmit = async (e: React.FormEvent) => {
- const result = await handleSubmit(e);
- if (result?.newChatId) {
- navigate(`/c/${result.newChatId}`);
- }
- };
-
- return (
-
- {/* Sidebar */}
-
setSidebarOpen(!sidebarOpen)}
- onNewChat={onNewChat}
- onSelectChat={onSelectChat}
- onDeleteCurrentChat={onDeleteCurrentChat}
- currentChatId={id || null}
- />
-
- {/* Main Content Area */}
-
-
-
- {messages.length === 0 ? (
- // Empty state: centered input
-
-
-
- What can I help you with?
-
-
-
-
- ) : (
- // Chat view: normal layout
- <>
-
-
- >
- )}
-
- {toast && (
-
- )}
-
-
- );
-}
function App() {
const { loadConfig } = useConfigStore();
diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx
index 2b8863c..55a2c25 100644
--- a/client/src/components/Sidebar.tsx
+++ b/client/src/components/Sidebar.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react';
+import { useEffect, useState } from 'react';
import { PenSquare, PanelLeft, Pencil, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useConfigStore } from '@/store/config-store';
@@ -24,7 +24,7 @@ export function Sidebar({
onDeleteCurrentChat,
currentChatId,
}: SidebarProps) {
- const [isHovering, setIsHovering] = React.useState(false);
+ const [isHovering, setIsHovering] = useState(false);
const [chatToDelete, setChatToDelete] = useState(null);
const [dropdownMenu, setDropdownMenu] = useState<{
chatId: string;
@@ -148,7 +148,7 @@ export function Sidebar({
{/* Chat List */}
{isOpen && (
-
+
{chats.map((chat) => (
void;
+}
+
+export function AttachmentPreview({ file, onRemove }: AttachmentPreviewProps) {
+ const [previewUrl, setPreviewUrl] = useState(null);
+
+ const isImage = file.type.startsWith('image/');
+ const isPdf = file.type === 'application/pdf';
+ const isCode =
+ file.type.includes('javascript') ||
+ file.type.includes('typescript') ||
+ file.type === 'application/json';
+ const isText = file.type.startsWith('text/');
+
+ // Create object URL for image preview
+ useEffect(() => {
+ if (isImage) {
+ const url = URL.createObjectURL(file);
+ setPreviewUrl(url);
+ return () => URL.revokeObjectURL(url);
+ }
+ }, [file, isImage]);
+
+ const getIcon = () => {
+ if (isImage) return Image;
+ if (isPdf) return File;
+ if (isCode) return FileCode;
+ if (isText) return FileText;
+ return File;
+ };
+
+ const Icon = getIcon();
+
+ return (
+
+ {isImage && previewUrl ? (
+

+ ) : (
+
+
+
+ )}
+
+
+
+ {file.name}
+
+
+ {(file.size / 1024).toFixed(1)} KB
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/chat/ChatListItem.tsx b/client/src/components/chat/ChatListItem.tsx
index 62af8cb..390be73 100644
--- a/client/src/components/chat/ChatListItem.tsx
+++ b/client/src/components/chat/ChatListItem.tsx
@@ -1,4 +1,4 @@
-import { MessageSquare, MoreHorizontal } from 'lucide-react';
+import { MoreHorizontal } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import { cn } from '@/lib/utils';
import type { ChatListItem } from '@/lib/types';
@@ -58,13 +58,11 @@ export function ChatListItem({
-
-
{isEditing ? (
{
+ const loadChatById = async (chatId: string) => {
+ try {
+ await handleSelectChat(chatId);
+ } catch (error) {
+ showToast('Chat not found', 'error');
+ navigate('/');
+ }
+ };
+
+ if (id) {
+ loadChatById(id);
+ } else {
+ handleNewChat();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [id]);
+
+ // Show toast on error
+ useEffect(() => {
+ if (error) {
+ showToast(error.message, 'error');
+ }
+ }, [error, showToast]);
+
+ // Navigation handlers (passed as props to children)
+ const onSelectChat = (chatId: string) => {
+ navigate(`/c/${chatId}`);
+ };
+
+ const onNewChat = () => {
+ navigate('/');
+ };
+
+ const onDeleteCurrentChat = () => {
+ navigate('/');
+ };
+
+ // Wrap handleSubmit to navigate to new chats
+ const onSubmit = async (e: React.FormEvent) => {
+ const result = await handleSubmit(e);
+ if (result?.newChatId) {
+ navigate(`/c/${result.newChatId}`);
+ }
+ };
+
+ return (
+
+ {/* Sidebar */}
+
setSidebarOpen(!sidebarOpen)}
+ onNewChat={onNewChat}
+ onSelectChat={onSelectChat}
+ onDeleteCurrentChat={onDeleteCurrentChat}
+ currentChatId={id || null}
+ />
+
+ {/* Main Content Area */}
+
+
+
+ {messages.length === 0 ? (
+ // Empty state: centered input
+
+
+
+ What can I help you with?
+
+
+
+
+ ) : (
+ // Chat view: normal layout
+ <>
+
+
+ >
+ )}
+
+ {toast && (
+
+ )}
+
+
+ );
+}
diff --git a/client/src/components/chat/FilePicker.tsx b/client/src/components/chat/FilePicker.tsx
new file mode 100644
index 0000000..5f64ca3
--- /dev/null
+++ b/client/src/components/chat/FilePicker.tsx
@@ -0,0 +1,62 @@
+import { useRef } from 'react';
+import { Paperclip } from 'lucide-react';
+
+interface FilePickerProps {
+ onFilesSelected: (files: FileList) => void;
+ disabled?: boolean;
+}
+
+const ACCEPTED_FILE_TYPES = [
+ 'image/png',
+ 'image/jpeg',
+ 'image/gif',
+ 'image/webp',
+ 'application/pdf',
+ 'text/plain',
+ 'text/markdown',
+ 'text/javascript',
+ 'application/javascript',
+ 'text/x-javascript',
+ 'application/x-javascript',
+ 'text/typescript',
+ 'application/x-typescript',
+ 'application/json',
+].join(',');
+
+export function FilePicker({ onFilesSelected, disabled }: FilePickerProps) {
+ const inputRef = useRef
(null);
+
+ const handleClick = () => {
+ inputRef.current?.click();
+ };
+
+ const handleChange = (e: React.ChangeEvent) => {
+ if (e.target.files && e.target.files.length > 0) {
+ onFilesSelected(e.target.files);
+ // Reset input so same file can be selected again
+ e.target.value = '';
+ }
+ };
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/client/src/components/chat/InputArea.tsx b/client/src/components/chat/InputArea.tsx
index 6a65039..1e9abe1 100644
--- a/client/src/components/chat/InputArea.tsx
+++ b/client/src/components/chat/InputArea.tsx
@@ -1,16 +1,18 @@
-import React from 'react';
+import { useState, ChangeEvent, FormEvent, KeyboardEvent } from 'react';
import { Button } from '@/components/ui/button';
import { Square } from 'lucide-react';
import { ToolMenu } from './ToolMenu';
-import { ToolPill } from './ToolPill';
+import { AttachmentPreview } from './AttachmentPreview';
+import { FilePicker } from './FilePicker';
+import { ToolPillContainer } from './ToolPillContainer';
import { toolRegistry } from '@/lib/tool-registry';
import { useConfigStore } from '@/store/config-store';
import birdIcon from '@/assets/bird.png';
interface InputAreaProps {
input: string;
- handleInputChange: (e: React.ChangeEvent) => void;
- handleSubmit: (e: React.FormEvent) => void;
+ handleInputChange: (e: ChangeEvent) => void;
+ handleSubmit: (e: FormEvent) => void;
isLoading: boolean;
onStop: () => void;
}
@@ -22,120 +24,105 @@ export function InputArea({
isLoading,
onStop,
}: InputAreaProps) {
- const [activePanelToolId, setActivePanelToolId] = React.useState<
- string | null
- >(null);
- const [activeConfigToolId, setActiveConfigToolId] = React.useState<
- string | null
- >(null);
- const { tools, toggleTool } = useConfigStore();
+ const [activePanelToolId, setActivePanelToolId] = useState(
+ null
+ );
+ const { tools, pendingFiles, addPendingFile, removePendingFile } =
+ useConfigStore();
- const enabledTools = tools.filter((t) => t.enabled);
+ const hasFiles = pendingFiles.length > 0;
- const handleKeyDown = (e: React.KeyboardEvent) => {
+ const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e as any);
}
};
- const handleRemoveTool = (toolId: string) => {
- toggleTool(toolId, false);
+ const handleFilesSelected = (files: FileList) => {
+ Array.from(files).forEach((file) => addPendingFile(file));
};
- const handleOpenConfig = (toolId: string) => {
- setActiveConfigToolId(toolId);
- };
+ const getFileId = (file: File) => `${file.name}-${file.lastModified}`;
return (
<>