[eric] minimized: one glass rail for apps and browsers, real thumbnails, no live webview behind a parked card

This commit is contained in:
ciregenz
2026-07-30 14:31:30 -07:00
parent 629e58a59b
commit 356f7a4ea1
5 changed files with 319 additions and 105 deletions
@@ -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;
}