"use client"; import React, { useEffect, useRef, useState } from "react"; import { Sparkles, Send, X, Loader2, MessageCircle, CheckCircle2, AlertCircle, ChevronDown, ChevronUp, } from "lucide-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { askAsf, jobs } from "@/lib/api"; import { useAppStore } from "@/stores/useAppStore"; import { Button } from "@/components/ui/Button"; import { Badge, JobStatusBadge } from "@/components/ui/Badge"; import { cn, fmtDate, truncate } from "@/lib/utils"; import type { Job } from "@/lib/types"; interface ChatMessage { id: string; role: "user" | "system" | "assistant"; kind?: "question" | "info" | "success" | "error"; content: string; ts: Date; jobId?: string; } export function AskAsfCommandBar() { const open = useAppStore((s) => s.asfOpen); const closeAsf = useAppStore((s) => s.closeAsf); const openAsf = useAppStore((s) => s.openAsf); const [q, setQ] = useState(""); const [messages, setMessages] = useState([ { id: "welcome", role: "system", kind: "info", content: "Ask anything about your tenant data: requirements without tests, why a build failed, which services implement REQ-441, … This UI wires to the AI Gateway through asf-worker — in bootstrap you'll see a transparent MockProvider response.", ts: new Date(), }, ]); const [pollingJobId, setPollingJobId] = useState(null); const textareaRef = useRef(null); // open via global event + ⌘K useEffect(() => { function onKey(e: KeyboardEvent) { const meta = e.metaKey || e.ctrlKey; if (meta && e.key.toLowerCase() === "k") { e.preventDefault(); if (open) closeAsf(); else openAsf(); } if (e.key === "Escape" && open) closeAsf(); } function onOpenEv() { openAsf(); } window.addEventListener("keydown", onKey); window.addEventListener("asf:open-command", onOpenEv); return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("asf:open-command", onOpenEv); }; }, [open, openAsf, closeAsf]); useEffect(() => { if (open) setTimeout(() => textareaRef.current?.focus(), 50); }, [open]); const submitMutation = useMutation({ mutationFn: (question: string) => askAsf.query({ question }), onMutate: (question) => { setMessages((m) => [ ...m, { id: "u" + Date.now(), role: "user", kind: "question", content: question, ts: new Date() }, ]); }, onSuccess: (resp) => { setMessages((m) => [ ...m, { id: "subm" + Date.now(), role: "system", kind: "info", content: `Query accepted. Polling job ${resp.job_id.slice(0, 8)}… — status: queued`, ts: new Date(), jobId: resp.job_id, }, ]); setPollingJobId(resp.job_id); }, onError: (err: Error) => { setMessages((m) => [ ...m, { id: "e" + Date.now(), role: "system", kind: "error", content: err.message, ts: new Date() }, ]); }, }); // long-poll the accepted job until terminal useQuery({ queryKey: ["ask-job", pollingJobId], enabled: !!pollingJobId, refetchInterval: (q) => (q.state.data?.status === "completed" || q.state.data?.status === "failed" || q.state.data?.status === "cancelled" ? false : 1500), queryFn: async (): Promise => { if (!pollingJobId) return undefined; const job = await jobs.get(pollingJobId); if (job && (job.status === "completed" || job.status === "failed" || job.status === "cancelled")) { setPollingJobId(null); setMessages((m) => { // avoid adding duplicate result messages if (m.some(x => x.id === `res-${job.id}`)) return m; if (job.status === "completed" && job.result) { const answer = (job.result as Record).answer as string || JSON.stringify(job.result, null, 2); return [ ...m, { id: `res-${job.id}`, role: "assistant", kind: "success", content: answer, ts: new Date(), jobId: job.id, }, ]; } if (job.status === "failed") { return [ ...m, { id: `fail-${job.id}`, role: "system", kind: "error", content: job.error || "Unknown AI job failure.", ts: new Date(), jobId: job.id, }, ]; } return m; }); } return job; }, }); function onSubmit(e: React.FormEvent) { e.preventDefault(); if (!q.trim()) return; submitMutation.mutate(q.trim()); setQ(""); } const [expanded, setExpanded] = useState(true); if (!open) return null; return (
e.stopPropagation()} > {/* header */}
Ask ASF
Context-aware queries backed by the ASF AI Gateway
mock provider
{/* messages */}
{messages.map((m) => ( ))} {submitMutation.isPending && (
Submitting query to ASF AI job queue…
)} {pollingJobId && (
Polling job {pollingJobId.slice(0,12)} until terminal state…
)}
{/* toggle for expanded mini-chat */} {/* input */}