From 2ee9a549a3b1d5d3512048df658e7089d18b7fd0 Mon Sep 17 00:00:00 2001 From: francy51 Date: Sat, 14 Mar 2026 19:32:00 -0400 Subject: [PATCH] Add hybrid research copilot workspace --- app/research/page.tsx | 771 +++++++++++------- components/dashboard/task-feed.tsx | 3 +- .../research/research-copilot-panel.tsx | 444 ++++++++++ docs/DESIGN_SYSTEM.md | 61 ++ drizzle/0013_research_copilot.sql | 36 + drizzle/meta/_journal.json | 14 + e2e/research-mvp.spec.ts | 10 + hooks/use-task-notifications-center.ts | 5 + lib/api.ts | 44 + lib/query/keys.ts | 1 + lib/query/options.ts | 11 + lib/server/api/app.ts | 112 +++ lib/server/api/research-copilot.e2e.test.ts | 219 +++++ lib/server/db/index.ts | 1 - lib/server/db/schema.ts | 40 +- lib/server/db/sqlite-schema-compat.ts | 45 + lib/server/repos/research-copilot.test.ts | 165 ++++ lib/server/repos/research-copilot.ts | 229 ++++++ lib/server/repos/research-library.ts | 30 +- lib/server/research-copilot-format.test.ts | 69 ++ lib/server/research-copilot-format.ts | 225 +++++ lib/server/research-copilot.ts | 419 ++++++++++ lib/server/task-notifications.test.ts | 31 + lib/server/task-notifications.ts | 15 + lib/server/task-processors.ts | 96 +++ lib/task-workflow.ts | 14 +- lib/types.ts | 77 +- 27 files changed, 2864 insertions(+), 323 deletions(-) create mode 100644 components/research/research-copilot-panel.tsx create mode 100644 drizzle/0013_research_copilot.sql create mode 100644 lib/server/api/research-copilot.e2e.test.ts create mode 100644 lib/server/repos/research-copilot.test.ts create mode 100644 lib/server/repos/research-copilot.ts create mode 100644 lib/server/research-copilot-format.test.ts create mode 100644 lib/server/research-copilot-format.ts create mode 100644 lib/server/research-copilot.ts diff --git a/app/research/page.tsx b/app/research/page.tsx index 5efe44a..a433263 100644 --- a/app/research/page.tsx +++ b/app/research/page.tsx @@ -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 = { + thesis: 'thesisMarkdown', + variant_view: 'variantViewMarkdown', + catalysts: 'catalystsMarkdown', + risks: 'risksMarkdown', + disconfirming_evidence: 'disconfirmingEvidenceMarkdown', + next_actions: 'nextActionsMarkdown' +}; + function parseTags(value: string) { const unique = new Set(); @@ -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 = () => ( + } + > +
+ setNoteForm((current) => ({ ...current, title: event.target.value }))} placeholder="Headline or checkpoint title" /> + setNoteForm((current) => ({ ...current, summary: event.target.value }))} placeholder="One-line summary for skimming and search" /> +