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/marked

Basic 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>
  );
}

Advanced Features