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:
2026-04-09 18:42:53 -04:00
parent 6b2d51e56b
commit 9d5e882b5e
20 changed files with 895 additions and 221 deletions

View File

@@ -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>,

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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

View File

@@ -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 */}

View File

@@ -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>

View File

@@ -6,3 +6,5 @@ export * from './useResearchProjection';
export * from './useResearchSelection';
export * from './useResearchCaptureFlow';
export * from './useResearchWorkspace';
export * from './usePortfolioNotes';
export * from './usePortfolioWorkflow';

View 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,
};
};

View File

@@ -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,
};
});

View File

@@ -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,

View File

@@ -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,
};
};

View File

@@ -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 });
},

View File

@@ -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 {

View File

@@ -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<{