From 86c0ae802662eb133aa5fda56e8fa049356eec9b Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Tue, 14 Jul 2026 11:08:40 -0700 Subject: [PATCH] Replace AI query chat hook with table filter chat hook. Rename useAiQueryChat to useAiTableFilterChat, scope it to a table, and surface assistant notes when no query is committed. Co-authored-by: Cursor --- ...st.ts => use-ai-table-filter-chat.test.ts} | 2 +- ...ry-chat.ts => use-ai-table-filter-chat.ts} | 83 +++++++++++++------ 2 files changed, 59 insertions(+), 26 deletions(-) rename apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/{use-ai-query-chat.test.ts => use-ai-table-filter-chat.test.ts} (97%) rename apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/{use-ai-query-chat.ts => use-ai-table-filter-chat.ts} (62%) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-query-chat.test.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-table-filter-chat.test.ts similarity index 97% rename from apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-query-chat.test.ts rename to apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-table-filter-chat.test.ts index f941edc63..a785379fd 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-query-chat.test.ts +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-table-filter-chat.test.ts @@ -1,6 +1,6 @@ import type { ThreadMessage } from "@assistant-ui/react"; import { describe, expect, it } from "vitest"; -import { extractLatestQuery } from "./use-ai-query-chat"; +import { extractLatestQuery } from "./use-ai-table-filter-chat"; const fixture = (messages: Array<{ id: string, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-query-chat.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-table-filter-chat.ts similarity index 62% rename from apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-query-chat.ts rename to apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-table-filter-chat.ts index 7f45a5d67..8513caf16 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-query-chat.ts +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-table-filter-chat.ts @@ -1,8 +1,8 @@ "use client"; -import { createAnalyticsQueryChatAdapter } from "@/components/vibe-coding"; +import { createAnalyticsTableFilterChatAdapter } from "@/components/vibe-coding"; import { getPublicEnvVar } from "@/lib/env"; -import { useLocalThreadRuntime, type AssistantRuntime, type ThreadMessage, type ToolCallContentPart } from "@assistant-ui/react"; +import { useLocalThreadRuntime, type ThreadMessage, type ToolCallContentPart } from "@assistant-ui/react"; import { useUser } from "@hexclave/next"; import { throwErr } from "@hexclave/shared/dist/utils/errors"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -58,20 +58,50 @@ export function extractLatestQuery(messages: readonly ThreadMessage[]): { return null; } -export type AiQueryChat = { - runtime: AssistantRuntime, +/** + * Extracts the trailing text of the last assistant message, so the UI can + * surface what the AI said when it answered without committing a query + * (e.g. "that's an aggregation, which this search can't display"). + */ +function extractLastAssistantText(messages: readonly ThreadMessage[]): string | null { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]!; + if (msg.role !== "assistant") continue; + for (let j = msg.content.length - 1; j >= 0; j--) { + const part = msg.content[j]!; + if (part.type === "text" && part.text.trim().length > 0) { + return part.text.trim(); + } + } + } + return null; +} + +export type AiTableFilterChat = { messages: readonly ThreadMessage[], isResponding: boolean, error: Error | null, sendMessage: (input: { text: string }) => void, clearMessages: () => void, - stop: () => void, + /** The last query the AI committed via the queryAnalytics tool (unvalidated). */ latestQuery: string | null, - queryGeneration: number, - rewindToQuery: (query: string) => void, + /** + * Set when the last run finished WITHOUT committing a new query — holds the + * assistant's text reply (or null if there was none / a query was + * committed). Cleared on the next send/clear or via `dismissAssistantNote`. + */ + assistantNote: string | null, + dismissAssistantNote: () => void, }; -export function useAiQueryChat(): AiQueryChat { +/** + * AI chat thread backing the analytics table search bar's AI fallback. Uses + * the constrained `filter-analytics-table` system prompt scoped to the given + * table, so the AI only produces `SELECT * FROM WHERE ...` row + * filters (callers still validate the shape before applying — see + * `getValidatedTableFilterQuery`). + */ +export function useAiTableFilterChat(tableName: string): AiTableFilterChat { const currentUser = useUser(); const projectId = useProjectId(); const backendBaseUrl = @@ -82,13 +112,14 @@ export function useAiQueryChat(): AiQueryChat { const [error, setError] = useState(null); const adapter = useMemo( - () => createAnalyticsQueryChatAdapter( + () => createAnalyticsTableFilterChatAdapter( backendBaseUrl, currentUser ?? undefined, projectId, + tableName, setError, ), - [backendBaseUrl, currentUser, projectId], + [backendBaseUrl, currentUser, projectId, tableName], ); const runtime = useLocalThreadRuntime(adapter, { maxSteps: 1 }); @@ -118,6 +149,7 @@ export function useAiQueryChat(): AiQueryChat { query: string, generation: number, } | null>(null); + const [assistantNote, setAssistantNote] = useState(null); const wasRespondingRef = useRef(false); const lastCommittedGenRef = useRef(0); @@ -127,10 +159,18 @@ export function useAiQueryChat(): AiQueryChat { if (!justFinished) return; const latest = extractLatestQuery(messages); - if (latest == null) return; - if (latest.toolCallIndex <= lastCommittedGenRef.current) return; + if (latest == null || latest.toolCallIndex <= lastCommittedGenRef.current) { + // The run ended without committing a new query — surface the + // assistant's text (if any) so callers can show WHY nothing changed. + // This must live here (not in a consuming component) because child + // effects run before this one; a child comparing commit state on the + // same falling edge would race the commit below. + setAssistantNote(extractLastAssistantText(messages)); + return; + } lastCommittedGenRef.current = latest.toolCallIndex; setCommitted({ query: latest.query, generation: latest.toolCallIndex }); + setAssistantNote(null); }, [isResponding, messages]); useEffect(() => { @@ -143,6 +183,7 @@ export function useAiQueryChat(): AiQueryChat { const sendMessage = useCallback( ({ text }: { text: string }) => { setError(null); + setAssistantNote(null); runtime.thread.append({ role: "user", content: [{ type: "text", text }], @@ -153,30 +194,22 @@ export function useAiQueryChat(): AiQueryChat { const clearMessages = useCallback(() => { setError(null); + setAssistantNote(null); runtime.thread.import({ messages: [], headId: null }); }, [runtime]); - const stop = useCallback(() => { - runtime.thread.cancelRun(); - }, [runtime]); - - const rewindToQuery = useCallback((query: string) => { - setCommitted((prev) => ({ - query, - generation: (prev?.generation ?? 0) + 1, - })); + const dismissAssistantNote = useCallback(() => { + setAssistantNote(null); }, []); return { - runtime, messages, isResponding, error, sendMessage, clearMessages, - stop, latestQuery: committed?.query ?? null, - queryGeneration: committed?.generation ?? 0, - rewindToQuery, + assistantNote, + dismissAssistantNote, }; }