mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Wire analytics tables to filter-first search and remove AI query builder.
Replace the AI query bar and dialog with TableSearchBar, keep columns during reloads, and move refresh into the toolbar actions slot. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
co-authored by
Cursor
parent
802bb88047
commit
2ab581076f
-134
@@ -1,134 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { SimpleTooltip } from "@/components/ui/simple-tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
EyeIcon,
|
||||
PaperPlaneTiltIcon,
|
||||
SparkleIcon,
|
||||
SpinnerGapIcon,
|
||||
XIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
|
||||
import { useCallback, useState, type KeyboardEvent } from "react";
|
||||
import type { AiQueryChat } from "./use-ai-query-chat";
|
||||
|
||||
type AiQueryBarProps = {
|
||||
chat: AiQueryChat,
|
||||
/** Whether the AI has committed a query (drives the purple accent). */
|
||||
isActive: boolean,
|
||||
/** Invoked when the user clicks the eye button. */
|
||||
onOpenDialog: () => void,
|
||||
/** Invoked when the user clicks the reset (clear) button. */
|
||||
onReset: () => void,
|
||||
};
|
||||
|
||||
/**
|
||||
* AI-powered search bar for the analytics tables page. Drops into the
|
||||
* DataGridToolbar in place of the built-in quick-search input. Typing
|
||||
* a message and pressing Enter sends a prompt to the shared AI chat;
|
||||
* the AI responds by calling the `queryAnalytics` tool, and the
|
||||
* extracted query drives the grid (via the parent component).
|
||||
*/
|
||||
export function AiQueryBar({
|
||||
chat,
|
||||
isActive,
|
||||
onOpenDialog,
|
||||
onReset,
|
||||
}: AiQueryBarProps) {
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const text = input.trim();
|
||||
if (!text || chat.isResponding) return;
|
||||
setInput("");
|
||||
runAsynchronously(chat.sendMessage({ text }));
|
||||
}, [input, chat]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"group flex h-8 min-w-0 flex-1 items-center gap-2 rounded-xl px-2.5 sm:max-w-72",
|
||||
"border border-black/[0.08] dark:border-white/[0.06]",
|
||||
"bg-white dark:bg-background shadow-sm ring-1 ring-black/[0.08] dark:ring-white/[0.06] transition-shadow hover:transition-none",
|
||||
isActive
|
||||
? "ring-purple-500/30 focus-within:ring-purple-500/50 dark:ring-purple-400/30 dark:focus-within:ring-purple-400/50"
|
||||
: "hover:ring-black/[0.12] dark:hover:ring-white/[0.1] focus-within:ring-foreground/[0.18]",
|
||||
)}
|
||||
>
|
||||
<SparkleIcon
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
isActive ? "text-purple-400" : "text-muted-foreground/50",
|
||||
)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
isActive ? "Refine with AI…" : "Ask about your analytics…"
|
||||
}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 bg-transparent text-xs outline-none",
|
||||
"placeholder:text-muted-foreground/40",
|
||||
)}
|
||||
disabled={chat.isResponding}
|
||||
/>
|
||||
{chat.isResponding && (
|
||||
<SpinnerGapIcon className="h-3 w-3 shrink-0 animate-spin text-purple-400" />
|
||||
)}
|
||||
{!chat.isResponding && input.trim().length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-purple-400 transition-colors hover:transition-none"
|
||||
aria-label="Send"
|
||||
>
|
||||
<PaperPlaneTiltIcon className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<SimpleTooltip tooltip="Open AI query builder">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenDialog}
|
||||
className={cn(
|
||||
"shrink-0 rounded p-0.5 transition-colors hover:transition-none",
|
||||
isActive
|
||||
? "text-purple-400 hover:text-purple-300"
|
||||
: "text-muted-foreground/60 hover:text-foreground",
|
||||
)}
|
||||
aria-label="Open AI query builder"
|
||||
>
|
||||
<EyeIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
|
||||
{isActive && (
|
||||
<SimpleTooltip tooltip="Clear AI query">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] transition-colors hover:transition-none"
|
||||
aria-label="Clear AI query"
|
||||
>
|
||||
<XIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-483
@@ -1,483 +0,0 @@
|
||||
"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,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
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 "@/components/config-update";
|
||||
import { AssistantRuntimeProvider, type ToolCallContentPartProps } from "@assistant-ui/react";
|
||||
import {
|
||||
ArrowClockwiseIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
FloppyDiskIcon,
|
||||
LayoutIcon,
|
||||
SparkleIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { generateSecureRandomString } from "@hexclave/shared/dist/utils/crypto";
|
||||
import {
|
||||
runAsynchronously,
|
||||
runAsynchronouslyWithAlert,
|
||||
} from "@hexclave/shared/dist/utils/promises";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useAdminApp } from "../../use-admin-app";
|
||||
import type { AiQueryChat } from "./use-ai-query-chat";
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<ToolFallback
|
||||
{...props}
|
||||
headerAction={canApply ? (
|
||||
<span className="flex items-center" onClick={(e) => e.stopPropagation()}>
|
||||
<SimpleTooltip tooltip="Use this query">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onApplyQuery(queryString)}
|
||||
className="inline-flex h-5 items-center gap-1 rounded-md px-1.5 text-[10px] font-medium leading-none text-muted-foreground/70 transition-colors hover:transition-none hover:bg-foreground/[0.06] hover:text-foreground"
|
||||
aria-label="Use this query"
|
||||
>
|
||||
<ArrowClockwiseIcon className="h-2.5 w-2.5" />
|
||||
Use query
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AiQueryWelcome() {
|
||||
return (
|
||||
<div className="flex w-full max-w-[var(--thread-max-width)] flex-grow flex-col">
|
||||
<div className="flex w-full flex-grow flex-col items-center justify-center py-16 px-6">
|
||||
<div className="w-12 h-12 rounded-2xl bg-purple-500/10 flex items-center justify-center mb-4 ring-1 ring-purple-500/20">
|
||||
<SparkleIcon className="w-6 h-6 text-purple-400" weight="duotone" />
|
||||
</div>
|
||||
<h2 className="text-base font-semibold tracking-tight text-foreground mb-1.5">
|
||||
Build an analytics query
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground text-center max-w-[300px] leading-relaxed">
|
||||
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”.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Save query sub-dialog ──────────────────────────────────────────
|
||||
|
||||
function SaveQueryInlineDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
sqlQuery,
|
||||
}: {
|
||||
open: boolean,
|
||||
onOpenChange: (open: boolean) => void,
|
||||
sqlQuery: string,
|
||||
}) {
|
||||
const adminApp = useAdminApp();
|
||||
const project = adminApp.useProject();
|
||||
const config = project.useConfig();
|
||||
const updateConfig = useUpdateConfig();
|
||||
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setDisplayName("");
|
||||
setSaving(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!displayName.trim() || !sqlQuery.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
// Reuse an existing folder if available, otherwise create an
|
||||
// "AI Queries" bucket on the fly so the save flow never stalls
|
||||
// on folder management.
|
||||
const existingFolders = Object.entries(config.analytics.queryFolders);
|
||||
let folderId: string;
|
||||
if (existingFolders.length > 0) {
|
||||
folderId = existingFolders[0]![0];
|
||||
} else {
|
||||
folderId = generateSecureRandomString();
|
||||
await updateConfig({
|
||||
adminApp,
|
||||
configUpdate: {
|
||||
[`analytics.queryFolders.${folderId}`]: {
|
||||
displayName: "AI Queries",
|
||||
sortOrder: 0,
|
||||
queries: {},
|
||||
},
|
||||
},
|
||||
pushable: false,
|
||||
});
|
||||
}
|
||||
|
||||
const queryId = generateSecureRandomString();
|
||||
await updateConfig({
|
||||
adminApp,
|
||||
configUpdate: {
|
||||
[`analytics.queryFolders.${folderId}.queries.${queryId}`]: {
|
||||
displayName: displayName.trim(),
|
||||
sqlQuery,
|
||||
},
|
||||
},
|
||||
pushable: false,
|
||||
});
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [displayName, sqlQuery, config, updateConfig, adminApp, onOpenChange]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save query</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ai-save-query-name">Name</Label>
|
||||
<Input
|
||||
id="ai-save-query-name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Recent signups"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
runAsynchronouslyWithAlert(handleSave);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/50 bg-muted/30 p-2">
|
||||
<pre className="whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground max-h-32 overflow-auto">
|
||||
{sqlQuery}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => runAsynchronouslyWithAlert(handleSave)}
|
||||
disabled={!displayName.trim() || saving}
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Build dashboard sub-dialog ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reuses the existing `CreateDashboardPreview` component (the same
|
||||
* one the Cmd+K command center uses) so the dashboard-builder
|
||||
* experience is identical whether you enter it from the command
|
||||
* palette or from the analytics AI query builder. Most of
|
||||
* `CmdKPreviewProps` are unused by `CreateDashboardPreview` internally,
|
||||
* so we pass no-op stubs for them.
|
||||
*/
|
||||
function BuildDashboardDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
sqlQuery,
|
||||
}: {
|
||||
open: boolean,
|
||||
onOpenChange: (open: boolean) => void,
|
||||
sqlQuery: string,
|
||||
}) {
|
||||
// Synthesize a prompt that pre-seeds the SQL query as context so
|
||||
// the dashboard the AI generates visualizes exactly these results.
|
||||
const dashboardPrompt = useMemo(
|
||||
() =>
|
||||
`Build a dashboard that visualizes the results of this ClickHouse query:\n\n\`\`\`sql\n${sqlQuery}\n\`\`\``,
|
||||
[sqlQuery],
|
||||
);
|
||||
|
||||
const stubProps: Omit<CmdKPreviewProps, "query" | "onClose"> = {
|
||||
isSelected: true,
|
||||
registerOnFocus: () => {
|
||||
// no-op
|
||||
},
|
||||
unregisterOnFocus: () => {
|
||||
// no-op
|
||||
},
|
||||
onBlur: () => {
|
||||
// no-op
|
||||
},
|
||||
registerNestedCommands: () => {
|
||||
// no-op
|
||||
},
|
||||
navigateToNested: () => {
|
||||
// no-op
|
||||
},
|
||||
depth: 0,
|
||||
pathname: "",
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl h-[80vh] p-0 overflow-hidden flex flex-col">
|
||||
<DialogHeader className="px-4 pt-4 pb-2">
|
||||
<DialogTitle className="flex items-center gap-2 text-sm">
|
||||
<LayoutIcon className="h-4 w-4 text-cyan-500" />
|
||||
Build dashboard
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 min-h-0">
|
||||
{open && (
|
||||
<CreateDashboardPreview
|
||||
query={dashboardPrompt}
|
||||
onClose={() => onOpenChange(false)}
|
||||
{...stubProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main dialog ────────────────────────────────────────────────────
|
||||
|
||||
type AiQueryDialogProps = {
|
||||
open: boolean,
|
||||
onOpenChange: (open: boolean) => void,
|
||||
chat: AiQueryChat,
|
||||
/** The query currently driving the data grid (may be `null` if none yet). */
|
||||
currentQuery: string | null,
|
||||
};
|
||||
|
||||
export function AiQueryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
chat,
|
||||
currentQuery,
|
||||
}: AiQueryDialogProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [saveOpen, setSaveOpen] = useState(false);
|
||||
const [buildOpen, setBuildOpen] = useState(false);
|
||||
const [currentQueryDraft, setCurrentQueryDraft] = useState(currentQuery ?? "");
|
||||
const assistantContentComponents = useMemo(() => ({
|
||||
Text: MarkdownText,
|
||||
tools: {
|
||||
Fallback: ToolFallback,
|
||||
by_name: {
|
||||
queryAnalytics: (props: ToolCallContentPartProps) => (
|
||||
<AnalyticsQueryToolCall
|
||||
{...props}
|
||||
currentQuery={currentQuery}
|
||||
onApplyQuery={chat.rewindToQuery}
|
||||
/>
|
||||
),
|
||||
},
|
||||
},
|
||||
}), [chat.rewindToQuery, currentQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentQueryDraft(currentQuery ?? "");
|
||||
}, [currentQuery]);
|
||||
|
||||
const applyCurrentQueryDraft = useCallback(() => {
|
||||
if (currentQueryDraft.trim().length === 0 || currentQueryDraft === currentQuery) return;
|
||||
chat.rewindToQuery(currentQueryDraft);
|
||||
}, [chat, currentQuery, currentQueryDraft]);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!currentQueryDraft) return;
|
||||
await navigator.clipboard.writeText(currentQueryDraft);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}, [currentQueryDraft]);
|
||||
|
||||
const canActOnQuery = currentQueryDraft.trim().length > 0;
|
||||
const hasUnappliedCurrentQueryEdits = currentQueryDraft.trim().length > 0 && currentQueryDraft !== (currentQuery ?? "");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent noCloseButton className="max-w-3xl h-[80vh] p-0 overflow-hidden flex flex-col gap-0">
|
||||
<DialogHeader className="px-5 pt-5 pb-3 border-b border-border/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2 text-sm">
|
||||
<SparkleIcon className="h-4 w-4 text-purple-400" />
|
||||
AI query builder
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-1">
|
||||
{chat.messages.length > 0 && (
|
||||
<SimpleTooltip tooltip="Clear chat">
|
||||
<button
|
||||
type="button"
|
||||
onClick={chat.clearMessages}
|
||||
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] transition-colors hover:transition-none"
|
||||
aria-label="Clear chat"
|
||||
>
|
||||
<TrashIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] transition-colors hover:transition-none"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="shrink-0 border-b border-black/[0.06] bg-zinc-50/80 dark:border-border/40 dark:bg-muted/20">
|
||||
<div className="flex items-center justify-between px-5 py-2">
|
||||
<Label className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Current query
|
||||
</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
{hasUnappliedCurrentQueryEdits && (
|
||||
<SimpleTooltip tooltip="Apply query">
|
||||
<button
|
||||
type="button"
|
||||
onClick={applyCurrentQueryDraft}
|
||||
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] transition-colors hover:transition-none"
|
||||
>
|
||||
<ArrowClockwiseIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
{canActOnQuery && (
|
||||
<SimpleTooltip tooltip={copied ? "Copied!" : "Copy SQL"}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runAsynchronously(handleCopy())}
|
||||
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] transition-colors hover:transition-none"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="h-3 w-3 text-green-400" />
|
||||
) : (
|
||||
<CopyIcon className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 pb-3">
|
||||
<Textarea
|
||||
value={currentQueryDraft}
|
||||
onChange={(e) => setCurrentQueryDraft(e.target.value)}
|
||||
onBlur={applyCurrentQueryDraft}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
applyCurrentQueryDraft();
|
||||
}
|
||||
}}
|
||||
placeholder="Ask the AI a question to generate a query."
|
||||
className="min-h-16 max-h-32 resize-y overflow-auto border-black/[0.08] bg-white/95 font-mono text-[11px] text-foreground/90 shadow-sm ring-1 ring-black/[0.06] placeholder:italic placeholder:text-muted-foreground dark:border-border/40 dark:bg-background/60 dark:ring-white/[0.06]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chat.error && (
|
||||
<div className="mx-5 mt-3 shrink-0 rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-[12px] text-red-300">
|
||||
{chat.error.message || "Failed to get a response."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<AssistantRuntimeProvider runtime={chat.runtime}>
|
||||
<Thread
|
||||
composerPlaceholder="Refine the query..."
|
||||
runningStatusMessages={["Thinking..."]}
|
||||
assistantContentComponents={assistantContentComponents}
|
||||
welcome={<AiQueryWelcome />}
|
||||
/>
|
||||
</AssistantRuntimeProvider>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-5 py-3 border-t border-border/40 sm:justify-between gap-2">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Save the query or turn it into a live dashboard.
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={!canActOnQuery}
|
||||
onClick={() => setSaveOpen(true)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<FloppyDiskIcon className="h-3.5 w-3.5" />
|
||||
Save query
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!canActOnQuery}
|
||||
onClick={() => setBuildOpen(true)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<LayoutIcon className="h-3.5 w-3.5" />
|
||||
Build dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<SaveQueryInlineDialog
|
||||
open={saveOpen}
|
||||
onOpenChange={setSaveOpen}
|
||||
sqlQuery={currentQueryDraft}
|
||||
/>
|
||||
<BuildDashboardDialog
|
||||
open={buildOpen}
|
||||
onOpenChange={setBuildOpen}
|
||||
sqlQuery={currentQueryDraft}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+51
-61
@@ -9,13 +9,13 @@ import { useCallback, useState } from "react";
|
||||
import { AppEnabledGuard } from "../../app-enabled-guard";
|
||||
import { PageLayout } from "../../page-layout";
|
||||
import { AnalyticsEventLimitBanner } from "../shared";
|
||||
import { AiQueryBar } from "./ai-query-bar";
|
||||
import { AiQueryDialog } from "./ai-query-dialog";
|
||||
import {
|
||||
QueryDataGrid,
|
||||
type QueryDataGridMode,
|
||||
} from "./query-data-grid";
|
||||
import { useAiQueryChat } from "./use-ai-query-chat";
|
||||
import { getValidatedTableFilterQuery } from "./search-bar-logic";
|
||||
import { TableSearchBar } from "./table-search-bar";
|
||||
import { useAiTableFilterChat } from "./use-ai-table-filter-chat";
|
||||
|
||||
// ─── Available tables ───────────────────────────────────────────────
|
||||
|
||||
@@ -143,56 +143,43 @@ const AVAILABLE_TABLES = new Map<TableId, TableConfig>([
|
||||
|
||||
function TableContent({ tableId }: { tableId: TableId }) {
|
||||
const tableConfig = AVAILABLE_TABLES.get(tableId) ?? throwErr(`Unknown analytics table: ${tableId}`);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
// Shared AI chat state — feeds both the search bar and the eye
|
||||
// dialog, so they operate on a single conversation thread.
|
||||
const chat = useAiQueryChat();
|
||||
// AI thread behind the search bar — constrained to row filters over this
|
||||
// table, so the grid's columns never change.
|
||||
const filterChat = useAiTableFilterChat(tableId);
|
||||
|
||||
const aiQuery = chat.latestQuery;
|
||||
const isAiActive = aiQuery != null;
|
||||
const rawFilterQuery = filterChat.latestQuery;
|
||||
// Defense-in-depth: the system prompt promises `SELECT * FROM <table>
|
||||
// WHERE ...`, but the model could still emit anything — validate the shape
|
||||
// before letting it drive the grid, otherwise the columns could change.
|
||||
const filterQuery = rawFilterQuery == null ? null : getValidatedTableFilterQuery(rawFilterQuery, tableId);
|
||||
const filterRejected = rawFilterQuery != null && filterQuery == null;
|
||||
|
||||
// When the AI has committed a query, it becomes the source of
|
||||
// truth; otherwise fall back to the table's own default query.
|
||||
const effectiveQuery = aiQuery ?? tableConfig.baseQuery;
|
||||
const effectiveMode: QueryDataGridMode = isAiActive ? "one-shot" : "paginated";
|
||||
|
||||
// Default sort / search only apply while the AI is inactive — an
|
||||
// AI-generated aggregate won't have an `event_at` column to sort on.
|
||||
const defaultOrderBy = isAiActive ? undefined : tableConfig.defaultOrderBy;
|
||||
const defaultOrderDir = isAiActive ? undefined : tableConfig.defaultOrderDir;
|
||||
|
||||
const handleResetChat = useCallback(() => {
|
||||
chat.clearMessages();
|
||||
}, [chat]);
|
||||
|
||||
const aiSearchBar = (
|
||||
<AiQueryBar
|
||||
chat={chat}
|
||||
isActive={isAiActive}
|
||||
onOpenDialog={() => setDialogOpen(true)}
|
||||
onReset={handleResetChat}
|
||||
/>
|
||||
);
|
||||
const effectiveQuery = filterQuery ?? tableConfig.baseQuery;
|
||||
const effectiveMode: QueryDataGridMode = filterQuery != null ? "one-shot" : "paginated";
|
||||
|
||||
const renderToolbarExtra = useCallback(
|
||||
(ctx: { rowCount: number, hasMore: boolean, reload: () => void }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={ctx.reload}
|
||||
className="h-7 w-7"
|
||||
title="Refresh"
|
||||
>
|
||||
<ArrowClockwiseIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<span className="hidden sm:inline px-1 text-[11px] tabular-nums text-muted-foreground">
|
||||
{ctx.hasMore
|
||||
? `${ctx.rowCount.toLocaleString()}+ rows`
|
||||
: `${ctx.rowCount.toLocaleString()} rows`}
|
||||
</span>
|
||||
</div>
|
||||
(ctx: { rowCount: number, hasMore: boolean }) => (
|
||||
<span className="hidden h-[22px] shrink-0 items-center rounded-full bg-foreground/[0.04] px-2 text-[10px] tabular-nums text-muted-foreground ring-1 ring-foreground/[0.06] sm:inline-flex">
|
||||
{ctx.hasMore
|
||||
? `${ctx.rowCount.toLocaleString()}+ rows`
|
||||
: `${ctx.rowCount.toLocaleString()} rows`}
|
||||
</span>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const renderToolbarActions = useCallback(
|
||||
(ctx: { reload: () => void }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={ctx.reload}
|
||||
className="h-7 gap-1.5 px-2.5 text-[11px] font-medium text-muted-foreground hover:text-foreground"
|
||||
title="Refresh"
|
||||
>
|
||||
<ArrowClockwiseIcon className="h-3.5 w-3.5" />
|
||||
Refresh
|
||||
</Button>
|
||||
),
|
||||
[],
|
||||
);
|
||||
@@ -204,23 +191,26 @@ function TableContent({ tableId }: { tableId: TableId }) {
|
||||
<QueryDataGrid
|
||||
query={effectiveQuery}
|
||||
mode={effectiveMode}
|
||||
defaultOrderBy={defaultOrderBy}
|
||||
defaultOrderDir={defaultOrderDir}
|
||||
enableQuickSearchFilter={!isAiActive}
|
||||
searchBar={aiSearchBar}
|
||||
defaultOrderBy={tableConfig.defaultOrderBy}
|
||||
defaultOrderDir={tableConfig.defaultOrderDir}
|
||||
searchBar={(ctx) => (
|
||||
<TableSearchBar
|
||||
ctx={ctx}
|
||||
queryKey={effectiveQuery}
|
||||
chat={filterChat}
|
||||
activeFilterQuery={filterQuery}
|
||||
filterRejected={filterRejected}
|
||||
onAiSubmit={(text) => filterChat.sendMessage({ text })}
|
||||
onClearFilter={filterChat.clearMessages}
|
||||
/>
|
||||
)}
|
||||
toolbarExtra={renderToolbarExtra}
|
||||
toolbarActions={renderToolbarActions}
|
||||
exportFilename={`${tableId}-export`}
|
||||
fillHeight
|
||||
stickyTop={0}
|
||||
horizontalScrollbarPosition="top"
|
||||
/>
|
||||
|
||||
<AiQueryDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
chat={chat}
|
||||
currentQuery={aiQuery}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -240,7 +230,7 @@ export default function PageClient() {
|
||||
|
||||
{/* Match the primary nav's dark:rounded-2xl so the gap junction mirrors
|
||||
the same radius on both sides (nav top-right ↔ tables top-left). */}
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden rounded-l-2xl rounded-tr-2xl lg:-ml-2">
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden rounded-l-2xl rounded-tr-2xl lg:ml-0.5">
|
||||
{/* Use the same surface treatment as the primary sidebar so equal radii render
|
||||
identically. Omit the right border to keep the sidebar/grid junction divider-free. */}
|
||||
<div className="hidden w-48 min-h-0 flex-shrink-0 flex-col overflow-hidden rounded-l-2xl bg-black/[0.03] dark:border dark:border-r-0 dark:border-foreground/5 dark:bg-foreground/5 dark:backdrop-blur-2xl dark:shadow-sm lg:flex">
|
||||
@@ -286,7 +276,7 @@ export default function PageClient() {
|
||||
// Toolbar row only (first child of sticky chrome) — analytics layout
|
||||
"[&_[role=grid]_.sticky>div:first-child>div]:pt-3",
|
||||
"[&_[role=grid]_.sticky>div:first-child>div]:pb-2.5",
|
||||
"[&_[role=grid]_.sticky>div:first-child>div]:pr-0",
|
||||
"[&_[role=grid]_.sticky>div:first-child>div]:pr-3",
|
||||
"[&_[role=grid]_.sticky>div:first-child>div]:pl-2.5",
|
||||
)}
|
||||
style={{
|
||||
|
||||
+37
-13
@@ -108,13 +108,21 @@ export type QueryDataGridProps = {
|
||||
| ReactNode
|
||||
| ((ctx: QueryDataGridToolbarContext<RowData>) => ReactNode),
|
||||
/**
|
||||
* Extra content slotted into the default toolbar, to the LEFT of
|
||||
* the built-in columns / export actions. Can be a node or a function
|
||||
* receiving the extended context.
|
||||
* Extra content slotted into the default toolbar's leading cluster
|
||||
* (after search), e.g. filters or a row-count badge. Can be a node or
|
||||
* a function receiving the extended context.
|
||||
*/
|
||||
toolbarExtra?:
|
||||
| ReactNode
|
||||
| ((ctx: QueryDataGridToolbarContext<RowData>) => ReactNode),
|
||||
/**
|
||||
* Extra content in the trailing action cluster, immediately before
|
||||
* Columns / Export (e.g. a labeled Refresh). Can be a node or a
|
||||
* function receiving the extended context.
|
||||
*/
|
||||
toolbarActions?:
|
||||
| ReactNode
|
||||
| ((ctx: QueryDataGridToolbarContext<RowData>) => ReactNode),
|
||||
/** Whether the default row-click-to-inspect dialog is enabled. Defaults to `true`. */
|
||||
enableRowDetailDialog?: boolean,
|
||||
/** Custom row click handler. Overrides the default row detail dialog. */
|
||||
@@ -322,6 +330,7 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
toolbar,
|
||||
searchBar,
|
||||
toolbarExtra,
|
||||
toolbarActions,
|
||||
enableRowDetailDialog = true,
|
||||
onRowClick,
|
||||
onError,
|
||||
@@ -373,12 +382,12 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
};
|
||||
});
|
||||
|
||||
// Whenever the query changes we want fresh columns + a reset sort
|
||||
// so a leftover sort from a previous schema doesn't crash the
|
||||
// next query (nonexistent column in ORDER BY).
|
||||
// Whenever the query changes, reset sort / search / page so a leftover
|
||||
// ORDER BY from a previous schema can't crash the next fetch. Keep the
|
||||
// last discovered columns — clearing them would leave the grid with an
|
||||
// empty schema during the reload (blank pane instead of a shimmer), and
|
||||
// the next successful page overwrites them anyway.
|
||||
useEffect(() => {
|
||||
setDiscoveredColumns([]);
|
||||
discoveredColumnsRef.current = [];
|
||||
setError(null);
|
||||
setGridState((prev) => ({
|
||||
...prev,
|
||||
@@ -433,6 +442,10 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
[discoveredColumns],
|
||||
);
|
||||
|
||||
// `query` / `mode` are in the dep list so the generator identity changes
|
||||
// when the SQL changes — useDataSource keys off that to refetch. The
|
||||
// generator still reads through refs so a single in-flight page always
|
||||
// sees the latest values.
|
||||
const dataSource = useMemo<DataGridDataSource<RowData>>(() => {
|
||||
return async function* (params) {
|
||||
setError(null);
|
||||
@@ -498,7 +511,7 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
yield { rows: [], hasMore: false };
|
||||
}
|
||||
};
|
||||
}, [serverApp]);
|
||||
}, [serverApp, query, mode]);
|
||||
|
||||
const getRowId = useCallback((row: RowData): string => {
|
||||
if (typeof row[INTERNAL_ROW_ID_KEY] === "string") return row[INTERNAL_ROW_ID_KEY];
|
||||
@@ -568,8 +581,8 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
* 2. `searchBar` provided — render our own DataGridToolbar
|
||||
* wrapper that hides the built-in quick search and slots the
|
||||
* caller's node where it used to live; keeps Columns/Export
|
||||
* intact. `toolbarExtra` (if provided) is passed through as
|
||||
* the built-in extras slot.
|
||||
* intact. `toolbarExtra` / `toolbarActions` (if provided) are
|
||||
* passed through to the leading / trailing slots.
|
||||
* 3. neither — undefined, so the DataGrid
|
||||
* falls back to its default toolbar behaviour (built-in
|
||||
* quick search, extras, columns, export).
|
||||
@@ -585,16 +598,23 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
: typeof toolbarExtra === "function"
|
||||
? toolbarExtra(extended)
|
||||
: toolbarExtra;
|
||||
const actions =
|
||||
toolbarActions === undefined
|
||||
? undefined
|
||||
: typeof toolbarActions === "function"
|
||||
? toolbarActions(extended)
|
||||
: toolbarActions;
|
||||
return (
|
||||
<DataGridToolbar
|
||||
ctx={ctx}
|
||||
extra={extras}
|
||||
extraLeading={leading}
|
||||
extraActions={actions}
|
||||
hideQuickSearch
|
||||
/>
|
||||
);
|
||||
},
|
||||
[searchBar, toolbarExtra, extendCtx],
|
||||
[searchBar, toolbarExtra, toolbarActions, extendCtx],
|
||||
);
|
||||
|
||||
const renderForwardedToolbar = useCallback(
|
||||
@@ -666,7 +686,11 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
emptyState={
|
||||
emptyState ?? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-16">
|
||||
<Typography variant="secondary">No data available</Typography>
|
||||
<Typography variant="secondary">
|
||||
{gridState.quickSearch.trim().length > 0
|
||||
? "No rows match your search"
|
||||
: "No data available"}
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user