mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 12:47:42 +02:00
[eric] browser: macOS-window chrome (traffic lights, fixed light strip, centered URL) + minimize to a right-edge thumbnail stack
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user