1"use client";
2
3
31import { useCallback, useEffect, useRef, useState } from "react";
32import {
33 AlertTriangle,
34 Loader2,
35 MessageSquarePlus,
36 Pencil,
37 RotateCcw,
38 Sparkles,
39 Square,
40 Trash2,
41 X
42} from "lucide-react";
43import { useWorkspace } from "@/hooks/use-workspace";
44import { useAiStream, type AiBannerError } from "@/hooks/use-ai-stream";
45import { useUiStore } from "@/stores/ui-store";
46import { formatRelativeTime } from "@/lib/utils";
47import type { AiMessage } from "@/lib/ai";
48
49
50
51
52function useIsMobile(): boolean {
53 const [match, setMatch] = useState(false);
54 useEffect(() => {
55 const mq = window.matchMedia("(max-width: 767px)");
56 setMatch(mq.matches);
57 const onChange = (e: MediaQueryListEvent) => setMatch(e.matches);
58 mq.addEventListener("change", onChange);
59 return () => mq.removeEventListener("change", onChange);
60 }, []);
61 return match;
62}
63
64const budgetBanner = (b: AiBannerError): boolean =>
65 b.code === "AI_BUDGET_EXCEEDED" || b.code === "AI_BUDGET_MONTHLY_EXCEEDED";
66
67
68
69export function AiPanel() {
70 const open = useUiStore((s) => s.aiPanelOpen);
71 const context = useUiStore((s) => s.aiPanelContext);
72 const setOpen = useUiStore((s) => s.setAiPanel);
73 const slug = useUiStore((s) => s.searchWorkspaceSlug);
74 const { data: workspace } = useWorkspace(slug);
75
76 const isMobile = useIsMobile();
77 const ai = useAiStream(workspace?.id ?? null, context);
78
79 const [draft, setDraft] = useState("");
80 const [historyOpen, setHistoryOpen] = useState(true);
81
82 const [renameId, setRenameId] = useState<string | null>(null);
83 const [renameDraft, setRenameDraft] = useState("");
84
85 const [deleteId, setDeleteId] = useState<string | null>(null);
86
87 const panelRef = useRef<HTMLDivElement>(null);
88 const inputRef = useRef<HTMLTextAreaElement>(null);
89 const scrollRef = useRef<HTMLDivElement>(null);
90 const triggerRef = useRef<HTMLElement | null>(null);
91
92
93 useEffect(() => {
94 if (open) {
95 setDraft("");
96 requestAnimationFrame(() => inputRef.current?.focus());
97 }
98 }, [open]);
99
100
101
102 const close = useCallback(() => {
103 ai.stop();
104 setOpen(false);
105 requestAnimationFrame(() => {
106 triggerRef.current?.focus();
107 triggerRef.current = null;
108 });
109 }, [ai, setOpen]);
110
111 useEffect(() => {
112 if (!open) return;
113 const onKey = (e: KeyboardEvent) => {
114 if (e.key === "Escape") {
115 e.stopPropagation();
116 if (deleteId) {
117 setDeleteId(null);
118 } else {
119 close();
120 }
121 }
122 };
123 window.addEventListener("keydown", onKey);
124 return () => window.removeEventListener("keydown", onKey);
125 }, [open, close, deleteId]);
126
127
128 useEffect(() => {
129 if (!open) return;
130 const onKey = (e: KeyboardEvent) => {
131 if (e.key !== "Tab") return;
132 const panel = panelRef.current;
133 if (!panel) return;
134 const focusables = panel.querySelectorAll<HTMLElement>('button, textarea, input, [tabindex]:not([tabindex="-1"])');
135 if (focusables.length === 0) return;
136 const first = focusables[0];
137 const last = focusables[focusables.length - 1];
138 if (!first || !last) return;
139 if (e.shiftKey && document.activeElement === first) {
140 e.preventDefault();
141 last.focus();
142 } else if (!e.shiftKey && document.activeElement === last) {
143 e.preventDefault();
144 first.focus();
145 }
146 };
147 window.addEventListener("keydown", onKey);
148 return () => window.removeEventListener("keydown", onKey);
149 }, [open]);
150
151
152 useEffect(() => {
153 if (!open) return;
154 const prev = document.activeElement as HTMLElement | null;
155 triggerRef.current = prev;
156 }, [open]);
157
158
159 useEffect(() => {
160 const el = scrollRef.current;
161 if (el) el.scrollTop = el.scrollHeight;
162 }, [ai.messages.length, ai.live?.content, ai.pendingUser, open]);
163
164
165
166 const doSend = useCallback(() => {
167 const text = draft.trim();
168 if (!text || ai.streaming) return;
169 setDraft("");
170 void ai.send(text);
171 }, [draft, ai]);
172
173 if (!open) return null;
174
175 const noWorkspace = !workspace?.id;
176
177 const renderErrorBanner = () => {
178 if (!ai.banner) return null;
179 const b = ai.banner;
180 const showRetry = b.retryable && !ai.streaming;
181 return (
182 <div
183 data-testid="ai-error-banner"
184 role="alert"
185 className="mx-3 mt-2 flex items-start gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200"
186 >
187 <AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden />
188 <div className="min-w-0 flex-1">
189 <p className="font-medium">
190 {budgetBanner(b) ? "Monthly AI budget reached" : b.code === "RATE_LIMITED" ? "Sending too fast" : "Something went wrong"}
191 </p>
192 <p className="mt-0.5 break-words">{b.message}</p>
193 {budgetBanner(b) ? (
194 <p className="mt-1 text-[11px] opacity-80">
195 Your workspace has hit its monthly token limit. Ask an admin to raise it in Settings → AI.
196 </p>
197 ) : null}
198 </div>
199 {showRetry ? (
200 <button
201 type="button"
202 data-testid="ai-retry"
203 onClick={() => void ai.retry()}
204 className="inline-flex shrink-0 items-center gap-1 rounded border border-amber-400 px-2 py-1 font-medium hover:bg-amber-100 dark:hover:bg-amber-900/40"
205 >
206 <RotateCcw className="size-3" aria-hidden />
207 Retry
208 </button>
209 ) : null}
210 </div>
211 );
212 };
213
214 const renderMessage = (m: AiMessage, live = false) => {
215 const isUser = m.role === "user";
216 const id = live ? "ai-message-live" : isUser ? "ai-message-user" : "ai-message-assistant";
217 return (
218 <div
219 key={live ? "live" : m.id}
220 data-testid={id}
221 className={isUser ? "flex justify-end" : "flex justify-start"}
222 >
223 <div
224 className={
225 "max-w-[85%] whitespace-pre-wrap break-words rounded-lg px-3 py-2 text-sm " +
226 (isUser
227 ? "bg-primary text-primary-foreground"
228 : "border border-border bg-muted/40 text-foreground")
229 }
230 >
231 {m.content}
232 {live ? <span className="ml-0.5 inline-block h-3 w-1.5 animate-pulse bg-foreground/60 align-middle" aria-hidden /> : null}
233 </div>
234 </div>
235 );
236 };
237
238 const showComposer = !noWorkspace;
239
240 return (
241 <div className="fixed inset-0 z-[80] flex justify-end" role="presentation">
242 <div className="absolute inset-0 bg-black/20" aria-hidden onClick={close} />
243 <div
244 ref={panelRef}
245 data-testid="ai-panel"
246 role="dialog"
247 aria-modal="true"
248 aria-label="Ask AI"
249 className={
250 "relative flex flex-col bg-background shadow-2xl " +
251 (isMobile
252 ? "inset-x-0 bottom-0 max-h-[85vh] rounded-t-xl border-t border-border"
253 : "inset-y-0 right-0 w-[420px] border-l border-border")
254 }
255 >
256 {}
257 <div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2.5">
258 <div className="flex min-w-0 items-center gap-2">
259 <Sparkles className="size-4 shrink-0 text-primary" aria-hidden />
260 <h2 className="text-sm font-semibold">Ask AI</h2>
261 {}
262 {context?.label ? (
263 <span
264 data-testid="ai-context-badge"
265 className="max-w-[45%] truncate rounded-full border border-border bg-muted/60 px-2 py-0.5 text-[10px] text-muted-foreground"
266 title={context.label}
267 >
268 {context.label}
269 </span>
270 ) : null}
271 </div>
272 <div className="flex items-center gap-1">
273 <button
274 type="button"
275 data-testid="ai-history-new"
276 onClick={ai.startNew}
277 className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-foreground/80 hover:bg-accent hover:text-foreground"
278 aria-label="Start a new chat"
279 >
280 <MessageSquarePlus className="size-3.5" aria-hidden /> New
281 </button>
282 <button
283 type="button"
284 data-testid="ai-close"
285 onClick={close}
286 className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
287 aria-label="Close Ask AI"
288 >
289 <X className="size-4" aria-hidden />
290 </button>
291 </div>
292 </div>
293
294 {}
295 <div className="shrink-0 border-b border-border">
296 <button
297 type="button"
298 onClick={() => setHistoryOpen((v) => !v)}
299 aria-expanded={historyOpen}
300 className="flex w-full items-center justify-between px-3 py-1.5 text-[11px] uppercase tracking-wide text-muted-foreground hover:bg-accent"
301 >
302 <span>Conversations</span>
303 <span aria-hidden>{historyOpen ? "▾" : "▸"}</span>
304 </button>
305 {historyOpen ? (
306 <div className="max-h-32 overflow-y-auto px-1 pb-1">
307 {ai.conversations.length === 0 ? (
308 <p className="px-2 py-1 text-xs text-muted-foreground">No conversations yet.</p>
309 ) : (
310 <ul>
311 {ai.conversations.map((c) => (
312 <li key={c.id} className="group flex items-center gap-1">
313 {renameId === c.id ? (
314 <input
315 data-testid="ai-history-rename"
316 autoFocus
317 value={renameDraft}
318 onChange={(e) => setRenameDraft(e.target.value)}
319 onBlur={() => {
320 void ai.renameConversation(c.id, renameDraft);
321 setRenameId(null);
322 }}
323 onKeyDown={(e) => {
324 if (e.key === "Enter") {
325 e.preventDefault();
326 void ai.renameConversation(c.id, renameDraft);
327 setRenameId(null);
328 } else if (e.key === "Escape") {
329 e.stopPropagation();
330 setRenameId(null);
331 }
332 }}
333 aria-label="Rename conversation"
334 className="min-w-0 flex-1 rounded-md border border-primary bg-background px-2 py-1 text-xs outline-none"
335 />
336 ) : (
337 <button
338 type="button"
339 data-testid="ai-history-item"
340 onClick={() => void ai.openConversation(c.id)}
341 onDoubleClick={() => {
342 setRenameId(c.id);
343 setRenameDraft(c.title ?? "");
344 }}
345 className={
346 "min-w-0 flex-1 truncate rounded-md px-2 py-1.5 text-left text-xs hover:bg-accent " +
347 (ai.activeId === c.id ? "bg-accent font-medium" : "text-foreground/90")
348 }
349 aria-current={ai.activeId === c.id ? "true" : undefined}
350 >
351 {c.title?.trim() ? c.title : "New chat"}
352 <span className="ml-1 text-[10px] text-muted-foreground">{formatRelativeTime(c.updatedAt)}</span>
353 </button>
354 )}
355 <button
356 type="button"
357 onClick={() => {
358 setRenameId(c.id);
359 setRenameDraft(c.title ?? "");
360 }}
361 className="rounded p-1 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover:opacity-100"
362 aria-label={`Rename conversation ${c.title ?? "new"}`}
363 >
364 <Pencil className="size-3" aria-hidden />
365 </button>
366 <button
367 type="button"
368 data-testid="ai-history-delete"
369 onClick={() => setDeleteId(c.id)}
370 className="rounded p-1 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover:opacity-100"
371 aria-label={`Delete conversation ${c.title ?? "new"}`}
372 >
373 <Trash2 className="size-3" aria-hidden />
374 </button>
375 </li>
376 ))}
377 </ul>
378 )}
379 </div>
380 ) : null}
381 </div>
382
383 {}
384 <div ref={scrollRef} className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
385 {noWorkspace ? (
386 <p className="text-xs text-muted-foreground" role="status">
387 Open a workspace to ask questions.
388 </p>
389 ) : ai.messagesLoading ? (
390 <p className="flex items-center gap-2 text-xs text-muted-foreground" role="status">
391 <Loader2 className="size-3.5 animate-spin" aria-hidden /> Loading…
392 </p>
393 ) : ai.messages.length === 0 && !ai.live && !ai.pendingUser ? (
394 <div className="mt-10 text-center text-xs text-muted-foreground">
395 <Sparkles className="mx-auto mb-2 size-6" aria-hidden />
396 <p>Ask anything about this workspace.</p>
397 <p className="mt-1">AI answers cite the pages and databases you link.</p>
398 </div>
399 ) : (
400 <>
401 {ai.messages.map((m) => renderMessage(m))}
402 {ai.pendingUser ? renderMessage({ id: ai.pendingUser.id, role: "user", content: ai.pendingUser.content, createdAt: "" }) : null}
403 {ai.live && ai.live.content !== "" ? (
404 renderMessage(
405 { id: "live", role: "assistant", content: ai.live.content, createdAt: "" },
406 true
407 )
408 ) : ai.live && ai.live.content === "" && ai.streaming ? (
409 <div data-testid="ai-message-live" className="flex justify-start">
410 <div className="flex items-center gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
411 <Loader2 className="size-3.5 animate-spin" aria-hidden /> Thinking…
412 </div>
413 </div>
414 ) : null}
415 {ai.live && ai.live.toolCalls.length > 0 ? (
416 <div className="space-y-1">
417 {ai.live.toolCalls.map((t) => (
418 <div
419 key={t.id}
420 data-testid="ai-tool-call"
421 className="flex items-center gap-2 rounded-md border border-border bg-background px-2 py-1 text-[11px] text-muted-foreground"
422 >
423 <Sparkles className="size-3" aria-hidden />
424 <span className="font-mono">{t.name}</span>
425 <span className="truncate" aria-hidden>
426 {JSON.stringify(t.arguments)}
427 </span>
428 </div>
429 ))}
430 </div>
431 ) : null}
432 </>
433 )}
434 </div>
435
436 {}
437 {renderErrorBanner()}
438
439 {}
441 {deleteId ? (
442 <div
443 data-testid="ai-delete-confirm"
444 role="alertdialog"
445 aria-label="Delete conversation?"
446 className="absolute inset-0 z-10 flex items-center justify-center bg-black/30 p-6"
447 >
448 <div className="w-full max-w-xs rounded-lg border border-border bg-background p-4 shadow-xl">
449 <p className="text-sm font-medium">Delete this conversation?</p>
450 <p className="mt-1 text-xs text-muted-foreground">
451 The messages will be soft-deleted (purged after 30 days). This cannot be undone from the UI.
452 </p>
453 <div className="mt-3 flex justify-end gap-2">
454 <button
455 type="button"
456 onClick={() => setDeleteId(null)}
457 className="rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent"
458 >
459 Cancel
460 </button>
461 <button
462 type="button"
463 data-testid="ai-delete-confirm-yes"
464 autoFocus
465 onClick={() => {
466 const id = deleteId;
467 setDeleteId(null);
468 void ai.removeConversation(id);
469 }}
470 className="rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground hover:opacity-90"
471 >
472 Delete
473 </button>
474 </div>
475 </div>
476 </div>
477 ) : null}
478
479 {}
480 <div className="sr-only" role="status" aria-live="polite">
481 {ai.streaming ? "AI is replying" : ai.banner ? "Error: " + ai.banner.message : ""}
482 </div>
483
484 {}
485 {showComposer ? (
486 <div className="shrink-0 border-t border-border p-2.5">
487 {context && (context.pageId || context.databaseId) ? (
488 <p className="mb-1.5 truncate px-1 text-[10px] text-muted-foreground" data-testid="ai-context-note">
489 <Sparkles className="mr-0.5 inline size-2.5" aria-hidden />
490 {context.databaseId ? "Answering about this database" : "Answering about this page"}
491 {context.label ? ` — ${context.label}` : ""}
492 </p>
493 ) : null}
494 <div className="flex items-end gap-2">
495 <textarea
496 ref={inputRef}
497 data-testid="ai-input"
498 value={draft}
499 onChange={(e) => setDraft(e.target.value)}
500 onKeyDown={(e) => {
501 if (e.key === "Enter" && !e.shiftKey) {
502 e.preventDefault();
503 doSend();
504 }
505 }}
506 rows={2}
507 placeholder="Ask anything… (Enter to send, Shift+Enter for a new line)"
508 aria-label="Ask AI message"
509 className="min-h-[52px] w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm outline-none placeholder:text-muted-foreground focus:border-primary"
510 />
511 {ai.streaming ? (
512 <button
513 type="button"
514 data-testid="ai-stop"
515 onClick={ai.stop}
516 className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground hover:opacity-90"
517 aria-label="Stop generating"
518 >
519 <Square className="size-4" aria-hidden />
520 </button>
521 ) : (
522 <button
523 type="button"
524 data-testid="ai-send"
525 onClick={doSend}
526 disabled={!draft.trim()}
527 className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-40"
528 aria-label="Send message"
529 >
530 <Sparkles className="size-4" aria-hidden />
531 </button>
532 )}
533 </div>
534 </div>
535 ) : null}
536 </div>
537 </div>
538 );
539}
540