mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-02 23:08:46 +02:00
[eric] shell: dead sidebar-era imports and consts swept; extractors typed unknown; minus glyphs
This commit is contained in:
@@ -1,40 +1,22 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, startTransition, useMemo } from 'react';
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import React, { useState, useEffect, useCallback, startTransition, useMemo } from 'react';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { openSettingsModal } from '@/shared/state/settingsSlice';
|
||||
import { getLastInteractedBrowser, getKeepAliveBrowserIds, setLastInteractedBrowser, clearLastInteractedBrowser } from '@/shared/browserFocus';
|
||||
import { getWebview } from '@/shared/browserRegistry';
|
||||
import { applyBrowserZoom } from '@/shared/browserZoom';
|
||||
import Box from '@mui/material/Box';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { VoiceDictationProvider } from '@/shared/voice/VoiceDictationContext';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import Button from '@mui/material/Button';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import InputBase from '@mui/material/InputBase';
|
||||
// One outlined icon language for the sidebar: thin monoline glyphs (not the filled Material clip-art) so the rail reads as designed, not assembled.
|
||||
import { LayoutDashboard } from 'lucide-react';
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import { Settings as LucideSettings } from 'lucide-react';
|
||||
import { ArrowLeft, ArrowRight, Plus, Clock, Search as SearchGlyph, X as CloseGlyph } from 'lucide-react';
|
||||
import ButtonBase from '@mui/material/ButtonBase';
|
||||
import { AnimatedPanelLeft } from './animatedIcons';
|
||||
import { Clock } from 'lucide-react';
|
||||
|
||||
const SEARCH_HOTKEY_LABEL = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? '⌘K' : 'Ctrl+K';
|
||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
// Settings modal lazy-loaded so its 2.3K LOC + Stripe/OAuth helpers don't ship on first paint.
|
||||
@@ -46,13 +28,11 @@ import { useLastDashboardId } from '@/shared/hooks/useLastDashboardId';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { hasModelConnected as selectHasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates';
|
||||
import { shallowEqual } from 'react-redux';
|
||||
import { fetchDashboards, createDashboard, renameDashboard, deleteDashboard, duplicateDashboard } from '@/shared/state/dashboardsSlice';
|
||||
import { Typewriter } from '@/app/components/feedback/Animated';
|
||||
import { fetchDashboards, createDashboard } from '@/shared/state/dashboardsSlice';
|
||||
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
|
||||
import { addBrowserCard, addBrowserTab, cycleBrowserTab, reopenLastClosed, addViewCard, selectFullscreenCardId, setTiledCard, clearTiledCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice';
|
||||
import { fetchOutputs, deleteOutput, updateOutput } from '@/shared/state/outputsSlice';
|
||||
import { removeViewCardCleanly } from '@/shared/viewTeardown';
|
||||
import { fetchOutputs } from '@/shared/state/outputsSlice';
|
||||
import { setInstalling } from '@/shared/state/updateSlice';
|
||||
import { findBrowserByWebContentsId } from '@/shared/browserRegistry';
|
||||
import { byPreviewRecency } from '@/shared/previewOrder';
|
||||
@@ -61,11 +41,6 @@ import SpacesStrip from '@/app/pages/Dashboard/desktop/SpacesStrip';
|
||||
import { washBackgroundUrl } from '@/shared/styles/washBackground';
|
||||
import { ErrorSlime } from '@/app/components/feedback/ErrorSlime';
|
||||
|
||||
const SIDEBAR_MIN = 160;
|
||||
const SIDEBAR_MAX = 400;
|
||||
// 260 matches Claude.ai's nav-sidebar width: roomy enough that names don't truncate.
|
||||
const SIDEBAR_DEFAULT = 260;
|
||||
const SIDEBAR_WIDTH_KEY = 'openswarm-sidebar-width';
|
||||
const UPDATE_DISMISS_KEY = 'openswarm-update-dismissed';
|
||||
|
||||
const AppShell: React.FC = () => {
|
||||
@@ -83,42 +58,9 @@ const AppShell: React.FC = () => {
|
||||
}, [navigateRaw]);
|
||||
const location = useLocation();
|
||||
// React Router (HashRouter) stores a monotonic index in history state. location re-renders on every nav, by which point window.history.state.idx is updated.
|
||||
const historyIdx = (window.history.state?.idx as number | undefined) ?? 0;
|
||||
const maxHistoryIdx = useRef(0);
|
||||
maxHistoryIdx.current = Math.max(maxHistoryIdx.current, historyIdx);
|
||||
const canGoBack = historyIdx > 0;
|
||||
const canGoForward = historyIdx < maxHistoryIdx.current;
|
||||
const [dashboardsExpanded, setDashboardsExpanded] = useState(true);
|
||||
const [appsExpanded, setAppsExpanded] = useState(true);
|
||||
// Desktop shell: the wallpaper canvas IS the home surface, so the sidebar starts docked away
|
||||
// (left-edge hover peeks it; the pin toggle brings it back full-time).
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
|
||||
const [renamingDashboardId, setRenamingDashboardId] = useState<string | null>(null);
|
||||
const [renamingAppId, setRenamingAppId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
// Arc-style delete: the row vanishes at once but the real delete waits behind an Undo toast.
|
||||
const [pendingDelete, setPendingDelete] = useState<{ id: string; name: string } | null>(null);
|
||||
const [rowMenu, setRowMenu] = useState<{ top: number; left: number; kind: 'dashboard' | 'app'; id: string; name: string } | null>(null);
|
||||
const pendingDeleteRef = useRef<{ id: string; name: string } | null>(null);
|
||||
const deleteTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Arc-style peek: hovering an app row (with intent, not a fly-by) floats a preview beside the sidebar.
|
||||
const [peek, setPeek] = useState<{ name: string; description: string; thumbnail: string | null; top: number } | null>(null);
|
||||
const peekTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Arc-style Space switching: a horizontal two-finger swipe over the sidebar flips dashboards. Kept OFF
|
||||
// the canvas (which owns horizontal pan) so the two never fight. accum + cooldown = one flip per swipe.
|
||||
const swipeAccumRef = useRef(0);
|
||||
const swipeCooldownRef = useRef(false);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(SIDEBAR_WIDTH_KEY);
|
||||
if (stored) {
|
||||
const w = Number(stored);
|
||||
if (w >= SIDEBAR_MIN && w <= SIDEBAR_MAX) return w;
|
||||
}
|
||||
} catch {}
|
||||
return SIDEBAR_DEFAULT;
|
||||
});
|
||||
const isResizing = useRef(false);
|
||||
|
||||
const updateStatus = useAppSelector((state) => state.update.status);
|
||||
const availableVersion = useAppSelector((state) => state.update.availableVersion);
|
||||
@@ -246,15 +188,6 @@ const AppShell: React.FC = () => {
|
||||
[dashboardItems],
|
||||
);
|
||||
|
||||
const outputItems = useAppSelector(
|
||||
(state) => state.outputs.items,
|
||||
shallowEqual,
|
||||
);
|
||||
const appsList = React.useMemo(
|
||||
() => Object.values(outputItems).sort(byPreviewRecency),
|
||||
[outputItems],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchDashboards());
|
||||
dispatch(fetchOutputs());
|
||||
@@ -408,10 +341,6 @@ const AppShell: React.FC = () => {
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(SIDEBAR_WIDTH_KEY, String(sidebarWidth)); } catch {}
|
||||
}, [sidebarWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail || {};
|
||||
@@ -426,33 +355,6 @@ const AppShell: React.FC = () => {
|
||||
return () => window.removeEventListener('openswarm:notification-click', handler as EventListener);
|
||||
}, [navigate, dispatch]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isResizing.current = true;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
if (!isResizing.current) return;
|
||||
setSidebarWidth(Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, ev.clientX)));
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
isResizing.current = false;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
}, []);
|
||||
|
||||
const handleResizeDoubleClick = useCallback(() => {
|
||||
setSidebarWidth(SIDEBAR_DEFAULT);
|
||||
}, []);
|
||||
|
||||
const isDashboardRoute = location.pathname === '/' || location.pathname.startsWith('/dashboard/');
|
||||
const isDashboardViewActive = location.pathname.startsWith('/dashboard/');
|
||||
// macOS full screen: a fullscreen-tiled card owns the window, so every shell chrome piece hides. Gated on the dashboard view so navigating away restores the chrome even mid-fullscreen.
|
||||
@@ -464,8 +366,6 @@ const AppShell: React.FC = () => {
|
||||
// unpinned fullscreen keeps the hover-peek overlay.
|
||||
const [fsSidebarPinned, setFsSidebarPinned] = useState(false);
|
||||
const sidebarAway = (sidebarCollapsed || (fsActive && !fsSidebarPinned)) && isDashboardViewActive;
|
||||
const [sidePeek, setSidePeek] = useState(false);
|
||||
useEffect(() => { if (!sidebarAway) setSidePeek(false); }, [sidebarAway]);
|
||||
// When the sidebar docks away, the canvas runs flush to the window's left edge, so the floating
|
||||
// dashboard header would sit right under the macOS traffic lights. Publish an inset the header reads
|
||||
// (only on macOS, where the lights exist) so it clears them; the sidebar carries its own clearance.
|
||||
@@ -491,42 +391,8 @@ const AppShell: React.FC = () => {
|
||||
useEffect(() => {
|
||||
window.dispatchEvent(new CustomEvent('openswarm:chrome-docked', { detail: { docked: !sidebarAway } }));
|
||||
}, [sidebarAway]);
|
||||
// Close-on-leave with a grace delay (cancelled on re-enter): a bare mouseLeave closed the peek the
|
||||
// instant the cursor dipped past the panel edge while reaching for an item, so clicks never landed.
|
||||
const peekCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const cancelPeekClose = useCallback(() => {
|
||||
if (peekCloseTimerRef.current) { clearTimeout(peekCloseTimerRef.current); peekCloseTimerRef.current = null; }
|
||||
}, []);
|
||||
const schedulePeekClose = useCallback(() => {
|
||||
cancelPeekClose();
|
||||
peekCloseTimerRef.current = setTimeout(() => setSidePeek(false), 260);
|
||||
}, [cancelPeekClose]);
|
||||
// Reliable window-level close: the panel's own mouseLeave can get eaten (webview/overlay capture),
|
||||
// leaving the peek stuck open. This fires on every move and closes ONLY when the cursor is clearly
|
||||
// to the right of the floating panel (generous 130px buffer so reaching for an item never closes it).
|
||||
useEffect(() => {
|
||||
if (!sidePeek) return undefined;
|
||||
const onMove = (e: MouseEvent): void => {
|
||||
if (e.clientX > 10 + sidebarWidth + 130) schedulePeekClose();
|
||||
else cancelPeekClose();
|
||||
};
|
||||
// A cursor gliding over a webview GUEST sends the host zero mousemoves, so the move-based close
|
||||
// above never fires and the peek looks stuck. Guest interactions bubble up as these app events;
|
||||
// any of them means the cursor left the panel. Window blur (app switch) likewise must not park it.
|
||||
const closeNow = (): void => setSidePeek(false);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('openswarm:browser-guest-select', closeNow);
|
||||
window.addEventListener('blur', closeNow);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('openswarm:browser-guest-select', closeNow);
|
||||
window.removeEventListener('blur', closeNow);
|
||||
};
|
||||
}, [sidePeek, sidebarWidth, schedulePeekClose, cancelPeekClose]);
|
||||
// Fullscreen still hides the top-center island anchor + banners; the sidebar floats in on peek.
|
||||
const fsHideChrome = fsActive;
|
||||
// In overlay mode the panel stays MOUNTED (so it can slide out, not vanish); sidePeek only drives the slide.
|
||||
const sideOverlay = sidebarAway;
|
||||
const isAppsRoute = false; // /apps route removed; app cards live on the dashboard now.
|
||||
const activeDashboardId = location.pathname.startsWith('/dashboard/')
|
||||
? location.pathname.split('/dashboard/')[1]
|
||||
@@ -542,18 +408,6 @@ const AppShell: React.FC = () => {
|
||||
if (next !== idx) navigate(`/dashboard/${dashboardList[next].id}`);
|
||||
}, [dashboardList, activeDashboardId, navigate]);
|
||||
|
||||
const handleSidebarSwipe = useCallback((e: React.WheelEvent) => {
|
||||
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) { swipeAccumRef.current = 0; return; } // vertical = list scroll
|
||||
if (swipeCooldownRef.current) return;
|
||||
swipeAccumRef.current += e.deltaX;
|
||||
if (Math.abs(swipeAccumRef.current) >= 80) {
|
||||
switchDashboard(swipeAccumRef.current > 0 ? 1 : -1);
|
||||
swipeAccumRef.current = 0;
|
||||
swipeCooldownRef.current = true;
|
||||
setTimeout(() => { swipeCooldownRef.current = false; }, 450);
|
||||
}
|
||||
}, [switchDashboard]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!(e.metaKey || e.ctrlKey) || !e.altKey || e.shiftKey) return;
|
||||
@@ -577,136 +431,7 @@ const AppShell: React.FC = () => {
|
||||
navigate(`/dashboard/${lastDashboardId}`);
|
||||
}
|
||||
}, [dispatch, navigate, lastDashboardId, location.pathname, fullscreenCardId]);
|
||||
// The real delete, run only once Undo has lapsed: tear the live view card down cleanly (never rip a
|
||||
// webview GPU surface mid-composite), then delete the output for good.
|
||||
const commitDeleteApp = useCallback((id: string) => {
|
||||
void removeViewCardCleanly(id, dispatch);
|
||||
dispatch(deleteOutput(id));
|
||||
}, [dispatch]);
|
||||
|
||||
// Arc-style delete: the row hides immediately and an Undo toast holds for 6s. Undo restores it;
|
||||
// silence commits it. A second delete flushes the first (only one pending at a time).
|
||||
const handleDeleteApp = useCallback((e: React.MouseEvent, id: string, name: string) => {
|
||||
e.stopPropagation();
|
||||
if (deleteTimerRef.current) clearTimeout(deleteTimerRef.current);
|
||||
const prev = pendingDeleteRef.current;
|
||||
if (prev && prev.id !== id) commitDeleteApp(prev.id);
|
||||
const entry = { id, name };
|
||||
pendingDeleteRef.current = entry;
|
||||
setPendingDelete(entry);
|
||||
deleteTimerRef.current = setTimeout(() => {
|
||||
commitDeleteApp(id);
|
||||
pendingDeleteRef.current = null;
|
||||
setPendingDelete(null);
|
||||
deleteTimerRef.current = null;
|
||||
}, 6000);
|
||||
}, [commitDeleteApp]);
|
||||
|
||||
const handleUndoDeleteApp = useCallback(() => {
|
||||
if (deleteTimerRef.current) { clearTimeout(deleteTimerRef.current); deleteTimerRef.current = null; }
|
||||
pendingDeleteRef.current = null;
|
||||
setPendingDelete(null);
|
||||
}, []);
|
||||
|
||||
// Leaving the app while a delete is still pending shouldn't strand a half-deleted ghost: commit it.
|
||||
useEffect(() => () => {
|
||||
if (deleteTimerRef.current) { clearTimeout(deleteTimerRef.current); }
|
||||
if (pendingDeleteRef.current) commitDeleteApp(pendingDeleteRef.current.id);
|
||||
if (peekTimerRef.current) clearTimeout(peekTimerRef.current);
|
||||
}, [commitDeleteApp]);
|
||||
|
||||
const handleStartAppRename = (id: string, currentName: string) => {
|
||||
setRenamingDashboardId(null);
|
||||
setRenamingAppId(id);
|
||||
setRenameValue(currentName);
|
||||
};
|
||||
|
||||
const handleAppRenameSubmit = (id: string) => {
|
||||
const trimmed = renameValue.trim();
|
||||
const previousName = outputItems[id]?.name;
|
||||
if (trimmed && trimmed !== previousName) {
|
||||
dispatch(updateOutput({ id, name: trimmed }));
|
||||
}
|
||||
setRenamingAppId(null);
|
||||
};
|
||||
// With the /apps route gone, an app row is "active" when its card is open on the dashboard, not from the URL.
|
||||
const openViewCardOutputIds = useAppSelector((s) =>
|
||||
new Set(Object.values(s.dashboardLayout.viewCards).map((vc) => vc.output_id)),
|
||||
);
|
||||
|
||||
const handleDashboardsClick = () => {
|
||||
if (isDashboardRoute && location.pathname === '/') {
|
||||
setDashboardsExpanded((prev) => !prev);
|
||||
} else {
|
||||
navigate('/');
|
||||
setDashboardsExpanded(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDashboardItemClick = (dashboardId: string) => {
|
||||
if (renamingDashboardId === dashboardId) return;
|
||||
// In fullscreen, clicking the dashboard you are already on means "show me the canvas": exit the
|
||||
// pinned view. A different dashboard exits via the resetLayout tile clear on switch.
|
||||
if (fullscreenCardId && activeDashboardId === dashboardId) {
|
||||
dispatch(clearTiledCard(fullscreenCardId));
|
||||
return;
|
||||
}
|
||||
navigate(`/dashboard/${dashboardId}`);
|
||||
};
|
||||
|
||||
const handleStartDashboardRename = (id: string, currentName: string) => {
|
||||
setRenamingAppId(null);
|
||||
setRenamingDashboardId(id);
|
||||
setRenameValue(currentName);
|
||||
};
|
||||
|
||||
// Right-click a sidebar row for the same actions the Dashboards grid offers, Mac-style at the cursor.
|
||||
const openRowMenu = (e: React.MouseEvent, kind: 'dashboard' | 'app', id: string, name: string): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setRowMenu({ top: e.clientY, left: e.clientX, kind, id, name });
|
||||
};
|
||||
const closeRowMenu = (): void => setRowMenu(null);
|
||||
const rowMenuRename = (): void => {
|
||||
const m = rowMenu;
|
||||
closeRowMenu();
|
||||
if (!m) return;
|
||||
// Defer so the menu's focus trap releases before the inline field autofocuses.
|
||||
setTimeout(() => (m.kind === 'dashboard' ? handleStartDashboardRename(m.id, m.name) : handleStartAppRename(m.id, m.name)), 150);
|
||||
};
|
||||
const rowMenuDelete = (e: React.MouseEvent): void => {
|
||||
const m = rowMenu;
|
||||
closeRowMenu();
|
||||
if (!m) return;
|
||||
if (m.kind === 'dashboard') dispatch(deleteDashboard(m.id));
|
||||
else handleDeleteApp(e, m.id, m.name);
|
||||
};
|
||||
const rowMenuDuplicate = (): void => {
|
||||
const m = rowMenu;
|
||||
closeRowMenu();
|
||||
if (m?.kind === 'dashboard') dispatch(duplicateDashboard(m.id));
|
||||
};
|
||||
|
||||
const handleDashboardRenameSubmit = (id: string) => {
|
||||
const trimmed = renameValue.trim();
|
||||
const previousName = dashboardItems[id]?.name;
|
||||
if (trimmed && trimmed !== previousName) {
|
||||
dispatch(renameDashboard({ id, name: trimmed, previousName }));
|
||||
}
|
||||
setRenamingDashboardId(null);
|
||||
};
|
||||
|
||||
const handleCreateDashboard = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const result = await dispatch(createDashboard('Untitled Dashboard'));
|
||||
if (createDashboard.fulfilled.match(result)) {
|
||||
navigate(`/dashboard/${result.payload.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAppsClick = () => {
|
||||
setAppsExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
@@ -1040,95 +765,7 @@ const AppShell: React.FC = () => {
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
{/* Arc-style peek: a live-thumbnail preview beside the sidebar while hovering an app row. Non-interactive so it never steals the hover. */}
|
||||
{peek && !sidebarCollapsed && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
left: sidebarWidth + 8,
|
||||
top: Math.min(Math.max(peek.top - 8, 12), window.innerHeight - 232),
|
||||
width: 264,
|
||||
zIndex: 1400,
|
||||
pointerEvents: 'none',
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
overflow: 'hidden',
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
boxShadow: c.shadow.lg,
|
||||
'@keyframes peekIn': { from: { opacity: 0, transform: 'translateX(-6px)' }, to: { opacity: 1, transform: 'none' } },
|
||||
animation: 'peekIn 0.14s ease-out',
|
||||
}}
|
||||
>
|
||||
{peek.thumbnail ? (
|
||||
<Box component="img" src={peek.thumbnail} alt="" sx={{ width: '100%', height: 150, objectFit: 'cover', objectPosition: 'top left', display: 'block' }} />
|
||||
) : (
|
||||
<Box sx={{ width: '100%', height: 92, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: `${c.text.tertiary}0A`, color: c.text.ghost, fontSize: '0.75rem' }}>
|
||||
No preview yet
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ p: 1.25 }}>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '0.875rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{peek.name}</Typography>
|
||||
{peek.description && (
|
||||
<Typography sx={{ fontSize: '0.75rem', color: c.text.muted, mt: 0.5, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{peek.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Right-click a dashboard or app row: the same actions as the Dashboards grid, at the cursor. */}
|
||||
<Menu
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={rowMenu ? { top: rowMenu.top, left: rowMenu.left } : undefined}
|
||||
open={!!rowMenu}
|
||||
onClose={closeRowMenu}
|
||||
slotProps={{ paper: { sx: { bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, boxShadow: c.shadow.lg, minWidth: 160 } } }}
|
||||
>
|
||||
<MenuItem onClick={rowMenuRename}>
|
||||
<ListItemIcon><EditIcon sx={{ fontSize: 18 }} /></ListItemIcon>
|
||||
<ListItemText>Rename</ListItemText>
|
||||
</MenuItem>
|
||||
{rowMenu?.kind === 'dashboard' && (
|
||||
<MenuItem onClick={rowMenuDuplicate}>
|
||||
<ListItemIcon><ContentCopyIcon sx={{ fontSize: 18 }} /></ListItemIcon>
|
||||
<ListItemText>Duplicate</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={rowMenuDelete} sx={{ color: c.status.error }}>
|
||||
<ListItemIcon><DeleteOutlineIcon sx={{ fontSize: 18, color: c.status.error }} /></ListItemIcon>
|
||||
<ListItemText>Delete</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* Arc-style Undo for a just-deleted app. Open while the delete is pending; our own 6s timer commits it and closes this. */}
|
||||
<Snackbar
|
||||
open={!!pendingDelete}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
>
|
||||
<Alert
|
||||
severity="info"
|
||||
icon={false}
|
||||
action={
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleUndoDeleteApp}
|
||||
sx={{ color: c.accent.primary, textTransform: 'none', fontWeight: 700, fontSize: '0.8125rem', minWidth: 'auto' }}
|
||||
>
|
||||
Undo
|
||||
</Button>
|
||||
}
|
||||
sx={{
|
||||
bgcolor: c.bg.surface,
|
||||
color: c.text.primary,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
boxShadow: c.shadow.md,
|
||||
'& .MuiAlert-action': { alignItems: 'center', pt: 0 },
|
||||
}}
|
||||
>
|
||||
{`Deleted "${pendingDelete?.name ?? ''}"`}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -76,13 +76,13 @@ export function isAskUiPair(pair: ToolPair): boolean {
|
||||
}
|
||||
|
||||
/** Latest ShowUI payload anywhere in a transcript; the collapsed card pins this artifact under its pill. */
|
||||
export function extractLatestShowUi(messages: Array<{ role: string; content: any }>): ShowUiPayload | null {
|
||||
export function extractLatestShowUi(messages: Array<{ role: string; content: unknown }>): ShowUiPayload | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.role !== 'tool_call') continue;
|
||||
const tool = typeof msg.content === 'object' ? String(msg.content?.tool || '') : '';
|
||||
if (!/(^|__)ShowUI$/.test(tool)) continue;
|
||||
const parsed = parseShowUiInput(msg.content?.input);
|
||||
const body = (typeof msg.content === 'object' && msg.content !== null ? msg.content : {}) as { tool?: unknown; input?: unknown };
|
||||
if (!/(^|__)ShowUI$/.test(String(body.tool || ''))) continue;
|
||||
const parsed = parseShowUiInput(body.input);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -14,7 +14,7 @@ interface WindowControlsProps {
|
||||
|
||||
// macOS-style traffic lights on every card = the "AI OS" window feel. Grey at rest so a canvas
|
||||
// full of cards isn't a wall of color; they colorize when the parent .osw-card is hovered, and the
|
||||
// × – + symbols reveal on hovering the group. Hovering the GREEN dot opens the tiling menu (Fill,
|
||||
// close/minus/plus symbols reveal on hovering the group. Hovering the GREEN dot opens the tiling menu (Fill,
|
||||
// Halves, Quarters, Thirds), exactly like macOS; clicking green = Full Screen (or restore if tiled).
|
||||
const RED = '#ff5f57';
|
||||
const YELLOW = '#febc2e';
|
||||
@@ -88,13 +88,13 @@ function WindowControls({ onClose, onMinimize, onTile, tiled, noTileMenu }: Wind
|
||||
pointerEvents: 'none', '.osw-card:hover &': { pointerEvents: 'auto' },
|
||||
}}>
|
||||
{btn(RED, '×', onClose, 'Close')}
|
||||
{btn(YELLOW, '–', onMinimize, 'Minimize')}
|
||||
{btn(YELLOW, '−', onMinimize, 'Minimize')}
|
||||
<Box ref={greenRef} sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}
|
||||
onMouseEnter={openMenu} onMouseLeave={scheduleClose}>
|
||||
<Box component="button" type="button" aria-label={tiled ? 'Exit Full Screen' : 'Full Screen'}
|
||||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); onTile(tiled ? 'restore' : 'fullscreen'); }}
|
||||
onPointerDown={stop} sx={dotSx(GREEN)}>
|
||||
<span>{tiled ? '–' : '+'}</span>
|
||||
<span>{tiled ? '−' : '+'}</span>
|
||||
</Box>
|
||||
{menuHot && <Box className="osw-tilemenu" onPointerDown={stop} onClick={stop}
|
||||
onMouseEnter={openMenu} onMouseLeave={scheduleClose}
|
||||
|
||||
@@ -8,19 +8,20 @@ function isTodoStatus(v: unknown): v is AgentTodoItem['status'] {
|
||||
}
|
||||
|
||||
/** Latest TodoWrite payload in the transcript = the agent's live plan; null when it never wrote one. */
|
||||
export function extractLatestTodos(messages: Array<{ role: string; content: any }>): AgentTodoItem[] | null {
|
||||
export function extractLatestTodos(messages: Array<{ role: string; content: unknown }>): AgentTodoItem[] | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.role !== 'tool_call') continue;
|
||||
const tool = typeof msg.content === 'object' ? String(msg.content?.tool || '') : '';
|
||||
if (!/todowrite$/i.test(tool)) continue;
|
||||
const raw = msg.content?.input?.todos;
|
||||
const body = (typeof msg.content === 'object' && msg.content !== null ? msg.content : {}) as { tool?: unknown; input?: unknown };
|
||||
if (!/todowrite$/i.test(String(body.tool || ''))) continue;
|
||||
const raw = (typeof body.input === 'object' && body.input !== null ? (body.input as { todos?: unknown }).todos : undefined);
|
||||
if (!Array.isArray(raw)) continue;
|
||||
const items: AgentTodoItem[] = [];
|
||||
for (const t of raw) {
|
||||
const content = typeof t?.content === 'string' ? t.content : (typeof t?.activeForm === 'string' ? t.activeForm : '');
|
||||
for (const entry of raw) {
|
||||
const t = (typeof entry === 'object' && entry !== null ? entry : {}) as { content?: unknown; activeForm?: unknown; status?: unknown };
|
||||
const content = typeof t.content === 'string' ? t.content : (typeof t.activeForm === 'string' ? t.activeForm : '');
|
||||
if (!content) continue;
|
||||
items.push({ content, status: isTodoStatus(t?.status) ? t.status : 'pending' });
|
||||
items.push({ content, status: isTodoStatus(t.status) ? t.status : 'pending' });
|
||||
}
|
||||
if (items.length > 0) return items;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user