From 8689e3ddd155129575585df3f5667d0d3a301951 Mon Sep 17 00:00:00 2001 From: francy51 Date: Sun, 5 Apr 2026 21:11:53 -0400 Subject: [PATCH] feat(sidebar): track recent ticker lookups --- MosaicIQ/src/App.tsx | 33 ++- MosaicIQ/src/components/Sidebar/Sidebar.tsx | 19 +- .../src/components/Sidebar/TickerHistory.tsx | 142 ++++++++++ MosaicIQ/src/hooks/useRealFinancialData.ts | 45 +++ MosaicIQ/src/hooks/useTickerHistory.ts | 267 ++++++++++++++++++ MosaicIQ/src/lib/terminalBridge.ts | 8 + MosaicIQ/src/lib/tickerHistory.ts | 81 ++++++ MosaicIQ/src/types/terminal.ts | 55 +++- 8 files changed, 631 insertions(+), 19 deletions(-) create mode 100644 MosaicIQ/src/components/Sidebar/TickerHistory.tsx create mode 100644 MosaicIQ/src/hooks/useRealFinancialData.ts create mode 100644 MosaicIQ/src/hooks/useTickerHistory.ts create mode 100644 MosaicIQ/src/lib/tickerHistory.ts diff --git a/MosaicIQ/src/App.tsx b/MosaicIQ/src/App.tsx index 97b9d0c..b697a04 100644 --- a/MosaicIQ/src/App.tsx +++ b/MosaicIQ/src/App.tsx @@ -4,8 +4,13 @@ import { Sidebar } from './components/Sidebar/Sidebar'; import { TabBar } from './components/TabBar/TabBar'; import { SettingsPage } from './components/Settings/SettingsPage'; import { useTabs } from './hooks/useTabs'; +import { useTickerHistory } from './hooks/useTickerHistory'; import { createEntry } from './hooks/useTerminal'; import { agentSettingsBridge } from './lib/agentSettingsBridge'; +import { + extractTickerSymbolFromResponse, + resolveTickerCommandFallback, +} from './lib/tickerHistory'; import { terminalBridge } from './lib/terminalBridge'; import { AgentConfigStatus } from './types/agentSettings'; import './App.css'; @@ -14,6 +19,7 @@ type AppView = 'terminal' | 'settings'; function App() { const tabs = useTabs(); + const tickerHistory = useTickerHistory(); const [sidebarOpen, setSidebarOpen] = React.useState(true); const [isProcessing, setIsProcessing] = React.useState(false); const [agentStatus, setAgentStatus] = React.useState(null); @@ -67,36 +73,38 @@ function App() { const handleCommand = useCallback(async (command: string) => { const trimmedCommand = command.trim(); + const latestTicker = tickerHistory.history[0]?.company.symbol; + const resolvedCommand = resolveTickerCommandFallback(trimmedCommand, latestTicker); const workspaceId = tabs.activeWorkspaceId; const currentWorkspace = tabs.workspaces.find( (workspace) => workspace.id === workspaceId, ); - const isSlashCommand = trimmedCommand.startsWith('/'); + const isSlashCommand = resolvedCommand.startsWith('/'); - if (!trimmedCommand) { + if (!resolvedCommand) { return; } setActiveView('terminal'); - if (trimmedCommand === '/clear' || trimmedCommand.toLowerCase() === 'clear') { + if (resolvedCommand === '/clear' || resolvedCommand.toLowerCase() === 'clear') { clearWorkspaceSession(workspaceId); return; } - pushCommandHistory(workspaceId, trimmedCommand); + pushCommandHistory(workspaceId, resolvedCommand); setIsProcessing(true); if (isSlashCommand) { // Slash commands intentionally reset the transcript and session before rendering a fresh result. - const commandEntry = createEntry({ type: 'command', content: trimmedCommand }); + const commandEntry = createEntry({ type: 'command', content: resolvedCommand }); clearWorkspaceSession(workspaceId); tabs.appendWorkspaceEntry(workspaceId, commandEntry); try { const response = await terminalBridge.executeTerminalCommand({ workspaceId, - input: trimmedCommand, + input: resolvedCommand, }); tabs.appendWorkspaceEntry( @@ -107,6 +115,11 @@ function App() { : { type: 'panel', content: response.panel }, ), ); + + const tickerSymbol = extractTickerSymbolFromResponse(response); + if (tickerSymbol) { + void tickerHistory.recordTicker(tickerSymbol); + } } catch (error) { tabs.appendWorkspaceEntry( workspaceId, @@ -123,7 +136,7 @@ function App() { } // Plain text keeps the current workspace conversation alive and streams into a placeholder response entry. - const commandEntry = createEntry({ type: 'command', content: trimmedCommand }); + const commandEntry = createEntry({ type: 'command', content: resolvedCommand }); const responseEntry = createEntry({ type: 'response', content: '' }); tabs.appendWorkspaceEntry(workspaceId, commandEntry); @@ -134,7 +147,7 @@ function App() { { workspaceId, sessionId: currentWorkspace?.chatSessionId, - prompt: trimmedCommand, + prompt: resolvedCommand, agentProfile: 'interactiveChat', }, { @@ -178,7 +191,7 @@ function App() { })); setIsProcessing(false); } - }, [tabs, clearWorkspaceSession, pushCommandHistory]); + }, [tabs, clearWorkspaceSession, pushCommandHistory, tickerHistory]); // Command history navigation // Accesses from END of array (most recent commands first) @@ -312,6 +325,8 @@ function App() { }} onToggle={() => setSidebarOpen(!sidebarOpen)} onCommand={handleCommand} + tickerHistory={tickerHistory.history} + isTickerHistoryLoaded={tickerHistory.isLoaded} /> {/* Main Terminal Area */} diff --git a/MosaicIQ/src/components/Sidebar/Sidebar.tsx b/MosaicIQ/src/components/Sidebar/Sidebar.tsx index d63e2ce..1d65a1e 100644 --- a/MosaicIQ/src/components/Sidebar/Sidebar.tsx +++ b/MosaicIQ/src/components/Sidebar/Sidebar.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { Settings, ChevronRight, ChevronLeft, Briefcase, Layout } from 'lucide-react'; -import { CompanyList } from './CompanyList'; +import { Settings, ChevronRight, ChevronLeft, Briefcase } from 'lucide-react'; import { PortfolioSummary } from './PortfolioSummary'; -import { useMockData } from '../../hooks/useMockData'; +import { TickerHistory } from './TickerHistory'; +import { useRealFinancialData } from '../../hooks/useRealFinancialData'; +import { TickerHistoryEntry } from '../../types/terminal'; interface SidebarProps { onCommand: (command: string) => void; @@ -10,6 +11,8 @@ interface SidebarProps { isSettingsActive: boolean; isOpen: boolean; onToggle: () => void; + tickerHistory: TickerHistoryEntry[]; + isTickerHistoryLoaded: boolean; } type SidebarState = 'closed' | 'minimized' | 'open'; @@ -20,8 +23,10 @@ export const Sidebar: React.FC = ({ isSettingsActive, isOpen, onToggle, + tickerHistory, + isTickerHistoryLoaded, }) => { - const { getAllCompanies, getPortfolio } = useMockData(); + const { getAllCompanies, getPortfolio } = useRealFinancialData(); const companies = getAllCompanies(); const portfolio = getPortfolio(); @@ -147,8 +152,10 @@ export const Sidebar: React.FC = ({ {/* Portfolio Summary */} - {/* Company List */} - + {/* Ticker History - shows only when loaded */} + {isTickerHistoryLoaded && ( + + )} {/* Footer */} diff --git a/MosaicIQ/src/components/Sidebar/TickerHistory.tsx b/MosaicIQ/src/components/Sidebar/TickerHistory.tsx new file mode 100644 index 0000000..cbdc448 --- /dev/null +++ b/MosaicIQ/src/components/Sidebar/TickerHistory.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { TrendingUp, TrendingDown } from 'lucide-react'; +import { TickerHistoryEntry } from '../../types/terminal'; + +interface TickerHistoryProps { + history: TickerHistoryEntry[]; + onTickerClick: (symbol: string) => void; +} + +const formatTimeAgo = (timestamp: Date): string => { + const now = new Date(); + const diffMs = now.getTime() - timestamp.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays === 1) return 'Yesterday'; + if (diffDays < 7) return `${diffDays}d ago`; + return timestamp.toLocaleDateString(); +}; + +export const TickerHistory: React.FC = ({ history, onTickerClick }) => { + if (!history || history.length === 0) { + return ( +
+

+ Recent Searches +

+
+

No recent searches

+

+ Use ticker commands like /search, /fa, /news, or /analyze +

+
+
+ ); + } + + const loadingCount = history.filter((entry) => entry.isLoading).length; + + return ( +
+
+

+ Recent Searches +

+ {loadingCount > 0 && ( +
+ + Updating {loadingCount} +
+ )} +
+ +
+ {history.map((entry) => { + const { company, timestamp } = entry; + const hasChange = typeof company.changePercent === 'number'; + const isPositive = typeof company.change === 'number' ? company.change >= 0 : false; + const isLoading = entry.isLoading; + + return ( + + ); + })} +
+
+ ); +}; diff --git a/MosaicIQ/src/hooks/useRealFinancialData.ts b/MosaicIQ/src/hooks/useRealFinancialData.ts new file mode 100644 index 0000000..4bcce6a --- /dev/null +++ b/MosaicIQ/src/hooks/useRealFinancialData.ts @@ -0,0 +1,45 @@ +import { invoke } from '@tauri-apps/api/core'; +import { Company, Portfolio } from '../types/financial'; + +export const useRealFinancialData = () => { + const searchCompanies = async (query: string): Promise => { + try { + await invoke('execute_terminal_command', { + request: { + workspaceId: 'sidebar', + input: `/search ${query}`, + }, + }); + + // Parse the response to extract company data + // This will depend on how your backend returns the data + return []; + } catch (error) { + console.error('Failed to search companies:', error); + return []; + } + }; + + const getPortfolio = (): Portfolio => { + // Keep the sidebar stable until a live portfolio source is wired in. + return { + holdings: [], + totalValue: 0, + dayChange: 0, + dayChangePercent: 0, + totalGain: 0, + totalGainPercent: 0, + }; + }; + + const getAllCompanies = (): Company[] => { + // Return empty array since real companies are fetched on demand + return []; + }; + + return { + searchCompanies, + getPortfolio, + getAllCompanies, + }; +}; diff --git a/MosaicIQ/src/hooks/useTickerHistory.ts b/MosaicIQ/src/hooks/useTickerHistory.ts new file mode 100644 index 0000000..1aa9052 --- /dev/null +++ b/MosaicIQ/src/hooks/useTickerHistory.ts @@ -0,0 +1,267 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { terminalBridge } from '../lib/terminalBridge'; +import { companyToTickerHistorySnapshot } from '../lib/tickerHistory'; +import { + TickerHistoryEntry, + TickerHistorySnapshot, +} from '../types/terminal'; + +const STORAGE_KEY = 'ticker_history'; +const MAX_HISTORY = 7; + +type StoredTickerHistoryEntry = { + company?: Partial; + symbol?: string; + timestamp?: string; +}; + +const normalizeSymbol = (symbol: string): string => symbol.trim().toUpperCase(); + +const normalizeSnapshot = ( + snapshot: Partial & { symbol: string }, +): TickerHistorySnapshot => { + const symbol = normalizeSymbol(snapshot.symbol); + + return { + symbol, + name: snapshot.name?.trim() || symbol, + price: snapshot.price, + change: snapshot.change, + changePercent: snapshot.changePercent, + volume: snapshot.volume, + marketCap: snapshot.marketCap, + }; +}; + +const mergeSnapshot = ( + existing: TickerHistorySnapshot, + incoming: TickerHistorySnapshot, +): TickerHistorySnapshot => ({ + symbol: incoming.symbol, + name: incoming.name || existing.name, + price: incoming.price ?? existing.price, + change: incoming.change ?? existing.change, + changePercent: incoming.changePercent ?? existing.changePercent, + volume: incoming.volume ?? existing.volume, + marketCap: incoming.marketCap ?? existing.marketCap, +}); + +const upsertHistory = ( + history: TickerHistoryEntry[], + snapshot: TickerHistorySnapshot, + options?: { + timestamp?: Date; + isLoading?: boolean; + }, +): TickerHistoryEntry[] => { + const timestamp = options?.timestamp ?? new Date(); + const normalizedSymbol = normalizeSymbol(snapshot.symbol); + const normalizedSnapshot = normalizeSnapshot({ + ...snapshot, + symbol: normalizedSymbol, + }); + + const existingEntry = history.find( + (entry) => entry.company.symbol === normalizedSymbol, + ); + + const nextEntry: TickerHistoryEntry = { + company: existingEntry + ? mergeSnapshot(existingEntry.company, normalizedSnapshot) + : normalizedSnapshot, + timestamp, + isLoading: options?.isLoading ?? existingEntry?.isLoading ?? false, + }; + + const deduped = history.filter( + (entry) => entry.company.symbol !== normalizedSymbol, + ); + + return [nextEntry, ...deduped] + .sort((left, right) => right.timestamp.getTime() - left.timestamp.getTime()) + .slice(0, MAX_HISTORY); +}; + +const setEntryLoadingState = ( + history: TickerHistoryEntry[], + symbol: string, + isLoading: boolean, +): TickerHistoryEntry[] => + history.map((entry) => + entry.company.symbol === symbol + ? { ...entry, isLoading } + : entry, + ); + +const deserializeHistoryEntry = ( + entry: StoredTickerHistoryEntry, +): TickerHistoryEntry | null => { + const symbol = entry.company?.symbol ?? entry.symbol; + const normalizedSymbol = symbol ? normalizeSymbol(symbol) : ''; + + if (!normalizedSymbol) { + return null; + } + + const timestamp = entry.timestamp ? new Date(entry.timestamp) : new Date(); + const normalizedTimestamp = Number.isNaN(timestamp.getTime()) + ? new Date() + : timestamp; + + return { + company: normalizeSnapshot({ + ...(entry.company ?? {}), + symbol: normalizedSymbol, + }), + timestamp: normalizedTimestamp, + isLoading: false, + }; +}; + +export const useTickerHistory = () => { + const [history, setHistory] = useState([]); + const [isLoaded, setIsLoaded] = useState(false); + const historyRef = useRef([]); + const hasRefreshedOnLoadRef = useRef(false); + + historyRef.current = history; + + useEffect(() => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + + if (stored) { + const parsed = JSON.parse(stored) as StoredTickerHistoryEntry[] | unknown; + const loadedHistory = (Array.isArray(parsed) ? parsed : []) + .map(deserializeHistoryEntry) + .filter( + (entry): entry is TickerHistoryEntry => entry !== null, + ) + .slice(0, MAX_HISTORY); + + setHistory(loadedHistory); + } + } catch (error) { + console.error('Failed to load ticker history from localStorage:', error); + } finally { + setIsLoaded(true); + } + }, []); + + useEffect(() => { + if (!isLoaded) { + return; + } + + try { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify( + history.map((entry) => ({ + company: entry.company, + symbol: entry.company.symbol, + timestamp: entry.timestamp.toISOString(), + })), + ), + ); + } catch (error) { + console.error('Failed to save ticker history to localStorage:', error); + } + }, [history, isLoaded]); + + const refreshHistoryQuotes = useCallback(async () => { + const entries = historyRef.current; + + if (entries.length === 0) { + return; + } + + setHistory((prev) => prev.map((entry) => ({ ...entry, isLoading: true }))); + + await Promise.allSettled( + entries.map(async (entry) => { + const symbol = entry.company.symbol; + const company = await terminalBridge.lookupCompany({ + symbol, + }); + + setHistory((prev) => + upsertHistory(prev, companyToTickerHistorySnapshot(company), { + timestamp: entry.timestamp, + isLoading: false, + }), + ); + }), + ).then((results) => { + results.forEach((result, index) => { + if (result.status === 'rejected') { + const symbol = entries[index]?.company.symbol; + if (symbol) { + setHistory((prev) => setEntryLoadingState(prev, symbol, false)); + } + console.error('Failed to refresh ticker history quote:', result.reason); + } + }); + }); + }, []); + + useEffect(() => { + if (!isLoaded || hasRefreshedOnLoadRef.current) { + return; + } + + hasRefreshedOnLoadRef.current = true; + void refreshHistoryQuotes(); + }, [isLoaded, refreshHistoryQuotes]); + + const recordTicker = useCallback(async (symbol: string) => { + const normalizedSymbol = normalizeSymbol(symbol); + const recordedAt = new Date(); + + if (!normalizedSymbol) { + return; + } + + setHistory((prev) => + upsertHistory( + prev, + normalizeSnapshot({ + symbol: normalizedSymbol, + name: normalizedSymbol, + }), + { + timestamp: recordedAt, + isLoading: true, + }, + ), + ); + + try { + const company = await terminalBridge.lookupCompany({ + symbol: normalizedSymbol, + }); + + setHistory((prev) => + upsertHistory(prev, companyToTickerHistorySnapshot(company), { + timestamp: recordedAt, + isLoading: false, + }), + ); + } catch (error) { + setHistory((prev) => setEntryLoadingState(prev, normalizedSymbol, false)); + console.error(`Failed to record ticker history for ${normalizedSymbol}:`, error); + } + }, []); + + const clearHistory = useCallback(() => { + setHistory([]); + }, []); + + return { + history, + recordTicker, + refreshHistoryQuotes, + clearHistory, + isLoaded, + }; +}; diff --git a/MosaicIQ/src/lib/terminalBridge.ts b/MosaicIQ/src/lib/terminalBridge.ts index b745e9f..a4106c2 100644 --- a/MosaicIQ/src/lib/terminalBridge.ts +++ b/MosaicIQ/src/lib/terminalBridge.ts @@ -6,6 +6,7 @@ import { AgentErrorEvent, AgentResultEvent, ChatStreamStart, + LookupCompanyRequest, ExecuteTerminalCommandRequest, PanelPayload, ResolvedTerminalCommandResponse, @@ -13,6 +14,7 @@ import { TerminalCommandResponse, TransportPanelPayload, } from '../types/terminal'; +import { Company } from '../types/financial'; interface StreamCallbacks { workspaceId: string; @@ -97,6 +99,12 @@ class TerminalBridge { }; } + async lookupCompany(request: LookupCompanyRequest): Promise { + return invoke('lookup_company', { + request, + }); + } + async startChatStream( request: StartChatStreamRequest, callbacks: Omit, diff --git a/MosaicIQ/src/lib/tickerHistory.ts b/MosaicIQ/src/lib/tickerHistory.ts new file mode 100644 index 0000000..10e8e4f --- /dev/null +++ b/MosaicIQ/src/lib/tickerHistory.ts @@ -0,0 +1,81 @@ +import { Company } from '../types/financial'; +import { + ResolvedTerminalCommandResponse, + TickerHistorySnapshot, +} from '../types/terminal'; + +const FREQUENCY_ARGS = new Set(['annual', 'quarterly']); +const TICKER_REQUIRED_COMMANDS = new Set(['/fa', '/cf', '/dvd', '/em', '/analyze']); +const FREQUENCY_OPTION_COMMANDS = new Set(['/fa', '/cf', '/em']); + +export const extractTickerSymbolFromResponse = ( + response: ResolvedTerminalCommandResponse, +): string | null => { + if (response.kind !== 'panel') { + return null; + } + + const { panel } = response; + + switch (panel.type) { + case 'company': + return panel.data.symbol; + case 'financials': + case 'cashFlow': + case 'dividends': + case 'earnings': + case 'analysis': + return panel.data.symbol; + case 'news': + return panel.ticker?.trim() || null; + case 'error': + case 'portfolio': + return null; + default: + return null; + } +}; + +export const companyToTickerHistorySnapshot = ( + company: Company, +): TickerHistorySnapshot => ({ + symbol: company.symbol, + name: company.name, + price: company.price, + change: company.change, + changePercent: company.changePercent, + volume: company.volume, + marketCap: company.marketCap, +}); + +export const resolveTickerCommandFallback = ( + command: string, + latestTicker: string | null | undefined, +): string => { + const trimmedCommand = command.trim(); + + if (!trimmedCommand.startsWith('/') || !latestTicker) { + return trimmedCommand; + } + + const parts = trimmedCommand.split(/\s+/); + const commandName = parts[0]?.toLowerCase(); + + if (!TICKER_REQUIRED_COMMANDS.has(commandName)) { + return trimmedCommand; + } + + if (parts.length === 1) { + return `${commandName} ${latestTicker}`; + } + + const firstArg = parts[1]?.toLowerCase(); + if ( + FREQUENCY_OPTION_COMMANDS.has(commandName) && + FREQUENCY_ARGS.has(firstArg) + ) { + return [commandName, latestTicker, ...parts.slice(1)].join(' '); + } + + return trimmedCommand; +}; diff --git a/MosaicIQ/src/types/terminal.ts b/MosaicIQ/src/types/terminal.ts index e3f104c..1ee214d 100644 --- a/MosaicIQ/src/types/terminal.ts +++ b/MosaicIQ/src/types/terminal.ts @@ -1,4 +1,14 @@ -import { Company, NewsItem, Portfolio, SerializedNewsItem, StockAnalysis } from './financial'; +import { + CashFlowPanelData, + Company, + DividendsPanelData, + EarningsPanelData, + FinancialsPanelData, + NewsItem, + Portfolio, + SerializedNewsItem, + StockAnalysis, +} from './financial'; import { TaskProfile } from './agentSettings'; export type PanelPayload = @@ -6,14 +16,22 @@ export type PanelPayload = | { type: 'error'; data: ErrorPanel } | { type: 'portfolio'; data: Portfolio } | { type: 'news'; data: NewsItem[]; ticker?: string } - | { type: 'analysis'; data: StockAnalysis }; + | { type: 'analysis'; data: StockAnalysis } + | { type: 'financials'; data: FinancialsPanelData } + | { type: 'cashFlow'; data: CashFlowPanelData } + | { type: 'dividends'; data: DividendsPanelData } + | { type: 'earnings'; data: EarningsPanelData }; export type TransportPanelPayload = | { type: 'company'; data: Company } | { type: 'error'; data: ErrorPanel } | { type: 'portfolio'; data: Portfolio } | { type: 'news'; data: SerializedNewsItem[]; ticker?: string } - | { type: 'analysis'; data: StockAnalysis }; + | { type: 'analysis'; data: StockAnalysis } + | { type: 'financials'; data: FinancialsPanelData } + | { type: 'cashFlow'; data: CashFlowPanelData } + | { type: 'dividends'; data: DividendsPanelData } + | { type: 'earnings'; data: EarningsPanelData }; export type TerminalCommandResponse = | { kind: 'text'; content: string } @@ -28,6 +46,10 @@ export interface ExecuteTerminalCommandRequest { input: string; } +export interface LookupCompanyRequest { + symbol: string; +} + export interface StartChatStreamRequest { workspaceId: string; sessionId?: string; @@ -81,7 +103,16 @@ export interface ErrorPanel { export interface CommandSuggestion { command: string; description: string; - category: 'search' | 'portfolio' | 'news' | 'analysis' | 'system'; + category: + | 'search' + | 'financials' + | 'cashflow' + | 'dividends' + | 'earnings' + | 'portfolio' + | 'news' + | 'analysis' + | 'system'; } export interface TerminalState { @@ -89,3 +120,19 @@ export interface TerminalState { currentIndex: number; isProcessing: boolean; } + +export interface TickerHistorySnapshot { + symbol: string; + name: string; + price?: number; + change?: number; + changePercent?: number; + volume?: number; + marketCap?: number; +} + +export interface TickerHistoryEntry { + company: TickerHistorySnapshot; + timestamp: Date; + isLoading: boolean; +}