Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
135 changes: 3 additions & 132 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex h-screen bg-background text-foreground">
{/* Sidebar */}
<Sidebar
isOpen={sidebarOpen}
onToggle={() => setSidebarOpen(!sidebarOpen)}
onNewChat={onNewChat}
onSelectChat={onSelectChat}
onDeleteCurrentChat={onDeleteCurrentChat}
currentChatId={id || null}
/>

{/* Main Content Area */}
<div className="flex flex-col flex-1 overflow-hidden">
<ModelSelector />

{messages.length === 0 ? (
// Empty state: centered input
<div className="flex-1 flex flex-col items-center justify-center p-4">
<div className="w-full max-w-3xl">
<h2 className="text-3xl font-normal text-center mb-8 text-foreground">
What can I help you with?
</h2>
<InputArea
input={input}
handleInputChange={handleInputChange as any}
handleSubmit={onSubmit}
isLoading={isLoading}
onStop={stop}
/>
</div>
</div>
) : (
// Chat view: normal layout
<>
<ChatContainer messages={messages} />
<InputArea
input={input}
handleInputChange={handleInputChange as any}
handleSubmit={onSubmit}
isLoading={isLoading}
onStop={stop}
/>
</>
)}

{toast && (
<Toast
message={toast.message}
variant={toast.variant}
onClose={hideToast}
/>
)}
</div>
</div>
);
}

function App() {
const { loadConfig } = useConfigStore();
Expand Down
6 changes: 3 additions & 3 deletions client/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<string | null>(null);
const [dropdownMenu, setDropdownMenu] = useState<{
chatId: string;
Expand Down Expand Up @@ -148,7 +148,7 @@ export function Sidebar({

{/* Chat List */}
{isOpen && (
<div className="flex-1 overflow-y-auto p-2 space-y-1">
<div className="flex-1 overflow-y-auto p-2">
{chats.map((chat) => (
<ChatListItem
key={chat.id}
Expand Down
71 changes: 71 additions & 0 deletions client/src/components/chat/AttachmentPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';
import { X, File, FileText, FileCode, Image } from 'lucide-react';

interface AttachmentPreviewProps {
file: File;
onRemove: () => void;
}

export function AttachmentPreview({ file, onRemove }: AttachmentPreviewProps) {
const [previewUrl, setPreviewUrl] = useState<string | null>(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 (
<div className="relative group inline-flex items-center gap-2 p-2 bg-accent border border-border rounded-lg">
{isImage && previewUrl ? (
<img
src={previewUrl}
alt={file.name}
className="w-12 h-12 object-cover rounded"
/>
) : (
<div className="w-12 h-12 flex items-center justify-center bg-background rounded">
<Icon className="h-6 w-6 text-muted-foreground" />
</div>
)}

<div className="flex flex-col min-w-0">
<span className="text-xs text-foreground truncate max-w-[150px]">
{file.name}
</span>
<span className="text-xs text-muted-foreground">
{(file.size / 1024).toFixed(1)} KB
</span>
</div>

<button
type="button"
onClick={onRemove}
className="absolute -top-2 -right-2 p-1 bg-destructive text-destructive-foreground rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="h-3 w-3" />
</button>
</div>
);
}
6 changes: 2 additions & 4 deletions client/src/components/chat/ChatListItem.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -58,13 +58,11 @@ export function ChatListItem({
<div
onClick={isEditing ? undefined : onClick}
className={cn(
'group flex items-center gap-3 p-3 rounded-lg transition-colors',
'group flex items-center gap-3 p-2 rounded-lg transition-colors',
!isEditing && 'cursor-pointer',
isActive ? 'bg-accent/70' : 'hover:bg-border'
)}
>
<MessageSquare className="h-4 w-4 text-foreground flex-shrink-0" />

{isEditing ? (
<div className="flex-1 flex items-center gap-2">
<input
Expand Down
Loading
Loading