[hAIk]: migrate dashboard layout thunks to backend-bridge: replace legacy fetchLayout with GET_DASHBOARD and saveLayout with UPDATE_DASHBOARD, move browser-card tab normalization into slice extraReducers, extend Dashboard interface with layout type, remove dashboardLayoutThunks.ts and unused LayoutPayload/SaveLayoutPayload types. Also removed UsageStats and DiffViewer

This commit is contained in:
haikdc
2026-04-18 06:19:24 -07:00
parent 09ea76b12d
commit 0d3c515616
12 changed files with 82 additions and 489 deletions
+31 -33
View File
@@ -1,7 +1,8 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { API_BASE } from '@/shared/config';
import { useAppDispatch } from '@/shared/hooks';
import { SUBSCRIPTIONS_STATUS } from '@/shared/backend-bridge/apps/subscriptions';
import { CREATE_TOOL, OAUTH_START, GET_TOOL, DISCOVER_TOOL } from '@/shared/backend-bridge/apps/tools';
import type { ToolDefinition } from '@/shared/state/toolsSlice';
import { ToolIntegration } from './onboardingConstants';
import { useSubscriptionConnect } from './useSubscriptionConnect';
@@ -76,58 +77,55 @@ export function useOnboarding() {
const handleToolConnect = useCallback(async (integration: ToolIntegration) => {
setConnecting(integration.name);
try {
const createRes = await fetch(`${API_BASE}/tools/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: integration.name,
description: integration.desc,
mcp_config: integration.mcp_config,
auth_type: 'oauth2',
auth_status: 'configured',
oauth_provider: integration.oauthProvider,
}),
});
if (!createRes.ok) { setConnecting(null); return; }
const { tool } = await createRes.json();
const createResult = await dispatch(CREATE_TOOL({
name: integration.name,
description: integration.desc,
mcp_config: integration.mcp_config,
auth_type: 'oauth2',
auth_status: 'configured',
oauth_provider: integration.oauthProvider,
}));
if (!CREATE_TOOL.fulfilled.match(createResult)) { setConnecting(null); return; }
const tool = createResult.payload.tool as unknown as ToolDefinition;
const oauthRes = await fetch(`${API_BASE}/tools/${tool.id}/oauth/start`, { method: 'POST' });
if (!oauthRes.ok) { setConnecting(null); return; }
const { auth_url } = await oauthRes.json();
const oauthResult = await dispatch(OAUTH_START(tool.id));
if (!OAUTH_START.fulfilled.match(oauthResult)) { setConnecting(null); return; }
const { auth_url } = oauthResult.payload;
const popup = window.open(auth_url, 'oauth', 'width=500,height=700,left=200,top=100');
const afterConnect = async () => {
const statusResult = await dispatch(GET_TOOL(tool.id));
if (
GET_TOOL.fulfilled.match(statusResult) &&
(statusResult.payload as unknown as ToolDefinition).auth_status === 'connected'
) {
setConnectedTools((prev) => new Set(prev).add(integration.name));
dispatch(DISCOVER_TOOL(tool.id));
}
setConnecting(null);
};
const onMsg = (event: MessageEvent) => {
if (event.data?.type === 'oauth_complete' && event.data?.tool_id === tool.id) {
window.removeEventListener('message', onMsg);
setConnectedTools((prev) => new Set(prev).add(integration.name));
setConnecting(null);
fetch(`${API_BASE}/tools/${tool.id}/discover`, { method: 'POST' }).catch(() => {});
afterConnect();
}
};
window.addEventListener('message', onMsg);
const poller = setInterval(() => {
if (popup && popup.closed) {
if (popup?.closed) {
clearInterval(poller);
window.removeEventListener('message', onMsg);
fetch(`${API_BASE}/tools/${tool.id}`)
.then(r => r.json())
.then(data => {
if (data.tool?.auth_status === 'connected') {
setConnectedTools((prev) => new Set(prev).add(integration.name));
fetch(`${API_BASE}/tools/${tool.id}/discover`, { method: 'POST' }).catch(() => {});
}
})
.catch(() => {});
setConnecting(null);
afterConnect();
}
}, 1000);
setTimeout(() => { clearInterval(poller); setConnecting(null); }, 60000);
} catch {
setConnecting(null);
}
}, []);
}, [dispatch]);
const handleApiKey = useCallback(() => advanceToTools(), [advanceToTools]);
const handleSkip = useCallback(() => dismiss(), [dismiss]);
@@ -4,7 +4,6 @@ import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import DiffViewer from './DiffViewer';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
interface ChatHeaderProps {
@@ -17,11 +16,10 @@ interface ChatHeaderProps {
id: string;
};
isDraft: boolean;
id: string | undefined;
onClose?: () => void;
}
const ChatHeader: React.FC<ChatHeaderProps> = ({ session, isDraft, id, onClose }) => {
const ChatHeader: React.FC<ChatHeaderProps> = ({ session, isDraft, onClose }) => {
const c = useClaudeTokens();
const STATUS_STYLES: Record<string, { color: string; bg: string }> = {
running: { color: c.status.success, bg: c.status.successBg },
@@ -73,7 +71,6 @@ const ChatHeader: React.FC<ChatHeaderProps> = ({ session, isDraft, id, onClose }
</Box>
)}
</Box>
{!isDraft && id && <DiffViewer sessionId={id} />}
{onClose && (
<IconButton onClick={onClose} size="small" sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}>
<CloseIcon fontSize="small" />
@@ -1,123 +0,0 @@
import React, { useState, useEffect } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import RefreshIcon from '@mui/icons-material/Refresh';
import DifferenceIcon from '@mui/icons-material/Difference';
import { CodeDiff } from '@/components/tool-ui/code-diff/code-diff';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { API_BASE } from '@/shared/config';
const AGENTS_API = `${API_BASE}/agents`;
interface Props {
sessionId: string;
}
const DiffViewer: React.FC<Props> = ({ sessionId }) => {
const c = useClaudeTokens();
const [diff, setDiff] = useState<string>('');
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const fetchDiff = async () => {
setLoading(true);
try {
const res = await fetch(`${AGENTS_API}/sessions/${sessionId}/diff`);
const data = await res.json();
setDiff(data.diff || '');
} catch {
setDiff('Failed to fetch diff');
}
setLoading(false);
};
useEffect(() => {
if (open) fetchDiff();
}, [open, sessionId]);
if (!open) {
return (
<Tooltip title="View changes">
<IconButton onClick={() => setOpen(true)} sx={{ color: c.text.tertiary }}>
<DifferenceIcon />
</IconButton>
</Tooltip>
);
}
return (
<Box
sx={{
width: 400,
flexShrink: 0,
boxShadow: '-1px 0 4px rgba(0,0,0,0.04)',
bgcolor: c.bg.surface,
display: 'flex',
flexDirection: 'column',
height: '100%',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2,
py: 1,
borderBottom: `0.5px solid ${c.border.medium}`,
bgcolor: c.bg.secondary,
}}
>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.85rem' }}>
Worktree Changes
</Typography>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title="Refresh">
<IconButton size="small" onClick={fetchDiff} sx={{ color: c.text.tertiary }}>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
<IconButton size="small" onClick={() => setOpen(false)} sx={{ color: c.text.tertiary }}>
<Typography sx={{ fontSize: '0.85rem' }}>×</Typography>
</IconButton>
</Box>
</Box>
<Box
sx={{
flex: 1,
overflow: 'auto',
p: 1.5,
'&::-webkit-scrollbar': { width: 5, height: 5 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
background: c.border.medium,
borderRadius: 3,
'&:hover': { background: c.border.strong },
},
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
}}
>
{loading ? (
<Typography sx={{ color: c.text.ghost, fontSize: '0.8rem' }}>Loading...</Typography>
) : diff ? (
<CodeDiff
id={`diff-${sessionId}`}
patch={diff}
language="diff"
diffStyle="unified"
lineNumbers="visible"
/>
) : (
<Typography sx={{ color: c.text.ghost, fontSize: '0.8rem' }}>
No changes detected in the worktree.
</Typography>
)}
</Box>
</Box>
);
};
export default DiffViewer;
+13 -5
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useCallback, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { toggleExpandSession } from '@/shared/state/agentsSlice';
import { saveLayout } from '@/shared/state/dashboardLayoutSlice';
import { UPDATE_DASHBOARD } from '@/shared/backend-bridge/apps/dashboards';
import { useCanvasControls } from './useCanvasControls';
import { useDashboardSelection } from './useDashboardSelection';
import { ElementSelectionProvider, useElementSelection } from '@/app/components/ElementSelectionContext';
@@ -170,15 +170,23 @@ const DashboardInner: React.FC = () => {
const skipInitialSave = useRef(true);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingSaveRef = useRef<Parameters<typeof saveLayout>[0] | null>(null);
const pendingSaveRef = useRef<Parameters<typeof UPDATE_DASHBOARD>[0] | null>(null);
useEffect(() => {
if (!layoutInitialized || !dashboardId) return;
if (skipInitialSave.current) { skipInitialSave.current = false; return; }
const payload = { dashboardId, cards, viewCards, browserCards, expandedSessionIds };
const payload = {
dashboardId,
layout: {
cards,
view_cards: viewCards,
browser_cards: browserCards,
expanded_session_ids: expandedSessionIds,
},
};
pendingSaveRef.current = payload;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
dispatch(saveLayout(payload));
dispatch(UPDATE_DASHBOARD(payload));
pendingSaveRef.current = null;
saveTimerRef.current = null;
captureNow();
@@ -188,7 +196,7 @@ const DashboardInner: React.FC = () => {
useEffect(() => {
return () => {
if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); saveTimerRef.current = null; }
if (pendingSaveRef.current) { dispatch(saveLayout(pendingSaveRef.current)); pendingSaveRef.current = null; }
if (pendingSaveRef.current) { dispatch(UPDATE_DASHBOARD(pendingSaveRef.current)); pendingSaveRef.current = null; }
};
}, [dispatch]);
@@ -3,8 +3,8 @@ import { useAppDispatch } from '@/shared/hooks';
import { store } from '@/shared/state/store';
import { setExpandedSessionIds } from '@/shared/state/agentsSlice';
import { GET_ALL_SESSIONS, GET_HISTORY } from '@/shared/backend-bridge/apps/agents';
import { GET_DASHBOARD } from '@/shared/backend-bridge/apps/dashboards';
import {
fetchLayout,
resetLayout,
reconcileSessions,
addBrowserCard,
@@ -51,7 +51,7 @@ export function useDashboardInit(deps: InitDeps) {
dispatch(resetLayout());
dispatch(GET_ALL_SESSIONS(dashboardId));
dispatch(GET_HISTORY({}));
dispatch(fetchLayout(dashboardId));
dispatch(GET_DASHBOARD(dashboardId));
dispatch(LIST_APPS());
dashboardWs.connect();
const cleanupBrowserHandler = initBrowserCommandHandler();
+1 -7
View File
@@ -17,13 +17,12 @@ import { CommandsContent } from '@/app/pages/Commands/Commands';
import { useSettings } from './hooks/useSettings';
import GeneralTab from './GeneralTab';
import ModelsTab from './ModelsTab';
import UsageStats from './UsageStats';
const Settings: React.FC = () => {
const s = useSettings();
const { c, open, activeTab, setActiveTab, hasChanges, handleSave, handleRequestClose,
confirmDiscard, setConfirmDiscard, handleConfirmDiscard, handleSaveAndClose,
saved, setSaved, form, setForm } = s;
saved, setSaved } = s;
return (
<>
<Dialog
@@ -76,7 +75,6 @@ const Settings: React.FC = () => {
>
<Tab label="General" value="general" disableRipple />
<Tab label="Models" value="models" disableRipple />
<Tab label="Usage" value="usage" disableRipple />
<Tab label="Commands" value="commands" disableRipple />
</Tabs>
</DialogTitle>
@@ -93,10 +91,6 @@ const Settings: React.FC = () => {
<GeneralTab s={s} />
) : activeTab === 'models' ? (
<ModelsTab s={s} />
) : activeTab === 'usage' ? (
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
<UsageStats />
</Box>
) : (
<Box sx={{ pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
<CommandsContent />
@@ -1,239 +0,0 @@
import React, { useState, useEffect } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { API_BASE } from '@/shared/config';
const PIXEL_SALMON = ['#C46B57', '#D4795F', '#E8927A', '#F0A088', '#F5B49E'];
const PIXEL_BLUE = ['#445588', '#5577AA', '#6688BB', '#7799CC', '#88AADD'];
const PixelBarOuter: React.FC<{ value: number; max: number; width?: number; palette?: string[]; tokens: any }> = ({ value, max, width = 16, palette = PIXEL_SALMON, tokens: c }) => {
const filled = max > 0 ? Math.max(value > 0 ? 1 : 0, Math.round((value / max) * width)) : 0;
return (
<Box sx={{ display: 'flex', gap: '1px', mt: 0.25 }}>
{Array.from({ length: width }, (_, i) => (
<Box
key={i}
sx={{
width: 5,
height: 5,
bgcolor: i < filled
? palette[Math.min(palette.length - 1, Math.floor((i / Math.max(filled - 1, 1)) * (palette.length - 1)))]
: c.border.subtle,
opacity: i < filled ? 1 : 0.3,
}}
/>
))}
</Box>
);
};
const UsageStats: React.FC = () => {
const c = useClaudeTokens();
const [stats, setStats] = useState<any>(null);
useEffect(() => {
fetch(`${API_BASE}/analytics/usage-summary`)
.then(r => r.json())
.then(setStats)
.catch(() => {});
}, []);
if (!stats) {
const skeletonPulse = {
animation: 'skeleton-pulse 1.5s ease-in-out infinite',
'@keyframes skeleton-pulse': { '0%, 100%': { opacity: 0.5 }, '50%': { opacity: 0.25 } },
};
const skeletonCard = {
p: 1.5, borderRadius: `${c.radius.md}px`, bgcolor: c.bg.elevated,
border: `1px solid ${c.border.subtle}`, ...skeletonPulse,
};
return (
<Box sx={{ mb: 2.5 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1 }}>
{Array.from({ length: 4 }, (_, i) => (
<Box key={i} sx={skeletonCard}>
<Box sx={{ width: 60, height: 8, bgcolor: c.border.subtle, borderRadius: 1, mb: 1 }} />
<Box sx={{ width: 50, height: 18, bgcolor: c.border.subtle, borderRadius: 1, mb: 0.5 }} />
<Box sx={{ width: 90, height: 8, bgcolor: c.border.subtle, borderRadius: 1 }} />
</Box>
))}
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1.5 }}>
{Array.from({ length: 4 }, (_, i) => (
<Box key={i} sx={skeletonCard}>
<Box sx={{ width: 70, height: 8, bgcolor: c.border.subtle, borderRadius: 1, mb: 1 }} />
<Box sx={{ width: 45, height: 18, bgcolor: c.border.subtle, borderRadius: 1, mb: 0.5 }} />
<Box sx={{ width: 80, height: 8, bgcolor: c.border.subtle, borderRadius: 1 }} />
</Box>
))}
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
{Array.from({ length: 2 }, (_, i) => (
<Box key={i} sx={{ ...skeletonCard, p: 2 }}>
<Box sx={{ width: 80, height: 8, bgcolor: c.border.subtle, borderRadius: 1, mb: 2 }} />
{Array.from({ length: 3 }, (_, j) => (
<Box key={j} sx={{ mb: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Box sx={{ width: 60 + j * 15, height: 10, bgcolor: c.border.subtle, borderRadius: 1 }} />
<Box sx={{ width: 35, height: 10, bgcolor: c.border.subtle, borderRadius: 1 }} />
</Box>
<Box sx={{ display: 'flex', gap: '1px' }}>
{Array.from({ length: 16 }, (_, k) => (
<Box key={k} sx={{ width: 5, height: 5, bgcolor: c.border.subtle, opacity: k < 8 - j * 2 ? 0.6 : 0.2 }} />
))}
</Box>
</Box>
))}
</Box>
))}
</Box>
</Box>
);
}
const formatCost = (v: number) => {
if (v === 0) return '$0.00';
if (v < 0.001) return `$${v.toFixed(6)}`;
if (v < 0.01) return `$${v.toFixed(5)}`;
if (v < 1) return `$${v.toFixed(4)}`;
return `$${v.toFixed(2)}`;
};
const formatDuration = (s: number) => {
if (s === 0) return '0s';
if (s < 60) return `${s.toFixed(1)}s`;
if (s < 3600) return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
};
const formatTotalTime = (s: number) => {
if (s < 60) return `${s.toFixed(1)}s`;
if (s < 3600) return `${(s / 60).toFixed(1)} min`;
return `${(s / 3600).toFixed(1)} hrs`;
};
const cardSx = {
p: 1.5,
borderRadius: `${c.radius.md}px`,
bgcolor: c.bg.elevated,
border: `1px solid ${c.border.subtle}`,
};
const labelSx = { fontSize: '0.58rem', fontWeight: 700, color: c.text.ghost, textTransform: 'uppercase' as const, letterSpacing: '0.06em', mb: 0.25 };
const valueSx = { fontSize: '1.05rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.2 };
const subSx = { fontSize: '0.62rem', color: c.text.tertiary, mt: 0.25 };
const modelEntries = Object.entries(stats.models_used || {}).sort((a: any, b: any) => b[1] - a[1]) as [string, number][];
const providerEntries = Object.entries(stats.providers_used || {}).sort((a: any, b: any) => b[1] - a[1]) as [string, number][];
const toolEntries = Object.entries(stats.top_tools || {}).slice(0, 10) as [string, number][];
const maxToolCount = toolEntries.length > 0 ? Math.max(...toolEntries.map(([, c]) => c)) : 1;
const statusEntries = Object.entries(stats.status_breakdown || {}) as [string, string][];
const PixelBar: React.FC<{ value: number; max: number; width?: number; palette?: string[] }> = (props) => (
<PixelBarOuter {...props} tokens={c} />
);
const totalTime = stats.avg_duration_seconds * stats.total_sessions;
const msgsPerSession = stats.total_sessions > 0 ? (stats.total_messages / stats.total_sessions).toFixed(1) : '0';
const toolsPerSession = stats.total_sessions > 0 ? (stats.total_tool_calls / stats.total_sessions).toFixed(1) : '0';
const formatTokens = (n: number) => {
if (n === 0) return '0';
if (n < 1000) return String(n);
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
return `${(n / 1_000_000).toFixed(2)}M`;
};
const isSubscription = stats.cost_source === '9router';
const costSourceLabel = isSubscription ? 'saved with your subscription' : stats.cost_source === 'sdk' ? 'via API' : '';
return (
<Box sx={{ mb: 2.5 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1 }}>
<Box sx={cardSx}>
<Typography sx={labelSx}>Total Sessions</Typography>
<Typography sx={valueSx}>{stats.total_sessions.toLocaleString()}</Typography>
<Typography sx={subSx}>
{statusEntries.map(([s, n]) => `${n} ${s}`).join(', ') || 'no sessions'}
</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={labelSx}>{isSubscription ? 'You Saved' : 'Total Cost'}</Typography>
<Typography sx={valueSx}>{formatCost(stats.total_cost_usd)}</Typography>
<Typography sx={subSx}>
{isSubscription
? `${formatCost(stats.avg_cost_per_session)} avg · saved with your subscription`
: costSourceLabel ? `${formatCost(stats.avg_cost_per_session)} avg · ${costSourceLabel}` : 'no cost data'}
</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={labelSx}>Total Messages</Typography>
<Typography sx={valueSx}>{stats.total_messages.toLocaleString()}</Typography>
<Typography sx={subSx}>{msgsPerSession} avg per session</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={labelSx}>Total Tool Calls</Typography>
<Typography sx={valueSx}>{stats.total_tool_calls.toLocaleString()}</Typography>
<Typography sx={subSx}>{toolsPerSession} avg per session</Typography>
</Box>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1.5 }}>
<Box sx={cardSx}>
<Typography sx={labelSx}>Total Run Time</Typography>
<Typography sx={valueSx}>{formatTotalTime(totalTime)}</Typography>
<Typography sx={subSx}>across all sessions</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={labelSx}>Avg Session</Typography>
<Typography sx={valueSx}>{formatDuration(stats.avg_duration_seconds)}</Typography>
<Typography sx={subSx}>per session duration</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={labelSx}>Completion Rate</Typography>
<Typography sx={valueSx}>{(stats.completion_rate * 100).toFixed(1)}%</Typography>
<Typography sx={subSx}>sessions finished successfully</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={labelSx}>Tokens Used</Typography>
<Typography sx={valueSx}>
{stats.total_prompt_tokens || stats.total_completion_tokens
? formatTokens((stats.total_prompt_tokens || 0) + (stats.total_completion_tokens || 0))
: Object.keys(stats.providers_used || {}).length}
</Typography>
<Typography sx={subSx}>
{stats.total_prompt_tokens || stats.total_completion_tokens
? `${formatTokens(stats.total_prompt_tokens || 0)} in · ${formatTokens(stats.total_completion_tokens || 0)} out`
: providerEntries.map(([p]) => p).join(', ') || 'none'}
</Typography>
</Box>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
<Box sx={{ ...cardSx, p: 2 }}>
<Typography sx={{ ...labelSx, mb: 1.5 }}>Models Used</Typography>
{modelEntries.length > 0 ? modelEntries.map(([model, count]) => {
const pct = stats.total_sessions > 0 ? ((count / stats.total_sessions) * 100).toFixed(0) : '0';
return (
<Box key={model} sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0 }}>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, fontWeight: 500 }}>{model}</Typography>
<Typography sx={{ fontSize: '0.68rem', color: c.text.tertiary, fontFamily: c.font.mono }}>
{count} ({pct}%)
</Typography>
</Box>
<PixelBar value={count} max={stats.total_sessions} palette={PIXEL_BLUE} />
</Box>
);
}) : <Typography sx={{ fontSize: '0.75rem', color: c.text.ghost }}>No sessions yet</Typography>}
</Box>
<Box sx={{ ...cardSx, p: 2 }}>
<Typography sx={{ ...labelSx, mb: 1.5 }}>Top Tools</Typography>
{toolEntries.length > 0 ? toolEntries.map(([tool, count]) => {
const shortName = tool.includes('__') ? tool.split('__').pop() : tool;
const pct = stats.total_tool_calls > 0 ? ((count / stats.total_tool_calls) * 100).toFixed(0) : '0';
return (
<Box key={tool} sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0 }}>
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, fontWeight: 500 }}>{shortName}</Typography>
<Typography sx={{ fontSize: '0.62rem', color: c.text.tertiary, fontFamily: c.font.mono }}>
{count} call{count !== 1 ? 's' : ''} ({pct}%)
</Typography>
</Box>
<PixelBar value={count} max={maxToolCount} />
</Box>
);
}) : <Typography sx={{ fontSize: '0.75rem', color: c.text.ghost }}>No tool calls yet</Typography>}
</Box>
</Box>
</Box>
);
};
export default UsageStats;
@@ -3,6 +3,13 @@ import { API_BASE } from '@/shared/backend-bridge/base_routes';
const DASHBOARDS_API: string = `${API_BASE}/dashboards`;
export interface DashboardLayout {
cards?: Record<string, unknown>;
view_cards?: Record<string, unknown>;
browser_cards?: Record<string, unknown>;
expanded_session_ids?: string[];
}
export interface Dashboard {
id: string;
name: string;
@@ -10,6 +17,7 @@ export interface Dashboard {
created_at: string;
updated_at: string;
thumbnail?: string | null;
layout?: DashboardLayout;
}
// ---------------------------------------------------------------------------
@@ -1,7 +1,8 @@
import { createSlice } from '@reduxjs/toolkit';
import { initialState } from './dashboardLayoutTypes';
import { initialState, generateTabId } from './dashboardLayoutTypes';
import type { CardPosition, ViewCardPosition, BrowserCardPosition } from './dashboardLayoutTypes';
import { dashboardLayoutReducers } from './dashboardLayoutReducers';
import { fetchLayout } from './dashboardLayoutThunks';
import { GET_DASHBOARD } from '@/shared/backend-bridge/apps/dashboards';
import { META_LAUNCH_AND_SEND } from '@/shared/backend-bridge/apps/agents';
const dashboardLayoutSlice = createSlice({
@@ -10,16 +11,31 @@ const dashboardLayoutSlice = createSlice({
reducers: dashboardLayoutReducers,
extraReducers: (builder) => {
builder
.addCase(fetchLayout.pending, (state) => {
.addCase(GET_DASHBOARD.pending, (state) => {
state.loading = true;
})
.addCase(fetchLayout.fulfilled, (state, action) => {
.addCase(GET_DASHBOARD.fulfilled, (state, action) => {
state.loading = false;
state.initialized = true;
state.cards = action.payload.cards;
state.viewCards = action.payload.viewCards;
state.browserCards = action.payload.browserCards;
state.persistedExpandedSessionIds = action.payload.expandedSessionIds;
const layout = action.payload.layout ?? {};
const browserCards = (layout.browser_cards ?? {}) as Record<string, any>;
for (const card of Object.values(browserCards)) {
if (!card.tabs || card.tabs.length === 0) {
const tabId = generateTabId();
card.tabs = [{ id: tabId, url: card.url || 'https://www.google.com', title: '' }];
card.activeTabId = tabId;
}
if (!card.url && card.tabs.length > 0) {
const active = card.tabs.find((t: any) => t.id === card.activeTabId) || card.tabs[0];
card.url = active.url;
}
}
state.cards = (layout.cards ?? {}) as Record<string, CardPosition>;
state.viewCards = (layout.view_cards ?? {}) as Record<string, ViewCardPosition>;
state.browserCards = browserCards as Record<string, BrowserCardPosition>;
state.persistedExpandedSessionIds = (layout.expanded_session_ids ?? []) as string[];
let maxZ = 0;
for (const c of Object.values(state.cards)) {
@@ -36,7 +52,7 @@ const dashboardLayoutSlice = createSlice({
}
state.nextZOrder = maxZ + 1;
})
.addCase(fetchLayout.rejected, (state) => {
.addCase(GET_DASHBOARD.rejected, (state) => {
state.loading = false;
state.initialized = true;
})
@@ -69,4 +85,3 @@ export const {
export default dashboardLayoutSlice.reducer;
export * from './dashboardLayoutTypes';
export * from './dashboardLayoutThunks';
@@ -1,54 +0,0 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
import type { CardPosition, ViewCardPosition, BrowserCardPosition, LayoutPayload, SaveLayoutPayload } from './dashboardLayoutTypes';
import { generateTabId } from './dashboardLayoutTypes';
const DASHBOARDS_API = `${API_BASE}/dashboards`;
export const fetchLayout = createAsyncThunk(
'dashboardLayout/fetch',
async (dashboardId: string) => {
const res = await fetch(`${DASHBOARDS_API}/${dashboardId}`);
const data = await res.json();
const layout = data.layout ?? {};
const browserCards = (layout.browser_cards ?? {}) as Record<string, any>;
for (const card of Object.values(browserCards)) {
if (!card.tabs || card.tabs.length === 0) {
const tabId = generateTabId();
card.tabs = [{ id: tabId, url: card.url || 'https://www.google.com', title: '' }];
card.activeTabId = tabId;
}
if (!card.url && card.tabs.length > 0) {
const active = card.tabs.find((t: any) => t.id === card.activeTabId) || card.tabs[0];
card.url = active.url;
}
}
return {
cards: (layout.cards ?? {}) as Record<string, CardPosition>,
viewCards: (layout.view_cards ?? {}) as Record<string, ViewCardPosition>,
browserCards: browserCards as Record<string, BrowserCardPosition>,
expandedSessionIds: (layout.expanded_session_ids ?? []) as string[],
} satisfies LayoutPayload;
},
);
export const saveLayout = createAsyncThunk(
'dashboardLayout/save',
async (payload: SaveLayoutPayload) => {
await fetch(`${DASHBOARDS_API}/${payload.dashboardId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
layout: {
cards: payload.cards,
view_cards: payload.viewCards,
browser_cards: payload.browserCards,
expanded_session_ids: payload.expandedSessionIds,
},
}),
});
return payload;
},
);
@@ -72,17 +72,6 @@ export const initialState: DashboardLayoutState = {
initialized: false,
};
export interface LayoutPayload {
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
expandedSessionIds: string[];
}
export interface SaveLayoutPayload extends LayoutPayload {
dashboardId: string;
}
export interface Rect {
x: number;
y: number;
File diff suppressed because one or more lines are too long