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::{
|
use crate::research::{
|
||||||
ArchiveResearchNoteRequest, CaptureResearchNoteRequest, CreateResearchWorkspaceRequest,
|
ArchiveResearchNoteRequest, CaptureResearchNoteRequest, CreateResearchWorkspaceRequest,
|
||||||
ExportResearchBundleRequest, GetNoteAuditTrailRequest, GetWorkspaceProjectionRequest,
|
ExportResearchBundleRequest, GetNoteAuditTrailRequest, GetNotesByTickerRequest,
|
||||||
GhostNote, ListNoteLinksRequest, ListResearchNotesRequest, ListWorkspaceGhostNotesRequest,
|
GetWorkspaceProjectionRequest, GhostNote, ListNoteLinksRequest, ListResearchNotesRequest,
|
||||||
NoteAuditTrail, NoteCaptureResult, NoteLink, PipelineJob, PromoteNoteToThesisRequest,
|
ListWorkspaceGhostNotesRequest, NoteAuditTrail, NoteCaptureResult, NoteLink, PipelineJob,
|
||||||
ResearchBundleExport, ResearchNote, ResearchWorkspace, RetryResearchJobsRequest,
|
PromoteNoteToThesisRequest, ResearchBundleExport, ResearchNote, ResearchWorkspace,
|
||||||
ReviewGhostNoteRequest, UpdateResearchNoteRequest, WorkspaceProjection,
|
RetryResearchJobsRequest, ReviewGhostNoteRequest, UpdateResearchNoteRequest, WorkspaceProjection,
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -91,6 +91,18 @@ pub async fn list_research_notes(
|
|||||||
.map_err(|error| error.to_string())
|
.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]
|
#[tauri::command]
|
||||||
pub async fn get_workspace_projection(
|
pub async fn get_workspace_projection(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ pub fn run() {
|
|||||||
commands::research::update_research_note,
|
commands::research::update_research_note,
|
||||||
commands::research::archive_research_note,
|
commands::research::archive_research_note,
|
||||||
commands::research::list_research_notes,
|
commands::research::list_research_notes,
|
||||||
|
commands::research::get_notes_by_ticker,
|
||||||
commands::research::get_workspace_projection,
|
commands::research::get_workspace_projection,
|
||||||
commands::research::list_note_links,
|
commands::research::list_note_links,
|
||||||
commands::research::list_workspace_ghost_notes,
|
commands::research::list_workspace_ghost_notes,
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ pub use pipeline::spawn_research_scheduler;
|
|||||||
pub use service::ResearchService;
|
pub use service::ResearchService;
|
||||||
pub use types::{
|
pub use types::{
|
||||||
ArchiveResearchNoteRequest, CaptureResearchNoteRequest, CreateResearchWorkspaceRequest,
|
ArchiveResearchNoteRequest, CaptureResearchNoteRequest, CreateResearchWorkspaceRequest,
|
||||||
ExportResearchBundleRequest, GetNoteAuditTrailRequest, GetWorkspaceProjectionRequest,
|
ExportResearchBundleRequest, GetNoteAuditTrailRequest, GetNotesByTickerRequest,
|
||||||
GhostNote, ListNoteLinksRequest, ListResearchNotesRequest, ListWorkspaceGhostNotesRequest,
|
GetWorkspaceProjectionRequest, GhostNote, ListNoteLinksRequest, ListResearchNotesRequest,
|
||||||
NoteAuditTrail, NoteCaptureResult, NoteLink, PipelineJob, PromoteNoteToThesisRequest,
|
ListWorkspaceGhostNotesRequest, NoteAuditTrail, NoteCaptureResult, NoteLink, PipelineJob,
|
||||||
ResearchBundleExport, ResearchNote, ResearchWorkspace, RetryResearchJobsRequest,
|
PromoteNoteToThesisRequest, ResearchBundleExport, ResearchNote, ResearchWorkspace,
|
||||||
ReviewGhostNoteRequest, UpdateResearchNoteRequest, WorkspaceProjection,
|
RetryResearchJobsRequest, ReviewGhostNoteRequest, UpdateResearchNoteRequest,
|
||||||
|
WorkspaceProjection,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,12 +24,13 @@ use crate::research::repository::ResearchRepository;
|
|||||||
use crate::research::types::{
|
use crate::research::types::{
|
||||||
ArchiveResearchNoteRequest, AuditActor, AuditEntityKind, AuditEvent, CaptureMethod,
|
ArchiveResearchNoteRequest, AuditActor, AuditEntityKind, AuditEvent, CaptureMethod,
|
||||||
CaptureResearchNoteRequest, CreateResearchWorkspaceRequest, EvidenceStatus,
|
CaptureResearchNoteRequest, CreateResearchWorkspaceRequest, EvidenceStatus,
|
||||||
ExportResearchBundleRequest, GetNoteAuditTrailRequest, GetWorkspaceProjectionRequest,
|
ExportResearchBundleRequest, GetNoteAuditTrailRequest, GetNotesByTickerRequest,
|
||||||
GhostLifecycleState, GhostReviewAction, GhostStatus, LinkOrigin, ListNoteLinksRequest,
|
GetWorkspaceProjectionRequest, GhostLifecycleState, GhostReviewAction, GhostStatus,
|
||||||
ListResearchNotesRequest, ListWorkspaceGhostNotesRequest, NoteAuditTrail, NoteCaptureResult,
|
LinkOrigin, ListNoteLinksRequest, ListResearchNotesRequest, ListWorkspaceGhostNotesRequest,
|
||||||
NotePriority, NoteType, PromoteNoteToThesisRequest, ProvenanceActor, ResearchBundleExport,
|
NoteAuditTrail, NoteCaptureResult, NotePriority, NoteType, PromoteNoteToThesisRequest,
|
||||||
ResearchNote, ResearchWorkspace, RetryResearchJobsRequest, ReviewGhostNoteRequest, SourceKind,
|
ProvenanceActor, ResearchBundleExport, ResearchNote, ResearchWorkspace, RetryResearchJobsRequest,
|
||||||
ThesisStatus, UpdateResearchNoteRequest, WorkspaceProjection, WorkspaceScope,
|
ReviewGhostNoteRequest, SourceKind, ThesisStatus, UpdateResearchNoteRequest, WorkspaceProjection,
|
||||||
|
WorkspaceScope,
|
||||||
};
|
};
|
||||||
use crate::research::util::{generate_id, normalize_text, now_rfc3339, sha256_hex};
|
use crate::research::util::{generate_id, normalize_text, now_rfc3339, sha256_hex};
|
||||||
|
|
||||||
@@ -429,6 +430,55 @@ impl<R: Runtime + 'static> ResearchService<R> {
|
|||||||
.await
|
.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(
|
pub async fn get_workspace_projection(
|
||||||
&self,
|
&self,
|
||||||
request: GetWorkspaceProjectionRequest,
|
request: GetWorkspaceProjectionRequest,
|
||||||
|
|||||||
@@ -780,6 +780,14 @@ pub struct ListResearchNotesRequest {
|
|||||||
pub note_type: Option<NoteType>,
|
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)]
|
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct GetWorkspaceProjectionRequest {
|
pub struct GetWorkspaceProjectionRequest {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { useResearchCaptureFlow } from './hooks/useResearchCaptureFlow';
|
|||||||
import type { ResearchComposerState } from './hooks/useResearchComposer';
|
import type { ResearchComposerState } from './hooks/useResearchComposer';
|
||||||
import { useResearchWorkspaces } from './hooks/useResearchWorkspaces';
|
import { useResearchWorkspaces } from './hooks/useResearchWorkspaces';
|
||||||
import { useTabs } from './hooks/useTabs';
|
import { useTabs } from './hooks/useTabs';
|
||||||
import { createEntry } from './hooks/useTerminal';
|
|
||||||
import { useTerminalOrchestrator } from './hooks/useTerminalOrchestrator';
|
import { useTerminalOrchestrator } from './hooks/useTerminalOrchestrator';
|
||||||
import { useTickerHistory } from './hooks/useTickerHistory';
|
import { useTickerHistory } from './hooks/useTickerHistory';
|
||||||
import { agentSettingsBridge } from './lib/agentSettingsBridge';
|
import { agentSettingsBridge } from './lib/agentSettingsBridge';
|
||||||
@@ -135,19 +134,6 @@ function App() {
|
|||||||
tabs.createWorkspace();
|
tabs.createWorkspace();
|
||||||
}, [tabs]);
|
}, [tabs]);
|
||||||
|
|
||||||
const appendTerminalSystemMessage = useCallback(
|
|
||||||
(message: string) => {
|
|
||||||
tabs.appendWorkspaceEntry(
|
|
||||||
tabs.activeWorkspaceId,
|
|
||||||
createEntry({
|
|
||||||
type: 'system',
|
|
||||||
content: message,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[tabs],
|
|
||||||
);
|
|
||||||
|
|
||||||
useAppShortcuts({
|
useAppShortcuts({
|
||||||
activeView,
|
activeView,
|
||||||
activeWorkspaceId: tabs.activeWorkspaceId,
|
activeWorkspaceId: tabs.activeWorkspaceId,
|
||||||
@@ -194,11 +180,25 @@ function App() {
|
|||||||
void handleCommand('/portfolio');
|
void handleCommand('/portfolio');
|
||||||
}, [activeView, handleCommand, isProcessing]);
|
}, [activeView, handleCommand, isProcessing]);
|
||||||
|
|
||||||
const tabBarTabs = tabs.workspaces.map((workspace) => ({
|
// Sync portfolio names from workflow to workspaces
|
||||||
id: workspace.id,
|
React.useEffect(() => {
|
||||||
name: workspace.name,
|
tabs.workspaces.forEach((workspace) => {
|
||||||
isActive: workspace.id === tabs.activeWorkspaceId,
|
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 (
|
return (
|
||||||
<div className="flex h-screen bg-[#0a0a0a]">
|
<div className="flex h-screen bg-[#0a0a0a]">
|
||||||
@@ -269,15 +269,9 @@ function App() {
|
|||||||
onClearPortfolioAction={handleClearPortfolioAction}
|
onClearPortfolioAction={handleClearPortfolioAction}
|
||||||
resetCommandIndex={resetCommandIndex}
|
resetCommandIndex={resetCommandIndex}
|
||||||
portfolioWorkflow={activePortfolioWorkflow}
|
portfolioWorkflow={activePortfolioWorkflow}
|
||||||
researchWorkspaces={researchWorkspaces.workspaces}
|
|
||||||
activeResearchWorkspaceId={researchWorkspaces.activeWorkspaceId}
|
|
||||||
onSelectResearchWorkspace={researchWorkspaces.selectWorkspace}
|
|
||||||
onEnsureResearchWorkspace={handleEnsureResearchWorkspace}
|
|
||||||
onCaptureResearchNote={handleCaptureResearchNote}
|
|
||||||
onOpenResearchContext={(intent) => {
|
onOpenResearchContext={(intent) => {
|
||||||
void handleOpenResearch(intent);
|
void handleOpenResearch(intent);
|
||||||
}}
|
}}
|
||||||
onAppendSystemMessage={appendTerminalSystemMessage}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Package } from 'lucide-react';
|
import { ChevronDown, ChevronRight, FileText, Package } from 'lucide-react';
|
||||||
import { Portfolio } from '../../types/financial';
|
import { Portfolio } from '../../types/financial';
|
||||||
import { PortfolioAction, PortfolioActionSeed } from '../../types/terminal';
|
import { PortfolioAction, PortfolioActionSeed } from '../../types/terminal';
|
||||||
import { MetricGrid, ActionButton, ButtonGroup, DataSection } from '../ui';
|
import { MetricGrid, ActionButton, ButtonGroup, DataSection } from '../ui';
|
||||||
|
import { usePortfolioNotes } from '../../hooks/usePortfolioNotes';
|
||||||
|
|
||||||
interface PortfolioPanelProps {
|
interface PortfolioPanelProps {
|
||||||
portfolio: Portfolio;
|
portfolio: Portfolio;
|
||||||
@@ -31,9 +32,19 @@ export const PortfolioPanel: React.FC<PortfolioPanelProps> = ({
|
|||||||
onStartAction,
|
onStartAction,
|
||||||
onSelectHolding,
|
onSelectHolding,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [expandedNotes, setExpandedNotes] = React.useState<Record<string, boolean>>({});
|
||||||
|
const { holdingsWithNotes } = usePortfolioNotes(portfolio.holdings, true);
|
||||||
|
|
||||||
const totalGainPositive = portfolio.totalGain >= 0;
|
const totalGainPositive = portfolio.totalGain >= 0;
|
||||||
const dayChangePositive = portfolio.dayChange >= 0;
|
const dayChangePositive = portfolio.dayChange >= 0;
|
||||||
|
|
||||||
|
const toggleNotesExpanded = (symbol: string) => {
|
||||||
|
setExpandedNotes((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[symbol]: !prev[symbol],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const summaryMetrics = [
|
const summaryMetrics = [
|
||||||
{
|
{
|
||||||
label: 'Total Value',
|
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">
|
<th className="px-4 py-2 text-right text-[10px] uppercase tracking-wider">
|
||||||
Gain/Loss
|
Gain/Loss
|
||||||
</th>
|
</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">
|
<th className="px-4 py-2 text-right text-[10px] uppercase tracking-wider">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[#1a1a1a]">
|
<tbody className="divide-y divide-[#1a1a1a]">
|
||||||
{portfolio.holdings.map((holding, index) => {
|
{holdingsWithNotes.map((holding, index) => {
|
||||||
const gainPositive = holding.gainLoss >= 0;
|
const gainPositive = holding.gainLoss >= 0;
|
||||||
|
const isExpanded = expandedNotes[holding.symbol] || false;
|
||||||
|
const hasNotes = (holding.notesCount ?? 0) > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<React.Fragment key={holding.symbol}>
|
||||||
key={holding.symbol}
|
<tr
|
||||||
className="transition-all duration-150 hover:bg-[#151515] hover:shadow-sm"
|
className="transition-all duration-150 hover:bg-[#151515] hover:shadow-sm"
|
||||||
style={{ animationDelay: `${index * 30}ms` }}
|
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]'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{formatSignedCurrency(holding.gainLoss)}
|
<td className="px-4 py-3">
|
||||||
<div className="text-[10px]">
|
<button
|
||||||
({gainPositive ? '+' : ''}
|
type="button"
|
||||||
{holding.gainLossPercent.toFixed(2)}%)
|
className="text-left transition-colors hover:text-[#58a6ff]"
|
||||||
</div>
|
onClick={() => onSelectHolding?.(holding.symbol)}
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<ActionButton
|
|
||||||
onClick={() => onStartAction('sell', { symbol: holding.symbol })}
|
|
||||||
size="sm"
|
|
||||||
>
|
>
|
||||||
Sell
|
<div className="font-semibold text-[#e0e0e0]">{holding.symbol}</div>
|
||||||
</ActionButton>
|
<div className="text-[10px] text-[#888888]">{holding.name}</div>
|
||||||
<ActionButton
|
</button>
|
||||||
onClick={() => onRunCommand(`/search ${holding.symbol}`)}
|
</td>
|
||||||
size="sm"
|
<td className="px-4 py-3 text-right text-[#e0e0e0]">
|
||||||
>
|
{formatQuantity(holding.quantity)}
|
||||||
Search
|
</td>
|
||||||
</ActionButton>
|
<td className="px-4 py-3 text-right text-[#e0e0e0]">
|
||||||
</div>
|
{formatCurrency(holding.avgCost)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
<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>
|
</tbody>
|
||||||
@@ -232,3 +304,24 @@ export const PortfolioPanel: React.FC<PortfolioPanelProps> = ({
|
|||||||
</div>
|
</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 React, { Suspense, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
import { researchBridge } from '../../lib/researchBridge';
|
import { researchBridge } from '../../lib/researchBridge';
|
||||||
import {
|
import {
|
||||||
type ResearchComposerState,
|
type ResearchComposerState,
|
||||||
@@ -16,7 +17,7 @@ import type {
|
|||||||
ResearchNavigationIntent,
|
ResearchNavigationIntent,
|
||||||
ResearchWorkspace,
|
ResearchWorkspace,
|
||||||
} from '../../types/research';
|
} from '../../types/research';
|
||||||
import { ResearchCaptureBar } from './ResearchCaptureBar';
|
import { ResearchCaptureModal } from './ResearchCaptureModal';
|
||||||
import { ResearchInspector } from './ResearchInspector';
|
import { ResearchInspector } from './ResearchInspector';
|
||||||
import { ResearchShell } from './ResearchShell';
|
import { ResearchShell } from './ResearchShell';
|
||||||
import { ResearchSidebar } from './ResearchSidebar';
|
import { ResearchSidebar } from './ResearchSidebar';
|
||||||
@@ -82,6 +83,8 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
|||||||
const [memoDraft, setMemoDraft] = useState('');
|
const [memoDraft, setMemoDraft] = useState('');
|
||||||
const [auditTrail, setAuditTrail] = useState<NoteAuditTrail | null>(null);
|
const [auditTrail, setAuditTrail] = useState<NoteAuditTrail | null>(null);
|
||||||
const [isLoadingAuditTrail, setIsLoadingAuditTrail] = useState(false);
|
const [isLoadingAuditTrail, setIsLoadingAuditTrail] = useState(false);
|
||||||
|
const [isCaptureModalOpen, setIsCaptureModalOpen] = useState(false);
|
||||||
|
const [terminalNoteSeed, setTerminalNoteSeed] = useState<Pick<ResearchComposerState, 'rawText' | 'ticker'> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeWorkspaceId) {
|
if (!activeWorkspaceId) {
|
||||||
@@ -116,6 +119,16 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
|||||||
if (navigationIntent.preferredView) {
|
if (navigationIntent.preferredView) {
|
||||||
workspaceState.setView(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();
|
onConsumeNavigationIntent();
|
||||||
}, [
|
}, [
|
||||||
activeWorkspaceId,
|
activeWorkspaceId,
|
||||||
@@ -208,10 +221,7 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
|||||||
useResearchKeyboard({
|
useResearchKeyboard({
|
||||||
enabled: Boolean(activeWorkspaceId),
|
enabled: Boolean(activeWorkspaceId),
|
||||||
onFocusCapture: () => {
|
onFocusCapture: () => {
|
||||||
const element = document.getElementById('research-capture-input');
|
setIsCaptureModalOpen(true);
|
||||||
if (element instanceof HTMLElement) {
|
|
||||||
element.focus();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onOpenSearch: () => {
|
onOpenSearch: () => {
|
||||||
const element = document.getElementById('research-sidebar-search');
|
const element = document.getElementById('research-sidebar-search');
|
||||||
@@ -357,30 +367,6 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col bg-[var(--research-bg)]">
|
<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
|
<ResearchShell
|
||||||
sidebar={
|
sidebar={
|
||||||
<ResearchSidebar
|
<ResearchSidebar
|
||||||
@@ -409,6 +395,16 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
|||||||
onDensityChange={setDensity}
|
onDensityChange={setDensity}
|
||||||
onToggleInspector={toggleInspector}
|
onToggleInspector={toggleInspector}
|
||||||
jobs={Object.values(projectionState.jobs)}
|
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={
|
inspector={
|
||||||
@@ -452,6 +448,35 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
|||||||
renderView()
|
renderView()
|
||||||
)}
|
)}
|
||||||
</ResearchShell>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface ResearchToolbarProps {
|
|||||||
onDensityChange: (density: ResearchDensityMode) => void;
|
onDensityChange: (density: ResearchDensityMode) => void;
|
||||||
onToggleInspector: () => void;
|
onToggleInspector: () => void;
|
||||||
jobs: PipelineJob[];
|
jobs: PipelineJob[];
|
||||||
|
extraActions?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ResearchToolbar: React.FC<ResearchToolbarProps> = ({
|
export const ResearchToolbar: React.FC<ResearchToolbarProps> = ({
|
||||||
@@ -21,6 +22,7 @@ export const ResearchToolbar: React.FC<ResearchToolbarProps> = ({
|
|||||||
onDensityChange,
|
onDensityChange,
|
||||||
onToggleInspector,
|
onToggleInspector,
|
||||||
jobs,
|
jobs,
|
||||||
|
extraActions,
|
||||||
}) => (
|
}) => (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -36,6 +38,7 @@ export const ResearchToolbar: React.FC<ResearchToolbarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<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">
|
<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]" />
|
<LayoutGrid className="h-4 w-4 text-[#7e93ab]" />
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface Tab {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
portfolioName?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TabBarProps {
|
interface TabBarProps {
|
||||||
@@ -85,9 +86,16 @@ export const TabBar: React.FC<TabBarProps> = ({
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className={`text-xs font-mono truncate max-w-[100px] ${tab.isActive ? 'text-[#e0e0e0]' : 'text-[#888888]'}`}>
|
<div className="flex flex-col">
|
||||||
{tab.name}
|
<span className={`text-xs font-mono truncate max-w-[100px] ${tab.isActive ? 'text-[#e0e0e0]' : 'text-[#888888]'}`}>
|
||||||
</span>
|
{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 */}
|
{/* Close Button */}
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { ResearchComposerState } from '../../hooks/useResearchComposer';
|
|
||||||
import { PortfolioWorkflowState } from '../../hooks/usePortfolioWorkflow';
|
import { PortfolioWorkflowState } from '../../hooks/usePortfolioWorkflow';
|
||||||
import { extractResearchContext } from '../../lib/researchContext';
|
import { extractResearchContext } from '../../lib/researchContext';
|
||||||
import { buildTerminalResearchNoteSeed } from '../../lib/terminalResearchNote';
|
import { buildTerminalResearchNoteSeed } from '../../lib/terminalResearchNote';
|
||||||
import {
|
import {
|
||||||
NoteCaptureResult,
|
|
||||||
ResearchNavigationIntent,
|
ResearchNavigationIntent,
|
||||||
ResearchWorkspace,
|
|
||||||
} from '../../types/research';
|
} from '../../types/research';
|
||||||
import {
|
import {
|
||||||
PortfolioAction,
|
PortfolioAction,
|
||||||
@@ -16,7 +13,6 @@ import {
|
|||||||
} from '../../types/terminal';
|
} from '../../types/terminal';
|
||||||
import { TerminalOutput } from './TerminalOutput';
|
import { TerminalOutput } from './TerminalOutput';
|
||||||
import { CommandInput, CommandInputHandle } from './CommandInput';
|
import { CommandInput, CommandInputHandle } from './CommandInput';
|
||||||
import { ResearchCaptureBar } from '../Research/ResearchCaptureBar';
|
|
||||||
|
|
||||||
interface TerminalProps {
|
interface TerminalProps {
|
||||||
history: TerminalEntry[];
|
history: TerminalEntry[];
|
||||||
@@ -33,21 +29,7 @@ interface TerminalProps {
|
|||||||
onClearPortfolioAction: () => void;
|
onClearPortfolioAction: () => void;
|
||||||
resetCommandIndex: () => void;
|
resetCommandIndex: () => void;
|
||||||
portfolioWorkflow: PortfolioWorkflowState;
|
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;
|
onOpenResearchContext: (intent: ResearchNavigationIntent) => void;
|
||||||
onAppendSystemMessage: (message: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Terminal: React.FC<TerminalProps> = ({
|
export const Terminal: React.FC<TerminalProps> = ({
|
||||||
@@ -62,13 +44,7 @@ export const Terminal: React.FC<TerminalProps> = ({
|
|||||||
onClearPortfolioAction,
|
onClearPortfolioAction,
|
||||||
resetCommandIndex,
|
resetCommandIndex,
|
||||||
portfolioWorkflow,
|
portfolioWorkflow,
|
||||||
researchWorkspaces,
|
|
||||||
activeResearchWorkspaceId,
|
|
||||||
onSelectResearchWorkspace,
|
|
||||||
onEnsureResearchWorkspace,
|
|
||||||
onCaptureResearchNote,
|
|
||||||
onOpenResearchContext,
|
onOpenResearchContext,
|
||||||
onAppendSystemMessage,
|
|
||||||
}) => {
|
}) => {
|
||||||
const researchContext = extractResearchContext(history);
|
const researchContext = extractResearchContext(history);
|
||||||
const [terminalNoteSeed, setTerminalNoteSeed] = React.useState<{
|
const [terminalNoteSeed, setTerminalNoteSeed] = React.useState<{
|
||||||
@@ -145,40 +121,40 @@ export const Terminal: React.FC<TerminalProps> = ({
|
|||||||
lastPortfolioCommand={portfolioWorkflow.lastPortfolioCommand}
|
lastPortfolioCommand={portfolioWorkflow.lastPortfolioCommand}
|
||||||
/>
|
/>
|
||||||
{showResearchCapture ? (
|
{showResearchCapture ? (
|
||||||
<div className="mt-4">
|
<div className="mt-4 flex items-center gap-3 px-4 py-3 bg-[#1a1a1a] border border-[#58a6ff] rounded">
|
||||||
<ResearchCaptureBar
|
<div className="flex-1">
|
||||||
workspaces={researchWorkspaces}
|
<div className="text-xs font-mono text-[#e0e0e0]">
|
||||||
defaultWorkspaceId={activeResearchWorkspaceId}
|
Research capture ready - click below to open capture modal
|
||||||
defaultTicker={captureTicker}
|
</div>
|
||||||
defaultRawText={terminalNoteSeed?.rawText}
|
<div className="text-[10px] font-mono text-[#888888] mt-0.5">
|
||||||
seedKey={terminalNoteSeed?.key}
|
{captureContextLabel} {captureTicker ? `• ${captureTicker}` : ''}
|
||||||
contextLabel={captureContextLabel}
|
</div>
|
||||||
onWorkspaceChange={onSelectResearchWorkspace}
|
</div>
|
||||||
onEnsureWorkspace={onEnsureResearchWorkspace}
|
<div className="flex gap-2">
|
||||||
onSubmitCapture={(draft) =>
|
<button
|
||||||
onCaptureResearchNote({
|
type="button"
|
||||||
draft,
|
onClick={() => setTerminalNoteSeed(null)}
|
||||||
fallbackTicker: captureTicker,
|
className="px-3 py-1.5 text-xs font-mono text-[#888888] hover:text-[#e0e0e0] transition-colors"
|
||||||
explicitWorkspaceId: activeResearchWorkspaceId,
|
>
|
||||||
autoCreateFromTicker: true,
|
Cancel
|
||||||
})
|
</button>
|
||||||
}
|
<button
|
||||||
onCaptured={(note) => {
|
type="button"
|
||||||
setTerminalNoteSeed(null);
|
onClick={() => {
|
||||||
onAppendSystemMessage(
|
onOpenResearchContext({
|
||||||
`Saved research note to ${
|
preferredView: undefined,
|
||||||
researchWorkspaces.find((workspace) => workspace.id === note.workspaceId)?.name ??
|
terminalNoteSeed: {
|
||||||
note.workspaceId
|
rawText: terminalNoteSeed?.rawText,
|
||||||
}.`,
|
ticker: captureTicker,
|
||||||
);
|
},
|
||||||
}}
|
});
|
||||||
onOpenResearch={() =>
|
setTerminalNoteSeed(null);
|
||||||
onOpenResearchContext({
|
}}
|
||||||
ticker: captureTicker,
|
className="px-3 py-1.5 text-xs font-mono bg-[#58a6ff] hover:bg-[#7ec8ff] text-[#0a0a0a] rounded font-semibold transition-colors"
|
||||||
preferredView: 'canvas',
|
>
|
||||||
})
|
Open Capture
|
||||||
}
|
</button>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ export * from './useResearchProjection';
|
|||||||
export * from './useResearchSelection';
|
export * from './useResearchSelection';
|
||||||
export * from './useResearchCaptureFlow';
|
export * from './useResearchCaptureFlow';
|
||||||
export * from './useResearchWorkspace';
|
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;
|
activePortfolioAction: PortfolioAction | null;
|
||||||
portfolioSnapshot: Portfolio | null;
|
portfolioSnapshot: Portfolio | null;
|
||||||
portfolioSnapshotStatus: PortfolioSnapshotStatus;
|
portfolioSnapshotStatus: PortfolioSnapshotStatus;
|
||||||
|
portfolioName: string | null;
|
||||||
draft: PortfolioActionDraft;
|
draft: PortfolioActionDraft;
|
||||||
lastPortfolioCommand: string | null;
|
lastPortfolioCommand: string | null;
|
||||||
}
|
}
|
||||||
@@ -30,6 +31,7 @@ const createDefaultState = (): PortfolioWorkflowState => ({
|
|||||||
activePortfolioAction: null,
|
activePortfolioAction: null,
|
||||||
portfolioSnapshot: null,
|
portfolioSnapshot: null,
|
||||||
portfolioSnapshotStatus: 'idle',
|
portfolioSnapshotStatus: 'idle',
|
||||||
|
portfolioName: null,
|
||||||
draft: EMPTY_DRAFT,
|
draft: EMPTY_DRAFT,
|
||||||
lastPortfolioCommand: null,
|
lastPortfolioCommand: null,
|
||||||
});
|
});
|
||||||
@@ -72,6 +74,20 @@ const commandToPortfolioAction = (command: string): PortfolioAction | null => {
|
|||||||
return 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 =>
|
export const isPortfolioCommand = (command: string): boolean =>
|
||||||
commandToPortfolioAction(command) !== null;
|
commandToPortfolioAction(command) !== null;
|
||||||
|
|
||||||
@@ -137,6 +153,8 @@ export const usePortfolioWorkflow = () => {
|
|||||||
? response.portfolio ?? null
|
? response.portfolio ?? null
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const portfolioName = derivePortfolioName(latestPortfolioSnapshot);
|
||||||
|
|
||||||
updateWorkflow(workspaceId, (current) => {
|
updateWorkflow(workspaceId, (current) => {
|
||||||
if (response.kind === 'panel' && response.panel.type === 'portfolio') {
|
if (response.kind === 'panel' && response.panel.type === 'portfolio') {
|
||||||
return {
|
return {
|
||||||
@@ -145,6 +163,7 @@ export const usePortfolioWorkflow = () => {
|
|||||||
activePortfolioAction: 'overview',
|
activePortfolioAction: 'overview',
|
||||||
portfolioSnapshot: latestPortfolioSnapshot,
|
portfolioSnapshot: latestPortfolioSnapshot,
|
||||||
portfolioSnapshotStatus: 'ready',
|
portfolioSnapshotStatus: 'ready',
|
||||||
|
portfolioName,
|
||||||
lastPortfolioCommand: command,
|
lastPortfolioCommand: command,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -163,6 +182,7 @@ export const usePortfolioWorkflow = () => {
|
|||||||
portfolioSnapshotStatus: latestPortfolioSnapshot
|
portfolioSnapshotStatus: latestPortfolioSnapshot
|
||||||
? 'ready'
|
? 'ready'
|
||||||
: current.portfolioSnapshotStatus,
|
: current.portfolioSnapshotStatus,
|
||||||
|
portfolioName: latestPortfolioSnapshot ? portfolioName : current.portfolioName,
|
||||||
lastPortfolioCommand: command,
|
lastPortfolioCommand: command,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ import {
|
|||||||
startTransition,
|
startTransition,
|
||||||
useDeferredValue,
|
useDeferredValue,
|
||||||
useEffect,
|
useEffect,
|
||||||
useEffectEvent,
|
|
||||||
useReducer,
|
useReducer,
|
||||||
useRef,
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useResearchEventSubscriptions } from '../lib/researchEvents';
|
|
||||||
import {
|
import {
|
||||||
replaceProjectionWorkspace,
|
replaceProjectionWorkspace,
|
||||||
upsertProjectionGhost,
|
upsertProjectionGhost,
|
||||||
@@ -116,7 +114,7 @@ export const useResearchProjection = (
|
|||||||
const deferredView = useDeferredValue(view);
|
const deferredView = useDeferredValue(view);
|
||||||
const refreshTimeoutRef = useRef<number | null>(null);
|
const refreshTimeoutRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const loadProjection = useEffectEvent(async (refresh: boolean) => {
|
const loadProjection = useRef(async (refresh: boolean) => {
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -134,7 +132,7 @@ export const useResearchProjection = (
|
|||||||
error: loadError instanceof Error ? loadError.message : String(loadError),
|
error: loadError instanceof Error ? loadError.message : String(loadError),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
}).current;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
@@ -151,7 +149,7 @@ export const useResearchProjection = (
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const scheduleRefresh = useEffectEvent(() => {
|
const scheduleRefresh = useRef(() => {
|
||||||
if (refreshTimeoutRef.current != null) {
|
if (refreshTimeoutRef.current != null) {
|
||||||
window.clearTimeout(refreshTimeoutRef.current);
|
window.clearTimeout(refreshTimeoutRef.current);
|
||||||
}
|
}
|
||||||
@@ -160,38 +158,71 @@ export const useResearchProjection = (
|
|||||||
void loadProjection(true);
|
void loadProjection(true);
|
||||||
refreshTimeoutRef.current = null;
|
refreshTimeoutRef.current = null;
|
||||||
}, 150);
|
}, 150);
|
||||||
});
|
}).current;
|
||||||
|
|
||||||
useResearchEventSubscriptions({
|
useEffect(() => {
|
||||||
workspaceId: workspaceId ?? undefined,
|
if (!workspaceId) {
|
||||||
onWorkspaceUpdate: (payload) => {
|
return;
|
||||||
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 });
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
let disposed = false;
|
||||||
payload.job.status === 'completed' &&
|
const unlisteners: Array<() => void> = [];
|
||||||
(payload.job.jobKind === 'infer_links' || payload.job.jobKind === 'evaluate_ghosts')
|
|
||||||
) {
|
const handleWorkspaceUpdate = (payload: { workspace: ResearchWorkspace }) => {
|
||||||
scheduleRefresh();
|
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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface Workspace {
|
|||||||
history: TerminalEntry[];
|
history: TerminalEntry[];
|
||||||
chatSessionId?: string;
|
chatSessionId?: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
portfolioName?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTabs = () => {
|
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 {
|
return {
|
||||||
workspaces,
|
workspaces,
|
||||||
activeWorkspace,
|
activeWorkspace,
|
||||||
@@ -151,6 +160,7 @@ export const useTabs = () => {
|
|||||||
updateWorkspaceEntry,
|
updateWorkspaceEntry,
|
||||||
clearWorkspace,
|
clearWorkspace,
|
||||||
setWorkspaceSession,
|
setWorkspaceSession,
|
||||||
renameWorkspace
|
renameWorkspace,
|
||||||
|
setPortfolioName,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
CreateResearchWorkspaceRequest,
|
CreateResearchWorkspaceRequest,
|
||||||
ExportResearchBundleRequest,
|
ExportResearchBundleRequest,
|
||||||
GetNoteAuditTrailRequest,
|
GetNoteAuditTrailRequest,
|
||||||
|
GetNotesByTickerRequest,
|
||||||
GetWorkspaceProjectionRequest,
|
GetWorkspaceProjectionRequest,
|
||||||
GhostNote,
|
GhostNote,
|
||||||
ListNoteLinksRequest,
|
ListNoteLinksRequest,
|
||||||
@@ -49,6 +50,7 @@ export interface ResearchBridge {
|
|||||||
updateResearchNote(request: UpdateResearchNoteRequest): Promise<ResearchNote>;
|
updateResearchNote(request: UpdateResearchNoteRequest): Promise<ResearchNote>;
|
||||||
archiveResearchNote(request: ArchiveResearchNoteRequest): Promise<ResearchNote>;
|
archiveResearchNote(request: ArchiveResearchNoteRequest): Promise<ResearchNote>;
|
||||||
listResearchNotes(request: ListResearchNotesRequest): Promise<ResearchNote[]>;
|
listResearchNotes(request: ListResearchNotesRequest): Promise<ResearchNote[]>;
|
||||||
|
getNotesByTicker(request: GetNotesByTickerRequest): Promise<ResearchNote[]>;
|
||||||
getWorkspaceProjection(
|
getWorkspaceProjection(
|
||||||
request: GetWorkspaceProjectionRequest,
|
request: GetWorkspaceProjectionRequest,
|
||||||
): Promise<WorkspaceProjection>;
|
): Promise<WorkspaceProjection>;
|
||||||
@@ -111,6 +113,10 @@ export const createResearchBridge = (
|
|||||||
return invoker<ResearchNote[]>('list_research_notes', { request });
|
return invoker<ResearchNote[]>('list_research_notes', { request });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getNotesByTicker(request) {
|
||||||
|
return invoker<ResearchNote[]>('get_notes_by_ticker', { request });
|
||||||
|
},
|
||||||
|
|
||||||
getWorkspaceProjection(request) {
|
getWorkspaceProjection(request) {
|
||||||
return invoker<WorkspaceProjection>('get_workspace_projection', { request });
|
return invoker<WorkspaceProjection>('get_workspace_projection', { request });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,6 +44,46 @@ export interface CompanyPricePoint {
|
|||||||
timestamp?: string;
|
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 {
|
export interface Holding {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -56,6 +96,8 @@ export interface Holding {
|
|||||||
costBasis?: number;
|
costBasis?: number;
|
||||||
unrealizedGain?: number;
|
unrealizedGain?: number;
|
||||||
latestTradeAt?: string;
|
latestTradeAt?: string;
|
||||||
|
recentNotes?: HoldingResearchNote[];
|
||||||
|
notesCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Portfolio {
|
export interface Portfolio {
|
||||||
|
|||||||
@@ -516,6 +516,11 @@ export interface ListResearchNotesRequest {
|
|||||||
noteType?: NoteType;
|
noteType?: NoteType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GetNotesByTickerRequest {
|
||||||
|
ticker: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GetWorkspaceProjectionRequest {
|
export interface GetWorkspaceProjectionRequest {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
view?: WorkspaceViewKind;
|
view?: WorkspaceViewKind;
|
||||||
@@ -588,6 +593,10 @@ export interface ResearchNavigationIntent {
|
|||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
preferredView?: WorkspaceViewKind;
|
preferredView?: WorkspaceViewKind;
|
||||||
seedNote?: string;
|
seedNote?: string;
|
||||||
|
terminalNoteSeed?: {
|
||||||
|
rawText?: string;
|
||||||
|
ticker?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RESEARCH_VIEWS: Array<{
|
export const RESEARCH_VIEWS: Array<{
|
||||||
|
|||||||
Reference in New Issue
Block a user