[eric] desktop: sidebar retired for a macOS Spaces top strip; chat header pops above the card, model + elapsed dropped

This commit is contained in:
ciregenz
2026-07-27 19:25:26 -07:00
parent ffa91b8488
commit d42fd08d8d
3 changed files with 145 additions and 66 deletions
@@ -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 && (
<Box onMouseEnter={() => { 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 && <SpacesStrip />}
<Box sx={{ position: 'fixed', top: 3, left: 260, right: 0, height: 22, zIndex: 5, WebkitAppRegion: 'drag' }} />
{/* 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. */}
<Box
sx={{
@@ -931,7 +933,8 @@ const AppShell: React.FC = () => {
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).
@@ -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, any>): string {
const mcp = parseMcpToolName(toolName);
if (mcp.isMcp) {
@@ -321,7 +306,6 @@ const AgentCard: React.FC<Props> = ({
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<Props> = ({
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<Props> = ({
};
// 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<Props> = ({
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<Props> = ({
</Typography>
</Box>
)}
{/* Welcome chat has no status to show, so name the model instead, otherwise the header reads bare. */}
{session.is_welcome_draft && friendlyModelLabel && (
<Typography sx={{ fontSize: '0.75rem', fontWeight: 500, color: c.text.tertiary, whiteSpace: 'nowrap', flexShrink: 0 }}>
{friendlyModelLabel}
</Typography>
)}
{/* Calm, zero-click signal: the agent recalled or built up memory of
this site, so the user feels it getting smarter on its own. */}
<Fade in={session.memory_recalled || session.memory_learned} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
@@ -1154,12 +1134,6 @@ const AgentCard: React.FC<Props> = ({
}}
>
<Box sx={{ display: 'flex', gap: 1.5, minWidth: 0, overflow: 'hidden' }}>
<Typography variant="caption" sx={{ color: c.text.tertiary, whiteSpace: 'nowrap' }}>
{friendlyModelLabel}
</Typography>
<Typography variant="caption" sx={{ color: c.text.tertiary, whiteSpace: 'nowrap' }}>
<ElapsedTimer messages={session.messages} status={session.status} />
</Typography>
{session.cost_usd > 0 && hasApiKey && (
<Typography variant="caption" sx={{ color: c.accent.primary, whiteSpace: 'nowrap' }}>
${session.cost_usd.toFixed(4)}
@@ -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<number | null>(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 (
<>
<Box onMouseEnter={reveal} sx={{ position: 'fixed', top: 0, left: 0, right: 0, height: HOT_ZONE_PX, zIndex: 99998 }} />
<Box
onMouseEnter={reveal}
onMouseLeave={scheduleClose}
sx={{
position: 'fixed', top: 0, left: 0, right: 0, zIndex: 99999,
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1,
px: 2, py: 1.25,
background: 'rgba(24,22,28,0.42)',
backdropFilter: 'blur(26px) saturate(150%)',
WebkitBackdropFilter: 'blur(26px) saturate(150%)',
borderBottom: '1px solid rgba(255,255,255,0.08)',
transform: open ? 'translateY(0)' : 'translateY(-110%)',
transition: 'transform 220ms cubic-bezier(0.22,1,0.36,1)',
pointerEvents: open ? 'auto' : 'none',
}}
>
{list.map((d) => {
const active = d.id === activeId;
return (
<Box
key={d.id}
component="button"
onClick={() => { 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'}
</Box>
);
})}
<Box
component="button"
aria-label="New dashboard"
onClick={addSpace}
sx={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: '9px', cursor: 'pointer',
border: '1px dashed rgba(255,255,255,0.35)', background: 'transparent',
color: 'rgba(255,255,255,0.85)',
transition: 'background 130ms',
'&:hover': { background: 'rgba(255,255,255,0.14)' },
}}
>
<Plus size={16} />
</Box>
</Box>
</>
);
};
export default SpacesStrip;