From 1a309a7101411c4814556588cdbd10414ddeb6cc Mon Sep 17 00:00:00 2001 From: lichao-sun <47399294+lichao-sun@users.noreply.github.com> Date: Sun, 12 Apr 2026 13:30:58 -0400 Subject: [PATCH 1/3] feat(md): enhance Think/Research mode with auto-save, hyperlinks, and skill integration - Auto-save Think/Research results as _XX.md files (01-99) in the same directory - Insert hyperlinks on selected text pointing to generated files - Smart markdown-aware text matching (handles bold, backticks, tables, CJK) - Block-level annotation for tables with best-match scoring across all table blocks - Hover tooltip on .md hyperlinks with Open and Delete actions - Delete removes both the hyperlink/annotation and the linked file - Preserve scroll position when navigating between linked files - Keep Think Mode popup state alive while viewing linked files - Allow clipboard copy (Cmd+C) of selected text before popup interaction - Deep Research mode now uses inno-deep-research skill automatically - Summary extraction for annotation labels (skips headings and labels) Co-Authored-By: Claude Opus 4.6 (1M context) --- server/index.js | 3 + server/routes/quick-qa.js | 180 +++++ src/components/CodeEditor.jsx | 342 +++++++++- src/components/MarkdownSelectionPopup.jsx | 782 ++++++++++++++++++++++ 4 files changed, 1273 insertions(+), 34 deletions(-) create mode 100644 server/routes/quick-qa.js create mode 100644 src/components/MarkdownSelectionPopup.jsx diff --git a/server/index.js b/server/index.js index 1f861604..1be40f47 100755 --- a/server/index.js +++ b/server/index.js @@ -72,6 +72,7 @@ import computeRoutes from './routes/compute.js'; import newsRoutes from './routes/news.js'; import autoResearchRoutes from './routes/auto-research.js'; import referencesRoutes from './routes/references.js'; +import quickQaRoutes from './routes/quick-qa.js'; import { initializeDatabase, sessionDb, tagDb } from './database/db.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; import { IS_PLATFORM } from './constants/config.js'; @@ -531,6 +532,8 @@ app.use('/api/auto-research', authenticateToken, autoResearchRoutes); // References (literature library) API Routes (protected) app.use('/api/references', authenticateToken, referencesRoutes); +app.use('/api/quick-qa', authenticateToken, quickQaRoutes); + // Agent API Routes (uses API key authentication) app.use('/api/agent', agentRoutes); diff --git a/server/routes/quick-qa.js b/server/routes/quick-qa.js new file mode 100644 index 00000000..1f43b330 --- /dev/null +++ b/server/routes/quick-qa.js @@ -0,0 +1,180 @@ +/** + * Quick Q&A Route + * + * Provides lightweight endpoints for inline Q&A in markdown preview mode: + * - Fast mode: quick haiku answer via SSE streaming + * - Think mode: detailed sonnet analysis via SSE streaming + * - Deep Research mode: comprehensive opus report via SSE streaming + */ + +import { Router } from 'express'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { readFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const router = Router(); + +// Active query abort controllers +const activeQueries = new Map(); + +const FAST_SYSTEM_PROMPT = `You are a helpful assistant providing quick, concise answers about text selected from markdown documents. Keep responses brief and direct. Use markdown formatting in your response when appropriate.`; + +const THINK_SYSTEM_PROMPT = `You are a deep-thinking assistant. Provide a detailed, well-reasoned analysis with thorough explanations. Break down concepts, explore implications, and provide comprehensive insights. Use markdown formatting with clear structure (headings, lists, etc.).`; + +// Load the inno-deep-research skill as the Deep Research system prompt +let RESEARCH_SYSTEM_PROMPT; +try { + const skillPath = join(__dirname, '../../skills/inno-deep-research/SKILL.md'); + const raw = readFileSync(skillPath, 'utf8'); + // Strip YAML frontmatter (between --- markers) and use the rest as the prompt + const stripped = raw.replace(/^---[\s\S]*?---\s*/, '').trim(); + RESEARCH_SYSTEM_PROMPT = stripped; + console.log('[QuickQA] Loaded inno-deep-research skill for Deep Research mode'); +} catch (err) { + console.warn('[QuickQA] Could not load inno-deep-research skill, using fallback:', err.message); + RESEARCH_SYSTEM_PROMPT = `You are a comprehensive research assistant. Provide a thorough research report that includes: overview and background, key concepts, current state of knowledge, different perspectives, related work, and conclusions. Use markdown formatting with clear structure, headings, and well-organized sections.`; +} + +const MODE_CONFIG = { + fast: { model: 'haiku', systemPrompt: FAST_SYSTEM_PROMPT }, + think: { model: 'sonnet', systemPrompt: THINK_SYSTEM_PROMPT }, + research: { model: 'sonnet', systemPrompt: RESEARCH_SYSTEM_PROMPT }, +}; + +function buildPrompt(selectedText, question, mode) { + if (mode === 'think') { + return question + ? `Please think deeply and provide a detailed, well-reasoned analysis of the following text, focusing on this question: ${question}\n\nSelected text:\n"""\n${selectedText}\n"""` + : `Please think deeply and provide a detailed, well-reasoned analysis of the following text. Break down the concepts, explore implications, and provide thorough explanations.\n\nSelected text:\n"""\n${selectedText}\n"""`; + } + if (mode === 'research') { + return question + ? `Please conduct a comprehensive deep research on the following topic/text, focusing on: ${question}\n\nProvide a thorough research report with: 1) Overview and background 2) Key concepts 3) Current state of knowledge 4) Different perspectives 5) Related work 6) Conclusions.\n\nSelected text:\n"""\n${selectedText}\n"""` + : `Please conduct a comprehensive deep research on the following topic/text. Provide a thorough research report with: 1) Overview and background 2) Key concepts 3) Current state of knowledge 4) Different perspectives 5) Related work 6) Conclusions.\n\nSelected text:\n"""\n${selectedText}\n"""`; + } + // fast + return question + ? `The user has selected the following text from a markdown document and has a question about it.\n\nSelected text:\n"""\n${selectedText}\n"""\n\nUser's question: ${question}\n\nPlease provide a concise, direct answer. Keep it brief and focused.` + : `The user has selected the following text from a markdown document and wants a quick explanation.\n\nSelected text:\n"""\n${selectedText}\n"""\n\nPlease provide a concise explanation of this text. Keep it brief and focused.`; +} + +/** + * Shared SSE query handler for all modes (fast, think, research). + */ +async function handleQueryRequest(req, res) { + const { selectedText, question, projectPath, mode = 'fast' } = req.body; + + if (!selectedText || selectedText.length < 2) { + return res.status(400).json({ error: 'Selected text must be at least 2 characters' }); + } + + const config = MODE_CONFIG[mode] || MODE_CONFIG.fast; + const queryId = `quick-qa-${mode}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + // Set up SSE + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Query-Id', queryId); + res.flushHeaders(); + + const abortController = new AbortController(); + activeQueries.set(queryId, abortController); + + // Clean up on client disconnect + res.on('close', () => { + if (!res.writableFinished) { + abortController.abort(); + activeQueries.delete(queryId); + } + }); + + const prompt = buildPrompt(selectedText, question, mode); + + const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT; + process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000'; + + try { + const conversation = query({ + prompt, + options: { + cwd: projectPath || process.cwd(), + model: config.model, + systemPrompt: config.systemPrompt, + tools: [], + allowedTools: [], + settingSources: [], + permissionMode: 'default', + }, + }); + + let hasStreamedContent = false; + let fullContent = ''; + + for await (const message of conversation) { + if (abortController.signal.aborted) break; + + if (message.type === 'assistant' && message.message?.content) { + for (const block of message.message.content) { + if (block.type === 'text' && block.text) { + hasStreamedContent = true; + fullContent += block.text; + res.write(`data: ${JSON.stringify({ type: 'text', content: block.text })}\n\n`); + } + } + } + + if (message.type === 'result') { + if (message.subtype === 'success' && message.result && !hasStreamedContent) { + fullContent = message.result; + res.write(`data: ${JSON.stringify({ type: 'text', content: message.result })}\n\n`); + } else if (message.subtype !== 'success') { + const errMsg = Array.isArray(message.errors) ? message.errors.join('\n') : 'Query failed'; + res.write(`data: ${JSON.stringify({ type: 'error', message: errMsg })}\n\n`); + } + } + } + + res.write(`data: ${JSON.stringify({ type: 'done', fullContent })}\n\n`); + } catch (error) { + if (error.name !== 'AbortError') { + console.error(`[QuickQA/${mode}] Error:`, error.message); + res.write(`data: ${JSON.stringify({ type: 'error', message: error.message })}\n\n`); + } + } finally { + activeQueries.delete(queryId); + if (prevStreamTimeout !== undefined) { + process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = prevStreamTimeout; + } else { + delete process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT; + } + res.end(); + } +} + +/** + * POST /api/quick-qa + * Unified endpoint for all modes. Pass { mode: 'fast' | 'think' | 'research' } in body. + */ +router.post('/', handleQueryRequest); + +/** + * POST /api/quick-qa/abort + * Aborts an active query. + */ +router.post('/abort', (req, res) => { + const { queryId } = req.body; + const controller = activeQueries.get(queryId); + if (controller) { + controller.abort(); + activeQueries.delete(queryId); + return res.json({ success: true }); + } + res.json({ success: false, message: 'Query not found' }); +}); + +export default router; diff --git a/src/components/CodeEditor.jsx b/src/components/CodeEditor.jsx index f14de7a8..63c38792 100644 --- a/src/components/CodeEditor.jsx +++ b/src/components/CodeEditor.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; +import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import CodeMirror from '@uiw/react-codemirror'; import { javascript } from '@codemirror/lang-javascript'; import { python } from '@codemirror/lang-python'; @@ -11,7 +11,7 @@ import { StreamLanguage } from '@codemirror/language'; import { EditorView, showPanel, ViewPlugin } from '@codemirror/view'; import { unifiedMergeView, getChunks } from '@codemirror/merge'; import { showMinimap } from '@replit/codemirror-minimap'; -import { X, Save, Download, Maximize2, Minimize2, Settings as SettingsIcon, FileText, MessageSquarePlus } from 'lucide-react'; +import { X, Save, Download, Maximize2, Minimize2, Settings as SettingsIcon, FileText, MessageSquarePlus, ExternalLink } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; @@ -19,10 +19,11 @@ import rehypeKatex from 'rehype-katex'; import rehypeRaw from 'rehype-raw'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; -import { api } from '../utils/api'; +import { api, authenticatedFetch } from '../utils/api'; import { useTranslation } from 'react-i18next'; import { Eye, Code2 } from 'lucide-react'; import JsonTreeViewer from './JsonTreeViewer'; +import MarkdownSelectionPopup from './MarkdownSelectionPopup'; // Custom .env file syntax highlighting const envLanguage = StreamLanguage.define({ @@ -102,41 +103,176 @@ function MarkdownCodeBlock({ inline, className, children, ...props }) { ); } -const markdownPreviewComponents = { - code: MarkdownCodeBlock, - blockquote: ({ children }) => ( -
- {children} -
- ), - a: ({ href, children }) => ( - - {children} - - ), - table: ({ children }) => ( -
- {children}
-
- ), - thead: ({ children }) => {children}, - th: ({ children }) => ( - {children} - ), - td: ({ children }) => ( - {children} - ), -}; - -function MarkdownPreview({ content }) { +/** + * Link component with hover tooltip for local .md links (Think Mode results). + * Shows Open / Delete actions on hover. + */ +function MdLinkWithTooltip({ href, children, onOpenLinkedFile, onDeleteLink }) { + const [showTooltip, setShowTooltip] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const tooltipTimeout = useRef(null); + const containerRef = useRef(null); + + const isLocalMd = href && !href.startsWith('http') && !href.startsWith('//') && href.endsWith('.md'); + + const handleMouseEnter = () => { + if (!isLocalMd) return; + clearTimeout(tooltipTimeout.current); + setShowTooltip(true); + }; + + const handleMouseLeave = () => { + tooltipTimeout.current = setTimeout(() => { + setShowTooltip(false); + setConfirmDelete(false); + }, 200); + }; + + const handleTooltipEnter = () => { + clearTimeout(tooltipTimeout.current); + }; + + const handleTooltipLeave = () => { + tooltipTimeout.current = setTimeout(() => { + setShowTooltip(false); + setConfirmDelete(false); + }, 200); + }; + + useEffect(() => { + return () => clearTimeout(tooltipTimeout.current); + }, []); + + if (!isLocalMd) { + return ( + + {children} + + ); + } + + return ( + + { + e.preventDefault(); + onOpenLinkedFile?.(href); + }} + > + {children} + + {showTooltip && ( +
+
+ {!confirmDelete ? ( + <> + +
+ + + ) : ( + <> + Delete this note? + + + + )} +
+ {/* Arrow pointing down */} +
+
+
+
+ )} + + ); +} + +function buildMarkdownComponents(onOpenLinkedFile, onDeleteLink) { + return { + code: MarkdownCodeBlock, + blockquote: ({ children }) => ( +
+ {children} +
+ ), + a: ({ href, children }) => ( + + {children} + + ), + table: ({ children }) => ( +
+ {children}
+
+ ), + thead: ({ children }) => {children}, + th: ({ children }) => ( + {children} + ), + td: ({ children }) => ( + {children} + ), + }; +} + +function MarkdownPreview({ content, onOpenLinkedFile, onDeleteLink }) { const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []); const rehypePlugins = useMemo(() => [rehypeRaw, rehypeKatex], []); + const components = useMemo( + () => buildMarkdownComponents(onOpenLinkedFile, onDeleteLink), + [onOpenLinkedFile, onDeleteLink] + ); return ( {content} @@ -184,8 +320,11 @@ function CodeEditor({ file, onClose, projectPath, selectedProject = null, onStar }); const [candidates, setCandidates] = useState(null); const [resolvedPath, setResolvedPath] = useState(null); + const [overlayContent, setOverlayContent] = useState(null); // { content, title } const editorRef = useRef(null); const abortRef = useRef(null); + const markdownPreviewRef = useRef(null); + const savedScrollTopRef = useRef(0); const handleAbortAndClose = () => { abortRef.current?.abort(); @@ -241,6 +380,86 @@ function CodeEditor({ file, onClose, projectPath, selectedProject = null, onStar ); }; + const handleStartSessionFromSelection = (prompt, _mode) => { + if (!selectedProject || !onStartWorkspaceQa) return; + onStartWorkspaceQa(selectedProject, prompt); + }; + + // Handle opening a linked .md file (Think Mode result) in the overlay + const handleOpenLinkedFile = useCallback(async (href) => { + if (!href || !file.projectName) return; + try { + // Resolve relative href to full path + const currentPath = resolvedPath || file.path; + const lastSlash = currentPath.lastIndexOf('/'); + const dir = lastSlash >= 0 ? currentPath.substring(0, lastSlash + 1) : ''; + const linkedPath = `${dir}${href}`; + + const res = await authenticatedFetch( + `/api/projects/${file.projectName}/files/content?path=${encodeURIComponent(linkedPath)}` + ); + if (!res.ok) throw new Error(`Failed to load: ${res.status}`); + const text = await res.text(); + // Save current scroll position before switching to overlay + if (markdownPreviewRef.current) { + savedScrollTopRef.current = markdownPreviewRef.current.scrollTop; + } + setOverlayContent({ content: text, title: href }); + // Scroll to top so the linked file starts at the beginning + if (markdownPreviewRef.current) { + markdownPreviewRef.current.scrollTop = 0; + } + } catch (err) { + console.error('Failed to open linked file:', err); + } + }, [file.projectName, file.path, resolvedPath]); + + // Handle deleting a linked .md file and removing the hyperlink from content + const handleDeleteLink = useCallback(async (href, linkText) => { + if (!href || !file.projectName) return; + try { + // Resolve the linked file path + const currentPath = resolvedPath || file.path; + const lastSlash = currentPath.lastIndexOf('/'); + const dir = lastSlash >= 0 ? currentPath.substring(0, lastSlash + 1) : ''; + const linkedPath = `${dir}${href}`; + + // Delete the linked file + await api.deleteFile(file.projectName, linkedPath); + + // Remove the hyperlink from content + const escapedHref = href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + // First, try to remove entire annotation lines (> 📎 [text](href) generated by Think/Research mode) + // Match the full line including leading newline and trailing newline + const annotationPattern = new RegExp( + `\\n> 📎 \\[[^\\]]*?\\]\\(${escapedHref}\\)\\n?`, + 'g' + ); + let updatedContent = content.replace(annotationPattern, '\n'); + + // If no annotation line was removed, fall back to inline link removal: [text](href) → text + if (updatedContent === content) { + const linkPattern = new RegExp( + `\\[([^\\]]*?)\\]\\(${escapedHref}\\)`, + 'g' + ); + updatedContent = content.replace(linkPattern, '$1'); + } + if (updatedContent !== content) { + setContent(updatedContent); + // Auto-save the updated content + try { + await api.saveFile(file.projectName, resolvedPath || file.path, updatedContent); + } catch (saveErr) { + console.error('Failed to save after link removal:', saveErr); + } + } + } catch (err) { + console.error('Failed to delete linked file:', err); + } + }, [file.projectName, file.path, resolvedPath, content]); + // Reset disambiguation state when file changes useEffect(() => { setResolvedPath(null); @@ -1042,9 +1261,64 @@ function CodeEditor({ file, onClose, projectPath, selectedProject = null, onStar {file.name}
) : viewMode === 'preview' && isMarkdownFile ? ( -
-
- +
+ {/* Overlay: linked file view */} + {overlayContent && ( + <> +
+ + {overlayContent.title} + + +
+
+ +
+ + )} + {/* Original file + selection popup: hidden while overlay is open, but always mounted */} +
+
+ +
+ { + if (markdownPreviewRef.current) { + savedScrollTopRef.current = markdownPreviewRef.current.scrollTop; + } + setOverlayContent(overlay); + requestAnimationFrame(() => { + if (markdownPreviewRef.current) markdownPreviewRef.current.scrollTop = 0; + }); + }} + projectName={file.projectName} + mdContent={content} + onContentChange={setContent} + filePath={resolvedPath || file.path} + onSaveFile={async (newContent) => { + try { + await api.saveFile(file.projectName, resolvedPath || file.path, newContent); + } catch (err) { + console.error('Auto-save failed:', err); + } + }} + />
) : viewMode === 'preview' && isJsonFile ? ( diff --git a/src/components/MarkdownSelectionPopup.jsx b/src/components/MarkdownSelectionPopup.jsx new file mode 100644 index 00000000..edca1141 --- /dev/null +++ b/src/components/MarkdownSelectionPopup.jsx @@ -0,0 +1,782 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { Zap, Brain, Search, X, Send, Loader2, ExternalLink } from 'lucide-react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { authenticatedFetch, api } from '../utils/api'; + +const MIN_SELECTION_LENGTH = 2; + +const popupStyles = ` +@keyframes md-popup-in { + from { + opacity: 0; + transform: translateX(-50%) translateY(-4px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} +.md-selection-popup { + animation: md-popup-in 0.15s ease-out; +} +`; + +// Compact markdown components for popup rendering +const inlineMarkdownComponents = { + h1: ({ children }) =>

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + h4: ({ children }) =>

{children}

, + p: ({ children }) =>

{children}

, + li: ({ children }) =>
  • {children}
  • , + strong: ({ children }) => {children}, + code: ({ inline, className, children, ...props }) => { + const raw = Array.isArray(children) ? children.join('') : String(children ?? ''); + if (inline || !/[\r\n]/.test(raw)) { + return ( + + {children} + + ); + } + const match = /language-(\w+)/.exec(className || ''); + return ( + + {raw} + + ); + }, +}; + +const MODE_CONFIG = { + fast: { icon: Zap, label: 'Fast', color: 'amber', title: 'Quick inline answer' }, + think: { icon: Brain, label: 'Think', color: 'blue', title: 'Detailed analysis' }, + research: { icon: Search, label: 'Deep Research', color: 'purple', title: 'Comprehensive research report' }, +}; + +function formatElapsed(ms) { + const totalSec = Math.floor(ms / 1000); + const min = Math.floor(totalSec / 60); + const sec = totalSec % 60; + return `${min}:${sec.toString().padStart(2, '0')}`; +} + +/** + * Find the selected plain text inside the raw markdown source, accounting for + * inline formatting characters (backticks, *, _, ~, \\) that appear in the + * source but not in the rendered/selected text. + * + * Returns the matched raw markdown span, or null if not found. + */ +function findMarkdownSpan(mdContent, plainText) { + // 1. Try exact match first (works for unformatted text) + if (mdContent.includes(plainText)) return plainText; + + // 2. Split the selected text into fine-grained segments by character-type boundaries + // (letters, CJK, digits, punctuation, whitespace) so that markdown formatting + // chars like ** or ` that appear between segments can be matched. + const segments = plainText.match( + /[a-zA-Z0-9]+|[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef]+|[^\sa-zA-Z0-9\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef]+|\s+/g + ); + if (!segments || segments.length === 0) return null; + + const MD = '[`*_~\\\\]{0,4}'; + const patternParts = segments.map((seg) => { + if (/^\s+$/.test(seg)) return '[\\s\\n]+'; + return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + }); + + try { + const regex = new RegExp(MD + patternParts.join(MD) + MD); + const match = mdContent.match(regex); + if (match) return match[0]; + } catch { + // regex too complex or invalid – fall through + } + + return null; +} + +/** + * When inline matching fails (e.g. tables, block elements), find the markdown + * block that contains the selected text and return its end position so we can + * append a link annotation after it. + * + * Strategy: extract distinctive tokens from the selected text, search for + * markdown table rows (lines starting with |) that contain those tokens, + * then return the full table block range. + * + * Returns { blockEnd: number, label: string } or null. + */ +function findContainingBlock(mdContent, plainText) { + // Extract ALL meaningful tokens from the selected text (not just first 8) + const tokens = plainText + .split(/[\s\t\n]+/) + .filter((t) => t.length >= 2); + if (tokens.length === 0) return null; + + const lines = mdContent.split('\n'); + + // 1. Identify all table blocks (groups of consecutive lines starting with |) + const tableBlocks = []; + let blockStart = -1; + for (let i = 0; i < lines.length; i++) { + const isTableLine = lines[i].trim().startsWith('|'); + if (isTableLine && blockStart === -1) { + blockStart = i; + } else if (!isTableLine && blockStart !== -1) { + tableBlocks.push({ start: blockStart, end: i - 1 }); + blockStart = -1; + } + } + if (blockStart !== -1) { + tableBlocks.push({ start: blockStart, end: lines.length - 1 }); + } + + if (tableBlocks.length === 0) return null; + + // 2. Score each table block by how many tokens it contains + let bestBlock = null; + let bestScore = 0; + + for (const block of tableBlocks) { + // Combine all lines of this table into one string for matching + let tableText = ''; + for (let i = block.start; i <= block.end; i++) { + tableText += lines[i] + '\n'; + } + + let score = 0; + for (const token of tokens) { + if (tableText.includes(token)) score++; + } + + if (score > bestScore) { + bestScore = score; + bestBlock = block; + } + } + + // Require at least 1 match (lower bar for short selections with few tokens) + const minScore = tokens.length >= 3 ? 2 : 1; + if (bestScore < minScore || !bestBlock) return null; + + // Compute the character offset of the end of the table block + let blockEnd = 0; + for (let i = 0; i <= bestBlock.end; i++) { + blockEnd += lines[i].length + 1; // +1 for \n + } + + const label = tokens.slice(0, 3).join(' '); + return { blockEnd, label }; +} + +/** + * Find the next available _XX.md suffix (01–99) for a given base path. + * baseName: filename without extension, e.g. "notes" + * dir: directory path, e.g. "docs/" + * existingContent: the current markdown content (used to scan for existing links) + */ +function findNextSuffix(existingContent, baseName) { + const used = new Set(); + // Scan for patterns like baseName_01.md, baseName_02.md, etc. in existing links + const regex = new RegExp(`${baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}_(\\d{2})\\.md`, 'g'); + let match; + while ((match = regex.exec(existingContent)) !== null) { + used.add(parseInt(match[1], 10)); + } + for (let i = 1; i <= 99; i++) { + if (!used.has(i)) return String(i).padStart(2, '0'); + } + return null; // all 99 used +} + +function MarkdownSelectionPopup({ containerRef, onStartSession, onOpenOverlay, projectName, mdContent, onContentChange, filePath, onSaveFile }) { + const [popupState, setPopupState] = useState('hidden'); // hidden | ready | answering | answered + const [selectedText, setSelectedText] = useState(''); + const [position, setPosition] = useState({ top: 0, left: 0 }); + const [question, setQuestion] = useState(''); + const [answer, setAnswer] = useState(''); + const [fullResult, setFullResult] = useState(''); + const [queryId, setQueryId] = useState(null); + const [activeMode, setActiveMode] = useState('fast'); + const [expanded, setExpanded] = useState(false); + const [startTime, setStartTime] = useState(null); + const [elapsed, setElapsed] = useState(0); + const popupRef = useRef(null); + const inputRef = useRef(null); + const answerRef = useRef(null); + const [highlightRects, setHighlightRects] = useState([]); + const popupStateRef = useRef(popupState); + const selectedTextRef = useRef(selectedText); + popupStateRef.current = popupState; + selectedTextRef.current = selectedText; + + const isBackgroundMode = activeMode === 'think' || activeMode === 'research'; + + // Timer for background modes + useEffect(() => { + if (!startTime || popupState !== 'answering') return; + const interval = setInterval(() => { + setElapsed(Date.now() - startTime); + }, 1000); + return () => clearInterval(interval); + }, [startTime, popupState]); + + // Detect text selection inside the markdown preview container + useEffect(() => { + const container = containerRef?.current; + if (!container) return; + + const handleMouseUp = () => { + setTimeout(() => { + const selection = window.getSelection(); + const text = selection?.toString()?.trim(); + + if (!text || text.length < MIN_SELECTION_LENGTH) return; + if (!selection.rangeCount) return; + + const range = selection.getRangeAt(0); + if (!container.contains(range.commonAncestorContainer)) return; + + // Don't re-trigger if popup is already showing for this text + if (popupStateRef.current !== 'hidden' && text === selectedTextRef.current) return; + + const rect = range.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + // Compute highlight overlay rects + const clientRects = range.getClientRects(); + const rects = []; + for (let i = 0; i < clientRects.length; i++) { + const r = clientRects[i]; + if (r.width === 0 || r.height === 0) continue; + rects.push({ + top: r.top - containerRect.top + container.scrollTop, + left: r.left - containerRect.left, + width: r.width, + height: r.height, + }); + } + setHighlightRects(rects); + + const POPUP_WIDTH = 320; + const POPUP_MARGIN = 8; + const selectionCenter = rect.left - containerRect.left + rect.width / 2; + const clampedLeft = Math.max( + POPUP_MARGIN + POPUP_WIDTH / 2, + Math.min(selectionCenter, containerRect.width - POPUP_MARGIN - POPUP_WIDTH / 2) + ); + + setSelectedText(text); + setPosition({ + top: rect.bottom - containerRect.top + container.scrollTop + 8, + left: clampedLeft, + }); + setPopupState('ready'); + setAnswer(''); + setFullResult(''); + setQuestion(''); + setActiveMode('fast'); + setExpanded(false); + setStartTime(null); + setElapsed(0); + }, 10); + }; + + container.addEventListener('mouseup', handleMouseUp); + return () => container.removeEventListener('mouseup', handleMouseUp); + }, [containerRef]); + + // Close popup on click outside (only in ready state) + useEffect(() => { + if (popupState !== 'ready') return; + + const handleClickOutside = (e) => { + if (popupRef.current && !popupRef.current.contains(e.target)) { + handleClose(); + } + }; + + const timer = setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside); + }, 100); + + return () => { + clearTimeout(timer); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [popupState]); + + // Auto-focus input only when the user switches mode tabs (not on initial popup) + const hasInteracted = useRef(false); + useEffect(() => { + if (popupState === 'ready' && inputRef.current && hasInteracted.current) { + inputRef.current.focus(); + } + }, [popupState, activeMode]); + + // Reset interaction flag when popup opens fresh + useEffect(() => { + if (popupState === 'hidden') { + hasInteracted.current = false; + } + }, [popupState]); + + // Auto-scroll answer container while streaming (Fast mode only) + useEffect(() => { + if (answerRef.current && popupState === 'answering' && !isBackgroundMode) { + answerRef.current.scrollTop = answerRef.current.scrollHeight; + } + }, [answer, popupState, isBackgroundMode]); + + // Auto-expand when Fast mode answer completes and overflows + useEffect(() => { + if (popupState === 'answered' && answerRef.current && !expanded && !isBackgroundMode) { + if (answerRef.current.scrollHeight > answerRef.current.clientHeight) { + setExpanded(true); + } + } + }, [popupState, expanded, isBackgroundMode]); + + const handleClose = useCallback(() => { + if (queryId) { + authenticatedFetch('/api/quick-qa/abort', { + method: 'POST', + body: JSON.stringify({ queryId }), + }).catch(() => {}); + } + setHighlightRects([]); + setPopupState('hidden'); + setSelectedText(''); + setAnswer(''); + setFullResult(''); + setQuestion(''); + setQueryId(null); + setActiveMode('fast'); + setExpanded(false); + setStartTime(null); + setElapsed(0); + }, [queryId]); + + /** + * Stream an SSE response from /api/quick-qa for all modes. + * For Fast mode, streams answer inline. + * For Think/Research, runs in background and captures fullContent for overlay. + */ + const runSSEQuery = useCallback(async (mode, selectedTxt, userQuestion) => { + setPopupState('answering'); + setAnswer(''); + setFullResult(''); + setStartTime(Date.now()); + setElapsed(0); + + try { + const response = await authenticatedFetch('/api/quick-qa', { + method: 'POST', + body: JSON.stringify({ + selectedText: selectedTxt, + question: userQuestion || null, + mode, + }), + }); + + if (!response.ok) { + let errMsg = `Server error (${response.status})`; + try { + const errBody = await response.json(); + errMsg = errBody.error || errMsg; + } catch {} + setAnswer(`**Error:** ${errMsg}`); + setPopupState('answered'); + return; + } + + const id = response.headers.get('X-Query-Id'); + if (id) setQueryId(id); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + try { + const data = JSON.parse(line.slice(6)); + if (data.type === 'text') { + if (mode === 'fast') { + setAnswer((prev) => prev + data.content); + } + // For think/research we don't show streaming text in popup + } else if (data.type === 'error') { + setAnswer((prev) => prev + `\n\n**Error:** ${data.message}`); + } else if (data.type === 'done') { + if (data.fullContent) { + setFullResult(data.fullContent); + } + setPopupState('answered'); + } + } catch { + // skip malformed JSON + } + } + } + + setPopupState('answered'); + } catch (error) { + if (error.name !== 'AbortError') { + setAnswer(`**Error:** ${error.message}`); + setPopupState('answered'); + } + } + }, []); + + const handleSubmit = useCallback(async () => { + if (!selectedText) return; + const userQuestion = question.trim(); + await runSSEQuery(activeMode, selectedText, userQuestion); + }, [selectedText, question, activeMode, runSSEQuery]); + + /** + * Auto-save: when Think/Research mode completes, immediately save the result + * as a _XX.md file and insert a hyperlink on the selected text. + * Uses a ref to ensure it only fires once per query. + */ + const autoSavedRef = useRef(false); + const savedFileNameRef = useRef(null); + + useEffect(() => { + // Reset the flag when popup reopens + if (popupState === 'hidden' || popupState === 'ready') { + autoSavedRef.current = false; + savedFileNameRef.current = null; + } + }, [popupState]); + + useEffect(() => { + if ( + popupState !== 'answered' || + !fullResult || + !isBackgroundMode || + autoSavedRef.current + ) return; + if (!filePath || mdContent === undefined || !onContentChange || !onSaveFile || !projectName) return; + + autoSavedRef.current = true; + + (async () => { + try { + const modeLabel = activeMode === 'think' ? 'Think' : 'Deep Research'; + + // Compute directory and base name from filePath + const lastSlash = filePath.lastIndexOf('/'); + const dir = lastSlash >= 0 ? filePath.substring(0, lastSlash + 1) : ''; + const fileName = lastSlash >= 0 ? filePath.substring(lastSlash + 1) : filePath; + const dotIdx = fileName.lastIndexOf('.'); + const baseName = dotIdx > 0 ? fileName.substring(0, dotIdx) : fileName; + + const suffix = findNextSuffix(mdContent, baseName); + if (!suffix) return; + + const newFileName = `${baseName}_${suffix}.md`; + const newFilePath = `${dir}${newFileName}`; + savedFileNameRef.current = newFileName; + + // Build content for the new file + const header = question.trim() + ? `# ${modeLabel}: ${question.trim()}\n\n` + : `# ${modeLabel} Result\n\n`; + const newFileContent = header + fullResult; + + // Save the new .md file + await api.saveFile(projectName, newFilePath, newFileContent); + + // Insert hyperlink on the selected text in the original content + const matchedSpan = findMarkdownSpan(mdContent, selectedText); + if (matchedSpan) { + // Inline match: wrap the matched text with a link + const linkMarkdown = `[${matchedSpan}](${newFileName})`; + const updatedContent = mdContent.replace(matchedSpan, linkMarkdown); + if (updatedContent !== mdContent) { + onContentChange(updatedContent); + await onSaveFile(updatedContent); + } + } else { + // Block-level fallback (tables, code blocks, etc.): + // insert a link annotation line after the block + const block = findContainingBlock(mdContent, selectedText); + if (block) { + // Extract a one-line summary: find the first real sentence from the result + // Skip headings, empty lines, bold-only labels (ending with : or :), and short fragments + const stripMd = (s) => s.replace(/[*_`~>#\-]+/g, '').trim(); + const summaryLine = fullResult + .split('\n') + .map((l) => l.trim()) + .map(stripMd) + .find((l) => l.length > 15 && !l.endsWith(':') && !l.endsWith(':')); + const linkLabel = summaryLine + ? summaryLine.slice(0, 80) + (summaryLine.length > 80 ? '...' : '') + : question.trim() || `${modeLabel} Note`; + const annotation = `\n> 📎 [${linkLabel}](${newFileName})\n`; + const updatedContent = + mdContent.slice(0, block.blockEnd) + + annotation + + mdContent.slice(block.blockEnd); + onContentChange(updatedContent); + await onSaveFile(updatedContent); + } + } + } catch (err) { + console.error('Failed to auto-save Think Mode result:', err); + } + })(); + }, [popupState, fullResult, isBackgroundMode, filePath, mdContent, onContentChange, onSaveFile, projectName, activeMode, question, selectedText]); + + /** + * Open button: just show the result in the overlay (file already saved automatically). + */ + const handleOpenResult = useCallback(() => { + if (!fullResult || !onOpenOverlay) return; + const modeLabel = activeMode === 'think' ? 'Think' : 'Deep Research'; + const title = question.trim() + ? `${modeLabel}: ${question.trim()}` + : `${modeLabel} Result`; + onOpenOverlay({ content: fullResult, title }); + handleClose(); + }, [fullResult, activeMode, question, onOpenOverlay, handleClose]); + + const handleKeyDown = useCallback((e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } else if (e.key === 'Escape') { + handleClose(); + } + }, [handleSubmit, handleClose]); + + if (popupState === 'hidden') return null; + + const currentModeColor = MODE_CONFIG[activeMode].color; + const ModeIcon = MODE_CONFIG[activeMode].icon; + + return ( + <> + + {/* Highlight overlays */} + {highlightRects.map((rect, i) => ( +
    + ))} +
    + {/* Arrow */} +
    + +
    + {/* Row 1: Input / Status header */} +
    + {popupState === 'ready' ? ( +
    + setQuestion(e.target.value)} + onFocus={() => { hasInteracted.current = true; }} + onKeyDown={handleKeyDown} + placeholder={ + activeMode === 'fast' + ? 'Ask a question (optional, press Enter)' + : activeMode === 'think' + ? 'What to think about? (optional, press Enter)' + : 'Research focus? (optional, press Enter)' + } + className={`flex-1 px-2.5 py-1.5 text-xs rounded-md border + bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 + placeholder-gray-400 dark:placeholder-gray-500 + focus:outline-none focus:ring-1 + ${currentModeColor === 'amber' ? 'border-gray-200 dark:border-gray-600 focus:ring-amber-400 dark:focus:ring-amber-500 focus:border-amber-400' : ''} + ${currentModeColor === 'blue' ? 'border-gray-200 dark:border-gray-600 focus:ring-blue-400 dark:focus:ring-blue-500 focus:border-blue-400' : ''} + ${currentModeColor === 'purple' ? 'border-gray-200 dark:border-gray-600 focus:ring-purple-400 dark:focus:ring-purple-500 focus:border-purple-400' : ''}`} + /> + +
    + ) : ( +
    + + {question || MODE_CONFIG[activeMode].title} + + +
    + )} +
    + + {/* Row 2: Mode toggle tabs */} + {popupState === 'ready' && ( +
    + {Object.entries(MODE_CONFIG).map(([mode, config]) => { + const Icon = config.icon; + const isActive = activeMode === mode; + const colorMap = { + amber: isActive + ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 border-amber-300 dark:border-amber-700' + : 'text-gray-500 dark:text-gray-400 border-transparent hover:bg-gray-50 dark:hover:bg-gray-700/50', + blue: isActive + ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700' + : 'text-gray-500 dark:text-gray-400 border-transparent hover:bg-gray-50 dark:hover:bg-gray-700/50', + purple: isActive + ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 border-purple-300 dark:border-purple-700' + : 'text-gray-500 dark:text-gray-400 border-transparent hover:bg-gray-50 dark:hover:bg-gray-700/50', + }; + return ( + + ); + })} + +
    + )} + + {/* Content area: depends on mode */} + {(popupState === 'answering' || popupState === 'answered') && ( +
    + {isBackgroundMode ? ( + /* Think / Deep Research: background progress or result */ + popupState === 'answering' ? ( +
    + + + {activeMode === 'think' ? 'Thinking...' : 'Researching...'} + + + {formatElapsed(elapsed)} + +
    + ) : ( +
    + + + Done in {formatElapsed(elapsed)} + + {fullResult && onOpenOverlay && ( + + )} +
    + ) + ) : ( + /* Fast mode: inline streaming answer */ + <> + {popupState === 'answering' && !answer && ( +
    + + Thinking... +
    + )} + {answer && ( +
    + + {answer} + + {popupState === 'answering' && ( + + )} +
    + )} + + )} +
    + )} +
    +
    + + ); +} + +export default MarkdownSelectionPopup; From cf9ac46a0de1d82c34fe4b93c59ba0752d4bbb66 Mon Sep 17 00:00:00 2001 From: Lichao Sun <47399294+lichao-sun@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:02:46 -0400 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20PR=20#166=20review=20?= =?UTF-8?q?=E2=80=94=20security,=20correctness=20&=20performance=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validate projectPath against registered projects to prevent path traversal - Set CLAUDE_CODE_STREAM_CLOSE_TIMEOUT at module level to fix env race condition - Escape []() in linkLabel to prevent markdown injection - Use functional state updater in handleDeleteLink to fix stale closure - Check filesystem for existing _XX.md files to avoid suffix collisions - Reverse delete order: remove link first, then delete file (no broken links on failure) - Add mounted flag to auto-save effect to prevent updates after unmount - Extend findContainingBlock to support fenced code blocks and blockquotes - Fix click-outside effect missing handleClose in dependency array Co-Authored-By: Claude Opus 4.6 (1M context) --- server/routes/quick-qa.js | 38 +++++-- src/components/CodeEditor.jsx | 49 +++++---- src/components/MarkdownSelectionPopup.jsx | 123 ++++++++++++++++------ 3 files changed, 143 insertions(+), 67 deletions(-) diff --git a/server/routes/quick-qa.js b/server/routes/quick-qa.js index 1f43b330..6b57d2bc 100644 --- a/server/routes/quick-qa.js +++ b/server/routes/quick-qa.js @@ -10,8 +10,9 @@ import { Router } from 'express'; import { query } from '@anthropic-ai/claude-agent-sdk'; import { readFileSync } from 'fs'; -import { join, dirname } from 'path'; +import { join, dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; +import { projectDb } from '../database/db.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -21,11 +22,31 @@ const router = Router(); // Active query abort controllers const activeQueries = new Map(); +// Set stream timeout once at module level to avoid per-request race conditions +process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000'; + +/** + * Validate that projectPath is a known project directory. + * Returns the resolved absolute path, or null if invalid. + */ +function validateProjectPath(projectPath) { + if (!projectPath || typeof projectPath !== 'string') return null; + const resolved = resolve(projectPath); + // Check that the path corresponds to a registered project + const allProjects = projectDb.getAllProjects() || []; + const isKnown = allProjects.some(p => { + if (!p.path) return false; + const projResolved = resolve(p.path); + return resolved === projResolved || resolved.startsWith(projResolved + '/'); + }); + return isKnown ? resolved : null; +} + const FAST_SYSTEM_PROMPT = `You are a helpful assistant providing quick, concise answers about text selected from markdown documents. Keep responses brief and direct. Use markdown formatting in your response when appropriate.`; const THINK_SYSTEM_PROMPT = `You are a deep-thinking assistant. Provide a detailed, well-reasoned analysis with thorough explanations. Break down concepts, explore implications, and provide comprehensive insights. Use markdown formatting with clear structure (headings, lists, etc.).`; -// Load the inno-deep-research skill as the Deep Research system prompt +// Load the inno-deep-research skill as the Deep Research system prompt (uses sonnet model) let RESEARCH_SYSTEM_PROMPT; try { const skillPath = join(__dirname, '../../skills/inno-deep-research/SKILL.md'); @@ -75,6 +96,9 @@ async function handleQueryRequest(req, res) { const config = MODE_CONFIG[mode] || MODE_CONFIG.fast; const queryId = `quick-qa-${mode}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + // Validate projectPath to prevent path traversal + const validatedCwd = validateProjectPath(projectPath) || process.cwd(); + // Set up SSE res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); @@ -95,14 +119,11 @@ async function handleQueryRequest(req, res) { const prompt = buildPrompt(selectedText, question, mode); - const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT; - process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000'; - try { const conversation = query({ prompt, options: { - cwd: projectPath || process.cwd(), + cwd: validatedCwd, model: config.model, systemPrompt: config.systemPrompt, tools: [], @@ -147,11 +168,6 @@ async function handleQueryRequest(req, res) { } } finally { activeQueries.delete(queryId); - if (prevStreamTimeout !== undefined) { - process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = prevStreamTimeout; - } else { - delete process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT; - } res.end(); } } diff --git a/src/components/CodeEditor.jsx b/src/components/CodeEditor.jsx index 63c38792..7d7d279e 100644 --- a/src/components/CodeEditor.jsx +++ b/src/components/CodeEditor.jsx @@ -424,41 +424,46 @@ function CodeEditor({ file, onClose, projectPath, selectedProject = null, onStar const dir = lastSlash >= 0 ? currentPath.substring(0, lastSlash + 1) : ''; const linkedPath = `${dir}${href}`; - // Delete the linked file - await api.deleteFile(file.projectName, linkedPath); - - // Remove the hyperlink from content + // Remove the hyperlink from content first (so failure leaves orphan file, not broken link) const escapedHref = href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - // First, try to remove entire annotation lines (> 📎 [text](href) generated by Think/Research mode) - // Match the full line including leading newline and trailing newline - const annotationPattern = new RegExp( - `\\n> 📎 \\[[^\\]]*?\\]\\(${escapedHref}\\)\\n?`, - 'g' - ); - let updatedContent = content.replace(annotationPattern, '\n'); - - // If no annotation line was removed, fall back to inline link removal: [text](href) → text - if (updatedContent === content) { - const linkPattern = new RegExp( - `\\[([^\\]]*?)\\]\\(${escapedHref}\\)`, + // Use functional updater to avoid stale closure over content + let updatedContent = null; + setContent((prev) => { + // First, try to remove entire annotation lines (> 📎 [text](href) generated by Think/Research mode) + const annotationPattern = new RegExp( + `\\n> 📎 \\[[^\\]]*?\\]\\(${escapedHref}\\)\\n?`, 'g' ); - updatedContent = content.replace(linkPattern, '$1'); - } - if (updatedContent !== content) { - setContent(updatedContent); - // Auto-save the updated content + let result = prev.replace(annotationPattern, '\n'); + + // If no annotation line was removed, fall back to inline link removal: [text](href) → text + if (result === prev) { + const linkPattern = new RegExp( + `\\[([^\\]]*?)\\]\\(${escapedHref}\\)`, + 'g' + ); + result = prev.replace(linkPattern, '$1'); + } + updatedContent = result !== prev ? result : null; + return result; + }); + + // Auto-save the updated content, then delete the linked file + if (updatedContent !== null) { try { await api.saveFile(file.projectName, resolvedPath || file.path, updatedContent); } catch (saveErr) { console.error('Failed to save after link removal:', saveErr); } } + + // Delete the linked file after link is removed + await api.deleteFile(file.projectName, linkedPath); } catch (err) { console.error('Failed to delete linked file:', err); } - }, [file.projectName, file.path, resolvedPath, content]); + }, [file.projectName, file.path, resolvedPath]); // Reset disambiguation state when file changes useEffect(() => { diff --git a/src/components/MarkdownSelectionPopup.jsx b/src/components/MarkdownSelectionPopup.jsx index edca1141..34bed7df 100644 --- a/src/components/MarkdownSelectionPopup.jsx +++ b/src/components/MarkdownSelectionPopup.jsx @@ -109,9 +109,8 @@ function findMarkdownSpan(mdContent, plainText) { * block that contains the selected text and return its end position so we can * append a link annotation after it. * - * Strategy: extract distinctive tokens from the selected text, search for - * markdown table rows (lines starting with |) that contain those tokens, - * then return the full table block range. + * Detects table blocks (lines starting with |), fenced code blocks (``` or ~~~), + * and blockquote blocks (lines starting with >). * * Returns { blockEnd: number, label: string } or null. */ @@ -123,39 +122,64 @@ function findContainingBlock(mdContent, plainText) { if (tokens.length === 0) return null; const lines = mdContent.split('\n'); + const blocks = []; + + // Identify table blocks, fenced code blocks, and blockquote blocks + let i = 0; + while (i < lines.length) { + const trimmed = lines[i].trim(); + + // Fenced code block (``` or ~~~) + if (trimmed.startsWith('```') || trimmed.startsWith('~~~')) { + const fence = trimmed.slice(0, 3); + const blockStart = i; + i++; + while (i < lines.length && !lines[i].trim().startsWith(fence)) { + i++; + } + blocks.push({ start: blockStart, end: i < lines.length ? i : lines.length - 1 }); + i++; + continue; + } - // 1. Identify all table blocks (groups of consecutive lines starting with |) - const tableBlocks = []; - let blockStart = -1; - for (let i = 0; i < lines.length; i++) { - const isTableLine = lines[i].trim().startsWith('|'); - if (isTableLine && blockStart === -1) { - blockStart = i; - } else if (!isTableLine && blockStart !== -1) { - tableBlocks.push({ start: blockStart, end: i - 1 }); - blockStart = -1; + // Table block (consecutive lines starting with |) + if (trimmed.startsWith('|')) { + const blockStart = i; + while (i < lines.length && lines[i].trim().startsWith('|')) { + i++; + } + blocks.push({ start: blockStart, end: i - 1 }); + continue; } - } - if (blockStart !== -1) { - tableBlocks.push({ start: blockStart, end: lines.length - 1 }); + + // Blockquote block (consecutive lines starting with >) + if (trimmed.startsWith('>')) { + const blockStart = i; + while (i < lines.length && lines[i].trim().startsWith('>')) { + i++; + } + blocks.push({ start: blockStart, end: i - 1 }); + continue; + } + + i++; } - if (tableBlocks.length === 0) return null; + if (blocks.length === 0) return null; - // 2. Score each table block by how many tokens it contains + // Score each block by how many tokens it contains let bestBlock = null; let bestScore = 0; - for (const block of tableBlocks) { - // Combine all lines of this table into one string for matching - let tableText = ''; - for (let i = block.start; i <= block.end; i++) { - tableText += lines[i] + '\n'; + for (const block of blocks) { + let blockText = ''; + for (let j = block.start; j <= block.end; j++) { + blockText += lines[j] + '\n'; } let score = 0; for (const token of tokens) { - if (tableText.includes(token)) score++; + if (blockText.includes(token)) score++; } if (score > bestScore) { @@ -168,10 +192,10 @@ function findContainingBlock(mdContent, plainText) { const minScore = tokens.length >= 3 ? 2 : 1; if (bestScore < minScore || !bestBlock) return null; - // Compute the character offset of the end of the table block + // Compute the character offset of the end of the block let blockEnd = 0; - for (let i = 0; i <= bestBlock.end; i++) { - blockEnd += lines[i].length + 1; // +1 for \n + for (let j = 0; j <= bestBlock.end; j++) { + blockEnd += lines[j].length + 1; // +1 for \n } const label = tokens.slice(0, 3).join(' '); @@ -181,17 +205,24 @@ function findContainingBlock(mdContent, plainText) { /** * Find the next available _XX.md suffix (01–99) for a given base path. * baseName: filename without extension, e.g. "notes" - * dir: directory path, e.g. "docs/" * existingContent: the current markdown content (used to scan for existing links) + * existingFiles: array of filenames already on disk in the same directory */ -function findNextSuffix(existingContent, baseName) { +function findNextSuffix(existingContent, baseName, existingFiles = []) { const used = new Set(); - // Scan for patterns like baseName_01.md, baseName_02.md, etc. in existing links - const regex = new RegExp(`${baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}_(\\d{2})\\.md`, 'g'); + const escapedBase = baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`${escapedBase}_(\\d{2})\\.md`, 'g'); + // Scan markdown content for existing links let match; while ((match = regex.exec(existingContent)) !== null) { used.add(parseInt(match[1], 10)); } + // Also scan filesystem filenames to avoid overwriting unlinked files + const fileRegex = new RegExp(`^${escapedBase}_(\\d{2})\\.md$`); + for (const f of existingFiles) { + const fm = fileRegex.exec(f); + if (fm) used.add(parseInt(fm[1], 10)); + } for (let i = 1; i <= 99; i++) { if (!used.has(i)) return String(i).padStart(2, '0'); } @@ -313,7 +344,7 @@ function MarkdownSelectionPopup({ containerRef, onStartSession, onOpenOverlay, p clearTimeout(timer); document.removeEventListener('mousedown', handleClickOutside); }; - }, [popupState]); + }, [popupState, handleClose]); // Auto-focus input only when the user switches mode tabs (not on initial popup) const hasInteracted = useRef(false); @@ -479,6 +510,8 @@ function MarkdownSelectionPopup({ containerRef, onStartSession, onOpenOverlay, p autoSavedRef.current = true; + let mounted = true; + (async () => { try { const modeLabel = activeMode === 'think' ? 'Think' : 'Deep Research'; @@ -490,7 +523,24 @@ function MarkdownSelectionPopup({ containerRef, onStartSession, onOpenOverlay, p const dotIdx = fileName.lastIndexOf('.'); const baseName = dotIdx > 0 ? fileName.substring(0, dotIdx) : fileName; - const suffix = findNextSuffix(mdContent, baseName); + // List existing files in the directory to avoid suffix collisions + let existingFiles = []; + try { + const dirPath = dir || '.'; + const res = await api.getFiles(projectName, { path: dirPath, maxDepth: 1 }); + if (res.ok) { + const files = await res.json(); + existingFiles = (Array.isArray(files) ? files : []).map( + f => (typeof f === 'string' ? f : f.name || '').split('/').pop() + ); + } + } catch { + // Fall back to content-only scan + } + + if (!mounted) return; + + const suffix = findNextSuffix(mdContent, baseName, existingFiles); if (!suffix) return; const newFileName = `${baseName}_${suffix}.md`; @@ -505,6 +555,7 @@ function MarkdownSelectionPopup({ containerRef, onStartSession, onOpenOverlay, p // Save the new .md file await api.saveFile(projectName, newFilePath, newFileContent); + if (!mounted) return; // Insert hyperlink on the selected text in the original content const matchedSpan = findMarkdownSpan(mdContent, selectedText); @@ -529,9 +580,11 @@ function MarkdownSelectionPopup({ containerRef, onStartSession, onOpenOverlay, p .map((l) => l.trim()) .map(stripMd) .find((l) => l.length > 15 && !l.endsWith(':') && !l.endsWith(':')); - const linkLabel = summaryLine + const rawLabel = summaryLine ? summaryLine.slice(0, 80) + (summaryLine.length > 80 ? '...' : '') : question.trim() || `${modeLabel} Note`; + // Escape markdown link-breaking characters in the label + const linkLabel = rawLabel.replace(/[[\]()]/g, '\\$&'); const annotation = `\n> 📎 [${linkLabel}](${newFileName})\n`; const updatedContent = mdContent.slice(0, block.blockEnd) + @@ -545,6 +598,8 @@ function MarkdownSelectionPopup({ containerRef, onStartSession, onOpenOverlay, p console.error('Failed to auto-save Think Mode result:', err); } })(); + + return () => { mounted = false; }; }, [popupState, fullResult, isBackgroundMode, filePath, mdContent, onContentChange, onSaveFile, projectName, activeMode, question, selectedText]); /** From 1e595d6e098d06c5e7c5ba95d39b3cac77c50bce Mon Sep 17 00:00:00 2001 From: bbsngg Date: Thu, 23 Apr 2026 10:50:45 -0400 Subject: [PATCH 3/3] fix(md-popup): move handleClose above useEffect that depends on it Fixes "ReferenceError: Cannot access 'handleClose' before initialization" on mount. const declarations aren't hoisted, and the click-outside useEffect references handleClose in its dependency array, which is evaluated synchronously during the first render. Co-Authored-By: Claude Opus 4.7 --- src/components/MarkdownSelectionPopup.jsx | 40 +++++++++++------------ 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/components/MarkdownSelectionPopup.jsx b/src/components/MarkdownSelectionPopup.jsx index 34bed7df..f4060dc0 100644 --- a/src/components/MarkdownSelectionPopup.jsx +++ b/src/components/MarkdownSelectionPopup.jsx @@ -326,6 +326,26 @@ function MarkdownSelectionPopup({ containerRef, onStartSession, onOpenOverlay, p return () => container.removeEventListener('mouseup', handleMouseUp); }, [containerRef]); + const handleClose = useCallback(() => { + if (queryId) { + authenticatedFetch('/api/quick-qa/abort', { + method: 'POST', + body: JSON.stringify({ queryId }), + }).catch(() => {}); + } + setHighlightRects([]); + setPopupState('hidden'); + setSelectedText(''); + setAnswer(''); + setFullResult(''); + setQuestion(''); + setQueryId(null); + setActiveMode('fast'); + setExpanded(false); + setStartTime(null); + setElapsed(0); + }, [queryId]); + // Close popup on click outside (only in ready state) useEffect(() => { if (popupState !== 'ready') return; @@ -377,26 +397,6 @@ function MarkdownSelectionPopup({ containerRef, onStartSession, onOpenOverlay, p } }, [popupState, expanded, isBackgroundMode]); - const handleClose = useCallback(() => { - if (queryId) { - authenticatedFetch('/api/quick-qa/abort', { - method: 'POST', - body: JSON.stringify({ queryId }), - }).catch(() => {}); - } - setHighlightRects([]); - setPopupState('hidden'); - setSelectedText(''); - setAnswer(''); - setFullResult(''); - setQuestion(''); - setQueryId(null); - setActiveMode('fast'); - setExpanded(false); - setStartTime(null); - setElapsed(0); - }, [queryId]); - /** * Stream an SSE response from /api/quick-qa for all modes. * For Fast mode, streams answer inline.