components/Chat.tsx (237 lines of code) (raw):
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import ToolCall from "./ToolCall";
import Message from "./Message";
import Annotations from "./Annotations";
import type { Item, ChatMessage } from "@/lib/assistant";
import useConversationStore from "@/stores/useConversationStore";
import { SendIcon, PencilIcon } from "lucide-react";
interface ChatProps {
items: Item[];
view: "user" | "agent";
onSendMessage: (message: string) => void;
}
function TypingIndicatorDot({
delay,
color,
}: {
delay: string;
color: string;
}) {
return (
<span
className={`w-1 h-1 rounded-full animate-bounce ${color}`}
style={{ animationDelay: delay }}
/>
);
}
function TypingIndicator({ sender }: { sender: "user" | "agent" }) {
const color = sender === "user" ? "bg-zinc-900" : "bg-white";
return (
<div
className={`flex mb-5 ${
sender === "user" ? "justify-start" : "justify-end"
}`}
>
<div
className={`flex gap-1 items-center rounded-[16px] px-4 py-3 ${
sender === "user"
? "text-zinc-900 bg-[#ECECF1] mr-4 md:mr-24 rounded-bl-[4px]"
: "bg-black text-white ml-4 md:ml-24 rounded-br-[4px]"
}`}
>
<TypingIndicatorDot delay="0s" color={color} />
<TypingIndicatorDot delay="0.2s" color={color} />
<TypingIndicatorDot delay="0.4s" color={color} />
</div>
</div>
);
}
export default function Chat({ items, view, onSendMessage }: ChatProps) {
const itemsEndRef = useRef<HTMLDivElement>(null);
const [inputMessageText, setInputMessageText] = useState("");
const [isComposing, setIsComposing] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const setUserTyping = useConversationStore((s) => s.setUserTyping);
const setAgentTyping = useConversationStore((s) => s.setAgentTyping);
const userTyping = useConversationStore((s) => s.userTyping);
const agentTyping = useConversationStore((s) => s.agentTyping);
const suggestedMessage = useConversationStore(
(s) => s.suggestedMessage
) as ChatMessage | null;
const setSuggestedMessage = useConversationStore(
(s) => s.setSuggestedMessage
);
const suggestedMessageDone = useConversationStore(
(s) => s.suggestedMessageDone
);
const composerText = useConversationStore((s) => s.composerText);
const setComposerText = useConversationStore((s) => s.setComposerText);
useEffect(() => {
itemsEndRef.current?.scrollIntoView({ behavior: "instant" });
}, [items, suggestedMessage]);
useEffect(() => {
const typing =
isComposing ||
(isFocused &&
(view === "agent" ? composerText.length : inputMessageText.length) > 0);
if (view === "user") {
setUserTyping(typing);
} else {
setAgentTyping(typing);
}
}, [
isComposing,
isFocused,
inputMessageText,
composerText,
view,
setUserTyping,
setAgentTyping,
]);
const handleSendMessage = useCallback(() => {
if (view === "agent") {
if (!composerText.trim()) return;
onSendMessage(composerText);
setComposerText("");
} else {
if (!inputMessageText.trim()) return;
onSendMessage(inputMessageText);
setInputMessageText("");
}
}, [view, composerText, inputMessageText, onSendMessage, setComposerText]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && !isComposing) {
e.preventDefault();
handleSendMessage();
}
},
[handleSendMessage, isComposing]
);
const handleSendNow = useCallback(() => {
if (!suggestedMessage) return;
const text = suggestedMessage.content[0]?.text ?? "";
onSendMessage(text);
setSuggestedMessage(null);
setAgentTyping(false);
}, [suggestedMessage, onSendMessage, setSuggestedMessage, setAgentTyping]);
const handleEdit = useCallback(() => {
if (!suggestedMessage) return;
const text = suggestedMessage.content[0]?.text ?? "";
setComposerText(text);
setSuggestedMessage(null);
setAgentTyping(false);
}, [suggestedMessage, setComposerText, setSuggestedMessage, setAgentTyping]);
return (
<div className="flex flex-col h-full max-w-[750px] mx-auto">
{/* Messages */}
<div className="flex-1 overflow-y-auto min-h-0 md:px-4 pt-4 pb-20">
{items.map((item, idx) => (
<React.Fragment key={idx}>
{item.type === "tool_call" && view === "agent" ? (
<ToolCall toolCall={item} />
) : item.type === "message" ? (
<div className="flex flex-col gap-1 mb-5">
<Message message={item} view={view} />
{view === "agent" &&
item.content[0]?.annotations &&
item.content[0]?.annotations?.length > 0 && (
<Annotations annotations={item.content[0].annotations!} />
)}
</div>
) : null}
</React.Fragment>
))}
{/* Suggested message + actions */}
{view === "agent" && suggestedMessage && (
<div className="flex flex-col gap-1 mb-5">
<Message message={suggestedMessage} view={view} suggestion={true} />
{suggestedMessageDone ? (
<div className="flex justify-end text-xs mt-2">
<div className="flex flex-col gap-1">
<div className="mt-2 flex gap-2">
<div
onClick={handleSendNow}
className="cursor-pointer flex items-center gap-1 px-3 py-1 font-medium rounded-md bg-black text-white hover:bg-zinc-800"
>
<SendIcon className="w-3 h-3" />
Send now
</div>
<div
onClick={handleEdit}
className="cursor-pointer flex items-center gap-1 px-3 py-1 font-medium rounded-md bg-black text-white hover:bg-zinc-800"
>
<PencilIcon className="w-3 h-3" />
Edit
</div>
</div>
</div>
</div>
) : null}
</div>
)}
{/* Typing indicators */}
{view === "user" && agentTyping && <TypingIndicator sender="agent" />}
{view === "agent" && userTyping && <TypingIndicator sender="user" />}
<div ref={itemsEndRef} />
</div>
{/* Input */}
<div className="p-2 md:px-4">
<div className="flex items-center">
<div className="flex w-full items-center pb-4 md:pb-1">
<div className="flex w-full flex-col gap-1.5 rounded-2xl p-2.5 pl-1.5 bg-white border border-stone-200 shadow-sm transition-colors">
<div className="flex items-end gap-1.5 md:gap-2 pl-4">
<div className="flex min-w-0 flex-1 flex-col">
<textarea
id="prompt-textarea"
tabIndex={0}
dir="auto"
rows={2}
placeholder="Message..."
className="mb-2 resize-none border-0 focus:outline-none text-sm bg-transparent px-0 pb-6 pt-2"
value={view === "agent" ? composerText : inputMessageText}
onChange={(e) =>
view === "agent"
? setComposerText(e.target.value)
: setInputMessageText(e.target.value)
}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
/>
</div>
<button
disabled={
view === "agent"
? !composerText.trim()
: !inputMessageText.trim()
}
data-testid="send-button"
className="flex h-8 w-8 items-end justify-center rounded-full bg-black text-white hover:opacity-70 disabled:bg-gray-300 disabled:text-gray-400 transition-colors focus:outline-none"
onClick={handleSendMessage}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
fill="none"
viewBox="0 0 32 32"
className="icon-2xl"
>
<path
fill="currentColor"
fillRule="evenodd"
d="M15.192 8.906a1.143 1.143 0 0 1 1.616 0l5.143 5.143a1.143 1.143 0 0 1-1.616 1.616l-3.192-3.192v9.813a1.143 1.143 0 0 1-2.286 0v-9.813l-3.192 3.192a1.143 1.143 0 1 1-1.616-1.616z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}