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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Vec<ResearchNote>, 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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<R: Runtime + 'static> ResearchService<R> {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_notes_by_ticker(
|
||||
&self,
|
||||
request: GetNotesByTickerRequest,
|
||||
) -> Result<Vec<ResearchNote>> {
|
||||
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<ResearchNote> = 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,
|
||||
|
||||
@@ -780,6 +780,14 @@ pub struct ListResearchNotesRequest {
|
||||
pub note_type: Option<NoteType>,
|
||||
}
|
||||
|
||||
#[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<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetWorkspaceProjectionRequest {
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex h-screen bg-[#0a0a0a]">
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<PortfolioPanelProps> = ({
|
||||
onStartAction,
|
||||
onSelectHolding,
|
||||
}) => {
|
||||
const [expandedNotes, setExpandedNotes] = React.useState<Record<string, boolean>>({});
|
||||
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<PortfolioPanelProps> = ({
|
||||
<th className="px-4 py-2 text-right text-[10px] uppercase tracking-wider">
|
||||
Gain/Loss
|
||||
</th>
|
||||
<th className="px-4 py-2 text-center text-[10px] uppercase tracking-wider">
|
||||
Research
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-[10px] uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#1a1a1a]">
|
||||
{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 (
|
||||
<tr
|
||||
key={holding.symbol}
|
||||
className="transition-all duration-150 hover:bg-[#151515] hover:shadow-sm"
|
||||
style={{ animationDelay: `${index * 30}ms` }}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
className="text-left transition-colors hover:text-[#58a6ff]"
|
||||
onClick={() => onSelectHolding?.(holding.symbol)}
|
||||
>
|
||||
<div className="font-semibold text-[#e0e0e0]">{holding.symbol}</div>
|
||||
<div className="text-[10px] text-[#888888]">{holding.name}</div>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-[#e0e0e0]">
|
||||
{formatQuantity(holding.quantity)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-[#e0e0e0]">
|
||||
{formatCurrency(holding.avgCost)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-[#e0e0e0]">
|
||||
{formatCurrency(holding.currentPrice)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-[#e0e0e0]">
|
||||
{formatCurrency(holding.currentValue)}
|
||||
</td>
|
||||
<td
|
||||
className={`px-4 py-3 text-right font-semibold ${
|
||||
gainPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'
|
||||
}`}
|
||||
<React.Fragment key={holding.symbol}>
|
||||
<tr
|
||||
className="transition-all duration-150 hover:bg-[#151515] hover:shadow-sm"
|
||||
style={{ animationDelay: `${index * 30}ms` }}
|
||||
>
|
||||
{formatSignedCurrency(holding.gainLoss)}
|
||||
<div className="text-[10px]">
|
||||
({gainPositive ? '+' : ''}
|
||||
{holding.gainLossPercent.toFixed(2)}%)
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<ActionButton
|
||||
onClick={() => onStartAction('sell', { symbol: holding.symbol })}
|
||||
size="sm"
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
className="text-left transition-colors hover:text-[#58a6ff]"
|
||||
onClick={() => onSelectHolding?.(holding.symbol)}
|
||||
>
|
||||
Sell
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
onClick={() => onRunCommand(`/search ${holding.symbol}`)}
|
||||
size="sm"
|
||||
>
|
||||
Search
|
||||
</ActionButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<div className="font-semibold text-[#e0e0e0]">{holding.symbol}</div>
|
||||
<div className="text-[10px] text-[#888888]">{holding.name}</div>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-[#e0e0e0]">
|
||||
{formatQuantity(holding.quantity)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-[#e0e0e0]">
|
||||
{formatCurrency(holding.avgCost)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-[#e0e0e0]">
|
||||
{formatCurrency(holding.currentPrice)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-[#e0e0e0]">
|
||||
{formatCurrency(holding.currentValue)}
|
||||
</td>
|
||||
<td
|
||||
className={`px-4 py-3 text-right font-semibold ${
|
||||
gainPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'
|
||||
}`}
|
||||
>
|
||||
{formatSignedCurrency(holding.gainLoss)}
|
||||
<div className="text-[10px]">
|
||||
({gainPositive ? '+' : ''}
|
||||
{holding.gainLossPercent.toFixed(2)}%)
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{hasNotes ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleNotesExpanded(holding.symbol)}
|
||||
className="flex items-center justify-center gap-1 text-xs text-[#58a6ff] hover:text-[#7ec8ff] transition-colors mx-auto"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)}
|
||||
<span>{holding.notesCount} notes</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[10px] text-[#666666]">No research</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<ActionButton
|
||||
onClick={() => onStartAction('sell', { symbol: holding.symbol })}
|
||||
size="sm"
|
||||
>
|
||||
Sell
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
onClick={() => onRunCommand(`/search ${holding.symbol}`)}
|
||||
size="sm"
|
||||
>
|
||||
Search
|
||||
</ActionButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && hasNotes && (
|
||||
<tr className="bg-[#0d0d0d]">
|
||||
<td colSpan={8} className="px-4 py-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-[10px] text-[#888888] uppercase tracking-wide mb-2">
|
||||
Recent Research Notes
|
||||
</div>
|
||||
{holding.recentNotes?.map((note) => (
|
||||
<div
|
||||
key={note.id}
|
||||
className="flex items-start gap-2 p-2 bg-[#151515] rounded border border-[#1a1a1a] hover:border-[#2a2a2a] transition-colors"
|
||||
>
|
||||
<FileText className="w-3 h-3 text-[#58a6ff] mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-[#1a1a1a] text-[#8ea6c0]">
|
||||
{note.noteType.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<EvidenceStatusBadge status={note.evidenceStatus} />
|
||||
</div>
|
||||
<div className="text-xs text-[#e0e0e0] line-clamp-2">
|
||||
{note.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<ActionButton
|
||||
onClick={() => onRunCommand(`/research ${holding.symbol}`)}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
>
|
||||
View all research for {holding.symbol}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
@@ -232,3 +304,24 @@ export const PortfolioPanel: React.FC<PortfolioPanelProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EvidenceStatusBadge: React.FC<{ status: string }> = ({ status }) => {
|
||||
const colors: Record<string, string> = {
|
||||
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 (
|
||||
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${colorClass}`}>
|
||||
{status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
296
MosaicIQ/src/components/Research/ResearchCaptureModal.tsx
Normal file
296
MosaicIQ/src/components/Research/ResearchCaptureModal.tsx
Normal file
@@ -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<NoteCaptureResult>;
|
||||
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<ResearchCaptureModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
workspaces,
|
||||
defaultWorkspaceId,
|
||||
defaultTicker,
|
||||
defaultRawText,
|
||||
seedKey,
|
||||
contextLabel,
|
||||
onWorkspaceChange,
|
||||
onSubmitCapture,
|
||||
onCaptured,
|
||||
}) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(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<typeof draft> = {};
|
||||
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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="capture-modal-title"
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-3xl rounded-lg border border-[#2a2a2a] bg-[#111111] shadow-2xl max-h-[90vh] overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[#2a2a2a]">
|
||||
<div>
|
||||
<h2 id="capture-modal-title" className="text-lg font-mono font-semibold text-[#e0e0e0]">
|
||||
Capture Research Note
|
||||
</h2>
|
||||
{contextLabel && (
|
||||
<p className="text-xs text-[#888888] mt-1">{contextLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-[#888888] hover:text-[#e0e0e0] transition-colors"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{/* Workspace & Ticker Row */}
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-mono text-[#888888] mb-1.5">
|
||||
Workspace
|
||||
</label>
|
||||
<select
|
||||
value={draft.workspaceId ?? ''}
|
||||
onChange={(e) => setDraft({ workspaceId: e.target.value || undefined })}
|
||||
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded px-3 py-2 text-sm text-[#e0e0e0] font-mono focus:outline-none focus:border-[#58a6ff] transition-colors"
|
||||
>
|
||||
{workspaces.length === 0 ? (
|
||||
<option value="">No workspaces</option>
|
||||
) : (
|
||||
workspaces.map((workspace) => (
|
||||
<option key={workspace.id} value={workspace.id}>
|
||||
{workspace.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-mono text-[#888888] mb-1.5">
|
||||
Ticker
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.ticker ?? ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-mono text-[#888888] mb-1.5">
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
value={draft.userNoteTypeOverride ?? 'fact'}
|
||||
onChange={(e) => setDraft({ userNoteTypeOverride: e.target.value as NoteType })}
|
||||
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded px-3 py-2 text-sm text-[#e0e0e0] font-mono focus:outline-none focus:border-[#58a6ff] transition-colors"
|
||||
>
|
||||
{noteTypeOptions.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{NOTE_TYPE_LABELS[type] ?? type.replace(/_/g, ' ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Textarea */}
|
||||
<div>
|
||||
<label className="block text-xs font-mono text-[#888888] mb-1.5">
|
||||
Note Content
|
||||
</label>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={draft.rawText}
|
||||
onChange={(e) => setDraft({ rawText: e.target.value })}
|
||||
placeholder="Enter your research note here..."
|
||||
rows={6}
|
||||
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 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Source Row */}
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-mono text-[#888888] mb-1.5">
|
||||
Source URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={draft.sourceUrl ?? ''}
|
||||
onChange={(e) => setDraft({ sourceUrl: e.target.value })}
|
||||
placeholder="https://..."
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<label className="block text-xs font-mono text-[#888888] mb-1.5">
|
||||
Source Type
|
||||
</label>
|
||||
<select
|
||||
value={draft.sourceKind ?? 'manual'}
|
||||
onChange={(e) => setDraft({ sourceKind: e.target.value as SourceKind })}
|
||||
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded px-3 py-2 text-sm text-[#e0e0e0] font-mono focus:outline-none focus:border-[#58a6ff] transition-colors"
|
||||
>
|
||||
{sourceKinds.map((kind) => (
|
||||
<option key={kind} value={kind}>
|
||||
{kind.replace(/_/g, ' ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded bg-[#2a1a1a] border border-[#4a2930] px-3 py-2 text-xs text-[#ffb6c0] font-mono">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Help Text */}
|
||||
<p className="text-[10px] text-[#666666] font-mono">
|
||||
Press <kbd className="px-1 py-0.5 bg-[#1a1a1a] rounded border border-[#2a2a2a]">⌘ Enter</kbd> to submit
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-[#2a2a2a] bg-[#0a0a0a]">
|
||||
<div className="text-xs text-[#888888] font-mono">
|
||||
{activeWorkspaceLabel}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="rounded border border-[#2a2a2a] bg-[#151515] px-4 py-2 text-xs font-mono text-[#e0e0e0] transition-colors hover:border-[#58a6ff] hover:text-[#58a6ff] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={!isValid || isSubmitting}
|
||||
className="rounded border border-[#58a6ff] bg-[#58a6ff] px-4 py-2 text-xs font-mono text-[#0a0a0a] transition-colors hover:bg-[#7ec8ff] hover:border-[#7ec8ff] disabled:opacity-50 disabled:cursor-not-allowed font-semibold"
|
||||
>
|
||||
{isSubmitting ? 'Capturing...' : 'Capture Note'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { Suspense, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { researchBridge } from '../../lib/researchBridge';
|
||||
import {
|
||||
type ResearchComposerState,
|
||||
@@ -16,7 +17,7 @@ import type {
|
||||
ResearchNavigationIntent,
|
||||
ResearchWorkspace,
|
||||
} from '../../types/research';
|
||||
import { ResearchCaptureBar } from './ResearchCaptureBar';
|
||||
import { ResearchCaptureModal } from './ResearchCaptureModal';
|
||||
import { ResearchInspector } from './ResearchInspector';
|
||||
import { ResearchShell } from './ResearchShell';
|
||||
import { ResearchSidebar } from './ResearchSidebar';
|
||||
@@ -82,6 +83,8 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
||||
const [memoDraft, setMemoDraft] = useState('');
|
||||
const [auditTrail, setAuditTrail] = useState<NoteAuditTrail | null>(null);
|
||||
const [isLoadingAuditTrail, setIsLoadingAuditTrail] = useState(false);
|
||||
const [isCaptureModalOpen, setIsCaptureModalOpen] = useState(false);
|
||||
const [terminalNoteSeed, setTerminalNoteSeed] = useState<Pick<ResearchComposerState, 'rawText' | 'ticker'> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeWorkspaceId) {
|
||||
@@ -116,6 +119,16 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
||||
if (navigationIntent.preferredView) {
|
||||
workspaceState.setView(navigationIntent.preferredView);
|
||||
}
|
||||
|
||||
// Open capture modal if terminal note seed is provided
|
||||
if (navigationIntent.terminalNoteSeed) {
|
||||
setTerminalNoteSeed({
|
||||
rawText: navigationIntent.terminalNoteSeed.rawText ?? '',
|
||||
ticker: navigationIntent.terminalNoteSeed.ticker ?? '',
|
||||
});
|
||||
setIsCaptureModalOpen(true);
|
||||
}
|
||||
|
||||
onConsumeNavigationIntent();
|
||||
}, [
|
||||
activeWorkspaceId,
|
||||
@@ -208,10 +221,7 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
||||
useResearchKeyboard({
|
||||
enabled: Boolean(activeWorkspaceId),
|
||||
onFocusCapture: () => {
|
||||
const element = document.getElementById('research-capture-input');
|
||||
if (element instanceof HTMLElement) {
|
||||
element.focus();
|
||||
}
|
||||
setIsCaptureModalOpen(true);
|
||||
},
|
||||
onOpenSearch: () => {
|
||||
const element = document.getElementById('research-sidebar-search');
|
||||
@@ -357,30 +367,6 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-[var(--research-bg)]">
|
||||
<div className="border-b border-[#1d2430] px-5 py-4">
|
||||
<ResearchCaptureBar
|
||||
workspaces={workspaces}
|
||||
defaultWorkspaceId={activeWorkspaceId}
|
||||
defaultTicker={currentWorkspace?.primaryTicker ?? navigationIntent?.ticker}
|
||||
contextLabel={currentWorkspace ? `${currentWorkspace.primaryTicker} workspace` : 'Quick capture'}
|
||||
onWorkspaceChange={onSelectWorkspace}
|
||||
onEnsureWorkspace={onEnsureWorkspace}
|
||||
onSubmitCapture={(draft) =>
|
||||
onCaptureResearchNote({
|
||||
draft,
|
||||
fallbackTicker: currentWorkspace?.primaryTicker ?? navigationIntent?.ticker,
|
||||
explicitWorkspaceId: activeWorkspaceId,
|
||||
autoCreateFromTicker: Boolean(
|
||||
currentWorkspace?.primaryTicker ?? navigationIntent?.ticker,
|
||||
),
|
||||
})
|
||||
}
|
||||
onCaptured={(note) => {
|
||||
onSelectWorkspace(note.workspaceId);
|
||||
selection.selectNote(note.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ResearchShell
|
||||
sidebar={
|
||||
<ResearchSidebar
|
||||
@@ -409,6 +395,16 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
||||
onDensityChange={setDensity}
|
||||
onToggleInspector={toggleInspector}
|
||||
jobs={Object.values(projectionState.jobs)}
|
||||
extraActions={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCaptureModalOpen(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-[#58a6ff] hover:bg-[#7ec8ff] text-[#0a0a0a] text-xs font-mono font-semibold transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
New Note
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
inspector={
|
||||
@@ -452,6 +448,35 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
||||
renderView()
|
||||
)}
|
||||
</ResearchShell>
|
||||
|
||||
{/* Capture Modal */}
|
||||
<ResearchCaptureModal
|
||||
isOpen={isCaptureModalOpen}
|
||||
onClose={() => {
|
||||
setIsCaptureModalOpen(false);
|
||||
setTerminalNoteSeed(null);
|
||||
}}
|
||||
workspaces={workspaces}
|
||||
defaultWorkspaceId={activeWorkspaceId}
|
||||
defaultTicker={currentWorkspace?.primaryTicker ?? navigationIntent?.ticker}
|
||||
defaultRawText={terminalNoteSeed?.rawText}
|
||||
contextLabel={currentWorkspace ? `${currentWorkspace.primaryTicker} workspace` : 'Quick capture'}
|
||||
onWorkspaceChange={onSelectWorkspace}
|
||||
onSubmitCapture={(draft) =>
|
||||
onCaptureResearchNote({
|
||||
draft,
|
||||
fallbackTicker: currentWorkspace?.primaryTicker ?? navigationIntent?.ticker,
|
||||
explicitWorkspaceId: activeWorkspaceId,
|
||||
autoCreateFromTicker: Boolean(
|
||||
currentWorkspace?.primaryTicker ?? navigationIntent?.ticker,
|
||||
),
|
||||
})
|
||||
}
|
||||
onCaptured={(note) => {
|
||||
onSelectWorkspace(note.workspaceId);
|
||||
selection.selectNote(note.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ interface ResearchToolbarProps {
|
||||
onDensityChange: (density: ResearchDensityMode) => void;
|
||||
onToggleInspector: () => void;
|
||||
jobs: PipelineJob[];
|
||||
extraActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ResearchToolbar: React.FC<ResearchToolbarProps> = ({
|
||||
@@ -21,6 +22,7 @@ export const ResearchToolbar: React.FC<ResearchToolbarProps> = ({
|
||||
onDensityChange,
|
||||
onToggleInspector,
|
||||
jobs,
|
||||
extraActions,
|
||||
}) => (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
@@ -36,6 +38,7 @@ export const ResearchToolbar: React.FC<ResearchToolbarProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{extraActions}
|
||||
<div className="inline-flex items-center gap-2 rounded-2xl border border-[#1d2430] bg-[#0f141b] px-3 py-2">
|
||||
<LayoutGrid className="h-4 w-4 text-[#7e93ab]" />
|
||||
<select
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface Tab {
|
||||
id: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
portfolioName?: string | null;
|
||||
}
|
||||
|
||||
interface TabBarProps {
|
||||
@@ -85,9 +86,16 @@ export const TabBar: React.FC<TabBarProps> = ({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className={`text-xs font-mono truncate max-w-[100px] ${tab.isActive ? 'text-[#e0e0e0]' : 'text-[#888888]'}`}>
|
||||
{tab.name}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className={`text-xs font-mono truncate max-w-[100px] ${tab.isActive ? 'text-[#e0e0e0]' : 'text-[#888888]'}`}>
|
||||
{tab.name}
|
||||
</span>
|
||||
{tab.portfolioName && (
|
||||
<span className={`text-[9px] font-mono truncate max-w-[100px] ${tab.isActive ? 'text-[#58a6ff]' : 'text-[#666666]'}`}>
|
||||
{tab.portfolioName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Close Button */}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import React from 'react';
|
||||
import type { ResearchComposerState } from '../../hooks/useResearchComposer';
|
||||
import { PortfolioWorkflowState } from '../../hooks/usePortfolioWorkflow';
|
||||
import { extractResearchContext } from '../../lib/researchContext';
|
||||
import { buildTerminalResearchNoteSeed } from '../../lib/terminalResearchNote';
|
||||
import {
|
||||
NoteCaptureResult,
|
||||
ResearchNavigationIntent,
|
||||
ResearchWorkspace,
|
||||
} from '../../types/research';
|
||||
import {
|
||||
PortfolioAction,
|
||||
@@ -16,7 +13,6 @@ import {
|
||||
} from '../../types/terminal';
|
||||
import { TerminalOutput } from './TerminalOutput';
|
||||
import { CommandInput, CommandInputHandle } from './CommandInput';
|
||||
import { ResearchCaptureBar } from '../Research/ResearchCaptureBar';
|
||||
|
||||
interface TerminalProps {
|
||||
history: TerminalEntry[];
|
||||
@@ -33,21 +29,7 @@ interface TerminalProps {
|
||||
onClearPortfolioAction: () => void;
|
||||
resetCommandIndex: () => void;
|
||||
portfolioWorkflow: PortfolioWorkflowState;
|
||||
researchWorkspaces: ResearchWorkspace[];
|
||||
activeResearchWorkspaceId: string | null;
|
||||
onSelectResearchWorkspace: (workspaceId: string) => void;
|
||||
onEnsureResearchWorkspace: (request: {
|
||||
ticker?: string;
|
||||
workspaceId?: string | null;
|
||||
}) => Promise<ResearchWorkspace | null>;
|
||||
onCaptureResearchNote: (args: {
|
||||
draft: ResearchComposerState;
|
||||
fallbackTicker?: string;
|
||||
explicitWorkspaceId?: string | null;
|
||||
autoCreateFromTicker?: boolean;
|
||||
}) => Promise<NoteCaptureResult>;
|
||||
onOpenResearchContext: (intent: ResearchNavigationIntent) => void;
|
||||
onAppendSystemMessage: (message: string) => void;
|
||||
}
|
||||
|
||||
export const Terminal: React.FC<TerminalProps> = ({
|
||||
@@ -62,13 +44,7 @@ export const Terminal: React.FC<TerminalProps> = ({
|
||||
onClearPortfolioAction,
|
||||
resetCommandIndex,
|
||||
portfolioWorkflow,
|
||||
researchWorkspaces,
|
||||
activeResearchWorkspaceId,
|
||||
onSelectResearchWorkspace,
|
||||
onEnsureResearchWorkspace,
|
||||
onCaptureResearchNote,
|
||||
onOpenResearchContext,
|
||||
onAppendSystemMessage,
|
||||
}) => {
|
||||
const researchContext = extractResearchContext(history);
|
||||
const [terminalNoteSeed, setTerminalNoteSeed] = React.useState<{
|
||||
@@ -145,40 +121,40 @@ export const Terminal: React.FC<TerminalProps> = ({
|
||||
lastPortfolioCommand={portfolioWorkflow.lastPortfolioCommand}
|
||||
/>
|
||||
{showResearchCapture ? (
|
||||
<div className="mt-4">
|
||||
<ResearchCaptureBar
|
||||
workspaces={researchWorkspaces}
|
||||
defaultWorkspaceId={activeResearchWorkspaceId}
|
||||
defaultTicker={captureTicker}
|
||||
defaultRawText={terminalNoteSeed?.rawText}
|
||||
seedKey={terminalNoteSeed?.key}
|
||||
contextLabel={captureContextLabel}
|
||||
onWorkspaceChange={onSelectResearchWorkspace}
|
||||
onEnsureWorkspace={onEnsureResearchWorkspace}
|
||||
onSubmitCapture={(draft) =>
|
||||
onCaptureResearchNote({
|
||||
draft,
|
||||
fallbackTicker: captureTicker,
|
||||
explicitWorkspaceId: activeResearchWorkspaceId,
|
||||
autoCreateFromTicker: true,
|
||||
})
|
||||
}
|
||||
onCaptured={(note) => {
|
||||
setTerminalNoteSeed(null);
|
||||
onAppendSystemMessage(
|
||||
`Saved research note to ${
|
||||
researchWorkspaces.find((workspace) => workspace.id === note.workspaceId)?.name ??
|
||||
note.workspaceId
|
||||
}.`,
|
||||
);
|
||||
}}
|
||||
onOpenResearch={() =>
|
||||
onOpenResearchContext({
|
||||
ticker: captureTicker,
|
||||
preferredView: 'canvas',
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="mt-4 flex items-center gap-3 px-4 py-3 bg-[#1a1a1a] border border-[#58a6ff] rounded">
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-mono text-[#e0e0e0]">
|
||||
Research capture ready - click below to open capture modal
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-[#888888] mt-0.5">
|
||||
{captureContextLabel} {captureTicker ? `• ${captureTicker}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTerminalNoteSeed(null)}
|
||||
className="px-3 py-1.5 text-xs font-mono text-[#888888] hover:text-[#e0e0e0] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onOpenResearchContext({
|
||||
preferredView: undefined,
|
||||
terminalNoteSeed: {
|
||||
rawText: terminalNoteSeed?.rawText,
|
||||
ticker: captureTicker,
|
||||
},
|
||||
});
|
||||
setTerminalNoteSeed(null);
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs font-mono bg-[#58a6ff] hover:bg-[#7ec8ff] text-[#0a0a0a] rounded font-semibold transition-colors"
|
||||
>
|
||||
Open Capture
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -6,3 +6,5 @@ export * from './useResearchProjection';
|
||||
export * from './useResearchSelection';
|
||||
export * from './useResearchCaptureFlow';
|
||||
export * from './useResearchWorkspace';
|
||||
export * from './usePortfolioNotes';
|
||||
export * from './usePortfolioWorkflow';
|
||||
|
||||
87
MosaicIQ/src/hooks/usePortfolioNotes.ts
Normal file
87
MosaicIQ/src/hooks/usePortfolioNotes.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { researchBridge } from '../lib/researchBridge';
|
||||
import type { Holding, HoldingResearchNote } from '../types/financial';
|
||||
import type { ResearchNote } from '../types/research';
|
||||
|
||||
const NOTE_COUNT_PER_HOLDING = 3;
|
||||
|
||||
const convertResearchNoteToHoldingNote = (
|
||||
note: ResearchNote,
|
||||
): HoldingResearchNote => ({
|
||||
id: note.id,
|
||||
title: note.title || note.cleanedText.slice(0, 100),
|
||||
noteType: note.noteType,
|
||||
evidenceStatus: note.evidenceStatus,
|
||||
updatedAt: note.updatedAt,
|
||||
});
|
||||
|
||||
export const usePortfolioNotes = (holdings: Holding[], enabled: boolean = true) => {
|
||||
const [holdingsWithNotes, setHoldingsWithNotes] = useState<Holding[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || holdings.length === 0) {
|
||||
setHoldingsWithNotes(holdings);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const fetchNotesForHoldings = async () => {
|
||||
try {
|
||||
const holdingsEnriched = await Promise.all(
|
||||
holdings.map(async (holding) => {
|
||||
try {
|
||||
const notes = await researchBridge.getNotesByTicker({
|
||||
ticker: holding.symbol,
|
||||
limit: NOTE_COUNT_PER_HOLDING,
|
||||
});
|
||||
|
||||
if (cancelled) {
|
||||
return holding;
|
||||
}
|
||||
|
||||
return {
|
||||
...holding,
|
||||
recentNotes: notes.map(convertResearchNoteToHoldingNote),
|
||||
notesCount: notes.length,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch notes for ${holding.symbol}:`, err);
|
||||
// Return holding without notes if fetch fails
|
||||
return holding;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (!cancelled) {
|
||||
setHoldingsWithNotes(holdingsEnriched);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setHoldingsWithNotes(holdings);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void fetchNotesForHoldings();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [holdings, enabled]);
|
||||
|
||||
return {
|
||||
holdingsWithNotes,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
};
|
||||
@@ -14,6 +14,7 @@ export interface PortfolioWorkflowState {
|
||||
activePortfolioAction: PortfolioAction | null;
|
||||
portfolioSnapshot: Portfolio | null;
|
||||
portfolioSnapshotStatus: PortfolioSnapshotStatus;
|
||||
portfolioName: string | null;
|
||||
draft: PortfolioActionDraft;
|
||||
lastPortfolioCommand: string | null;
|
||||
}
|
||||
@@ -30,6 +31,7 @@ const createDefaultState = (): PortfolioWorkflowState => ({
|
||||
activePortfolioAction: null,
|
||||
portfolioSnapshot: null,
|
||||
portfolioSnapshotStatus: 'idle',
|
||||
portfolioName: null,
|
||||
draft: EMPTY_DRAFT,
|
||||
lastPortfolioCommand: null,
|
||||
});
|
||||
@@ -72,6 +74,20 @@ const commandToPortfolioAction = (command: string): PortfolioAction | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const derivePortfolioName = (portfolio: Portfolio | null): string | null => {
|
||||
if (!portfolio || portfolio.holdings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If there's only one holding, use that symbol
|
||||
if (portfolio.holdings.length === 1) {
|
||||
return portfolio.holdings[0].symbol;
|
||||
}
|
||||
|
||||
// For multiple holdings, use "Portfolio: X holdings" or similar
|
||||
return `Portfolio (${portfolio.holdings.length})`;
|
||||
};
|
||||
|
||||
export const isPortfolioCommand = (command: string): boolean =>
|
||||
commandToPortfolioAction(command) !== null;
|
||||
|
||||
@@ -137,6 +153,8 @@ export const usePortfolioWorkflow = () => {
|
||||
? response.portfolio ?? null
|
||||
: null;
|
||||
|
||||
const portfolioName = derivePortfolioName(latestPortfolioSnapshot);
|
||||
|
||||
updateWorkflow(workspaceId, (current) => {
|
||||
if (response.kind === 'panel' && response.panel.type === 'portfolio') {
|
||||
return {
|
||||
@@ -145,6 +163,7 @@ export const usePortfolioWorkflow = () => {
|
||||
activePortfolioAction: 'overview',
|
||||
portfolioSnapshot: latestPortfolioSnapshot,
|
||||
portfolioSnapshotStatus: 'ready',
|
||||
portfolioName,
|
||||
lastPortfolioCommand: command,
|
||||
};
|
||||
}
|
||||
@@ -163,6 +182,7 @@ export const usePortfolioWorkflow = () => {
|
||||
portfolioSnapshotStatus: latestPortfolioSnapshot
|
||||
? 'ready'
|
||||
: current.portfolioSnapshotStatus,
|
||||
portfolioName: latestPortfolioSnapshot ? portfolioName : current.portfolioName,
|
||||
lastPortfolioCommand: command,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2,11 +2,9 @@ import {
|
||||
startTransition,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useReducer,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useResearchEventSubscriptions } from '../lib/researchEvents';
|
||||
import {
|
||||
replaceProjectionWorkspace,
|
||||
upsertProjectionGhost,
|
||||
@@ -116,7 +114,7 @@ export const useResearchProjection = (
|
||||
const deferredView = useDeferredValue(view);
|
||||
const refreshTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const loadProjection = useEffectEvent(async (refresh: boolean) => {
|
||||
const loadProjection = useRef(async (refresh: boolean) => {
|
||||
if (!workspaceId) {
|
||||
return;
|
||||
}
|
||||
@@ -134,7 +132,7 @@ export const useResearchProjection = (
|
||||
error: loadError instanceof Error ? loadError.message : String(loadError),
|
||||
});
|
||||
}
|
||||
});
|
||||
}).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId) {
|
||||
@@ -151,7 +149,7 @@ export const useResearchProjection = (
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scheduleRefresh = useEffectEvent(() => {
|
||||
const scheduleRefresh = useRef(() => {
|
||||
if (refreshTimeoutRef.current != null) {
|
||||
window.clearTimeout(refreshTimeoutRef.current);
|
||||
}
|
||||
@@ -160,38 +158,71 @@ export const useResearchProjection = (
|
||||
void loadProjection(true);
|
||||
refreshTimeoutRef.current = null;
|
||||
}, 150);
|
||||
});
|
||||
}).current;
|
||||
|
||||
useResearchEventSubscriptions({
|
||||
workspaceId: workspaceId ?? undefined,
|
||||
onWorkspaceUpdate: (payload) => {
|
||||
startTransition(() => {
|
||||
dispatch({ type: 'workspace_updated', workspace: payload.workspace });
|
||||
});
|
||||
},
|
||||
onNoteUpdate: (payload) => {
|
||||
startTransition(() => {
|
||||
dispatch({ type: 'note_updated', note: payload.note });
|
||||
});
|
||||
},
|
||||
onGhostUpdate: (payload) => {
|
||||
startTransition(() => {
|
||||
dispatch({ type: 'ghost_updated', ghost: payload.ghost });
|
||||
});
|
||||
},
|
||||
onJobUpdate: (payload) => {
|
||||
startTransition(() => {
|
||||
dispatch({ type: 'job_updated', job: payload.job });
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
payload.job.status === 'completed' &&
|
||||
(payload.job.jobKind === 'infer_links' || payload.job.jobKind === 'evaluate_ghosts')
|
||||
) {
|
||||
scheduleRefresh();
|
||||
let disposed = false;
|
||||
const unlisteners: Array<() => void> = [];
|
||||
|
||||
const handleWorkspaceUpdate = (payload: { workspace: ResearchWorkspace }) => {
|
||||
if (payload.workspace.id === workspaceId && !disposed) {
|
||||
startTransition(() => {
|
||||
dispatch({ type: 'workspace_updated', workspace: payload.workspace });
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleNoteUpdate = (payload: { note: ResearchNote; workspaceId: string }) => {
|
||||
if (payload.workspaceId === workspaceId && !disposed) {
|
||||
startTransition(() => {
|
||||
dispatch({ type: 'note_updated', note: payload.note });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleGhostUpdate = (payload: { ghost: GhostNote; workspaceId: string }) => {
|
||||
if (payload.workspaceId === workspaceId && !disposed) {
|
||||
startTransition(() => {
|
||||
dispatch({ type: 'ghost_updated', ghost: payload.ghost });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleJobUpdate = (payload: { job: PipelineJob; workspaceId: string }) => {
|
||||
if (payload.workspaceId === workspaceId && !disposed) {
|
||||
startTransition(() => {
|
||||
dispatch({ type: 'job_updated', job: payload.job });
|
||||
});
|
||||
|
||||
if (
|
||||
payload.job.status === 'completed' &&
|
||||
(payload.job.jobKind === 'infer_links' || payload.job.jobKind === 'evaluate_ghosts')
|
||||
) {
|
||||
scheduleRefresh();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Promise.all([
|
||||
researchBridge.listenForWorkspaceUpdates(handleWorkspaceUpdate),
|
||||
researchBridge.listenForNoteUpdates(handleNoteUpdate),
|
||||
researchBridge.listenForGhostUpdates(handleGhostUpdate),
|
||||
researchBridge.listenForJobUpdates(handleJobUpdate),
|
||||
]).then((listeners) => {
|
||||
if (!disposed) {
|
||||
unlisteners.push(...listeners);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisteners.forEach((listener) => listener());
|
||||
};
|
||||
}, [workspaceId, loadProjection, scheduleRefresh]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface Workspace {
|
||||
history: TerminalEntry[];
|
||||
chatSessionId?: string;
|
||||
createdAt: Date;
|
||||
portfolioName?: string | null;
|
||||
}
|
||||
|
||||
export const useTabs = () => {
|
||||
@@ -139,6 +140,14 @@ export const useTabs = () => {
|
||||
);
|
||||
}, []);
|
||||
|
||||
const setPortfolioName = useCallback((id: string, portfolioName: string | null) => {
|
||||
setWorkspaces(prev =>
|
||||
prev.map(w =>
|
||||
w.id === id ? { ...w, portfolioName } : w
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
workspaces,
|
||||
activeWorkspace,
|
||||
@@ -151,6 +160,7 @@ export const useTabs = () => {
|
||||
updateWorkspaceEntry,
|
||||
clearWorkspace,
|
||||
setWorkspaceSession,
|
||||
renameWorkspace
|
||||
renameWorkspace,
|
||||
setPortfolioName,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
CreateResearchWorkspaceRequest,
|
||||
ExportResearchBundleRequest,
|
||||
GetNoteAuditTrailRequest,
|
||||
GetNotesByTickerRequest,
|
||||
GetWorkspaceProjectionRequest,
|
||||
GhostNote,
|
||||
ListNoteLinksRequest,
|
||||
@@ -49,6 +50,7 @@ export interface ResearchBridge {
|
||||
updateResearchNote(request: UpdateResearchNoteRequest): Promise<ResearchNote>;
|
||||
archiveResearchNote(request: ArchiveResearchNoteRequest): Promise<ResearchNote>;
|
||||
listResearchNotes(request: ListResearchNotesRequest): Promise<ResearchNote[]>;
|
||||
getNotesByTicker(request: GetNotesByTickerRequest): Promise<ResearchNote[]>;
|
||||
getWorkspaceProjection(
|
||||
request: GetWorkspaceProjectionRequest,
|
||||
): Promise<WorkspaceProjection>;
|
||||
@@ -111,6 +113,10 @@ export const createResearchBridge = (
|
||||
return invoker<ResearchNote[]>('list_research_notes', { request });
|
||||
},
|
||||
|
||||
getNotesByTicker(request) {
|
||||
return invoker<ResearchNote[]>('get_notes_by_ticker', { request });
|
||||
},
|
||||
|
||||
getWorkspaceProjection(request) {
|
||||
return invoker<WorkspaceProjection>('get_workspace_projection', { request });
|
||||
},
|
||||
|
||||
@@ -44,6 +44,46 @@ export interface CompanyPricePoint {
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export type NoteType =
|
||||
| 'fact'
|
||||
| 'quote'
|
||||
| 'management_signal'
|
||||
| 'claim'
|
||||
| 'thesis'
|
||||
| 'sub_thesis'
|
||||
| 'risk'
|
||||
| 'catalyst'
|
||||
| 'valuation_point'
|
||||
| 'scenario_assumption'
|
||||
| 'industry_observation'
|
||||
| 'competitor_comparison'
|
||||
| 'question'
|
||||
| 'contradiction'
|
||||
| 'uncertainty'
|
||||
| 'follow_up_task'
|
||||
| 'source_reference'
|
||||
| 'event_takeaway'
|
||||
| 'channel_check'
|
||||
| 'mosaic_insight';
|
||||
|
||||
export type EvidenceStatus =
|
||||
| 'unsourced'
|
||||
| 'source_linked'
|
||||
| 'quoted'
|
||||
| 'corroborated'
|
||||
| 'inferred'
|
||||
| 'contradicted'
|
||||
| 'stale'
|
||||
| 'superseded';
|
||||
|
||||
export interface HoldingResearchNote {
|
||||
id: string;
|
||||
title: string;
|
||||
noteType: NoteType;
|
||||
evidenceStatus: EvidenceStatus;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Holding {
|
||||
symbol: string;
|
||||
name: string;
|
||||
@@ -56,6 +96,8 @@ export interface Holding {
|
||||
costBasis?: number;
|
||||
unrealizedGain?: number;
|
||||
latestTradeAt?: string;
|
||||
recentNotes?: HoldingResearchNote[];
|
||||
notesCount?: number;
|
||||
}
|
||||
|
||||
export interface Portfolio {
|
||||
|
||||
@@ -516,6 +516,11 @@ export interface ListResearchNotesRequest {
|
||||
noteType?: NoteType;
|
||||
}
|
||||
|
||||
export interface GetNotesByTickerRequest {
|
||||
ticker: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface GetWorkspaceProjectionRequest {
|
||||
workspaceId: string;
|
||||
view?: WorkspaceViewKind;
|
||||
@@ -588,6 +593,10 @@ export interface ResearchNavigationIntent {
|
||||
workspaceId?: string;
|
||||
preferredView?: WorkspaceViewKind;
|
||||
seedNote?: string;
|
||||
terminalNoteSeed?: {
|
||||
rawText?: string;
|
||||
ticker?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const RESEARCH_VIEWS: Array<{
|
||||
|
||||
Reference in New Issue
Block a user