mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-07 02:07:45 +02:00
[eric] shell: the dashboards picker page dies, / auto-enters the latest dashboard and self-heals when the backend answers
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user