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.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 ( <>
-
- {/* Plus Menu */} - - -