Skip to content

Commit 023d3b2

Browse files
committed
a bunch of tidying up the client side
1 parent 0962f46 commit 023d3b2

File tree

8 files changed

+262
-312
lines changed

8 files changed

+262
-312
lines changed

example/src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import "./App.css";
2-
import ChatWindow from "./components/chat-window";
2+
import ChatWindow from "./components/ChatWindow";
33

44
function App() {
55
return (
Lines changed: 135 additions & 175 deletions
Original file line numberDiff line numberDiff line change
@@ -1,175 +1,135 @@
1-
import type React from "react";
2-
3-
import type { Message } from "@/lib/types";
4-
import MessageItem from "./message-item";
5-
import {
6-
useRef,
7-
useEffect,
8-
useCallback,
9-
useState,
10-
useLayoutEffect,
11-
} from "react";
12-
import { useMutation, useQuery } from "convex/react";
13-
import { api } from "../../convex/_generated/api";
14-
15-
function useMessages() {
16-
const localMessages: Message[] = [
17-
{
18-
id: "1",
19-
chatId: "1",
20-
role: "assistant",
21-
timestamp: new Date(),
22-
content: "Hello! How can I help you today?",
23-
},
24-
];
25-
const serverMessages = useQuery(api.messages.listMessages);
26-
if (!serverMessages) return localMessages;
27-
28-
for (const message of serverMessages) {
29-
localMessages.push({
30-
id: message._id + "-user",
31-
chatId: message._id,
32-
role: "user",
33-
timestamp: new Date(message._creationTime),
34-
content: message.prompt,
35-
});
36-
localMessages.push({
37-
id: message._id + "-assistant",
38-
chatId: message._id,
39-
role: "assistant",
40-
timestamp: new Date(message._creationTime),
41-
streamId: message.responseStreamId,
42-
});
43-
}
44-
return localMessages;
45-
}
46-
47-
export default function ChatWindow() {
48-
const [drivenIds, setDrivenIds] = useState<Set<string>>(new Set());
49-
const [isStreaming, setIsStreaming] = useState(false);
50-
const messages = useMessages();
51-
const [inputValue, setInputValue] = useState("");
52-
const messagesEndRef = useRef<HTMLDivElement>(null);
53-
const messageContainerRef = useRef<HTMLDivElement>(null);
54-
const inputRef = useRef<HTMLInputElement>(null);
55-
const clearAllMessages = useMutation(api.messages.clearMessages);
56-
57-
const focusInput = useCallback(() => {
58-
inputRef.current?.focus();
59-
}, []);
60-
61-
const scrollToBottom = useCallback(
62-
(behavior: ScrollBehavior = "smooth") => {
63-
if (messagesEndRef.current) {
64-
messagesEndRef.current.scrollIntoView({ behavior });
65-
}
66-
},
67-
[messagesEndRef]
68-
);
69-
70-
const windowSize = useWindowSize();
71-
72-
useEffect(() => {
73-
scrollToBottom();
74-
}, [windowSize, scrollToBottom]);
75-
76-
const sendMessage = useMutation(api.messages.sendMessage);
77-
78-
const handleSubmit = async (e: React.FormEvent) => {
79-
e.preventDefault();
80-
if (!inputValue.trim()) return;
81-
82-
setInputValue("");
83-
84-
const chatId = await sendMessage({
85-
prompt: inputValue,
86-
});
87-
88-
setDrivenIds((prev) => {
89-
prev.add(chatId);
90-
return prev;
91-
});
92-
93-
setIsStreaming(true);
94-
};
95-
96-
return (
97-
<div className="flex-1 flex flex-col h-full bg-white">
98-
<div
99-
ref={messageContainerRef}
100-
className="flex-1 overflow-y-auto py-6 px-4 md:px-8 lg:px-12"
101-
>
102-
<div className="w-full max-w-5xl mx-auto space-y-6">
103-
{messages.map((message) => (
104-
<MessageItem
105-
key={message.id}
106-
driven={
107-
message.role === "assistant" && drivenIds.has(message.chatId)
108-
}
109-
scrollToBottom={scrollToBottom}
110-
stopStreaming={() => {
111-
setIsStreaming(false);
112-
focusInput();
113-
}}
114-
message={message}
115-
/>
116-
))}
117-
<div ref={messagesEndRef} />
118-
</div>
119-
</div>
120-
121-
<div className="border-t border-gray-200 py-6 px-4 md:px-8 lg:px-12">
122-
<form onSubmit={handleSubmit} className="w-full max-w-5xl mx-auto">
123-
<div className="flex items-center gap-3">
124-
<input
125-
ref={inputRef}
126-
value={inputValue}
127-
onChange={(e) => setInputValue(e.target.value)}
128-
placeholder="Type your message..."
129-
disabled={isStreaming}
130-
className="flex-1 p-4 border border-gray-300 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none text-base text-black"
131-
/>
132-
<button
133-
type="submit"
134-
disabled={!inputValue.trim() || isStreaming}
135-
className="px-8 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:bg-gray-400 disabled:text-gray-200 font-medium"
136-
>
137-
Send
138-
</button>
139-
<button
140-
type="button"
141-
disabled={messages.length < 2 || isStreaming}
142-
onClick={() => {
143-
clearAllMessages();
144-
setInputValue("");
145-
setIsStreaming(false);
146-
focusInput();
147-
}}
148-
className="px-8 py-4 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:bg-gray-400 disabled:text-gray-200 font-medium"
149-
>
150-
Clear Chat
151-
</button>
152-
</div>
153-
{isStreaming && (
154-
<div className="text-xs text-gray-500 mt-2">
155-
AI is responding...
156-
</div>
157-
)}
158-
</form>
159-
</div>
160-
</div>
161-
);
162-
}
163-
164-
function useWindowSize() {
165-
const [size, setSize] = useState([0, 0]);
166-
useLayoutEffect(() => {
167-
function updateSize() {
168-
setSize([window.innerWidth, window.innerHeight]);
169-
}
170-
window.addEventListener("resize", updateSize);
171-
updateSize();
172-
return () => window.removeEventListener("resize", updateSize);
173-
}, []);
174-
return size;
175-
}
1+
import { useQuery, useMutation } from "convex/react";
2+
import React, { useState, useRef, useCallback, useEffect } from "react";
3+
import { useWindowSize } from "@/lib/utils";
4+
import MessageItem from "./MessageItem";
5+
import { ServerMessage } from "./ServerMessage";
6+
import { api } from "../../convex/_generated/api";
7+
8+
export default function ChatWindow() {
9+
const [drivenIds, setDrivenIds] = useState<Set<string>>(new Set());
10+
const [isStreaming, setIsStreaming] = useState(false);
11+
const messages = useQuery(api.messages.listMessages);
12+
const [inputValue, setInputValue] = useState("");
13+
const messagesEndRef = useRef<HTMLDivElement>(null);
14+
const messageContainerRef = useRef<HTMLDivElement>(null);
15+
const inputRef = useRef<HTMLInputElement>(null);
16+
const clearAllMessages = useMutation(api.messages.clearMessages);
17+
18+
const focusInput = useCallback(() => {
19+
inputRef.current?.focus();
20+
}, []);
21+
22+
const scrollToBottom = useCallback(
23+
(behavior: ScrollBehavior = "smooth") => {
24+
if (messagesEndRef.current) {
25+
messagesEndRef.current.scrollIntoView({ behavior });
26+
}
27+
},
28+
[messagesEndRef]
29+
);
30+
31+
const windowSize = useWindowSize();
32+
33+
useEffect(() => {
34+
scrollToBottom();
35+
}, [windowSize, scrollToBottom]);
36+
37+
const sendMessage = useMutation(api.messages.sendMessage);
38+
39+
if (!messages) return null;
40+
41+
return (
42+
<div className="flex-1 flex flex-col h-full bg-white">
43+
<div
44+
ref={messageContainerRef}
45+
className="flex-1 overflow-y-auto py-6 px-4 md:px-8 lg:px-12"
46+
>
47+
<div className="w-full max-w-5xl mx-auto space-y-6">
48+
{messages.length === 0 && (
49+
<div className="text-center text-gray-500">
50+
No messages yet. Start the conversation!
51+
</div>
52+
)}
53+
{messages.map((message) => (
54+
<React.Fragment key={message._id}>
55+
<MessageItem message={message} isUser={true}>
56+
{message.prompt}
57+
</MessageItem>
58+
<MessageItem message={message} isUser={false}>
59+
<ServerMessage
60+
message={message}
61+
driven={drivenIds.has(message._id)}
62+
stopStreaming={() => {
63+
setIsStreaming(false);
64+
focusInput();
65+
}}
66+
scrollToBottom={scrollToBottom}
67+
/>
68+
</MessageItem>
69+
</React.Fragment>
70+
))}
71+
<div ref={messagesEndRef} />
72+
</div>
73+
</div>
74+
75+
<div className="border-t border-gray-200 py-6 px-4 md:px-8 lg:px-12">
76+
<form
77+
onSubmit={async (e) => {
78+
e.preventDefault();
79+
if (!inputValue.trim()) return;
80+
81+
setInputValue("");
82+
83+
const chatId = await sendMessage({
84+
prompt: inputValue,
85+
});
86+
87+
setDrivenIds((prev) => {
88+
prev.add(chatId);
89+
return prev;
90+
});
91+
92+
setIsStreaming(true);
93+
}}
94+
className="w-full max-w-5xl mx-auto"
95+
>
96+
<div className="flex items-center gap-3">
97+
<input
98+
ref={inputRef}
99+
value={inputValue}
100+
onChange={(e) => setInputValue(e.target.value)}
101+
placeholder="Type your message..."
102+
disabled={isStreaming}
103+
className="flex-1 p-4 border border-gray-300 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none text-base text-black"
104+
/>
105+
<button
106+
type="submit"
107+
disabled={!inputValue.trim() || isStreaming}
108+
className="px-8 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:bg-gray-400 disabled:text-gray-200 font-medium"
109+
>
110+
Send
111+
</button>
112+
<button
113+
type="button"
114+
disabled={messages.length < 2 || isStreaming}
115+
onClick={() => {
116+
clearAllMessages();
117+
setInputValue("");
118+
setIsStreaming(false);
119+
focusInput();
120+
}}
121+
className="px-8 py-4 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:bg-gray-400 disabled:text-gray-200 font-medium"
122+
>
123+
Clear Chat
124+
</button>
125+
</div>
126+
{isStreaming && (
127+
<div className="text-xs text-gray-500 mt-2">
128+
AI is responding...
129+
</div>
130+
)}
131+
</form>
132+
</div>
133+
</div>
134+
);
135+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Doc } from "../../convex/_generated/dataModel";
2+
3+
type Props = {
4+
message: Doc<"userMessages">;
5+
children: React.ReactNode;
6+
isUser: boolean;
7+
};
8+
9+
export default function MessageItem({ message, children, isUser }: Props) {
10+
return (
11+
<>
12+
{isUser && (
13+
<div className="flex items-center gap-4 my-4">
14+
<div className="flex-1 h-px bg-gray-200" />
15+
<div className="text-sm text-gray-500">
16+
{new Date(message._creationTime).toLocaleDateString()}{" "}
17+
{new Date(message._creationTime).toLocaleTimeString()}
18+
</div>
19+
<div className="flex-1 h-px bg-gray-200" />
20+
</div>
21+
)}
22+
23+
<div className={`flex gap-4 ${isUser ? "justify-end" : "justify-start"}`}>
24+
<div
25+
className={`flex gap-4 max-w-[95%] md:max-w-[85%] ${isUser && "flex-row-reverse"}`}
26+
>
27+
<div
28+
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full ${isUser ? "bg-blue-600 text-white" : "bg-gray-300 text-gray-700"} font-medium text-sm`}
29+
>
30+
{isUser ? "U" : "AI"}
31+
</div>
32+
33+
<div
34+
className={`rounded-lg px-5 py-4 text-base ${
35+
isUser
36+
? "bg-blue-600 text-white"
37+
: "bg-gray-100 border border-gray-200 text-gray-900"
38+
}`}
39+
>
40+
{children}
41+
</div>
42+
</div>
43+
</div>
44+
</>
45+
);
46+
}

0 commit comments

Comments
 (0)