diff --git a/apps/web/src/components/wiki/ArticleReader.tsx b/apps/web/src/components/wiki/ArticleReader.tsx index 78e383db..aa48e70e 100644 --- a/apps/web/src/components/wiki/ArticleReader.tsx +++ b/apps/web/src/components/wiki/ArticleReader.tsx @@ -17,7 +17,7 @@ import { ExportDropdown } from "./ExportDropdown"; import { ShareButton } from "./ShareButton"; import { TagSelector } from "./TagSelector"; import { preprocessMarkdown, extractSynthesizedFrom, extractConceptKind } from "./preprocessMarkdown"; -import { markdownComponents } from "./markdownComponents"; +import { createMarkdownComponents } from "./markdownComponents"; import { useArticleEditor } from "./useArticleEditor"; import { ClaimsPanel } from "./ClaimsPanel"; import { DiscussionPanel } from "./DiscussionPanel"; @@ -54,6 +54,11 @@ export function ArticleReader({ article, onArticleUpdated }: ArticleReaderProps) [article.content, isConcept], ); + const mdComponents = useMemo( + () => createMarkdownComponents(article.sources), + [article.sources], + ); + const primaryConcept = article.concepts.length > 0 ? article.concepts[0] : null; return ( @@ -172,7 +177,7 @@ export function ArticleReader({ article, onArticleUpdated }: ArticleReaderProps) {processed} @@ -182,7 +187,7 @@ export function ArticleReader({ article, onArticleUpdated }: ArticleReaderProps) {/* Per-claim confidence panel */} {!isEditing && (
- +
)} diff --git a/apps/web/src/components/wiki/CitationContext.tsx b/apps/web/src/components/wiki/CitationContext.tsx new file mode 100644 index 00000000..e553ca30 --- /dev/null +++ b/apps/web/src/components/wiki/CitationContext.tsx @@ -0,0 +1,50 @@ +import { createContext, useContext, useState, useCallback } from "react"; +import type { ReactNode } from "react"; + +export interface CitationTarget { + sourceId: string; + spanText: string; + sourceName: string | null; + locatorInfo: string; +} + +interface CitationContextValue { + /** The citation currently being viewed in the source panel. */ + activeCitation: CitationTarget | null; + /** Open the source panel with a highlighted span. */ + showCitation: (target: CitationTarget) => void; + /** Clear the active citation (back to default sidebar). */ + clearCitation: () => void; +} + +const CitationCtx = createContext(null); + +export function CitationProvider({ children }: { children: ReactNode }) { + const [activeCitation, setActiveCitation] = useState( + null, + ); + + const showCitation = useCallback((target: CitationTarget) => { + setActiveCitation(target); + }, []); + + const clearCitation = useCallback(() => { + setActiveCitation(null); + }, []); + + return ( + + {children} + + ); +} + +export function useCitation(): CitationContextValue { + const ctx = useContext(CitationCtx); + if (!ctx) { + throw new Error("useCitation must be used within a CitationProvider"); + } + return ctx; +} diff --git a/apps/web/src/components/wiki/CitationPopover.tsx b/apps/web/src/components/wiki/CitationPopover.tsx new file mode 100644 index 00000000..abaf5000 --- /dev/null +++ b/apps/web/src/components/wiki/CitationPopover.tsx @@ -0,0 +1,102 @@ +import { useEffect, useRef } from "react"; +import type { CitationTarget } from "./CitationContext"; +import { useCitation } from "./CitationContext"; + +interface CitationPopoverProps { + /** The citation data to display. */ + citation: CitationTarget; + /** Callback to close the popover. */ + onClose: () => void; + /** Anchor element for positioning (popover appears below it). */ + anchorRect: DOMRect | null; +} + +export function CitationPopover({ + citation, + onClose, + anchorRect, +}: CitationPopoverProps) { + const ref = useRef(null); + const { showCitation } = useCitation(); + + // Close on click outside + useEffect(() => { + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + onClose(); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [onClose]); + + // Close on Escape + useEffect(() => { + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [onClose]); + + if (!anchorRect) return null; + + const style: React.CSSProperties = { + position: "fixed", + top: anchorRect.bottom + 8, + left: Math.max(8, anchorRect.left - 120), + zIndex: 50, + }; + + return ( +
+ {/* Quoted span text */} +
+ {citation.spanText.length > 200 + ? citation.spanText.slice(0, 200) + "..." + : citation.spanText} +
+ + {/* Source info */} +
+ + {citation.sourceName || "Unknown source"} + + {citation.locatorInfo && ( + <> + · + {citation.locatorInfo} + + )} +
+ + {/* View in source link */} + +
+ ); +} diff --git a/apps/web/src/components/wiki/ClaimsPanel.tsx b/apps/web/src/components/wiki/ClaimsPanel.tsx index b4674895..6d938ca7 100644 --- a/apps/web/src/components/wiki/ClaimsPanel.tsx +++ b/apps/web/src/components/wiki/ClaimsPanel.tsx @@ -1,11 +1,17 @@ -import { useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { getArticleClaims, type ClaimConfidenceItem } from "../../api/wiki"; +import { getSourceSpans } from "../../api/sources"; +import type { ArticleSourceRef, SourceSpanResponse } from "../../types/api"; import { Badge, type BadgeTone } from "../shared/Badge"; import { Spinner } from "../shared/Spinner"; +import { CitationPopover } from "./CitationPopover"; +import type { CitationTarget } from "./CitationContext"; +import { formatLocator } from "./citationUtils"; interface ClaimsPanelProps { articleId: string; + sources?: ArticleSourceRef[]; } function confidenceColor(score: number): string { @@ -39,12 +45,74 @@ function levelLabel(level: string): string { return level.charAt(0).toUpperCase() + level.slice(1); } -function ClaimCard({ claim }: { claim: ClaimConfidenceItem }) { +function ClaimCard({ + claim, + sources, +}: { + claim: ClaimConfidenceItem; + sources?: ArticleSourceRef[]; +}) { const pct = Math.round(claim.confidence_score * 100); + const [popoverCitation, setPopoverCitation] = useState( + null, + ); + const [popoverRect, setPopoverRect] = useState(null); + const [loadingSpans, setLoadingSpans] = useState(false); + const markerRef = useRef(null); + + const handleCitationClick = useCallback(async () => { + if (claim.source_ids.length === 0) return; + if (popoverCitation) { + setPopoverCitation(null); + return; + } + + setLoadingSpans(true); + try { + // Fetch spans for the first source + const sourceId = claim.source_ids[0]; + const spans: SourceSpanResponse[] = await getSourceSpans(sourceId); + const source = sources?.find((s) => s.id === sourceId); + + if (spans.length > 0) { + const span = spans[0]; + const citation: CitationTarget = { + sourceId, + spanText: span.text, + sourceName: source?.title ?? null, + locatorInfo: formatLocator(span), + }; + setPopoverCitation(citation); + if (markerRef.current) { + setPopoverRect(markerRef.current.getBoundingClientRect()); + } + } + } catch (err) { + console.error("Failed to fetch source spans:", err); + } finally { + setLoadingSpans(false); + } + }, [claim.source_ids, popoverCitation, sources]); return (
-

{claim.text}

+
+

{claim.text}

+ {claim.source_ids.length > 0 && ( + + )} +
{/* Confidence bar */}
@@ -69,11 +137,19 @@ function ClaimCard({ claim }: { claim: ClaimConfidenceItem }) { )}
+ + {popoverCitation && ( + setPopoverCitation(null)} + anchorRect={popoverRect} + /> + )}
); } -export function ClaimsPanel({ articleId }: ClaimsPanelProps) { +export function ClaimsPanel({ articleId, sources }: ClaimsPanelProps) { const [open, setOpen] = useState(false); const { data, isLoading, isError } = useQuery({ @@ -192,7 +268,7 @@ export function ClaimsPanel({ articleId }: ClaimsPanelProps) { {data.claims.length > 0 ? (
{data.claims.map((claim) => ( - + ))}
) : ( diff --git a/apps/web/src/components/wiki/InlineCitationMarker.tsx b/apps/web/src/components/wiki/InlineCitationMarker.tsx new file mode 100644 index 00000000..dde1729d --- /dev/null +++ b/apps/web/src/components/wiki/InlineCitationMarker.tsx @@ -0,0 +1,75 @@ +import { useCallback, useRef, useState } from "react"; +import { getSourceSpans } from "../../api/sources"; +import type { ArticleSourceRef, SourceSpanResponse } from "../../types/api"; +import { CitationPopover } from "./CitationPopover"; +import type { CitationTarget } from "./CitationContext"; +import { formatLocator } from "./citationUtils"; + +interface InlineCitationMarkerProps { + /** The article's sources to look up spans from. */ + sources: ArticleSourceRef[]; +} + +export function InlineCitationMarker({ sources }: InlineCitationMarkerProps) { + const [popoverCitation, setPopoverCitation] = useState( + null, + ); + const [popoverRect, setPopoverRect] = useState(null); + const [loading, setLoading] = useState(false); + const markerRef = useRef(null); + + const handleClick = useCallback(async () => { + if (popoverCitation) { + setPopoverCitation(null); + return; + } + if (sources.length === 0) return; + + setLoading(true); + try { + // Try each source until we find one with spans + for (const source of sources) { + const spans: SourceSpanResponse[] = await getSourceSpans(source.id); + if (spans.length > 0) { + const span = spans[0]; + const citation: CitationTarget = { + sourceId: source.id, + spanText: span.text, + sourceName: source.title, + locatorInfo: formatLocator(span), + }; + setPopoverCitation(citation); + if (markerRef.current) { + setPopoverRect(markerRef.current.getBoundingClientRect()); + } + return; + } + } + } finally { + setLoading(false); + } + }, [popoverCitation, sources]); + + if (sources.length === 0) return null; + + return ( + <> + + {popoverCitation && ( + setPopoverCitation(null)} + anchorRect={popoverRect} + /> + )} + + ); +} diff --git a/apps/web/src/components/wiki/SourceHighlightPanel.tsx b/apps/web/src/components/wiki/SourceHighlightPanel.tsx new file mode 100644 index 00000000..81e9ec99 --- /dev/null +++ b/apps/web/src/components/wiki/SourceHighlightPanel.tsx @@ -0,0 +1,153 @@ +import { useEffect, useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { getSourceContent } from "../../api/sources"; +import type { CitationTarget } from "./CitationContext"; +import { useCitation } from "./CitationContext"; +import { Spinner } from "../shared/Spinner"; + +interface SourceHighlightPanelProps { + citation: CitationTarget; +} + +export function SourceHighlightPanel({ citation }: SourceHighlightPanelProps) { + const { clearCitation } = useCitation(); + const highlightRef = useRef(null); + + const contentQuery = useQuery({ + queryKey: ["source-content", citation.sourceId], + queryFn: () => getSourceContent(citation.sourceId), + }); + + // Auto-scroll to highlighted span once content loads + useEffect(() => { + if (highlightRef.current) { + highlightRef.current.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + }, [contentQuery.data]); + + return ( +
+ {/* Header */} +
+

+ Source Preview +

+ +
+ + {/* Source info */} +
+

+ {citation.sourceName || "Unknown source"} +

+ {citation.locatorInfo && ( +

+ {citation.locatorInfo} +

+ )} +
+ + {/* Content with highlighted span */} +
+ {contentQuery.isLoading ? ( +
+ Loading source content... +
+ ) : contentQuery.isError ? ( +
+ Failed to load source content. +
+ ) : contentQuery.data ? ( + + ) : null} +
+
+ ); +} + +interface HighlightedSourceTextProps { + content: string; + spanText: string; + highlightRef: React.RefObject; +} + +function HighlightedSourceText({ + content, + spanText, + highlightRef, +}: HighlightedSourceTextProps) { + // Find the span text in the source content + const normalizedContent = content; + const matchIndex = normalizedContent.indexOf(spanText); + + if (matchIndex === -1) { + // Fallback: show the whole content with the quoted span at the top + return ( +
+
+

+ Cited passage +

+
+ {spanText} +
+
+
+ {content.split(/\n{2,}/).map((para, idx) => ( +

+ {para} +

+ ))} +
+
+ ); + } + + // Split content into before, match, and after + const before = content.slice(0, matchIndex); + const match = content.slice(matchIndex, matchIndex + spanText.length); + const after = content.slice(matchIndex + spanText.length); + + return ( +
+ {before && ( + {before} + )} + + {match} + + {after && ( + {after} + )} +
+ ); +} diff --git a/apps/web/src/components/wiki/WikiExplorerView.tsx b/apps/web/src/components/wiki/WikiExplorerView.tsx index 346ebd9f..93be4d5f 100644 --- a/apps/web/src/components/wiki/WikiExplorerView.tsx +++ b/apps/web/src/components/wiki/WikiExplorerView.tsx @@ -9,15 +9,25 @@ import { ArticleCardGrid } from "./ArticleCardGrid"; import { ArticleOutline } from "./ArticleOutline"; import { ArticleReader } from "./ArticleReader"; import { BacklinkPanel } from "./BacklinkPanel"; +import { CitationProvider, useCitation } from "./CitationContext"; import { ConceptTree } from "./ConceptTree"; import { CreateStubModal } from "./CreateStubModal"; import { FiguresPanel } from "./FiguresPanel"; import { InArticleSearch } from "./InArticleSearch"; import { SavedSearches } from "./SavedSearches"; import { SearchBar } from "./SearchBar"; +import { SourceHighlightPanel } from "./SourceHighlightPanel"; import { SourcePanel } from "./SourcePanel"; export function WikiExplorerView() { + return ( + + + + ); +} + +function WikiExplorerViewInner() { const params = useParams<{ slug?: string }>(); const slug = params.slug; const articleQuery = useArticle(slug); @@ -26,6 +36,7 @@ export function WikiExplorerView() { const [figureCount, setFigureCount] = useState(0); const navigate = useNavigate(); const [showSources, setShowSources] = useState(false); + const { activeCitation } = useCitation(); const executeSavedSearchMutation = useMutation({ mutationFn: (searchId: string) => executeSavedSearch(searchId), onSuccess: () => { @@ -146,7 +157,7 @@ export function WikiExplorerView() { {slug ? (