diff --git a/src/components/CodeReferencesPanel.tsx b/src/components/CodeReferencesPanel.tsx index 14d400d84..4bda5cca0 100644 --- a/src/components/CodeReferencesPanel.tsx +++ b/src/components/CodeReferencesPanel.tsx @@ -36,11 +36,24 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = removeCodeReference, clearCodeReferences, setSelectedNode, + codeReferenceFocus, } = useAppState(); const [isCollapsed, setIsCollapsed] = useState(false); + const [glowRefId, setGlowRefId] = useState(null); const panelRef = useRef(null); const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null); + const refCardEls = useRef>(new Map()); + const glowTimerRef = useRef(null); + + useEffect(() => { + return () => { + if (glowTimerRef.current) { + window.clearTimeout(glowTimerRef.current); + glowTimerRef.current = null; + } + }; + }, []); const [panelWidth, setPanelWidth] = useState(() => { try { @@ -90,6 +103,47 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = const aiReferences = useMemo(() => codeReferences.filter(r => r.source === 'ai'), [codeReferences]); + // When the user clicks a citation badge in chat, focus the corresponding snippet card: + // - expand the panel if collapsed + // - smooth-scroll the card into view + // - briefly glow it for discoverability + useEffect(() => { + if (!codeReferenceFocus) return; + + // Ensure panel is expanded + setIsCollapsed(false); + + const { filePath, startLine, endLine } = codeReferenceFocus; + const target = + aiReferences.find(r => + r.filePath === filePath && + r.startLine === startLine && + r.endLine === endLine + ) ?? + aiReferences.find(r => r.filePath === filePath); + + if (!target) return; + + // Double rAF: wait for collapse state + list DOM to render. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const el = refCardEls.current.get(target.id); + if (!el) return; + + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + setGlowRefId(target.id); + + if (glowTimerRef.current) { + window.clearTimeout(glowTimerRef.current); + } + glowTimerRef.current = window.setTimeout(() => { + setGlowRefId((prev) => (prev === target.id ? null : prev)); + glowTimerRef.current = null; + }, 1200); + }); + }); + }, [codeReferenceFocus?.ts, aiReferences]); + const refsWithSnippets = useMemo(() => { return aiReferences.map((ref) => { const content = fileContents.get(ref.filePath); @@ -291,8 +345,17 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = ref.filePath.endsWith('.ts') || ref.filePath.endsWith('.tsx') ? 'typescript' : 'text'; + const isGlowing = glowRefId === ref.id; + return ( -
+
{ refCardEls.current.set(ref.id, el); }} + className={[ + 'bg-elevated border border-border-subtle rounded-xl overflow-hidden transition-all', + isGlowing ? 'ring-2 ring-cyan-300/70 shadow-[0_0_0_6px_rgba(34,211,238,0.14)] animate-pulse' : '', + ].join(' ')} + >
{ }, [addCodeReference, findFileNodeIdForUI, resolveFilePathForUI]); const formatMarkdownForDisplay = useCallback((md: string) => { - // Avoid rewriting inside fenced code blocks. Also avoid rewriting when immediately preceded by a backtick. + // Avoid rewriting inside fenced code blocks. const parts = md.split('```'); for (let i = 0; i < parts.length; i += 2) { - parts[i] = parts[i].replace(/(^|[^`])\[\[([^\]\n]+?)\]\]/g, (_m, prefix: string, inner: string) => { + parts[i] = parts[i].replace(/\[\[([^\]\n]+?)\]\]/g, (_m, inner: string) => { const trimmed = inner.trim(); const href = `code-ref:${encodeURIComponent(trimmed)}`; - return `${prefix}[${trimmed}](${href})`; + return `[${trimmed}](${href})`; }); } return parts.join('```'); }, []); + + const formatRefChipLabel = useCallback((ref: string): string => { + const raw = ref.trim(); + if (!raw) return ''; + + // Drop any scheme prefix we might have accidentally passed through + const withoutScheme = raw.startsWith('code-ref:') ? raw.slice('code-ref:'.length) : raw; + + // Strip query/hash + const cleaned = withoutScheme.split('#')[0].split('?')[0]; + + const m = cleaned.match(/^(.*):(\d+)(?:-(\d+))?$/); + const path = (m ? m[1] : cleaned).replace(/\\/g, '/'); + const base = path.split('/').pop() ?? path; + + if (!m) return base; + + const start = m[2]; + const end = m[3] ?? m[2]; + return `${base} ${start}–${end}`; + }, []); + + const isLikelyFileRefHref = useCallback((href: string): boolean => { + const h = href.trim(); + if (!h) return false; + if (h.startsWith('code-ref:')) return true; + if (/^(https?:|mailto:|tel:|#)/i.test(h)) return false; + if (h.includes('://')) return false; + + // Strip query/hash + const cleaned = h.split('#')[0].split('?')[0]; + + // Looks like: path/to/file.ext or path\to\file.ext:12-34 + return /[A-Za-z0-9_\-./\\]+\.[A-Za-z0-9]+(?::\d+(?:-\d+)?)?$/.test(cleaned); + }, []); + + const extractTextFromChildren = useCallback((children: any): string => { + if (children == null) return ''; + if (typeof children === 'string' || typeof children === 'number') return String(children); + if (Array.isArray(children)) return children.map(extractTextFromChildren).join(''); + // React element or other objects + return ''; + }, []); + + const getInternalRefFromLink = useCallback((href: string | undefined, children: any): string | null => { + const hrefStr = (href ?? '').trim(); + const textStr = extractTextFromChildren(children).trim(); + + if (hrefStr && isLikelyFileRefHref(hrefStr)) return hrefStr; + if (textStr && isLikelyFileRefHref(textStr)) return textStr; + + return null; + }, [extractTextFromChildren, isLikelyFileRefHref]); // Auto-resize textarea as user types const adjustTextareaHeight = useCallback(() => { @@ -298,8 +351,91 @@ export const RightPanel = () => { {message.steps.map((step) => (
{step.type === 'reasoning' && step.content && ( -
- {step.content} +
+ { + if (href && href.startsWith('code-ref:')) { + const inner = decodeURIComponent(href.slice('code-ref:'.length)); + const label = formatRefChipLabel(inner); + return ( + { + e.preventDefault(); + handleGroundingClick(inner); + }} + className="inline-flex items-center px-2 py-0.5 rounded-md border border-cyan-300/55 bg-cyan-400/10 !text-cyan-200 visited:!text-cyan-200 font-mono text-[12px] !no-underline hover:!no-underline hover:bg-cyan-400/15 hover:border-cyan-200/70 transition-colors" + title={`Open in Code panel • ${inner}`} + {...props} + > + {label || children} + + ); + } + const internalRef = getInternalRefFromLink(href, children); + if (internalRef) { + const label = formatRefChipLabel(internalRef); + return ( + { + e.preventDefault(); + handleGroundingClick(internalRef); + }} + className="inline-flex items-center px-2 py-0.5 rounded-md border border-cyan-300/55 bg-cyan-400/10 !text-cyan-200 visited:!text-cyan-200 font-mono text-[12px] !no-underline hover:!no-underline hover:bg-cyan-400/15 hover:border-cyan-200/70 transition-colors" + title={`Open in Code panel • ${internalRef}`} + {...props} + > + {label || children} + + ); + } + return ( + + {children} + + ); + }, + code: ({ className, children, ...props }) => { + const match = /language-(\w+)/.exec(className || ''); + const isInline = !className && !match; + const codeContent = String(children).replace(/\n$/, ''); + + if (isInline) { + return {children}; + } + + const language = match ? match[1] : 'text'; + return ( + + {codeContent} + + ); + }, + pre: ({ children }) => <>{children}, + }} + > + {formatMarkdownForDisplay(step.content)} +
)} {step.type === 'tool_call' && step.toolCall && ( @@ -311,6 +447,7 @@ export const RightPanel = () => { a: ({ href, children, ...props }) => { if (href && href.startsWith('code-ref:')) { const inner = decodeURIComponent(href.slice('code-ref:'.length)); + const label = formatRefChipLabel(inner); return ( { e.preventDefault(); handleGroundingClick(inner); }} - className="inline-flex items-center px-2 py-0.5 rounded-md border border-cyan-400/40 bg-cyan-500/10 text-cyan-200 font-mono text-[12px] hover:bg-cyan-500/15 hover:border-cyan-300/60 transition-colors" - title="Open in Code panel" + className="inline-flex items-center px-2 py-0.5 rounded-md border border-cyan-300/55 bg-cyan-400/10 !text-cyan-200 visited:!text-cyan-200 font-mono text-[12px] !no-underline hover:!no-underline hover:bg-cyan-400/15 hover:border-cyan-200/70 transition-colors" + title={`Open in Code panel • ${inner}`} {...props} > - {children} + {label || children} + + ); + } + const internalRef = getInternalRefFromLink(href, children); + if (internalRef) { + const label = formatRefChipLabel(internalRef); + return ( + { + e.preventDefault(); + handleGroundingClick(internalRef); + }} + className="inline-flex items-center px-2 py-0.5 rounded-md border border-cyan-300/55 bg-cyan-400/10 !text-cyan-200 visited:!text-cyan-200 font-mono text-[12px] !no-underline hover:!no-underline hover:bg-cyan-400/15 hover:border-cyan-200/70 transition-colors" + title={`Open in Code panel • ${internalRef}`} + {...props} + > + {label || children} ); } @@ -383,6 +538,7 @@ export const RightPanel = () => { a: ({ href, children, ...props }) => { if (href && href.startsWith('code-ref:')) { const inner = decodeURIComponent(href.slice('code-ref:'.length)); + const label = formatRefChipLabel(inner); return ( { e.preventDefault(); handleGroundingClick(inner); }} - className="inline-flex items-center px-2 py-0.5 rounded-md border border-cyan-400/40 bg-cyan-500/10 text-cyan-200 font-mono text-[12px] hover:bg-cyan-500/15 hover:border-cyan-300/60 transition-colors" - title="Open in Code panel" + className="inline-flex items-center px-2 py-0.5 rounded-md border border-cyan-300/55 bg-cyan-400/10 !text-cyan-200 visited:!text-cyan-200 font-mono text-[12px] !no-underline hover:!no-underline hover:bg-cyan-400/15 hover:border-cyan-200/70 transition-colors" + title={`Open in Code panel • ${inner}`} {...props} > - {children} + {label || children} + + ); + } + const internalRef = getInternalRefFromLink(href, children); + if (internalRef) { + const label = formatRefChipLabel(internalRef); + return ( + { + e.preventDefault(); + handleGroundingClick(internalRef); + }} + className="inline-flex items-center px-2 py-0.5 rounded-md border border-cyan-300/55 bg-cyan-400/10 !text-cyan-200 visited:!text-cyan-200 font-mono text-[12px] !no-underline hover:!no-underline hover:bg-cyan-400/15 hover:border-cyan-200/70 transition-colors" + title={`Open in Code panel • ${internalRef}`} + {...props} + > + {label || children} ); } diff --git a/src/core/llm/agent.ts b/src/core/llm/agent.ts index f004805f1..62cd4d9f5 100644 --- a/src/core/llm/agent.ts +++ b/src/core/llm/agent.ts @@ -108,8 +108,19 @@ Single polymorphic table: \`CodeNode\` with \`label\` property (File, Function, Relationships: \`CodeRelation\` with \`type\` (CALLS, IMPORTS, CONTAINS, DEFINES) +**IMPORTANT:** There is NO relationship label/table named \`CALLS\` / \`IMPORTS\` / etc. +Always use \`CodeRelation\` and filter on \`r.type\`, e.g.: +- ✅ \`MATCH (a:CodeNode)-[r:CodeRelation]->(b:CodeNode) WHERE r.type = 'CALLS'\` +- ❌ \`MATCH (a)-[:CALLS]->(b)\` -- WRONG, will fail with "Table CALLS does not exist" + Vector search requires JOIN: \`CALL QUERY_VECTOR_INDEX(...) YIELD node AS emb, distance WITH emb, distance WHERE ... MATCH (n:CodeNode {id: emb.nodeId})\` +## ERROR RECOVERY (BE AGENTIC) + +If a tool call returns an error (e.g., Cypher binder/syntax errors), do NOT stop. +- Correct the query and retry at least once. +- If unsure, call \`get_graph_schema\` to ground the correct schema, then retry. + ## USE HIGHLIGHTING The user sees a visual knowledge graph alongside this chat. Use \`highlight_in_graph\` liberally to: @@ -255,7 +266,11 @@ export async function* streamAgentResponse( // Use BOTH modes: 'values' for structure, 'messages' for token streaming const stream = await agent.stream( { messages: formattedMessages }, - { streamMode: ['values', 'messages'] as any } + { + streamMode: ['values', 'messages'] as any, + // Allow longer tool/reasoning loops (more Cursor-like persistence) + recursionLimit: 50, + } as any ); // Track what we've yielded to avoid duplicates @@ -264,6 +279,10 @@ export async function* streamAgentResponse( let lastProcessedMsgCount = formattedMessages.length; // Track if all tools are done (for distinguishing reasoning vs final content) let allToolsDone = true; + // Track if we've seen any tool calls in this response turn. + // Anything before the first tool call should be treated as "reasoning/narration" + // so the UI can show the Cursor-like loop: plan → tool → update → tool → answer. + let hasSeenToolCallThisTurn = false; for await (const event of stream) { // Events come as [streamMode, data] tuples when using multiple modes @@ -297,8 +316,14 @@ export async function* streamAgentResponse( // If chunk has content, stream it if (content && typeof content === 'string' && content.length > 0) { - // Determine if this is reasoning (before/between tools) or final content - const isReasoning = toolCalls.length > 0 || !allToolsDone; + // Determine if this is reasoning/narration vs final answer content. + // - Before the first tool call: treat as reasoning (narration) + // - Between tool calls/results: treat as reasoning + // - After all tools are done: treat as final content + const isReasoning = + !hasSeenToolCallThisTurn || + toolCalls.length > 0 || + !allToolsDone; yield { type: isReasoning ? 'reasoning' : 'content', [isReasoning ? 'reasoning' : 'content']: content, @@ -307,6 +332,7 @@ export async function* streamAgentResponse( // Track tool calls from message chunks if (toolCalls.length > 0) { + hasSeenToolCallThisTurn = true; allToolsDone = false; for (const tc of toolCalls) { const toolId = tc.id || `tool-${Date.now()}-${Math.random().toString(36).slice(2)}`; diff --git a/src/core/llm/tools.ts b/src/core/llm/tools.ts index c79f73a65..1f6d59882 100644 --- a/src/core/llm/tools.ts +++ b/src/core/llm/tools.ts @@ -23,6 +23,26 @@ export const createGraphRAGTools = ( isBM25Ready: () => boolean, fileContents: Map ) => { + const buildCypherSchemaHint = (message: string): string => { + const m = message.match(/Table\s+([A-Za-z_][A-Za-z0-9_]*)\s+does\s+not\s+exist/i); + const missing = (m?.[1] ?? '').toUpperCase(); + const knownRelTypes = new Set(['CALLS', 'IMPORTS', 'CONTAINS', 'DEFINES']); + if (!knownRelTypes.has(missing)) return ''; + + return [ + '', + 'Schema hint:', + `- There is NO relationship label/table named "${missing}".`, + "- All relationships use the single relationship label `CodeRelation` with a `type` property.", + `- Rewrite patterns like \`-[:${missing}]->\` to \`-[r:CodeRelation]->\` and add \`WHERE r.type = '${missing}'\`.`, + '', + 'Example:', + "MATCH (a:CodeNode)-[r:CodeRelation]->(b:CodeNode)", + `WHERE r.type = '${missing}'`, + 'RETURN a.id, b.id LIMIT 25', + ].join('\n'); + }; + /** * Tool: Execute Cypher Query * Allows the agent to run arbitrary Cypher queries against the graph @@ -51,7 +71,8 @@ export const createGraphRAGTools = ( return `Query returned ${results.length} results:\n${resultText}${truncated}`; } catch (error) { const message = error instanceof Error ? error.message : String(error); - return `Cypher query error: ${message}\n\nPlease check your query syntax and try again.`; + const hint = buildCypherSchemaHint(message); + return `Cypher query error: ${message}\n\nPlease check your query syntax and try again.${hint ? `\n\n${hint}` : ''}`; } }, { diff --git a/src/hooks/useAppState.tsx b/src/hooks/useAppState.tsx index 86309a84f..fe140a086 100644 --- a/src/hooks/useAppState.tsx +++ b/src/hooks/useAppState.tsx @@ -33,6 +33,13 @@ export interface CodeReference { source: 'ai' | 'user'; // How it was added } +export interface CodeReferenceFocus { + filePath: string; + startLine?: number; + endLine?: number; + ts: number; +} + interface AppState { // View state viewMode: ViewMode; @@ -131,6 +138,7 @@ interface AppState { removeCodeReference: (id: string) => void; clearAICodeReferences: () => void; clearCodeReferences: () => void; + codeReferenceFocus: CodeReferenceFocus | null; } const AppStateContext = createContext(null); @@ -215,6 +223,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { // Code References Panel state const [codeReferences, setCodeReferences] = useState([]); const [isCodePanelOpen, setCodePanelOpen] = useState(false); + const [codeReferenceFocus, setCodeReferenceFocus] = useState(null); const normalizePath = useCallback((p: string) => { return p.replace(/\\/g, '/').replace(/^\.?\//, ''); @@ -283,6 +292,16 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { // Auto-open panel when references are added setCodePanelOpen(true); + + // Signal the Code Inspector to focus (scroll + glow) this reference. + // This should happen even if the reference already exists (duplicates are ignored), + // so it must be separate from the add-to-list behavior. + setCodeReferenceFocus({ + filePath: ref.filePath, + startLine: ref.startLine, + endLine: ref.endLine, + ts: Date.now(), + }); // Track AI highlights separately so they can be toggled off in the UI if (ref.nodeId && ref.source === 'ai') { @@ -832,6 +851,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { const clearCodeReferences = useCallback(() => { setCodeReferences([]); setCodePanelOpen(false); + setCodeReferenceFocus(null); }, []); const toggleLabelVisibility = useCallback((label: NodeLabel) => { @@ -914,6 +934,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { removeCodeReference, clearAICodeReferences, clearCodeReferences, + codeReferenceFocus, }; return (