diff --git a/.claude/todo-unified-query-exploration.md b/.claude/todo-unified-query-exploration.md new file mode 100644 index 000000000..fac443759 --- /dev/null +++ b/.claude/todo-unified-query-exploration.md @@ -0,0 +1,79 @@ +# Unified Query Exploration System + +## Vision + +The saved query is the universal unit of work across all explorer views. A query is an ordered list of Cypher statements with additive/subtractive operators, built interactively or by hand, that flows through every explorer identically. + +### Core Concept + +``` +{ name: string, statements: { op: '+' | '-', cypher: string }[] } +``` + +Each statement represents one intentional action — a discrete thought in an exploration sequence. The order mirrors how the user actually explored. Replay executes them in order, merging (+) or removing (-) each result. + +### Entry Points (all produce the same saved artifact) + +- **Smart Search** — interactive clicks, add-to-existing, add-adjacent → generates Cypher per action +- **Block Builder** — visual composition → compiles to Cypher +- **Cypher Editor** — hand-written or pasted from a friend +- **Saved query recall** — load a previously saved exploration + +### Explorer Views (all consume the same saved query) + +| Explorer | What it shows | +|----------|---------------| +| 2D Graph | Force-directed visualization, primary interactive builder | +| 3D Graph | Same graph, spatial perspective | +| Cypher Editor | The statements as editable text, copy/pasteable | +| Vocabulary Analysis | Relationship type introspection on the query's result set | +| Document Explorer | Source documents contributing to the query's concepts | +| Polarity Explorer | Pick edges from the query's graph for polarity axis analysis | + +### Sidebar Consistency + +Folder icon in the nav rail across all explorer views. Same saved queries list, different lens on the data. Switching views preserves the graph. + +## Implementation Phases + +### Phase 1: Exploration Tracking & Cypher Generation ✓ +- [x] Add `ExplorationStep` type and `explorationSession` to graphStore +- [x] Add `addExplorationStep()`, `clearExploration()` store actions +- [x] Record steps at action points: handleLoadExplore, handleLoadPath, handleFollowConcept, handleAddToGraph +- [x] Create `cypherGenerator.ts` — convert steps to ordered Cypher statements with +/- operators +- [x] Persist `rawGraphData` + `explorationSession` to localStorage (survive refresh) +- [x] Add `subtractRawGraphData` and "Remove from Graph" context menu (op: '-') + +### Phase 2: Saved Queries Folder ✓ +- [x] Unify sidebar folder icon across all explorer views (FolderOpen, consistent with report explorer) +- [x] Saved query data model: `{ name, statements: { op, cypher }[] }` via QueryDefinition +- [x] Save exploration → creates QueryDefinition with `definition_type: 'exploration'` +- [x] Load saved query → replays statements in order with +/- semantics via executeCypherQuery +- [x] Delete saved query (already worked via queryDefinitionStore) + +### Phase 3: Editor Integration +- [ ] "Export as Cypher" sends ordered statements to the Cypher editor +- [ ] Cypher editor displays +/- prefixed statements +- [ ] Execute from editor replays the statement sequence +- [x] Subtractive operator: context menu "Remove from Graph" option + +### Phase 4: Cross-Explorer Flow +- [ ] Vocabulary explorer reads same saved queries from folder +- [ ] Document explorer reads same saved queries +- [ ] Polarity explorer loads graph from saved query +- [ ] Verify all explorers share the same folder state + +### Phase 5: Documentation & Docstrings +- [ ] Add JSDoc docstrings to new exploration tracking code +- [ ] Add JSDoc docstrings to graphStore (actions, types) +- [ ] Add JSDoc docstrings to cypherGenerator +- [ ] Add JSDoc docstrings to SearchBar handlers +- [ ] Add JSDoc docstrings to useGraphContextMenu handlers +- [ ] Document the unified query exploration workflow in user manual +- [ ] Document the +/- operator algebra concept + +## Notes + +- Block builder compiles TO Cypher but we don't decompose Cypher back to blocks (existing ADR) +- Graph accelerator makes this practical — path finding is now fast enough for interactive multi-step exploration +- The +/- algebra on statements is like set operations: union then difference, letting users sculpt their graph diff --git a/api/app/models/query_definition.py b/api/app/models/query_definition.py index 56901aaef..2875b373b 100644 --- a/api/app/models/query_definition.py +++ b/api/app/models/query_definition.py @@ -15,10 +15,11 @@ 'cypher', 'search', 'polarity', - 'connection' + 'connection', + 'exploration' ] -DefinitionType = Literal['block_diagram', 'cypher', 'search', 'polarity', 'connection'] +DefinitionType = Literal['block_diagram', 'cypher', 'search', 'polarity', 'connection', 'exploration'] class QueryDefinitionCreate(BaseModel): diff --git a/api/app/routes/queries.py b/api/app/routes/queries.py index a8a3a8030..96da5a413 100644 --- a/api/app/routes/queries.py +++ b/api/app/routes/queries.py @@ -1643,8 +1643,18 @@ async def execute_cypher_query( # Extract nodes and relationships from each record for key, value in record.items(): if isinstance(value, dict): - # Check if it's a node (has 'id' and typically 'label') - if 'id' in value: + # Check for relationship first (AGE edges have start_id/end_id) + start_id = value.get('start_id') or value.get('start') + end_id = value.get('end_id') or value.get('end') + + if start_id and end_id: + relationships.append(CypherRelationship( + from_id=str(start_id), + to_id=str(end_id), + type=value.get('label', value.get('type', 'RELATED')), + properties=value.get('properties', {}) + )) + elif 'id' in value: node_id = str(value['id']) if node_id not in nodes_map: # Prefer properties.label (actual name) over AGE label (node type like "Concept") diff --git a/schema/migrations/050_exploration_definition_type.sql b/schema/migrations/050_exploration_definition_type.sql new file mode 100644 index 000000000..5fb476beb --- /dev/null +++ b/schema/migrations/050_exploration_definition_type.sql @@ -0,0 +1,41 @@ +-- Migration 050: Add 'exploration' to query_definitions definition_type +-- +-- Supports saving graph explorations as ordered +/- Cypher statement sequences. +-- Each exploration is a replayable series of additive/subtractive graph operations. +-- =========================================================================== + +-- Skip if already applied +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM public.schema_migrations WHERE version = 50) THEN + RAISE NOTICE 'Migration 050 already applied, skipping'; + RETURN; + END IF; + + -- Drop and recreate CHECK constraint to include 'exploration' + ALTER TABLE kg_api.query_definitions + DROP CONSTRAINT IF EXISTS valid_definition_type; + + ALTER TABLE kg_api.query_definitions + ADD CONSTRAINT valid_definition_type CHECK (definition_type IN ( + 'block_diagram', + 'cypher', + 'search', + 'polarity', + 'connection', + 'exploration' + )); + + COMMENT ON COLUMN kg_api.query_definitions.definition_type IS + 'Type of query: block_diagram, cypher, search, polarity, connection, exploration'; + + RAISE NOTICE 'Migration 050: Added exploration definition type'; +END $$; + +-- =========================================================================== +-- Record Migration +-- =========================================================================== + +INSERT INTO public.schema_migrations (version, name) +VALUES (50, 'exploration_definition_type') +ON CONFLICT (version) DO NOTHING; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ce78f1953..b8cdfd512 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -98,7 +98,33 @@ class APIClient { }).then(r => r.data).catch(() => null) ); - const allConceptDetails = (await Promise.all(conceptDetailsPromises)).filter(Boolean); + let allConceptDetails = (await Promise.all(conceptDetailsPromises)).filter(Boolean); + + // Step 3b: Discover relationship targets missing from our set and fetch them. + // The /query/related traversal can miss neighbors (stale accelerator, etc.), + // but concept details include the actual relationships. Hydrate any targets + // we don't already have so the subgraph is complete. + const fetchedIds = new Set(allConceptIds); + const missingIds: string[] = []; + allConceptDetails.forEach((concept: any) => { + (concept.relationships || []).forEach((rel: any) => { + if (rel.to_id && !fetchedIds.has(rel.to_id)) { + fetchedIds.add(rel.to_id); + missingIds.push(rel.to_id); + } + }); + }); + + if (missingIds.length > 0) { + const extraDetails = (await Promise.all( + missingIds.map(id => + this.client.get(`/query/concept/${id}`, { + params: { include_grounding: false } + }).then(r => r.data).catch(() => null) + ) + )).filter(Boolean); + allConceptDetails = [...allConceptDetails, ...extraDetails]; + } // Step 4: Build nodes array (with grounding strength) const nodes = allConceptDetails.map((concept: any) => ({ @@ -111,7 +137,7 @@ class APIClient { // Step 5: Build links array from ALL concepts' relationships // Only include links where both source and target are in our node set - const nodeIdSet = new Set(allConceptIds); + const nodeIdSet = new Set(allConceptDetails.map((c: any) => c.concept_id)); const links: any[] = []; const seenEdges = new Set(); // Deduplicate edges @@ -120,7 +146,9 @@ class APIClient { concept.relationships.forEach((rel: any) => { // Only include if target is in our subgraph if (nodeIdSet.has(rel.to_id)) { - const edgeKey = `${concept.concept_id}->${rel.to_id}-${rel.rel_type}`; + // Deduplicate: normalize edge key to treat A→B and B→A same-type as one edge + const [lo, hi] = [concept.concept_id, rel.to_id].sort(); + const edgeKey = `${lo}<>${hi}-${rel.rel_type}`; if (!seenEdges.has(edgeKey)) { seenEdges.add(edgeKey); links.push({ diff --git a/web/src/components/report/ReportWorkspace.tsx b/web/src/components/report/ReportWorkspace.tsx index 2953400eb..6fdea1603 100644 --- a/web/src/components/report/ReportWorkspace.tsx +++ b/web/src/components/report/ReportWorkspace.tsx @@ -27,6 +27,7 @@ import { Save, Eye, X, + Route, } from 'lucide-react'; import * as d3 from 'd3'; import { IconRailPanel } from '../shared/IconRailPanel'; @@ -39,6 +40,7 @@ import { type GraphReportData, type PolarityReportData, type DocumentReportData, + type TraversalReportData, type PreviousValues, } from '../../store/reportStore'; @@ -269,6 +271,36 @@ const documentToCSV = (data: DocumentReportData): string => { return lines.join('\n'); }; +// Convert traversal report to CSV +const traversalToCSV = (data: TraversalReportData): string => { + const lines: string[] = []; + + lines.push(`# Traversal`); + lines.push(`Origin,${data.origin.label}`); + lines.push(`Destination,${data.destination.label}`); + lines.push(`Max Hops,${data.maxHops}`); + lines.push(`Paths Found,${data.pathCount}`); + + data.paths.forEach((path, i) => { + lines.push(''); + lines.push(`# Path ${i + 1} (${path.hops} hops)`); + lines.push('Step,Label,Description,Grounding,Diversity,Relationship'); + path.nodes.forEach((n, j) => { + const relAfter = j < path.relationships.length ? path.relationships[j] : ''; + lines.push([ + j + 1, + `"${(n.label || '').replace(/"/g, '""')}"`, + `"${(n.description || '').replace(/"/g, '""')}"`, + n.grounding_strength?.toFixed(3) || '', + n.diversity_score?.toFixed(3) || '', + relAfter, + ].join(',')); + }); + }); + + return lines.join('\n'); +}; + // Convert to Markdown const toMarkdown = (report: Report): string => { const lines: string[] = []; @@ -319,6 +351,25 @@ const toMarkdown = (report: Report): string => { data.concepts.forEach(c => { lines.push(`| ${c.label} | ${c.position.toFixed(3)} | ${c.grounding_strength?.toFixed(2) || '-'} |`); }); + } else if (report.type === 'traversal') { + const data = report.data as TraversalReportData; + + lines.push('## Traversal'); + lines.push(`- **Origin:** ${data.origin.label}`); + lines.push(`- **Destination:** ${data.destination.label}`); + lines.push(`- **Paths Found:** ${data.pathCount}`); + + data.paths.forEach((path, i) => { + lines.push(''); + lines.push(`### Path ${i + 1} (${path.hops} hops)`); + lines.push(''); + path.nodes.forEach((n, j) => { + lines.push(`${j + 1}. **${n.label}**${n.grounding_strength != null ? ` (grounding: ${n.grounding_strength.toFixed(2)})` : ''}`); + if (j < path.relationships.length) { + lines.push(` *${path.relationships[j]}* ↓`); + } + }); + }); } else { const data = report.data as DocumentReportData; @@ -651,6 +702,7 @@ export const ReportWorkspace: React.FC = () => { const getReportIcon = (report: Report) => { if (report.type === 'polarity') return Compass; if (report.type === 'document') return FileText; + if (report.type === 'traversal') return Route; return GitBranch; }; @@ -678,6 +730,8 @@ export const ReportWorkspace: React.FC = () => { ? (report.data as GraphReportData).nodes.length : report.type === 'polarity' ? (report.data as PolarityReportData).concepts.length + : report.type === 'traversal' + ? (report.data as TraversalReportData).pathCount : (report.data as DocumentReportData).documents.length; return ( @@ -1149,6 +1203,106 @@ export const ReportWorkspace: React.FC = () => { ); }; + // Render table for traversal report + const renderTraversalTable = (data: TraversalReportData) => { + return ( +
+ {/* Origin → Destination header */} +
+
+
Origin
+
{data.origin.label}
+
+
+
+
Paths
+
{data.pathCount}
+
+
+
+
Destination
+
{data.destination.label}
+
+
+ + {/* Paths */} + {data.paths.map((path, pathIdx) => ( +
+

+ + Path {pathIdx + 1} ({path.hops} hops) +

+
+
+ + + + + + + + + + + + + {path.nodes.map((node, nodeIdx) => ( + + + + + + + + + ))} + +
#ConceptDescriptionGroundingDiversityRelationship
+ {nodeIdx + 1} + + {node.label} + + {node.description || '-'} + + {node.grounding_strength != null ? ( + = 0.7 + ? 'bg-green-500/20 text-green-600' + : node.grounding_strength >= 0.3 + ? 'bg-yellow-500/20 text-yellow-600' + : 'bg-red-500/20 text-red-600' + }`}> + {(node.grounding_strength * 100).toFixed(0)}% + + ) : '-'} + + {node.diversity_score?.toFixed(2) || '-'} + + {nodeIdx < path.relationships.length ? ( + + {path.relationships[nodeIdx]} ↓ + + ) : null} +
+
+
+
+ ))} + + {data.pathCount === 0 && ( +
+ No paths found between origin and destination. +
+ )} +
+ ); + }; + // Render table for document report const renderDocumentTable = (data: DocumentReportData) => { // Get all unique ontologies for color calculation @@ -1335,6 +1489,8 @@ export const ReportWorkspace: React.FC = () => { ? graphToCSV(selectedReport.data as GraphReportData) : selectedReport.type === 'polarity' ? polarityToCSV(selectedReport.data as PolarityReportData) + : selectedReport.type === 'traversal' + ? traversalToCSV(selectedReport.data as TraversalReportData) : documentToCSV(selectedReport.data as DocumentReportData); copyToClipboard(csv, 'csv', setCopiedFormat); }} @@ -1381,6 +1537,8 @@ export const ReportWorkspace: React.FC = () => { ? renderGraphTable(selectedReport.data as GraphReportData, selectedReport) : selectedReport.type === 'polarity' ? renderPolarityTable(selectedReport.data as PolarityReportData) + : selectedReport.type === 'traversal' + ? renderTraversalTable(selectedReport.data as TraversalReportData) : renderDocumentTable(selectedReport.data as DocumentReportData) ) : (
diff --git a/web/src/components/shared/IconRailPanel.tsx b/web/src/components/shared/IconRailPanel.tsx index 187c7439f..1e18cf76c 100644 --- a/web/src/components/shared/IconRailPanel.tsx +++ b/web/src/components/shared/IconRailPanel.tsx @@ -15,6 +15,13 @@ interface PanelTab { content: React.ReactNode; } +interface RailAction { + id: string; + icon: React.ComponentType<{ className?: string }>; + label: string; + onClick: () => void; +} + interface IconRailPanelProps { tabs: PanelTab[]; activeTab: string; @@ -22,6 +29,8 @@ interface IconRailPanelProps { defaultExpanded?: boolean; expandedWidth?: string; position?: 'left' | 'right'; + /** Standalone action buttons rendered above tabs in the icon rail */ + actions?: RailAction[]; } export const IconRailPanel: React.FC = ({ @@ -31,6 +40,7 @@ export const IconRailPanel: React.FC = ({ defaultExpanded = false, expandedWidth = 'w-72', position = 'left', + actions = [], }) => { const [isExpanded, setIsExpanded] = useState(defaultExpanded); @@ -52,6 +62,22 @@ export const IconRailPanel: React.FC = ({
{/* Icon Rail - always visible */}
+ {actions.map((action) => { + const Icon = action.icon; + return ( + + ); + })} + {actions.length > 0 && ( +
+ )} {tabs.map((tab) => { const Icon = tab.icon; const isActive = activeTab === tab.id && isExpanded; diff --git a/web/src/components/shared/SearchBar.tsx b/web/src/components/shared/SearchBar.tsx index 22730f8fe..ba1ecccaf 100644 --- a/web/src/components/shared/SearchBar.tsx +++ b/web/src/components/shared/SearchBar.tsx @@ -1,19 +1,20 @@ /** - * Multi-Mode Smart Search Component + * Multi-Mode Search Component * * Top-level: Query mode selection via radio dial - * - Smart Search (with sub-modes: Concept/Neighborhood/Path) - * - Block Builder (future) - * - openCypher Editor (future) + * - Smart Search (unified progressive interface) + * - Block Builder + * - openCypher Editor * - * Within Smart Search: - * - Concept: Semantic search for individual concepts (IMPLEMENTED) - * - Neighborhood: Explore concepts within N hops (IMPLEMENTED) - * - Path: Find paths connecting two concepts (IMPLEMENTED) + * Smart Search is a single progressive flow: + * 1. Search for a concept → select it + * 2. Depth slider appears (1 = immediate, >1 = neighborhood) + * 3. Optional: add destination concept for path finding + * 4. Path mode: max hops + find paths + select + load */ -import React, { useState } from 'react'; -import { Search, Network, GitBranch, Blocks, Code, ChevronDown, ChevronRight } from 'lucide-react'; +import React, { useState, useEffect } from 'react'; +import { Search, GitBranch, Blocks, Code, ChevronDown, ChevronRight, Plus, X } from 'lucide-react'; import { LoadingSpinner } from './LoadingSpinner'; import { useSearchConcepts } from '../../hooks/useGraphData'; import { useDebouncedValue } from '../../hooks/useDebouncedValue'; @@ -21,90 +22,52 @@ import { useGraphStore } from '../../store/graphStore'; import { ModeDial } from './ModeDial'; import { apiClient } from '../../api/client'; import { BlockBuilder } from '../blocks/BlockBuilder'; -import { SearchResultsDropdown } from './SearchResultsDropdown'; -import { getZIndexClass } from '../../config/zIndex'; - -type SmartSearchSubMode = 'concept' | 'neighborhood' | 'path'; +import { stepToCypher, parseCypherStatements } from '../../utils/cypherGenerator'; +import { mapCypherResultToRawGraph, extractGraphFromPath } from '../../utils/cypherResultMapper'; +import type { PathResult } from '../../utils/cypherResultMapper'; +import { + ConceptSearchInput, + SliderControl, + LoadButtons, + PathResults, +} from './search'; export const SearchBar: React.FC = () => { - // Top-level mode (dial): smart-search, block-builder, cypher-editor - use store - const { queryMode, setQueryMode, blockBuilderExpanded, setBlockBuilderExpanded } = useGraphStore(); - - // Smart Search sub-mode (pills) - const [smartSearchMode, setSmartSearchMode] = useState('concept'); + // Top-level mode (dial) + const { queryMode, setQueryMode, blockBuilderExpanded, setBlockBuilderExpanded, cypherEditorContent, setCypherEditorContent } = useGraphStore(); - // Collapsible sections state - const [expandedSections, setExpandedSections] = useState>({ - concept: true, - neighborhood: true, - path: true, - }); - - // Collapsible state for Smart Search + // Collapsible state const [smartSearchExpanded, setSmartSearchExpanded] = useState(true); - - // Collapsible state for openCypher editor const [cypherEditorExpanded, setCypherEditorExpanded] = useState(true); - const toggleSmartSearch = () => { - setSmartSearchExpanded(!smartSearchExpanded); - - // Trigger window resize event after a short delay to let the DOM update - setTimeout(() => { - window.dispatchEvent(new Event('resize')); - }, 100); - }; - - const toggleSection = (mode: SmartSearchSubMode) => { - setExpandedSections(prev => ({ - ...prev, - [mode]: !prev[mode], - })); - - // Trigger window resize event after a short delay to let the DOM update - setTimeout(() => { - window.dispatchEvent(new Event('resize')); - }, 100); - }; - - const toggleCypherEditor = () => { - setCypherEditorExpanded(!cypherEditorExpanded); - - // Trigger window resize event after a short delay to let the DOM update - setTimeout(() => { - window.dispatchEvent(new Event('resize')); - }, 100); - }; - - const toggleBlockBuilder = () => { - setBlockBuilderExpanded(!blockBuilderExpanded); - - // Trigger window resize event after a short delay to let the DOM update - setTimeout(() => { - window.dispatchEvent(new Event('resize')); - }, 100); - }; - - // Shared controls - use global similarity threshold from store - const { similarityThreshold: similarity, setSimilarityThreshold: setSimilarity } = useGraphStore(); - - // Concept mode state - const [conceptQuery, setConceptQuery] = useState(''); - const [selectedConcept, setSelectedConcept] = useState(null); - - // Neighborhood mode state - const [neighborhoodQuery, setNeighborhoodQuery] = useState(''); - const [selectedCenterConcept, setSelectedCenterConcept] = useState(null); - const [neighborhoodDepth, setNeighborhoodDepth] = useState(2); - - // Path mode state - const [pathFromQuery, setPathFromQuery] = useState(''); - const [pathToQuery, setPathToQuery] = useState(''); - const [selectedFromConcept, setSelectedFromConcept] = useState(null); - const [selectedToConcept, setSelectedToConcept] = useState(null); + const triggerResize = () => setTimeout(() => window.dispatchEvent(new Event('resize')), 100); + + // Shared controls from store + const { + similarityThreshold: similarity, + setSimilarityThreshold: setSimilarity, + searchParams, + setSearchParams, + setRawGraphData, + mergeRawGraphData, + setGraphData, + } = useGraphStore(); + + // === UNIFIED SEARCH STATE === + // Primary concept (the starting point for any search) + const [primaryQuery, setPrimaryQuery] = useState(''); + const [selectedPrimary, setSelectedPrimary] = useState<{ concept_id: string; label: string } | null>(null); + const [depth, setDepth] = useState(1); + + // Destination concept (optional — triggers path mode) + const [destinationQuery, setDestinationQuery] = useState(''); + const [selectedDestination, setSelectedDestination] = useState<{ concept_id: string; label: string } | null>(null); + const [showDestination, setShowDestination] = useState(false); const [maxHops, setMaxHops] = useState(5); - const [pathResults, setPathResults] = useState(null); - const [selectedPath, setSelectedPath] = useState(null); + + // Path search state + const [pathResults, setPathResults] = useState<{ paths: PathResult[]; count: number; error?: string } | null>(null); + const [selectedPath, setSelectedPath] = useState(null); const [isLoadingPath, setIsLoadingPath] = useState(false); // Cypher editor state @@ -115,110 +78,107 @@ LIMIT 50`); const [isExecutingCypher, setIsExecutingCypher] = useState(false); const [cypherError, setCypherError] = useState(null); - const { setSearchParams, setRawGraphData, mergeRawGraphData, setGraphData } = useGraphStore(); - - // Debounce values to prevent excessive API calls while user is typing/dragging sliders - // 800ms for typing (embeddings are expensive), 500ms for sliders (cheaper operations) - const debouncedConceptQuery = useDebouncedValue(conceptQuery, 800); - const debouncedNeighborhoodQuery = useDebouncedValue(neighborhoodQuery, 800); - const debouncedPathFromQuery = useDebouncedValue(pathFromQuery, 800); - const debouncedPathToQuery = useDebouncedValue(pathToQuery, 800); + // Debounce values + const debouncedPrimaryQuery = useDebouncedValue(primaryQuery, 800); + const debouncedDestinationQuery = useDebouncedValue(destinationQuery, 800); const debouncedSimilarity = useDebouncedValue(similarity, 500); - // Concept search results (only when no concept selected) - // Uses debounced similarity to prevent search spam while dragging slider - const { data: conceptResults, isLoading: isLoadingConcepts } = useSearchConcepts( - debouncedConceptQuery, + // Search hooks + const { data: primaryResults, isLoading: isLoadingPrimary } = useSearchConcepts( + debouncedPrimaryQuery, { limit: 10, minSimilarity: debouncedSimilarity, - enabled: queryMode === 'smart-search' && smartSearchMode === 'concept' && !selectedConcept, + enabled: queryMode === 'smart-search' && !selectedPrimary, } ); - // Neighborhood center search results (only when no center selected) - const { data: neighborhoodSearchResults, isLoading: isLoadingNeighborhoodSearch } = useSearchConcepts( - debouncedNeighborhoodQuery, + const { data: destinationResults, isLoading: isLoadingDestination } = useSearchConcepts( + debouncedDestinationQuery, { limit: 10, minSimilarity: debouncedSimilarity, - enabled: queryMode === 'smart-search' && smartSearchMode === 'neighborhood' && !selectedCenterConcept, + enabled: queryMode === 'smart-search' && showDestination && !selectedDestination, } ); - // Path From search results (only when no from concept selected) - const { data: pathFromSearchResults, isLoading: isLoadingPathFromSearch } = useSearchConcepts( - debouncedPathFromQuery, - { - limit: 10, - minSimilarity: debouncedSimilarity, - enabled: queryMode === 'smart-search' && smartSearchMode === 'path' && !selectedFromConcept, + // Sync from store → local state when Follow Concept updates searchParams externally + useEffect(() => { + if (searchParams.primaryConceptId && searchParams.primaryConceptLabel) { + // External update (e.g., node click) — sync to local state + if (!selectedPrimary || selectedPrimary.concept_id !== searchParams.primaryConceptId) { + setSelectedPrimary({ + concept_id: searchParams.primaryConceptId, + label: searchParams.primaryConceptLabel, + }); + setPrimaryQuery(searchParams.primaryConceptLabel); + setDepth(searchParams.depth); + } } - ); - - // Path To search results (only when no to concept selected) - const { data: pathToSearchResults, isLoading: isLoadingPathToSearch } = useSearchConcepts( - debouncedPathToQuery, - { - limit: 10, - minSimilarity: debouncedSimilarity, - enabled: queryMode === 'smart-search' && smartSearchMode === 'path' && !selectedToConcept, + }, [searchParams.primaryConceptId, searchParams.primaryConceptLabel]); + + // Consume exported Cypher from ExplorerView "Export to Editor" action + useEffect(() => { + if (cypherEditorContent !== null) { + setCypherQuery(cypherEditorContent); + setCypherEditorContent(null); + setQueryMode('cypher-editor'); + setCypherEditorExpanded(true); } - ); + }, [cypherEditorContent, setCypherEditorContent, setQueryMode]); - // Note: Path search is now manual (via button) instead of auto-search - // This prevents the UI from appearing to hang during expensive path searches + // === HANDLERS === - // Handler: Select concept in Concept mode - const handleSelectConcept = (concept: any) => { - setSelectedConcept(concept); - setConceptQuery(''); + const handleSelectPrimary = (concept: { concept_id: string; label: string }) => { + setSelectedPrimary(concept); + setPrimaryQuery(concept.label); }; - // Handler: Select center concept in Neighborhood mode - const handleSelectCenterConcept = (concept: any) => { - setSelectedCenterConcept(concept); - setNeighborhoodQuery(''); + const handleSelectDestination = (concept: { concept_id: string; label: string }) => { + setSelectedDestination(concept); + setDestinationQuery(concept.label); }; - // Handler: Select From concept in Path mode - const handleSelectFromConcept = (concept: any) => { - setSelectedFromConcept(concept); - setPathFromQuery(''); - }; - - // Handler: Select To concept in Path mode - const handleSelectToConcept = (concept: any) => { - setSelectedToConcept(concept); - setPathToQuery(''); + const handleRemoveDestination = () => { + setShowDestination(false); + setSelectedDestination(null); + setDestinationQuery(''); + setPathResults(null); + setSelectedPath(null); + setDepth(1); // Reset to explore default }; - // Handler: Load concept (sets search parameters for App.tsx to react to) - const handleLoadConcept = (loadMode: 'clean' | 'add') => { - if (!selectedConcept) return; - - setSearchParams({ - mode: 'concept', - conceptId: selectedConcept.concept_id, - loadMode, + /** Load explore — fetch subgraph around selected concept and record the step */ + const handleLoadExplore = (loadMode: 'clean' | 'add') => { + if (!selectedPrimary) return; + + const stepParams = { + action: 'explore' as const, + conceptLabel: selectedPrimary.label, + depth, + }; + + useGraphStore.getState().addExplorationStep({ + action: 'explore', + op: '+', + cypher: stepToCypher(stepParams), + conceptId: selectedPrimary.concept_id, + conceptLabel: selectedPrimary.label, + depth, }); - }; - - // Handler: Load neighborhood (sets search parameters for App.tsx to react to) - const handleLoadNeighborhood = (loadMode: 'clean' | 'add') => { - if (!selectedCenterConcept) return; setSearchParams({ - mode: 'neighborhood', - centerConceptId: selectedCenterConcept.concept_id, - depth: neighborhoodDepth, + primaryConceptId: selectedPrimary.concept_id, + primaryConceptLabel: selectedPrimary.label, + depth, + maxHops: 5, loadMode, }); }; - // Handler: Search for paths between selected concepts (stores results locally) + // Path search (manual) const handleFindPaths = async () => { - if (!selectedFromConcept || !selectedToConcept) return; + if (!selectedPrimary || !selectedDestination) return; setIsLoadingPath(true); setPathResults(null); @@ -226,73 +186,55 @@ LIMIT 50`); try { const result = await apiClient.findConnection({ - from_id: selectedFromConcept.concept_id, - to_id: selectedToConcept.concept_id, + from_id: selectedPrimary.concept_id, + to_id: selectedDestination.concept_id, max_hops: maxHops, }); setPathResults(result); - } catch (error: any) { + } catch (error: unknown) { console.error('Failed to find paths:', error); + const err = error as { code?: string; response?: { data?: { detail?: string } }; message?: string }; let errorMessage = 'Failed to find paths'; - if (error.code === 'ECONNABORTED') { + if (err.code === 'ECONNABORTED') { errorMessage = `Search timed out. Try reducing max hops.`; - } else if (error.response?.data?.detail) { - errorMessage = error.response.data.detail; - } else if (error.message) { - errorMessage = error.message; + } else if (err.response?.data?.detail) { + errorMessage = err.response.data.detail; + } else if (err.message) { + errorMessage = err.message; } - setPathResults({ - error: errorMessage, - count: 0, - paths: [] - }); + setPathResults({ error: errorMessage, count: 0, paths: [] }); } finally { setIsLoadingPath(false); } }; - // Handler: Load selected path directly into graph - // Converts the specific selected path to graph format (matching useFindConnection logic) - const handleLoadPath = (loadMode: 'clean' | 'add') => { - if (!selectedPath) return; - - // Extract Concept nodes only (skip Source/Ontology with empty IDs) - const conceptNodes: any[] = []; - const conceptRelTypes: string[][] = []; - let pendingRels: string[] = []; - - for (let i = 0; i < selectedPath.nodes.length; i++) { - const node = selectedPath.nodes[i]; - if (node.id && node.id !== '') { - conceptNodes.push(node); - conceptRelTypes.push(pendingRels); - pendingRels = []; - } - if (i < selectedPath.relationships.length) { - pendingRels.push(selectedPath.relationships[i]); - } - } + /** Load selected path into graph and record the step */ + const handleLoadPath = async (loadMode: 'clean' | 'add') => { + if (!selectedPath || !selectedPrimary || !selectedDestination) return; + + const stepParams = { + action: 'load-path' as const, + conceptLabel: selectedPrimary.label, + depth, + destinationConceptLabel: selectedDestination.label, + maxHops, + }; + + useGraphStore.getState().addExplorationStep({ + action: 'load-path', + op: '+', + cypher: stepToCypher(stepParams), + conceptId: selectedPrimary.concept_id, + conceptLabel: selectedPrimary.label, + depth, + destinationConceptId: selectedDestination.concept_id, + destinationConceptLabel: selectedDestination.label, + maxHops, + }); - const nodes = conceptNodes.map((node: any) => ({ - concept_id: node.id, - label: node.label, - description: node.description, - ontology: 'default', - grounding_strength: node.grounding_strength, - })); - - const links: any[] = []; - for (let i = 0; i < conceptNodes.length - 1; i++) { - const rels = conceptRelTypes[i + 1]; - const relType = rels.find(r => r !== 'APPEARS' && r !== 'SCOPED_BY') || rels[0] || 'CONNECTED'; - links.push({ - from_id: conceptNodes[i].id, - to_id: conceptNodes[i + 1].id, - relationship_type: relType, - }); - } + const { nodes, links, conceptNodeIds } = extractGraphFromPath(selectedPath); if (loadMode === 'clean') { setGraphData(null); @@ -301,12 +243,33 @@ LIMIT 50`); mergeRawGraphData({ nodes, links }); } - // Dismiss the path selection UI after loading + // Enrich path nodes with neighborhood context + if (depth > 0 && conceptNodeIds.length <= 50) { + const enrichDepth = Math.min(depth, 2); + if (conceptNodeIds.length > 0) { + try { + const enrichments = await Promise.all( + conceptNodeIds.map((id) => + apiClient.getSubgraph({ center_concept_id: id, depth: enrichDepth }) + ) + ); + for (const data of enrichments) { + mergeRawGraphData({ nodes: data.nodes, links: data.links }); + } + } catch (error) { + console.error('Path enrichment failed:', error); + } + } + } + setPathResults(null); setSelectedPath(null); }; - // Handler: Execute openCypher query + // Execute Cypher program — parses +/- prefixed multi-statement scripts, + // routes results through the rawGraphData pipeline (not setGraphData directly), + // and records each statement as an exploration step for save/export round-trip. + // Plain Cypher without operators is treated as a single additive statement. const handleExecuteCypher = async () => { if (!cypherQuery.trim()) return; @@ -314,69 +277,76 @@ LIMIT 50`); setCypherError(null); try { - const result = await apiClient.executeCypherQuery({ - query: cypherQuery, - limit: 100, // Default limit for safety - }); - - // Transform result to graph format and load - const { transformForD3 } = await import('../../utils/graphTransform'); - - // Convert CypherNode to our node format - const nodes = result.nodes.map((n: any) => ({ - concept_id: n.id, - label: n.label, - ontology: n.properties?.ontology || 'default', - })); + let statements = parseCypherStatements(cypherQuery); + + // Plain Cypher without +/- operators → treat as single additive statement + if (statements.length === 0) { + const cypher = cypherQuery.trim().replace(/;\s*$/, ''); + if (cypher) { + statements = [{ op: '+', cypher }]; + } + } - // Convert CypherRelationship to our link format - const links = result.relationships.map((r: any) => ({ - from_id: r.from_id, - to_id: r.to_id, - relationship_type: r.type, - })); + if (statements.length === 0) return; - const graphData = transformForD3(nodes, links); - useGraphStore.getState().setGraphData(graphData); + const store = useGraphStore.getState(); - } catch (error: any) { + // Start fresh — clear graph and reset exploration session + setGraphData(null); + setRawGraphData(null); + store.resetExplorationSession(); + + for (const stmt of statements) { + const result = await apiClient.executeCypherQuery({ + query: stmt.cypher, + limit: 500, + }); + + const mapped = mapCypherResultToRawGraph(result); + + // Record exploration step + store.addExplorationStep({ + action: 'cypher', + op: stmt.op, + cypher: stmt.cypher, + }); + + // Apply through rawGraphData pipeline + if (stmt.op === '+') { + mergeRawGraphData(mapped); + } else { + store.subtractRawGraphData(mapped); + } + } + } catch (error: unknown) { console.error('Failed to execute Cypher query:', error); - setCypherError(error.response?.data?.detail || error.message || 'Query execution failed'); + const err = error as { response?: { data?: { detail?: string } }; message?: string }; + setCypherError(err.response?.data?.detail || err.message || 'Query execution failed'); } finally { setIsExecutingCypher(false); } }; - // Handler: Send compiled blocks to openCypher editor + // Block builder → Cypher editor const handleSendToEditor = (compiledCypher: string) => { - // Check if there's existing code in the editor - const hasExistingCode = cypherQuery.trim().length > 0; - - if (hasExistingCode) { + if (cypherQuery.trim().length > 0) { const confirmed = window.confirm( - 'The openCypher editor already has code. Do you want to overwrite it with the compiled query from the block builder?' + 'The openCypher editor already has code. Do you want to overwrite it?' ); - - if (!confirmed) { - return; // User cancelled - } + if (!confirmed) return; } - - // Load the compiled query into the editor setCypherQuery(compiledCypher); - - // Switch to cypher-editor mode setQueryMode('cypher-editor'); }; - // Get mode-specific info for the header + // Mode info for header const getModeInfo = () => { switch (queryMode) { case 'smart-search': return { icon: Search, title: 'Smart Search', - description: 'Find concepts using semantic similarity, explore neighborhoods, and discover paths between ideas', + description: 'Find concepts, explore neighborhoods, and discover paths between ideas', }; case 'block-builder': return { @@ -396,11 +366,59 @@ LIMIT 50`); const modeInfo = getModeInfo(); const ModeIcon = modeInfo.icon; + // No-results content with below-threshold suggestions + const noResultsContent = primaryResults && primaryResults.results && primaryResults.results.length === 0 && ( +
+
+ {primaryResults.below_threshold_count ? ( +
+
+
No results at {(similarity * 100).toFixed(0)}% similarity
+
+ Found {primaryResults.below_threshold_count} concept{primaryResults.below_threshold_count > 1 ? 's' : ''} at lower similarity +
+
+ {primaryResults.suggested_threshold && ( + + )} + {primaryResults.top_match && ( +
+
Top match:
+
{primaryResults.top_match.label}
+
+ {(primaryResults.top_match.score * 100).toFixed(0)}% similarity +
+
+ )} +
+ ) : ( +
No results found
+ )} +
+
+ ); + + // Reusable similarity slider + const similaritySlider = ( + setSimilarity(v / 100)} + displayValue={`${(similarity * 100).toFixed(0)}%`} + /> + ); + return ( -
+
{/* Header with Mode Info and Dial */}
- {/* Mode Description Panel */}
@@ -414,17 +432,14 @@ LIMIT 50`);

- - {/* Mode Dial */}
- {/* Smart Search Mode */} + {/* ===== SMART SEARCH (Unified Progressive) ===== */} {queryMode === 'smart-search' && (
- {/* Collapsible Header */} {smartSearchExpanded && ( - <> - {/* Sub-mode Selector (Pill Buttons) */} -
- - - -
- - {/* Concept Search */} - {smartSearchMode === 'concept' && (
- {/* Collapsible Header */} - - - {expandedSections.concept && ( - <> - {!selectedConcept ? ( -
-
- - setConceptQuery(e.target.value)} - placeholder="Search for a concept..." - className="w-full pl-10 pr-10 py-2 rounded-lg border border-input bg-background focus:outline-none focus:ring-2 focus:ring-ring" - /> - {isLoadingConcepts && ( - - )} -
- - {/* Similarity Slider */} -
- - setSimilarity(parseInt(e.target.value) / 100)} - className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer - [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 - [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary - [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 - [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0" - /> - - {(similarity * 100).toFixed(0)}% - -
- - {/* Search Results */} - {debouncedConceptQuery && conceptResults && conceptResults.results && conceptResults.results.length > 0 && ( - - )} - - {/* No Results with Smart Recommendations */} - {debouncedConceptQuery && conceptResults && conceptResults.results && conceptResults.results.length === 0 && ( -
-
- {conceptResults.below_threshold_count ? ( -
-
-
No results at {(similarity * 100).toFixed(0)}% similarity
-
- Found {conceptResults.below_threshold_count} concept{conceptResults.below_threshold_count > 1 ? 's' : ''} at lower similarity -
-
- {conceptResults.suggested_threshold && ( - - )} - {conceptResults.top_match && ( -
-
Top match:
-
{conceptResults.top_match.label}
-
- {(conceptResults.top_match.score * 100).toFixed(0)}% similarity -
-
- )} -
- ) : ( -
No results found
- )} -
-
- )} -
- ) : ( + {/* Search input — always visible */} + { + setPrimaryQuery(q); + if (selectedPrimary) { + // User is typing — clear selection to restart search + setSelectedPrimary(null); + setSelectedDestination(null); + setShowDestination(false); + setPathResults(null); + setSelectedPath(null); + } + }} + placeholder="Search for a concept..." + icon={Search} + isLoading={isLoadingPrimary} + results={primaryResults?.results} + debouncedQuery={debouncedPrimaryQuery} + onSelect={handleSelectPrimary} + noResultsContent={noResultsContent} + /> + + {similaritySlider} + + {/* Controls appear when a concept is selected */} + {selectedPrimary && (
- {/* Selected Concept */} -
-
-
-
Selected concept:
-
{selectedConcept.label}
-
- -
-
- - {/* Similarity Slider */} -
- - setSimilarity(parseInt(e.target.value) / 100)} - className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer - [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 - [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary - [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 - [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0" - /> - - {(similarity * 100).toFixed(0)}% - -
- - {/* Load Buttons */} -
- - -
-
- )} - - )} -
- )} - - {/* Neighborhood Search */} - {smartSearchMode === 'neighborhood' && ( -
- {/* Collapsible Header */} - - - {expandedSections.neighborhood && ( - <> - {!selectedCenterConcept ? ( -
-
- - setNeighborhoodQuery(e.target.value)} - placeholder="Search for center concept..." - className="w-full pl-10 pr-10 py-2 rounded-lg border border-input bg-background focus:outline-none focus:ring-2 focus:ring-ring" - /> - {isLoadingNeighborhoodSearch && ( - - )} -
- - {/* Similarity Slider */} -
- - setSimilarity(parseInt(e.target.value) / 100)} - className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer - [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 - [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary - [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 - [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0" - /> - - {(similarity * 100).toFixed(0)}% - -
- - {/* Search Results */} - {debouncedNeighborhoodQuery && neighborhoodSearchResults && neighborhoodSearchResults.results && neighborhoodSearchResults.results.length > 0 && ( - + + {/* Load buttons (explore mode — no destination) */} + {!showDestination && ( + handleLoadExplore('clean')} + onLoadAdd={() => handleLoadExplore('add')} /> )} -
- ) : ( -
- {/* Selected Center Concept */} -
-
-
-
Center concept:
-
{selectedCenterConcept.label}
-
- -
-
- - {/* Depth Slider */} -
- - setNeighborhoodDepth(parseInt(e.target.value))} - className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer - [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 - [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary - [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 - [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0" - /> - - {neighborhoodDepth} - - - hop{neighborhoodDepth > 1 ? 's' : ''} - -
- - {/* Load Buttons */} -
- + + {/* Destination toggle / search */} + {!showDestination ? ( -
-
- )} - - )} -
- )} - - {/* Path Search */} - {smartSearchMode === 'path' && ( -
- {/* Collapsible Header */} - - - {expandedSections.path && ( - <> - {/* Step 1: Search for From concept */} - {!selectedFromConcept ? ( -
-
- - setPathFromQuery(e.target.value)} - placeholder="Search for starting concept..." - className="w-full pl-10 pr-10 py-2 rounded-lg border border-input bg-background focus:outline-none focus:ring-2 focus:ring-ring" - /> - {isLoadingPathFromSearch && ( - - )} -
- - {/* Similarity Slider */} -
- - setSimilarity(parseInt(e.target.value) / 100)} - className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer - [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 - [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary - [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 - [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0" - /> - - {(similarity * 100).toFixed(0)}% - -
- - {/* From Search Results */} - {debouncedPathFromQuery && pathFromSearchResults && pathFromSearchResults.results && pathFromSearchResults.results.length > 0 && ( - - )} -
- ) : !selectedToConcept ? ( -
- {/* Selected From Concept */} -
-
-
-
From concept:
-
{selectedFromConcept.label}
-
- -
-
- - {/* Step 2: Search for To concept */} -
-
- - setPathToQuery(e.target.value)} - placeholder="Search for target concept..." - className="w-full pl-10 pr-10 py-2 rounded-lg border border-input bg-background focus:outline-none focus:ring-2 focus:ring-ring" - /> - {isLoadingPathToSearch && ( - - )} -
- - {/* To Search Results */} - {debouncedPathToQuery && pathToSearchResults && pathToSearchResults.results && pathToSearchResults.results.length > 0 && ( - - )} -
-
- ) : ( -
- {/* Selected From and To Concepts */} -
-
-
-
-
From concept:
-
{selectedFromConcept.label}
-
- -
-
-
-
-
-
To concept:
-
{selectedToConcept.label}
+ ) : ( +
+ {/* Destination header with close button */} +
+
+ + Path Destination
-
-
- - {/* Similarity Slider */} -
- - setSimilarity(parseInt(e.target.value) / 100)} - className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer - [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 - [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary - [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 - [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0" - /> - - {(similarity * 100).toFixed(0)}% - -
- - {/* Max Hops Slider */} -
- - setMaxHops(parseInt(e.target.value))} - className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer - [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 - [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary - [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 - [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0" - /> - - {maxHops} - -
- - {/* Find Paths Button */} - - - {/* Path Results */} - {pathResults && !isLoadingPath && ( -
- {pathResults.error ? ( -
- {pathResults.error} -
- ) : pathResults.count > 0 ? ( - <> -
- Found {pathResults.count} path{pathResults.count > 1 ? 's' : ''} ({pathResults.paths[0].hops} hop{pathResults.paths[0].hops > 1 ? 's' : ''}) -
- - {/* Path Selection */} -
-
Select a path:
- {pathResults.paths.map((path: any, index: number) => ( - - ))} -
- - {/* Load Buttons (only if path selected) */} - {selectedPath && ( -
- - -
- )} - - ) : ( -
- No paths found. Try lowering similarity or increasing max hops. + + { + setDestinationQuery(q); + if (selectedDestination) { + setSelectedDestination(null); + setPathResults(null); + setSelectedPath(null); + } + }} + placeholder="Search for destination concept..." + icon={GitBranch} + isLoading={isLoadingDestination} + results={destinationResults?.results} + debouncedQuery={debouncedDestinationQuery} + onSelect={handleSelectDestination} + /> + + {selectedDestination && ( +
+ + + + + handleLoadPath('clean')} + onLoadAdd={() => handleLoadPath('add')} + />
)}
)}
)} - - )}
)} - - )}
)} {/* Block Builder Mode */} {queryMode === 'block-builder' && (
- {/* Collapsible Header */} {cypherEditorExpanded && ( - <> - {/* Query Editor (Textarea for now, can upgrade to Monaco later) */} -
-