diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index 27cb646c..1dd3f047 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -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 = ({ // 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 = ({ {!anyFullscreen && ( { canvas.actions.fitToCards([rect], 1.15, true); onHighlightCard?.(cardId); @@ -285,6 +289,8 @@ const DashboardCanvas: React.FC = ({ 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); }} diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index cf4a7fad..1bbf07e6 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ // 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 = ({ e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center', flexShrink: 0, mr: 0.25 }}> handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} noTileMenu={tileZone === 'fullscreen'} /> - {!isMinimized && } + = ({ )} - {showControls && !isMinimized && ( + {showControls && ( <> {hasWorkspace && ( = ({ )} - {!isMinimized && ( - - { e.stopPropagation(); setHeaderPeek(false); setHeaderCollapsed((v) => !v); }} - onPointerDown={(e) => e.stopPropagation()} - sx={{ color: c.text.ghost, p: 0.5, '&:hover': { color: c.text.primary } }} - > - - - - )} + + { e.stopPropagation(); setHeaderPeek(false); setHeaderCollapsed((v) => !v); }} + onPointerDown={(e) => e.stopPropagation()} + sx={{ color: c.text.ghost, p: 0.5, '&:hover': { color: c.text.primary } }} + > + + + {/* Preview body */} diff --git a/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx index f19f866e..ffaf6526 100644 --- a/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx @@ -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; - onRestore: (id: string, rect: CardRect) => void; + viewCards: Record; + outputs: Record; + 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 ( - {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 ( - - 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', - }} - > - { 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} - /> - - {shot ? ( - - ) : ( - - {activeTab?.favicon ? ( - - ) : ( - - )} - - {activeTab?.title || 'Browser'} - - - )} - - ); - })} + {entries.map((entry) => ( + restore(entry)} + onClose={() => close(entry)} + onTile={(zone: string) => { restore(entry); if (zone !== 'restore') dispatch(setTiledCard({ cardId: entry.id, zone })); }} + /> + ))} ); } diff --git a/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx b/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx new file mode 100644 index 00000000..e9a38c59 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx @@ -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 ( + setFaviconFailed(true)} + sx={{ width: size, height: size, borderRadius: `${Math.round(size / 4)}px`, display: 'block' }} + /> + ); + } + if (entry.kind === 'browser') return ; + // 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 ; + return ; + }; + + return ( + + + {preview ? ( + + ) : ( + mark(26) + )} + 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', + }} + > + + + + + + + {mark(13)} + + {entry.label} + + + + ); +} + +export default MinimizedTile; diff --git a/frontend/src/app/pages/Dashboard/desktop/minimizedEntries.ts b/frontend/src/app/pages/Dashboard/desktop/minimizedEntries.ts new file mode 100644 index 00000000..0cc1e131 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/minimizedEntries.ts @@ -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; + viewCards: Record; + outputs: Record; + minimizedCards: Record; +} + +/** 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; +}