Add hybrid research copilot workspace

This commit is contained in:
2026-03-14 19:32:00 -04:00
parent 7a42d73a48
commit 2ee9a549a3
27 changed files with 2864 additions and 323 deletions

View File

@@ -0,0 +1,444 @@
'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<void>;
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<HTMLDivElement | null>(null);
const [prompt, setPrompt] = useState('');
const [selectedSources, setSelectedSources] = useState<SearchSource[]>(session?.selected_sources ?? DEFAULT_SOURCES);
const [pinnedArtifactIds, setPinnedArtifactIds] = useState<number[]>(session?.pinned_artifact_ids ?? []);
const [optimisticMessages, setOptimisticMessages] = useState<OptimisticMessage[]>([]);
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 (
<Panel
className={panelClassName}
title={variant === 'focus' ? 'Copilot Focus' : 'Research Copilot'}
subtitle="Cited answers, follow-up questions, and draft actions tied directly to the current company workspace."
actions={<BrainCircuit className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-[color:var(--line-strong)] bg-[color:var(--panel)] px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-[color:var(--accent)]">
{ticker}
</span>
<span className="rounded-full border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
Target: {targetSection.replace('_', ' ')}
</span>
{pinnedArtifactIds.length > 0 ? (
<span className="rounded-full border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
{pinnedArtifactIds.length} pinned
</span>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{SOURCE_OPTIONS.map((option) => {
const selected = selectedSources.includes(option.value);
return (
<Button
key={option.value}
type="button"
variant={selected ? 'primary' : 'ghost'}
className="px-2 py-1 text-xs"
onClick={() => {
setSelectedSources((current) => {
if (selected && current.length > 1) {
return current.filter((entry) => entry !== option.value);
}
return selected ? current : [...current, option.value];
});
}}
>
{option.label}
</Button>
);
})}
</div>
<div
ref={scrollRef}
className={variant === 'focus'
? 'max-h-[52vh] overflow-y-auto rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4'
: 'max-h-[36rem] overflow-y-auto rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4'
}
>
{messages.length === 0 ? (
<div className="space-y-3">
<p className="text-sm text-[color:var(--terminal-muted)]">
Ask for cited thesis changes, memo updates, risk summaries, or evidence gaps. This copilot uses the existing search index and current research context.
</p>
<div className="flex flex-wrap gap-2">
{suggestedPrompts.map((suggestion) => (
<button
key={suggestion}
type="button"
className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-3 py-2 text-left text-xs text-[color:var(--terminal-muted)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--terminal-bright)]"
onClick={() => setPrompt(suggestion)}
>
{suggestion}
</button>
))}
</div>
</div>
) : (
<div className="space-y-4">
{messages.map((message, index) => (
<article
key={`${message.role}-${message.created_at}-${index}`}
className={message.role === 'assistant'
? 'rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-4'
: 'rounded-2xl border border-[color:var(--line-weak)] bg-[rgba(255,255,255,0.03)] p-4'
}
>
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
{message.role === 'assistant' ? 'Copilot' : 'You'}
</p>
{message.role === 'assistant' ? <Sparkles className="size-4 text-[color:var(--accent)]" /> : null}
</div>
<p className="mt-3 whitespace-pre-wrap text-sm leading-7 text-[color:var(--terminal-bright)]">
{message.content_markdown}
</p>
{message.citations.length > 0 ? (
<div className="mt-4 space-y-2">
{message.citations.map((citation) => {
const pinned = citation.artifactId !== null && pinnedArtifactIds.includes(citation.artifactId);
return (
<div key={`${message.id}-${citation.index}`} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p className="text-xs text-[color:var(--accent)]">[{citation.index}] {citation.label}</p>
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">{citation.excerpt}</p>
</div>
<div className="flex flex-wrap gap-2">
<a
href={citation.href}
className="inline-flex items-center gap-1 rounded-lg border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)]"
>
Open
<ExternalLink className="size-3" />
</a>
{citation.artifactId ? (
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => {
setPinnedArtifactIds((current) => pinned
? current.filter((entry) => entry !== citation.artifactId)
: [...current, citation.artifactId!]);
}}
>
{pinned ? <PinOff className="size-3" /> : <Pin className="size-3" />}
{pinned ? 'Unpin' : 'Pin'}
</Button>
) : null}
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => {
void onAttachCitation(citation, targetSection);
}}
disabled={!citation.artifactId}
>
<Link2 className="size-3" />
Attach
</Button>
</div>
</div>
</div>
);
})}
</div>
) : null}
{message.role === 'assistant' && message.suggested_actions.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-2">
{message.suggested_actions.map((action) => (
<Button
key={action.id}
variant={action.type === 'queue_research_brief' ? 'secondary' : 'ghost'}
className="px-2 py-1 text-xs"
onClick={() => {
void applySuggestedAction(action);
}}
>
{action.type === 'draft_note' ? <NotebookIcon /> : action.type === 'draft_memo_section' ? <FileText className="size-3" /> : <Sparkles className="size-3" />}
{action.label}
</Button>
))}
</div>
) : null}
{message.role === 'assistant' && message.follow_ups.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-2">
{message.follow_ups.map((followUp) => (
<button
key={followUp}
type="button"
className="rounded-full border border-[color:var(--line-weak)] px-2 py-1 text-[11px] text-[color:var(--terminal-muted)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--terminal-bright)]"
onClick={() => setPrompt(followUp)}
>
{followUp}
</button>
))}
</div>
) : null}
</article>
))}
</div>
)}
{submitting ? (
<div className="mt-3 flex items-center gap-2 text-xs text-[color:var(--terminal-muted)]">
<LoaderCircle className="size-3.5 animate-spin" />
Running cited research turn...
</div>
) : null}
</div>
<div className="space-y-3 rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
<Input
aria-label="Research copilot prompt"
value={prompt}
onChange={(event) => 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();
}
}}
/>
<div className="flex flex-wrap gap-2">
<Button onClick={() => void sendTurn()} disabled={!prompt.trim() || submitting}>
<BrainCircuit className="size-4" />
{submitting ? 'Thinking...' : 'Ask copilot'}
</Button>
<Button variant="secondary" onClick={() => void queueBrief()} disabled={!prompt.trim() || queueing}>
<Sparkles className="size-4" />
{queueing ? 'Queueing...' : 'Queue brief'}
</Button>
</div>
</div>
</div>
</Panel>
);
}
function NotebookIcon() {
return <FileText className="size-3" />;
}