Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions apps/web/src/components/wiki/ArticleReader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -172,7 +177,7 @@ export function ArticleReader({ article, onArticleUpdated }: ArticleReaderProps)
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex, rehypeHighlight, rehypeRaw]}
components={markdownComponents}
components={mdComponents}
>
{processed}
</ReactMarkdown>
Expand All @@ -182,7 +187,7 @@ export function ArticleReader({ article, onArticleUpdated }: ArticleReaderProps)
{/* Per-claim confidence panel */}
{!isEditing && (
<div className="mt-8 border-t border-slate-200 pt-6">
<ClaimsPanel articleId={article.id} />
<ClaimsPanel articleId={article.id} sources={article.sources} />
</div>
)}

Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/components/wiki/CitationContext.tsx
Original file line number Diff line number Diff line change
@@ -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<CitationContextValue | null>(null);

export function CitationProvider({ children }: { children: ReactNode }) {
const [activeCitation, setActiveCitation] = useState<CitationTarget | null>(
null,
);

const showCitation = useCallback((target: CitationTarget) => {
setActiveCitation(target);
}, []);

const clearCitation = useCallback(() => {
setActiveCitation(null);
}, []);

return (
<CitationCtx.Provider
value={{ activeCitation, showCitation, clearCitation }}
>
{children}
</CitationCtx.Provider>
);
}

export function useCitation(): CitationContextValue {
const ctx = useContext(CitationCtx);
if (!ctx) {
throw new Error("useCitation must be used within a CitationProvider");
}
return ctx;
}
102 changes: 102 additions & 0 deletions apps/web/src/components/wiki/CitationPopover.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div
ref={ref}
className="w-80 rounded-lg border border-gray-200 bg-white p-4 shadow-lg transition-all duration-200"
style={style}
>
{/* Quoted span text */}
<blockquote className="mb-3 border-l-2 border-brand-300 pl-3 text-sm italic text-slate-700">
{citation.spanText.length > 200
? citation.spanText.slice(0, 200) + "..."
: citation.spanText}
</blockquote>

{/* Source info */}
<div className="mb-3 flex items-center gap-2 text-xs text-slate-500">
<span className="font-medium text-slate-700">
{citation.sourceName || "Unknown source"}
</span>
{citation.locatorInfo && (
<>
<span className="text-slate-300">&middot;</span>
<span>{citation.locatorInfo}</span>
</>
)}
</div>

{/* View in source link */}
<button
onClick={() => {
showCitation(citation);
onClose();
}}
className="inline-flex items-center gap-1 text-xs font-medium text-brand-600 hover:text-brand-800"
>
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
View in source
</button>
</div>
);
}
86 changes: 81 additions & 5 deletions apps/web/src/components/wiki/ClaimsPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<CitationTarget | null>(
null,
);
const [popoverRect, setPopoverRect] = useState<DOMRect | null>(null);
const [loadingSpans, setLoadingSpans] = useState(false);
const markerRef = useRef<HTMLButtonElement>(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 (
<div className="rounded-lg border border-slate-200 bg-white p-4">
<p className="text-sm text-slate-800">{claim.text}</p>
<div className="flex items-start gap-1">
<p className="flex-1 text-sm text-slate-800">{claim.text}</p>
{claim.source_ids.length > 0 && (
<button
ref={markerRef}
onClick={handleCitationClick}
className="mt-0.5 shrink-0 text-xs font-semibold text-brand-600 hover:text-brand-800"
title="View source citation"
>
{loadingSpans ? (
<Spinner size={12} />
) : (
<span className="cursor-pointer">[{claim.source_ids.length}]</span>
)}
</button>
)}
</div>
<div className="mt-3 flex items-center gap-3">
{/* Confidence bar */}
<div className="flex flex-1 items-center gap-2">
Expand All @@ -69,11 +137,19 @@ function ClaimCard({ claim }: { claim: ClaimConfidenceItem }) {
</span>
)}
</div>

{popoverCitation && (
<CitationPopover
citation={popoverCitation}
onClose={() => setPopoverCitation(null)}
anchorRect={popoverRect}
/>
)}
</div>
);
}

export function ClaimsPanel({ articleId }: ClaimsPanelProps) {
export function ClaimsPanel({ articleId, sources }: ClaimsPanelProps) {
const [open, setOpen] = useState(false);

const { data, isLoading, isError } = useQuery({
Expand Down Expand Up @@ -192,7 +268,7 @@ export function ClaimsPanel({ articleId }: ClaimsPanelProps) {
{data.claims.length > 0 ? (
<div className="space-y-3">
{data.claims.map((claim) => (
<ClaimCard key={claim.id} claim={claim} />
<ClaimCard key={claim.id} claim={claim} sources={sources} />
))}
</div>
) : (
Expand Down
75 changes: 75 additions & 0 deletions apps/web/src/components/wiki/InlineCitationMarker.tsx
Original file line number Diff line number Diff line change
@@ -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<CitationTarget | null>(
null,
);
const [popoverRect, setPopoverRect] = useState<DOMRect | null>(null);
const [loading, setLoading] = useState(false);
const markerRef = useRef<HTMLButtonElement>(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 (
<>
<button
ref={markerRef}
onClick={handleClick}
className="ml-0.5 cursor-pointer align-super text-[10px] font-semibold text-brand-600 hover:text-brand-800"
title="View source citation"
type="button"
>
{loading ? "..." : "[src]"}
</button>
{popoverCitation && (
<CitationPopover
citation={popoverCitation}
onClose={() => setPopoverCitation(null)}
anchorRect={popoverRect}
/>
)}
</>
);
}
Loading