Skip to content

Commit 0f0490b

Browse files
feat(chat): implement floating chat component with message input and list
- Added ChatInput component for sending messages. - Created FloatingChat component to manage chat state and display messages. - Implemented MessageBubble and MessageList components for rendering chat messages. - Integrated chat functionality with Laravel Stream for real-time messaging. - Updated app layout to include FloatingChat. - Added ChatMessage and ChatConversation types for better type safety. - Updated package.json to include necessary dependencies for chat features. - Enhanced CSS with Tailwind typography plugin for improved styling. - Added CSRF token meta tag in app.blade.php for security.
1 parent 1e484de commit 0f0490b

11 files changed

Lines changed: 1586 additions & 60 deletions

File tree

package-lock.json

Lines changed: 1290 additions & 60 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"dependencies": {
3030
"@headlessui/react": "^2.2.0",
3131
"@inertiajs/react": "^2.3.7",
32+
"@laravel/stream-react": "^0.3.10",
3233
"@radix-ui/react-avatar": "^1.1.3",
3334
"@radix-ui/react-checkbox": "^1.1.4",
3435
"@radix-ui/react-collapsible": "^1.1.3",
@@ -42,6 +43,7 @@
4243
"@radix-ui/react-toggle": "^1.1.2",
4344
"@radix-ui/react-toggle-group": "^1.1.2",
4445
"@radix-ui/react-tooltip": "^1.1.8",
46+
"@tailwindcss/typography": "^0.5.19",
4547
"@tailwindcss/vite": "^4.1.11",
4648
"@types/react": "^19.2.0",
4749
"@types/react-dom": "^19.2.0",
@@ -55,6 +57,7 @@
5557
"lucide-react": "^0.475.0",
5658
"react": "^19.2.0",
5759
"react-dom": "^19.2.0",
60+
"react-markdown": "^10.1.0",
5861
"tailwind-merge": "^3.0.1",
5962
"tailwindcss": "^4.0.0",
6063
"tw-animate-css": "^1.4.0",

resources/css/app.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
@import 'tailwindcss';
2+
@plugin '@tailwindcss/typography';
23

34
@import 'tw-animate-css';
45

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { SendHorizonal } from 'lucide-react';
2+
import { type KeyboardEvent, useRef, useState } from 'react';
3+
import { Button } from '@/components/ui/button';
4+
import { Spinner } from '@/components/ui/spinner';
5+
6+
type ChatInputProps = {
7+
onSend: (message: string) => void;
8+
disabled?: boolean;
9+
};
10+
11+
export function ChatInput({ onSend, disabled }: ChatInputProps) {
12+
const [message, setMessage] = useState('');
13+
const textareaRef = useRef<HTMLTextAreaElement>(null);
14+
15+
const handleSend = () => {
16+
const trimmed = message.trim();
17+
18+
if (!trimmed || disabled) {
19+
return;
20+
}
21+
22+
onSend(trimmed);
23+
setMessage('');
24+
25+
if (textareaRef.current) {
26+
textareaRef.current.style.height = 'auto';
27+
}
28+
};
29+
30+
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
31+
if (e.key === 'Enter' && !e.shiftKey) {
32+
e.preventDefault();
33+
handleSend();
34+
}
35+
};
36+
37+
const handleInput = () => {
38+
if (textareaRef.current) {
39+
textareaRef.current.style.height = 'auto';
40+
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
41+
}
42+
};
43+
44+
return (
45+
<div className="border-t p-4">
46+
<div className="flex items-end gap-2">
47+
<textarea
48+
ref={textareaRef}
49+
value={message}
50+
onChange={(e) => setMessage(e.target.value)}
51+
onKeyDown={handleKeyDown}
52+
onInput={handleInput}
53+
placeholder="Type a message..."
54+
disabled={disabled}
55+
rows={1}
56+
className="border-input bg-background placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 flex max-h-32 min-h-9 w-full resize-none rounded-md border px-3 py-2 text-sm shadow-xs outline-none focus-visible:ring-[3px] disabled:opacity-50"
57+
/>
58+
<Button onClick={handleSend} disabled={disabled || !message.trim()} size="icon">
59+
{disabled ? <Spinner /> : <SendHorizonal />}
60+
</Button>
61+
</div>
62+
</div>
63+
);
64+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { usePage } from '@inertiajs/react';
2+
import { useStream } from '@laravel/stream-react';
3+
import { MessageCircle, X } from 'lucide-react';
4+
import { useCallback, useEffect, useRef, useState } from 'react';
5+
import { stream } from '@/actions/App/Http/Controllers/ChatController';
6+
import { ChatInput } from '@/components/chat/chat-input';
7+
import { MessageList } from '@/components/chat/message-list';
8+
import { Button } from '@/components/ui/button';
9+
import { cn } from '@/lib/utils';
10+
import type { ChatMessage, SharedData } from '@/types';
11+
12+
const CONVERSATION_MARKER_REGEX = /\n?<<conversation:([a-f0-9-]*)>>/g;
13+
const CONVERSATION_EXTRACT_REGEX = /<<conversation:([a-f0-9-]+)>>/;
14+
15+
export function FloatingChat() {
16+
const { auth } = usePage<SharedData>().props;
17+
18+
const [open, setOpen] = useState(false);
19+
const [messages, setMessages] = useState<ChatMessage[]>([]);
20+
const [streamingContent, setStreamingContent] = useState('');
21+
const conversationIdRef = useRef<string | null>(null);
22+
const accumulatedRef = useRef('');
23+
24+
useEffect(() => {
25+
const handleKeyDown = (e: KeyboardEvent) => {
26+
if (e.key === 'Escape') {
27+
setOpen(false);
28+
}
29+
};
30+
31+
if (open) {
32+
document.addEventListener('keydown', handleKeyDown);
33+
}
34+
35+
return () => document.removeEventListener('keydown', handleKeyDown);
36+
}, [open]);
37+
38+
const { send, isFetching, isStreaming } = useStream(stream.url(), {
39+
onData: (chunk: string) => {
40+
accumulatedRef.current += chunk;
41+
setStreamingContent(accumulatedRef.current.replace(CONVERSATION_MARKER_REGEX, ''));
42+
},
43+
onFinish: () => {
44+
const fullContent = accumulatedRef.current;
45+
const match = fullContent.match(CONVERSATION_EXTRACT_REGEX);
46+
const cleanContent = fullContent.replace(CONVERSATION_MARKER_REGEX, '').trim();
47+
48+
if (cleanContent) {
49+
const assistantMessage: ChatMessage = {
50+
id: `assistant-${Date.now()}`,
51+
role: 'assistant',
52+
content: cleanContent,
53+
created_at: new Date().toISOString(),
54+
};
55+
56+
setMessages((prev) => [...prev, assistantMessage]);
57+
}
58+
59+
setStreamingContent('');
60+
accumulatedRef.current = '';
61+
62+
if (match) {
63+
conversationIdRef.current = match[1];
64+
}
65+
},
66+
onError: () => {
67+
setStreamingContent('');
68+
accumulatedRef.current = '';
69+
},
70+
});
71+
72+
const handleSend = useCallback(
73+
(message: string) => {
74+
const userMessage: ChatMessage = {
75+
id: `user-${Date.now()}`,
76+
role: 'user',
77+
content: message,
78+
created_at: new Date().toISOString(),
79+
};
80+
81+
setMessages((prev) => [...prev, userMessage]);
82+
setStreamingContent('');
83+
accumulatedRef.current = '';
84+
85+
send({
86+
message,
87+
conversation_id: conversationIdRef.current,
88+
});
89+
},
90+
[send],
91+
);
92+
93+
if (!auth.user) {
94+
return null;
95+
}
96+
97+
return (
98+
<>
99+
<Button
100+
size="icon"
101+
className="fixed right-6 bottom-6 z-50 h-14 w-14 rounded-full shadow-lg"
102+
onClick={() => setOpen(true)}
103+
>
104+
<MessageCircle className="h-6 w-6" />
105+
<span className="sr-only">Open chat</span>
106+
</Button>
107+
108+
{open && (
109+
<div
110+
className="fixed inset-0 z-50 bg-black/80"
111+
onClick={() => setOpen(false)}
112+
/>
113+
)}
114+
115+
<div
116+
className={cn(
117+
'bg-background fixed inset-y-0 right-0 z-50 flex w-full flex-col border-l shadow-lg transition-transform duration-300 ease-in-out sm:max-w-md',
118+
open ? 'translate-x-0' : 'translate-x-full',
119+
)}
120+
>
121+
<div className="flex items-center justify-between border-b px-4 py-3">
122+
<div>
123+
<h2 className="text-foreground font-semibold">Chat</h2>
124+
<p className="text-muted-foreground text-sm">Pregúntale a nuestro asistente de IA cualquier cosa.</p>
125+
</div>
126+
<Button variant="ghost" size="icon" onClick={() => setOpen(false)}>
127+
<X className="h-4 w-4" />
128+
<span className="sr-only">Cerrar</span>
129+
</Button>
130+
</div>
131+
<MessageList messages={messages} streamingContent={streamingContent || undefined} />
132+
<ChatInput onSend={handleSend} disabled={isFetching || isStreaming} />
133+
</div>
134+
</>
135+
);
136+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import Markdown from 'react-markdown';
2+
import { cn } from '@/lib/utils';
3+
import type { ChatMessage } from '@/types';
4+
5+
export function MessageBubble({ message }: { message: ChatMessage }) {
6+
const isUser = message.role === 'user';
7+
8+
return (
9+
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
10+
<div
11+
className={cn(
12+
'max-w-[80%] rounded-lg px-4 py-2 text-sm',
13+
isUser
14+
? 'bg-primary text-primary-foreground'
15+
: 'bg-muted text-muted-foreground',
16+
)}
17+
>
18+
{isUser ? (
19+
<span className="whitespace-pre-wrap">{message.content}</span>
20+
) : (
21+
<div className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
22+
<Markdown>{message.content}</Markdown>
23+
</div>
24+
)}
25+
</div>
26+
</div>
27+
);
28+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import Markdown from 'react-markdown';
2+
import { cn } from '@/lib/utils';
3+
import type { ChatMessage } from '@/types';
4+
import { useEffect, useRef } from 'react';
5+
import { MessageBubble } from './message-bubble';
6+
7+
type MessageListProps = {
8+
messages: ChatMessage[];
9+
streamingContent?: string;
10+
};
11+
12+
export function MessageList({ messages, streamingContent }: MessageListProps) {
13+
const bottomRef = useRef<HTMLDivElement>(null);
14+
15+
useEffect(() => {
16+
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
17+
}, [messages, streamingContent]);
18+
19+
return (
20+
<div className="flex-1 space-y-4 overflow-y-auto p-4">
21+
{messages.length === 0 && !streamingContent && (
22+
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
23+
Send a message to start a conversation.
24+
</div>
25+
)}
26+
27+
{messages.map((message) => (
28+
<MessageBubble key={message.id} message={message} />
29+
))}
30+
31+
{streamingContent && (
32+
<div className="flex justify-start">
33+
<div
34+
className={cn(
35+
'bg-muted text-muted-foreground max-w-[80%] rounded-lg px-4 py-2 text-sm',
36+
)}
37+
>
38+
<div className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
39+
<Markdown>{streamingContent}</Markdown>
40+
</div>
41+
</div>
42+
</div>
43+
)}
44+
45+
<div ref={bottomRef} />
46+
</div>
47+
);
48+
}

resources/js/layouts/app/app-sidebar-layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AppContent } from '@/components/app-content';
22
import { AppShell } from '@/components/app-shell';
33
import { AppSidebar } from '@/components/app-sidebar';
44
import { AppSidebarHeader } from '@/components/app-sidebar-header';
5+
import { FloatingChat } from '@/components/chat/floating-chat';
56
import type { AppLayoutProps } from '@/types';
67

78
export default function AppSidebarLayout({
@@ -15,6 +16,7 @@ export default function AppSidebarLayout({
1516
<AppSidebarHeader breadcrumbs={breadcrumbs} />
1617
{children}
1718
</AppContent>
19+
<FloatingChat />
1820
</AppShell>
1921
);
2022
}

resources/js/types/chat.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export type ChatMessage = {
2+
id: string;
3+
role: 'user' | 'assistant';
4+
content: string;
5+
created_at: string;
6+
};
7+
8+
export type ChatConversation = {
9+
id: string;
10+
title: string;
11+
updated_at: string;
12+
};

resources/js/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export type * from './auth';
2+
export type * from './chat';
23
export type * from './navigation';
34
export type * from './ui';
45

0 commit comments

Comments
 (0)