[eric] shell: the dashboards picker page dies, / auto-enters the latest dashboard and self-heals when the backend answers

This commit is contained in:
ciregenz
2026-08-03 16:33:51 -07:00
parent 17219c2974
commit e10560416e
2 changed files with 34 additions and 375 deletions
+2 -2
View File
@@ -23,7 +23,7 @@ import {
} from '@/shared/state/updateSlice';
import AppShell from './components/Layout/AppShell';
import ImportEntryPoint from './components/share/ImportEntryPoint';
import DashboardSelection from './pages/DashboardSelection/DashboardSelection';
import DashboardAutoEnter from './pages/DashboardAutoEnter/DashboardAutoEnter';
import ErrorBoundary from './components/feedback/ErrorBoundary';
import { setPanelMode, disableOnboardingAfterCrash } from '@/shared/state/onboardingProgressSlice';
@@ -530,7 +530,7 @@ const ThemedApp: React.FC = () => {
<Suspense fallback={null}>
<Routes>
<Route element={<AppShell />}>
<Route path="/" element={<DashboardSelection />} />
<Route path="/" element={<DashboardAutoEnter />} />
{/* Dashboard renders persistently in AppShell so webviews survive nav. */}
<Route path="/dashboard/:id" element={null} />
<Route path="/analytics" element={<Analytics />} />
@@ -1,386 +1,45 @@
import React, { useEffect, useState, useMemo } from 'react';
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import AddIcon from '@mui/icons-material/Add';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import { Skeleton } from '@/app/components/feedback/Loading';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import EditIcon from '@mui/icons-material/Edit';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import SearchIcon from '@mui/icons-material/Search';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import {
fetchDashboards,
createDashboard,
deleteDashboard,
duplicateDashboard,
renameDashboard,
Dashboard,
} from '@/shared/state/dashboardsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch } from '@/shared/hooks';
import { fetchDashboards, createDashboard, Dashboard } from '@/shared/state/dashboardsSlice';
import { byPreviewRecency } from '@/shared/previewOrder';
// Module-scope: auto-enter fires once per app boot; revisiting "/" later shows the picker normally.
let bootAutoEntered = false;
function formatRelativeTime(dateStr: string | null): string {
if (!dateStr) return '';
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
const DashboardSelection: React.FC = () => {
const c = useClaudeTokens();
// The old picker page is gone: the Spaces strip owns dashboard switching, so landing on "/" just
// drops the user into their latest dashboard (creating the first one on a fresh install). Renders
// nothing; while the backend is down the shell's warning banner is the message, and the retry loop
// self-heals the moment it answers.
const DashboardAutoEnter: React.FC = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const items = useAppSelector((state) => state.dashboards.items);
const loading = useAppSelector((state) => state.dashboards.loading);
const [search, setSearch] = useState('');
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
const [menuDashboard, setMenuDashboard] = useState<Dashboard | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
useEffect(() => {
if (bootAutoEntered) {
dispatch(fetchDashboards());
return;
}
// First mount of the session: skip the picker and drop the user into their latest dashboard.
bootAutoEntered = true;
(async () => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const enter = async (): Promise<void> => {
const res = await dispatch(fetchDashboards());
if (!fetchDashboards.fulfilled.match(res)) return;
const list = (res.payload as Dashboard[]).slice().sort(byPreviewRecency);
if (list.length > 0) {
navigate(`/dashboard/${list[0].id}`, { replace: true });
return;
if (cancelled) return;
if (fetchDashboards.fulfilled.match(res)) {
const list = (res.payload as Dashboard[]).slice().sort(byPreviewRecency);
if (list.length > 0) {
navigate(`/dashboard/${list[0].id}`, { replace: true });
return;
}
const created = await dispatch(createDashboard('Untitled Dashboard'));
if (!cancelled && createDashboard.fulfilled.match(created)) {
navigate(`/dashboard/${created.payload.id}`, { replace: true });
return;
}
}
const created = await dispatch(createDashboard('Untitled Dashboard'));
if (createDashboard.fulfilled.match(created)) {
navigate(`/dashboard/${created.payload.id}`, { replace: true });
}
})();
if (!cancelled) timer = setTimeout(enter, 3000);
};
enter();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [dispatch, navigate]);
const dashboards = useMemo(() => {
const all = Object.values(items).sort(byPreviewRecency);
if (!search.trim()) return all;
const q = search.toLowerCase();
return all.filter((d) => d.name.toLowerCase().includes(q));
}, [items, search]);
const handleCreate = async () => {
const result = await dispatch(createDashboard('Untitled Dashboard'));
if (createDashboard.fulfilled.match(result)) {
navigate(`/dashboard/${result.payload.id}`);
}
};
const handleOpenMenu = (e: React.MouseEvent<HTMLElement>, d: Dashboard) => {
e.stopPropagation();
setMenuAnchor(e.currentTarget);
setMenuDashboard(d);
};
// Right-click anywhere on a card opens the same menu at the cursor, Mac-style.
const handleContextMenu = (e: React.MouseEvent, d: Dashboard) => {
e.preventDefault();
e.stopPropagation();
setMenuPosition({ top: e.clientY, left: e.clientX });
setMenuDashboard(d);
};
const handleCloseMenu = () => {
setMenuAnchor(null);
setMenuPosition(null);
setMenuDashboard(null);
};
const handleDelete = () => {
if (menuDashboard) dispatch(deleteDashboard(menuDashboard.id));
handleCloseMenu();
};
const handleDuplicate = () => {
if (menuDashboard) dispatch(duplicateDashboard(menuDashboard.id));
handleCloseMenu();
};
const handleStartRename = () => {
const target = menuDashboard;
handleCloseMenu();
if (target) {
setTimeout(() => {
setRenamingId(target.id);
setRenameValue(target.name);
}, 150);
}
};
const handleRenameSubmit = (id: string) => {
const trimmed = renameValue.trim();
const previousName = items[id]?.name;
if (trimmed && trimmed !== previousName) {
dispatch(renameDashboard({ id, name: trimmed, previousName }));
}
setRenamingId(null);
};
return (
<Box sx={{ height: '100%', overflow: 'auto', p: 4 }}>
<Box sx={{ maxWidth: 1200, mx: 'auto' }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 3,
}}
>
<Box>
<Typography variant="h4" sx={{ fontWeight: 700, color: c.text.primary }}>
Dashboards
</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.875rem', mt: 0.5 }}>
Monitor and manage your agents from a single workspace.
</Typography>
</Box>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleCreate}
sx={{
bgcolor: c.accent.primary,
borderRadius: 2,
textTransform: 'none',
fontWeight: 500,
px: 2.5,
'&:hover': { bgcolor: c.accent.hover },
}}
>
New dashboard
</Button>
</Box>
<Box sx={{ mb: 3 }}>
<TextField
placeholder="Search dashboards..."
size="small"
value={search}
onChange={(e) => setSearch(e.target.value)}
InputProps={{
startAdornment: (
<SearchIcon sx={{ color: c.text.ghost, mr: 1, fontSize: 20 }} />
),
}}
sx={{
width: 320,
'& .MuiOutlinedInput-root': {
bgcolor: c.bg.surface,
borderRadius: 2,
fontSize: '0.875rem',
'& fieldset': { borderColor: c.border.subtle },
'&:hover fieldset': { borderColor: c.border.medium },
},
}}
/>
</Box>
{loading ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, py: 4 }}>
{[0, 1, 2].map((i) => (
<Skeleton key={i} variant="card" height={64} />
))}
</Box>
) : dashboards.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 10, color: c.text.muted }}>
<Typography sx={{ fontSize: '1.125rem', mb: 1 }}>
{search ? 'No dashboards match your search' : 'No dashboards yet'}
</Typography>
<Typography sx={{ fontSize: '0.875rem', color: c.text.tertiary }}>
{search ? 'Try a different search term' : 'Create your first dashboard to get started'}
</Typography>
</Box>
) : (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: 2.5,
}}
>
{dashboards.map((d) => (
<Box
key={d.id}
onClick={() => {
if (renamingId === d.id) return;
navigate(`/dashboard/${d.id}`);
}}
onContextMenu={(e) => handleContextMenu(e, d)}
sx={{
cursor: renamingId === d.id ? 'default' : 'pointer',
borderRadius: 3,
border: `1px solid ${c.border.subtle}`,
bgcolor: c.bg.surface,
overflow: 'hidden',
transition: 'all 0.2s ease',
// One elevation cue: the shadow fades in on hover, the card doesn't jump.
'&:hover': {
borderColor: c.border.strong,
boxShadow: c.shadow.md,
},
'&:hover .card-actions': { opacity: 1 },
display: 'flex',
flexDirection: 'column',
}}
>
<Box
sx={{
height: 120,
// Whisper of warmth at the top that fades into the card surface, no hard tint block, so preview and title read as one panel.
background: `radial-gradient(120% 90% at 50% 0%, ${c.accent.primary}1F 0%, transparent 62%)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
}}
>
{d.thumbnail ? (
<Box
component="img"
src={d.thumbnail}
alt={`${d.name} preview`}
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'top left',
}}
/>
) : null}
<Box
className="card-actions"
sx={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
gap: 0.5,
opacity: 0,
transition: 'opacity 0.15s',
}}
>
<Tooltip title="More actions">
<IconButton
size="small"
onClick={(e) => handleOpenMenu(e, d)}
sx={{
bgcolor: c.bg.surface,
color: c.text.muted,
boxShadow: c.shadow.sm,
'&:hover': { bgcolor: c.bg.elevated },
}}
>
<MoreVertIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
</Box>
</Box>
<Box sx={{ p: 2, flex: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{renamingId === d.id ? (
<TextField
autoFocus
size="small"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => handleRenameSubmit(d.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameSubmit(d.id);
if (e.key === 'Escape') setRenamingId(null);
}}
onClick={(e) => e.stopPropagation()}
sx={{
'& .MuiOutlinedInput-root': {
fontSize: '1rem',
fontWeight: 600,
},
}}
/>
) : (
<Typography
sx={{
fontSize: '1rem',
fontWeight: 600,
color: c.text.primary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{d.name}
</Typography>
)}
<Typography sx={{ fontSize: '0.75rem', color: c.text.ghost }}>
Updated {formatRelativeTime(d.updated_at)}
</Typography>
</Box>
</Box>
))}
</Box>
)}
</Box>
<Menu
anchorEl={menuAnchor}
anchorReference={menuPosition ? 'anchorPosition' : 'anchorEl'}
anchorPosition={menuPosition ?? undefined}
open={Boolean(menuAnchor) || Boolean(menuPosition)}
onClose={handleCloseMenu}
slotProps={{
paper: {
sx: {
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
boxShadow: c.shadow.lg,
minWidth: 160,
},
},
}}
>
<MenuItem onClick={handleStartRename}>
<ListItemIcon><EditIcon sx={{ fontSize: 18 }} /></ListItemIcon>
<ListItemText>Rename</ListItemText>
</MenuItem>
<MenuItem onClick={handleDuplicate}>
<ListItemIcon><ContentCopyIcon sx={{ fontSize: 18 }} /></ListItemIcon>
<ListItemText>Duplicate</ListItemText>
</MenuItem>
<MenuItem onClick={handleDelete} sx={{ color: c.status.error }}>
<ListItemIcon><DeleteOutlineIcon sx={{ fontSize: 18, color: c.status.error }} /></ListItemIcon>
<ListItemText>Delete</ListItemText>
</MenuItem>
</Menu>
</Box>
);
return null;
};
export default DashboardSelection;
export default DashboardAutoEnter;