feat(web): add budget alert banner with 80%/95% thresholds

Port BudgetAlertPanel behaviour to CostDashboard: warning at 80% and
critical at 95% of configured daily budget. Adds BudgetBanner (sticky
top-of-page alert when budget >= 80%), burn rate with ETA-to-exhaust,
and top consumers breakdown. Sources data from /api/cost/summary polled
every 15 seconds.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-24 06:38:33 -04:00
parent 9938630bdd
commit 0c1a4eebeb
4 changed files with 867 additions and 45 deletions

View file

@ -0,0 +1,246 @@
import React, { useState, useEffect, useCallback } from 'react';
interface BudgetStatus {
limit: number;
spent: number;
percentUsed: number;
isOverBudget: boolean;
warningLevel: 'none' | 'warning' | 'critical';
remaining: number;
}
interface BurnRate {
costPerMinute: number;
minutesToExhaustion: number | null;
timeToExhaustion: string | null;
projectedTotalCost: number;
windowMinutes: number;
isHighBurnRate: boolean;
}
interface CostSummary {
totalCostUsd: number;
budget: BudgetStatus;
burnRate: BurnRate;
workerCount: number;
}
interface WorkerCostEntry {
workerId: string;
costUsd: number;
totalTokens: number;
apiCalls: number;
currentBead?: string;
}
function formatCost(usd: number): string {
if (usd < 0.01) return `$${(usd * 100).toFixed(2)}c`;
if (usd < 1) return `$${usd.toFixed(3)}`;
if (usd < 100) return `$${usd.toFixed(2)}`;
return `$${usd.toFixed(0)}`;
}
function formatTokens(count: number): string {
if (count < 1000) return count.toString();
if (count < 1_000_000) return `${(count / 1000).toFixed(1)}K`;
return `${(count / 1_000_000).toFixed(2)}M`;
}
function formatBurnRate(rate: number): string {
if (rate < 0.01) return `$${(rate * 100).toFixed(2)}c/min`;
return `$${rate.toFixed(2)}/min`;
}
// ── Banner: shown at top of page when budget >= 80% ──
interface BudgetBannerProps {
budget: BudgetStatus;
burnRate: BurnRate;
onOpenPanel: () => void;
onDismiss: () => void;
}
export const BudgetBanner: React.FC<BudgetBannerProps> = ({ budget, burnRate, onOpenPanel, onDismiss }) => {
if (budget.warningLevel === 'none') return null;
const isCritical = budget.warningLevel === 'critical' || budget.isOverBudget;
return (
<div className={`budget-banner ${isCritical ? 'budget-banner--critical' : 'budget-banner--warning'}`}>
<div className="budget-banner-content">
<span className="budget-banner-icon">{isCritical ? '!!' : '!'}</span>
<span className="budget-banner-text">
{budget.isOverBudget
? `Budget exceeded: ${formatCost(budget.spent)} / ${formatCost(budget.limit)} (${Math.round(budget.percentUsed)}%)`
: `${isCritical ? 'Critical' : 'Warning'}: ${formatCost(budget.spent)} / ${formatCost(budget.limit)} (${Math.round(budget.percentUsed)}% used)`}
</span>
<span className="budget-banner-burn">
{formatBurnRate(burnRate.costPerMinute)}
{burnRate.timeToExhaustion && ` | ETA: ${burnRate.timeToExhaustion}`}
</span>
<button className="budget-banner-action" onClick={onOpenPanel}>Details</button>
<button className="budget-banner-dismiss" onClick={onDismiss} title="Dismiss for this session">&times;</button>
</div>
<div className="budget-banner-bar">
<div
className="budget-banner-bar-fill"
style={{ width: `${Math.min(100, budget.percentUsed)}%` }}
/>
</div>
</div>
);
};
// ── Full panel: detailed budget alert view ──
interface BudgetAlertPanelProps {
visible: boolean;
onClose: () => void;
}
const BudgetAlertPanel: React.FC<BudgetAlertPanelProps> = ({ visible, onClose }) => {
const [summary, setSummary] = useState<CostSummary | null>(null);
const [workers, setWorkers] = useState<WorkerCostEntry[]>([]);
const fetchData = useCallback(async () => {
try {
const [summaryRes, workersRes] = await Promise.all([
fetch('/api/cost/summary'),
fetch('/api/cost/workers'),
]);
if (summaryRes.ok) setSummary(await summaryRes.json());
if (workersRes.ok) {
const data = await workersRes.json();
setWorkers((data.workers || []).slice(0, 10));
}
} catch (err) {
console.error('Failed to fetch budget data:', err);
}
}, []);
useEffect(() => {
if (visible) {
fetchData();
const interval = setInterval(fetchData, 10000);
return () => clearInterval(interval);
}
}, [visible, fetchData]);
if (!visible || !summary) return null;
const { budget, burnRate, totalCostUsd, workerCount } = summary;
const maxWorkerCost = Math.max(...workers.map(w => w.costUsd), 0.001);
return (
<div className="cost-dashboard-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="cost-dashboard budget-alert-panel">
<div className="cost-dashboard-header">
<h3>Budget Alerts</h3>
<button className="close-button" onClick={onClose}>&times;</button>
</div>
<div className="cost-dashboard-content">
{/* Budget Status */}
<div className="cost-card">
<div className="cost-card-title">Budget Status</div>
<div className="budget-alert-summary-row">
<div className="budget-alert-stat">
<span className="budget-alert-stat-label">Spent</span>
<span className={`budget-alert-stat-value ${budget.warningLevel === 'critical' || budget.isOverBudget ? 'cost-high' : budget.warningLevel === 'warning' ? 'cost-warn' : ''}`}>
{formatCost(budget.spent)}
</span>
</div>
<div className="budget-alert-stat">
<span className="budget-alert-stat-label">Limit</span>
<span className="budget-alert-stat-value">{formatCost(budget.limit)}</span>
</div>
<div className="budget-alert-stat">
<span className="budget-alert-stat-label">Remaining</span>
<span className="budget-alert-stat-value">{formatCost(Math.max(0, budget.remaining))}</span>
</div>
<div className="budget-alert-stat">
<span className="budget-alert-stat-label">Usage</span>
<span className={`budget-alert-stat-value ${budget.warningLevel === 'critical' || budget.isOverBudget ? 'cost-high' : budget.warningLevel === 'warning' ? 'cost-warn' : ''}`}>
{Math.round(budget.percentUsed)}%
</span>
</div>
</div>
<div className="budget-progress-container">
<div className="budget-progress-bar">
<div
className="budget-progress-fill"
style={{
width: `${Math.min(100, budget.percentUsed)}%`,
backgroundColor: budget.warningLevel === 'critical' || budget.isOverBudget
? 'var(--error)'
: budget.warningLevel === 'warning'
? 'var(--warning)'
: 'var(--success)',
}}
/>
</div>
<div className="budget-progress-markers">
<span className="budget-marker" style={{ left: '80%' }}>80%</span>
<span className="budget-marker" style={{ left: '95%' }}>95%</span>
</div>
</div>
</div>
{/* Burn Rate & ETA */}
<div className="cost-card">
<div className="cost-card-title">Burn Rate &amp; ETA</div>
<div className="budget-alert-summary-row">
<div className="budget-alert-stat">
<span className="budget-alert-stat-label">Rate</span>
<span className={`budget-alert-stat-value ${burnRate.isHighBurnRate ? 'cost-high' : ''}`}>
{formatBurnRate(burnRate.costPerMinute)}
</span>
</div>
<div className="budget-alert-stat">
<span className="budget-alert-stat-label">ETA to Exhaust</span>
<span className="budget-alert-stat-value">
{burnRate.timeToExhaustion || 'N/A'}
</span>
</div>
<div className="budget-alert-stat">
<span className="budget-alert-stat-label">Projected Total</span>
<span className="budget-alert-stat-value">{formatCost(burnRate.projectedTotalCost)}</span>
</div>
</div>
{burnRate.isHighBurnRate && (
<div className="budget-burn-warning">High burn rate detected</div>
)}
</div>
{/* Top Consumers */}
<div className="cost-card">
<div className="cost-card-title">Top Consumers ({workerCount} workers)</div>
<div className="budget-consumers-list">
{workers.length === 0 && (
<div className="cost-empty">No cost data yet</div>
)}
{workers.map(w => (
<div key={w.workerId} className="budget-consumer-row">
<div className="budget-consumer-info">
<span className="budget-consumer-id">{w.workerId}</span>
{w.currentBead && <span className="budget-consumer-bead">{w.currentBead}</span>}
</div>
<div className="budget-consumer-bar-container">
<div
className="budget-consumer-bar"
style={{ width: `${(w.costUsd / maxWorkerCost) * 100}%` }}
/>
</div>
<span className="budget-consumer-cost">{formatCost(w.costUsd)}</span>
<span className="budget-consumer-tokens">{formatTokens(w.totalTokens)} tok | {w.apiCalls} calls</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
};
export default BudgetAlertPanel;

View file

@ -131,6 +131,12 @@ const BudgetProgressBar: React.FC<BudgetProgressBarProps> = ({ spent, limit, per
backgroundColor: getColor(),
}}
/>
<div className="budget-threshold-marker" style={{ left: '80%' }} title="Warning (80%)" />
<div className="budget-threshold-marker budget-threshold-critical" style={{ left: '95%' }} title="Critical (95%)" />
</div>
<div className="budget-progress-markers">
<span className="budget-marker" style={{ left: '80%' }}>80%</span>
<span className="budget-marker" style={{ left: '95%' }}>95%</span>
</div>
</div>
);
@ -181,7 +187,7 @@ const CostDashboard: React.FC<CostDashboardProps> = ({ visible, onClose }) => {
const [beads, setBeads] = useState<BeadCostEntry[]>([]);
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
const [alerts, setAlerts] = useState<BudgetAlert[]>([]);
const [activeTab, setActiveTab] = useState<'overview' | 'workers' | 'beads' | 'trends'>('overview');
const [activeTab, setActiveTab] = useState<'overview' | 'workers' | 'beads' | 'trends' | 'alerts'>('overview');
const [loading, setLoading] = useState(false);
const fetchCostData = useCallback(async () => {
@ -243,6 +249,7 @@ const CostDashboard: React.FC<CostDashboardProps> = ({ visible, onClose }) => {
{ id: 'workers' as const, label: 'Workers' },
{ id: 'beads' as const, label: 'Tasks' },
{ id: 'trends' as const, label: 'Trends' },
{ id: 'alerts' as const, label: 'Alerts', badge: alerts.filter(a => !a.acknowledged).length || undefined },
];
return (
@ -268,6 +275,9 @@ const CostDashboard: React.FC<CostDashboardProps> = ({ visible, onClose }) => {
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
{tab.badge && tab.badge > 0 && (
<span className="cost-tab-badge">{tab.badge}</span>
)}
</button>
))}
</div>
@ -279,6 +289,30 @@ const CostDashboard: React.FC<CostDashboardProps> = ({ visible, onClose }) => {
{activeTab === 'overview' && summary && (
<div className="cost-overview">
{/* Budget Alert Banner (when >= 80%) */}
{summary.budget.limit > 0 && summary.budget.warningLevel !== 'none' && (
<div className={`cost-dashboard-alert-banner ${summary.budget.warningLevel === 'critical' || summary.budget.isOverBudget ? 'cost-dashboard-alert-critical' : 'cost-dashboard-alert-warning'}`}>
<span className="cost-dashboard-alert-icon">
{summary.budget.isOverBudget ? '!!' : summary.budget.warningLevel === 'critical' ? '!!' : '!'}
</span>
<span className="cost-dashboard-alert-text">
{summary.budget.isOverBudget
? `Budget exceeded: ${formatCost(summary.budget.spent)} / ${formatCost(summary.budget.limit)} (${Math.round(summary.budget.percentUsed)}%)`
: `${summary.budget.warningLevel === 'critical' ? 'Critical' : 'Warning'}: ${formatCost(summary.budget.spent)} / ${formatCost(summary.budget.limit)} (${Math.round(summary.budget.percentUsed)}% used)`}
</span>
<span className="cost-dashboard-alert-burn">
{formatBurnRate(summary.burnRate.costPerMinute)}
{summary.burnRate.timeToExhaustion && ` | ETA: ${summary.burnRate.timeToExhaustion}`}
</span>
<div className="cost-dashboard-alert-bar">
<div
className="cost-dashboard-alert-bar-fill"
style={{ width: `${Math.min(100, summary.budget.percentUsed)}%` }}
/>
</div>
</div>
)}
{/* Session Cost */}
<div className="cost-card">
<div className="cost-card-title">Session Cost</div>
@ -296,57 +330,56 @@ const CostDashboard: React.FC<CostDashboardProps> = ({ visible, onClose }) => {
)}
</div>
{/* Burn Rate */}
{/* Burn Rate & ETA */}
<div className="cost-card">
<div className="cost-card-title">Burn Rate</div>
<div className={`cost-card-value ${summary.burnRate.isHighBurnRate ? 'cost-high' : ''}`}>
{formatBurnRate(summary.burnRate.costPerMinute)}
</div>
<div className="cost-card-subtitle">
Window: {summary.burnRate.windowMinutes} min avg
</div>
{summary.burnRate.timeToExhaustion && (
<div className="cost-exhaustion">
Time to exhaustion: <strong>{summary.burnRate.timeToExhaustion}</strong>
<div className="cost-card-title">Burn Rate & ETA</div>
<div className="budget-alert-summary-row">
<div className="budget-alert-stat">
<span className="budget-alert-stat-label">Rate</span>
<span className={`budget-alert-stat-value ${summary.burnRate.isHighBurnRate ? 'cost-high' : ''}`}>
{formatBurnRate(summary.burnRate.costPerMinute)}
</span>
</div>
<div className="budget-alert-stat">
<span className="budget-alert-stat-label">ETA to Exhaust</span>
<span className="budget-alert-stat-value">
{summary.burnRate.timeToExhaustion || 'N/A'}
</span>
</div>
<div className="budget-alert-stat">
<span className="budget-alert-stat-label">Projected Total</span>
<span className="budget-alert-stat-value">{formatCost(summary.burnRate.projectedTotalCost)}</span>
</div>
)}
<div className="cost-projected">
Projected session total: {formatCost(summary.burnRate.projectedTotalCost)}
</div>
{summary.burnRate.isHighBurnRate && (
<div className="budget-burn-warning">High burn rate detected</div>
)}
</div>
{/* Alerts */}
{alerts.filter(a => !a.acknowledged).length > 0 && (
<div className="cost-card cost-alerts-card">
<div className="cost-card-title">Active Alerts</div>
{alerts.filter(a => !a.acknowledged).map(alert => (
<div key={alert.id} className={`cost-alert-item cost-alert-${alert.type}`}>
<div className="cost-alert-header">
<span className="cost-alert-type">{alert.type.toUpperCase()}</span>
<span className="cost-alert-time">{new Date(alert.timestamp).toLocaleTimeString()}</span>
{/* Top Consumers */}
<div className="cost-card">
<div className="cost-card-title">Top Consumers ({summary.workerCount} workers)</div>
<div className="budget-consumers-list">
{workers.slice(0, 10).length === 0 && (
<div className="cost-empty">No cost data yet</div>
)}
{workers.slice(0, 10).map(w => (
<div key={w.workerId} className="budget-consumer-row">
<div className="budget-consumer-info">
<span className="budget-consumer-id">{w.workerId}</span>
{w.currentBead && <span className="budget-consumer-bead">{w.currentBead}</span>}
</div>
<div className="cost-alert-details">
{formatCost(alert.spent)} / {formatCost(alert.limit)} at {formatBurnRate(alert.burnRate)}
<div className="budget-consumer-bar-container">
<div
className="budget-consumer-bar"
style={{ width: `${(w.costUsd / (Math.max(...workers.slice(0, 10).map(wc => wc.costUsd)) || 0.001)) * 100}%` }}
/>
</div>
<button className="cost-alert-ack" onClick={() => handleAcknowledge(alert.id)}>
Acknowledge
</button>
<span className="budget-consumer-cost">{formatCost(w.costUsd)}</span>
<span className="budget-consumer-tokens">{formatTokens(w.totalTokens)} tok | {w.apiCalls} calls</span>
</div>
))}
</div>
)}
{/* Quick Workers Summary */}
<div className="cost-card">
<div className="cost-card-title">Top Workers ({summary.workerCount} total)</div>
{workers.slice(0, 5).map(w => (
<div key={w.workerId} className="cost-worker-row">
<span className="cost-worker-id">{w.workerId}</span>
<span className="cost-worker-cost">{formatCost(w.costUsd)}</span>
<span className="cost-worker-tokens">{formatTokens(w.totalTokens)} tok</span>
</div>
))}
{workers.length === 0 && <div className="cost-empty">No cost data yet</div>}
</div>
</div>
)}
@ -452,6 +485,99 @@ const CostDashboard: React.FC<CostDashboardProps> = ({ visible, onClose }) => {
</div>
</div>
)}
{activeTab === 'alerts' && summary && (
<div className="cost-alerts-view">
{/* Budget Status */}
{summary.budget.limit > 0 && (
<div className="cost-card">
<div className="cost-card-title">Budget Status</div>
<div className="budget-alert-summary-row">
<div className="budget-alert-stat">
<span className="budget-alert-stat-label">Spent</span>
<span className={`budget-alert-stat-value ${summary.budget.warningLevel === 'critical' || summary.budget.isOverBudget ? 'cost-high' : summary.budget.warningLevel === 'warning' ? 'cost-warn' : ''}`}>
{formatCost(summary.budget.spent)}
</span>
</div>
<div className="budget-alert-stat">
<span className="budget-alert-stat-label">Limit</span>
<span className="budget-alert-stat-value">{formatCost(summary.budget.limit)}</span>
</div>
<div className="budget-alert-stat">
<span className="budget-alert-stat-label">Remaining</span>
<span className="budget-alert-stat-value">{formatCost(Math.max(0, summary.budget.remaining))}</span>
</div>
<div className="budget-alert-stat">
<span className="budget-alert-stat-label">Usage</span>
<span className={`budget-alert-stat-value ${summary.budget.warningLevel === 'critical' || summary.budget.isOverBudget ? 'cost-high' : summary.budget.warningLevel === 'warning' ? 'cost-warn' : ''}`}>
{Math.round(summary.budget.percentUsed)}%
</span>
</div>
</div>
<BudgetProgressBar
spent={summary.budget.spent}
limit={summary.budget.limit}
percentUsed={summary.budget.percentUsed}
warningLevel={summary.budget.warningLevel}
/>
</div>
)}
{/* Burn Rate & ETA */}
<div className="cost-card">
<div className="cost-card-title">Burn Rate & ETA</div>
<div className="budget-alert-summary-row">
<div className="budget-alert-stat">
<span className="budget-alert-stat-label">Rate</span>
<span className={`budget-alert-stat-value ${summary.burnRate.isHighBurnRate ? 'cost-high' : ''}`}>
{formatBurnRate(summary.burnRate.costPerMinute)}
</span>
</div>
<div className="budget-alert-stat">
<span className="budget-alert-stat-label">ETA to Exhaust</span>
<span className="budget-alert-stat-value">
{summary.burnRate.timeToExhaustion || 'N/A'}
</span>
</div>
<div className="budget-alert-stat">
<span className="budget-alert-stat-label">Projected Total</span>
<span className="budget-alert-stat-value">{formatCost(summary.burnRate.projectedTotalCost)}</span>
</div>
</div>
{summary.burnRate.isHighBurnRate && (
<div className="budget-burn-warning">High burn rate detected</div>
)}
</div>
{/* Active Alerts */}
{alerts.length > 0 ? (
<div className="cost-card cost-alerts-card">
<div className="cost-card-title">Alerts ({alerts.filter(a => !a.acknowledged).length} active)</div>
{alerts.map(alert => (
<div key={alert.id} className={`cost-alert-item cost-alert-${alert.type}${alert.acknowledged ? ' cost-alert-acked' : ''}`}>
<div className="cost-alert-header">
<span className="cost-alert-type">{alert.type.toUpperCase()}</span>
<span className="cost-alert-time">{new Date(alert.timestamp).toLocaleTimeString()}</span>
</div>
<div className="cost-alert-details">
{formatCost(alert.spent)} / {formatCost(alert.limit)} at {formatBurnRate(alert.burnRate)}
</div>
{!alert.acknowledged && (
<button className="cost-alert-ack" onClick={() => handleAcknowledge(alert.id)}>
Acknowledge
</button>
)}
</div>
))}
</div>
) : (
<div className="cost-card">
<div className="cost-card-title">Alerts</div>
<div className="cost-empty">No budget alerts</div>
</div>
)}
</div>
)}
</div>
</div>
</div>

View file

@ -811,6 +811,326 @@ body {
margin-top: 0.5rem;
}
/* ============================================
Worker Detail Tabs
============================================ */
.worker-detail-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--bg-tertiary);
margin-bottom: 0.75rem;
}
.worker-detail-tab {
flex: 1;
padding: 0.5rem 0.75rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
}
.worker-detail-tab:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.worker-detail-tab.active {
color: var(--info);
border-bottom-color: var(--info);
}
.worker-detail-tab-count {
background: var(--bg-tertiary);
padding: 0.0625rem 0.375rem;
border-radius: 10px;
font-size: 0.6875rem;
color: var(--text-secondary);
}
.worker-detail-tab.active .worker-detail-tab-count {
background: rgba(33, 150, 243, 0.15);
color: var(--info);
}
.worker-detail-tab-content {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
/* ============================================
Conversation Transcript Styles
============================================ */
.conversation-transcript {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.conversation-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0;
margin-bottom: 0.5rem;
}
.conversation-turn-count {
font-size: 0.75rem;
color: var(--text-secondary);
}
.conversation-toolbar-actions {
display: flex;
gap: 0.25rem;
}
.conversation-toolbar-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 0.25rem 0.5rem;
border-radius: 3px;
font-size: 0.6875rem;
cursor: pointer;
transition: all 0.15s;
}
.conversation-toolbar-btn:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
.conversation-search-bar {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0;
margin-bottom: 0.5rem;
border-bottom: 1px solid var(--bg-tertiary);
}
.conversation-search-input {
flex: 1;
background: var(--input-bg);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 0.25rem 0.5rem;
border-radius: 3px;
font-size: 0.8125rem;
font-family: inherit;
outline: none;
}
.conversation-search-input:focus {
border-color: var(--info);
}
.conversation-search-results {
font-size: 0.6875rem;
color: var(--text-secondary);
white-space: nowrap;
}
.conversation-search-nav {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 0.125rem 0.375rem;
border-radius: 2px;
cursor: pointer;
font-size: 0.75rem;
line-height: 1;
}
.conversation-search-nav:hover {
color: var(--text-primary);
}
.conversation-search-close {
background: none;
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 0.125rem 0.375rem;
border-radius: 2px;
cursor: pointer;
font-size: 0.6875rem;
}
.conversation-search-close:hover {
color: var(--text-primary);
}
.conversation-turn-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.conversation-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
font-size: 0.875rem;
padding: 2rem 0;
}
.conversation-turn {
border-left: 3px solid var(--border-color);
border-radius: 0 4px 4px 0;
background: var(--bg-primary);
transition: background 0.15s;
}
.conversation-turn:hover {
background: var(--hover-bg);
}
.conversation-turn-search-hit {
background: rgba(255, 193, 7, 0.05);
}
.conversation-turn-current-hit {
background: rgba(33, 150, 243, 0.1);
outline: 1px solid rgba(33, 150, 243, 0.3);
}
.conversation-turn-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
font-size: 0.75rem;
user-select: none;
}
.conversation-turn-role {
font-weight: 700;
font-size: 0.6875rem;
letter-spacing: 0.04em;
min-width: 56px;
}
.conversation-turn-tool {
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.75rem;
color: #00bcd4;
background: rgba(0, 188, 212, 0.1);
padding: 0.0625rem 0.375rem;
border-radius: 3px;
}
.conversation-turn-event {
font-size: 0.6875rem;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.conversation-turn-time {
font-size: 0.6875rem;
color: var(--text-secondary);
font-family: 'SF Mono', Monaco, monospace;
flex-shrink: 0;
}
.conversation-turn-duration {
font-size: 0.6875rem;
color: var(--text-secondary);
font-family: 'SF Mono', Monaco, monospace;
flex-shrink: 0;
}
.conversation-turn-error-badge {
background: rgba(244, 67, 54, 0.15);
color: var(--error);
font-size: 0.625rem;
font-weight: 700;
padding: 0.0625rem 0.25rem;
border-radius: 2px;
}
.conversation-turn-collapse-icon {
color: var(--text-secondary);
font-size: 0.6875rem;
font-family: 'SF Mono', Monaco, monospace;
}
.conversation-turn-content {
padding: 0 0.5rem 0.375rem 0.5rem;
}
.conversation-turn-text {
font-size: 0.8125rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
color: var(--text-primary);
max-height: 200px;
overflow-y: auto;
}
.conversation-turn-code {
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
font-size: 0.75rem;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
color: var(--text-primary);
background: var(--bg-primary);
padding: 0.375rem 0.5rem;
border-radius: 3px;
border: 1px solid var(--bg-tertiary);
max-height: 300px;
overflow-y: auto;
margin: 0;
}
.conversation-turn-error {
font-size: 0.75rem;
color: var(--error);
padding: 0.25rem 0.375rem;
margin-top: 0.25rem;
background: rgba(244, 67, 54, 0.08);
border-radius: 3px;
font-family: 'SF Mono', Monaco, monospace;
}
.conversation-turn-meta {
display: flex;
gap: 0.75rem;
margin-top: 0.25rem;
font-size: 0.6875rem;
color: var(--text-secondary);
font-family: 'SF Mono', Monaco, monospace;
}
.search-highlight {
background: rgba(255, 193, 7, 0.3);
color: inherit;
padding: 0.0625rem 0;
border-radius: 2px;
}
/* ============================================
Session Replay Component Styles
============================================ */
@ -4107,6 +4427,107 @@ body {
color: var(--text-secondary);
}
/* Cost Dashboard Alert Banner (inside dashboard overlay) */
.cost-dashboard-alert-banner {
grid-column: 1 / -1;
border-radius: 8px;
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
font-size: 0.85rem;
animation: slideDown 0.25s ease-out;
}
.cost-dashboard-alert-warning {
background: linear-gradient(135deg, #332200, #443300);
border: 1px solid var(--warning);
}
.cost-dashboard-alert-critical {
background: linear-gradient(135deg, #330a0a, #441111);
border: 1px solid var(--error);
}
.cost-dashboard-alert-icon {
font-weight: 700;
font-size: 1rem;
}
.cost-dashboard-alert-warning .cost-dashboard-alert-icon {
color: var(--warning);
}
.cost-dashboard-alert-critical .cost-dashboard-alert-icon {
color: var(--error);
}
.cost-dashboard-alert-text {
color: var(--text-primary);
font-weight: 500;
}
.cost-dashboard-alert-burn {
color: var(--text-secondary);
font-size: 0.8rem;
margin-left: auto;
}
.cost-dashboard-alert-bar {
flex-basis: 100%;
height: 4px;
background: var(--bg-tertiary);
border-radius: 2px;
overflow: hidden;
}
.cost-dashboard-alert-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s ease;
}
.cost-dashboard-alert-warning .cost-dashboard-alert-bar-fill {
background: var(--warning);
}
.cost-dashboard-alert-critical .cost-dashboard-alert-bar-fill {
background: var(--error);
}
/* Budget threshold markers on progress bar */
.budget-threshold-marker {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: var(--warning);
opacity: 0.7;
z-index: 1;
}
.budget-threshold-critical {
background: var(--error);
}
/* Tab badge */
.cost-tab-badge {
background: var(--error);
color: white;
font-size: 0.6rem;
padding: 0.1rem 0.35rem;
border-radius: 8px;
font-weight: 600;
margin-left: 0.3rem;
line-height: 1;
}
.cost-alert-acked {
opacity: 0.5;
}
.cost-alerts-card {
grid-column: 1 / -1;
}

View file

@ -15,9 +15,16 @@ export interface LogEvent {
tool?: string;
message: string;
raw: string;
bead?: string; // Bead/task identifier for Focus Mode
sequence?: number; // Per-worker monotonic counter — authoritative for ordering
ts?: number; // Unix timestamp in ms (display only)
bead?: string;
sequence?: number;
ts?: number;
msg?: string;
error?: string;
path?: string;
provider?: string;
model?: string;
session?: string;
duration_ms?: number;
}
/**
@ -503,3 +510,25 @@ export interface SimilarError {
resolution_successful: boolean | null;
similarity: number;
}
// ============================================
// Conversation Transcript Types
// ============================================
export type ConversationTurnRole = 'system' | 'user' | 'assistant' | 'tool';
export interface ConversationTurn {
id: string;
role: ConversationTurnRole;
eventType: string;
timestamp: number;
content: string;
isCollapsible: boolean;
isCollapsed: boolean;
tool?: string;
durationMs?: number;
error?: string;
success?: boolean;
sequence?: number;
meta?: Record<string, unknown>;
}