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 { 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<AgentConfigStatus | null>(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 */}
|
||||
|
||||
@@ -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<SidebarProps> = ({
|
||||
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<SidebarProps> = ({
|
||||
{/* Portfolio Summary */}
|
||||
<PortfolioSummary portfolio={portfolio} />
|
||||
|
||||
{/* Company List */}
|
||||
<CompanyList companies={companies.slice(0, 7)} onCompanyClick={handleCompanyClick} />
|
||||
{/* Ticker History - shows only when loaded */}
|
||||
{isTickerHistoryLoaded && (
|
||||
<TickerHistory history={tickerHistory} onTickerClick={handleCompanyClick} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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,
|
||||
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<Company> {
|
||||
return invoke<Company>('lookup_company', {
|
||||
request,
|
||||
});
|
||||
}
|
||||
|
||||
async startChatStream(
|
||||
request: StartChatStreamRequest,
|
||||
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';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user