From 9d5e882b5e68489c57827d20cb2434d6f53cc68a Mon Sep 17 00:00:00 2001 From: francy51 Date: Thu, 9 Apr 2026 18:42:53 -0400 Subject: [PATCH] Fix portfolio research state issues and convert capture to modal - Fix critical event subscription bug causing stale state when switching workspaces - Add research notes display to portfolio holdings with expandable note previews - Implement getNotesByTicker backend command for fetching notes by symbol - Add portfolio context to terminal tabs showing active portfolio name - Convert research capture bar to space-saving modal dialog - Add "New Note" button to research toolbar for quick access - Improve portfolio switching UX with visual indicators and clear labeling Co-Authored-By: Claude Opus 4.6 (1M context) --- MosaicIQ/src-tauri/src/commands/research.rs | 22 +- MosaicIQ/src-tauri/src/lib.rs | 1 + MosaicIQ/src-tauri/src/research/mod.rs | 11 +- MosaicIQ/src-tauri/src/research/service.rs | 62 +++- MosaicIQ/src-tauri/src/research/types.rs | 8 + MosaicIQ/src/App.tsx | 44 ++- .../src/components/Panels/PortfolioPanel.tsx | 203 ++++++++---- .../Research/ResearchCaptureModal.tsx | 296 ++++++++++++++++++ .../src/components/Research/ResearchMode.tsx | 83 +++-- .../components/Research/ResearchToolbar.tsx | 3 + MosaicIQ/src/components/TabBar/TabBar.tsx | 14 +- MosaicIQ/src/components/Terminal/Terminal.tsx | 92 ++---- MosaicIQ/src/hooks/index.ts | 2 + MosaicIQ/src/hooks/usePortfolioNotes.ts | 87 +++++ MosaicIQ/src/hooks/usePortfolioWorkflow.ts | 20 ++ MosaicIQ/src/hooks/useResearchProjection.ts | 99 ++++-- MosaicIQ/src/hooks/useTabs.ts | 12 +- MosaicIQ/src/lib/researchBridge.ts | 6 + MosaicIQ/src/types/financial.ts | 42 +++ MosaicIQ/src/types/research.ts | 9 + 20 files changed, 895 insertions(+), 221 deletions(-) create mode 100644 MosaicIQ/src/components/Research/ResearchCaptureModal.tsx create mode 100644 MosaicIQ/src/hooks/usePortfolioNotes.ts diff --git a/MosaicIQ/src-tauri/src/commands/research.rs b/MosaicIQ/src-tauri/src/commands/research.rs index c860b3a..68f0c0d 100644 --- a/MosaicIQ/src-tauri/src/commands/research.rs +++ b/MosaicIQ/src-tauri/src/commands/research.rs @@ -1,10 +1,10 @@ use crate::research::{ ArchiveResearchNoteRequest, CaptureResearchNoteRequest, CreateResearchWorkspaceRequest, - ExportResearchBundleRequest, GetNoteAuditTrailRequest, GetWorkspaceProjectionRequest, - GhostNote, ListNoteLinksRequest, ListResearchNotesRequest, ListWorkspaceGhostNotesRequest, - NoteAuditTrail, NoteCaptureResult, NoteLink, PipelineJob, PromoteNoteToThesisRequest, - ResearchBundleExport, ResearchNote, ResearchWorkspace, RetryResearchJobsRequest, - ReviewGhostNoteRequest, UpdateResearchNoteRequest, WorkspaceProjection, + ExportResearchBundleRequest, GetNoteAuditTrailRequest, GetNotesByTickerRequest, + GetWorkspaceProjectionRequest, GhostNote, ListNoteLinksRequest, ListResearchNotesRequest, + ListWorkspaceGhostNotesRequest, NoteAuditTrail, NoteCaptureResult, NoteLink, PipelineJob, + PromoteNoteToThesisRequest, ResearchBundleExport, ResearchNote, ResearchWorkspace, + RetryResearchJobsRequest, ReviewGhostNoteRequest, UpdateResearchNoteRequest, WorkspaceProjection, }; use crate::state::AppState; @@ -91,6 +91,18 @@ pub async fn list_research_notes( .map_err(|error| error.to_string()) } +#[tauri::command] +pub async fn get_notes_by_ticker( + state: tauri::State<'_, AppState>, + request: GetNotesByTickerRequest, +) -> Result, String> { + state + .research_service + .get_notes_by_ticker(request) + .await + .map_err(|error| error.to_string()) +} + #[tauri::command] pub async fn get_workspace_projection( state: tauri::State<'_, AppState>, diff --git a/MosaicIQ/src-tauri/src/lib.rs b/MosaicIQ/src-tauri/src/lib.rs index 4efa73d..fab88c0 100644 --- a/MosaicIQ/src-tauri/src/lib.rs +++ b/MosaicIQ/src-tauri/src/lib.rs @@ -59,6 +59,7 @@ pub fn run() { commands::research::update_research_note, commands::research::archive_research_note, commands::research::list_research_notes, + commands::research::get_notes_by_ticker, commands::research::get_workspace_projection, commands::research::list_note_links, commands::research::list_workspace_ghost_notes, diff --git a/MosaicIQ/src-tauri/src/research/mod.rs b/MosaicIQ/src-tauri/src/research/mod.rs index 933276e..cd0dcaf 100644 --- a/MosaicIQ/src-tauri/src/research/mod.rs +++ b/MosaicIQ/src-tauri/src/research/mod.rs @@ -19,9 +19,10 @@ pub use pipeline::spawn_research_scheduler; pub use service::ResearchService; pub use types::{ ArchiveResearchNoteRequest, CaptureResearchNoteRequest, CreateResearchWorkspaceRequest, - ExportResearchBundleRequest, GetNoteAuditTrailRequest, GetWorkspaceProjectionRequest, - GhostNote, ListNoteLinksRequest, ListResearchNotesRequest, ListWorkspaceGhostNotesRequest, - NoteAuditTrail, NoteCaptureResult, NoteLink, PipelineJob, PromoteNoteToThesisRequest, - ResearchBundleExport, ResearchNote, ResearchWorkspace, RetryResearchJobsRequest, - ReviewGhostNoteRequest, UpdateResearchNoteRequest, WorkspaceProjection, + ExportResearchBundleRequest, GetNoteAuditTrailRequest, GetNotesByTickerRequest, + GetWorkspaceProjectionRequest, GhostNote, ListNoteLinksRequest, ListResearchNotesRequest, + ListWorkspaceGhostNotesRequest, NoteAuditTrail, NoteCaptureResult, NoteLink, PipelineJob, + PromoteNoteToThesisRequest, ResearchBundleExport, ResearchNote, ResearchWorkspace, + RetryResearchJobsRequest, ReviewGhostNoteRequest, UpdateResearchNoteRequest, + WorkspaceProjection, }; diff --git a/MosaicIQ/src-tauri/src/research/service.rs b/MosaicIQ/src-tauri/src/research/service.rs index 60b22b9..73f069c 100644 --- a/MosaicIQ/src-tauri/src/research/service.rs +++ b/MosaicIQ/src-tauri/src/research/service.rs @@ -24,12 +24,13 @@ use crate::research::repository::ResearchRepository; use crate::research::types::{ ArchiveResearchNoteRequest, AuditActor, AuditEntityKind, AuditEvent, CaptureMethod, CaptureResearchNoteRequest, CreateResearchWorkspaceRequest, EvidenceStatus, - ExportResearchBundleRequest, GetNoteAuditTrailRequest, GetWorkspaceProjectionRequest, - GhostLifecycleState, GhostReviewAction, GhostStatus, LinkOrigin, ListNoteLinksRequest, - ListResearchNotesRequest, ListWorkspaceGhostNotesRequest, NoteAuditTrail, NoteCaptureResult, - NotePriority, NoteType, PromoteNoteToThesisRequest, ProvenanceActor, ResearchBundleExport, - ResearchNote, ResearchWorkspace, RetryResearchJobsRequest, ReviewGhostNoteRequest, SourceKind, - ThesisStatus, UpdateResearchNoteRequest, WorkspaceProjection, WorkspaceScope, + ExportResearchBundleRequest, GetNoteAuditTrailRequest, GetNotesByTickerRequest, + GetWorkspaceProjectionRequest, GhostLifecycleState, GhostReviewAction, GhostStatus, + LinkOrigin, ListNoteLinksRequest, ListResearchNotesRequest, ListWorkspaceGhostNotesRequest, + NoteAuditTrail, NoteCaptureResult, NotePriority, NoteType, PromoteNoteToThesisRequest, + ProvenanceActor, ResearchBundleExport, ResearchNote, ResearchWorkspace, RetryResearchJobsRequest, + ReviewGhostNoteRequest, SourceKind, ThesisStatus, UpdateResearchNoteRequest, WorkspaceProjection, + WorkspaceScope, }; use crate::research::util::{generate_id, normalize_text, now_rfc3339, sha256_hex}; @@ -429,6 +430,55 @@ impl ResearchService { .await } + pub async fn get_notes_by_ticker( + &self, + request: GetNotesByTickerRequest, + ) -> Result> { + let normalized_ticker = request.ticker.to_uppercase(); + + // Get all workspaces that match this ticker + let workspaces = self.repository.list_workspaces().await?; + let matching_workspaces: Vec<_> = workspaces + .into_iter() + .filter(|ws| { + ws.primary_ticker.to_uppercase() == normalized_ticker + || ws.primary_ticker.to_uppercase() + == format!("{}-US", normalized_ticker) + }) + .collect(); + + if matching_workspaces.is_empty() { + return Ok(Vec::new()); + } + + // Collect notes from all matching workspaces + let mut all_notes = Vec::new(); + for workspace in matching_workspaces { + let workspace_notes = self + .repository + .list_notes(&workspace.id, false, None) + .await?; + + // Filter for non-archived notes only + let notes: Vec = workspace_notes + .into_iter() + .filter(|note| !note.archived) + .collect(); + + all_notes.extend(notes); + } + + // Sort by updated_at (most recent first) + all_notes.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + + // Apply limit if specified + if let Some(limit) = request.limit { + all_notes.truncate(limit); + } + + Ok(all_notes) + } + pub async fn get_workspace_projection( &self, request: GetWorkspaceProjectionRequest, diff --git a/MosaicIQ/src-tauri/src/research/types.rs b/MosaicIQ/src-tauri/src/research/types.rs index 53122b7..c991bda 100644 --- a/MosaicIQ/src-tauri/src/research/types.rs +++ b/MosaicIQ/src-tauri/src/research/types.rs @@ -780,6 +780,14 @@ pub struct ListResearchNotesRequest { pub note_type: Option, } +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct GetNotesByTickerRequest { + pub ticker: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + #[derive(Debug, Clone, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct GetWorkspaceProjectionRequest { diff --git a/MosaicIQ/src/App.tsx b/MosaicIQ/src/App.tsx index 4c56321..8565da3 100644 --- a/MosaicIQ/src/App.tsx +++ b/MosaicIQ/src/App.tsx @@ -9,7 +9,6 @@ import { useResearchCaptureFlow } from './hooks/useResearchCaptureFlow'; import type { ResearchComposerState } from './hooks/useResearchComposer'; import { useResearchWorkspaces } from './hooks/useResearchWorkspaces'; import { useTabs } from './hooks/useTabs'; -import { createEntry } from './hooks/useTerminal'; import { useTerminalOrchestrator } from './hooks/useTerminalOrchestrator'; import { useTickerHistory } from './hooks/useTickerHistory'; import { agentSettingsBridge } from './lib/agentSettingsBridge'; @@ -135,19 +134,6 @@ function App() { tabs.createWorkspace(); }, [tabs]); - const appendTerminalSystemMessage = useCallback( - (message: string) => { - tabs.appendWorkspaceEntry( - tabs.activeWorkspaceId, - createEntry({ - type: 'system', - content: message, - }), - ); - }, - [tabs], - ); - useAppShortcuts({ activeView, activeWorkspaceId: tabs.activeWorkspaceId, @@ -194,11 +180,25 @@ function App() { void handleCommand('/portfolio'); }, [activeView, handleCommand, isProcessing]); - const tabBarTabs = tabs.workspaces.map((workspace) => ({ - id: workspace.id, - name: workspace.name, - isActive: workspace.id === tabs.activeWorkspaceId, - })); + // Sync portfolio names from workflow to workspaces + React.useEffect(() => { + tabs.workspaces.forEach((workspace) => { + const workflow = portfolioWorkflow.readWorkflow(workspace.id); + if (workflow.portfolioName !== workspace.portfolioName) { + tabs.setPortfolioName(workspace.id, workflow.portfolioName); + } + }); + }, [portfolioWorkflow, tabs.workspaces, tabs.setPortfolioName]); + + const tabBarTabs = tabs.workspaces.map((workspace) => { + const workflow = portfolioWorkflow.readWorkflow(workspace.id); + return { + id: workspace.id, + name: workspace.name, + isActive: workspace.id === tabs.activeWorkspaceId, + portfolioName: workflow.portfolioName, + }; + }); return (
@@ -269,15 +269,9 @@ function App() { onClearPortfolioAction={handleClearPortfolioAction} resetCommandIndex={resetCommandIndex} portfolioWorkflow={activePortfolioWorkflow} - researchWorkspaces={researchWorkspaces.workspaces} - activeResearchWorkspaceId={researchWorkspaces.activeWorkspaceId} - onSelectResearchWorkspace={researchWorkspaces.selectWorkspace} - onEnsureResearchWorkspace={handleEnsureResearchWorkspace} - onCaptureResearchNote={handleCaptureResearchNote} onOpenResearchContext={(intent) => { void handleOpenResearch(intent); }} - onAppendSystemMessage={appendTerminalSystemMessage} /> )}
diff --git a/MosaicIQ/src/components/Panels/PortfolioPanel.tsx b/MosaicIQ/src/components/Panels/PortfolioPanel.tsx index 346a650..db548c6 100644 --- a/MosaicIQ/src/components/Panels/PortfolioPanel.tsx +++ b/MosaicIQ/src/components/Panels/PortfolioPanel.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { Package } from 'lucide-react'; +import { ChevronDown, ChevronRight, FileText, Package } from 'lucide-react'; import { Portfolio } from '../../types/financial'; import { PortfolioAction, PortfolioActionSeed } from '../../types/terminal'; import { MetricGrid, ActionButton, ButtonGroup, DataSection } from '../ui'; +import { usePortfolioNotes } from '../../hooks/usePortfolioNotes'; interface PortfolioPanelProps { portfolio: Portfolio; @@ -31,9 +32,19 @@ export const PortfolioPanel: React.FC = ({ onStartAction, onSelectHolding, }) => { + const [expandedNotes, setExpandedNotes] = React.useState>({}); + const { holdingsWithNotes } = usePortfolioNotes(portfolio.holdings, true); + const totalGainPositive = portfolio.totalGain >= 0; const dayChangePositive = portfolio.dayChange >= 0; + const toggleNotesExpanded = (symbol: string) => { + setExpandedNotes((prev) => ({ + ...prev, + [symbol]: !prev[symbol], + })); + }; + const summaryMetrics = [ { label: 'Total Value', @@ -157,71 +168,132 @@ export const PortfolioPanel: React.FC = ({ Gain/Loss + + Research + Actions - {portfolio.holdings.map((holding, index) => { + {holdingsWithNotes.map((holding, index) => { const gainPositive = holding.gainLoss >= 0; + const isExpanded = expandedNotes[holding.symbol] || false; + const hasNotes = (holding.notesCount ?? 0) > 0; return ( - - - - - - {formatQuantity(holding.quantity)} - - - {formatCurrency(holding.avgCost)} - - - {formatCurrency(holding.currentPrice)} - - - {formatCurrency(holding.currentValue)} - - + - {formatSignedCurrency(holding.gainLoss)} -
- ({gainPositive ? '+' : ''} - {holding.gainLossPercent.toFixed(2)}%) -
- - -
- onStartAction('sell', { symbol: holding.symbol })} - size="sm" + +
- - +
{holding.symbol}
+
{holding.name}
+ + + + {formatQuantity(holding.quantity)} + + + {formatCurrency(holding.avgCost)} + + + {formatCurrency(holding.currentPrice)} + + + {formatCurrency(holding.currentValue)} + + + {formatSignedCurrency(holding.gainLoss)} +
+ ({gainPositive ? '+' : ''} + {holding.gainLossPercent.toFixed(2)}%) +
+ + + {hasNotes ? ( + + ) : ( + No research + )} + + +
+ onStartAction('sell', { symbol: holding.symbol })} + size="sm" + > + Sell + + onRunCommand(`/search ${holding.symbol}`)} + size="sm" + > + Search + +
+ + + {isExpanded && hasNotes && ( + + +
+
+ Recent Research Notes +
+ {holding.recentNotes?.map((note) => ( +
+ +
+
+ + {note.noteType.replace(/_/g, ' ')} + + +
+
+ {note.title} +
+
+
+ ))} + onRunCommand(`/research ${holding.symbol}`)} + size="sm" + className="w-full" + > + View all research for {holding.symbol} + +
+ + + )} + ); })} @@ -232,3 +304,24 @@ export const PortfolioPanel: React.FC = ({ ); }; + +const EvidenceStatusBadge: React.FC<{ status: string }> = ({ status }) => { + const colors: Record = { + unsourced: 'bg-[#3a3a3a] text-[#888888]', + source_linked: 'bg-[#2a3a2a] text-[#8ea6c0]', + quoted: 'bg-[#2a3a4a] text-[#8ec0e0]', + corroborated: 'bg-[#1a4a2a] text-[#60c0a0]', + inferred: 'bg-[#3a2a4a] text-[#a080c0]', + contradicted: 'bg-[#4a1a1a] text-[#c08080]', + stale: 'bg-[#4a3a1a] text-[#c0a060]', + superseded: 'bg-[#3a3a3a] text-[#888888]', + }; + + const colorClass = colors[status] || colors.unsourced; + + return ( + + {status.replace(/_/g, ' ')} + + ); +}; diff --git a/MosaicIQ/src/components/Research/ResearchCaptureModal.tsx b/MosaicIQ/src/components/Research/ResearchCaptureModal.tsx new file mode 100644 index 0000000..8735054 --- /dev/null +++ b/MosaicIQ/src/components/Research/ResearchCaptureModal.tsx @@ -0,0 +1,296 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import { X } from 'lucide-react'; +import { useResearchComposer, type ResearchComposerState } from '../../hooks/useResearchComposer'; +import type { + NoteCaptureResult, + NoteType, + ResearchNote, + ResearchWorkspace, + SourceKind, +} from '../../types/research'; +import { NOTE_TYPE_LABELS } from './primitives/researchMeta'; + +interface ResearchCaptureModalProps { + isOpen: boolean; + onClose: () => void; + workspaces: ResearchWorkspace[]; + defaultWorkspaceId?: string | null; + defaultTicker?: string; + defaultRawText?: string; + seedKey?: string; + contextLabel?: string; + onWorkspaceChange?: (workspaceId: string) => void; + onSubmitCapture?: (draft: ResearchComposerState) => Promise; + onCaptured?: (note: ResearchNote) => void; +} + +const noteTypeOptions: NoteType[] = [ + 'fact', + 'management_signal', + 'claim', + 'risk', + 'catalyst', + 'valuation_point', + 'question', +]; + +const sourceKinds: SourceKind[] = ['manual', 'article', 'transcript', 'filing', 'news_feed', 'model']; + +export const ResearchCaptureModal: React.FC = ({ + isOpen, + onClose, + workspaces, + defaultWorkspaceId, + defaultTicker, + defaultRawText, + seedKey, + contextLabel, + onWorkspaceChange, + onSubmitCapture, + onCaptured, +}) => { + const textareaRef = useRef(null); + const composer = useResearchComposer({ + defaultWorkspaceId, + defaultTicker, + }); + const { draft, error, isSubmitting, isValid, setDraft, submit } = composer; + + // Focus textarea when modal opens + useEffect(() => { + if (isOpen && textareaRef.current) { + textareaRef.current.focus(); + } + }, [isOpen]); + + // Reset draft when default values change + useEffect(() => { + if (!isOpen) return; + + const patch: Partial = {}; + if (defaultWorkspaceId) { + patch.workspaceId = defaultWorkspaceId; + } + if (defaultTicker) { + patch.ticker = defaultTicker; + } + if (defaultRawText !== undefined) { + patch.rawText = defaultRawText; + } + if (Object.keys(patch).length > 0) { + setDraft(patch); + } + }, [defaultRawText, defaultTicker, defaultWorkspaceId, seedKey, isOpen, setDraft]); + + const activeWorkspaceLabel = useMemo( + () => + workspaces.find((workspace) => workspace.id === draft.workspaceId)?.name ?? + 'Choose workspace', + [draft.workspaceId, workspaces], + ); + + const handleSubmit = async () => { + try { + const result = onSubmitCapture ? await onSubmitCapture(draft) : await submit(); + composer.resetDraft( + result.note.workspaceId, + result.note.ticker ?? draft.ticker, + ); + onWorkspaceChange?.(result.note.workspaceId); + onCaptured?.(result.note); + onClose(); + } catch (captureError) { + composer.setErrorMessage( + captureError instanceof Error ? captureError.message : String(captureError), + ); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + if (isValid) { + void handleSubmit(); + } + } + }; + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + onKeyDown={handleKeyDown} + > + {/* Header */} +
+
+

+ Capture Research Note +

+ {contextLabel && ( +

{contextLabel}

+ )} +
+ +
+ + {/* Content */} +
+ {/* Workspace & Ticker Row */} +
+
+ + +
+
+ + setDraft({ ticker: e.target.value.toUpperCase() })} + placeholder="AAPL" + className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded px-3 py-2 text-sm text-[#e0e0e0] font-mono placeholder-[#666666] focus:outline-none focus:border-[#58a6ff] transition-colors" + /> +
+
+ + +
+
+ + {/* Main Content Textarea */} +
+ +