[eric] browser: macOS-window chrome (traffic lights, fixed light strip, centered URL) + minimize to a right-edge thumbnail stack

This commit is contained in:
ciregenz
2026-07-19 21:08:51 -07:00
parent 80a164c77c
commit 121dacd679
4 changed files with 203 additions and 35 deletions
@@ -9,6 +9,7 @@ import DashboardOverlays from './DashboardOverlays';
import DashboardEmptyState from './DashboardEmptyState';
import DesktopWallpaper from '../desktop/DesktopWallpaper';
import DesktopDock from '../desktop/DesktopDock';
import MinimizedStack from '../desktop/MinimizedStack';
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
import { useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext';
import { GRAIN_URL } from '@/shared/styles/grainTexture';
@@ -230,6 +231,16 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
</Box>
</Box>
{!fullscreenCardId && (
<MinimizedStack
browserCards={browserCards}
onRestore={(cardId, rect) => {
canvas.actions.fitToCards([rect], 1.15, true);
onHighlightCard?.(cardId);
}}
/>
)}
{!fullscreenCardId && (
<DesktopDock
sessions={sessions}
@@ -37,8 +37,10 @@ import {
reorderBrowserTab,
moveBrowserTab,
recordClosedCard,
toggleMinimizeCard,
type BrowserTab,
} from '@/shared/state/dashboardLayoutSlice';
import { saveMinimizedShot } from '../desktop/minimizedShots';
import { removeBrowserCardCleanly } from '@/shared/browserTeardown';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
@@ -58,6 +60,26 @@ import { getActionLabel } from '@/shared/browserCommandHandler';
import { resolveInput, isGoogleSearch } from '@/shared/resolveUrl';
import BrowserAgentOverlay from './BrowserAgentOverlay';
import { useOverlayScrollPassthrough } from '../hooks/interaction/useOverlayScrollPassthrough';
// Fixed light chrome for the macOS-window look; deliberately theme-independent, like a real browser window.
const CHROME_BG = '#f2eff5';
const CHROME_SURFACE = '#ffffff';
const CHROME_PAGE = '#faf9fc';
const CHROME_BORDER = 'rgba(0,0,0,0.08)';
const CHROME_TEXT = '#3c3744';
const CHROME_TEXT_MUTED = '#8a8494';
const browserLightSx = (color: string): Record<string, unknown> => ({
width: 12,
height: 12,
p: 0,
borderRadius: '50%',
border: '0.5px solid rgba(0,0,0,0.08)',
background: '#d6d3cd',
cursor: 'pointer',
transition: 'background 150ms',
'.osw-card:hover &': { background: color },
});
import { useElementSelection } from '@/app/components/editor/ElementSelectionContext';
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
@@ -206,6 +228,7 @@ const BrowserCard: React.FC<Props> = ({
[browserId],
);
const browserAgentSession = useAppSelector(selectBrowserAgentSession);
const isMinimized = useAppSelector((s) => Boolean(s.dashboardLayout.minimizedCards[browserId]));
const suspendedSnap = useAppSelector((state) => state.dashboardLayout.suspendedBrowserCards[browserId]);
const endingState = useAppSelector((state) => state.dashboardLayout.endingBrowserCards[browserId]);
@@ -496,6 +519,22 @@ const BrowserCard: React.FC<Props> = ({
dispatch(addBrowserTab({ browserId, url: browserHomepage }));
}, [dispatch, browserId, browserHomepage]);
// Yellow light: snapshot the live page first so the right-edge stack shows a real thumbnail,
// then park the card (webContents stays mounted, same as the keep-alive off-screen park).
const handleMinimize = useCallback(() => {
const wv = webviewMap.current.get(activeTabId);
const capture = wv?.capturePage?.();
const park = (): void => { dispatch(toggleMinimizeCard({ cardId: browserId })); };
if (capture && typeof (capture as Promise<unknown>).then === 'function') {
(capture as Promise<{ toDataURL(): string }>)
.then((img) => { saveMinimizedShot(browserId, img.toDataURL()); })
.catch(() => undefined)
.finally(park);
} else {
park();
}
}, [dispatch, browserId, activeTabId]);
const handleCloseTab = useCallback((tabId: string, e: React.MouseEvent) => {
e.stopPropagation();
// Closing the last tab destroys the whole card, so record it as a browser-card close (reopen brings the card back), not a tab close.
@@ -819,11 +858,12 @@ const BrowserCard: React.FC<Props> = ({
return (
<Box
className="osw-card"
data-select-type="browser-card"
data-select-id={browserId}
data-select-meta={JSON.stringify({ name: activeTitle || 'Browser', url: activeUrl })}
// Marks a kept-alive card parked off-screen (it belongs to another dashboard); fit-to-view must skip it or it pans the canvas to chase it and the card bleeds onto the dashboard you're viewing.
data-keepalive-hidden={keepAliveHidden ? '1' : undefined}
data-keepalive-hidden={keepAliveHidden || isMinimized ? '1' : undefined}
onPointerDownCapture={(e: React.PointerEvent) => {
onBringToFront?.(browserId, 'browser');
// Capture-phase so chrome clicks (tab strip, URL bar) the children swallow still select the card; clicks inside the guest page never reach the host at all. Shift keeps the bubbled toggle path.
@@ -840,12 +880,12 @@ const BrowserCard: React.FC<Props> = ({
sx={{
position: 'absolute',
// Kept-alive card from another dashboard: parked far off-screen so its webview surface can't bleed onto the dashboard you're viewing; click-through, webContents stays mounted.
pointerEvents: keepAliveHidden ? 'none' : undefined,
pointerEvents: keepAliveHidden || isMinimized ? 'none' : undefined,
// contain: webview repaints don't shake neighbor cards.
contain: 'layout style',
// Own compositor layer so hover/paint invalidations stay contained to this card. See AgentCard for full rationale.
willChange: 'transform',
left: keepAliveHidden ? -100000 : displayX,
left: keepAliveHidden || isMinimized ? -100000 : displayX,
top: displayY,
width: displayW,
height: displayH,
@@ -893,8 +933,9 @@ const BrowserCard: React.FC<Props> = ({
zIndex: 16,
display: 'flex',
alignItems: 'stretch',
bgcolor: agentActive ? `${accentColor}0a` : c.bg.secondary,
borderBottom: `1px solid ${agentActive ? `${accentColor}30` : c.border.subtle}`,
// Real-browser-window chrome stays light in both app themes, like the window it imitates.
bgcolor: agentActive ? `${accentColor}14` : CHROME_BG,
borderBottom: `1px solid ${agentActive ? `${accentColor}30` : CHROME_BORDER}`,
cursor: isDragging ? 'grabbing' : 'grab',
flexShrink: 0,
minHeight: 34,
@@ -903,6 +944,25 @@ const BrowserCard: React.FC<Props> = ({
overflow: 'hidden',
}}
>
<Box
onPointerDown={(e: React.PointerEvent) => e.stopPropagation()}
sx={{ display: 'flex', alignItems: 'center', gap: '7px', pl: 1.25, pr: 0.75, flexShrink: 0 }}
>
<Box
component="button"
type="button"
aria-label="Close browser"
onClick={handleRemove}
sx={{ ...browserLightSx('#ff5f57'), }}
/>
<Box
component="button"
type="button"
aria-label="Minimize browser"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleMinimize(); }}
sx={{ ...browserLightSx('#febc2e'), }}
/>
</Box>
<Box
sx={{
display: 'flex',
@@ -936,13 +996,13 @@ const BrowserCard: React.FC<Props> = ({
maxWidth: 180,
flex: '0 1 180px',
position: 'relative',
borderRight: `1px solid ${c.border.subtle}`,
bgcolor: isActive ? c.bg.surface : 'transparent',
borderRight: `1px solid ${CHROME_BORDER}`,
bgcolor: isActive ? CHROME_SURFACE : 'transparent',
cursor: isBeingDragged ? 'grabbing' : 'pointer',
transform: isBeingDragged ? `translateX(${dragTabOffset}px)` : 'none',
transition: isBeingDragged ? 'none' : 'background 0.15s ease, transform 0.2s ease',
zIndex: isBeingDragged ? 10 : 1,
'&:hover': { bgcolor: isActive ? c.bg.surface : c.bg.secondary },
'&:hover': { bgcolor: isActive ? CHROME_SURFACE : 'rgba(0,0,0,0.04)' },
'&:hover .tab-close': { opacity: 1 },
...(isActive && {
'&::after': {
@@ -968,7 +1028,7 @@ const BrowserCard: React.FC<Props> = ({
onError={(e: any) => { e.target.style.display = 'none'; }}
/>
) : (
<LanguageIcon sx={{ fontSize: 13, color: isActive ? accentColor : c.text.ghost }} />
<LanguageIcon sx={{ fontSize: 13, color: isActive ? accentColor : CHROME_TEXT_MUTED }} />
)}
</Box>
@@ -977,7 +1037,7 @@ const BrowserCard: React.FC<Props> = ({
flex: 1,
fontSize: '0.7rem',
fontWeight: isActive ? 600 : 400,
color: isActive ? c.text.primary : c.text.muted,
color: isActive ? CHROME_TEXT : CHROME_TEXT_MUTED,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
@@ -1003,10 +1063,10 @@ const BrowserCard: React.FC<Props> = ({
opacity: isActive ? 0.6 : 0,
cursor: 'pointer',
transition: 'opacity 0.15s, background 0.15s',
'&:hover': { bgcolor: `${c.text.muted}25`, opacity: 1 },
'&:hover': { bgcolor: 'rgba(0,0,0,0.09)', opacity: 1 },
}}
>
<CloseIcon sx={{ fontSize: 10, color: c.text.muted }} />
<CloseIcon sx={{ fontSize: 10, color: CHROME_TEXT_MUTED }} />
</Box>
</Box>
);
@@ -1026,10 +1086,10 @@ const BrowserCard: React.FC<Props> = ({
mx: 0.25,
my: 0.5,
transition: 'background 0.15s',
'&:hover': { bgcolor: `${c.text.muted}15` },
'&:hover': { bgcolor: 'rgba(0,0,0,0.06)' },
}}
>
<AddIcon sx={{ fontSize: 15, color: c.text.muted }} />
<AddIcon sx={{ fontSize: 15, color: CHROME_TEXT_MUTED }} />
</Box>
</Box>
@@ -1072,16 +1132,6 @@ const BrowserCard: React.FC<Props> = ({
</Box>
)}
<Tooltip title="Close browser" placement="top">
<IconButton
size="small"
onClick={handleRemove}
onPointerDown={(e) => e.stopPropagation()}
sx={{ color: c.text.ghost, p: 0.4, '&:hover': { color: c.status.error } }}
>
<CloseIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
</Box>
</Box>
@@ -1093,8 +1143,8 @@ const BrowserCard: React.FC<Props> = ({
gap: 0.25,
px: 0.5,
py: 0.25,
bgcolor: c.bg.page,
borderBottom: `1px solid ${c.border.subtle}`,
bgcolor: CHROME_PAGE,
borderBottom: `1px solid ${CHROME_BORDER}`,
flexShrink: 0,
}}
>
@@ -1105,7 +1155,7 @@ const BrowserCard: React.FC<Props> = ({
onClick={handleBack}
onPointerDown={(e) => e.stopPropagation()}
disabled={!activeLocal.canGoBack}
sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }}
sx={{ color: CHROME_TEXT_MUTED, p: 0.4, '&:hover': { color: CHROME_TEXT } }}
>
<ArrowBackIcon sx={{ fontSize: 15 }} />
</IconButton>
@@ -1119,7 +1169,7 @@ const BrowserCard: React.FC<Props> = ({
onClick={handleForward}
onPointerDown={(e) => e.stopPropagation()}
disabled={!activeLocal.canGoForward}
sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }}
sx={{ color: CHROME_TEXT_MUTED, p: 0.4, '&:hover': { color: CHROME_TEXT } }}
>
<ArrowForwardIcon sx={{ fontSize: 15 }} />
</IconButton>
@@ -1131,7 +1181,7 @@ const BrowserCard: React.FC<Props> = ({
size="small"
onClick={handleRefresh}
onPointerDown={(e) => e.stopPropagation()}
sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }}
sx={{ color: CHROME_TEXT_MUTED, p: 0.4, '&:hover': { color: CHROME_TEXT } }}
>
<RefreshIcon sx={{ fontSize: 15 }} />
</IconButton>
@@ -1147,13 +1197,13 @@ const BrowserCard: React.FC<Props> = ({
ml: 0.5,
px: 1,
py: 0.2,
bgcolor: c.bg.secondary,
bgcolor: '#eceaf1',
borderRadius: `${c.radius.md}px`,
border: `1px solid ${c.border.subtle}`,
border: `1px solid ${CHROME_BORDER}`,
}}
>
{isSearch ? (
<SearchIcon sx={{ fontSize: 13, color: c.text.muted, flexShrink: 0 }} />
<SearchIcon sx={{ fontSize: 13, color: CHROME_TEXT_MUTED, flexShrink: 0 }} />
) : isSecure ? (
<LockIcon sx={{ fontSize: 12, color: c.status.success, flexShrink: 0 }} />
) : null}
@@ -1169,10 +1219,10 @@ const BrowserCard: React.FC<Props> = ({
flex: 1,
fontSize: '0.74rem',
fontFamily: c.font.mono,
color: c.text.secondary,
color: CHROME_TEXT,
py: 0,
'& input': { py: '2px' },
'& input::placeholder': { color: c.text.ghost, opacity: 1 },
'& input': { py: '2px', textAlign: 'center' },
'& input::placeholder': { color: CHROME_TEXT_MUTED, opacity: 1 },
}}
/>
</Box>
@@ -0,0 +1,88 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import LanguageIcon from '@mui/icons-material/Language';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { toggleMinimizeCard } from '@/shared/state/dashboardLayoutSlice';
import { getMinimizedShot, dropMinimizedShot } from './minimizedShots';
import type { BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice';
interface CardRect {
x: number;
y: number;
width: number;
height: number;
}
interface MinimizedStackProps {
browserCards: Record<string, BrowserCardPosition>;
onRestore: (id: string, rect: CardRect) => void;
}
const THUMB_W = 96;
/** Right-edge stack of minimized browser windows; click restores the card where it was. */
function MinimizedStack({ browserCards, onRestore }: MinimizedStackProps): React.ReactElement | null {
const dispatch = useAppDispatch();
const minimized = useAppSelector((s) => s.dashboardLayout.minimizedCards);
const entries = Object.values(browserCards).filter((bc) => minimized[bc.browser_id]);
if (entries.length === 0) return null;
return (
<Box
sx={{
position: 'absolute',
right: 14,
top: 120,
zIndex: 11,
display: 'flex',
flexDirection: 'column',
gap: 1.5,
alignItems: 'flex-end',
}}
>
{entries.map((bc) => {
const activeTab = bc.tabs.find((t) => t.id === bc.activeTabId) || bc.tabs[0];
const shot = getMinimizedShot(bc.browser_id);
return (
<Box
key={bc.browser_id}
onClick={() => {
dropMinimizedShot(bc.browser_id);
dispatch(toggleMinimizeCard({ cardId: bc.browser_id }));
onRestore(bc.browser_id, bc);
}}
title={activeTab?.title || 'Browser'}
sx={{
width: THUMB_W,
borderRadius: '8px',
overflow: 'hidden',
cursor: 'pointer',
boxShadow: '0 6px 20px rgba(0,0,0,0.3)',
background: '#fff',
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
'&:hover': { transform: 'scale(1.06)', boxShadow: '0 10px 28px rgba(0,0,0,0.4)' },
}}
>
{shot ? (
<Box component="img" src={shot} alt="" sx={{ width: '100%', display: 'block' }} />
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5, py: 1.5, px: 1 }}>
{activeTab?.favicon ? (
<Box component="img" src={activeTab.favicon} alt="" sx={{ width: 20, height: 20, borderRadius: '4px' }} />
) : (
<LanguageIcon sx={{ fontSize: 20, color: '#8a8494' }} />
)}
<Typography sx={{ fontSize: '0.62rem', color: '#3c3744', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '100%' }}>
{activeTab?.title || 'Browser'}
</Typography>
</Box>
)}
</Box>
);
})}
</Box>
);
}
export default MinimizedStack;
@@ -0,0 +1,19 @@
/** Last visual of a card captured at minimize time; in-memory only, keyed by card id. */
const shots = new Map<string, string>();
const CAP = 40;
export function saveMinimizedShot(cardId: string, dataUrl: string): void {
if (shots.size >= CAP && !shots.has(cardId)) {
const oldest = shots.keys().next().value;
if (oldest) shots.delete(oldest);
}
shots.set(cardId, dataUrl);
}
export function getMinimizedShot(cardId: string): string | undefined {
return shots.get(cardId);
}
export function dropMinimizedShot(cardId: string): void {
shots.delete(cardId);
}