feat(sidebar): track recent ticker lookups

This commit is contained in:
2026-04-05 21:11:53 -04:00
parent d89a1ec84b
commit 8689e3ddd1
8 changed files with 631 additions and 19 deletions

View File

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

View File

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

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

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

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

View File

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

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

View File

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