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

@@ -6,6 +6,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { BookOpenText, Download, FilePlus2, Filter, FolderUp, Link2, NotebookPen, Search, ShieldCheck, Sparkles, Trash2 } from 'lucide-react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { ResearchCopilotPanel } from '@/components/research/research-copilot-panel';
import { AppShell } from '@/components/shell/app-shell';
import { Panel } from '@/components/ui/panel';
import { Button } from '@/components/ui/button';
@@ -31,6 +32,8 @@ import type {
ResearchArtifact,
ResearchArtifactKind,
ResearchArtifactSource,
ResearchCopilotCitation,
ResearchCopilotSession,
ResearchMemo,
ResearchMemoSection,
ResearchWorkspace
@@ -104,6 +107,15 @@ const EMPTY_MEMO_FORM: MemoFormState = {
nextActionsMarkdown: ''
};
const MEMO_FORM_FIELD_BY_SECTION: Record<ResearchMemoSection, keyof MemoFormState> = {
thesis: 'thesisMarkdown',
variant_view: 'variantViewMarkdown',
catalysts: 'catalystsMarkdown',
risks: 'risksMarkdown',
disconfirming_evidence: 'disconfirmingEvidenceMarkdown',
next_actions: 'nextActionsMarkdown'
};
function parseTags(value: string) {
const unique = new Set<string>();
@@ -197,6 +209,8 @@ function ResearchPageContent() {
const [uploadTitle, setUploadTitle] = useState('');
const [uploadSummary, setUploadSummary] = useState('');
const [uploadTags, setUploadTags] = useState('');
const [researchMode, setResearchMode] = useState<'workspace' | 'copilot'>('workspace');
const [focusTab, setFocusTab] = useState<'library' | 'memo' | 'packet'>('library');
const ticker = useMemo(() => searchParams.get('ticker')?.trim().toUpperCase() ?? '', [searchParams]);
const deferredSearch = useDeferredValue(searchInput);
@@ -253,6 +267,7 @@ function ResearchPageContent() {
const invalidateResearch = async (symbol: string) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.researchWorkspace(symbol) }),
queryClient.invalidateQueries({ queryKey: queryKeys.researchCopilotSession(symbol) }),
queryClient.invalidateQueries({ queryKey: ['research', 'library', symbol] }),
queryClient.invalidateQueries({ queryKey: queryKeys.researchMemo(symbol) }),
queryClient.invalidateQueries({ queryKey: queryKeys.researchPacket(symbol) }),
@@ -399,9 +414,369 @@ function ResearchPageContent() {
}
};
const handleCopilotSessionChange = (session: ResearchCopilotSession) => {
setWorkspace((current) => current ? { ...current, copilotSession: session } : current);
};
const handleDraftNote = (input: { title: string; summary: string; bodyMarkdown: string }) => {
setNoteForm({
id: null,
title: input.title,
summary: input.summary,
bodyMarkdown: input.bodyMarkdown,
tags: 'copilot'
});
setNotice('Loaded copilot output into the note draft editor for review.');
};
const handleDraftMemoSection = (section: ResearchMemoSection, contentMarkdown: string) => {
const field = MEMO_FORM_FIELD_BY_SECTION[section];
setMemoForm((current) => ({
...current,
[field]: contentMarkdown
}));
setAttachSection(section);
setNotice(`Loaded copilot output into ${MEMO_SECTIONS.find((item) => item.value === section)?.label}.`);
};
const handleAttachCitation = async (citation: ResearchCopilotCitation, section: ResearchMemoSection) => {
if (!citation.artifactId) {
setError('This citation cannot be attached because no research artifact is available for it.');
return;
}
try {
const memoId = await ensureMemo();
await addResearchMemoEvidence({
memoId,
artifactId: citation.artifactId,
section
});
setNotice(`Attached cited evidence to ${MEMO_SECTIONS.find((item) => item.value === section)?.label}.`);
await invalidateResearch(ticker);
await loadWorkspace(ticker);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to attach cited evidence');
}
};
const availableTags = workspace?.availableTags ?? [];
const memoEvidenceCount = workspace?.packet.sections.reduce((sum, section) => sum + section.evidence.length, 0) ?? 0;
const renderQuickNotePanel = () => (
<Panel
title={noteForm.id === null ? 'Quick Note' : 'Edit Note'}
subtitle="Capture thesis changes, diligence notes, and interpretation gaps directly into the library."
actions={<NotebookPen className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-3">
<Input aria-label="Research note title" value={noteForm.title} onChange={(event) => setNoteForm((current) => ({ ...current, title: event.target.value }))} placeholder="Headline or checkpoint title" />
<Input aria-label="Research note summary" value={noteForm.summary} onChange={(event) => setNoteForm((current) => ({ ...current, summary: event.target.value }))} placeholder="One-line summary for skimming and search" />
<textarea
aria-label="Research note body"
value={noteForm.bodyMarkdown}
onChange={(event) => setNoteForm((current) => ({ ...current, bodyMarkdown: event.target.value }))}
placeholder="Write the actual research note, variant view, or diligence conclusion..."
className="min-h-[160px] w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition placeholder:text-[color:var(--terminal-muted)] focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_var(--focus-ring)]"
/>
<Input aria-label="Research note tags" value={noteForm.tags} onChange={(event) => setNoteForm((current) => ({ ...current, tags: event.target.value }))} placeholder="Tags, comma-separated" />
<div className="flex flex-wrap gap-2">
<Button onClick={() => void saveNote()}>
<FilePlus2 className="size-4" />
{noteForm.id === null ? 'Save note' : 'Update note'}
</Button>
{noteForm.id !== null ? (
<Button variant="ghost" onClick={() => setNoteForm(EMPTY_NOTE_FORM)}>
Cancel edit
</Button>
) : null}
</div>
</div>
</Panel>
);
const renderLibraryPanel = () => (
<Panel
title="Research Library"
subtitle={`${library.length} artifacts match the current filter set.`}
actions={(
<div className="flex items-center gap-2">
<span className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Attach to</span>
<select className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2 py-1 text-xs text-[color:var(--terminal-bright)]" value={attachSection} onChange={(event) => setAttachSection(event.target.value as ResearchMemoSection)}>
{MEMO_SECTIONS.map((section) => (
<option key={section.value} value={section.value}>{section.label}</option>
))}
</select>
</div>
)}
>
<div className="space-y-4">
<div className="grid gap-3 rounded-2xl border border-[color:var(--line-weak)] bg-[rgba(255,255,255,0.03)] p-4 lg:grid-cols-[1.4fr_repeat(3,minmax(0,1fr))]">
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Search</label>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[color:var(--terminal-muted)]" />
<Input aria-label="Research search" className="pl-9" value={searchInput} onChange={(event) => setSearchInput(event.target.value)} placeholder="Keyword search research..." />
</div>
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Artifact Type</label>
<select aria-label="Artifact type filter" className="w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={kindFilter} onChange={(event) => setKindFilter(event.target.value as '' | ResearchArtifactKind)}>
{KIND_OPTIONS.map((option) => (
<option key={option.label} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Source</label>
<select aria-label="Artifact source filter" className="w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value as '' | ResearchArtifactSource)}>
{SOURCE_OPTIONS.map((option) => (
<option key={option.label} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Tag</label>
<select aria-label="Artifact tag filter" className="w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={tagFilter} onChange={(event) => setTagFilter(event.target.value)}>
<option value="">All tags</option>
{availableTags.map((tag) => (
<option key={tag} value={tag}>{tag}</option>
))}
</select>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]">
<input type="checkbox" checked={linkedOnly} onChange={(event) => setLinkedOnly(event.target.checked)} />
Show memo-linked evidence only
</label>
<div className="flex items-center gap-2 rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-xs text-[color:var(--terminal-muted)]">
<ShieldCheck className="size-4 text-[color:var(--accent)]" />
Private workspace scope
</div>
</div>
<div className="space-y-3">
{library.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No artifacts match the current search and filter combination.</p>
) : (
library.map((artifact) => (
<article key={artifact.id} className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
{artifact.kind.replace('_', ' ')} · {artifact.source} · {formatTimestamp(artifact.updated_at)}
</p>
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">
{artifact.title ?? `${artifact.kind.replace('_', ' ')} artifact`}
</h4>
</div>
<div className="flex flex-wrap gap-2">
{artifact.kind === 'upload' && artifact.storage_path ? (
<a 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)]" href={getResearchArtifactFileUrl(artifact.id)}>
<Download className="size-3" />
File
</a>
) : null}
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => void attachArtifact(artifact)}>
<Link2 className="size-3" />
Attach
</Button>
{artifact.kind === 'note' ? (
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setNoteForm(noteFormFromArtifact(artifact))}>
Edit
</Button>
) : null}
{artifact.source === 'user' || artifact.kind === 'upload' ? (
<Button
variant="danger"
className="px-2 py-1 text-xs"
onClick={async () => {
try {
await deleteResearchArtifact(artifact.id);
setNotice('Removed artifact from the library.');
await invalidateResearch(ticker);
await loadWorkspace(ticker);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to delete artifact');
}
}}
>
<Trash2 className="size-3" />
Delete
</Button>
) : null}
</div>
</div>
{artifact.summary ? (
<p className="mt-3 text-sm leading-6 text-[color:var(--terminal-bright)]">{artifact.summary}</p>
) : null}
{artifact.body_markdown ? (
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-muted)]">{artifact.body_markdown}</p>
) : null}
<div className="mt-4 flex flex-wrap gap-2">
{artifact.linked_to_memo ? (
<span className="rounded-full border border-[color:var(--line-strong)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--accent)]">In memo</span>
) : null}
{artifact.accession_number ? (
<span className="rounded-full border border-[color:var(--line-weak)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">{artifact.accession_number}</span>
) : null}
{artifact.tags.map((tag) => (
<button
key={`${artifact.id}-${tag}`}
type="button"
className="rounded-full border border-[color:var(--line-weak)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--terminal-muted)] transition hover:border-[color:var(--line-strong)]"
onClick={() => setTagFilter(tag)}
>
{tag}
</button>
))}
</div>
</article>
))
)}
</div>
</div>
</Panel>
);
const renderUploadPanel = () => (
<Panel
title="Upload Research"
subtitle="Store decks, transcripts, channel-check notes, and internal models with metadata-first handling."
actions={<FolderUp className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-3">
<Input aria-label="Upload title" value={uploadTitle} onChange={(event) => setUploadTitle(event.target.value)} placeholder="Optional display title" />
<Input aria-label="Upload summary" value={uploadSummary} onChange={(event) => setUploadSummary(event.target.value)} placeholder="Optional file summary" />
<Input aria-label="Upload tags" value={uploadTags} onChange={(event) => setUploadTags(event.target.value)} placeholder="Tags, comma-separated" />
<input
aria-label="Upload file"
type="file"
onChange={(event) => setUploadFile(event.target.files?.[0] ?? null)}
className="block w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]"
/>
<Button variant="secondary" onClick={() => void uploadFileToLibrary()} disabled={!uploadFile}>
<UploadIcon />
Upload file
</Button>
</div>
</Panel>
);
const renderMemoPanel = () => (
<Panel
title="Investment Memo"
subtitle="This is the living buy-side thesis. Use the library to attach evidence into sections before packet review."
actions={<BookOpenText className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<select aria-label="Memo rating" className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={memoForm.rating} onChange={(event) => setMemoForm((current) => ({ ...current, rating: event.target.value }))}>
<option value="">Rating</option>
<option value="strong_buy">Strong Buy</option>
<option value="buy">Buy</option>
<option value="hold">Hold</option>
<option value="sell">Sell</option>
</select>
<select aria-label="Memo conviction" className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={memoForm.conviction} onChange={(event) => setMemoForm((current) => ({ ...current, conviction: event.target.value }))}>
<option value="">Conviction</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
<div className="grid gap-3 md:grid-cols-2">
<Input aria-label="Memo time horizon" value={memoForm.timeHorizonMonths} onChange={(event) => setMemoForm((current) => ({ ...current, timeHorizonMonths: event.target.value }))} placeholder="Time horizon in months" />
<Input aria-label="Packet title" value={memoForm.packetTitle} onChange={(event) => setMemoForm((current) => ({ ...current, packetTitle: event.target.value }))} placeholder="Packet title override" />
</div>
<Input aria-label="Packet subtitle" value={memoForm.packetSubtitle} onChange={(event) => setMemoForm((current) => ({ ...current, packetSubtitle: event.target.value }))} placeholder="Packet subtitle" />
{MEMO_SECTIONS.map((section) => {
const field = MEMO_FORM_FIELD_BY_SECTION[section.value];
return (
<div key={section.value}>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{section.label}</label>
<textarea
aria-label={`Memo ${section.label}`}
value={memoForm[field]}
onChange={(event) => setMemoForm((current) => ({ ...current, [field]: event.target.value }))}
className="min-h-[108px] w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition placeholder:text-[color:var(--terminal-muted)] focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_var(--focus-ring)]"
placeholder={`Write ${section.label.toLowerCase()}...`}
/>
</div>
);
})}
<Button onClick={() => void saveMemo()}>
<NotebookPen className="size-4" />
Save memo
</Button>
</div>
</Panel>
);
const renderPacketPanel = () => (
<Panel
title="Research Packet"
subtitle="Presentation-ready memo sections with attached evidence for quick PM or IC review."
actions={<Sparkles className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-6">
{workspace!.packet.sections.map((section) => (
<section key={section.section} className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Packet Section</p>
<h3 className="mt-1 text-lg font-semibold text-[color:var(--terminal-bright)]">{section.title}</h3>
</div>
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{section.evidence.length} evidence items</p>
</div>
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-[color:var(--terminal-bright)]">
{section.body_markdown || 'No memo content yet for this section.'}
</p>
{section.evidence.length > 0 ? (
<div className="mt-4 grid gap-3 lg:grid-cols-2">
{section.evidence.map((item) => (
<article key={item.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">{item.artifact.kind.replace('_', ' ')}</p>
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">{item.artifact.title ?? 'Untitled evidence'}</h4>
</div>
{workspace!.memo ? (
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={async () => {
try {
await deleteResearchMemoEvidence(workspace!.memo!.id, item.id);
setNotice('Removed memo evidence.');
await invalidateResearch(ticker);
await loadWorkspace(ticker);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to remove memo evidence');
}
}}
>
Remove
</Button>
) : null}
</div>
{item.annotation ? (
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">{item.annotation}</p>
) : null}
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">{item.artifact.summary ?? item.artifact.body_markdown ?? 'No summary available.'}</p>
</article>
))}
</div>
) : null}
</section>
))}
</div>
</Panel>
);
return (
<AppShell
title="Research"
@@ -413,6 +788,28 @@ function ResearchPageContent() {
]}
actions={(
<div className="flex flex-wrap gap-2">
<div className="inline-flex rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-1">
<button
type="button"
className={researchMode === 'workspace'
? 'rounded-lg bg-[color:var(--panel)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]'
: 'rounded-lg px-3 py-2 text-sm text-[color:var(--terminal-muted)] transition hover:text-[color:var(--terminal-bright)]'
}
onClick={() => setResearchMode('workspace')}
>
Workspace
</button>
<button
type="button"
className={researchMode === 'copilot'
? 'rounded-lg bg-[color:var(--panel)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]'
: 'rounded-lg px-3 py-2 text-sm text-[color:var(--terminal-muted)] transition hover:text-[color:var(--terminal-bright)]'
}
onClick={() => setResearchMode('copilot')}
>
Copilot Focus
</button>
</div>
<Button
variant="secondary"
onClick={() => {
@@ -499,325 +896,75 @@ function ResearchPageContent() {
</Panel>
) : (
<>
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.25fr_1.1fr]">
<Panel
title="Library Filters"
subtitle="Narrow the evidence set by structure, ownership, and memo linkage."
actions={<Filter className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Search</label>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[color:var(--terminal-muted)]" />
<Input aria-label="Research search" className="pl-9" value={searchInput} onChange={(event) => setSearchInput(event.target.value)} placeholder="Keyword search research..." />
{researchMode === 'workspace' ? (
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.7fr)_minmax(22rem,0.85fr)]">
<div className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<div className="space-y-6">
{renderQuickNotePanel()}
{renderLibraryPanel()}
{renderUploadPanel()}
</div>
{renderMemoPanel()}
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Artifact Type</label>
<select aria-label="Artifact type filter" className="w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={kindFilter} onChange={(event) => setKindFilter(event.target.value as '' | ResearchArtifactKind)}>
{KIND_OPTIONS.map((option) => (
<option key={option.label} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Source</label>
<select aria-label="Artifact source filter" className="w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value as '' | ResearchArtifactSource)}>
{SOURCE_OPTIONS.map((option) => (
<option key={option.label} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Tag</label>
<select aria-label="Artifact tag filter" className="w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={tagFilter} onChange={(event) => setTagFilter(event.target.value)}>
<option value="">All tags</option>
{availableTags.map((tag) => (
<option key={tag} value={tag}>{tag}</option>
))}
</select>
</div>
<label className="flex items-center gap-2 rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]">
<input type="checkbox" checked={linkedOnly} onChange={(event) => setLinkedOnly(event.target.checked)} />
Show memo-linked evidence only
</label>
<div className="rounded-2xl border border-[color:var(--line-weak)] bg-[rgba(255,255,255,0.03)] p-4">
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
<ShieldCheck className="size-4 text-[color:var(--accent)]" />
Access Model
</div>
<p className="mt-3 text-sm text-[color:var(--terminal-muted)]">All research artifacts are private to the authenticated user in this release. The data model is prepared for workspace scopes later.</p>
</div>
{renderPacketPanel()}
</div>
</Panel>
<div className="space-y-6">
<Panel
title={noteForm.id === null ? 'Quick Note' : 'Edit Note'}
subtitle="Capture thesis changes, diligence notes, and interpretation gaps directly into the library."
actions={<NotebookPen className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-3">
<Input aria-label="Research note title" value={noteForm.title} onChange={(event) => setNoteForm((current) => ({ ...current, title: event.target.value }))} placeholder="Headline or checkpoint title" />
<Input aria-label="Research note summary" value={noteForm.summary} onChange={(event) => setNoteForm((current) => ({ ...current, summary: event.target.value }))} placeholder="One-line summary for skimming and search" />
<textarea
aria-label="Research note body"
value={noteForm.bodyMarkdown}
onChange={(event) => setNoteForm((current) => ({ ...current, bodyMarkdown: event.target.value }))}
placeholder="Write the actual research note, variant view, or diligence conclusion..."
className="min-h-[160px] w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition placeholder:text-[color:var(--terminal-muted)] focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_var(--focus-ring)]"
/>
<Input aria-label="Research note tags" value={noteForm.tags} onChange={(event) => setNoteForm((current) => ({ ...current, tags: event.target.value }))} placeholder="Tags, comma-separated" />
<ResearchCopilotPanel
ticker={ticker}
companyName={workspace.companyName}
session={workspace.copilotSession}
targetSection={attachSection}
onSessionChange={handleCopilotSessionChange}
onDraftNote={handleDraftNote}
onDraftMemoSection={handleDraftMemoSection}
onAttachCitation={handleAttachCitation}
onNotice={setNotice}
onError={setError}
/>
</div>
) : (
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.25fr)_minmax(22rem,0.95fr)]">
<ResearchCopilotPanel
ticker={ticker}
companyName={workspace.companyName}
session={workspace.copilotSession}
targetSection={attachSection}
variant="focus"
onSessionChange={handleCopilotSessionChange}
onDraftNote={handleDraftNote}
onDraftMemoSection={handleDraftMemoSection}
onAttachCitation={handleAttachCitation}
onNotice={setNotice}
onError={setError}
/>
<div className="space-y-6">
<Panel
title="Research Surfaces"
subtitle="Keep the memo, library, and packet within reach while the copilot stays full-width."
actions={<Filter className="size-4 text-[color:var(--accent)]" />}
>
<div className="flex flex-wrap gap-2">
<Button onClick={() => void saveNote()}>
<FilePlus2 className="size-4" />
{noteForm.id === null ? 'Save note' : 'Update note'}
</Button>
{noteForm.id !== null ? (
<Button variant="ghost" onClick={() => setNoteForm(EMPTY_NOTE_FORM)}>
Cancel edit
</Button>
) : null}
{(['library', 'memo', 'packet'] as const).map((tab) => (
<button
key={tab}
type="button"
className={focusTab === tab
? 'rounded-lg border border-[color:var(--line-strong)] bg-[color:var(--panel)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]'
: 'rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-muted)] transition hover:text-[color:var(--terminal-bright)]'
}
onClick={() => setFocusTab(tab)}
>
{tab === 'library' ? 'Library' : tab === 'memo' ? 'Memo' : 'Packet'}
</button>
))}
</div>
</div>
</Panel>
<Panel
title="Research Library"
subtitle={`${library.length} artifacts match the current filter set.`}
actions={(
<div className="flex items-center gap-2">
<span className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Attach to</span>
<select className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2 py-1 text-xs text-[color:var(--terminal-bright)]" value={attachSection} onChange={(event) => setAttachSection(event.target.value as ResearchMemoSection)}>
{MEMO_SECTIONS.map((section) => (
<option key={section.value} value={section.value}>{section.label}</option>
))}
</select>
</div>
)}
>
<div className="space-y-3">
{library.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No artifacts match the current search and filter combination.</p>
) : (
library.map((artifact) => (
<article key={artifact.id} className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
{artifact.kind.replace('_', ' ')} · {artifact.source} · {formatTimestamp(artifact.updated_at)}
</p>
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">
{artifact.title ?? `${artifact.kind.replace('_', ' ')} artifact`}
</h4>
</div>
<div className="flex flex-wrap gap-2">
{artifact.kind === 'upload' && artifact.storage_path ? (
<a 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)]" href={getResearchArtifactFileUrl(artifact.id)}>
<Download className="size-3" />
File
</a>
) : null}
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => void attachArtifact(artifact)}>
<Link2 className="size-3" />
Attach
</Button>
{artifact.kind === 'note' ? (
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setNoteForm(noteFormFromArtifact(artifact))}>
Edit
</Button>
) : null}
{artifact.source === 'user' || artifact.kind === 'upload' ? (
<Button
variant="danger"
className="px-2 py-1 text-xs"
onClick={async () => {
try {
await deleteResearchArtifact(artifact.id);
setNotice('Removed artifact from the library.');
await invalidateResearch(ticker);
await loadWorkspace(ticker);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to delete artifact');
}
}}
>
<Trash2 className="size-3" />
Delete
</Button>
) : null}
</div>
</div>
{artifact.summary ? (
<p className="mt-3 text-sm leading-6 text-[color:var(--terminal-bright)]">{artifact.summary}</p>
) : null}
{artifact.body_markdown ? (
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-muted)]">{artifact.body_markdown}</p>
) : null}
<div className="mt-4 flex flex-wrap gap-2">
{artifact.linked_to_memo ? (
<span className="rounded-full border border-[color:var(--line-strong)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--accent)]">In memo</span>
) : null}
{artifact.accession_number ? (
<span className="rounded-full border border-[color:var(--line-weak)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">{artifact.accession_number}</span>
) : null}
{artifact.tags.map((tag) => (
<button
key={`${artifact.id}-${tag}`}
type="button"
className="rounded-full border border-[color:var(--line-weak)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--terminal-muted)] transition hover:border-[color:var(--line-strong)]"
onClick={() => setTagFilter(tag)}
>
{tag}
</button>
))}
</div>
</article>
))
)}
</div>
</Panel>
<Panel
title="Upload Research"
subtitle="Store decks, transcripts, channel-check notes, and internal models with metadata-first handling."
actions={<FolderUp className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-3">
<Input aria-label="Upload title" value={uploadTitle} onChange={(event) => setUploadTitle(event.target.value)} placeholder="Optional display title" />
<Input aria-label="Upload summary" value={uploadSummary} onChange={(event) => setUploadSummary(event.target.value)} placeholder="Optional file summary" />
<Input aria-label="Upload tags" value={uploadTags} onChange={(event) => setUploadTags(event.target.value)} placeholder="Tags, comma-separated" />
<input
aria-label="Upload file"
type="file"
onChange={(event) => setUploadFile(event.target.files?.[0] ?? null)}
className="block w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]"
/>
<Button variant="secondary" onClick={() => void uploadFileToLibrary()} disabled={!uploadFile}>
<UploadIcon />
Upload file
</Button>
</div>
</Panel>
</div>
<Panel
title="Investment Memo"
subtitle="This is the living buy-side thesis. Use the library to attach evidence into sections before packet review."
actions={<BookOpenText className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<select aria-label="Memo rating" className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={memoForm.rating} onChange={(event) => setMemoForm((current) => ({ ...current, rating: event.target.value }))}>
<option value="">Rating</option>
<option value="strong_buy">Strong Buy</option>
<option value="buy">Buy</option>
<option value="hold">Hold</option>
<option value="sell">Sell</option>
</select>
<select aria-label="Memo conviction" className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={memoForm.conviction} onChange={(event) => setMemoForm((current) => ({ ...current, conviction: event.target.value }))}>
<option value="">Conviction</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
<div className="grid gap-3 md:grid-cols-2">
<Input aria-label="Memo time horizon" value={memoForm.timeHorizonMonths} onChange={(event) => setMemoForm((current) => ({ ...current, timeHorizonMonths: event.target.value }))} placeholder="Time horizon in months" />
<Input aria-label="Packet title" value={memoForm.packetTitle} onChange={(event) => setMemoForm((current) => ({ ...current, packetTitle: event.target.value }))} placeholder="Packet title override" />
</div>
<Input aria-label="Packet subtitle" value={memoForm.packetSubtitle} onChange={(event) => setMemoForm((current) => ({ ...current, packetSubtitle: event.target.value }))} placeholder="Packet subtitle" />
{MEMO_SECTIONS.map((section) => {
const fieldMap: Record<ResearchMemoSection, keyof MemoFormState> = {
thesis: 'thesisMarkdown',
variant_view: 'variantViewMarkdown',
catalysts: 'catalystsMarkdown',
risks: 'risksMarkdown',
disconfirming_evidence: 'disconfirmingEvidenceMarkdown',
next_actions: 'nextActionsMarkdown'
};
const field = fieldMap[section.value];
return (
<div key={section.value}>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{section.label}</label>
<textarea
aria-label={`Memo ${section.label}`}
value={memoForm[field]}
onChange={(event) => setMemoForm((current) => ({ ...current, [field]: event.target.value }))}
className="min-h-[108px] w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition placeholder:text-[color:var(--terminal-muted)] focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_var(--focus-ring)]"
placeholder={`Write ${section.label.toLowerCase()}...`}
/>
</div>
);
})}
<Button onClick={() => void saveMemo()}>
<NotebookPen className="size-4" />
Save memo
</Button>
</Panel>
{focusTab === 'library' ? renderLibraryPanel() : null}
{focusTab === 'memo' ? renderMemoPanel() : null}
{focusTab === 'packet' ? renderPacketPanel() : null}
</div>
</Panel>
</div>
<Panel
title="Research Packet"
subtitle="Presentation-ready memo sections with attached evidence for quick PM or IC review."
actions={<Sparkles className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-6">
{workspace.packet.sections.map((section) => (
<section key={section.section} className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Packet Section</p>
<h3 className="mt-1 text-lg font-semibold text-[color:var(--terminal-bright)]">{section.title}</h3>
</div>
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{section.evidence.length} evidence items</p>
</div>
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-[color:var(--terminal-bright)]">
{section.body_markdown || 'No memo content yet for this section.'}
</p>
{section.evidence.length > 0 ? (
<div className="mt-4 grid gap-3 lg:grid-cols-2">
{section.evidence.map((item) => (
<article key={item.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">{item.artifact.kind.replace('_', ' ')}</p>
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">{item.artifact.title ?? 'Untitled evidence'}</h4>
</div>
{workspace.memo ? (
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={async () => {
try {
await deleteResearchMemoEvidence(workspace.memo!.id, item.id);
setNotice('Removed memo evidence.');
await invalidateResearch(ticker);
await loadWorkspace(ticker);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to remove memo evidence');
}
}}
>
Remove
</Button>
) : null}
</div>
{item.annotation ? (
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">{item.annotation}</p>
) : null}
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">{item.artifact.summary ?? item.artifact.body_markdown ?? 'No summary available.'}</p>
</article>
))}
</div>
) : null}
</section>
))}
</div>
</Panel>
)}
</>
)}
</>