diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index fbe86dfd..cd4296ae 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -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 = ({ + {!fullscreenCardId && ( + { + canvas.actions.fitToCards([rect], 1.15, true); + onHighlightCard?.(cardId); + }} + /> + )} + {!fullscreenCardId && ( => ({ + 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 = ({ [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 = ({ 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).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 = ({ return ( { 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 = ({ 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 = ({ 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 = ({ overflow: 'hidden', }} > + e.stopPropagation()} + sx={{ display: 'flex', alignItems: 'center', gap: '7px', pl: 1.25, pr: 0.75, flexShrink: 0 }} + > + + { e.stopPropagation(); handleMinimize(); }} + sx={{ ...browserLightSx('#febc2e'), }} + /> + = ({ 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 = ({ onError={(e: any) => { e.target.style.display = 'none'; }} /> ) : ( - + )} @@ -977,7 +1037,7 @@ const BrowserCard: React.FC = ({ 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 = ({ 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 }, }} > - + ); @@ -1026,10 +1086,10 @@ const BrowserCard: React.FC = ({ mx: 0.25, my: 0.5, transition: 'background 0.15s', - '&:hover': { bgcolor: `${c.text.muted}15` }, + '&:hover': { bgcolor: 'rgba(0,0,0,0.06)' }, }} > - + @@ -1072,16 +1132,6 @@ const BrowserCard: React.FC = ({ )} - - e.stopPropagation()} - sx={{ color: c.text.ghost, p: 0.4, '&:hover': { color: c.status.error } }} - > - - - @@ -1093,8 +1143,8 @@ const BrowserCard: React.FC = ({ 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 = ({ 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 } }} > @@ -1119,7 +1169,7 @@ const BrowserCard: React.FC = ({ 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 } }} > @@ -1131,7 +1181,7 @@ const BrowserCard: React.FC = ({ 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 } }} > @@ -1147,13 +1197,13 @@ const BrowserCard: React.FC = ({ 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 ? ( - + ) : isSecure ? ( ) : null} @@ -1169,10 +1219,10 @@ const BrowserCard: React.FC = ({ 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 }, }} /> diff --git a/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx new file mode 100644 index 00000000..abe96e3b --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx @@ -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; + 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 ( + + {entries.map((bc) => { + const activeTab = bc.tabs.find((t) => t.id === bc.activeTabId) || bc.tabs[0]; + const shot = getMinimizedShot(bc.browser_id); + return ( + { + 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 ? ( + + ) : ( + + {activeTab?.favicon ? ( + + ) : ( + + )} + + {activeTab?.title || 'Browser'} + + + )} + + ); + })} + + ); +} + +export default MinimizedStack; diff --git a/frontend/src/app/pages/Dashboard/desktop/minimizedShots.ts b/frontend/src/app/pages/Dashboard/desktop/minimizedShots.ts new file mode 100644 index 00000000..8a086620 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/minimizedShots.ts @@ -0,0 +1,19 @@ +/** Last visual of a card captured at minimize time; in-memory only, keyed by card id. */ +const shots = new Map(); +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); +}