feat(sidebar): track recent ticker lookups
This commit is contained in:
@@ -4,8 +4,13 @@ import { Sidebar } from './components/Sidebar/Sidebar';
|
|||||||
import { TabBar } from './components/TabBar/TabBar';
|
import { TabBar } from './components/TabBar/TabBar';
|
||||||
import { SettingsPage } from './components/Settings/SettingsPage';
|
import { SettingsPage } from './components/Settings/SettingsPage';
|
||||||
import { useTabs } from './hooks/useTabs';
|
import { useTabs } from './hooks/useTabs';
|
||||||
|
import { useTickerHistory } from './hooks/useTickerHistory';
|
||||||
import { createEntry } from './hooks/useTerminal';
|
import { createEntry } from './hooks/useTerminal';
|
||||||
import { agentSettingsBridge } from './lib/agentSettingsBridge';
|
import { agentSettingsBridge } from './lib/agentSettingsBridge';
|
||||||
|
import {
|
||||||
|
extractTickerSymbolFromResponse,
|
||||||
|
resolveTickerCommandFallback,
|
||||||
|
} from './lib/tickerHistory';
|
||||||
import { terminalBridge } from './lib/terminalBridge';
|
import { terminalBridge } from './lib/terminalBridge';
|
||||||
import { AgentConfigStatus } from './types/agentSettings';
|
import { AgentConfigStatus } from './types/agentSettings';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
@@ -14,6 +19,7 @@ type AppView = 'terminal' | 'settings';
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const tabs = useTabs();
|
const tabs = useTabs();
|
||||||
|
const tickerHistory = useTickerHistory();
|
||||||
const [sidebarOpen, setSidebarOpen] = React.useState(true);
|
const [sidebarOpen, setSidebarOpen] = React.useState(true);
|
||||||
const [isProcessing, setIsProcessing] = React.useState(false);
|
const [isProcessing, setIsProcessing] = React.useState(false);
|
||||||
const [agentStatus, setAgentStatus] = React.useState<AgentConfigStatus | null>(null);
|
const [agentStatus, setAgentStatus] = React.useState<AgentConfigStatus | null>(null);
|
||||||
@@ -67,36 +73,38 @@ function App() {
|
|||||||
|
|
||||||
const handleCommand = useCallback(async (command: string) => {
|
const handleCommand = useCallback(async (command: string) => {
|
||||||
const trimmedCommand = command.trim();
|
const trimmedCommand = command.trim();
|
||||||
|
const latestTicker = tickerHistory.history[0]?.company.symbol;
|
||||||
|
const resolvedCommand = resolveTickerCommandFallback(trimmedCommand, latestTicker);
|
||||||
const workspaceId = tabs.activeWorkspaceId;
|
const workspaceId = tabs.activeWorkspaceId;
|
||||||
const currentWorkspace = tabs.workspaces.find(
|
const currentWorkspace = tabs.workspaces.find(
|
||||||
(workspace) => workspace.id === workspaceId,
|
(workspace) => workspace.id === workspaceId,
|
||||||
);
|
);
|
||||||
const isSlashCommand = trimmedCommand.startsWith('/');
|
const isSlashCommand = resolvedCommand.startsWith('/');
|
||||||
|
|
||||||
if (!trimmedCommand) {
|
if (!resolvedCommand) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveView('terminal');
|
setActiveView('terminal');
|
||||||
|
|
||||||
if (trimmedCommand === '/clear' || trimmedCommand.toLowerCase() === 'clear') {
|
if (resolvedCommand === '/clear' || resolvedCommand.toLowerCase() === 'clear') {
|
||||||
clearWorkspaceSession(workspaceId);
|
clearWorkspaceSession(workspaceId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pushCommandHistory(workspaceId, trimmedCommand);
|
pushCommandHistory(workspaceId, resolvedCommand);
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
|
|
||||||
if (isSlashCommand) {
|
if (isSlashCommand) {
|
||||||
// Slash commands intentionally reset the transcript and session before rendering a fresh result.
|
// 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);
|
clearWorkspaceSession(workspaceId);
|
||||||
tabs.appendWorkspaceEntry(workspaceId, commandEntry);
|
tabs.appendWorkspaceEntry(workspaceId, commandEntry);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await terminalBridge.executeTerminalCommand({
|
const response = await terminalBridge.executeTerminalCommand({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
input: trimmedCommand,
|
input: resolvedCommand,
|
||||||
});
|
});
|
||||||
|
|
||||||
tabs.appendWorkspaceEntry(
|
tabs.appendWorkspaceEntry(
|
||||||
@@ -107,6 +115,11 @@ function App() {
|
|||||||
: { type: 'panel', content: response.panel },
|
: { type: 'panel', content: response.panel },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const tickerSymbol = extractTickerSymbolFromResponse(response);
|
||||||
|
if (tickerSymbol) {
|
||||||
|
void tickerHistory.recordTicker(tickerSymbol);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
tabs.appendWorkspaceEntry(
|
tabs.appendWorkspaceEntry(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -123,7 +136,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Plain text keeps the current workspace conversation alive and streams into a placeholder response entry.
|
// 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: '' });
|
const responseEntry = createEntry({ type: 'response', content: '' });
|
||||||
|
|
||||||
tabs.appendWorkspaceEntry(workspaceId, commandEntry);
|
tabs.appendWorkspaceEntry(workspaceId, commandEntry);
|
||||||
@@ -134,7 +147,7 @@ function App() {
|
|||||||
{
|
{
|
||||||
workspaceId,
|
workspaceId,
|
||||||
sessionId: currentWorkspace?.chatSessionId,
|
sessionId: currentWorkspace?.chatSessionId,
|
||||||
prompt: trimmedCommand,
|
prompt: resolvedCommand,
|
||||||
agentProfile: 'interactiveChat',
|
agentProfile: 'interactiveChat',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -178,7 +191,7 @@ function App() {
|
|||||||
}));
|
}));
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
}, [tabs, clearWorkspaceSession, pushCommandHistory]);
|
}, [tabs, clearWorkspaceSession, pushCommandHistory, tickerHistory]);
|
||||||
|
|
||||||
// Command history navigation
|
// Command history navigation
|
||||||
// Accesses from END of array (most recent commands first)
|
// Accesses from END of array (most recent commands first)
|
||||||
@@ -312,6 +325,8 @@ function App() {
|
|||||||
}}
|
}}
|
||||||
onToggle={() => setSidebarOpen(!sidebarOpen)}
|
onToggle={() => setSidebarOpen(!sidebarOpen)}
|
||||||
onCommand={handleCommand}
|
onCommand={handleCommand}
|
||||||
|
tickerHistory={tickerHistory.history}
|
||||||
|
isTickerHistoryLoaded={tickerHistory.isLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main Terminal Area */}
|
{/* Main Terminal Area */}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Settings, ChevronRight, ChevronLeft, Briefcase, Layout } from 'lucide-react';
|
import { Settings, ChevronRight, ChevronLeft, Briefcase } from 'lucide-react';
|
||||||
import { CompanyList } from './CompanyList';
|
|
||||||
import { PortfolioSummary } from './PortfolioSummary';
|
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 {
|
interface SidebarProps {
|
||||||
onCommand: (command: string) => void;
|
onCommand: (command: string) => void;
|
||||||
@@ -10,6 +11,8 @@ interface SidebarProps {
|
|||||||
isSettingsActive: boolean;
|
isSettingsActive: boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
tickerHistory: TickerHistoryEntry[];
|
||||||
|
isTickerHistoryLoaded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SidebarState = 'closed' | 'minimized' | 'open';
|
type SidebarState = 'closed' | 'minimized' | 'open';
|
||||||
@@ -20,8 +23,10 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
isSettingsActive,
|
isSettingsActive,
|
||||||
isOpen,
|
isOpen,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
tickerHistory,
|
||||||
|
isTickerHistoryLoaded,
|
||||||
}) => {
|
}) => {
|
||||||
const { getAllCompanies, getPortfolio } = useMockData();
|
const { getAllCompanies, getPortfolio } = useRealFinancialData();
|
||||||
const companies = getAllCompanies();
|
const companies = getAllCompanies();
|
||||||
const portfolio = getPortfolio();
|
const portfolio = getPortfolio();
|
||||||
|
|
||||||
@@ -147,8 +152,10 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
{/* Portfolio Summary */}
|
{/* Portfolio Summary */}
|
||||||
<PortfolioSummary portfolio={portfolio} />
|
<PortfolioSummary portfolio={portfolio} />
|
||||||
|
|
||||||
{/* Company List */}
|
{/* Ticker History - shows only when loaded */}
|
||||||
<CompanyList companies={companies.slice(0, 7)} onCompanyClick={handleCompanyClick} />
|
{isTickerHistoryLoaded && (
|
||||||
|
<TickerHistory history={tickerHistory} onTickerClick={handleCompanyClick} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|||||||
142
MosaicIQ/src/components/Sidebar/TickerHistory.tsx
Normal file
142
MosaicIQ/src/components/Sidebar/TickerHistory.tsx
Normal file
@@ -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<TickerHistoryProps> = ({ history, onTickerClick }) => {
|
||||||
|
if (!history || history.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-[10px] font-mono uppercase tracking-wider text-[#888888] px-1">
|
||||||
|
Recent Searches
|
||||||
|
</h4>
|
||||||
|
<div className="text-center py-4 px-2">
|
||||||
|
<p className="text-xs text-[#666666]">No recent searches</p>
|
||||||
|
<p className="text-[10px] text-[#555555] mt-1">
|
||||||
|
Use ticker commands like /search, /fa, /news, or /analyze
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingCount = history.filter((entry) => entry.isLoading).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<h4 className="text-[10px] font-mono uppercase tracking-wider text-[#888888]">
|
||||||
|
Recent Searches
|
||||||
|
</h4>
|
||||||
|
{loadingCount > 0 && (
|
||||||
|
<div className="inline-flex items-center gap-1.5 rounded-full border border-[#1f3a53] bg-[#0e1a24] px-2 py-0.5 text-[9px] font-mono uppercase tracking-[0.18em] text-[#78b7ff]">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-[#58a6ff] animate-pulse" />
|
||||||
|
Updating {loadingCount}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
|
key={company.symbol}
|
||||||
|
onClick={() => onTickerClick(company.symbol)}
|
||||||
|
className={`w-full text-left px-2 py-2 border-l-2 transition-all group ${
|
||||||
|
isLoading
|
||||||
|
? 'border-[#33536e] bg-[linear-gradient(90deg,rgba(18,28,37,0.96),rgba(12,19,26,0.88))]'
|
||||||
|
: 'border-transparent hover:border-[#58a6ff] hover:bg-[#1a1a1a]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
<span className="font-mono font-bold text-sm text-[#e0e0e0] group-hover:text-[#58a6ff] transition-colors">
|
||||||
|
{company.symbol}
|
||||||
|
</span>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
<span className="text-[10px] text-[#7f93a7] font-mono uppercase tracking-[0.16em]">
|
||||||
|
syncing
|
||||||
|
</span>
|
||||||
|
<span className="h-2 w-[4.5rem] rounded-full bg-[#223646] animate-pulse" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-[#888888] truncate">
|
||||||
|
{company.name || company.symbol}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="h-3 w-14 rounded-full bg-[#203447] animate-pulse" />
|
||||||
|
) : hasChange && (
|
||||||
|
<>
|
||||||
|
<span className={`text-xs font-mono ${isPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'}`}>
|
||||||
|
{isPositive ? '+' : ''}{company.changePercent!.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
{isPositive ? (
|
||||||
|
<TrendingUp className="h-3 w-3 text-[#00d26a]" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="h-3 w-3 text-[#ff4757]" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-1">
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="h-3 w-20 rounded-full bg-[#2a4257] animate-pulse" />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-mono text-[#e0e0e0]">
|
||||||
|
{typeof company.price === 'number' ? `$${company.price.toFixed(2)}` : 'Quote pending'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<span className="h-2.5 w-16 rounded-full bg-[#1e313f] animate-pulse" />
|
||||||
|
<span className="h-2.5 w-12 rounded-full bg-[#182631] animate-pulse" />
|
||||||
|
</>
|
||||||
|
) : typeof company.volume === 'number' && (
|
||||||
|
<span className="text-[10px] text-[#666666]">
|
||||||
|
Vol: {company.volume.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!isLoading && (
|
||||||
|
<span className="text-[10px] text-[#888888]">
|
||||||
|
{formatTimeAgo(timestamp)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
45
MosaicIQ/src/hooks/useRealFinancialData.ts
Normal file
45
MosaicIQ/src/hooks/useRealFinancialData.ts
Normal file
@@ -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<Company[]> => {
|
||||||
|
try {
|
||||||
|
await invoke<string>('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,
|
||||||
|
};
|
||||||
|
};
|
||||||
267
MosaicIQ/src/hooks/useTickerHistory.ts
Normal file
267
MosaicIQ/src/hooks/useTickerHistory.ts
Normal file
@@ -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<TickerHistorySnapshot>;
|
||||||
|
symbol?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeSymbol = (symbol: string): string => symbol.trim().toUpperCase();
|
||||||
|
|
||||||
|
const normalizeSnapshot = (
|
||||||
|
snapshot: Partial<TickerHistorySnapshot> & { 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<TickerHistoryEntry[]>([]);
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
const historyRef = useRef<TickerHistoryEntry[]>([]);
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
AgentErrorEvent,
|
AgentErrorEvent,
|
||||||
AgentResultEvent,
|
AgentResultEvent,
|
||||||
ChatStreamStart,
|
ChatStreamStart,
|
||||||
|
LookupCompanyRequest,
|
||||||
ExecuteTerminalCommandRequest,
|
ExecuteTerminalCommandRequest,
|
||||||
PanelPayload,
|
PanelPayload,
|
||||||
ResolvedTerminalCommandResponse,
|
ResolvedTerminalCommandResponse,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
TerminalCommandResponse,
|
TerminalCommandResponse,
|
||||||
TransportPanelPayload,
|
TransportPanelPayload,
|
||||||
} from '../types/terminal';
|
} from '../types/terminal';
|
||||||
|
import { Company } from '../types/financial';
|
||||||
|
|
||||||
interface StreamCallbacks {
|
interface StreamCallbacks {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -97,6 +99,12 @@ class TerminalBridge {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async lookupCompany(request: LookupCompanyRequest): Promise<Company> {
|
||||||
|
return invoke<Company>('lookup_company', {
|
||||||
|
request,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async startChatStream(
|
async startChatStream(
|
||||||
request: StartChatStreamRequest,
|
request: StartChatStreamRequest,
|
||||||
callbacks: Omit<StreamCallbacks, 'workspaceId'>,
|
callbacks: Omit<StreamCallbacks, 'workspaceId'>,
|
||||||
|
|||||||
81
MosaicIQ/src/lib/tickerHistory.ts
Normal file
81
MosaicIQ/src/lib/tickerHistory.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
@@ -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';
|
import { TaskProfile } from './agentSettings';
|
||||||
|
|
||||||
export type PanelPayload =
|
export type PanelPayload =
|
||||||
@@ -6,14 +16,22 @@ export type PanelPayload =
|
|||||||
| { type: 'error'; data: ErrorPanel }
|
| { type: 'error'; data: ErrorPanel }
|
||||||
| { type: 'portfolio'; data: Portfolio }
|
| { type: 'portfolio'; data: Portfolio }
|
||||||
| { type: 'news'; data: NewsItem[]; ticker?: string }
|
| { 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 =
|
export type TransportPanelPayload =
|
||||||
| { type: 'company'; data: Company }
|
| { type: 'company'; data: Company }
|
||||||
| { type: 'error'; data: ErrorPanel }
|
| { type: 'error'; data: ErrorPanel }
|
||||||
| { type: 'portfolio'; data: Portfolio }
|
| { type: 'portfolio'; data: Portfolio }
|
||||||
| { type: 'news'; data: SerializedNewsItem[]; ticker?: string }
|
| { 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 =
|
export type TerminalCommandResponse =
|
||||||
| { kind: 'text'; content: string }
|
| { kind: 'text'; content: string }
|
||||||
@@ -28,6 +46,10 @@ export interface ExecuteTerminalCommandRequest {
|
|||||||
input: string;
|
input: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LookupCompanyRequest {
|
||||||
|
symbol: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StartChatStreamRequest {
|
export interface StartChatStreamRequest {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
@@ -81,7 +103,16 @@ export interface ErrorPanel {
|
|||||||
export interface CommandSuggestion {
|
export interface CommandSuggestion {
|
||||||
command: string;
|
command: string;
|
||||||
description: string;
|
description: string;
|
||||||
category: 'search' | 'portfolio' | 'news' | 'analysis' | 'system';
|
category:
|
||||||
|
| 'search'
|
||||||
|
| 'financials'
|
||||||
|
| 'cashflow'
|
||||||
|
| 'dividends'
|
||||||
|
| 'earnings'
|
||||||
|
| 'portfolio'
|
||||||
|
| 'news'
|
||||||
|
| 'analysis'
|
||||||
|
| 'system';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TerminalState {
|
export interface TerminalState {
|
||||||
@@ -89,3 +120,19 @@ export interface TerminalState {
|
|||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
isProcessing: boolean;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user