From 3cb2527900dbd7d657cb736882eb5aa49dd66c08 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Wed, 19 Aug 2026 10:55:29 +0300 Subject: [PATCH 1/7] feat: add query highlighting feature for SQL queries in chat - Updated package dependency for @falkordb/canvas to version 0.2.7. - Introduced QueryHighlightContext to manage the state of highlighted SQL queries and their referenced tables. - Integrated QueryHighlightProvider in the App component to provide context to child components. - Enhanced ChatInterface to toggle query highlights and pass relevant props to ChatMessage. - Modified ChatMessage to display highlight status and handle click events for highlighting. - Updated SchemaViewer to highlight tables and relations based on the selected SQL query. - Created SchemaCanvasControls for managing canvas layout, zoom, and focus mode. - Implemented utility functions for extracting table names from SQL queries. - Added logic to automatically reveal the schema viewer when a SQL query is selected. --- app/package-lock.json | 70 +-- app/package.json | 2 +- app/src/App.tsx | 25 +- app/src/components/chat/ChatInterface.tsx | 4 + app/src/components/chat/ChatMessage.tsx | 35 +- .../schema/SchemaCanvasControls.tsx | 445 ++++++++++++++++++ app/src/components/schema/SchemaViewer.tsx | 269 ++++++++--- app/src/contexts/QueryHighlightContext.tsx | 61 +++ app/src/pages/Index.tsx | 9 + app/src/utils/sqlTables.ts | 102 ++++ 10 files changed, 874 insertions(+), 148 deletions(-) create mode 100644 app/src/components/schema/SchemaCanvasControls.tsx create mode 100644 app/src/contexts/QueryHighlightContext.tsx create mode 100644 app/src/utils/sqlTables.ts diff --git a/app/package-lock.json b/app/package-lock.json index 772d2a4d..bfce3163 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -8,7 +8,7 @@ "name": "queryweaver-app", "version": "0.3.2", "dependencies": { - "@falkordb/canvas": "^0.0.45", + "@falkordb/canvas": "^0.2.7", "@hookform/resolvers": "^5.7.1", "@radix-ui/react-accordion": "^1.2.20", "@radix-ui/react-alert-dialog": "^1.1.23", @@ -505,9 +505,9 @@ } }, "node_modules/@falkordb/canvas": { - "version": "0.0.45", - "resolved": "https://registry.npmjs.org/@falkordb/canvas/-/canvas-0.0.45.tgz", - "integrity": "sha512-BIb24rfnaLCxO2UH7L4cNYLikmOTXlVR4I/FIpEG/d9ZEp3F+9U/ugaLYOcYe1pmZzT72YnzWUuvdhNE3zK99A==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@falkordb/canvas/-/canvas-0.2.7.tgz", + "integrity": "sha512-AC13kQ/LDC4klPDKlczfiCIXnHx0Y94uw+S7xQAiD+7AKECw+bA1Xfl1dfIKRUXaNjIlmDzKApJ924OFHNwjtw==", "license": "MIT", "dependencies": { "d3": "^7.9.0", @@ -6256,9 +6256,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6276,9 +6273,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6296,9 +6290,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6316,9 +6307,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6336,9 +6324,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6356,9 +6341,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6557,9 +6539,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -6577,9 +6556,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -6597,9 +6573,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -6617,9 +6590,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -6637,9 +6607,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -6657,9 +6624,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -6870,9 +6834,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6890,9 +6851,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6910,9 +6868,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6930,9 +6885,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8612,7 +8564,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -9122,9 +9074,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9146,9 +9095,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9170,9 +9116,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9194,9 +9137,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/app/package.json b/app/package.json index 0bf51cd3..25dd6533 100644 --- a/app/package.json +++ b/app/package.json @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "@falkordb/canvas": "^0.0.45", + "@falkordb/canvas": "^0.2.7", "@hookform/resolvers": "^5.7.1", "@radix-ui/react-accordion": "^1.2.20", "@radix-ui/react-alert-dialog": "^1.1.23", diff --git a/app/src/App.tsx b/app/src/App.tsx index 10ca2825..3ea101d4 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -6,6 +6,7 @@ import { AuthProvider } from "@/contexts/AuthContext"; import { DatabaseProvider } from "@/contexts/DatabaseContext"; import { SettingsProvider } from "@/contexts/SettingsContext"; import { ChatProvider } from "@/contexts/ChatContext"; +import { QueryHighlightProvider } from "@/contexts/QueryHighlightContext"; import Index from "./pages/Index"; import Settings from "./pages/Settings"; import NotFound from "./pages/NotFound"; @@ -18,17 +19,19 @@ const App = () => ( - - - - - } /> - } /> - {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} - } /> - - - + + + + + + } /> + } /> + {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} + } /> + + + + diff --git a/app/src/components/chat/ChatInterface.tsx b/app/src/components/chat/ChatInterface.tsx index 6bfd0718..3c9ed543 100644 --- a/app/src/components/chat/ChatInterface.tsx +++ b/app/src/components/chat/ChatInterface.tsx @@ -5,6 +5,7 @@ import { useDatabase } from "@/contexts/DatabaseContext"; import { useAuth } from "@/contexts/AuthContext"; import { useSettings } from "@/contexts/SettingsContext"; import { useChat } from "@/contexts/ChatContext"; +import { useQueryHighlight } from "@/contexts/QueryHighlightContext"; import LoadingSpinner from "@/components/ui/loading-spinner"; import { Skeleton } from "@/components/ui/skeleton"; import ChatMessage from "./ChatMessage"; @@ -58,6 +59,7 @@ const ChatInterface = ({ const { selectedGraph } = useDatabase(); const { vendor, apiKey, modelName, isApiKeyValid } = useSettings(); const { messages, setMessages, conversationHistory, isProcessing, setIsProcessing } = useChat(); + const { selectedQueryId, toggleQueryHighlight } = useQueryHighlight(); const messagesEndRef = useRef(null); const chatContainerRef = useRef(null); @@ -510,6 +512,8 @@ const ChatInterface = ({ analysisInfo={msg.analysisInfo} confirmationData={msg.confirmationData} user={user} + isQueryHighlighted={msg.type === 'sql-query' && selectedQueryId === msg.id} + onToggleQueryHighlight={msg.type === 'sql-query' ? () => toggleQueryHighlight(msg.id, msg.content) : undefined} onConfirm={msg.type === 'confirmation' ? () => handleConfirmDestructive(msg.id) : undefined} onCancel={msg.type === 'confirmation' ? () => handleCancelDestructive(msg.id) : undefined} /> diff --git a/app/src/components/chat/ChatMessage.tsx b/app/src/components/chat/ChatMessage.tsx index f8848d72..c860d703 100644 --- a/app/src/components/chat/ChatMessage.tsx +++ b/app/src/components/chat/ChatMessage.tsx @@ -31,11 +31,13 @@ interface ChatMessageProps { }; progress?: number; // Progress percentage for AI steps user?: UserType | null; // User info for avatar + isQueryHighlighted?: boolean; // Whether this query's tables are highlighted in the schema canvas + onToggleQueryHighlight?: () => void; // Select/unselect this query to highlight it in the schema canvas onConfirm?: () => void; onCancel?: () => void; } -const ChatMessage = ({ type, content, steps, queryData, analysisInfo, confirmationData, progress, user, onConfirm, onCancel }: ChatMessageProps) => { +const ChatMessage = ({ type, content, steps, queryData, analysisInfo, confirmationData, progress, user, isQueryHighlighted, onToggleQueryHighlight, onConfirm, onCancel }: ChatMessageProps) => { const [copied, setCopied] = useState(false); const handleCopyQuery = async () => { @@ -148,6 +150,7 @@ const ChatMessage = ({ type, content, steps, queryData, analysisInfo, confirmati if (type === 'sql-query') { const hasSQL = content && content.trim().length > 0; const isValid = analysisInfo?.isValid !== false; // Default to true if not specified + const isClickable = hasSQL && Boolean(onToggleQueryHighlight); return (
@@ -165,6 +168,11 @@ const ChatMessage = ({ type, content, steps, queryData, analysisInfo, confirmati {hasSQL ? 'Generated SQL Query' : 'Query Analysis'} + {isQueryHighlighted && ( + + Shown in schema + + )}
{hasSQL && ( @@ -183,9 +191,32 @@ const ChatMessage = ({ type, content, steps, queryData, analysisInfo, confirmati )} -
+                    
 {
+                        if (event.key === 'Enter' || event.key === ' ') {
+                          event.preventDefault();
+                          onToggleQueryHighlight?.();
+                        }
+                      } : undefined}
+                      className={`bg-background text-foreground p-3 pr-12 rounded text-sm mb-1 w-fit min-w-full font-mono whitespace-pre-wrap break-words overflow-wrap-anywhere transition-colors ${
+                        isClickable ? 'cursor-pointer hover:ring-1 hover:ring-primary/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary' : ''
+                      } ${isQueryHighlighted ? 'ring-2 ring-primary bg-primary/5' : ''}`}
+                    >
                       {content}
                     
+ {isClickable && ( +

+ {isQueryHighlighted + ? 'Click the query again to clear the schema highlight.' + : 'Click the query to highlight its tables and relations in the schema.'} +

+ )} )} diff --git a/app/src/components/schema/SchemaCanvasControls.tsx b/app/src/components/schema/SchemaCanvasControls.tsx new file mode 100644 index 00000000..9a80bf89 --- /dev/null +++ b/app/src/components/schema/SchemaCanvasControls.tsx @@ -0,0 +1,445 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; +import type { KeyboardEvent, RefObject } from 'react'; +import type { + FalkorDBCanvas, + GraphNode, + HierarchyDirection, + LayoutMode, + RadialDirection, +} from '@falkordb/canvas'; +import { + ChevronDown, + Circle, + Pause, + Pin, + PinOff, + Play, + Search, + Shrink, + Telescope, + X, + ZoomIn, + ZoomOut, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +const LAYOUTS: { value: LayoutMode; label: string }[] = [ + { value: 'force', label: 'Force' }, + { value: 'tree', label: 'Tree' }, + { value: 'radial', label: 'Radial' }, +]; + +const HIERARCHY_DIRECTIONS: { value: HierarchyDirection; label: string }[] = [ + { value: 'td', label: 'Top → Down' }, + { value: 'bu', label: 'Bottom → Up' }, + { value: 'lr', label: 'Left → Right' }, + { value: 'rl', label: 'Right → Left' }, +]; + +const RADIAL_DIRECTIONS: { value: RadialDirection; label: string }[] = [ + { value: 'out', label: 'Outward' }, + { value: 'in', label: 'Inward' }, +]; + +const MAX_SUGGESTIONS = 8; + +const getDefaultDirection = (mode: LayoutMode) => { + if (mode === 'tree') return 'td'; + if (mode === 'radial') return 'out'; + return ''; +}; + +export interface SchemaTableOption { + id: number; + name: string; + columns: string[]; +} + +interface SchemaCanvasControlsProps { + canvasRef: RefObject; + tables: SchemaTableOption[]; + disabled?: boolean; + focusMode: boolean; + onFocusModeChange: (enabled: boolean) => void; + selectedTableId: number | null; + onSelectTable: (tableId: number | null) => void; +} + +const SchemaCanvasControls = ({ + canvasRef, + tables, + disabled = false, + focusMode, + onFocusModeChange, + selectedTableId, + onSelectTable, +}: SchemaCanvasControlsProps) => { + const [layout, setLayout] = useState('force'); + const [direction, setDirection] = useState(''); + const [animation, setAnimation] = useState(true); + const [pinned, setPinned] = useState(false); + const [search, setSearch] = useState(''); + const [suggestionsOpen, setSuggestionsOpen] = useState(false); + const [activeSuggestion, setActiveSuggestion] = useState(0); + + // Remembers the last direction picked per layout so switching back restores it. + const directionsRef = useRef>({ tree: 'td', radial: 'out' }); + + const suggestions = useMemo(() => { + const term = search.trim().toLowerCase(); + + if (!term) return []; + + return tables + .filter( + (table) => + table.name.toLowerCase().includes(term) || + table.columns.some((column) => column.toLowerCase().includes(term)) + ) + .slice(0, MAX_SUGGESTIONS); + }, [tables, search]); + + const focusTable = useCallback( + (table: SchemaTableOption) => { + onSelectTable(table.id); + setSearch(table.name); + setSuggestionsOpen(false); + canvasRef.current?.zoomToFit(4, (node: GraphNode) => node.id === table.id); + }, + [canvasRef, onSelectTable] + ); + + const clearSearch = useCallback(() => { + setSearch(''); + setSuggestionsOpen(false); + onSelectTable(null); + }, [onSelectTable]); + + const handleSearchKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setSuggestionsOpen(false); + return; + } + + if (suggestions.length === 0) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSuggestionsOpen(true); + setActiveSuggestion((index) => (index + 1) % suggestions.length); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSuggestionsOpen(true); + setActiveSuggestion((index) => (index - 1 + suggestions.length) % suggestions.length); + } else if (e.key === 'Enter') { + e.preventDefault(); + focusTable(suggestions[Math.min(activeSuggestion, suggestions.length - 1)]); + } + }; + + const handleZoom = (factor: number) => { + const canvas = canvasRef.current; + + if (!canvas) return; + + if (selectedTableId !== null) { + const node = canvas.getGraphData()?.nodes.find((n) => n.id === selectedTableId); + + if (node) { + canvas.centerAt(node.x ?? 0, node.y ?? 0, 300); + } + } + + canvas.zoom(canvas.getZoom() * factor); + }; + + const handleCenter = () => { + canvasRef.current?.zoomToFit(); + }; + + const applyDirection = (mode: LayoutMode, value: string) => { + if (mode === 'tree') { + canvasRef.current?.setLayoutOptions({ tree: { direction: value as HierarchyDirection } }); + } else if (mode === 'radial') { + canvasRef.current?.setLayoutOptions({ radial: { direction: value as RadialDirection } }); + } + }; + + const handleLayoutChange = (value: string) => { + const mode = value as LayoutMode; + const dir = mode === 'force' ? '' : directionsRef.current[mode] || getDefaultDirection(mode); + + // Direction options must be applied before setLayout so the layout engine uses them. + applyDirection(mode, dir); + canvasRef.current?.setLayout(mode); + + setLayout(mode); + setDirection(dir); + // Tree and radial layouts are deterministic, so the canvas pins their nodes. + setPinned(mode !== 'force'); + }; + + const handleDirectionChange = (value: string, targetLayout: LayoutMode) => { + directionsRef.current = { ...directionsRef.current, [targetLayout]: value }; + setDirection(value); + applyDirection(targetLayout, value); + }; + + const handleAnimationToggle = (checked: boolean) => { + setAnimation(checked); + canvasRef.current?.setAnimation(checked); + }; + + const handleFocusToggle = (checked: boolean) => { + onFocusModeChange(checked); + }; + + const handlePinToggle = () => { + const next = !pinned; + setPinned(next); + canvasRef.current?.setPinOnDragEnd(next); + + if (next) { + setAnimation(false); + } + }; + + const animationDisabled = disabled || pinned || layout !== 'force'; + + return ( +
+ {/* Search */} +
+ + { + setSearch(e.target.value); + setSuggestionsOpen(true); + setActiveSuggestion(0); + }} + onFocus={() => setSuggestionsOpen(true)} + onBlur={() => window.setTimeout(() => setSuggestionsOpen(false), 120)} + onKeyDown={handleSearchKeyDown} + /> + {search && ( + + )} + {suggestionsOpen && suggestions.length > 0 && ( +
    + {suggestions.map((table, index) => ( +
  • + +
  • + ))} +
+ )} +
+ + {/* Layout */} + + + + + + + Force + + + + {layout === 'tree' && ( + + + + )} + Tree + + + {HIERARCHY_DIRECTIONS.map((d) => ( + { + if (layout !== 'tree') handleLayoutChange('tree'); + handleDirectionChange(d.value, 'tree'); + }} + > + {layout === 'tree' && direction === d.value && ( + + + + )} + {d.label} + + ))} + + + + + {layout === 'radial' && ( + + + + )} + Radial + + + {RADIAL_DIRECTIONS.map((d) => ( + { + if (layout !== 'radial') handleLayoutChange('radial'); + handleDirectionChange(d.value, 'radial'); + }} + > + {layout === 'radial' && direction === d.value && ( + + + + )} + {d.label} + + ))} + + + + + + {/* Animation */} +
+ {animation ? ( + + ) : ( + + )} + +
+ + {/* Focus mode */} +
+ + +
+ + {/* Pin / zoom */} +
+ + + + +
+
+ ); +}; + +export default SchemaCanvasControls; diff --git a/app/src/components/schema/SchemaViewer.tsx b/app/src/components/schema/SchemaViewer.tsx index 47714714..a6fba9f9 100644 --- a/app/src/components/schema/SchemaViewer.tsx +++ b/app/src/components/schema/SchemaViewer.tsx @@ -1,10 +1,12 @@ -import { useEffect, useRef, useState, useCallback } from 'react'; -import type { Data, FalkorDBCanvas, GraphNode } from '@falkordb/canvas'; -import { ZoomIn, ZoomOut, Locate, X, GripVertical } from 'lucide-react'; +import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import type { Data, FalkorDBCanvas, GraphNode, GraphLink } from '@falkordb/canvas'; +import { X, GripVertical } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useDatabase } from '@/contexts/DatabaseContext'; +import { useQueryHighlight } from '@/contexts/QueryHighlightContext'; import { DatabaseService } from '@/services/database'; import { useToast } from '@/components/ui/use-toast'; +import SchemaCanvasControls, { type SchemaTableOption } from './SchemaCanvasControls'; interface SchemaNode { id: number; @@ -31,14 +33,98 @@ interface SchemaViewerProps { sidebarWidth?: number; } +/** Accent used for tables/relations referenced by the selected SQL query. */ +const HIGHLIGHT_COLOR = '#8b5cf6'; +/** Opacity applied to schema elements the selected query does not touch. */ +const DIMMED_OPACITY = 0.25; + const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: SchemaViewerProps) => { const canvasRef = useRef(null); const resizeRef = useRef(null); + // Schema snapshot currently seeded into the canvas, used to avoid re-seeding + // (and losing node positions) when only the highlight changed. + const renderedSchemaRef = useRef(null); const [schemaData, setSchemaData] = useState(null); const [loading, setLoading] = useState(false); + // Focus mode dims everything that is not connected to the hovered/selected table. + const [focusMode, setFocusMode] = useState(false); + const [hoveredNodeId, setHoveredNodeId] = useState(null); + const [selectedTableId, setSelectedTableId] = useState(null); const { selectedGraph } = useDatabase(); + const { highlightedTables, clearQueryHighlight } = useQueryHighlight(); const { toast } = useToast(); + // Tables referenced by the currently selected SQL query, and the relations + // between them. Empty sets mean "no highlight" — everything renders normally. + const { highlightedNodeIds, highlightedLinkKeys } = useMemo(() => { + const nodeIds = new Set(); + const linkKeys = new Set(); + + if (!schemaData || highlightedTables.length === 0) { + return { highlightedNodeIds: nodeIds, highlightedLinkKeys: linkKeys }; + } + + const wanted = new Set(highlightedTables.map((table) => table.toLowerCase())); + schemaData.nodes.forEach((node) => { + if (node.name && wanted.has(String(node.name).toLowerCase())) { + nodeIds.add(node.id); + } + }); + + schemaData.links.forEach((link) => { + if (nodeIds.has(link.source) && nodeIds.has(link.target)) { + linkKeys.add(`${link.source}-${link.target}`); + } + }); + + return { highlightedNodeIds: nodeIds, highlightedLinkKeys: linkKeys }; + }, [schemaData, highlightedTables]); + + const hasHighlight = highlightedNodeIds.size > 0; + + // Table the user is pointing at (hover wins over the searched/clicked table). + const focusTargetId = hoveredNodeId ?? selectedTableId; + + // Elements rendered with the accent colour. A selected SQL query wins; otherwise + // the hovered/selected table together with its direct relations is emphasised. + const { emphasisNodeIds, emphasisLinkKeys } = useMemo(() => { + if (hasHighlight) { + return { emphasisNodeIds: highlightedNodeIds, emphasisLinkKeys: highlightedLinkKeys }; + } + + const nodeIds = new Set(); + const linkKeys = new Set(); + + if (schemaData && focusTargetId !== null) { + nodeIds.add(focusTargetId); + schemaData.links.forEach((link) => { + if (link.source === focusTargetId || link.target === focusTargetId) { + nodeIds.add(link.source); + nodeIds.add(link.target); + linkKeys.add(`${link.source}-${link.target}`); + } + }); + } + + return { emphasisNodeIds: nodeIds, emphasisLinkKeys: linkKeys }; + }, [hasHighlight, highlightedNodeIds, highlightedLinkKeys, schemaData, focusTargetId]); + + // A query highlight always dims the rest; hover only dims in focus mode. + const dimInactive = hasHighlight || (focusMode && emphasisNodeIds.size > 0); + + // Search options for the canvas controls. + const tableOptions = useMemo(() => { + if (!schemaData) return []; + + return schemaData.nodes.map((node) => ({ + id: node.id, + name: String(node.name ?? ''), + columns: (node.columns || []).map((column) => + typeof column === 'object' ? String(column.name ?? '') : String(column) + ), + })); + }, [schemaData]); + // Track current theme for canvas colors const [theme, setTheme] = useState(() => { return document.documentElement.getAttribute('data-theme') || 'dark'; @@ -156,6 +242,8 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch const nodesMap = new Map(data.nodes.map((node) => [node.id, node])); + setHoveredNodeId(null); + setSelectedTableId(null); setSchemaData({ ...data, nodesMap }); } catch (error) { console.error('Failed to load schema:', error); @@ -170,31 +258,10 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch } }; - const handleZoomIn = () => { - const canvas = canvasRef.current - - if (canvas) { - canvas.zoom(canvas.getZoom() * 1.1); - } - }; - - const handleZoomOut = () => { - const canvas = canvasRef.current; - - if (canvas) { - canvas.zoom(canvas.getZoom() * 0.9); - } - }; - - const handleCenter = useCallback(() => { - console.log("Stop"); - - const canvas = canvasRef.current; - - if (canvas) { - canvas.zoomToFit(); - } - }, []); + const linkColorFor = useCallback((sourceId: number, targetId: number) => { + if (hasHighlight && highlightedLinkKeys.has(`${sourceId}-${targetId}`)) return HIGHLIGHT_COLOR; + return theme === 'light' ? '#9ca3af' : '#4b5563'; + }, [theme, hasHighlight, highlightedLinkKeys]); // Convert schema data to canvas format const convertToCanvasData = useCallback((data: SchemaData): Data => { @@ -226,7 +293,7 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch return { id: index + 1, relationship: 'REFERENCES', - color: theme === 'light' ? '#9ca3af' : '#4b5563', + color: linkColorFor(link.source, link.target), visible: true, source: link.source, target: link.target, @@ -235,9 +302,11 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch }); return { nodes, links }; - }, [theme]); + }, [theme, linkColorFor]); - // Set up canvas configuration and data - MUST be in single effect to ensure proper order + // Canvas configuration: custom table rendering, focus/dim predicates and + // interaction handlers. Kept separate from the data effect so hovering never + // re-seeds the graph. useEffect(() => { const canvas = canvasRef.current; @@ -262,13 +331,22 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch if (!schemaNode) return; + const isEmphasized = emphasisNodeIds.has(node.id); + const isDimmed = dimInactive && !isEmphasized; + const isActive = node.id === hoveredNodeId || node.id === selectedTableId; + const columns = schemaNode.columns || []; const nodeHeight = headerHeight + columns.length * lineHeight + padding * 2; + const previousAlpha = ctx.globalAlpha; + if (isDimmed) { + ctx.globalAlpha = previousAlpha * DIMMED_OPACITY; + } + ctx.fillStyle = fillColor; - ctx.strokeStyle = strokeColor; - ctx.lineWidth = 1; + ctx.strokeStyle = isEmphasized || isActive ? HIGHLIGHT_COLOR : strokeColor; + ctx.lineWidth = isActive ? 3 : isEmphasized ? 2.5 : 1; ctx.fillRect( (node.x || 0) - NODE_WIDTH / 2, (node.y || 0) - nodeHeight / 2, @@ -282,7 +360,7 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch nodeHeight ); - ctx.fillStyle = textColor; + ctx.fillStyle = isEmphasized || isActive ? HIGHLIGHT_COLOR : textColor; ctx.font = `bold ${fontSize}px Arial`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; @@ -333,6 +411,8 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch colY += lineHeight; }); + + ctx.globalAlpha = previousAlpha; }; const nodePointerAreaPaint = (node: GraphNode, color: string, ctx: CanvasRenderingContext2D) => { @@ -357,20 +437,71 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch }; - const canvasData = convertToCanvasData(schemaData); + const linkKeyOf = (link: GraphLink) => `${link.source?.id}-${link.target?.id}`; canvas.setConfig({ - autoStopOnSettle: false, + dimmed: dimInactive, + dimOpacity: DIMMED_OPACITY, + isNodeDimmed: (node: GraphNode) => !emphasisNodeIds.has(node.id), + isLinkDimmed: (link: GraphLink) => !emphasisLinkKeys.has(linkKeyOf(link)), + isNodeSelected: (node: GraphNode) => + node.id === hoveredNodeId || node.id === selectedTableId, + isLinkSelected: (link: GraphLink) => emphasisLinkKeys.has(linkKeyOf(link)), node: { nodeCanvasObject, nodePointerAreaPaint, - } + }, + eventHandlers: { + onNodeHover: (node: GraphNode | null) => setHoveredNodeId(node ? node.id : null), + onNodeClick: (node: GraphNode) => + setSelectedTableId((current) => (current === node.id ? null : node.id)), + onBackgroundClick: () => setSelectedTableId(null), + }, }); - + canvas.setBackgroundColor(theme === 'light' ? '#ffffff' : '#191919'); canvas.setForegroundColor(theme === 'light' ? '#111' : '#f5f5f5'); - canvas.setData(canvasData); - }, [schemaData, theme, canvasLoaded, convertToCanvasData]); + }, [ + schemaData, + theme, + canvasLoaded, + dimInactive, + emphasisNodeIds, + emphasisLinkKeys, + hoveredNodeId, + selectedTableId, + ]); + + // Seed the canvas with the schema. `setData` recomputes the layout, so it only + // runs for a new schema; later updates reuse the existing node positions. + useEffect(() => { + const canvas = canvasRef.current; + + if (!canvas || !canvasLoaded || !schemaData) return; + + const canvasData = convertToCanvasData(schemaData); + + if (renderedSchemaRef.current !== schemaData) { + renderedSchemaRef.current = schemaData; + canvas.setData(canvasData); + return; + } + + canvas.setGraphData(canvasData); + }, [schemaData, canvasLoaded, convertToCanvasData]); + + // Bring the highlighted tables into view when a query is selected + useEffect(() => { + const canvas = canvasRef.current; + + if (!canvas || !canvasLoaded || !hasHighlight) return; + + const timer = setTimeout(() => { + canvas.zoomToFit(1.5, (node: GraphNode) => highlightedNodeIds.has(node.id)); + }, 100); + + return () => clearTimeout(timer); + }, [canvasLoaded, hasHighlight, highlightedNodeIds]); if (!isOpen) return null; @@ -411,38 +542,38 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch {/* Controls */} -
- - - -
+ + Highlighting {highlightedNodeIds.size} table{highlightedNodeIds.size === 1 ? '' : 's'} used by the selected query + + + + )} {/* Graph Container */} -
+
{loading && (
Loading schema...
diff --git a/app/src/contexts/QueryHighlightContext.tsx b/app/src/contexts/QueryHighlightContext.tsx new file mode 100644 index 00000000..87e4081f --- /dev/null +++ b/app/src/contexts/QueryHighlightContext.tsx @@ -0,0 +1,61 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { useDatabase } from '@/contexts/DatabaseContext'; +import { extractTablesFromSQL } from '@/utils/sqlTables'; + +interface QueryHighlightContextType { + /** Id of the chat message whose SQL query is currently selected. */ + selectedQueryId: string | null; + /** Table names referenced by the selected query (empty when nothing is selected). */ + highlightedTables: string[]; + /** Selects the query, or unselects it when it is already selected. */ + toggleQueryHighlight: (messageId: string, sql: string) => void; + clearQueryHighlight: () => void; +} + +const QueryHighlightContext = createContext(undefined); + +export const QueryHighlightProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { selectedGraph } = useDatabase(); + const [selectedQueryId, setSelectedQueryId] = useState(null); + const [highlightedTables, setHighlightedTables] = useState([]); + + const clearQueryHighlight = useCallback(() => { + setSelectedQueryId(null); + setHighlightedTables([]); + }, []); + + // The highlight refers to tables of the active schema — drop it on switch. + useEffect(() => { + clearQueryHighlight(); + }, [selectedGraph?.id, clearQueryHighlight]); + + const toggleQueryHighlight = useCallback((messageId: string, sql: string) => { + setSelectedQueryId((current) => { + if (current === messageId) { + setHighlightedTables([]); + return null; + } + setHighlightedTables(extractTablesFromSQL(sql)); + return messageId; + }); + }, []); + + const value = useMemo( + () => ({ selectedQueryId, highlightedTables, toggleQueryHighlight, clearQueryHighlight }), + [selectedQueryId, highlightedTables, toggleQueryHighlight, clearQueryHighlight], + ); + + return ( + + {children} + + ); +}; + +export const useQueryHighlight = () => { + const context = useContext(QueryHighlightContext); + if (context === undefined) { + throw new Error('useQueryHighlight must be used within a QueryHighlightProvider'); + } + return context; +}; diff --git a/app/src/pages/Index.tsx b/app/src/pages/Index.tsx index f19059fb..54766fa5 100644 --- a/app/src/pages/Index.tsx +++ b/app/src/pages/Index.tsx @@ -13,6 +13,7 @@ import SchemaViewer from "@/components/schema"; import LoadingSpinner from "@/components/ui/loading-spinner"; import { useAuth } from "@/contexts/AuthContext"; import { useDatabase } from "@/contexts/DatabaseContext"; +import { useQueryHighlight } from "@/contexts/QueryHighlightContext"; import { DatabaseService } from "@/services/database"; import { useToast } from "@/components/ui/use-toast"; import { csrfHeaders } from "@/lib/csrf"; @@ -27,6 +28,7 @@ import { const Index = () => { const { isAuthenticated, isLoading: authLoading, logout, user } = useAuth(); const { selectedGraph, graphs, selectGraph, uploadSchema } = useDatabase(); + const { selectedQueryId } = useQueryHighlight(); const { toast } = useToast(); const [showDatabaseModal, setShowDatabaseModal] = useState(false); const [showLoginModal, setShowLoginModal] = useState(false); @@ -131,6 +133,13 @@ const Index = () => { // No need to fetch rules - we just pass the toggle state to backend + // Reveal the schema panel when a SQL query is selected in the chat + useEffect(() => { + if (selectedQueryId) { + setShowSchemaViewer(true); + } + }, [selectedQueryId]); + // Show login modal when not authenticated after loading completes useEffect(() => { // Only auto-open the login modal once per user/session to avoid locking diff --git a/app/src/utils/sqlTables.ts b/app/src/utils/sqlTables.ts new file mode 100644 index 00000000..e5173be9 --- /dev/null +++ b/app/src/utils/sqlTables.ts @@ -0,0 +1,102 @@ +/** + * Lightweight SQL table extraction. + * + * Pulls the table names referenced by a generated SQL query so the schema + * canvas can highlight the tables (and the relations between them) the query + * actually touches. This is intentionally a heuristic scanner — it does not + * aim to be a full SQL parser. + */ + +// Keywords that may directly follow FROM/JOIN/... and must never be treated +// as a table name or as a table alias. +const RESERVED = new Set([ + 'select', 'from', 'where', 'group', 'order', 'having', 'limit', 'offset', + 'union', 'intersect', 'except', 'join', 'inner', 'left', 'right', 'full', + 'cross', 'outer', 'natural', 'lateral', 'on', 'using', 'as', 'and', 'or', + 'not', 'set', 'values', 'returning', 'with', 'distinct', 'into', 'window', + 'fetch', 'for', 'exists', 'case', 'when', 'then', 'else', 'end', +]); + +// Optionally-qualified identifier: schema."table", `db`.`table`, [dbo].[table] +const IDENTIFIER = + '(?:[A-Za-z_][\\w$]*|"[^"]*"|`[^`]*`|\\[[^\\]]*\\])' + + '(?:\\s*\\.\\s*(?:[A-Za-z_][\\w$]*|"[^"]*"|`[^`]*`|\\[[^\\]]*\\]))*'; + +const unquote = (identifier: string): string => + identifier.replace(/^["`[]/, '').replace(/["`\]]$/, ''); + +/** Keep only the table part of a qualified name (`public.users` → `users`). */ +const tableName = (qualified: string): string => { + const parts = qualified.split('.').map((part) => unquote(part.trim())); + return parts[parts.length - 1] ?? ''; +}; + +/** Names introduced by `WITH AS (...)` — they are not real tables. */ +const collectCteNames = (sql: string): Set => { + const names = new Set(); + const re = /\b([A-Za-z_][\w$]*)\s+AS\s*\(/gi; + let match: RegExpExecArray | null; + while ((match = re.exec(sql)) !== null) { + names.add(match[1].toLowerCase()); + } + return names; +}; + +/** + * Extracts the table names referenced by a SQL statement. + * Returns unique names in the order they appear. + */ +export const extractTablesFromSQL = (sql: string): string[] => { + if (!sql || !sql.trim()) return []; + + // Drop comments and string literals so they cannot produce false matches. + const cleaned = sql + .replace(/--[^\n]*/g, ' ') + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/'(?:''|[^'])*'/g, "''"); + + const cteNames = collectCteNames(cleaned); + const tables: string[] = []; + const seen = new Set(); + + const add = (qualified: string) => { + const name = tableName(qualified); + const key = name.toLowerCase(); + if (!name || RESERVED.has(key) || cteNames.has(key) || seen.has(key)) return; + seen.add(key); + tables.push(name); + }; + + const keywordRe = /\b(from|join|update|into)\b/gi; + const referenceRe = new RegExp(`^\\s*(${IDENTIFIER})`, 'i'); + const aliasRe = /^\s+(?:as\s+)?([A-Za-z_][\w$]*)/i; + + let keyword: RegExpExecArray | null; + while ((keyword = keywordRe.exec(cleaned)) !== null) { + // Only `FROM` accepts a comma-separated list of tables. + const acceptsList = keyword[1].toLowerCase() === 'from'; + let index = keywordRe.lastIndex; + + for (;;) { + const reference = cleaned.slice(index).match(referenceRe); + // A subquery or unexpected token — the inner FROM is picked up separately. + if (!reference) break; + + index += reference[0].length; + add(reference[1]); + + if (!acceptsList) break; + + const alias = cleaned.slice(index).match(aliasRe); + if (alias && !RESERVED.has(alias[1].toLowerCase())) { + index += alias[0].length; + } + + const comma = cleaned.slice(index).match(/^\s*,/); + if (!comma) break; + index += comma[0].length; + } + } + + return tables; +}; From da85eb0b1af7cbcfadee934a060626f5c59fb61f Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Wed, 19 Aug 2026 11:43:05 +0300 Subject: [PATCH 2/7] fix: sync root lockfile with @falkordb/canvas 0.2.7 bump The root package-lock.json embeds the app package via "queryweaver-app": "file:app", so bumping app/package.json also requires regenerating the root lock. Without it, "npm ci" at the repo root fails in the Playwright workflow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 63 +++-------------------------------------------- 1 file changed, 4 insertions(+), 59 deletions(-) diff --git a/package-lock.json b/package-lock.json index b6b670c0..f668ace9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "name": "queryweaver-app", "version": "0.3.2", "dependencies": { - "@falkordb/canvas": "^0.0.45", + "@falkordb/canvas": "^0.2.7", "@hookform/resolvers": "^5.7.1", "@radix-ui/react-accordion": "^1.2.20", "@radix-ui/react-alert-dialog": "^1.1.23", @@ -839,24 +839,6 @@ "undici-types": "~8.3.0" } }, - "app/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "app/node_modules/autoprefixer": { "version": "10.4.27", "dev": true, @@ -1135,14 +1117,6 @@ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, - "app/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "optional": true, - "peer": true - }, "app/node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -1686,9 +1660,9 @@ } }, "node_modules/@falkordb/canvas": { - "version": "0.0.45", - "resolved": "https://registry.npmjs.org/@falkordb/canvas/-/canvas-0.0.45.tgz", - "integrity": "sha512-BIb24rfnaLCxO2UH7L4cNYLikmOTXlVR4I/FIpEG/d9ZEp3F+9U/ugaLYOcYe1pmZzT72YnzWUuvdhNE3zK99A==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@falkordb/canvas/-/canvas-0.2.7.tgz", + "integrity": "sha512-AC13kQ/LDC4klPDKlczfiCIXnHx0Y94uw+S7xQAiD+7AKECw+bA1Xfl1dfIKRUXaNjIlmDzKApJ924OFHNwjtw==", "license": "MIT", "dependencies": { "d3": "^7.9.0", @@ -9704,24 +9678,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause", - "optional": true, - "peer": true - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -10995,17 +10951,6 @@ "redux": "^5.0.0" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/reselect": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", From 188bf16c1acfe58ebc63e544c1a3ce3afba79493 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Wed, 19 Aug 2026 13:19:25 +0300 Subject: [PATCH 3/7] fix: address review feedback on query highlighting - scope CTE detection to the leading WITH clause at paren depth 0 so derived-table aliases are not dropped - keep dots inside quoted identifiers when reducing a qualified name - make schema link keys direction-agnostic and tolerant of object or id endpoints - clear hover/table selection when a query highlight is cleared - sync canvas pin/animation state on pin toggle and layout change, and apply the picked direction before setLayout - match canvas node ids loosely when recentering on zoom - keep the SQL block selectable: move the toggle to an explicit button and ignore clicks that end a text selection - only auto-open the schema panel when a database is connected Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/src/components/chat/ChatMessage.tsx | 33 +++++++---- .../schema/SchemaCanvasControls.tsx | 36 +++++++++--- app/src/components/schema/SchemaViewer.tsx | 32 ++++++++++- app/src/pages/Index.tsx | 7 ++- app/src/utils/sqlTables.ts | 56 ++++++++++++++++--- 5 files changed, 131 insertions(+), 33 deletions(-) diff --git a/app/src/components/chat/ChatMessage.tsx b/app/src/components/chat/ChatMessage.tsx index c860d703..c23116e7 100644 --- a/app/src/components/chat/ChatMessage.tsx +++ b/app/src/components/chat/ChatMessage.tsx @@ -50,6 +50,13 @@ const ChatMessage = ({ type, content, steps, queryData, analysisInfo, confirmati } }; + // Clicking the query toggles the schema highlight, but a click that ends a + // text selection must not steal the selection from the user. + const handleQueryBlockClick = () => { + if (window.getSelection()?.toString()) return; + onToggleQueryHighlight?.(); + }; + if (type === 'confirmation') { const operationType = (confirmationData?.operationType ?? 'UNKNOWN').toUpperCase(); const isHighRisk = ['DELETE', 'DROP', 'TRUNCATE'].includes(operationType); @@ -173,6 +180,18 @@ const ChatMessage = ({ type, content, steps, queryData, analysisInfo, confirmati Shown in schema )} + {isClickable && ( + + )}
{hasSQL && ( @@ -192,20 +211,10 @@ const ChatMessage = ({ type, content, steps, queryData, analysisInfo, confirmati )}
 {
-                        if (event.key === 'Enter' || event.key === ' ') {
-                          event.preventDefault();
-                          onToggleQueryHighlight?.();
-                        }
-                      } : undefined}
+                      onClick={isClickable ? handleQueryBlockClick : undefined}
                       className={`bg-background text-foreground p-3 pr-12 rounded text-sm mb-1 w-fit min-w-full font-mono whitespace-pre-wrap break-words overflow-wrap-anywhere transition-colors ${
-                        isClickable ? 'cursor-pointer hover:ring-1 hover:ring-primary/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary' : ''
+                        isClickable ? 'cursor-pointer hover:ring-1 hover:ring-primary/50' : ''
                       } ${isQueryHighlighted ? 'ring-2 ring-primary bg-primary/5' : ''}`}
                     >
                       {content}
diff --git a/app/src/components/schema/SchemaCanvasControls.tsx b/app/src/components/schema/SchemaCanvasControls.tsx
index 9a80bf89..1395d1c3 100644
--- a/app/src/components/schema/SchemaCanvasControls.tsx
+++ b/app/src/components/schema/SchemaCanvasControls.tsx
@@ -156,7 +156,10 @@ const SchemaCanvasControls = ({
     if (!canvas) return;
 
     if (selectedTableId !== null) {
-      const node = canvas.getGraphData()?.nodes.find((n) => n.id === selectedTableId);
+      // Canvas node ids may be normalised to strings, so compare loosely.
+      const node = canvas
+        .getGraphData()
+        ?.nodes.find((n) => String(n.id) === String(selectedTableId));
 
       if (node) {
         canvas.centerAt(node.x ?? 0, node.y ?? 0, 300);
@@ -178,9 +181,16 @@ const SchemaCanvasControls = ({
     }
   };
 
-  const handleLayoutChange = (value: string) => {
+  const handleLayoutChange = (value: string, directionOverride?: string) => {
     const mode = value as LayoutMode;
-    const dir = mode === 'force' ? '' : directionsRef.current[mode] || getDefaultDirection(mode);
+    const dir =
+      mode === 'force'
+        ? ''
+        : (directionOverride ?? (directionsRef.current[mode] || getDefaultDirection(mode)));
+
+    if (mode !== 'force') {
+      directionsRef.current = { ...directionsRef.current, [mode]: dir };
+    }
 
     // Direction options must be applied before setLayout so the layout engine uses them.
     applyDirection(mode, dir);
@@ -189,7 +199,14 @@ const SchemaCanvasControls = ({
     setLayout(mode);
     setDirection(dir);
     // Tree and radial layouts are deterministic, so the canvas pins their nodes.
-    setPinned(mode !== 'force');
+    const nextPinned = mode !== 'force';
+    setPinned(nextPinned);
+    canvasRef.current?.setPinOnDragEnd(nextPinned);
+
+    if (nextPinned) {
+      setAnimation(false);
+      canvasRef.current?.setAnimation(false);
+    }
   };
 
   const handleDirectionChange = (value: string, targetLayout: LayoutMode) => {
@@ -214,6 +231,7 @@ const SchemaCanvasControls = ({
 
     if (next) {
       setAnimation(false);
+      canvasRef.current?.setAnimation(false);
     }
   };
 
@@ -311,8 +329,9 @@ const SchemaCanvasControls = ({
                   key={d.value}
                   className={`pl-8 relative ${layout === 'tree' && direction === d.value ? 'bg-accent' : ''}`}
                   onSelect={() => {
-                    if (layout !== 'tree') handleLayoutChange('tree');
-                    handleDirectionChange(d.value, 'tree');
+                    // Switching layout already applies the picked direction first.
+                    if (layout !== 'tree') handleLayoutChange('tree', d.value);
+                    else handleDirectionChange(d.value, 'tree');
                   }}
                 >
                   {layout === 'tree' && direction === d.value && (
@@ -340,8 +359,9 @@ const SchemaCanvasControls = ({
                   key={d.value}
                   className={`pl-8 relative ${layout === 'radial' && direction === d.value ? 'bg-accent' : ''}`}
                   onSelect={() => {
-                    if (layout !== 'radial') handleLayoutChange('radial');
-                    handleDirectionChange(d.value, 'radial');
+                    // Switching layout already applies the picked direction first.
+                    if (layout !== 'radial') handleLayoutChange('radial', d.value);
+                    else handleDirectionChange(d.value, 'radial');
                   }}
                 >
                   {layout === 'radial' && direction === d.value && (
diff --git a/app/src/components/schema/SchemaViewer.tsx b/app/src/components/schema/SchemaViewer.tsx
index a6fba9f9..8ecf32f8 100644
--- a/app/src/components/schema/SchemaViewer.tsx
+++ b/app/src/components/schema/SchemaViewer.tsx
@@ -38,6 +38,18 @@ const HIGHLIGHT_COLOR = '#8b5cf6';
 /** Opacity applied to schema elements the selected query does not touch. */
 const DIMMED_OPACITY = 0.25;
 
+/** Link endpoints are ids before the layout runs and node objects afterwards. */
+const endpointId = (endpoint: unknown): string => {
+  if (endpoint && typeof endpoint === 'object' && 'id' in endpoint) {
+    return String((endpoint as { id: unknown }).id);
+  }
+  return String(endpoint);
+};
+
+/** Direction-agnostic key so a relation matches however the canvas orders it. */
+const linkKey = (source: unknown, target: unknown): string =>
+  [endpointId(source), endpointId(target)].sort().join('|');
+
 const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: SchemaViewerProps) => {
   const canvasRef = useRef(null);
   const resizeRef = useRef(null);
@@ -73,7 +85,7 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch
 
     schemaData.links.forEach((link) => {
       if (nodeIds.has(link.source) && nodeIds.has(link.target)) {
-        linkKeys.add(`${link.source}-${link.target}`);
+        linkKeys.add(linkKey(link.source, link.target));
       }
     });
 
@@ -101,7 +113,7 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch
         if (link.source === focusTargetId || link.target === focusTargetId) {
           nodeIds.add(link.source);
           nodeIds.add(link.target);
-          linkKeys.add(`${link.source}-${link.target}`);
+          linkKeys.add(linkKey(link.source, link.target));
         }
       });
     }
@@ -112,6 +124,17 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch
   // A query highlight always dims the rest; hover only dims in focus mode.
   const dimInactive = hasHighlight || (focusMode && emphasisNodeIds.size > 0);
 
+  // Hover/selection emphasis is hidden while a query highlight is active, so
+  // drop it when the highlight clears instead of letting it reappear.
+  const hadHighlightRef = useRef(false);
+  useEffect(() => {
+    if (hadHighlightRef.current && !hasHighlight) {
+      setHoveredNodeId(null);
+      setSelectedTableId(null);
+    }
+    hadHighlightRef.current = hasHighlight;
+  }, [hasHighlight]);
+
   // Search options for the canvas controls.
   const tableOptions = useMemo(() => {
     if (!schemaData) return [];
@@ -437,11 +460,14 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch
     };
 
 
-    const linkKeyOf = (link: GraphLink) => `${link.source?.id}-${link.target?.id}`;
+    const linkKeyOf = (link: GraphLink) => linkKey(link.source, link.target);
 
     canvas.setConfig({
       dimmed: dimInactive,
       dimOpacity: DIMMED_OPACITY,
+      // Nodes are drawn by `nodeCanvasObject`, which replaces the canvas' own
+      // node renderer — so node dimming is applied manually there and this
+      // predicate only keeps the config coherent. Links still dim natively.
       isNodeDimmed: (node: GraphNode) => !emphasisNodeIds.has(node.id),
       isLinkDimmed: (link: GraphLink) => !emphasisLinkKeys.has(linkKeyOf(link)),
       isNodeSelected: (node: GraphNode) =>
diff --git a/app/src/pages/Index.tsx b/app/src/pages/Index.tsx
index 54766fa5..87274c73 100644
--- a/app/src/pages/Index.tsx
+++ b/app/src/pages/Index.tsx
@@ -133,12 +133,13 @@ const Index = () => {
 
   // No need to fetch rules - we just pass the toggle state to backend
 
-  // Reveal the schema panel when a SQL query is selected in the chat
+  // Reveal the schema panel when a SQL query is selected in the chat, but only
+  // when a database is connected — otherwise the panel has nothing to render.
   useEffect(() => {
-    if (selectedQueryId) {
+    if (selectedQueryId && selectedGraph) {
       setShowSchemaViewer(true);
     }
-  }, [selectedQueryId]);
+  }, [selectedQueryId, selectedGraph]);
 
   // Show login modal when not authenticated after loading completes
   useEffect(() => {
diff --git a/app/src/utils/sqlTables.ts b/app/src/utils/sqlTables.ts
index e5173be9..49576c10 100644
--- a/app/src/utils/sqlTables.ts
+++ b/app/src/utils/sqlTables.ts
@@ -25,20 +25,62 @@ const IDENTIFIER =
 const unquote = (identifier: string): string =>
   identifier.replace(/^["`[]/, '').replace(/["`\]]$/, '');
 
+// A single (possibly quoted) part of a qualified name. Quoted forms come first
+// so that a dot inside quotes stays part of the same name.
+const NAME_PART = /"[^"]*"|`[^`]*`|\[[^\]]*\]|[A-Za-z_][\w$]*/g;
+
 /** Keep only the table part of a qualified name (`public.users` → `users`). */
 const tableName = (qualified: string): string => {
-  const parts = qualified.split('.').map((part) => unquote(part.trim()));
-  return parts[parts.length - 1] ?? '';
+  const parts = qualified.match(NAME_PART) ?? [];
+  return unquote(parts[parts.length - 1] ?? '');
 };
 
-/** Names introduced by `WITH  AS (...)` — they are not real tables. */
+// ` [(cols)] AS [[NOT] MATERIALIZED] (` at the start of a CTE entry.
+const CTE_ENTRY =
+  /^\s*([A-Za-z_][\w$]*)(?:\s*\([^)]*\))?\s+as\s+(?:(?:not\s+)?materialized\s+)?\(/i;
+
+/**
+ * Names introduced by `WITH  AS (...)` — they are not real tables.
+ *
+ * Only the leading `WITH` clause is scanned, and only at paren depth 0, so
+ * derived-table aliases such as `JOIN (SELECT ...) AS t (a, b)` are not
+ * mistaken for CTEs.
+ */
 const collectCteNames = (sql: string): Set => {
   const names = new Set();
-  const re = /\b([A-Za-z_][\w$]*)\s+AS\s*\(/gi;
-  let match: RegExpExecArray | null;
-  while ((match = re.exec(sql)) !== null) {
-    names.add(match[1].toLowerCase());
+  const withClause = /^\s*with\s+(?:recursive\s+)?/i.exec(sql);
+  if (!withClause) return names;
+
+  let index = withClause[0].length;
+  let depth = 0;
+
+  while (index < sql.length) {
+    if (depth === 0) {
+      const entry = CTE_ENTRY.exec(sql.slice(index));
+      // Anything else at depth 0 means the CTE list is over.
+      if (!entry) break;
+      names.add(entry[1].toLowerCase());
+      index += entry[0].length;
+      depth = 1;
+      continue;
+    }
+
+    const char = sql[index];
+    if (char === '(') {
+      depth += 1;
+    } else if (char === ')') {
+      depth -= 1;
+      if (depth === 0) {
+        // Another CTE only follows after a comma.
+        const comma = /^\s*,/.exec(sql.slice(index + 1));
+        if (!comma) break;
+        index += 1 + comma[0].length;
+        continue;
+      }
+    }
+    index += 1;
   }
+
   return names;
 };
 

From 3a1c565cd60eea6a34450f3573b17cefc07041b1 Mon Sep 17 00:00:00 2001
From: Anchel135 
Date: Wed, 19 Aug 2026 13:26:41 +0300
Subject: [PATCH 4/7] fix: accept quoted CTE identifiers in SQL table
 extraction

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 app/src/utils/sqlTables.ts | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/app/src/utils/sqlTables.ts b/app/src/utils/sqlTables.ts
index 49576c10..dbd7ed88 100644
--- a/app/src/utils/sqlTables.ts
+++ b/app/src/utils/sqlTables.ts
@@ -37,7 +37,7 @@ const tableName = (qualified: string): string => {
 
 // ` [(cols)] AS [[NOT] MATERIALIZED] (` at the start of a CTE entry.
 const CTE_ENTRY =
-  /^\s*([A-Za-z_][\w$]*)(?:\s*\([^)]*\))?\s+as\s+(?:(?:not\s+)?materialized\s+)?\(/i;
+  /^\s*("[^"]*"|`[^`]*`|\[[^\]]*\]|[A-Za-z_][\w$]*)(?:\s*\([^)]*\))?\s+as\s+(?:(?:not\s+)?materialized\s+)?\(/i;
 
 /**
  * Names introduced by `WITH  AS (...)` — they are not real tables.
@@ -59,7 +59,7 @@ const collectCteNames = (sql: string): Set => {
       const entry = CTE_ENTRY.exec(sql.slice(index));
       // Anything else at depth 0 means the CTE list is over.
       if (!entry) break;
-      names.add(entry[1].toLowerCase());
+      names.add(unquote(entry[1]).toLowerCase());
       index += entry[0].length;
       depth = 1;
       continue;

From 689ef7101fd0d0e9fb3a1d61c2a1a1e0e3805b59 Mon Sep 17 00:00:00 2001
From: Anchel135 
Date: Wed, 19 Aug 2026 13:51:55 +0300
Subject: [PATCH 5/7] fix: correct table extraction and highlight state edge
 cases

- keep CTE names during extraction so a CTE shadowing a real table
  (`WITH orders AS (SELECT * FROM public.orders) SELECT * FROM orders`)
  no longer yields an empty highlight; callers match against the schema,
  so names that are not tables never match
- ignore `FROM` used as an argument separator in EXTRACT/SUBSTRING/TRIM/
  OVERLAY so columns are not reported as tables
- use the direction-agnostic link key in `linkColorFor`, which stopped
  matching after the key format changed
- make `toggleQueryHighlight` a pure state update instead of setting
  state from inside an updater
- clear the highlight when the schema panel is closed
- reset the schema search box when the schema changes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .../schema/SchemaCanvasControls.tsx           |  9 ++-
 app/src/components/schema/SchemaViewer.tsx    |  8 ++-
 app/src/contexts/QueryHighlightContext.tsx    | 17 +++---
 app/src/pages/Index.tsx                       | 19 ++++--
 app/src/utils/sqlTables.ts                    | 59 +++++++------------
 5 files changed, 58 insertions(+), 54 deletions(-)

diff --git a/app/src/components/schema/SchemaCanvasControls.tsx b/app/src/components/schema/SchemaCanvasControls.tsx
index 1395d1c3..3ff47d9c 100644
--- a/app/src/components/schema/SchemaCanvasControls.tsx
+++ b/app/src/components/schema/SchemaCanvasControls.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import type { KeyboardEvent, RefObject } from 'react';
 import type {
   FalkorDBCanvas,
@@ -98,6 +98,13 @@ const SchemaCanvasControls = ({
   // Remembers the last direction picked per layout so switching back restores it.
   const directionsRef = useRef>({ tree: 'td', radial: 'out' });
 
+  // A new schema invalidates the search term and its suggestions.
+  useEffect(() => {
+    setSearch('');
+    setSuggestionsOpen(false);
+    setActiveSuggestion(0);
+  }, [tables]);
+
   const suggestions = useMemo(() => {
     const term = search.trim().toLowerCase();
 
diff --git a/app/src/components/schema/SchemaViewer.tsx b/app/src/components/schema/SchemaViewer.tsx
index 8ecf32f8..1bc1715b 100644
--- a/app/src/components/schema/SchemaViewer.tsx
+++ b/app/src/components/schema/SchemaViewer.tsx
@@ -50,6 +50,10 @@ const endpointId = (endpoint: unknown): string => {
 const linkKey = (source: unknown, target: unknown): string =>
   [endpointId(source), endpointId(target)].sort().join('|');
 
+// Must stay above the canvas' `interaction.zoomToFitDelay` (50ms default) so the
+// highlight framing is applied after the canvas' own initial fit.
+const HIGHLIGHT_ZOOM_DELAY_MS = 100;
+
 const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: SchemaViewerProps) => {
   const canvasRef = useRef(null);
   const resizeRef = useRef(null);
@@ -282,7 +286,7 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch
   };
 
   const linkColorFor = useCallback((sourceId: number, targetId: number) => {
-    if (hasHighlight && highlightedLinkKeys.has(`${sourceId}-${targetId}`)) return HIGHLIGHT_COLOR;
+    if (hasHighlight && highlightedLinkKeys.has(linkKey(sourceId, targetId))) return HIGHLIGHT_COLOR;
     return theme === 'light' ? '#9ca3af' : '#4b5563';
   }, [theme, hasHighlight, highlightedLinkKeys]);
 
@@ -524,7 +528,7 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch
 
     const timer = setTimeout(() => {
       canvas.zoomToFit(1.5, (node: GraphNode) => highlightedNodeIds.has(node.id));
-    }, 100);
+    }, HIGHLIGHT_ZOOM_DELAY_MS);
 
     return () => clearTimeout(timer);
   }, [canvasLoaded, hasHighlight, highlightedNodeIds]);
diff --git a/app/src/contexts/QueryHighlightContext.tsx b/app/src/contexts/QueryHighlightContext.tsx
index 87e4081f..ded5716e 100644
--- a/app/src/contexts/QueryHighlightContext.tsx
+++ b/app/src/contexts/QueryHighlightContext.tsx
@@ -30,15 +30,14 @@ export const QueryHighlightProvider: React.FC<{ children: React.ReactNode }> = (
   }, [selectedGraph?.id, clearQueryHighlight]);
 
   const toggleQueryHighlight = useCallback((messageId: string, sql: string) => {
-    setSelectedQueryId((current) => {
-      if (current === messageId) {
-        setHighlightedTables([]);
-        return null;
-      }
-      setHighlightedTables(extractTablesFromSQL(sql));
-      return messageId;
-    });
-  }, []);
+    if (selectedQueryId === messageId) {
+      clearQueryHighlight();
+      return;
+    }
+
+    setSelectedQueryId(messageId);
+    setHighlightedTables(extractTablesFromSQL(sql));
+  }, [selectedQueryId, clearQueryHighlight]);
 
   const value = useMemo(
     () => ({ selectedQueryId, highlightedTables, toggleQueryHighlight, clearQueryHighlight }),
diff --git a/app/src/pages/Index.tsx b/app/src/pages/Index.tsx
index 87274c73..7b64bd7b 100644
--- a/app/src/pages/Index.tsx
+++ b/app/src/pages/Index.tsx
@@ -1,4 +1,4 @@
-import { useState, useRef, useEffect } from "react";
+import { useState, useRef, useEffect, useCallback } from "react";
 import { Button } from "@/components/ui/button";
 import { Badge } from "@/components/ui/badge";
 import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
@@ -28,7 +28,7 @@ import {
 const Index = () => {
   const { isAuthenticated, isLoading: authLoading, logout, user } = useAuth();
   const { selectedGraph, graphs, selectGraph, uploadSchema } = useDatabase();
-  const { selectedQueryId } = useQueryHighlight();
+  const { selectedQueryId, clearQueryHighlight } = useQueryHighlight();
   const { toast } = useToast();
   const [showDatabaseModal, setShowDatabaseModal] = useState(false);
   const [showLoginModal, setShowLoginModal] = useState(false);
@@ -141,6 +141,13 @@ const Index = () => {
     }
   }, [selectedQueryId, selectedGraph]);
 
+  // Closing the panel also drops the highlight, so the chat toggle does not stay
+  // marked as active while nothing is highlighted.
+  const closeSchemaViewer = useCallback(() => {
+    setShowSchemaViewer(false);
+    clearQueryHighlight();
+  }, [clearQueryHighlight]);
+
   // Show login modal when not authenticated after loading completes
   useEffect(() => {
     // Only auto-open the login modal once per user/session to avoid locking
@@ -354,7 +361,11 @@ const Index = () => {
       
       {/* Left Sidebar */}
        { if (!isRefreshingSchema) setShowSchemaViewer(!showSchemaViewer); }}
+        onSchemaClick={() => {
+          if (isRefreshingSchema) return;
+          if (showSchemaViewer) closeSchemaViewer();
+          else setShowSchemaViewer(true);
+        }}
         isSchemaOpen={showSchemaViewer}
         isCollapsed={sidebarCollapsed}
         onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
@@ -363,7 +374,7 @@ const Index = () => {
       {/* Schema Viewer */}
        setShowSchemaViewer(false)}
+        onClose={closeSchemaViewer}
         onWidthChange={setSchemaViewerWidth}
         sidebarWidth={sidebarWidth}
       />
diff --git a/app/src/utils/sqlTables.ts b/app/src/utils/sqlTables.ts
index dbd7ed88..03c0806b 100644
--- a/app/src/utils/sqlTables.ts
+++ b/app/src/utils/sqlTables.ts
@@ -35,53 +35,32 @@ const tableName = (qualified: string): string => {
   return unquote(parts[parts.length - 1] ?? '');
 };
 
-// ` [(cols)] AS [[NOT] MATERIALIZED] (` at the start of a CTE entry.
-const CTE_ENTRY =
-  /^\s*("[^"]*"|`[^`]*`|\[[^\]]*\]|[A-Za-z_][\w$]*)(?:\s*\([^)]*\))?\s+as\s+(?:(?:not\s+)?materialized\s+)?\(/i;
+// Functions that use FROM as an argument separator rather than a table clause.
+const FROM_ARG_FUNCTIONS = new Set(['extract', 'substring', 'trim', 'overlay']);
 
 /**
- * Names introduced by `WITH  AS (...)` — they are not real tables.
- *
- * Only the leading `WITH` clause is scanned, and only at paren depth 0, so
- * derived-table aliases such as `JOIN (SELECT ...) AS t (a, b)` are not
- * mistaken for CTEs.
+ * True when the `FROM` at `index` belongs to a call such as
+ * `EXTRACT(YEAR FROM ts)` or `TRIM(BOTH ' ' FROM name)`, where what follows is
+ * a column rather than a table.
  */
-const collectCteNames = (sql: string): Set => {
-  const names = new Set();
-  const withClause = /^\s*with\s+(?:recursive\s+)?/i.exec(sql);
-  if (!withClause) return names;
-
-  let index = withClause[0].length;
+const isFunctionArgumentFrom = (sql: string, index: number): boolean => {
   let depth = 0;
 
-  while (index < sql.length) {
-    if (depth === 0) {
-      const entry = CTE_ENTRY.exec(sql.slice(index));
-      // Anything else at depth 0 means the CTE list is over.
-      if (!entry) break;
-      names.add(unquote(entry[1]).toLowerCase());
-      index += entry[0].length;
-      depth = 1;
-      continue;
-    }
-
-    const char = sql[index];
-    if (char === '(') {
+  // Walk back to the innermost unclosed `(` and look at the name before it.
+  for (let i = index - 1; i >= 0; i -= 1) {
+    const char = sql[i];
+    if (char === ')') {
       depth += 1;
-    } else if (char === ')') {
-      depth -= 1;
+    } else if (char === '(') {
       if (depth === 0) {
-        // Another CTE only follows after a comma.
-        const comma = /^\s*,/.exec(sql.slice(index + 1));
-        if (!comma) break;
-        index += 1 + comma[0].length;
-        continue;
+        const callee = /([A-Za-z_][\w$]*)\s*$/.exec(sql.slice(0, i));
+        return !!callee && FROM_ARG_FUNCTIONS.has(callee[1].toLowerCase());
       }
+      depth -= 1;
     }
-    index += 1;
   }
 
-  return names;
+  return false;
 };
 
 /**
@@ -97,14 +76,17 @@ export const extractTablesFromSQL = (sql: string): string[] => {
     .replace(/\/\*[\s\S]*?\*\//g, ' ')
     .replace(/'(?:''|[^'])*'/g, "''");
 
-  const cteNames = collectCteNames(cleaned);
   const tables: string[] = [];
   const seen = new Set();
 
+  // CTE names are deliberately kept: a CTE may shadow a real table
+  // (`WITH orders AS (SELECT * FROM public.orders) SELECT * FROM orders`), and
+  // callers match the result against the schema, so a name that is not a table
+  // simply never matches.
   const add = (qualified: string) => {
     const name = tableName(qualified);
     const key = name.toLowerCase();
-    if (!name || RESERVED.has(key) || cteNames.has(key) || seen.has(key)) return;
+    if (!name || RESERVED.has(key) || seen.has(key)) return;
     seen.add(key);
     tables.push(name);
   };
@@ -117,6 +99,7 @@ export const extractTablesFromSQL = (sql: string): string[] => {
   while ((keyword = keywordRe.exec(cleaned)) !== null) {
     // Only `FROM` accepts a comma-separated list of tables.
     const acceptsList = keyword[1].toLowerCase() === 'from';
+    if (acceptsList && isFunctionArgumentFrom(cleaned, keyword.index)) continue;
     let index = keywordRe.lastIndex;
 
     for (;;) {

From daaa66f86141af5f8c6b7d07f938f40441b13661 Mon Sep 17 00:00:00 2001
From: Anchel135 
Date: Wed, 19 Aug 2026 14:02:12 +0300
Subject: [PATCH 6/7] fix: ignore parens inside quoted identifiers when
 scanning for FROM

The backward scan in `isFunctionArgumentFrom` counted parentheses inside
double-quoted, backtick-quoted and bracket-quoted identifiers, so
`TRIM(BOTH "x)" FROM name)` was not recognised as a function argument and
reported `name` as a table. The scan now runs on an offset-preserving copy
with quoted bodies blanked; extraction still reads the original text.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 app/src/utils/sqlTables.ts | 15 +++++++++++++--
 1 file changed, 13 insertions(+), 2 deletions(-)

diff --git a/app/src/utils/sqlTables.ts b/app/src/utils/sqlTables.ts
index 03c0806b..87c16589 100644
--- a/app/src/utils/sqlTables.ts
+++ b/app/src/utils/sqlTables.ts
@@ -38,10 +38,17 @@ const tableName = (qualified: string): string => {
 // Functions that use FROM as an argument separator rather than a table clause.
 const FROM_ARG_FUNCTIONS = new Set(['extract', 'substring', 'trim', 'overlay']);
 
+/**
+ * Blanks the body of quoted identifiers, keeping offsets intact, so a scan can
+ * ignore punctuation inside them (`TRIM(BOTH "x)" FROM name)`).
+ */
+const maskQuoted = (sql: string): string =>
+  sql.replace(/"[^"]*"|`[^`]*`|\[[^\]]*\]/g, (span) => ' '.repeat(span.length));
+
 /**
  * True when the `FROM` at `index` belongs to a call such as
  * `EXTRACT(YEAR FROM ts)` or `TRIM(BOTH ' ' FROM name)`, where what follows is
- * a column rather than a table.
+ * a column rather than a table. `sql` must be quote-masked.
  */
 const isFunctionArgumentFrom = (sql: string, index: number): boolean => {
   let depth = 0;
@@ -76,6 +83,10 @@ export const extractTablesFromSQL = (sql: string): string[] => {
     .replace(/\/\*[\s\S]*?\*\//g, ' ')
     .replace(/'(?:''|[^'])*'/g, "''");
 
+  // Offset-preserving copy used for the parenthesis scan only; the extraction
+  // below still reads `cleaned` so quoted names stay intact.
+  const masked = maskQuoted(cleaned);
+
   const tables: string[] = [];
   const seen = new Set();
 
@@ -99,7 +110,7 @@ export const extractTablesFromSQL = (sql: string): string[] => {
   while ((keyword = keywordRe.exec(cleaned)) !== null) {
     // Only `FROM` accepts a comma-separated list of tables.
     const acceptsList = keyword[1].toLowerCase() === 'from';
-    if (acceptsList && isFunctionArgumentFrom(cleaned, keyword.index)) continue;
+    if (acceptsList && isFunctionArgumentFrom(masked, keyword.index)) continue;
     let index = keywordRe.lastIndex;
 
     for (;;) {

From 8c83623f6703cf877d8b681e0320d637418afebd Mon Sep 17 00:00:00 2001
From: Anchel135 
Date: Wed, 19 Aug 2026 14:35:22 +0300
Subject: [PATCH 7/7] fix: scan SQL clause keywords on the quote-masked
 statement

`keywordRe` ran over `cleaned`, so a quoted identifier containing a clause
keyword was read as a clause: `SELECT "from users" FROM orders` returned
`["users", "orders"]`. The keyword scan now runs on the offset-preserving
masked copy while identifier extraction still reads `cleaned`, so quoted
table names such as `FROM "from users"` survive intact.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 app/src/utils/sqlTables.ts | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/app/src/utils/sqlTables.ts b/app/src/utils/sqlTables.ts
index 87c16589..9cb140ae 100644
--- a/app/src/utils/sqlTables.ts
+++ b/app/src/utils/sqlTables.ts
@@ -83,8 +83,9 @@ export const extractTablesFromSQL = (sql: string): string[] => {
     .replace(/\/\*[\s\S]*?\*\//g, ' ')
     .replace(/'(?:''|[^'])*'/g, "''");
 
-  // Offset-preserving copy used for the parenthesis scan only; the extraction
-  // below still reads `cleaned` so quoted names stay intact.
+  // Offset-preserving copy used for the keyword and parenthesis scans, so a
+  // quoted identifier such as `"from users"` cannot look like a clause. The
+  // extraction below still reads `cleaned` so quoted names stay intact.
   const masked = maskQuoted(cleaned);
 
   const tables: string[] = [];
@@ -107,7 +108,7 @@ export const extractTablesFromSQL = (sql: string): string[] => {
   const aliasRe = /^\s+(?:as\s+)?([A-Za-z_][\w$]*)/i;
 
   let keyword: RegExpExecArray | null;
-  while ((keyword = keywordRe.exec(cleaned)) !== null) {
+  while ((keyword = keywordRe.exec(masked)) !== null) {
     // Only `FROM` accepts a comma-separated list of tables.
     const acceptsList = keyword[1].toLowerCase() === 'from';
     if (acceptsList && isFunctionArgumentFrom(masked, keyword.index)) continue;