A floating chat widget that can query your database in real time is one of those features that looks deceptively simple until you actually build it. This post walks through the full implementation in CitizenApp — what works, what blew up, and the patterns I’d use again.
What It Does
The widget lives in the bottom-right corner of the app. Clicking it opens a 420×560px panel. The user types a natural language question. A single API call goes to the backend, which:
- Asks Claude Haiku to either answer using aggregate statistics, or generate a safe
SELECTquery - If Claude generated SQL, the backend validates it (SELECT only, single table, no PII columns), injects
tenant_id, enforcesLIMIT 100, and executes it - Returns
{ reply, sql_used, rows, row_count, query_error }in one response
The frontend renders the reply text, and — if SQL was used — a collapsible QueryBlock with the SQL and a scrollable result table.
Component Structure
AiChatWidget/
├── AiChatWidget.tsx — floating button + panel shell
├── MessageList.tsx — scrollable message history
├── QueryBlock.tsx — collapsible SQL + table renderer
└── ChatInput.tsx — textarea + send button
I kept these co-located rather than in a shared components/ folder because nothing else needs them. Feature folders beat premature abstraction.
The Message Type
Start with the data model. Each message can be from the user or assistant, and assistant messages may include query results:
type MessageRole = "user" | "assistant";
interface QueryResult {
sql: string;
rows: Record<string, unknown>[] | null;
rowCount: number;
error: string | null;
}
interface ChatMessage {
id: string;
role: MessageRole;
content: string;
queryResult?: QueryResult;
timestamp: Date;
}
The queryResult field is optional — plain text answers don’t have it. This keeps rendering logic clean: check message.queryResult before trying to render the table.
Panel Open/Close with Focus Trap
The widget uses a simple isOpen boolean, but focus management matters for accessibility. When the panel opens, focus should move to the input:
function AiChatWidget() {
const [isOpen, setIsOpen] = useState(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
// Move focus to input when panel opens
useEffect(() => {
if (isOpen) {
// Delay one frame — panel needs to be in the DOM first
requestAnimationFrame(() => inputRef.current?.focus());
}
}, [isOpen]);
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) setIsOpen(false);
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [isOpen]);
return (
<>
{/* Floating trigger button */}
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-6 z-50 w-14 h-14 bg-violet-600 hover:bg-violet-500 text-white rounded-full shadow-xl flex items-center justify-center transition-all"
aria-label="Open AI assistant"
>
<Sparkles className="w-6 h-6" />
</button>
{/* Panel */}
{isOpen && (
<div
ref={panelRef}
className="fixed bottom-24 right-6 z-50 w-[420px] h-[560px] bg-navy-2 border border-ice/20 rounded-2xl shadow-2xl flex flex-col overflow-hidden"
role="dialog"
aria-label="AI assistant"
>
<ChatHeader onClose={() => setIsOpen(false)} />
<MessageList messages={messages} />
<ChatInput ref={inputRef} onSend={handleSend} isLoading={isLoading} />
</div>
)}
</>
);
}
The requestAnimationFrame trick for focus is important. If you call focus() synchronously before the conditional render completes, the element doesn’t exist yet.
Message State and the Send Handler
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
async function handleSend(text: string) {
if (!text.trim() || isLoading) return;
// Optimistically add user message
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: "user",
content: text,
timestamp: new Date(),
};
setMessages(prev => [...prev, userMsg]);
setIsLoading(true);
try {
// Send full conversation history — Claude needs context
const apiMessages = [...messages, userMsg].map(m => ({
role: m.role,
content: m.content,
}));
const result = await aiChat(apiMessages); // axios call, 60s timeout
const assistantMsg: ChatMessage = {
id: crypto.randomUUID(),
role: "assistant",
content: result.reply,
timestamp: new Date(),
queryResult: result.sql_used
? {
sql: result.sql_used,
rows: result.rows,
rowCount: result.row_count ?? 0,
error: result.query_error ?? null,
}
: undefined,
};
setMessages(prev => [...prev, assistantMsg]);
} catch (err) {
// Add error message as assistant turn — don't silently fail
setMessages(prev => [
...prev,
{
id: crypto.randomUUID(),
role: "assistant",
content: "Something went wrong. Please try again.",
timestamp: new Date(),
},
]);
} finally {
setIsLoading(false);
}
}
Two things worth noting:
Send the full history. The API receives messages (the state before the user’s message) plus the new user message. This gives Claude conversation context — “show me more” works because it knows what “more” refers to.
Surface errors as messages. Throwing an error toast and leaving the conversation frozen is worse UX than showing “Something went wrong” inline. The user can retry without re-typing.
Auto-Scroll to Latest Message
function MessageList({ messages }: { messages: ChatMessage[] }) {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
return (
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map(msg => (
<MessageBubble key={msg.id} message={msg} />
))}
{/* Invisible anchor at the bottom */}
<div ref={bottomRef} />
</div>
);
}
scrollIntoView on a zero-height div at the bottom of the list reliably scrolls to the newest message. Using messages as the dependency keeps it synced — every new message triggers a scroll.
The QueryBlock: Collapsible SQL + Results Table
This is the most interesting component. It needs to:
- Show a summary (“7 records from live query”)
- Let the user expand to see the SQL
- Render a scrollable table of results
- Handle query errors gracefully
function QueryBlock({ result }: { result: QueryResult }) {
const [expanded, setExpanded] = useState(false);
if (result.error) {
return (
<div className="mt-2 text-xs text-red-400 font-mono bg-red-400/10 rounded-lg p-3">
Query error: {result.error}
</div>
);
}
const cols = result.rows && result.rows.length > 0
? Object.keys(result.rows[0])
: [];
return (
<div className="mt-2 border border-violet-400/30 rounded-xl overflow-hidden text-xs">
{/* Header — always visible */}
<button
onClick={() => setExpanded(v => !v)}
className="w-full flex items-center justify-between px-3 py-2 bg-violet-500/10 hover:bg-violet-500/20 text-violet-300 transition-colors"
>
<span className="font-mono">
{result.rowCount} row{result.rowCount !== 1 ? "s" : ""} · live query
</span>
<ChevronDown
className={`w-3.5 h-3.5 transition-transform ${expanded ? "rotate-180" : ""}`}
/>
</button>
{expanded && (
<>
{/* SQL block */}
<pre className="px-3 py-2 bg-navy text-ice/70 overflow-x-auto font-mono text-[11px] border-b border-violet-400/20">
{result.sql}
</pre>
{/* Results table */}
{result.rows && result.rows.length > 0 ? (
<div className="overflow-x-auto max-h-48">
<table className="w-full text-[11px]">
<thead>
<tr className="bg-navy/60">
{cols.map(col => (
<th
key={col}
className="px-3 py-1.5 text-left font-mono text-ice/50 whitespace-nowrap border-b border-ice/10"
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{result.rows.map((row, i) => (
<tr
key={i}
className="border-b border-ice/5 hover:bg-ice/5 transition-colors"
>
{cols.map(col => (
<td
key={col}
className="px-3 py-1.5 text-ice/70 whitespace-nowrap"
>
{row[col] == null ? (
<span className="text-ice/30 italic">null</span>
) : (
String(row[col])
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="px-3 py-2 text-ice/40 italic">No results.</div>
)}
</>
)}
</div>
);
}
Three details that matter:
max-h-48 on the table wrapper. Without it, a 100-row result blows the panel height completely. The max-h + overflow-x-auto combination lets users scroll both horizontally and vertically within the block.
Null handling. String(null) gives you "null" — render it italic and muted instead, so users can distinguish actual null values from the string "null".
Derive columns from the first row. Don’t assume the backend always returns a columns field. Object.keys(result.rows[0]) is robust enough for this use case.
The Typing Indicator
A simple pulsing dots component for the loading state:
function TypingIndicator() {
return (
<div className="flex items-center gap-1 px-4 py-3">
{[0, 1, 2].map(i => (
<span
key={i}
className="w-1.5 h-1.5 bg-violet-400 rounded-full animate-bounce"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</div>
);
}
Render it in MessageList when isLoading is true:
{isLoading && <TypingIndicator />}
<div ref={bottomRef} />
Put the indicator above the scroll anchor so the auto-scroll reveals it.
The Textarea: Enter to Send, Shift+Enter for Newline
function ChatInput({ onSend, isLoading }: ChatInputProps, ref: Ref<HTMLTextAreaElement>) {
const [value, setValue] = useState("");
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); // don't add newline
submit();
}
// Shift+Enter → natural newline insertion (default behavior)
}
function submit() {
const trimmed = value.trim();
if (!trimmed || isLoading) return;
onSend(trimmed);
setValue("");
}
return (
<div className="border-t border-ice/10 p-3 flex gap-2 items-end">
<textarea
ref={ref}
value={value}
onChange={e => setValue(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
placeholder="Ask anything about your data..."
className="flex-1 resize-none bg-navy/60 border border-ice/20 rounded-xl px-3 py-2 text-sm text-ice placeholder:text-ice/30 focus:outline-none focus:border-violet-400/50 transition-colors"
style={{ maxHeight: "120px", overflowY: "auto" }}
/>
<button
onClick={submit}
disabled={!value.trim() || isLoading}
className="w-9 h-9 flex-shrink-0 bg-violet-600 hover:bg-violet-500 disabled:opacity-40 text-white rounded-xl flex items-center justify-center transition-all"
>
<Send className="w-4 h-4" />
</button>
</div>
);
}
The style={{ maxHeight: "120px", overflowY: "auto" }} lets the textarea grow naturally up to 4-5 lines, then scrolls. Better UX than a fixed single-line input for multi-sentence questions.
What I’d Do Differently
Virtualize long conversations. Once a session has 50+ messages, the DOM gets heavy. @tanstack/virtual or a simple windowing approach would keep rendering fast for power users.
Persist chat history. Right now the conversation resets on panel close. Storing it in sessionStorage (not localStorage — chat is ephemeral) would let users close and reopen without losing context.
Stream responses. The current implementation blocks for the full API response before displaying anything. For long Claude replies, that’s a poor experience. FastAPI supports StreamingResponse; the frontend would consume it with ReadableStream and update the last message’s content incrementally. Worth the complexity for AI features.
Separate the SQL validation from the AI call. Validation currently happens on the backend only. Adding a lightweight client-side check (starts with SELECT, no forbidden keywords) would let you show an inline warning before the round-trip, saving a credit.
The full implementation is live in CitizenApp and the source is on GitHub. If you’re building something similar, the pattern generalizes: keep the AI call to a single round-trip, structure your message type to carry optional query metadata, and render fallback states explicitly at every level.
Questions about the architecture? Drop me a line.