'use client'; import { useEffect, useMemo, useRef, useState } from 'react'; import { BrainCircuit, ExternalLink, FileText, Link2, LoaderCircle, Pin, PinOff, Sparkles } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Panel } from '@/components/ui/panel'; import { Input } from '@/components/ui/input'; import { queueResearchCopilotJob, runResearchCopilotTurn } from '@/lib/api'; import type { ResearchCopilotCitation, ResearchCopilotSession, ResearchCopilotSuggestedAction, ResearchMemoSection, SearchSource } from '@/lib/types'; type ResearchCopilotPanelProps = { ticker: string; companyName: string | null; session: ResearchCopilotSession | null; targetSection: ResearchMemoSection; variant?: 'docked' | 'focus'; onSessionChange: (session: ResearchCopilotSession) => void; onDraftNote: (input: { title: string; summary: string; bodyMarkdown: string }) => void; onDraftMemoSection: (section: ResearchMemoSection, contentMarkdown: string) => void; onAttachCitation: (citation: ResearchCopilotCitation, section: ResearchMemoSection) => Promise; onNotice: (message: string) => void; onError: (message: string) => void; }; const DEFAULT_SOURCES: SearchSource[] = ['documents', 'filings', 'research']; const SOURCE_OPTIONS: Array<{ value: SearchSource; label: string }> = [ { value: 'documents', label: 'Documents' }, { value: 'filings', label: 'Filing briefs' }, { value: 'research', label: 'Research notes' } ]; type OptimisticMessage = { id: string; role: 'user'; content_markdown: string; }; function draftStorageKey(ticker: string) { return `research-copilot-draft:${ticker}`; } function toSummary(markdown: string) { const normalized = markdown.replace(/\s+/g, ' ').trim(); return normalized.length > 180 ? `${normalized.slice(0, 177).trimEnd()}...` : normalized; } function buildSuggestedPrompts(companyName: string | null, ticker: string) { const label = companyName ?? ticker; return [ `What changed in the latest evidence set for ${label}?`, `Draft a thesis update for ${ticker} with the strongest citations.`, `What evidence most directly challenges the current bull case?` ]; } export function ResearchCopilotPanel({ ticker, companyName, session, targetSection, variant = 'docked', onSessionChange, onDraftNote, onDraftMemoSection, onAttachCitation, onNotice, onError }: ResearchCopilotPanelProps) { const scrollRef = useRef(null); const [prompt, setPrompt] = useState(''); const [selectedSources, setSelectedSources] = useState(session?.selected_sources ?? DEFAULT_SOURCES); const [pinnedArtifactIds, setPinnedArtifactIds] = useState(session?.pinned_artifact_ids ?? []); const [optimisticMessages, setOptimisticMessages] = useState([]); const [submitting, setSubmitting] = useState(false); const [queueing, setQueueing] = useState(false); useEffect(() => { setSelectedSources(session?.selected_sources ?? DEFAULT_SOURCES); setPinnedArtifactIds(session?.pinned_artifact_ids ?? []); }, [session?.id, session?.updated_at]); useEffect(() => { const saved = typeof window === 'undefined' ? null : window.localStorage.getItem(draftStorageKey(ticker)); setPrompt(saved ?? ''); }, [ticker]); useEffect(() => { if (typeof window === 'undefined') { return; } const timeout = window.setTimeout(() => { window.localStorage.setItem(draftStorageKey(ticker), prompt); }, 250); return () => window.clearTimeout(timeout); }, [prompt, ticker]); useEffect(() => { if (!scrollRef.current) { return; } scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [session?.updated_at, optimisticMessages.length, submitting]); const messages = useMemo(() => { const persisted = session?.messages ?? []; const optimistic = optimisticMessages.map((message) => ({ id: -1, session_id: session?.id ?? 0, user_id: '', role: message.role, content_markdown: message.content_markdown, citations: [], follow_ups: [], suggested_actions: [], selected_sources: selectedSources, pinned_artifact_ids: pinnedArtifactIds, memo_section: targetSection, created_at: new Date().toISOString() })); return [...persisted, ...optimistic]; }, [optimisticMessages, pinnedArtifactIds, selectedSources, session, targetSection]); const sendTurn = async () => { const query = prompt.trim(); if (!query || submitting) { return; } const optimisticMessage: OptimisticMessage = { id: `${Date.now()}`, role: 'user', content_markdown: query }; setOptimisticMessages((current) => [...current, optimisticMessage]); setSubmitting(true); try { const response = await runResearchCopilotTurn({ ticker, query, sources: selectedSources, pinnedArtifactIds, memoSection: targetSection }); setPrompt(''); setOptimisticMessages([]); if (typeof window !== 'undefined') { window.localStorage.removeItem(draftStorageKey(ticker)); } onSessionChange(response.session); } catch (error) { setOptimisticMessages([]); onError(error instanceof Error ? error.message : 'Unable to run copilot turn'); } finally { setSubmitting(false); } }; const queueBrief = async (queryOverride?: string | null) => { const query = queryOverride?.trim() || prompt.trim(); if (!query || queueing) { return; } setQueueing(true); try { await queueResearchCopilotJob({ ticker, query, sources: selectedSources }); onNotice('Queued research brief. Track progress in notifications.'); } catch (error) { onError(error instanceof Error ? error.message : 'Unable to queue research brief'); } finally { setQueueing(false); } }; const applySuggestedAction = async (action: ResearchCopilotSuggestedAction) => { if (action.type === 'draft_note' && action.content_markdown) { onDraftNote({ title: action.title ?? `${ticker} copilot draft`, summary: toSummary(action.content_markdown), bodyMarkdown: action.content_markdown }); return; } if (action.type === 'draft_memo_section' && action.section && action.content_markdown) { onDraftMemoSection(action.section, action.content_markdown); return; } if (action.type === 'queue_research_brief') { await queueBrief(action.query); } }; const suggestedPrompts = buildSuggestedPrompts(companyName, ticker); const panelClassName = variant === 'focus' ? 'min-h-[70vh]' : 'xl:sticky xl:top-6 self-start'; return ( } >
{ticker} Target: {targetSection.replace('_', ' ')} {pinnedArtifactIds.length > 0 ? ( {pinnedArtifactIds.length} pinned ) : null}
{SOURCE_OPTIONS.map((option) => { const selected = selectedSources.includes(option.value); return ( ); })}
{messages.length === 0 ? (

Ask for cited thesis changes, memo updates, risk summaries, or evidence gaps. This copilot uses the existing search index and current research context.

{suggestedPrompts.map((suggestion) => ( ))}
) : (
{messages.map((message, index) => (

{message.role === 'assistant' ? 'Copilot' : 'You'}

{message.role === 'assistant' ? : null}

{message.content_markdown}

{message.citations.length > 0 ? (
{message.citations.map((citation) => { const pinned = citation.artifactId !== null && pinnedArtifactIds.includes(citation.artifactId); return (

[{citation.index}] {citation.label}

{citation.excerpt}

Open {citation.artifactId ? ( ) : null}
); })}
) : null} {message.role === 'assistant' && message.suggested_actions.length > 0 ? (
{message.suggested_actions.map((action) => ( ))}
) : null} {message.role === 'assistant' && message.follow_ups.length > 0 ? (
{message.follow_ups.map((followUp) => ( ))}
) : null}
))}
)} {submitting ? (
Running cited research turn...
) : null}
setPrompt(event.target.value)} placeholder="Ask for evidence-backed thesis updates, risk summaries, or draft memo language..." onKeyDown={(event) => { if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault(); void sendTurn(); } }} />
); } function NotebookIcon() { return ; }