Spaces:
Running
Running
import React, { | |
useCallback, | |
useEffect, | |
useMemo, | |
useRef, | |
useState, | |
} from "react"; | |
import { BookmarkIcon } from "@heroicons/react/24/outline"; | |
import { TrashIcon } from "@heroicons/react/24/outline"; | |
import ReactMarkdown from "react-markdown"; | |
import remarkGfm from "remark-gfm"; | |
import { SparklesIcon } from "@heroicons/react/24/outline"; | |
// const API_PATH = "/chat"; | |
// const THREAD_KEY = "lg_thread_id"; | |
import { useChat } from "./useChat"; | |
import type { ThreadMeta } from "./threads"; | |
const SUGGESTIONS = [ | |
{ | |
title: "Quick intro to Krishna", | |
text: "Give me a 90-second intro to Krishna Vamsi Dhulipalla—recent work, top strengths, and impact.", | |
}, | |
{ | |
title: "Get Krishna’s resume", | |
text: "Share Krishna’s latest resume and provide a download link.", | |
}, | |
{ | |
title: "What this agent can do", | |
text: "What tools and actions can you perform for me? Show examples and how to use them.", | |
}, | |
{ | |
title: "Schedule/modify a meeting", | |
text: "Schedule a 30-minute meeting with Krishna next week and show how I can reschedule or cancel.", | |
}, | |
]; | |
// --- Helpers for message actions --- | |
const copyToClipboard = async (text: string) => { | |
try { | |
await navigator.clipboard.writeText(text); | |
} catch { | |
console.error("Failed to copy text to clipboard"); | |
} | |
}; | |
const getLastUserMessage = (msgs: { role: string; content: string }[]) => | |
[...msgs].reverse().find((m) => m.role === "user") || null; | |
export default function App() { | |
const { | |
threads, | |
active, | |
messages, | |
setActiveThread, | |
newChat, | |
clearChat, | |
deleteThread, | |
send, | |
isStreaming, | |
hasFirstToken, | |
} = useChat(); | |
const [input, setInput] = useState(""); | |
const bottomRef = useRef<HTMLDivElement | null>(null); | |
const inputRef = useRef<HTMLTextAreaElement | null>(null); | |
const prevThreadId = useRef<string | null>(null); | |
// Scroll on message changes | |
useEffect(() => { | |
const currentThreadId = active?.id ?? null; | |
// If the thread changed, scroll instantly to bottom | |
if (currentThreadId !== prevThreadId.current) { | |
prevThreadId.current = currentThreadId; | |
bottomRef.current?.scrollIntoView({ behavior: "auto" }); // instant scroll | |
} else { | |
// If same thread but messages changed, smooth scroll | |
bottomRef.current?.scrollIntoView({ behavior: "smooth" }); | |
} | |
}, [messages, active?.id]); | |
const handleShare = async () => { | |
const url = window.location.href; | |
const title = document.title || "My Chat"; | |
try { | |
if (navigator.share) { | |
await navigator.share({ title, url }); | |
} else { | |
await navigator.clipboard.writeText(url); | |
// optionally toast: "Link copied" | |
} | |
} catch { | |
// ignored | |
} | |
}; | |
const handleBookmark = () => { | |
// Browsers don't allow programmatic bookmarks; show the right shortcut. | |
const isMac = navigator.platform.toUpperCase().includes("MAC"); | |
const combo = isMac ? "⌘ + D" : "Ctrl + D"; | |
alert(`Press ${combo} to bookmark this page.`); | |
}; | |
const sendMessage = useCallback(() => { | |
const text = input.trim(); | |
if (!text || isStreaming) return; | |
send(text); | |
setInput(""); | |
}, [input, isStreaming, send]); | |
const selectSuggestion = useCallback((text: string) => { | |
setInput(text); | |
requestAnimationFrame(() => inputRef.current?.focus()); | |
}, []); | |
const sendSuggestion = useCallback( | |
(text: string) => { | |
if (isStreaming) return; | |
setInput(text); | |
setTimeout(() => sendMessage(), 0); | |
}, | |
[isStreaming, sendMessage] | |
); | |
const onKeyDown = useCallback( | |
(e: React.KeyboardEvent<HTMLTextAreaElement>) => { | |
if (e.key === "Enter" && !e.shiftKey) { | |
e.preventDefault(); | |
sendMessage(); | |
} | |
}, | |
[sendMessage] | |
); | |
const REGEN_PREFIX = "Regenerate this response with a different angle:\n\n"; | |
const sendPrefixed = useCallback( | |
(prefix: string, text: string) => { | |
if (!text || isStreaming) return; | |
// your hook’s `send` already appends messages & streams | |
send(`${prefix}${text}`); | |
setInput(""); | |
}, | |
[send, isStreaming] | |
); | |
// Sidebar | |
const Sidebar = useMemo( | |
() => ( | |
<aside className="hidden md:flex w-64 shrink-0 flex-col bg-zinc-950/80 border-r border-zinc-800/60"> | |
<div className="p-4 border-b border-zinc-800/60"> | |
<h1 className="flex items-center text-zinc-100 font-semibold tracking-tight"> | |
<SparklesIcon className="h-4 w-4 text-zinc-300 mr-2" /> | |
ChatK | |
</h1> | |
<p className="text-xs text-zinc-400 mt-1"> | |
Chatbot ID:{" "} | |
<span className="font-mono"> | |
{active?.id ? active.id.slice(0, 8) : "…"}{" "} | |
</span> | |
</p> | |
</div> | |
<div className="p-3 space-y-2"> | |
<button | |
className="w-full rounded-xl bg-emerald-600 text-white hover:bg-emerald-500 px-3 py-2 text-sm" | |
onClick={newChat} | |
title="Start a new session" | |
> | |
New Chat | |
</button> | |
<button | |
className="w-full rounded-xl bg-zinc-800 text-zinc-200 hover:bg-zinc-700 px-3 py-2 text-sm" | |
onClick={clearChat} | |
title="Clear current messages" | |
> | |
Clear Chat | |
</button> | |
{/* View Source on GitHub */} | |
<a | |
href="https://github.com/krishna-dhulipalla/LangGraph_ChatBot" | |
target="_blank" | |
rel="noopener noreferrer" | |
className="w-full flex items-center justify-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900 hover:bg-zinc-800 px-3 py-2 text-sm text-zinc-300" | |
title="View the source code on GitHub" | |
> | |
{/* GitHub Icon */} | |
<svg | |
xmlns="http://www.w3.org/2000/svg" | |
viewBox="0 0 24 24" | |
fill="currentColor" | |
className="h-4 w-4" | |
> | |
<path | |
fillRule="evenodd" | |
d="M12 0C5.37 0 0 5.37 0 | |
12c0 5.3 3.438 9.8 8.205 | |
11.387.6.113.82-.262.82-.58 | |
0-.287-.01-1.045-.015-2.05-3.338.724-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.754-1.333-1.754-1.09-.745.083-.73.083-.73 | |
1.205.085 1.84 1.238 1.84 1.238 | |
1.07 1.835 2.807 1.305 3.492.997.107-.775.418-1.305.762-1.605-2.665-.3-5.466-1.334-5.466-5.93 | |
0-1.31.468-2.38 1.235-3.22-.124-.303-.536-1.523.117-3.176 | |
0 0 1.008-.322 3.3 1.23a11.5 11.5 | |
0 013.003-.404c1.018.005 2.045.138 | |
3.003.404 2.29-1.552 3.297-1.23 | |
3.297-1.23.655 1.653.243 2.873.12 | |
3.176.77.84 1.233 1.91 1.233 3.22 | |
0 4.61-2.803 5.625-5.475 5.92.43.372.823 | |
1.102.823 2.222 0 1.606-.015 2.898-.015 3.293 | |
0 .32.218.698.825.58C20.565 21.796 24 | |
17.297 24 12c0-6.63-5.37-12-12-12z" | |
clipRule="evenodd" | |
/> | |
</svg> | |
View Source | |
</a> | |
</div> | |
{/* Thread list */} | |
<div className="px-3 pb-3 space-y-1 overflow-y-auto"> | |
{threads.map((t: ThreadMeta) => ( | |
<div | |
key={t.id} | |
className={`group w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-zinc-800 cursor-pointer ${ | |
t.id === active?.id ? "bg-zinc-800" : "" | |
}`} | |
onClick={() => setActiveThread(t)} | |
title={t.id} | |
> | |
<div className="flex-1 min-w-0 p-1 hover:bg-gray-100"> | |
<div className="text-sm"> | |
{t.title && t.title.length > 20 | |
? t.title.slice(0, 20) + "..." | |
: t.title || "Untitled"} | |
</div> | |
<div | |
className="text-zinc-500 truncate" | |
style={{ fontSize: "10px", fontStyle: "italic" }} | |
> | |
{new Date(t.lastAt).toLocaleString()} | |
</div> | |
</div> | |
{/* Delete button (shows on hover) */} | |
<button | |
type="button" | |
className="opacity-0 group-hover:opacity-100 shrink-0 rounded-md p-1 border border-zinc-700/60 bg-zinc-900/60 hover:bg-zinc-800/80" | |
title="Delete thread" | |
aria-label="Delete thread" | |
onClick={(e) => { | |
e.stopPropagation(); // don't switch threads | |
if ( | |
window.confirm("Delete this thread? This cannot be undone.") | |
) { | |
deleteThread(t.id); | |
} | |
}} | |
> | |
<TrashIcon className="h-4 w-4 text-zinc-300" /> | |
</button> | |
</div> | |
))} | |
</div> | |
<div className="mt-auto p-3 text-xs text-zinc-500"> | |
Tip: Press <kbd className="px-1 bg-zinc-800 rounded">Enter</kbd> to | |
send, | |
<span className="mx-1" />{" "} | |
<kbd className="px-1 bg-zinc-800 rounded">Shift+Enter</kbd> for | |
newline. | |
</div> | |
</aside> | |
), | |
[active?.id, clearChat, newChat, setActiveThread, deleteThread, threads] | |
); | |
return ( | |
<div className="h-screen w-screen bg-[#0b0b0f] text-zinc-100 flex"> | |
{Sidebar} | |
{/* Main column */} | |
<main className="flex-1 flex flex-col"> | |
{/* Header minimal */} | |
<div className="h-12 shrink-0 flex items-center justify-between px-3 md:px-6 border-b border-zinc-800/60 bg-zinc-950/60 backdrop-blur"> | |
<div className="flex items-center gap-2"> | |
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500 animate-pulse" /> | |
<span className="font-medium">Krishna’s Assistant</span> | |
</div> | |
<div className="text-xs text-zinc-400 md:hidden"> | |
ID: {active?.id ? active.id.slice(0, 8) : "…"} | |
</div> | |
<div className="ml-auto flex items-center gap-2"> | |
{/* LinkedIn */} | |
<a | |
href="https://www.linkedin.com/in/krishnavamsidhulipalla/" | |
target="_blank" | |
rel="noopener noreferrer" | |
className="rounded-xl px-3 py-1.5 text-sm border border-zinc-800 bg-zinc-900 hover:bg-zinc-800" | |
title="LinkedIn" | |
> | |
<svg | |
xmlns="http://www.w3.org/2000/svg" | |
viewBox="0 0 24 24" | |
fill="currentColor" | |
className="h-5 w-5 text-zinc-400 hover:text-zinc-200" | |
> | |
<path | |
d="M19 0h-14c-2.76 0-5 2.24-5 5v14c0 | |
2.76 2.24 5 5 5h14c2.76 0 5-2.24 | |
5-5v-14c0-2.76-2.24-5-5-5zm-11 | |
19h-3v-10h3v10zm-1.5-11.27c-.97 | |
0-1.75-.79-1.75-1.76s.78-1.76 | |
1.75-1.76 1.75.79 | |
1.75 1.76-.78 1.76-1.75 | |
1.76zm13.5 11.27h-3v-5.5c0-1.31-.02-3-1.83-3-1.83 | |
0-2.12 1.43-2.12 2.9v5.6h-3v-10h2.88v1.36h.04c.4-.75 | |
1.38-1.54 2.85-1.54 3.05 0 3.61 | |
2.01 3.61 4.63v5.55z" | |
/> | |
</svg> | |
</a> | |
{/* GitHub */} | |
<a | |
href="https://github.com/krishna-dhulipalla" | |
target="_blank" | |
rel="noopener noreferrer" | |
className="rounded-xl px-3 py-1.5 text-sm border border-zinc-800 bg-zinc-900 hover:bg-zinc-800" | |
title="GitHub" | |
> | |
<svg | |
xmlns="http://www.w3.org/2000/svg" | |
viewBox="0 0 24 24" | |
fill="currentColor" | |
className="h-5 w-5 text-zinc-400 hover:text-zinc-200" | |
> | |
<path | |
fillRule="evenodd" | |
d="M12 0C5.37 0 0 5.37 0 | |
12c0 5.3 3.438 9.8 8.205 | |
11.387.6.113.82-.262.82-.58 | |
0-.287-.01-1.045-.015-2.05-3.338.724-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.754-1.333-1.754-1.09-.745.083-.73.083-.73 | |
1.205.085 1.84 1.238 1.84 1.238 | |
1.07 1.835 2.807 1.305 3.492.997.107-.775.418-1.305.762-1.605-2.665-.3-5.466-1.334-5.466-5.93 | |
0-1.31.468-2.38 1.235-3.22-.124-.303-.536-1.523.117-3.176 | |
0 0 1.008-.322 3.3 1.23a11.5 11.5 | |
0 013.003-.404c1.018.005 2.045.138 | |
3.003.404 2.29-1.552 3.297-1.23 | |
3.297-1.23.655 1.653.243 2.873.12 | |
3.176.77.84 1.233 1.91 1.233 3.22 | |
0 4.61-2.803 5.625-5.475 5.92.43.372.823 | |
1.102.823 2.222 0 1.606-.015 2.898-.015 3.293 | |
0 .32.218.698.825.58C20.565 21.796 24 | |
17.297 24 12c0-6.63-5.37-12-12-12z" | |
clipRule="evenodd" | |
/> | |
</svg> | |
</a> | |
<button | |
onClick={handleShare} | |
className="rounded-xl px-3 py-1.5 text-sm border border-zinc-800 bg-zinc-900 hover:bg-zinc-800" | |
title="Share" | |
> | |
Share | |
</button> | |
<button | |
onClick={handleBookmark} | |
className="rounded-xl px-3 py-1.5 text-sm border border-zinc-800 bg-zinc-900 hover:bg-zinc-800" | |
title="Bookmark" | |
> | |
<BookmarkIcon className="h-5 w-5 text-zinc-400 hover:text-zinc-200" /> | |
</button> | |
</div> | |
</div> | |
{/* Messages */} | |
<div className="flex-1 overflow-y-auto px-3 md:px-6 py-4"> | |
{messages.length === 0 ? ( | |
<EmptyState onSelect={selectSuggestion} onSend={sendSuggestion} /> | |
) : ( | |
<div className="mx-auto max-w-3xl space-y-3 relative"> | |
{messages.map((m, idx) => { | |
const isAssistant = m.role === "assistant"; | |
const emptyAssistant = | |
isAssistant && (!m.content || m.content.trim() === ""); | |
if (emptyAssistant) return null; // hide blank bubble | |
const key = m.id ?? `m-${idx}`; // NEW stable key | |
return ( | |
<div key={key} className={isAssistant ? "group" : undefined}> | |
{/* bubble row */} | |
<div | |
className={`flex ${ | |
isAssistant ? "justify-start" : "justify-end" | |
}`} | |
> | |
<div | |
className={`max-w-[85%] md:max-w-[75%] leading-relaxed tracking-tight rounded-2xl px-4 py-3 shadow-sm ${ | |
isAssistant | |
? "bg-zinc-900/80 text-zinc-100 border border-zinc-800/60" | |
: "bg-emerald-600/90 text-white" | |
}`} | |
> | |
{isAssistant ? ( | |
<> | |
<ReactMarkdown | |
remarkPlugins={[remarkGfm]} | |
components={{ | |
a: (props) => ( | |
<a | |
{...props} | |
target="_blank" | |
rel="noreferrer" | |
className="underline text-blue-400 hover:text-blue-600" | |
/> | |
), | |
p: (props) => ( | |
<p className="mb-2 last:mb-0" {...props} /> | |
), | |
ul: (props) => ( | |
<ul | |
className="list-disc list-inside mb-2 last:mb-0" | |
{...props} | |
/> | |
), | |
ol: (props) => ( | |
<ol | |
className="list-decimal list-inside mb-2 last:mb-0" | |
{...props} | |
/> | |
), | |
li: (props) => ( | |
<li className="ml-4 mb-1" {...props} /> | |
), | |
code: ( | |
props: React.HTMLAttributes<HTMLElement> & { | |
inline?: boolean; | |
} | |
) => { | |
// react-markdown v8+ passes 'node', 'inline', etc. in props, but types may not include 'inline' | |
const { | |
className, | |
children, | |
inline, | |
...rest | |
} = props; | |
const isInline = inline; | |
return isInline ? ( | |
<code | |
className="bg-zinc-800/80 px-1 py-0.5 rounded" | |
{...rest} | |
> | |
{children} | |
</code> | |
) : ( | |
<pre className="overflow-x-auto rounded-xl border border-zinc-800/60 bg-zinc-950/80 p-3 mb-2"> | |
<code className={className} {...rest}> | |
{children} | |
</code> | |
</pre> | |
); | |
}, | |
}} | |
> | |
{m.content} | |
</ReactMarkdown> | |
</> | |
) : ( | |
m.content | |
)} | |
</div> | |
</div> | |
{/* actions row – only for assistant & only when not streaming */} | |
{isAssistant && !isStreaming && ( | |
<div className="mt-1 pl-1 flex justify-start"> | |
<MsgActions | |
content={m.content} | |
onEdit={() => { | |
// Prefill composer with the *last user* prompt (ChatGPT-style “Edit”) | |
const lastUser = getLastUserMessage(messages); | |
setInput(lastUser ? lastUser.content : m.content); | |
requestAnimationFrame(() => | |
inputRef.current?.focus() | |
); | |
}} | |
onRegenerate={() => { | |
// Resend last user prompt | |
const lastUser = getLastUserMessage(messages); | |
if (!lastUser) return; | |
sendPrefixed(REGEN_PREFIX, lastUser.content); | |
}} | |
/> | |
</div> | |
)} | |
</div> | |
); | |
})} | |
{/* Thinking indicator (only BEFORE first token) */} | |
{isStreaming && !hasFirstToken && ( | |
<div className="pointer-events-none relative top-3 left-0 bottom-0 translate-y-2 z-20"> | |
<TypingDots /> | |
</div> | |
)} | |
<div ref={bottomRef} /> | |
</div> | |
)} | |
</div> | |
{/* Warm bottom glow (simple bar + blur) | |
<div | |
aria-hidden | |
className="fixed inset-x-0 bottom-0 z-30 pointer-events-none" | |
> | |
<div className="mx-auto max-w-3xl relative h-0"> | |
<div className="absolute left-6 right-6 bottom-0 h-[6px] rounded-full bg-amber-300/40 blur-3xl" /> | |
</div> | |
</div> */} | |
{/* Composer */} | |
<div className="shrink-0 border-t border-zinc-800/60 bg-zinc-950/80"> | |
<div className="mx-auto max-w-3xl p-3 md:p-4"> | |
<div className="flex gap-2 items-end"> | |
<div className="relative flex-1"> | |
<textarea | |
ref={inputRef} | |
className="w-full flex-1 resize-none rounded-2xl bg-zinc-900 text-zinc-100 placeholder-zinc-500 p-3 pr-8 outline-none focus:ring-2 focus:ring-emerald-500/60 min-h-[56px] max-h-48 border border-zinc-800/60" | |
placeholder="Type a message…" | |
value={input} | |
onChange={(e) => setInput(e.target.value)} | |
onKeyDown={onKeyDown} | |
disabled={isStreaming} | |
/> | |
{input && ( | |
<button | |
onClick={() => { | |
setInput(""); | |
requestAnimationFrame(() => inputRef.current?.focus()); | |
}} | |
className="absolute right-2 top-2.5 h-6 w-6 rounded-md border border-zinc-800 bg-zinc-950/70 hover:bg-zinc-800/70 text-zinc-400" | |
title="Clear" | |
aria-label="Clear input" | |
> | |
× | |
</button> | |
)} | |
</div> | |
<button | |
className="rounded-2xl px-4 py-3 bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed" | |
onClick={sendMessage} | |
disabled={!input.trim() || isStreaming} | |
> | |
Send | |
</button> | |
</div> | |
</div> | |
</div> | |
</main> | |
</div> | |
); | |
} | |
function MsgActions({ | |
content, | |
onEdit, | |
onRegenerate, | |
}: { | |
content: string; | |
onEdit: () => void; | |
onRegenerate: () => void; | |
}) { | |
return ( | |
<div className="mt-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> | |
<button | |
onClick={() => copyToClipboard(content)} | |
className="text-xs px-2 py-1 rounded border border-zinc-800 bg-zinc-900 hover:bg-zinc-800" | |
title="Copy" | |
> | |
Copy | |
</button> | |
<button | |
onClick={onEdit} | |
className="text-xs px-2 py-1 rounded border border-zinc-800 bg-zinc-900 hover:bg-zinc-800" | |
title="Edit this as new prompt" | |
> | |
Edit | |
</button> | |
<button | |
onClick={onRegenerate} | |
className="text-xs px-2 py-1 rounded border border-zinc-800 bg-zinc-900 hover:bg-zinc-800" | |
title="Regenerate" | |
> | |
Regenerate | |
</button> | |
</div> | |
); | |
} | |
function EmptyState({ | |
onSelect, | |
onSend, | |
}: { | |
onSelect: (text: string) => void; | |
onSend: (text: string) => void; | |
}) { | |
return ( | |
<div className="h-full flex items-center justify-center"> | |
<div className="mx-auto max-w-3xl w-full px-3 md:px-6"> | |
<div className="text-center text-zinc-400 mb-6"> | |
<h2 className="text-xl text-zinc-200 mb-2">Ask me anything</h2> | |
<p className="text-sm"> | |
The agent can call tools and remember the conversation (per chatbot | |
id). | |
</p> | |
</div> | |
{/* Starter prompts */} | |
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> | |
{SUGGESTIONS.map((s) => ( | |
<div | |
role="button" | |
tabIndex={0} | |
onClick={() => onSelect(s.text)} | |
className="group text-left rounded-2xl border border-zinc-800/60 bg-zinc-900/60 hover:bg-zinc-900/90 transition-colors p-4 shadow-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/60" | |
title="Click to prefill. Use the arrow to send." | |
> | |
<div className="flex items-start gap-3"> | |
<div className="shrink-0 h-8 w-8 rounded-xl bg-zinc-800/80 flex items-center justify-center"> | |
<span className="text-sm text-zinc-300">💡</span> | |
</div> | |
<div className="flex-1"> | |
<div className="text-sm font-medium text-zinc-200"> | |
{s.title} | |
</div> | |
<div className="text-xs text-zinc-400 mt-1 line-clamp-2"> | |
{s.text} | |
</div> | |
</div> | |
<button | |
type="button" | |
aria-label="Send this suggestion" | |
onClick={(e) => { | |
e.stopPropagation(); | |
onSend(s.text); | |
}} | |
className="shrink-0 rounded-xl border border-zinc-700/60 bg-zinc-950/60 px-2 py-2 hover:bg-zinc-900/80" | |
title="Send now" | |
> | |
{/* Arrow icon */} | |
<svg | |
viewBox="0 0 24 24" | |
className="h-4 w-4 text-zinc-300 group-hover:text-emerald-400" | |
fill="none" | |
stroke="currentColor" | |
strokeWidth={2} | |
strokeLinecap="round" | |
strokeLinejoin="round" | |
> | |
<path d="M7 17L17 7M7 7h10v10" /> | |
</svg> | |
</button> | |
</div> | |
</div> | |
))} | |
</div> | |
</div> | |
</div> | |
); | |
} | |
function TypingDots() { | |
return ( | |
<span className="inline-flex items-center gap-1 align-middle"> | |
<span className="sr-only">Assistant is typing…</span> | |
<span className="h-1.5 w-1.5 rounded-full bg-zinc-300 animate-bounce [animation-delay:-0.2s]" /> | |
<span className="h-1.5 w-1.5 rounded-full bg-zinc-300 animate-bounce" /> | |
<span className="h-1.5 w-1.5 rounded-full bg-zinc-300 animate-bounce [animation-delay:0.2s]" /> | |
</span> | |
); | |
} | |