mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-27 20:14:49 +02:00
[eric] minimized: one glass rail for apps and browsers, real thumbnails, no live webview behind a parked card
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, type RefObject } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { addViewCard, clearTiledCard, selectFullscreenCardId } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { addViewCard, clearTiledCard, toggleMinimizeCard, selectFullscreenCardId } from '@/shared/state/dashboardLayoutSlice';
|
||||
import DashboardHeader from './DashboardHeader';
|
||||
import TetherLayer from './TetherLayer';
|
||||
import DashboardCardLayer from './DashboardCardLayer';
|
||||
@@ -170,6 +170,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
// macOS full screen: one card owns the whole window, every piece of chrome steps aside; Esc exits.
|
||||
const dispatch = useAppDispatch();
|
||||
const fullscreenCardId = useAppSelector(selectFullscreenCardId);
|
||||
const minimizedCards = useAppSelector((s) => s.dashboardLayout.minimizedCards);
|
||||
// The singleton app windows (Workflows, Settings) carry their own fullscreen flag, not a tiledCard; their fill also hides the dock.
|
||||
const settingsFullscreen = useAppSelector((s) => !!s.dashboardLayout.settingsCard?.fullscreen);
|
||||
const anyFullscreen = !!fullscreenCardId || !!workflowsHub?.fullscreen || settingsFullscreen;
|
||||
@@ -268,6 +269,9 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
{!anyFullscreen && (
|
||||
<MinimizedStack
|
||||
browserCards={browserCards}
|
||||
viewCards={viewCards}
|
||||
outputs={outputs}
|
||||
selectedIds={Array.from(selection.selectedIds.keys())}
|
||||
onRestore={(cardId, rect) => {
|
||||
canvas.actions.fitToCards([rect], 1.15, true);
|
||||
onHighlightCard?.(cardId);
|
||||
@@ -285,6 +289,8 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
outputs={outputs}
|
||||
selectedIds={Array.from(selection.selectedIds.keys())}
|
||||
onFocusCard={(cardId, rect) => {
|
||||
// A parked card sits off-canvas, so flying to its stored rect would land on empty space; unpark it first.
|
||||
if (minimizedCards[cardId]) dispatch(toggleMinimizeCard({ cardId }));
|
||||
canvas.actions.fitToCards([rect], 1.15, true);
|
||||
onHighlightCard?.(cardId);
|
||||
}}
|
||||
|
||||
@@ -18,6 +18,7 @@ import KeyboardArrowUpRounded from '@mui/icons-material/KeyboardArrowUpRounded';
|
||||
import { Output, SERVE_BASE } from '@/shared/state/outputsSlice';
|
||||
import { setViewCardPosition, setViewDocked, setViewCardSize, setActiveViewCardId, recordClosedCard, addViewCard, setTiledCard, clearTiledCard, toggleMinimizeCard, activateViewCardPreview } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { removeViewCardCleanly } from '@/shared/viewTeardown';
|
||||
import { saveMinimizedShot } from '../desktop/minimizedShots';
|
||||
import { requestAppSlot, releaseAppSlot, subscribeAppBudget } from '@/shared/appWebviewBudget';
|
||||
import { expandSession } from '@/shared/state/agentsSlice';
|
||||
import WindowControls from './WindowControls';
|
||||
@@ -223,7 +224,9 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
useEffect(() => {
|
||||
const evaluate = (): void => {
|
||||
let want: boolean;
|
||||
if (previewDeferred) {
|
||||
if (isMinimized) {
|
||||
want = false; // parked in the rail as a still, so a live renderer behind it is pure waste
|
||||
} else if (previewDeferred) {
|
||||
want = false; // reveal-parked: never boot until the first click clears the defer
|
||||
} else if (alwaysLive) {
|
||||
// Actively used (selected, interacting, agent-driven, tiled, fullscreen): pinned, never capped.
|
||||
@@ -263,8 +266,8 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
previewLiveRef.current = true;
|
||||
setSuspendSnapshot(null);
|
||||
setPreviewLive(true);
|
||||
} else if (previewDeferred) {
|
||||
// Reveal-parked from birth: never booted, so no settle + no frame to capture, just stay light.
|
||||
} else if (isMinimized || previewDeferred) {
|
||||
// Parked (minimize already froze its own frame) or never booted: drop it now, no settle beat.
|
||||
if (suspendTimerRef.current) { clearTimeout(suspendTimerRef.current); suspendTimerRef.current = null; }
|
||||
previewLiveRef.current = false;
|
||||
setPreviewLive(false);
|
||||
@@ -290,7 +293,7 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
window.removeEventListener('resize', evaluate);
|
||||
if (suspendTimerRef.current) { clearTimeout(suspendTimerRef.current); suspendTimerRef.current = null; }
|
||||
};
|
||||
}, [alwaysLive, previewDeferred, cardX, cardY, cardWidth, cardHeight, getCanvasState, cardKey]);
|
||||
}, [alwaysLive, previewDeferred, isMinimized, cardX, cardY, cardWidth, cardHeight, getCanvasState, cardKey]);
|
||||
|
||||
// Free the cap slot on unmount (card deleted, dashboard switch) so a slot is never leaked.
|
||||
useEffect(() => () => releaseAppSlot(cardKey), [cardKey]);
|
||||
@@ -568,7 +571,20 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
dispatch(recordClosedCard({ kind: 'view', id: cardKey }));
|
||||
void removeViewCardCleanly(cardKey, dispatch);
|
||||
};
|
||||
const onMinimize = () => dispatch(toggleMinimizeCard({ cardId: cardKey }));
|
||||
// Freeze the app's last frame first so the rail tile shows the real app, then park the card.
|
||||
const onMinimize = useCallback(() => {
|
||||
let parked = false;
|
||||
const park = (): void => { if (parked) return; parked = true; dispatch(toggleMinimizeCard({ cardId: cardKey })); };
|
||||
// capturePage can hang forever on a guest that stopped painting (Electron 42); the timer guarantees the park.
|
||||
window.setTimeout(park, 250);
|
||||
void (async () => {
|
||||
try {
|
||||
const snap = await previewRef.current?.capture?.();
|
||||
if (snap) saveMinimizedShot(cardKey, snap);
|
||||
} catch { /* no frame, the tile falls back to the app's stored thumbnail */ }
|
||||
park();
|
||||
})();
|
||||
}, [dispatch, cardKey]);
|
||||
const onTile = (zone: string) => {
|
||||
if (zone === 'restore') dispatch(clearTiledCard(cardKey));
|
||||
else dispatch(setTiledCard({ cardId: cardKey, zone }));
|
||||
@@ -628,6 +644,7 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
ref={dockRootRef}
|
||||
data-select-type="view-card"
|
||||
data-select-id={cardKey}
|
||||
data-keepalive-hidden={isMinimized ? '1' : undefined}
|
||||
onContextMenu={(e: React.MouseEvent) => openCardContextMenu(e, {
|
||||
items: [
|
||||
{ label: 'Full Screen', onClick: () => onTile('fullscreen') },
|
||||
@@ -651,10 +668,13 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
// contain + willChange: own compositor layer so paint stays scoped (see AgentCard for full rationale).
|
||||
contain: 'layout style',
|
||||
willChange: 'transform',
|
||||
left: tiledStyle ? tiledStyle.left : dockActive ? dockRect!.x : (dragging ? cardX : displayX),
|
||||
top: tiledStyle ? tiledStyle.top : (dragging ? cardY : displayY),
|
||||
width: tiledStyle ? tiledStyle.width : (isMinimized ? 220 : displayW),
|
||||
height: tiledStyle ? tiledStyle.height : (isMinimized ? 44 : displayH),
|
||||
// Minimized apps live in the right-edge rail, so the card itself parks off-canvas at full size
|
||||
// (same trick as browser cards) and restores to exactly the geometry it left.
|
||||
pointerEvents: isMinimized ? 'none' : undefined,
|
||||
left: isMinimized ? -100000 : (tiledStyle ? tiledStyle.left : dockActive ? dockRect!.x : (dragging ? cardX : displayX)),
|
||||
top: isMinimized ? -100000 : (tiledStyle ? tiledStyle.top : (dragging ? cardY : displayY)),
|
||||
width: tiledStyle ? tiledStyle.width : displayW,
|
||||
height: tiledStyle ? tiledStyle.height : displayH,
|
||||
transform: tiledStyle ? tiledStyle.transform : (dragging ? `translate3d(${dragTx}px, ${dragTy}px, 0)` : undefined),
|
||||
transformOrigin: tiledStyle ? tiledStyle.transformOrigin : undefined,
|
||||
borderRadius: isFullscreen ? '12px' : `${c.radius.lg}px`,
|
||||
@@ -750,7 +770,7 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
<Box onPointerDown={(e) => e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center', flexShrink: 0, mr: 0.25 }}>
|
||||
<WindowControls onClose={() => handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} noTileMenu={tileZone === 'fullscreen'} />
|
||||
</Box>
|
||||
{!isMinimized && <GridViewRoundedIcon sx={{ fontSize: 16, color: c.accent.primary, flexShrink: 0 }} />}
|
||||
<GridViewRoundedIcon sx={{ fontSize: 16, color: c.accent.primary, flexShrink: 0 }} />
|
||||
<Typography
|
||||
sx={{
|
||||
flex: 1,
|
||||
@@ -770,7 +790,7 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{showControls && !isMinimized && (
|
||||
{showControls && (
|
||||
<>
|
||||
{hasWorkspace && (
|
||||
<Box
|
||||
@@ -849,18 +869,16 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isMinimized && (
|
||||
<Tooltip title={headerCollapsed ? 'Show toolbar' : 'Hide toolbar'} placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => { e.stopPropagation(); setHeaderPeek(false); setHeaderCollapsed((v) => !v); }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ color: c.text.ghost, p: 0.5, '&:hover': { color: c.text.primary } }}
|
||||
>
|
||||
<KeyboardArrowUpRounded sx={{ fontSize: 18, transition: 'transform 0.15s', transform: headerCollapsed ? 'rotate(180deg)' : 'none' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={headerCollapsed ? 'Show toolbar' : 'Hide toolbar'} placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => { e.stopPropagation(); setHeaderPeek(false); setHeaderCollapsed((v) => !v); }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ color: c.text.ghost, p: 0.5, '&:hover': { color: c.text.primary } }}
|
||||
>
|
||||
<KeyboardArrowUpRounded sx={{ fontSize: 18, transition: 'transform 0.15s', transform: headerCollapsed ? 'rotate(180deg)' : 'none' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Preview body */}
|
||||
|
||||
@@ -1,37 +1,53 @@
|
||||
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, setTiledCard, recordClosedCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { removeBrowserCardCleanly } from '@/shared/browserTeardown';
|
||||
import WindowControls, { ARC_CHIP_SX } from '../cards/WindowControls';
|
||||
import { getMinimizedShot, dropMinimizedShot } from './minimizedShots';
|
||||
import type { BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
|
||||
interface CardRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
import { removeViewCardCleanly } from '@/shared/viewTeardown';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { GLASS_SURFACE, GLASS_SURFACE_BLUR } from '@/shared/styles/glassSurface';
|
||||
import { dropMinimizedShot } from './minimizedShots';
|
||||
import { buildMinimizedEntries, MinimizedEntry, MinimizedRect } from './minimizedEntries';
|
||||
import MinimizedTile, { MINIMIZED_TILE_W } from './MinimizedTile';
|
||||
import type { BrowserCardPosition, ViewCardPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { Output } from '@/shared/state/outputsSlice';
|
||||
|
||||
interface MinimizedStackProps {
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
onRestore: (id: string, rect: CardRect) => void;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
outputs: Record<string, Output>;
|
||||
selectedIds: string[];
|
||||
onRestore: (id: string, rect: MinimizedRect) => 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 {
|
||||
/** Right-edge rail of parked windows, browsers and apps alike; click a tile to put it back. */
|
||||
function MinimizedStack({ browserCards, viewCards, outputs, selectedIds, 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]);
|
||||
const c = useClaudeTokens();
|
||||
const minimizedCards = useAppSelector((s) => s.dashboardLayout.minimizedCards);
|
||||
const entries = buildMinimizedEntries({ browserCards, viewCards, outputs, minimizedCards });
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
const restore = (entry: MinimizedEntry): void => {
|
||||
dropMinimizedShot(entry.id);
|
||||
dispatch(toggleMinimizeCard({ cardId: entry.id }));
|
||||
onRestore(entry.id, entry.rect);
|
||||
};
|
||||
const close = (entry: MinimizedEntry): void => {
|
||||
dropMinimizedShot(entry.id);
|
||||
if (entry.kind === 'browser') {
|
||||
dispatch(recordClosedCard({ kind: 'browser', id: entry.id }));
|
||||
void removeBrowserCardCleanly(entry.id, dispatch);
|
||||
} else {
|
||||
dispatch(recordClosedCard({ kind: 'view', id: entry.id }));
|
||||
void removeViewCardCleanly(entry.id, dispatch);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
data-minimized-rail
|
||||
// Must stay OUTSIDE the canvas transform: a transformed ancestor becomes the containing block, so a rail nested in there would ride the camera.
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 14,
|
||||
@@ -39,70 +55,41 @@ function MinimizedStack({ browserCards, onRestore }: MinimizedStackProps): React
|
||||
zIndex: 11,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.5,
|
||||
alignItems: 'flex-end',
|
||||
gap: '6px',
|
||||
p: '6px',
|
||||
borderRadius: '16px',
|
||||
width: MINIMIZED_TILE_W + 12,
|
||||
maxHeight: 'calc(100% - 220px)',
|
||||
overflowY: 'auto',
|
||||
scrollbarWidth: 'none',
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
background: GLASS_SURFACE,
|
||||
backdropFilter: GLASS_SURFACE_BLUR,
|
||||
WebkitBackdropFilter: GLASS_SURFACE_BLUR,
|
||||
border: '1px solid rgba(255,255,255,0.10)',
|
||||
boxShadow: '0 8px 28px rgba(0,0,0,0.35)',
|
||||
'@keyframes osw-min-tile-in': {
|
||||
from: { opacity: 0, transform: 'scale(0.96)' },
|
||||
to: { opacity: 1, transform: 'scale(1)' },
|
||||
},
|
||||
// A minimize is a physical snap, so the aggressive out-ease; nothing here loops.
|
||||
'& .osw-min-tile': { animation: 'osw-min-tile-in 180ms cubic-bezier(0.16, 1, 0.3, 1) both' },
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
'& .osw-min-tile': { animation: 'none' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{entries.map((bc) => {
|
||||
const activeTab = bc.tabs.find((t) => t.id === bc.activeTabId) || bc.tabs[0];
|
||||
const shot = getMinimizedShot(bc.browser_id);
|
||||
const restore = (): void => {
|
||||
dropMinimizedShot(bc.browser_id);
|
||||
dispatch(toggleMinimizeCard({ cardId: bc.browser_id }));
|
||||
onRestore(bc.browser_id, bc);
|
||||
};
|
||||
return (
|
||||
<Box
|
||||
key={bc.browser_id}
|
||||
onClick={restore}
|
||||
title={activeTab?.title || 'Browser'}
|
||||
className="osw-card osw-pill-host"
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: THUMB_W,
|
||||
borderRadius: '8px',
|
||||
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)' },
|
||||
'&:hover .osw-pill-lights': { opacity: 1, pointerEvents: 'auto' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className="osw-pill-lights"
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
sx={{
|
||||
...ARC_CHIP_SX,
|
||||
position: 'absolute', top: 2, left: 2, zIndex: 2, background: 'rgba(24,14,32,0.85)',
|
||||
backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)',
|
||||
opacity: 0, pointerEvents: 'none', transition: 'opacity 140ms ease',
|
||||
}}
|
||||
>
|
||||
<WindowControls
|
||||
onClose={() => { dispatch(recordClosedCard({ kind: 'browser', id: bc.browser_id })); removeBrowserCardCleanly(bc.browser_id, dispatch); }}
|
||||
onMinimize={restore}
|
||||
onTile={(zone: string) => { restore(); if (zone !== 'restore') dispatch(setTiledCard({ cardId: bc.browser_id, zone })); }}
|
||||
tiled={false}
|
||||
/>
|
||||
</Box>
|
||||
{shot ? (
|
||||
<Box component="img" src={shot} alt="" sx={{ width: '100%', display: 'block', borderRadius: '8px' }} />
|
||||
) : (
|
||||
<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.625rem', color: '#3c3744', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '100%' }}>
|
||||
{activeTab?.title || 'Browser'}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{entries.map((entry) => (
|
||||
<MinimizedTile
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
accent={c.accent.primary}
|
||||
selected={selectedIds.includes(entry.id)}
|
||||
onRestore={() => restore(entry)}
|
||||
onClose={() => close(entry)}
|
||||
onTile={(zone: string) => { restore(entry); if (zone !== 'restore') dispatch(setTiledCard({ cardId: entry.id, zone })); }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
|
||||
import { pickIcon } from '../canvas/DashboardGlyph';
|
||||
import WindowControls, { ARC_CHIP_SX } from '../cards/WindowControls';
|
||||
import { getMinimizedShot } from './minimizedShots';
|
||||
import type { MinimizedEntry } from './minimizedEntries';
|
||||
|
||||
interface MinimizedTileProps {
|
||||
entry: MinimizedEntry;
|
||||
accent: string;
|
||||
selected: boolean;
|
||||
onRestore: () => void;
|
||||
onClose: () => void;
|
||||
onTile: (zone: string) => void;
|
||||
}
|
||||
|
||||
export const MINIMIZED_TILE_W = 132;
|
||||
|
||||
// Opaque interior: the rail above is the one glass layer, so stacking more alpha in here just muddies
|
||||
// the text. Chromium also does a backdrop readback per blurred element, so N blurred tiles is N passes.
|
||||
const TILE_BODY = '#262624';
|
||||
const TILE_WELL = '#1b1b19';
|
||||
const REST_EDGE = 'rgba(255,255,255,0.10)';
|
||||
const HOVER_EDGE = 'rgba(255,255,255,0.16)';
|
||||
|
||||
/** One parked window: its own last frame, a favicon or glyph, and the title. Hover reveals the lights. */
|
||||
function MinimizedTile({ entry, accent, selected, onRestore, onClose, onTile }: MinimizedTileProps): React.ReactElement {
|
||||
const [faviconFailed, setFaviconFailed] = useState(false);
|
||||
const preview = getMinimizedShot(entry.id) || entry.thumbnail || null;
|
||||
const showFavicon = entry.kind === 'browser' && !!entry.faviconUrl && !faviconFailed;
|
||||
|
||||
const mark = (size: number): React.ReactElement => {
|
||||
if (showFavicon) {
|
||||
return (
|
||||
<Box
|
||||
component="img"
|
||||
src={entry.faviconUrl}
|
||||
alt=""
|
||||
onError={() => setFaviconFailed(true)}
|
||||
sx={{ width: size, height: size, borderRadius: `${Math.round(size / 4)}px`, display: 'block' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (entry.kind === 'browser') return <LanguageIcon sx={{ fontSize: size, color: 'rgba(255,255,255,0.55)', display: 'block' }} />;
|
||||
// Never a letter here: a glyph initial reads as a bug, so an unmatched name falls back to a real symbol.
|
||||
const AppIcon = pickIcon(entry.label);
|
||||
if (AppIcon) return <AppIcon size={size} strokeWidth={1.75} color="rgba(255,255,255,0.6)" />;
|
||||
return <GridViewRoundedIcon sx={{ fontSize: size, color: 'rgba(255,255,255,0.55)', display: 'block' }} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="osw-card osw-pill-host osw-min-tile"
|
||||
onClick={onRestore}
|
||||
title={entry.label}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: MINIMIZED_TILE_W,
|
||||
flexShrink: 0,
|
||||
p: '4px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
borderRadius: '12px',
|
||||
cursor: 'pointer',
|
||||
border: `1px solid ${REST_EDGE}`,
|
||||
// Selection reads as a soft tint plus ONE accent inner edge; the outer glow is decoration only.
|
||||
background: selected ? `linear-gradient(0deg, ${accent}1f, ${accent}1f), ${TILE_BODY}` : TILE_BODY,
|
||||
boxShadow: selected ? `inset 0 0 0 1px ${accent}, 0 0 24px ${accent}26` : 'none',
|
||||
transition: 'border-color 140ms ease, background 140ms ease',
|
||||
'&:hover': { borderColor: HOVER_EDGE },
|
||||
'&:hover .osw-pill-lights': { opacity: 1, pointerEvents: 'auto' },
|
||||
'&:hover .osw-min-well': { filter: 'brightness(1.08)' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className="osw-min-well"
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
aspectRatio: '16 / 10',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
background: preview ? TILE_WELL : `linear-gradient(135deg, #302f2c, ${TILE_WELL})`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'filter 140ms ease',
|
||||
}}
|
||||
>
|
||||
{preview ? (
|
||||
<Box component="img" src={preview} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'top center', display: 'block' }} />
|
||||
) : (
|
||||
mark(26)
|
||||
)}
|
||||
<Box
|
||||
className="osw-pill-lights"
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
sx={{
|
||||
...ARC_CHIP_SX,
|
||||
position: 'absolute', top: 4, left: 4, zIndex: 2, background: 'rgba(24,14,32,0.85)',
|
||||
backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)',
|
||||
opacity: 0, pointerEvents: 'none', transition: 'opacity 140ms ease',
|
||||
}}
|
||||
>
|
||||
<WindowControls onClose={onClose} onMinimize={onRestore} onTile={onTile} tiled={false} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: '6px', px: '2px', height: 22 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 5, height: 5, borderRadius: '50%', flexShrink: 0,
|
||||
background: selected ? accent : 'rgba(255,255,255,0.26)',
|
||||
transition: 'background 140ms ease',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexShrink: 0 }}>{mark(13)}</Box>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: selected ? 600 : 500,
|
||||
color: selected ? '#FAF9F5' : 'rgba(255,255,255,0.62)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'color 140ms ease',
|
||||
}}
|
||||
>
|
||||
{entry.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default MinimizedTile;
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { BrowserCardPosition, ViewCardPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { Output } from '@/shared/state/outputsSlice';
|
||||
|
||||
export interface MinimizedRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type MinimizedKind = 'browser' | 'view';
|
||||
|
||||
export interface MinimizedEntry {
|
||||
id: string;
|
||||
kind: MinimizedKind;
|
||||
label: string;
|
||||
/** Geometry the card is restored to, so the camera can fly back to exactly where it was. */
|
||||
rect: MinimizedRect;
|
||||
faviconUrl?: string;
|
||||
/** Stored still for the window; the fresh minimize-time shot wins over this when there is one. */
|
||||
thumbnail?: string | null;
|
||||
}
|
||||
|
||||
export interface MinimizedSlices {
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
outputs: Record<string, Output>;
|
||||
minimizedCards: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/** Every window parked in the minimized rail, browsers and apps in one list so both share a small state. */
|
||||
export function buildMinimizedEntries({
|
||||
browserCards,
|
||||
viewCards,
|
||||
outputs,
|
||||
minimizedCards,
|
||||
}: MinimizedSlices): MinimizedEntry[] {
|
||||
const list: MinimizedEntry[] = [];
|
||||
for (const bc of Object.values(browserCards)) {
|
||||
if (!minimizedCards[bc.browser_id]) continue;
|
||||
const activeTab = bc.tabs.find((t) => t.id === bc.activeTabId) || bc.tabs[0];
|
||||
list.push({
|
||||
id: bc.browser_id,
|
||||
kind: 'browser',
|
||||
label: activeTab?.title || 'Browser',
|
||||
rect: bc,
|
||||
faviconUrl: activeTab?.favicon,
|
||||
});
|
||||
}
|
||||
for (const [cardKey, vc] of Object.entries(viewCards)) {
|
||||
if (!minimizedCards[cardKey]) continue;
|
||||
const name = outputs[vc.output_id]?.name || 'App';
|
||||
const instance = vc.instance ?? 1;
|
||||
list.push({
|
||||
id: cardKey,
|
||||
kind: 'view',
|
||||
label: instance > 1 ? `${name} #${instance}` : name,
|
||||
rect: vc,
|
||||
thumbnail: outputs[vc.output_id]?.thumbnail,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
Reference in New Issue
Block a user