diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-dialog.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-dialog.tsx index 8359ea35fc..1e80621352 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-dialog.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-dialog.tsx @@ -1,5 +1,9 @@ "use client"; +import { MarkdownText } from "@/components/assistant-ui/markdown-text"; +import { Thread } from "@/components/assistant-ui/thread"; +import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; +import type { CmdKPreviewProps } from "@/components/cmdk-commands"; import { Button } from "@/components/ui"; import { Dialog, @@ -12,20 +16,18 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { SimpleTooltip } from "@/components/ui/simple-tooltip"; +import { Textarea } from "@/components/ui/textarea"; +import { CreateDashboardPreview } from "@/components/commands/create-dashboard/create-dashboard-preview"; import { useUpdateConfig } from "@/lib/config-update"; -import { cn } from "@/lib/utils"; +import { AssistantRuntimeProvider, type ToolCallContentPartProps } from "@assistant-ui/react"; import { - ArrowCounterClockwiseIcon, + ArrowClockwiseIcon, CheckIcon, CopyIcon, FloppyDiskIcon, LayoutIcon, - PaperPlaneTiltIcon, SparkleIcon, - SpinnerGapIcon, - StopIcon, TrashIcon, - UserIcon, XIcon, } from "@phosphor-icons/react"; import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto"; @@ -33,160 +35,66 @@ import { runAsynchronously, runAsynchronouslyWithAlert, } from "@stackframe/stack-shared/dist/utils/promises"; -import type { UIMessage } from "@ai-sdk/react"; import { - memo, useCallback, useEffect, useMemo, - useRef, useState, - type KeyboardEvent, } from "react"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import type { CmdKPreviewProps } from "@/components/cmdk-commands"; -import { CreateDashboardPreview } from "@/components/commands/create-dashboard/create-dashboard-preview"; import { useAdminApp } from "../../use-admin-app"; import type { AiQueryChat } from "./use-ai-query-chat"; -// ─── Chat message rendering ───────────────────────────────────────── - -type OrderedPart = - | { kind: "text", text: string } - | { kind: "tool", id: string, state: string, query: string | null, error: string | null }; - -function getOrderedParts(message: UIMessage): OrderedPart[] { - const parts: OrderedPart[] = []; - for (const [idx, part] of message.parts.entries()) { - if (part.type === "text") { - const text = (part as { type: "text", text: string }).text; - if (text.trim()) { - parts.push({ kind: "text", text }); - } - continue; - } - if (!part.type.startsWith("tool-") || !part.type.endsWith("queryAnalytics")) continue; - const tp = part as { - type: string, - state: string, - input?: Record, - output?: Record, - }; - const query = - typeof tp.input?.query === "string" ? (tp.input.query as string) : null; - const output = tp.output; - let errorMessage: string | null = null; - if (output && typeof output === "object") { - const success = (output as { success?: unknown }).success; - if (success === false) { - const err = (output as { error?: unknown }).error; - errorMessage = typeof err === "string" ? err : "Query failed"; - } - } - parts.push({ - kind: "tool", - id: `${message.id}-${idx}`, - state: tp.state, - query, - error: errorMessage, - }); - } - return parts; -} +function AnalyticsQueryToolCall( + props: ToolCallContentPartProps & { + currentQuery: string | null, + onApplyQuery: (query: string) => void, + }, +) { + const query = (props.args as { query?: unknown } | undefined)?.query; + const queryString = typeof query === "string" ? query : ""; + const result = props.result as { success?: unknown } | null | undefined; + const isSuccessful = props.status.type === "complete" && result?.success !== false; + const canApply = isSuccessful && queryString.trim().length > 0 && queryString !== props.currentQuery; -const UserMessageBubble = memo(function UserMessageBubble({ - content, -}: { - content: string, -}) { return ( -
-
- -
-
- {content} -
-
+ e.stopPropagation()}> + + + + + ) : undefined} + /> ); -}); +} -const AssistantMessageBubble = memo(function AssistantMessageBubble({ - parts, - currentQuery, - onRewindToQuery, -}: { - parts: OrderedPart[], - currentQuery: string | null, - onRewindToQuery: (query: string) => void, -}) { +function AiQueryWelcome() { return ( -
-
- -
-
- {parts.map((part, idx) => { - if (part.kind === "text") { - return ( -
- {part.text} -
- ); - } - const isActiveQuery = part.query != null && part.query === currentQuery; - return ( -
-
- {part.state !== "output-available" && !part.error && ( - - )} - - {part.error - ? "Query failed" - : part.state === "output-available" - ? "Ran query" - : "Building query"} - - {!isActiveQuery && part.query && part.state === "output-available" && !part.error && ( - <> -
- - - - - )} -
- {part.error && ( -

- {part.error} -

- )} -
- ); - })} +
+
+
+ +
+

+ Build an analytics query +

+

+ Ask for a table, segment, trend, or funnel. Try “daily signups over the last 30 days” or “top 10 users by event count this week”. +

); -}); +} // ─── Save query sub-dialog ────────────────────────────────────────── @@ -389,52 +297,44 @@ export function AiQueryDialog({ chat, currentQuery, }: AiQueryDialogProps) { - const [followUpInput, setFollowUpInput] = useState(""); const [copied, setCopied] = useState(false); const [saveOpen, setSaveOpen] = useState(false); const [buildOpen, setBuildOpen] = useState(false); - const messagesContainerRef = useRef(null); - const inputRef = useRef(null); - - // Auto-scroll to the bottom whenever a new message arrives. - useEffect(() => { - if (!messagesContainerRef.current) return; - messagesContainerRef.current.scrollTop = - messagesContainerRef.current.scrollHeight; - }, [chat.messages, chat.isResponding]); + const [currentQueryDraft, setCurrentQueryDraft] = useState(currentQuery ?? ""); + const assistantContentComponents = useMemo(() => ({ + Text: MarkdownText, + tools: { + Fallback: ToolFallback, + by_name: { + queryAnalytics: (props: ToolCallContentPartProps) => ( + + ), + }, + }, + }), [chat.rewindToQuery, currentQuery]); - // Focus the input when the dialog opens so the user can keep typing. useEffect(() => { - if (open) { - requestAnimationFrame(() => inputRef.current?.focus()); - } - }, [open]); - - const handleSend = useCallback(() => { - const text = followUpInput.trim(); - if (!text || chat.isResponding) return; - setFollowUpInput(""); - runAsynchronously(chat.sendMessage({ text })); - }, [followUpInput, chat]); + setCurrentQueryDraft(currentQuery ?? ""); + }, [currentQuery]); - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - }, - [handleSend], - ); + const applyCurrentQueryDraft = useCallback(() => { + if (currentQueryDraft.trim().length === 0 || currentQueryDraft === currentQuery) return; + chat.rewindToQuery(currentQueryDraft); + }, [chat, currentQuery, currentQueryDraft]); const handleCopy = useCallback(async () => { - if (!currentQuery) return; - await navigator.clipboard.writeText(currentQuery); + if (!currentQueryDraft) return; + await navigator.clipboard.writeText(currentQueryDraft); setCopied(true); setTimeout(() => setCopied(false), 1500); - }, [currentQuery]); + }, [currentQueryDraft]); - const canActOnQuery = Boolean(currentQuery && currentQuery.trim().length > 0); + const canActOnQuery = currentQueryDraft.trim().length > 0; + const hasUnappliedCurrentQueryEdits = currentQueryDraft.trim().length > 0 && currentQueryDraft !== (currentQuery ?? ""); return ( <> @@ -451,7 +351,7 @@ export function AiQueryDialog({
- {/* Current query */}
- {canActOnQuery && ( - - - - )} +
+ {hasUnappliedCurrentQueryEdits && ( + + + + )} + {canActOnQuery && ( + + + + )} +
+
+
+