From d42fd08d8dbb83a1c14e81613d5ed27bd2307c71 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 27 Jul 2026 19:25:26 -0700 Subject: [PATCH] [eric] desktop: sidebar retired for a macOS Spaces top strip; chat header pops above the card, model + elapsed dropped --- .../src/app/components/Layout/AppShell.tsx | 11 +- .../app/pages/Dashboard/cards/AgentCard.tsx | 98 +++++++---------- .../pages/Dashboard/desktop/SpacesStrip.tsx | 102 ++++++++++++++++++ 3 files changed, 145 insertions(+), 66 deletions(-) create mode 100644 frontend/src/app/pages/Dashboard/desktop/SpacesStrip.tsx diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 3939ebd3..d03e45c5 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -57,6 +57,7 @@ import { setInstalling } from '@/shared/state/updateSlice'; import { findBrowserByWebContentsId } from '@/shared/browserRegistry'; import { byPreviewRecency } from '@/shared/previewOrder'; import { useClaudeTokens, useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext'; +import SpacesStrip from '@/app/pages/Dashboard/desktop/SpacesStrip'; import { ErrorSlime } from '@/app/components/feedback/ErrorSlime'; const SIDEBAR_MIN = 160; @@ -711,9 +712,10 @@ const AppShell: React.FC = () => { display: 'flex', flexDirection: 'column', height: '100vh', bgcolor: c.bg.secondary, ...(fsActive && fsWashStops ? { backgroundImage: `linear-gradient(115deg, ${fsWashStops.map((hex, i) => `${hex}${Math.round(themeWashOpacity * 255).toString(16).padStart(2, '0')} ${fsWashStops.length > 1 ? (i / (fsWashStops.length - 1)) * 100 : 100}%`).join(', ')})` } : {}), }}> - {sidebarAway && !sidePeek && !v3FlowActive && ( - { cancelPeekClose(); setSidePeek(true); }} sx={{ position: 'fixed', top: 0, left: 0, bottom: 0, width: 14, zIndex: 2147483000, pointerEvents: 'auto' }} /> - )} + {/* Sidebar retired: dashboards switch via the macOS-Spaces top strip; a slim band below the + spaces hot zone keeps the frameless window draggable (the sidebar's drag strip is gone). */} + {isDashboardViewActive && !v3FlowActive && } + {/* Top bar dropped (Arc/Zen): a zero-height anchor left only to float the agent-activity island at top-center; the island renders nothing when idle. */} { width: sidebarWidth, flexShrink: 0, bgcolor: c.bg.secondary, - display: 'flex', + // RETIRED: the Spaces strip owns dashboard switching now; excising the full sidebar tree is a follow-up. + display: 'none', flexDirection: 'column', // Zen/Arc compact mode: a detached, rounded panel that SLIDES in and out from the left edge // (sidePeek drives the transform both ways; it stays mounted so leaving glides it away, not vanish). diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 96433575..e66e8383 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -52,7 +52,6 @@ import { useStreamingMessage } from '@/shared/state/streamingSlice'; import { isCanvasInteractionActive, onCanvasInteractionEnd } from '@/shared/canvasInteractionState'; import { setCardSidecar } from '@/shared/state/workflowsSlice'; import { openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice'; -import { getAgentWorkTime, fmtSeconds } from '@/shared/agentWorkTime'; import { friendlyStatusLabel } from '@/shared/statusLabel'; /** Extract up to 3 substantive user-prompt steps to seed a workflow. */ @@ -178,20 +177,6 @@ const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ servi return null; }; -/** Self-ticking elapsed-time leaf; owns its 1Hz interval so AgentCard doesn't re-render every second. */ -const ElapsedTimer: React.FC<{ - messages: Array<{ role: string; timestamp: string; elapsed_ms?: number; hidden?: boolean }>; - status: string; -}> = React.memo(({ messages, status }) => { - const [, setTick] = React.useState(0); - React.useEffect(() => { - if (status !== 'running' && status !== 'waiting_approval') return; - const id = setInterval(() => setTick((t) => (t + 1) & 0xffff), 1000); - return () => clearInterval(id); - }, [status]); - return <>{fmtSeconds(getAgentWorkTime(messages, status).last)}; -}); - function summarizeToolInput(toolName: string, toolInput: Record): string { const mcp = parseMcpToolName(toolName); if (mcp.isMcp) { @@ -321,7 +306,6 @@ const AgentCard: React.FC = ({ const dispatch = useAppDispatch(); const isDashboardActive = useDashboardActive(); const hasApiKey = !!useAppSelector((s) => s.settings.data.anthropic_api_key); - const modelsByProvider = useAppSelector((s) => s.models.byProvider); const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds); const workflowSuggestion = useMemo(() => findWorkflowSuggestion(session), [session]); // Suppress the convert-suggestion glow when this chat is already entangled with a workflow. Two cases: (a) The session is one of a workflow's runner sessions, OR (b) The session is the source the workflow was originally derived from. Either way a fresh convert would just clone the workflow, which is confusing identity collapse. @@ -363,20 +347,6 @@ const AgentCard: React.FC = ({ hasUserPrompt && (messageCount >= 2 || isConvertBlockedByTurn || !!workflowSuggestion); const canConvertToWorkflow = showConvertToWorkflow && !isConvertBlockedByTurn; - // Curated picker label with a tidy fallback for unknowns. - const friendlyModelLabel = useMemo(() => { - const value = session.model; - if (!value) return ''; - for (const models of Object.values(modelsByProvider)) { - for (const m of models as any[]) { - if (m.value === value) return m.label; - } - } - let s = String(value); - if (s.startsWith('or:')) s = s.slice(3); - if (s.includes('/')) s = s.split('/').pop() || s; - return s; - }, [session.model, modelsByProvider]); const scrollOverlayRef = useOverlayScrollPassthrough(isSelected && !expanded); const suggestionPulseRef = useRef(''); @@ -687,8 +657,6 @@ const AgentCard: React.FC = ({ }; - // ElapsedTimer owns its own 1Hz tick so AgentCard doesn't re-render every second. - const lastMessage = session.messages[session.messages.length - 1]; // Subscribe to this card's own streaming entry so per-character mutations don't churn other cards. const streamingMessage = useStreamingMessage(session.id); @@ -1026,24 +994,42 @@ const AgentCard: React.FC = ({ onPointerUp={handleDragPointerUp} sx={{ ...(expanded - ? { - position: 'absolute', - top: 0, - left: 0, - right: 0, - zIndex: 17, - px: 2, - pt: 1.5, - pb: 2, - opacity: 0, - transition: 'opacity 0.15s ease', - '&:hover': { opacity: 1 }, - background: 'linear-gradient(to bottom, rgba(20,12,28,0.92) 0%, rgba(20,12,28,0.65) 60%, rgba(20,12,28,0) 100%)', - borderRadius: '20px 20px 0 0', - // Header text must read over the dark scrim regardless of app theme. - '& .MuiTypography-root': { color: 'rgba(255,255,255,0.92)' }, - '& input': { color: 'rgba(255,255,255,0.92)' }, - } + ? tiledStyle + ? { + // Fullscreen/tiled: no room above the card, keep the inside hover scrim. + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 17, + px: 2, + pt: 1.5, + pb: 2, + opacity: 0, + transition: 'opacity 0.15s ease', + '&:hover': { opacity: 1 }, + background: 'linear-gradient(to bottom, rgba(20,12,28,0.92) 0%, rgba(20,12,28,0.65) 60%, rgba(20,12,28,0) 100%)', + borderRadius: '12px 12px 0 0', + // Header text must read over the dark scrim regardless of app theme. + '& .MuiTypography-root': { color: 'rgba(255,255,255,0.92)' }, + '& input': { color: 'rgba(255,255,255,0.92)' }, + } + : { + // On the canvas the title + lights pop up ABOVE the card, same as the minimized pill. + position: 'absolute', + bottom: '100%', + top: 'auto', + left: 0, + right: 0, + zIndex: 17, + px: 0.25, + pb: 0.75, + opacity: 0, + pointerEvents: 'none', + transition: 'opacity 0.15s ease, transform 0.15s ease', + transform: 'translateY(4px)', + '.osw-card:hover &': { opacity: 1, pointerEvents: 'auto', transform: 'none' }, + } : { position: 'relative', zIndex: 16, @@ -1110,12 +1096,6 @@ const AgentCard: React.FC = ({ )} - {/* Welcome chat has no status to show, so name the model instead, otherwise the header reads bare. */} - {session.is_welcome_draft && friendlyModelLabel && ( - - {friendlyModelLabel} - - )} {/* Calm, zero-click signal: the agent recalled or built up memory of this site, so the user feels it getting smarter on its own. */} @@ -1154,12 +1134,6 @@ const AgentCard: React.FC = ({ }} > - - {friendlyModelLabel} - - - - {session.cost_usd > 0 && hasApiKey && ( ${session.cost_usd.toFixed(4)} diff --git a/frontend/src/app/pages/Dashboard/desktop/SpacesStrip.tsx b/frontend/src/app/pages/Dashboard/desktop/SpacesStrip.tsx new file mode 100644 index 00000000..89f4203c --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/SpacesStrip.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import { Plus } from 'lucide-react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { createDashboard } from '@/shared/state/dashboardsSlice'; + +// macOS Spaces, one for one: rest the cursor on the very top edge and a translucent bar of +// dashboard "spaces" slides down (Mission Control's spaces row), click switches, + adds one. +// This replaces the sidebar as the dashboard switcher; tools live in the dock, search on Cmd+K. +const HOT_ZONE_PX = 3; +const CLOSE_DELAY_MS = 280; + +const SpacesStrip: React.FC = () => { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const location = useLocation(); + const dashboards = useAppSelector((s) => s.dashboards.items); + const [open, setOpen] = React.useState(false); + const closeTimer = React.useRef(null); + + const activeId = React.useMemo(() => { + const m = location.pathname.match(/\/dashboard\/([^/]+)/); + return m ? m[1] : null; + }, [location.pathname]); + + const list = React.useMemo( + () => Object.values(dashboards).sort((a, b) => (a.created_at < b.created_at ? -1 : 1)), + [dashboards], + ); + + const hold = (): void => { if (closeTimer.current) window.clearTimeout(closeTimer.current); }; + const reveal = (): void => { hold(); setOpen(true); }; + const scheduleClose = (): void => { hold(); closeTimer.current = window.setTimeout(() => setOpen(false), CLOSE_DELAY_MS); }; + + const addSpace = (): void => { + void dispatch(createDashboard('Untitled Dashboard')).then((result) => { + if (createDashboard.fulfilled.match(result)) navigate(`/dashboard/${(result.payload as { id: string }).id}`); + }); + }; + + return ( + <> + + + {list.map((d) => { + const active = d.id === activeId; + return ( + { navigate(`/dashboard/${d.id}`); setOpen(false); }} + sx={{ + px: 2, py: 0.8, borderRadius: '9px', cursor: 'pointer', + border: active ? '2px solid rgba(255,255,255,0.85)' : '1px solid rgba(255,255,255,0.18)', + background: active ? 'rgba(255,255,255,0.16)' : 'rgba(255,255,255,0.07)', + color: 'rgba(255,255,255,0.92)', fontFamily: 'inherit', fontSize: '0.8125rem', fontWeight: active ? 600 : 500, + whiteSpace: 'nowrap', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', + transition: 'background 130ms, border-color 130ms', + '&:hover': { background: 'rgba(255,255,255,0.18)' }, + }} + > + {d.name || 'Untitled'} + + ); + })} + + + + + + ); +}; + +export default SpacesStrip;