From 0b19797f7fcc8991e6e03bc4e24820d2892f773b Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 20 Jul 2026 21:42:24 -0700 Subject: [PATCH] [eric] desktop: traffic lights everywhere (browser green+tiling, pill hover chips, arc app lights), fullscreen dark+full+dock-switcher, knob drag, dock glyphs, rest composer removed --- electron/main.js | 9 ++ electron/preload.js | 2 + .../src/app/components/Layout/AppShell.tsx | 2 +- .../src/app/components/theme/WashDials.tsx | 20 +++-- .../src/app/pages/AgentChat/AgentChat.tsx | 1 - .../src/app/pages/AgentChat/ChatInput.tsx | 5 +- .../ChatInput/toolbar/ChatInputToolbar.tsx | 23 ++--- .../ChatInput/toolbar/ToolbarActions.tsx | 5 +- .../ChatInput/view/ChatInputView.tsx | 11 --- .../Dashboard/canvas/DashboardCanvas.tsx | 90 +++++++++++++------ .../pages/Dashboard/canvas/DashboardGlyph.tsx | 2 +- .../app/pages/Dashboard/cards/AgentCard.tsx | 47 ++++++++-- .../app/pages/Dashboard/cards/BrowserCard.tsx | 71 ++++++++------- .../app/pages/Dashboard/cards/tileZones.ts | 5 +- .../pages/Dashboard/desktop/DesktopDock.tsx | 15 ++-- .../Dashboard/desktop/MinimizedStack.tsx | 38 ++++++-- frontend/src/types/electron.d.ts | 1 + 17 files changed, 224 insertions(+), 123 deletions(-) diff --git a/electron/main.js b/electron/main.js index 2c23248f..8413da4c 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1238,6 +1238,11 @@ function createWindow() { }, }); + // Arc-style traffic lights: hidden until the renderer's top-edge hover asks for them. + if (process.platform === 'darwin') { + try { mainWindow.setWindowButtonVisibility(false); } catch (err) { console.warn('[main] setWindowButtonVisibility failed:', err.message); } + } + if (isDev) { // Dev only: OPENSWARM_DEV_URL (full override) or OPENSWARM_DEV_PORT lets a second worktree's Electron point at its own webpack-dev-server instead of colliding on the shared :3000. Packaged builds never hit this branch. mainWindow.loadURL(process.env.OPENSWARM_DEV_URL || `http://localhost:${process.env.OPENSWARM_DEV_PORT || 3000}`); @@ -2828,6 +2833,10 @@ ipcMain.handle('get-auth-token', async () => { ipcMain.on('perf:first-agent-response', () => perfMark('first-agent-response')); ipcMain.handle('get-app-version', () => app.getVersion()); +ipcMain.handle('set-window-buttons-visible', (_e, visible) => { + if (process.platform !== 'darwin' || !mainWindow || mainWindow.isDestroyed()) return; + try { mainWindow.setWindowButtonVisibility(!!visible); } catch (err) { console.warn('[main] setWindowButtonVisibility failed:', err.message); } +}); // Phase 2 provenance: the renderer's About panel shows the commit this build // was cut from, so a screenshot is enough to identify the exact code shipped. ipcMain.handle('get-build-info', () => getBuildInfo()); diff --git a/electron/preload.js b/electron/preload.js index b34f065a..7e814bee 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -41,6 +41,8 @@ contextBridge.exposeInMainWorld('openswarm', { getAuthToken: () => ipcRenderer.invoke('get-auth-token'), getAppVersion: () => ipcRenderer.invoke('get-app-version'), + // Arc-style chrome: the mac traffic lights hide at rest; the dashboard's top-edge hover reveals them. + setWindowButtonsVisible: (visible) => ipcRenderer.invoke('set-window-buttons-visible', visible), // Phase 2 provenance: { sha, shortSha, builtAt, channel } for the About panel. getBuildInfo: () => ipcRenderer.invoke('get-build-info'), diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 53d94009..1bf4586f 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -628,7 +628,7 @@ const AppShell: React.FC = () => { return ( - {sidebarAway && !sidePeek && ( + {sidebarAway && !sidePeek && !fullscreenCardId && ( { cancelPeekClose(); setSidePeek(true); }} sx={{ position: 'fixed', top: 0, left: 0, bottom: 0, width: 14, zIndex: 2147483000, pointerEvents: 'auto' }} /> )} {/* Top bar dropped (Arc/Zen): a zero-height anchor left only to float the agent-activity island at top-center; the island renders nothing when idle. */} diff --git a/frontend/src/app/components/theme/WashDials.tsx b/frontend/src/app/components/theme/WashDials.tsx index 88543815..be05fdf6 100644 --- a/frontend/src/app/components/theme/WashDials.tsx +++ b/frontend/src/app/components/theme/WashDials.tsx @@ -40,17 +40,27 @@ export const SquiggleSlider: React.FC<{ value: number; onChange: (v: number) => }; export const Knob: React.FC<{ value: number; onChange: (v: number) => void; size?: number }> = ({ value, onChange, size = 34 }) => { - const dragging = useRef<{ startY: number; startV: number } | null>(null); + const ref = useRef(null); + const [grabbing, setGrabbing] = React.useState(false); const angle = -135 + value * 270; + // Turn like a physical knob: the indicator chases the pointer's angle around the center. + const applyAngle = useCallback((clientX: number, clientY: number) => { + const el = ref.current; + if (!el) return; + const r = el.getBoundingClientRect(); + const deg = Math.atan2(clientX - (r.left + r.width / 2), (r.top + r.height / 2) - clientY) * (180 / Math.PI); + onChange(Math.min(1, Math.max(0, (Math.max(-135, Math.min(135, deg)) + 135) / 270))); + }, [onChange]); return (
{ dragging.current = { startY: e.clientY, startV: value }; (e.target as HTMLElement).setPointerCapture?.(e.pointerId); }} - onPointerMove={(e) => { const d = dragging.current; if (!d) return; onChange(Math.min(1, Math.max(0, d.startV + (d.startY - e.clientY) / 120))); }} - onPointerUp={() => { dragging.current = null; }} + onPointerDown={(e) => { setGrabbing(true); (e.target as HTMLElement).setPointerCapture?.(e.pointerId); applyAngle(e.clientX, e.clientY); }} + onPointerMove={(e) => { if (grabbing) applyAngle(e.clientX, e.clientY); }} + onPointerUp={() => setGrabbing(false)} style={{ position: 'relative', width: size + 10, height: size + 10, display: 'flex', alignItems: 'center', - justifyContent: 'center', cursor: 'ns-resize', touchAction: 'none', flexShrink: 0, + justifyContent: 'center', cursor: grabbing ? 'grabbing' : 'grab', touchAction: 'none', flexShrink: 0, }} >
diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 6883d901..42a2e0ea 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -2375,7 +2375,6 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose autoFocus={autoFocus} prefillPrompt={prefillPrompt} placeholderOverride={runContext ? 'Ask about this run...' : embedded ? 'Send a message...' : undefined} - quietComposer={embedded} runContext={runContext} onClearRunContext={onClearRunContext} thinkingLevel={session?.thinking_level ?? 'auto'} diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx index 28b2477a..6027f1cb 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx @@ -48,14 +48,12 @@ interface Props { prefillPrompt?: string; // Replaces the default "Agent, @ for context..." placeholder (e.g. "Ask about this run..."). placeholderOverride?: string; - // Desktop-card composer: rest as input + attach/mic; pickers return on focus. - quietComposer?: boolean; // A workflow run shown as a small removable chip inside the composer. runContext?: WorkflowsRunContext; onClearRunContext?: () => void; } -const ChatInput = forwardRef(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0, thinkingLevel = 'auto', onThinkingLevelChange, onActivityLabelChange, prefillPrompt, placeholderOverride, quietComposer, runContext, onClearRunContext }, ref) => { +const ChatInput = forwardRef(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0, thinkingLevel = 'auto', onThinkingLevelChange, onActivityLabelChange, prefillPrompt, placeholderOverride, runContext, onClearRunContext }, ref) => { const c = useClaudeTokens(); const editorRef = useRef(null); const containerRef = useRef(null); @@ -322,7 +320,6 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, editorRef={editorRef} generalFileInputRef={generalFileInputRef} embedded={embedded} - quietComposer={quietComposer} isDragOver={isDragOver} isUploading={isUploading} handleDragOver={handleDragOver} diff --git a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx index 4d1100fe..045e199f 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx @@ -50,8 +50,6 @@ interface Props { isRunning?: boolean; onStop?: () => void; handleSend: () => void; - /** Embedded-card resting look: only attach + mic; pickers come back on focus. */ - restMode?: boolean; } export const ChatInputToolbar: React.FC = (p) => { @@ -60,7 +58,7 @@ export const ChatInputToolbar: React.FC = (p) => { allModelFlat, model, onModelChange, onProviderChange, picker, pendingKinds, pendingPayloadEstimate, thinkingLevel, onThinkingLevelChange, contextEstimate, elementSelection, autoRunMode, ownerId, sessionId, generalFileInputRef, addImageFiles, uploadAndAttachFiles, - hasContent, disabled, isRunning, onStop, handleSend, restMode, + hasContent, disabled, isRunning, onStop, handleSend, } = p; const menuPaperProps = { @@ -96,14 +94,12 @@ export const ChatInputToolbar: React.FC = (p) => { pt: 0, }} > - {!restMode && ( - - )} + = (p) => { pendingPayloadEstimate={pendingPayloadEstimate} /> - {!hideForTrial && !restMode && ( + {!hideForTrial && ( = (p) => { - {contextEstimate && !restMode && ( + {contextEstimate && ( = (p) => { void; handleSend: () => void; - restMode?: boolean; } export const ToolbarActions: React.FC = ({ c, elementSelection, autoRunMode, ownerId, sessionId, generalFileInputRef, - addImageFiles, uploadAndAttachFiles, hasContent, disabled, isRunning, onStop, handleSend, restMode, + addImageFiles, uploadAndAttachFiles, hasContent, disabled, isRunning, onStop, handleSend, }) => { return ( <> - {elementSelection && !autoRunMode && !restMode && (() => { + {elementSelection && !autoRunMode && (() => { const isMySelectMode = elementSelection.selectMode && elementSelection.activeOwnerId === ownerId; return ( diff --git a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx index fa317fb5..a90c8f5c 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx @@ -28,7 +28,6 @@ interface Props { editorRef: RefObject; generalFileInputRef: RefObject; embedded?: boolean; - quietComposer?: boolean; isDragOver: boolean; isUploading: boolean; handleDragOver: (e: React.DragEvent) => void; @@ -107,18 +106,9 @@ interface Props { export const ChatInputView: React.FC = (p) => { const { c } = p; - // Embedded card composers rest as just the input + attach/mic (the frame look); the pickers return on focus, draft text, or any open menu. - const [focusWithin, setFocusWithin] = useState(false); - const restMode = Boolean( - p.quietComposer && !focusWithin && !p.hasContent && !p.modelAnchor && !p.thinkingAnchor && !p.modeAnchor, - ); return ( setFocusWithin(true)} - onBlurCapture={(e) => { - if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocusWithin(false); - }} onDragOver={p.handleDragOver} onDragLeave={p.handleDragLeave} onDrop={p.handleDrop} @@ -256,7 +246,6 @@ export const ChatInputView: React.FC = (p) => { = ({ return () => window.removeEventListener('keydown', onKey, true); }, [fullscreenCardId, dispatch]); + // Arc-style chrome: the mac traffic lights ride the same top-edge hover as the header overlay. + useEffect(() => { + window.openswarm?.setWindowButtonsVisible?.(headerRevealed && !fullscreenCardId); + }, [headerRevealed, fullscreenCardId]); + + // Reveal on any pointer graze of the top edge. The old 22px strip Box was dead in practice: the + // hidden header overlay's pointer-events:auto children sat above it and ate the mouseenter. + useEffect(() => { + const onMove = (e: MouseEvent): void => { + if (e.clientY <= 22) setHeaderRevealed(true); + }; + window.addEventListener('mousemove', onMove); + return () => window.removeEventListener('mousemove', onMove); + }, []); + + // Arc/Zen fullscreen: the dock hides with the rest of the chrome but slides back on a left-edge + // graze, and clicking a tile SWAPS which card owns the full screen instead of moving the camera. + const [fsDockRevealed, setFsDockRevealed] = React.useState(false); + useEffect(() => { + if (!fullscreenCardId) { setFsDockRevealed(false); return undefined; } + const onMove = (e: MouseEvent): void => { + if (e.clientX <= 16) setFsDockRevealed(true); + else if (e.clientX > 120) setFsDockRevealed(false); + }; + window.addEventListener('mousemove', onMove); + return () => window.removeEventListener('mousemove', onMove); + }, [fullscreenCardId]); + const swapFullscreen = React.useCallback((cardId: string) => { + if (!fullscreenCardId || cardId === fullscreenCardId) return; + dispatch(clearTiledCard(fullscreenCardId)); + if (sessions[cardId]) dispatch(expandSession(cardId)); + dispatch(setTiledCard({ cardId, zone: 'fullscreen' })); + }, [fullscreenCardId, dispatch, sessions]); + // Gestures write the transform imperatively (no React commit per frame), so a foreign render mid-gesture would paint the stale committed transform for a frame. Re-applying live after EVERY render seals that; do not remove. React.useLayoutEffect(() => { canvas.actions.syncTransform(); @@ -193,11 +228,6 @@ const DashboardCanvas: React.FC = ({ return ( <> - {/* Top-edge hover strip: the desktop shell keeps the top chromeless; grazing it reveals the header. */} - setHeaderRevealed(true)} - sx={{ position: 'absolute', top: 0, left: 0, right: 0, height: 22, zIndex: 9 }} - /> {/* Floating header overlay */} setHeaderRevealed(false)} @@ -251,25 +281,35 @@ const DashboardCanvas: React.FC = ({ /> )} - {!fullscreenCardId && ( - { - canvas.actions.fitToCards([rect], 1.15, true); - onHighlightCard?.(cardId); - }} - onApplications={() => setAppsWindowOpen((v) => !v)} - onNewAgent={onNewAgent} - onAddBrowser={onAddBrowser} - onAddNote={onAddNote} - /> + {(!fullscreenCardId || fsDockRevealed) && ( + + { + if (fullscreenCardId) { swapFullscreen(cardId); return; } + canvas.actions.fitToCards([rect], 1.15, true); + onHighlightCard?.(cardId); + }} + onApplications={() => setAppsWindowOpen((v) => !v)} + onNewAgent={onNewAgent} + onAddBrowser={onAddBrowser} + onAddNote={onAddNote} + /> + )} {appsWindowOpen && !fullscreenCardId && ( diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardGlyph.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardGlyph.tsx index e2581403..71aa8dc3 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardGlyph.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardGlyph.tsx @@ -65,7 +65,7 @@ const KEYWORDS: Record = { weather: CloudSun, forecast: CloudSun, temperature: CloudSun, }; -function pickIcon(title: string): LucideIcon | null { +export function pickIcon(title: string): LucideIcon | null { const words = title.toLowerCase().match(/[a-z]+/g) || []; for (const w of words) { const hit = KEYWORDS[w] || (w.endsWith('s') ? KEYWORDS[w.slice(0, -1)] : undefined); diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 57b24feb..959dee61 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -17,6 +17,7 @@ import { AgentSession, handleApproval, collapseSession, + expandSession, closeSession, fetchSession, renameSession, @@ -670,10 +671,18 @@ const AgentCard: React.FC = ({ void tileTick; const cam = getCanvasState(); const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom); + // A collapsed chat can never stay tiled: collapsing while fullscreen left a white full-window shell + // (the header collapse control still fires in full size view). Seal the state instead of the path. + useEffect(() => { + if (tileZone && !expanded) dispatch(clearTiledCard(session.id)); + }, [tileZone, expanded, dispatch, session.id]); const onMinimize = (): void => { dispatch(collapseSession(session.id)); }; const onTile = (zone: string): void => { if (zone === 'restore') dispatch(clearTiledCard(session.id)); - else dispatch(setTiledCard({ cardId: session.id, zone })); + else { + if (!expanded) dispatch(expandSession(session.id)); + dispatch(setTiledCard({ cardId: session.id, zone })); + } }; @@ -913,13 +922,18 @@ const AgentCard: React.FC = ({ cursor: isDragging ? 'grabbing' : 'grab', '&:hover': {}, }), - // Expanded chat wears the desktop dark glass; the header only surfaces on hover. - ...(expanded && !tiledStyle && { - bgcolor: 'rgba(26,16,34,0.85)', - backdropFilter: 'blur(24px) saturate(150%)', - WebkitBackdropFilter: 'blur(24px) saturate(150%)', + // Expanded chat wears the desktop dark glass; the header only surfaces on hover. Tiled keeps + // the SAME dark surface (excluding it rendered the light-theme white card, the "fullscreen + // turns white" bug) but solid + blur-free: nothing shows behind a tiled card, and a + // window-sized backdrop blur is pure GPU tax. + ...(expanded && { + bgcolor: tiledStyle ? 'rgb(26,16,34)' : 'rgba(26,16,34,0.85)', + ...(tiledStyle ? {} : { + backdropFilter: 'blur(24px) saturate(150%)', + WebkitBackdropFilter: 'blur(24px) saturate(150%)', + }), border: isSelected ? '2px solid #3b82f6' : '1px solid rgba(255,255,255,0.08)', - borderRadius: '20px', + borderRadius: tiledStyle ? '12px' : '20px', boxShadow: '0 18px 48px rgba(0,0,0,0.4)', }), }} @@ -968,8 +982,25 @@ const AgentCard: React.FC = ({ onPointerDown={handleDragPointerDown} onPointerMove={handleDragPointerMove} onPointerUp={handleDragPointerUp} - sx={{ touchAction: 'none', userSelect: 'none' }} + sx={{ position: 'relative', touchAction: 'none', userSelect: 'none', pt: '26px', mt: '-26px', '&:hover .osw-pill-lights': { opacity: 1, pointerEvents: 'auto' } }} > + e.stopPropagation()} + sx={{ + position: 'absolute', top: 0, left: 4, zIndex: 2, display: 'flex', alignItems: 'center', + px: 1, py: 0.5, borderRadius: 999, background: 'rgba(24,14,32,0.85)', + backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', + opacity: 0, pointerEvents: 'none', transition: 'opacity 140ms ease', + }} + > + handleRemove()} + onMinimize={() => dispatch(expandSession(session.id))} + onTile={(zone: string) => { dispatch(expandSession(session.id)); onTile(zone); }} + tiled={false} + /> + => ({ - 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'; @@ -229,6 +222,22 @@ const BrowserCard: React.FC = ({ ); const browserAgentSession = useAppSelector(selectBrowserAgentSession); const isMinimized = useAppSelector((s) => Boolean(s.dashboardLayout.minimizedCards[browserId])); + const tileZone = useAppSelector((s) => s.dashboardLayout.tiledCards[browserId]); + // Tiled geometry must track pan/zoom, but the camera lives outside React now; subscribe to the pan event ONLY while tiled and read the live getter. + const [tileTick, setTileTick] = useState(0); + useEffect(() => { + if (!tileZone) return undefined; + const onPan = (): void => setTileTick((t) => t + 1); + window.addEventListener('openswarm:canvas-pan-changed', onPan); + return () => window.removeEventListener('openswarm:canvas-pan-changed', onPan); + }, [tileZone]); + void tileTick; + const cam = getCanvasState(); + const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom); + const onTile = useCallback((zone: string): void => { + if (zone === 'restore') dispatch(clearTiledCard(browserId)); + else dispatch(setTiledCard({ cardId: browserId, zone })); + }, [dispatch, browserId]); const suspendedSnap = useAppSelector((state) => state.dashboardLayout.suspendedBrowserCards[browserId]); const endingState = useAppSelector((state) => state.dashboardLayout.endingBrowserCards[browserId]); @@ -546,8 +555,11 @@ const BrowserCard: React.FC = ({ const handleMinimize = useCallback(() => { const wv = webviewMap.current.get(activeTabId); const capture = wv?.capturePage?.(); - const park = (): void => { dispatch(toggleMinimizeCard({ cardId: browserId })); }; + let parked = false; + const park = (): void => { if (parked) return; parked = true; dispatch(toggleMinimizeCard({ cardId: browserId })); }; if (capture && typeof (capture as Promise).then === 'function') { + // capturePage can hang forever on off-screen guests (Electron 42); the timer guarantees the park. + window.setTimeout(park, 800); (capture as Promise<{ toDataURL(): string }>) .then((img) => { saveMinimizedShot(browserId, img.toDataURL()); }) .catch(() => undefined) @@ -917,19 +929,20 @@ const BrowserCard: React.FC = ({ contain: 'layout style', // Own compositor layer so hover/paint invalidations stay contained to this card. See AgentCard for full rationale. willChange: 'transform', - left: keepAliveHidden || isMinimized ? -100000 : (dragging ? cardX : displayX), - top: dragging ? cardY : displayY, - transform: dragging ? `translate3d(${dragTx}px, ${dragTy}px, 0)` : undefined, - width: displayW, - height: displayH, - borderRadius: `${c.radius.lg}px`, + left: keepAliveHidden || isMinimized ? -100000 : (tiledStyle ? tiledStyle.left : (dragging ? cardX : displayX)), + top: tiledStyle && !(keepAliveHidden || isMinimized) ? tiledStyle.top : (dragging ? cardY : displayY), + transform: tiledStyle ? tiledStyle.transform : (dragging ? `translate3d(${dragTx}px, ${dragTy}px, 0)` : undefined), + transformOrigin: tiledStyle ? tiledStyle.transformOrigin : undefined, + width: tiledStyle ? tiledStyle.width : displayW, + height: tiledStyle ? tiledStyle.height : displayH, + borderRadius: tileZone === 'fullscreen' ? '12px' : `${c.radius.lg}px`, border: agentBorder, bgcolor: c.bg.surface, boxShadow: agentShadow, overflow: 'hidden', display: 'flex', flexDirection: 'column', - zIndex: (isDragging || isResizing) ? 999999 : cardZOrder, + zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : cardZOrder, transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease', '&:hover .resize-handle': { opacity: 1 }, ...(isHighlighted && { @@ -979,21 +992,13 @@ const BrowserCard: React.FC = ({ > e.stopPropagation()} - sx={{ display: 'flex', alignItems: 'center', gap: '7px', pl: 1.25, pr: 0.75, flexShrink: 0 }} + sx={{ display: 'flex', alignItems: 'center', pl: 1.25, pr: 0.75, flexShrink: 0 }} > - - { e.stopPropagation(); handleMinimize(); }} - sx={{ ...browserLightSx('#febc2e'), }} + { dispatch(recordClosedCard({ kind: 'browser', id: browserId })); removeBrowserCardCleanly(browserId, dispatch); }} + onMinimize={handleMinimize} + onTile={onTile} + tiled={!!tileZone} /> , + icon: , snippet: session.turn_label?.label || undefined, }); } @@ -230,6 +232,7 @@ function DesktopDock({ onFocusCard(entry.id, entry.rect); }} sx={{ + position: 'relative', width: TILE, height: TILE, borderRadius: '9px', @@ -245,15 +248,15 @@ function DesktopDock({ ...(isActive && { outline: '2px solid #6aa2ff', outlineOffset: '2px' }), }} > - {entry.faviconUrl ? ( + {entry.icon} + {entry.faviconUrl && ( ) => { e.currentTarget.style.display = 'none'; }} + sx={{ position: 'absolute', width: 18, height: 18, borderRadius: '4px' }} /> - ) : ( - entry.icon )} ); diff --git a/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx index abe96e3b..18789319 100644 --- a/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx @@ -3,7 +3,9 @@ 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 { toggleMinimizeCard, setTiledCard, recordClosedCard } from '@/shared/state/dashboardLayoutSlice'; +import { removeBrowserCardCleanly } from '@/shared/browserTeardown'; +import WindowControls from '../cards/WindowControls'; import { getMinimizedShot, dropMinimizedShot } from './minimizedShots'; import type { BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice'; @@ -44,28 +46,48 @@ function MinimizedStack({ browserCards, onRestore }: MinimizedStackProps): React {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 ( { - dropMinimizedShot(bc.browser_id); - dispatch(toggleMinimizeCard({ cardId: bc.browser_id })); - onRestore(bc.browser_id, bc); - }} + onClick={restore} title={activeTab?.title || 'Browser'} + className="osw-card" sx={{ + position: 'relative', 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)' }, + '&:hover .osw-pill-lights': { opacity: 1, pointerEvents: 'auto' }, }} > + e.stopPropagation()} + sx={{ + position: 'absolute', top: 3, left: 3, zIndex: 2, display: 'flex', alignItems: 'center', + px: 1, py: 0.5, borderRadius: 999, 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 ? ( diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index 38f904cc..52b23eb4 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -35,6 +35,7 @@ declare global { getBackendPort: () => number; getWebviewPreloadPath: () => string; getAppVersion: () => Promise; + setWindowButtonsVisible?: (visible: boolean) => Promise; getBuildInfo: () => Promise<{ sha: string; shortSha: string; builtAt: string | null; channel: string }>; getUpdateStatus: () => Promise<{ status: string; info: any; error: string | null }>; getCrashRecoveryInfo?: () => Promise<{ ts: number; parent_pid: number; uptime_ms: number } | null>;