"use client"; /** * TASK-0122 — Ask AI panel. * * A right-side drawer (desktop) / bottom sheet (mobile < 768 px) mounted at * the workspace shell, so every entry point (page header, block menu, * database header, search page, command palette) can open it with a * resource context. It is a thin view over `useAiStream` (the streaming * conversation hook) + `aiApi`. * * a11y (mirrors search-dialog.tsx): role="dialog" aria-modal, a focus trap * (Tab wraps inside), Escape closes, focus is restored to the trigger on * close, and a live region announces the streaming state to screen readers. * * Pinned e2e contract (tests/e2e/specs/ai.spec.ts — do not rename): * ai-panel the drawer (role=dialog) * ai-history-item a conversation row in the history list * ai-history-new the "New chat" button * ai-message-user a user bubble * ai-message-assistant an assistant bubble * ai-message-live the in-flight assistant bubble (streaming) * ai-tool-call a tool-call card * ai-error-banner the error / budget banner * ai-retry the banner's Retry button * ai-input the composer input * ai-send the send button * ai-stop the stop button (visible while streaming) * ai-close the close button */ import { useCallback, useEffect, useRef, useState } from "react"; import { AlertTriangle, Loader2, MessageSquarePlus, Pencil, RotateCcw, Sparkles, Square, Trash2, X } from "lucide-react"; import { useWorkspace } from "@/hooks/use-workspace"; import { useAiStream, type AiBannerError } from "@/hooks/use-ai-stream"; import { useUiStore } from "@/stores/ui-store"; import { formatRelativeTime } from "@/lib/utils"; import type { AiMessage } from "@/lib/ai"; /* --------------------------------------------------------------- helpers */ /** Local (file-private) mobile breakpoint — mirrors form-responses-view. */ function useIsMobile(): boolean { const [match, setMatch] = useState(false); useEffect(() => { const mq = window.matchMedia("(max-width: 767px)"); setMatch(mq.matches); const onChange = (e: MediaQueryListEvent) => setMatch(e.matches); mq.addEventListener("change", onChange); return () => mq.removeEventListener("change", onChange); }, []); return match; } const budgetBanner = (b: AiBannerError): boolean => b.code === "AI_BUDGET_EXCEEDED" || b.code === "AI_BUDGET_MONTHLY_EXCEEDED"; /* -------------------------------------------------------------- component */ export function AiPanel() { const open = useUiStore((s) => s.aiPanelOpen); const context = useUiStore((s) => s.aiPanelContext); const setOpen = useUiStore((s) => s.setAiPanel); const slug = useUiStore((s) => s.searchWorkspaceSlug); const { data: workspace } = useWorkspace(slug); const isMobile = useIsMobile(); const ai = useAiStream(workspace?.id ?? null, context); const [draft, setDraft] = useState(""); const [historyOpen, setHistoryOpen] = useState(true); /** Inline rename (the row id being edited) + its draft text. */ const [renameId, setRenameId] = useState(null); const [renameDraft, setRenameDraft] = useState(""); /** Delete confirm (spec: "delete with confirm dialog"). */ const [deleteId, setDeleteId] = useState(null); const panelRef = useRef(null); const inputRef = useRef(null); const scrollRef = useRef(null); const triggerRef = useRef(null); /* ------------------------------------------------------------ reset */ useEffect(() => { if (open) { setDraft(""); requestAnimationFrame(() => inputRef.current?.focus()); } }, [open]); /* ------------------------------------------------------ focus + keys */ const close = useCallback(() => { ai.stop(); setOpen(false); requestAnimationFrame(() => { triggerRef.current?.focus(); triggerRef.current = null; }); }, [ai, setOpen]); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); if (deleteId) { setDeleteId(null); // the confirm dialog takes the first Escape } else { close(); } } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open, close, deleteId]); // Focus trap (Tab wraps inside the panel). useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key !== "Tab") return; const panel = panelRef.current; if (!panel) return; const focusables = panel.querySelectorAll('button, textarea, input, [tabindex]:not([tabindex="-1"])'); if (focusables.length === 0) return; const first = focusables[0]; const last = focusables[focusables.length - 1]; if (!first || !last) return; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open]); // Record the last focused element (the trigger) so close() can restore it. useEffect(() => { if (!open) return; const prev = document.activeElement as HTMLElement | null; triggerRef.current = prev; }, [open]); // Auto-scroll to the newest message / live bubble. useEffect(() => { const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; }, [ai.messages.length, ai.live?.content, ai.pendingUser, open]); /* ----------------------------------------------------------- actions */ const doSend = useCallback(() => { const text = draft.trim(); if (!text || ai.streaming) return; setDraft(""); void ai.send(text); }, [draft, ai]); if (!open) return null; const noWorkspace = !workspace?.id; const renderErrorBanner = () => { if (!ai.banner) return null; const b = ai.banner; const showRetry = b.retryable && !ai.streaming; return (

{budgetBanner(b) ? "Monthly AI budget reached" : b.code === "RATE_LIMITED" ? "Sending too fast" : "Something went wrong"}

{b.message}

{budgetBanner(b) ? (

Your workspace has hit its monthly token limit. Ask an admin to raise it in Settings → AI.

) : null}
{showRetry ? ( ) : null}
); }; const renderMessage = (m: AiMessage, live = false) => { const isUser = m.role === "user"; const id = live ? "ai-message-live" : isUser ? "ai-message-user" : "ai-message-assistant"; return (
{m.content} {live ? : null}
); }; const showComposer = !noWorkspace; return (
{/* Header */}

Ask AI

{/* Context badge: the bound resource title (page/database). */} {context?.label ? ( {context.label} ) : null}
{/* History (collapsed list at the top) */}
{historyOpen ? (
{ai.conversations.length === 0 ? (

No conversations yet.

) : (
    {ai.conversations.map((c) => (
  • {renameId === c.id ? ( setRenameDraft(e.target.value)} onBlur={() => { void ai.renameConversation(c.id, renameDraft); setRenameId(null); }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void ai.renameConversation(c.id, renameDraft); setRenameId(null); } else if (e.key === "Escape") { e.stopPropagation(); setRenameId(null); } }} aria-label="Rename conversation" className="min-w-0 flex-1 rounded-md border border-primary bg-background px-2 py-1 text-xs outline-none" /> ) : ( )}
  • ))}
)}
) : null}
{/* Messages */}
{noWorkspace ? (

Open a workspace to ask questions.

) : ai.messagesLoading ? (

Loading…

) : ai.messages.length === 0 && !ai.live && !ai.pendingUser ? (

Ask anything about this workspace.

AI answers cite the pages and databases you link.

) : ( <> {ai.messages.map((m) => renderMessage(m))} {ai.pendingUser ? renderMessage({ id: ai.pendingUser.id, role: "user", content: ai.pendingUser.content, createdAt: "" }) : null} {ai.live && ai.live.content !== "" ? ( renderMessage( { id: "live", role: "assistant", content: ai.live.content, createdAt: "" }, true ) ) : ai.live && ai.live.content === "" && ai.streaming ? (
Thinking…
) : null} {ai.live && ai.live.toolCalls.length > 0 ? (
{ai.live.toolCalls.map((t) => (
{t.name} {JSON.stringify(t.arguments)}
))}
) : null} )}
{/* Error banner */} {renderErrorBanner()} {/* Delete confirm (spec: "delete with confirm dialog") — a nested dialog inside the panel (shares its focus trap). */} {deleteId ? (

Delete this conversation?

The messages will be soft-deleted (purged after 30 days). This cannot be undone from the UI.

) : null} {/* Screen-reader streaming state */}
{ai.streaming ? "AI is replying" : ai.banner ? "Error: " + ai.banner.message : ""}
{/* Composer */} {showComposer ? (
{context && (context.pageId || context.databaseId) ? (

{context.databaseId ? "Answering about this database" : "Answering about this page"} {context.label ? ` — ${context.label}` : ""}

) : null}