diff --git a/apps/desktop/src/DocumentPreview.css b/apps/desktop/src/DocumentPreview.css index 58a73b7..825f7bc 100644 --- a/apps/desktop/src/DocumentPreview.css +++ b/apps/desktop/src/DocumentPreview.css @@ -1,22 +1,29 @@ +/* Document preview modal — warm-stone palette, tokens only. + * Lived as cold neutrals (#1f1f1f, #e5e5e5) until Wave 2 of the UX rebuild. + */ + .document-preview-overlay { position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.85); + inset: 0; + background: rgba(12, 10, 9, 0.82); + backdrop-filter: blur(6px); display: flex; align-items: center; justify-content: center; z-index: 10000; - padding: 2rem; - backdrop-filter: blur(4px); + padding: var(--space-6); + animation: docOverlayIn 180ms var(--ease-out); +} + +@keyframes docOverlayIn { + from { opacity: 0; } + to { opacity: 1; } } .document-preview-container { - background: #1f1f1f; - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 12px; + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-modal); width: 90vw; height: 90vh; max-width: 1200px; @@ -24,124 +31,134 @@ display: flex; flex-direction: column; overflow: hidden; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + box-shadow: var(--shadow-lg); + animation: docContainerIn 220ms var(--ease-out); +} + +@keyframes docContainerIn { + from { opacity: 0; transform: translateY(6px) scale(0.99); } + to { opacity: 1; transform: translateY(0) scale(1); } } .document-preview-header { display: flex; justify-content: space-between; align-items: center; - padding: 1rem 1.5rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); - background: #171717; + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--color-border); + background: var(--color-bg-elevated); flex-shrink: 0; + gap: var(--space-4); } .document-preview-title { margin: 0; - font-size: 0.9375rem; + font-family: var(--font-sans); + font-size: var(--text-sm); font-weight: 600; - color: #f5f5f5; + letter-spacing: -0.01em; + color: var(--color-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; - margin-right: 1rem; } .document-preview-controls { display: flex; align-items: center; - gap: 1rem; + gap: var(--space-3); } -.document-preview-pagination { +.document-preview-pagination, +.document-preview-zoom { display: flex; align-items: center; - gap: 0.5rem; - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 6px; - padding: 0.25rem 0.5rem; + gap: var(--space-1); + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-pill); + padding: 2px var(--space-1); } -.preview-nav-button { - background: none; +.preview-nav-button, +.preview-zoom-button { + background: transparent; border: none; - color: #e5e5e5; - font-size: 1rem; + color: var(--color-text-secondary); + font-family: var(--font-mono); + font-size: 14px; + font-weight: 500; + line-height: 1; cursor: pointer; - padding: 0.25rem 0.5rem; - border-radius: 4px; - transition: background 0.2s ease; + padding: 6px 10px; + border-radius: var(--radius-pill); + transition: background var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out); } -.preview-nav-button:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.1); +.preview-nav-button:hover:not(:disabled), +.preview-zoom-button:hover { + background: var(--color-bg-hover); + color: var(--color-text-primary); } .preview-nav-button:disabled { - opacity: 0.4; + opacity: 0.35; cursor: not-allowed; } -.preview-page-info { - font-size: 0.8125rem; - color: #a3a3a3; +.preview-page-info, +.preview-zoom-info { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; + font-size: var(--text-xs); + color: var(--color-text-muted); min-width: 60px; text-align: center; - font-weight: 500; } -.document-preview-zoom { - display: flex; - align-items: center; - gap: 0.5rem; - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 6px; - padding: 0.25rem 0.5rem; -} - -.preview-zoom-button { - background: none; - border: none; - color: #e5e5e5; - font-size: 1rem; +.preview-close-button { + background: transparent; + border: 1px solid var(--color-border); + border-radius: var(--radius-pill); + color: var(--color-text-secondary); + font-family: var(--font-sans); + font-size: 18px; + line-height: 1; cursor: pointer; - padding: 0.25rem 0.5rem; - border-radius: 4px; - transition: background 0.2s ease; - font-weight: 600; + padding: 6px 12px; + transition: background var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out), + border-color var(--duration-fast) var(--ease-out); } -.preview-zoom-button:hover { - background: rgba(255, 255, 255, 0.1); +.preview-close-button:hover { + background: var(--color-bg-hover); + color: var(--color-text-primary); + border-color: var(--color-border-hover); } -.preview-zoom-info { - font-size: 0.8125rem; - color: #a3a3a3; - min-width: 50px; - text-align: center; +.preview-view-toggle { + background: transparent; + border: 1px solid var(--color-border); + border-radius: var(--radius-pill); + color: var(--color-text-secondary); + font-family: var(--font-sans); + font-size: var(--text-sm); font-weight: 500; -} - -.preview-close-button { - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 6px; - color: #e5e5e5; - font-size: 1.25rem; cursor: pointer; - padding: 0.5rem 0.75rem; - transition: all 0.2s ease; - line-height: 1; + padding: 6px 14px; + transition: background var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out), + border-color var(--duration-fast) var(--ease-out); } -.preview-close-button:hover { - background: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 255, 255, 0.15); +.preview-view-toggle:hover { + background: var(--color-bg-hover); + color: var(--color-text-primary); + border-color: var(--color-border-hover); } .document-preview-content { @@ -150,21 +167,24 @@ display: flex; align-items: flex-start; justify-content: center; - padding: 2rem; - background: #171717; + padding: var(--space-6); + background: var(--color-bg-primary); position: relative; } .preview-loading { - color: #a3a3a3; - font-size: 0.9375rem; + color: var(--color-text-secondary); + font-family: var(--font-serif); + font-style: italic; + font-size: var(--text-lg); + letter-spacing: -0.01em; } .preview-error { - color: #f87171; - font-size: 0.9375rem; + color: var(--color-error); + font-size: var(--text-sm); text-align: center; - padding: 2rem; + padding: var(--space-6); } .preview-single-page-container { @@ -173,51 +193,34 @@ align-items: flex-start; width: 100%; min-height: 100%; - padding-top: 1rem; + padding-top: var(--space-2); } .preview-pages-container { display: flex; flex-direction: column; align-items: center; - gap: 1.5rem; + gap: var(--space-5); width: 100%; - padding: 1rem 0; + padding: var(--space-3) 0; } .preview-pdf-page { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); - border-radius: 4px; + box-shadow: var(--shadow-md); + border-radius: var(--radius-xs); background: white; margin: 0 auto; } -.preview-view-toggle { - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 6px; - color: #e5e5e5; - font-size: 0.8125rem; - cursor: pointer; - padding: 0.5rem 0.75rem; - transition: all 0.2s ease; - font-weight: 500; -} - -.preview-view-toggle:hover { - background: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 255, 255, 0.15); -} - .preview-iframe { width: 100%; height: 100%; border: none; - border-radius: 4px; + border-radius: var(--radius-xs); background: white; } -/* PDF text highlight — citation grounding, uses amber cite color */ +/* PDF text highlight — amber citation glow, NEVER Tailwind warning yellow */ .pdf-highlight { background: rgba(217, 146, 90, 0.32); border-radius: 2px; @@ -226,10 +229,12 @@ } .preview-highlight-badge { + display: inline-flex; + align-items: center; font-family: var(--font-mono); font-size: 11px; font-variant-numeric: tabular-nums; - letter-spacing: 0.02em; + letter-spacing: 0.04em; color: var(--color-cite); font-weight: 500; padding: 3px 10px; @@ -238,4 +243,3 @@ border-radius: var(--radius-pill); white-space: nowrap; } - diff --git a/apps/desktop/src/DocumentPreview.tsx b/apps/desktop/src/DocumentPreview.tsx index 09e8a71..6cfe168 100644 --- a/apps/desktop/src/DocumentPreview.tsx +++ b/apps/desktop/src/DocumentPreview.tsx @@ -1,11 +1,19 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Document, Page, pdfjs } from 'react-pdf'; import 'react-pdf/dist/Page/AnnotationLayer.css'; import 'react-pdf/dist/Page/TextLayer.css'; import './DocumentPreview.css'; -// Set up PDF.js worker - use local worker for offline support -// Worker file copied from react-pdf's pdfjs-dist (version 5.4.296) to public directory +// Structural subset of the PDFDocumentProxy shape we actually use. Avoids +// react-pdf's bundled pdfjs-dist type-identity skew with top-level pdfjs-dist. +interface LoadedPdf { + numPages: number; + getPage: (n: number) => Promise<{ + getTextContent: () => Promise<{ items: Array<{ str?: string } | unknown> }>; + }>; +} + +// Local worker — keeps this offline-first. pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs'; interface DocumentPreviewProps { @@ -28,93 +36,108 @@ function DocumentPreview({ isOpen, onClose, documentUrl, filename, highlightText const [scale, setScale] = useState(1.2); const [showAllPages, setShowAllPages] = useState(false); const [highlightPageFound, setHighlightPageFound] = useState(false); + const overlayRef = useRef(null); const isPdf = filename.toLowerCase().endsWith('.pdf'); - // Search snippet: take first 60 chars of highlight text for matching - const searchSnippet = highlightText - ? normalizeText(highlightText).slice(0, 60) - : null; + // Tight search target — first 60 chars of the normalized snippet. + const searchSnippet = highlightText ? normalizeText(highlightText).slice(0, 60) : null; + // Re-focus the overlay when a new preview opens so keyboard shortcuts work + // without a click-to-focus step. useEffect(() => { if (isOpen) { setPageNumber(1); setLoading(true); setError(null); setHighlightPageFound(false); + const id = requestAnimationFrame(() => overlayRef.current?.focus()); + return () => cancelAnimationFrame(id); } }, [isOpen, documentUrl]); - const onDocumentLoadSuccess = useCallback(async ({ numPages: np }: { numPages: number }) => { - setNumPages(np); - setLoading(false); - setError(null); + // Use the react-pdf-loaded proxy (passed to onLoadSuccess) for the text + // search instead of loading the PDF a second time via pdfjs.getDocument. + const onDocumentLoadSuccess = useCallback( + async (pdfLike: { numPages: number }) => { + const pdf = pdfLike as unknown as LoadedPdf; + const np = pdf.numPages; + setNumPages(np); + setLoading(false); + setError(null); - // If we have highlight text, find which page contains it - if (searchSnippet && isPdf) { + if (!searchSnippet || !isPdf) return; try { - const pdf = await pdfjs.getDocument(documentUrl).promise; for (let i = 1; i <= np; i++) { const page = await pdf.getPage(i); const textContent = await page.getTextContent(); const pageText = normalizeText( - textContent.items.map((item) => ('str' in item ? item.str : '')).join(' ') + textContent.items + .map((item) => + typeof item === 'object' && item && 'str' in item && typeof (item as { str?: unknown }).str === 'string' + ? (item as { str: string }).str + : '', + ) + .join(' '), ); if (pageText.includes(searchSnippet)) { setPageNumber(i); setHighlightPageFound(true); setShowAllPages(false); - break; + return; } } } catch { - // Failed to search, just show page 1 + // Search failed — leave viewer on page 1. } - } - }, [searchSnippet, documentUrl, isPdf]); + }, + [searchSnippet, isPdf], + ); const onDocumentLoadError = (error: Error) => { setError(`Failed to load document: ${error.message}`); setLoading(false); }; - const goToPrevPage = () => { - setPageNumber((prev) => Math.max(1, prev - 1)); - }; - - const goToNextPage = () => { - setPageNumber((prev) => Math.min(numPages || 1, prev + 1)); - }; + const goToPrevPage = () => setPageNumber((prev) => Math.max(1, prev - 1)); + const goToNextPage = () => setPageNumber((prev) => Math.min(numPages || 1, prev + 1)); const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Escape') { - onClose(); - } else if (e.key === 'ArrowLeft') { - goToPrevPage(); - } else if (e.key === 'ArrowRight') { - goToNextPage(); - } else if (e.key === '+' || e.key === '=') { - setScale((prev) => Math.min(3, prev + 0.1)); - } else if (e.key === '-') { - setScale((prev) => Math.max(0.5, prev - 0.1)); - } + if (e.key === 'Escape') onClose(); + else if (e.key === 'ArrowLeft') goToPrevPage(); + else if (e.key === 'ArrowRight') goToNextPage(); + else if (e.key === '+' || e.key === '=') setScale((prev) => Math.min(3, prev + 0.1)); + else if (e.key === '-') setScale((prev) => Math.max(0.5, prev - 0.1)); }; - // Custom text renderer that highlights matching text + /** + * Highlight text renderer. Uses the longest-contiguous-word-run match so + * unrelated paragraphs don't get painted amber. Threshold is "this item + * contains a run of at least N consecutive snippet words." + */ const customTextRenderer = useCallback( (textItem: { str: string }) => { if (!searchSnippet) return textItem.str; - const normalizedItem = normalizeText(textItem.str); - // Check if this text item contains part of the search snippet - // Use a simpler word-overlap approach for partial matching - const words = searchSnippet.split(' ').filter((w) => w.length > 3); - if (words.length === 0) return textItem.str; + if (!normalizedItem) return textItem.str; - const matchCount = words.filter((w) => normalizedItem.includes(w)).length; - const matchRatio = matchCount / words.length; + const snippetWords = searchSnippet.split(' ').filter((w) => w.length > 2); + if (snippetWords.length === 0) return textItem.str; - if (matchRatio >= 0.3) { + // Find the longest run of consecutive snippet words present in this item. + let bestRun = 0; + let current = 0; + for (const w of snippetWords) { + if (normalizedItem.includes(w)) { + current += 1; + bestRun = Math.max(bestRun, current); + } else { + current = 0; + } + } + + const minRun = Math.min(4, Math.max(2, Math.floor(snippetWords.length * 0.6))); + if (bestRun >= minRun) { return `${textItem.str}`; } return textItem.str; @@ -125,7 +148,16 @@ function DocumentPreview({ isOpen, onClose, documentUrl, filename, highlightText if (!isOpen) return null; return ( -
+
e.stopPropagation()}>

{filename}

@@ -135,23 +167,25 @@ function DocumentPreview({ isOpen, onClose, documentUrl, filename, highlightText
{isPdf && numPages && ( <> -
+
- {showAllPages ? `All pages` : `${pageNumber} / ${numPages}`} + {showAllPages ? 'All pages' : `${pageNumber} / ${numPages}`} @@ -160,18 +194,19 @@ function DocumentPreview({ isOpen, onClose, documentUrl, filename, highlightText type="button" className="preview-view-toggle" onClick={() => setShowAllPages(!showAllPages)} - title={showAllPages ? 'Switch to single page view' : 'Show all pages (scrollable)'} + aria-label={showAllPages ? 'Show single page' : 'Show all pages'} > - {showAllPages ? '📄 Single' : '📑 All'} + {showAllPages ? 'Single' : 'All pages'} )} {isPdf && ( -
+
@@ -180,26 +215,32 @@ function DocumentPreview({ isOpen, onClose, documentUrl, filename, highlightText type="button" className="preview-zoom-button" onClick={() => setScale((prev) => Math.min(3, prev + 0.1))} + aria-label="Zoom in" > +
)} -
- {loading &&
Loading document...
} + {loading &&
Loading…
} {error &&
{error}
} {!error && isPdf && ( Loading PDF...
} + loading={
Loading…
} > {showAllPages && numPages ? (
diff --git a/apps/desktop/src/components/chat/ChatView.tsx b/apps/desktop/src/components/chat/ChatView.tsx index c498b1f..722c5b0 100644 --- a/apps/desktop/src/components/chat/ChatView.tsx +++ b/apps/desktop/src/components/chat/ChatView.tsx @@ -6,6 +6,7 @@ import { MessageBubble } from './MessageBubble'; import { QuickChips } from './QuickChips'; import { OverflowMenu } from '../ui/OverflowMenu'; import { downloadBibtex } from '../../utils/bibtex'; +import type { SourceChunk } from '../../types'; import './chat.css'; const EMPTY_CHIPS = [ @@ -19,7 +20,14 @@ const FOLLOWUP_CHIPS = [ 'Simplify this', ]; -export function ChatView({ pendingSuggest, onSuggestConsumed }: { pendingSuggest?: string | null; onSuggestConsumed?: () => void } = {}) { +interface ChatViewProps { + pendingSuggest?: string | null; + onSuggestConsumed?: () => void; + onCitationClick?: (source: SourceChunk, index: number) => void; + onCitationHover?: (index: number | null) => void; +} + +export function ChatView({ pendingSuggest, onSuggestConsumed, onCitationClick, onCitationHover }: ChatViewProps = {}) { const { messages, isStreaming, send, clearChat, abort } = useChat(); const activeNotebookId = useAppStore((s) => s.activeNotebookId); const notebooks = useAppStore((s) => s.notebooks); @@ -44,9 +52,13 @@ export function ChatView({ pendingSuggest, onSuggestConsumed }: { pendingSuggest const messagesEndRef = useRef(null); const inputRef = useRef(null); + // Auto-scroll during streaming only if the user is already near the bottom. + // Prior behavior snapped scrolled-up users back down on every token. useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages]); + if (!isScrolledUp) { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + } + }, [messages, isScrolledUp]); useEffect(() => { if (!isStreaming && status === 'ready') { @@ -171,6 +183,9 @@ export function ChatView({ pendingSuggest, onSuggestConsumed }: { pendingSuggest { diff --git a/apps/desktop/src/components/chat/MessageBubble.tsx b/apps/desktop/src/components/chat/MessageBubble.tsx index 7b9674c..06fb05f 100644 --- a/apps/desktop/src/components/chat/MessageBubble.tsx +++ b/apps/desktop/src/components/chat/MessageBubble.tsx @@ -1,15 +1,173 @@ -import { useState } from 'react'; -import ReactMarkdown from 'react-markdown'; -import type { ChatMessage } from '../../types'; +import { useState, Fragment, type ReactNode } from 'react'; +import ReactMarkdown, { type Components } from 'react-markdown'; +import type { ChatMessage, SourceChunk } from '../../types'; interface MessageBubbleProps { message: ChatMessage; + sources?: SourceChunk[]; + onCitationClick?: (source: SourceChunk, index: number) => void; + onCitationHover?: (index: number | null) => void; onRetry?: () => void; } -export function MessageBubble({ message, onRetry }: MessageBubbleProps) { +// Accepts `[Source 1]`, `[Source #1]`, `[source 1]`, `[1]` — we convert them +// all into a citation marker keyed by 1-based index. +const CITATION_RE = /\[(?:source\s*#?\s*)?(\d+)\]/gi; + +/** + * Split a paragraph of text into sentences. Keeps terminators + trailing + * whitespace. Rough, but good enough to identify which sentence is the + * bearer of a citation. + */ +function splitSentences(text: string): string[] { + const out: string[] = []; + const re = /[^.!?\n]+(?:[.!?]+|$)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + if (m[0]) out.push(m[0]); + } + if (out.length === 0) out.push(text); + return out; +} + +/** + * Render the marker itself — a small mono chip sitting inside the sentence. + * Clicking opens the source; hover previews in the side panel via callbacks. + */ +function CitationMarker({ + n, + source, + onClick, + onHover, +}: { + n: number; + source: SourceChunk | undefined; + onClick?: (e: React.MouseEvent) => void; + onHover?: (hovering: boolean) => void; +}) { + if (!source) { + // Model hallucinated a citation index outside the source list. Still + // render it — but styled as a quiet placeholder so we don't pretend + // it's real. + return ( + + [{n}] + + ); + } + return ( + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick?.(e as unknown as React.MouseEvent); + } + }} + onMouseEnter={() => onHover?.(true)} + onMouseLeave={() => onHover?.(false)} + > + [{n}] + + ); +} + +/** + * Given a string, return the rendered nodes — sentences containing a citation + * become `.cited` blocks with an amber left-rule, citations become clickable + * `.cite-marker` superscript chips. + */ +function renderWithCitations( + text: string, + sources: SourceChunk[] | undefined, + onCitationClick: MessageBubbleProps['onCitationClick'], + onCitationHover: MessageBubbleProps['onCitationHover'], +): ReactNode { + if (!sources || sources.length === 0 || !CITATION_RE.test(text)) { + CITATION_RE.lastIndex = 0; + return text; + } + CITATION_RE.lastIndex = 0; + + const sentences = splitSentences(text); + return sentences.map((sentence, sIdx) => { + const hasCitation = /\[(?:source\s*#?\s*)?\d+\]/i.test(sentence); + if (!hasCitation) { + return {sentence}; + } + + // Interleave plain text + CitationMarker nodes. + const nodes: ReactNode[] = []; + let lastIdx = 0; + let m: RegExpExecArray | null; + const re = new RegExp(CITATION_RE.source, CITATION_RE.flags); + while ((m = re.exec(sentence)) !== null) { + const n = parseInt(m[1]!, 10); + const source = sources[n - 1]; + if (m.index > lastIdx) { + nodes.push(sentence.slice(lastIdx, m.index)); + } + nodes.push( + { + e.stopPropagation(); + onCitationClick?.(source, n - 1); + } + : undefined + } + onHover={(hovering) => onCitationHover?.(hovering ? n - 1 : null)} + />, + ); + lastIdx = m.index + m[0].length; + } + if (lastIdx < sentence.length) { + nodes.push(sentence.slice(lastIdx)); + } + + return ( + + {nodes} + + ); + }); +} + +/** Walk ReactMarkdown's rendered children, replacing string nodes with + * citation-rendered trees. Non-string children (bold, links) pass through. + */ +function processChildren( + children: ReactNode, + sources: SourceChunk[] | undefined, + onCitationClick: MessageBubbleProps['onCitationClick'], + onCitationHover: MessageBubbleProps['onCitationHover'], +): ReactNode { + if (children == null) return children; + if (typeof children === 'string') { + return renderWithCitations(children, sources, onCitationClick, onCitationHover); + } + if (Array.isArray(children)) { + return children.map((child, i) => ( + + {processChildren(child, sources, onCitationClick, onCitationHover)} + + )); + } + return children; +} + +export function MessageBubble({ message, sources, onCitationClick, onCitationHover, onRetry }: MessageBubbleProps) { const isUser = message.role === 'user'; const isError = !isUser && message.content.startsWith('Error: '); + const isAborted = !isUser && message.aborted === true; const [copied, setCopied] = useState(false); const handleCopy = async () => { @@ -18,14 +176,35 @@ export function MessageBubble({ message, onRetry }: MessageBubbleProps) { setTimeout(() => setCopied(false), 1500); }; + // Only wire citation rendering into assistant-rendered markdown. + const markdownComponents: Components | undefined = + !isUser && sources && sources.length > 0 + ? { + p: ({ children, ...rest }) => ( +

{processChildren(children, sources, onCitationClick, onCitationHover)}

+ ), + li: ({ children, ...rest }) => ( +
  • {processChildren(children, sources, onCitationClick, onCitationHover)}
  • + ), + } + : undefined; + return ( -
    +
    {message.content ? ( <> - + {message.content} - {!isUser && !isError && ( + {isAborted && stopped} + {!isUser && !isError && !isAborted && ( diff --git a/apps/desktop/src/components/chat/chat.css b/apps/desktop/src/components/chat/chat.css index 2640dfe..80b0515 100644 --- a/apps/desktop/src/components/chat/chat.css +++ b/apps/desktop/src/components/chat/chat.css @@ -294,6 +294,26 @@ color: var(--color-error) !important; } +.message-bubble-aborted .message-body { + opacity: 0.68; +} + +.message-aborted-tag { + display: inline-flex; + align-items: center; + margin-top: var(--space-2); + padding: 2px 10px; + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-text-muted); + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-pill); +} + .message-retry-btn { background: transparent; border: 1px solid rgba(251, 113, 133, 0.3); diff --git a/apps/desktop/src/components/layout/AppShell.tsx b/apps/desktop/src/components/layout/AppShell.tsx index 8e5516d..1e08df6 100644 --- a/apps/desktop/src/components/layout/AppShell.tsx +++ b/apps/desktop/src/components/layout/AppShell.tsx @@ -14,6 +14,7 @@ import { SetupWizard } from '../ui/SetupWizard'; import { CommandPalette } from '../ui/CommandPalette'; import { KeyboardShortcutsOverlay } from '../ui/KeyboardShortcuts'; import { ZoteroImportDialog } from '../ui/ZoteroImport'; +import type { SourceChunk } from '../../types'; import './layout.css'; function isWizardComplete(): boolean { @@ -35,6 +36,9 @@ export function AppShell() { const [shortcutsOpen, setShortcutsOpen] = useState(false); const [zoteroOpen, setZoteroOpen] = useState(false); const [highlightText, setHighlightText] = useState(null); + // Shared between SourcePanel card-hover and MessageBubble citation-hover so + // the two-way link between a sentence and its source card is visible. + const [hoveredSourceIndex, setHoveredSourceIndex] = useState(null); // Global keyboard shortcuts useEffect(() => { @@ -208,21 +212,44 @@ export function AppShell() { ); } + // Single entry point for opening a source — called from both the source + // panel card click and citation marker click in MessageBubble. Keeps the + // fetch/open/highlight dance in one place. + const openSource = useCallback( + async (source: SourceChunk) => { + const notebookId = source.notebook_id || activeNotebookId; + if (!notebookId) return; + try { + const url = await getDocumentPreviewUrl(notebookId, source.source_path); + const filename = source.document_name || source.source_path.split(/[/\\]/).pop() || source.source_path; + setResolvedPreviewUrl(url); + setHighlightText(source.preview); + setPreviewDocument({ filename, source_path: source.source_path, chunk_count: 0, preview: '' }); + } catch { + showToast('Could not open source', 'error'); + } + }, + [activeNotebookId, setPreviewDocument], + ); + return ( <>
    - setPendingSuggest(null)} /> - { - if (!activeNotebookId) return; - try { - const url = await getDocumentPreviewUrl(activeNotebookId, sourcePath); - const filename = sourcePath.split(/[/\\]/).pop() ?? sourcePath; - setResolvedPreviewUrl(url); - setHighlightText(preview); - setPreviewDocument({ filename, source_path: sourcePath, chunk_count: 0, preview: '' }); - } catch {} - }} /> + setPendingSuggest(null)} + onCitationClick={(source, index) => { + setHoveredSourceIndex(index); + openSource(source); + }} + onCitationHover={setHoveredSourceIndex} + /> + openSource(source)} + hoveredIndex={hoveredSourceIndex} + onCardHover={setHoveredSourceIndex} + />
    {previewDocument && activeNotebookId && resolvedPreviewUrl && ( diff --git a/apps/desktop/src/components/layout/SourcePanel.tsx b/apps/desktop/src/components/layout/SourcePanel.tsx index 0d211b5..9a39bd2 100644 --- a/apps/desktop/src/components/layout/SourcePanel.tsx +++ b/apps/desktop/src/components/layout/SourcePanel.tsx @@ -1,66 +1,117 @@ +import { useEffect, useRef } from 'react'; import { useAppStore } from '../../store/app-store'; +import type { SourceChunk } from '../../types'; import './layout.css'; -function relevanceColor(score: number): string { - if (score > 70) return '#7c9a82'; // sage green - if (score >= 40) return '#fbbf24'; // amber - return '#a8a29e'; // muted gray +interface SourcePanelProps { + onSourceClick?: (source: SourceChunk, index: number) => void; + hoveredIndex?: number | null; + onCardHover?: (index: number | null) => void; } -interface SourcePanelProps { - onSourceClick?: (sourcePath: string, highlightText: string) => void; +/** + * Map a 0–100 relevance score to a design-token color. 0–1 float scores + * (defensive — backend sometimes emits them) are treated as 0%. + */ +function relevanceTone(score: number | undefined): string { + if (score == null) return 'var(--color-text-muted)'; + if (score > 70) return 'var(--color-accent)'; + if (score >= 40) return 'var(--color-cite)'; + return 'var(--color-text-muted)'; +} + +function clampPercent(score: number | undefined): number { + if (score == null) return 0; + // Some backends emit 0–1 floats; treat anything ≤1 as a fraction. + const pct = score <= 1 ? score * 100 : score; + return Math.max(0, Math.min(100, pct)); } -export function SourcePanel({ onSourceClick }: SourcePanelProps) { +export function SourcePanel({ onSourceClick, hoveredIndex, onCardHover }: SourcePanelProps) { const activeSources = useAppStore((s) => s.activeSources); + const sourcePanelOpen = useAppStore((s) => s.sourcePanelOpen); const crossNotebookMode = useAppStore((s) => s.crossNotebookMode); const notebooks = useAppStore((s) => s.notebooks); + const cardRefs = useRef>(new Map()); - if (activeSources.length === 0) return null; + // When a citation is hovered/clicked, scroll its card into view in the panel. + useEffect(() => { + if (hoveredIndex == null) return; + const el = cardRefs.current.get(hoveredIndex); + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, [hoveredIndex]); + + if (!sourcePanelOpen) return null; return ( -