Chat Component
Complete chat UI component with markdown, tool cards, and auto-scroll.
A complete chat component example that includes Markdown rendering, Tool Cards visualization, and automatic scrolling functionality.
Dependencies
First, install the required dependencies for Markdown rendering:
Install dependencies
bun add marked
bun add -d @types/markedBasic Component Implementation
This component includes the following UI sections and features:
- Header bar — Displays the title and a "New Chat" button that resets the conversation session.
- Message list — Renders user and AI messages as chat bubbles; AI responses support Markdown (bold, lists, code blocks, etc.).
- Tool cards — When the AI invokes a tool, a card appears in real time showing execution status (pending / completed), giving users visibility into background tool calls.
- Loading animation — Shows a three-dot bounce animation while the AI is thinking (only when no tool cards are present).
- Error display — Renders an error message at the bottom of the message list if something goes wrong.
- Input box — Multi-line input with auto-resize (Shift + Enter for new line); press Enter to send. While loading, the send button switches to "Stop" to abort the response.
- Auto-scroll — Automatically scrolls to the bottom whenever a new message or tool card is added.
Component Code
src/components/LensChat.tsx
"use client";
import { useState, useEffect, useRef } from "react";
import { useLensAgent } from "@lens-os/sdk/react";
import type { SSEEvent } from "@lens-os/sdk";
import { marked } from "marked";
// Markdown configuration
marked.setOptions({ gfm: true, breaks: true });
interface ToolCard {
id: string;
name: string;
parameters: Record<string, any>;
result?: any;
status: "pending" | "completed" | "error";
}
interface LensChatProps {
userId: string;
className?: string;
}
export function LensChat({ userId, className = "" }: LensChatProps) {
const [input, setInput] = useState("");
const [toolCards, setToolCards] = useState<ToolCard[]>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const {
messages,
isLoading,
error,
sendMessage,
abort,
newSession,
} = useLensAgent({
endpoint: "/api/lens/agent/chat",
userId,
onEvent: (event: SSEEvent) => {
if (event.type === "tool_call") {
setToolCards((prev) => [
...prev,
{
id: `tool-${Date.now()}`,
name: event.name!,
parameters: event.parameters,
status: "pending",
},
]);
}
if (event.type === "tool_result") {
setToolCards((prev) =>
prev.map((card) =>
card.name === event.name && card.status === "pending"
? { ...card, result: event.result, status: "completed" }
: card
)
);
}
},
});
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, toolCards]);
// Auto-adjust textarea height
const adjustHeight = () => {
const textarea = inputRef.current;
if (textarea) {
textarea.style.height = "auto";
textarea.style.height = Math.min(textarea.scrollHeight, 120) + "px";
}
};
const handleSend = async () => {
if (!input.trim() || isLoading) return;
const message = input;
setInput("");
setToolCards([]);
if (inputRef.current) {
inputRef.current.style.height = "auto";
}
await sendMessage(message);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
handleSend();
}
};
const parseMarkdown = (text: string) => {
return marked.parse(text) as string;
};
return (
<div className={`flex flex-col h-full bg-white rounded-lg shadow ${className}`}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b">
<span className="font-semibold">AI Assistant</span>
<button
onClick={newSession}
className="text-sm text-gray-500 hover:text-gray-700"
>
New Chat
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-auto p-4 space-y-4">
{messages.length === 0 && (
<div className="text-center text-gray-400 py-8">
Start a conversation!
</div>
)}
{messages.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[80%] p-3 rounded-lg ${
msg.role === "user"
? "bg-blue-500 text-white"
: "bg-gray-100 text-gray-800"
}`}
>
{msg.role === "assistant" ? (
<div
className="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{
__html: parseMarkdown(
typeof msg.content === "string"
? msg.content
: JSON.stringify(msg.content)
),
}}
/>
) : (
<span>
{typeof msg.content === "string"
? msg.content
: JSON.stringify(msg.content)}
</span>
)}
</div>
</div>
))}
{/* Tool Cards */}
{toolCards.map((tool) => (
<div
key={tool.id}
className={`flex items-center gap-2 p-2 rounded text-sm ${
tool.status === "pending"
? "bg-yellow-50 text-yellow-700"
: "bg-green-50 text-green-700"
}`}
>
{tool.status === "pending" ? (
<span className="animate-pulse">●</span>
) : (
<span>✓</span>
)}
<span>{tool.name}</span>
</div>
))}
{/* Loading */}
{isLoading && toolCards.length === 0 && (
<div className="flex justify-start">
<div className="bg-gray-100 rounded-lg p-3">
<div className="flex gap-1">
<span className="animate-bounce">●</span>
<span className="animate-bounce delay-100">●</span>
<span className="animate-bounce delay-200">●</span>
</div>
</div>
</div>
)}
{/* Error */}
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded">
Error: {error.message}
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="border-t p-4">
<div className="flex gap-2">
<textarea
ref={inputRef}
value={input}
onChange={(e) => {
setInput(e.target.value);
adjustHeight();
}}
onKeyDown={handleKeyDown}
placeholder="Type a message... (Shift+Enter for new line)"
rows={1}
className="flex-1 resize-none border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={isLoading ? abort : handleSend}
disabled={!isLoading && !input.trim()}
className={`px-4 py-2 rounded-lg font-medium transition ${
isLoading
? "bg-red-500 hover:bg-red-600 text-white"
: input.trim()
? "bg-blue-500 hover:bg-blue-600 text-white"
: "bg-gray-200 text-gray-400 cursor-not-allowed"
}`}
>
{isLoading ? "Stop" : "Send"}
</button>
</div>
</div>
</div>
);
}