Skip to content
Closed
Prev Previous commit
Next Next commit
feat: add triangulated news UI components and integration
  • Loading branch information
raydeStar committed Dec 9, 2025
commit 54e92aa2d1e87f312597422813ed867f7669bafb
35 changes: 24 additions & 11 deletions src/components/MessageBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { useSpeech } from 'react-text-to-speech';
import ThinkBox from './ThinkBox';
import { useChat, Section } from '@/lib/hooks/useChat';
import Citation from './Citation';
import { TriangulatedNewsResult } from './TriangulatedNews';

const ThinkTagProcessor = ({
children,
Expand All @@ -45,7 +46,13 @@ const MessageBox = ({
dividerRef?: MutableRefObject<HTMLDivElement | null>;
isLast: boolean;
}) => {
const { loading, chatTurns, sendMessage, rewrite } = useChat();
const { loading, chatTurns, sendMessage, rewrite, focusMode } = useChat();

// Check if this is triangulated news data (has sharedFacts/conflicts/uniqueAngles)
const isTriangulatedNews =
focusMode === 'triangulateNews' &&
section.sourceMessage?.sources &&
'sharedFacts' in (section.sourceMessage.sources as any);

const parsedMessage = section.parsedAssistantMessage || '';
const speechMessage = section.speechMessage || '';
Expand Down Expand Up @@ -81,17 +88,23 @@ const MessageBox = ({
className="flex flex-col space-y-6 w-full lg:w-9/12"
>
{section.sourceMessage &&
section.sourceMessage.sources.length > 0 && (
<div className="flex flex-col space-y-2">
<div className="flex flex-row items-center space-x-2">
<BookCopy className="text-black dark:text-white" size={20} />
<h3 className="text-black dark:text-white font-medium text-xl">
Sources
</h3>
(isTriangulatedNews ? (
<TriangulatedNewsResult
data={section.sourceMessage.sources as any}
/>
) : (
section.sourceMessage.sources.length > 0 && (
<div className="flex flex-col space-y-2">
<div className="flex flex-row items-center space-x-2">
<BookCopy className="text-black dark:text-white" size={20} />
<h3 className="text-black dark:text-white font-medium text-xl">
Sources
</h3>
</div>
<MessageSources sources={section.sourceMessage.sources} />
</div>
<MessageSources sources={section.sourceMessage.sources} />
</div>
)}
)
))}

<div className="flex flex-col space-y-2">
{section.sourceMessage && (
Expand Down
144 changes: 144 additions & 0 deletions src/components/TriangulatedNews/ClaimClusterSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
'use client';

import { cn } from '@/lib/utils';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { useState } from 'react';
import type { ClaimCluster, Lane } from '@/types/newsTriangulate';

interface ClaimClusterSectionProps {
title: string;
icon: React.ReactNode;
clusters: ClaimCluster[];
variant: 'shared' | 'conflict' | 'unique';
className?: string;
}

const LANE_BADGE_STYLES: Record<Lane, string> = {
LEFT: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20',
RIGHT: 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20',
CENTER:
'bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20',
UNKNOWN: 'bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/20',
};

const VARIANT_STYLES = {
shared: {
accent: 'border-l-green-500',
badge: 'bg-green-500/10 text-green-600 dark:text-green-400',
},
conflict: {
accent: 'border-l-amber-500',
badge: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
},
unique: {
accent: 'border-l-sky-500',
badge: 'bg-sky-500/10 text-sky-600 dark:text-sky-400',
},
};

/**
* Displays a section of claim clusters (shared facts, conflicts, or unique angles).
* Each cluster shows the representative claim text, lane coverage, and source count.
*/
const ClaimClusterSection = ({
title,
icon,
clusters,
variant,
className,
}: ClaimClusterSectionProps) => {
const [expanded, setExpanded] = useState(true);

if (clusters.length === 0) return null;

const styles = VARIANT_STYLES[variant];

return (
<div className={cn('space-y-3', className)}>
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center justify-between w-full group"
>
<div className="flex items-center gap-2">
<span className="text-black dark:text-white">{icon}</span>
<h4 className="text-black dark:text-white font-medium text-base">
{title}
</h4>
<span className="text-xs text-black/40 dark:text-white/40 bg-light-200 dark:bg-dark-200 px-2 py-0.5 rounded-full">
{clusters.length}
</span>
</div>
{expanded ? (
<ChevronUp
size={18}
className="text-black/40 dark:text-white/40 group-hover:text-black dark:group-hover:text-white transition"
/>
) : (
<ChevronDown
size={18}
className="text-black/40 dark:text-white/40 group-hover:text-black dark:group-hover:text-white transition"
/>
)}
</button>

{expanded && (
<div className="space-y-2">
{clusters.map((cluster) => (
<div
key={cluster.clusterId}
className={cn(
'bg-light-100 dark:bg-dark-100 rounded-lg p-3 border-l-4 space-y-2',
styles.accent,
)}
>
<p className="text-sm text-black/90 dark:text-white/90 leading-relaxed">
{cluster.representativeText}
</p>

<div className="flex items-center justify-between flex-wrap gap-2">
{/* Lane badges */}
<div className="flex items-center gap-1.5 flex-wrap">
{cluster.lanesCovered.map((lane) => (
<span
key={lane}
className={cn(
'text-[10px] px-1.5 py-0.5 rounded border font-medium',
LANE_BADGE_STYLES[lane],
)}
>
{lane === 'UNKNOWN' ? 'Other' : lane}
</span>
))}
</div>

{/* Source count and agreement level */}
<div className="flex items-center gap-2 text-[10px] text-black/50 dark:text-white/50">
<span>
{cluster.supportingClaims.length} source
{cluster.supportingClaims.length !== 1 ? 's' : ''}
</span>
<span
className={cn(
'px-1.5 py-0.5 rounded font-medium uppercase tracking-wide',
cluster.agreementLevel === 'high' &&
'bg-green-500/10 text-green-600 dark:text-green-400',
cluster.agreementLevel === 'medium' &&
'bg-amber-500/10 text-amber-600 dark:text-amber-400',
cluster.agreementLevel === 'low' &&
'bg-gray-500/10 text-gray-600 dark:text-gray-400',
)}
>
{cluster.agreementLevel}
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
};

export default ClaimClusterSection;

97 changes: 97 additions & 0 deletions src/components/TriangulatedNews/LaneIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
'use client';

import { cn } from '@/lib/utils';
import type { Lane } from '@/types/newsTriangulate';

interface LaneCount {
lane: Lane;
count: number;
}

interface LaneIndicatorProps {
lanes: LaneCount[];
className?: string;
}

const LANE_COLORS: Record<Lane, { bg: string; text: string; label: string }> = {
LEFT: {
bg: 'bg-blue-500/20 dark:bg-blue-400/20',
text: 'text-blue-600 dark:text-blue-400',
label: 'Left',
},
RIGHT: {
bg: 'bg-red-500/20 dark:bg-red-400/20',
text: 'text-red-600 dark:text-red-400',
label: 'Right',
},
CENTER: {
bg: 'bg-purple-500/20 dark:bg-purple-400/20',
text: 'text-purple-600 dark:text-purple-400',
label: 'Center',
},
UNKNOWN: {
bg: 'bg-gray-500/20 dark:bg-gray-400/20',
text: 'text-gray-600 dark:text-gray-400',
label: 'Other',
},
};

/**
* Displays the distribution of sources across political lanes.
* Shows a horizontal bar with proportional segments for each lane.
*/
const LaneIndicator = ({ lanes, className }: LaneIndicatorProps) => {
const total = lanes.reduce((sum, l) => sum + l.count, 0);
if (total === 0) return null;

return (
<div className={cn('space-y-2', className)}>
<div className="flex items-center justify-between text-xs text-black/60 dark:text-white/60">
<span className="font-medium">Source Balance</span>
<span>{total} sources</span>
</div>

{/* Proportional bar */}
<div className="flex h-2 rounded-full overflow-hidden bg-light-200 dark:bg-dark-200">
{lanes
.filter((l) => l.count > 0)
.map((lane) => {
const width = (lane.count / total) * 100;
return (
<div
key={lane.lane}
className={cn(LANE_COLORS[lane.lane].bg, 'transition-all')}
style={{ width: `${width}%` }}
title={`${LANE_COLORS[lane.lane].label}: ${lane.count}`}
/>
);
})}
</div>

{/* Legend */}
<div className="flex flex-wrap gap-3 text-xs">
{lanes
.filter((l) => l.count > 0)
.map((lane) => (
<div key={lane.lane} className="flex items-center gap-1.5">
<div
className={cn(
'w-2.5 h-2.5 rounded-full',
LANE_COLORS[lane.lane].bg,
)}
/>
<span className={LANE_COLORS[lane.lane].text}>
{LANE_COLORS[lane.lane].label}
</span>
<span className="text-black/40 dark:text-white/40">
({lane.count})
</span>
</div>
))}
</div>
</div>
);
};

export default LaneIndicator;

Loading