diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index 2f7a9f62..33a3193b 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -1,5 +1,7 @@ -import React, { type RefObject } from 'react'; +import React, { useEffect, type RefObject } from 'react'; import Box from '@mui/material/Box'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { clearTiledCard, selectFullscreenCardId } from '@/shared/state/dashboardLayoutSlice'; import DashboardHeader from './DashboardHeader'; import TetherLayer from './TetherLayer'; import DashboardCardLayer from './DashboardCardLayer'; @@ -7,6 +9,7 @@ import DashboardOverlays from './DashboardOverlays'; import DashboardEmptyState from './DashboardEmptyState'; import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; import { useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext'; +import { GRAIN_URL } from '@/shared/styles/grainTexture'; import type { AgentSession } from '@/shared/state/agentsSlice'; import type { CardPosition, @@ -161,12 +164,27 @@ const DashboardCanvas: React.FC = ({ useWebviewSuspend(browserCards, canvas.panX, canvas.panY, canvas.zoom, canvas.viewportRef); + // macOS full screen: one card owns the whole window, every piece of chrome steps aside; Esc exits. + const dispatch = useAppDispatch(); + const fullscreenCardId = useAppSelector(selectFullscreenCardId); + useEffect(() => { + if (!fullscreenCardId) return undefined; + const onKey = (e: KeyboardEvent): void => { + if (e.key !== 'Escape') return; + e.stopPropagation(); + dispatch(clearTiledCard(fullscreenCardId)); + }; + window.addEventListener('keydown', onKey, true); + return () => window.removeEventListener('keydown', onKey, true); + }, [fullscreenCardId, dispatch]); + return ( <> {/* Floating header overlay */} = ({ // p: 3 (24px) was leaving a chunky air gap between the sidebar edge and the dashboard header that read as "two disconnected panels" rather than one continuous surface. 0.75 (6px) tightens the inset so the header floats just inside the content area without losing its breathing room from the top-most pixel. p: 0.75, pb: 0, - background: `linear-gradient(to bottom, ${c.bg.page} 60%, transparent)`, + // No scrim: the header carries its own translucent pill (DashboardHeader), so a full-width + // page->transparent fade here just read as a light-leak band over the themed canvas. }} > @@ -201,6 +220,7 @@ const DashboardCanvas: React.FC = ({ {/* Canvas viewport */} = ({ }} /> )} - {gradient && gradient.length > 1 && grain > 0 && ( + {grain > 0 && ( )} @@ -304,11 +324,13 @@ const DashboardCanvas: React.FC = ({ /> )} - {sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 && Object.keys(workflowCards).length === 0 && !workflowsHub && ( + {sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 && Object.keys(workflowCards).length === 0 && !workflowsHub && !fullscreenCardId && ( )} + {/* display:contents when visible so the overlays' absolute children keep positioning against the canvas root; display:none (not unmount) so the toolbar composer draft survives fullscreen. */} + = ({ toolbarPrefill={toolbarPrefill} toolbarPrefillMode={toolbarPrefillMode} /> + ); diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 693e898c..5b4fc18f 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -11,7 +11,6 @@ import CheckIcon from '@mui/icons-material/Check'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import CancelIcon from '@mui/icons-material/Cancel'; import CloseIcon from '@mui/icons-material/Close'; -import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import TerminalIcon from '@mui/icons-material/Terminal'; import { motion } from 'framer-motion'; import { @@ -31,7 +30,11 @@ import { clearGlowingAgentCard, removeCard, recordClosedCard, + setTiledCard, + clearTiledCard, } from '@/shared/state/dashboardLayoutSlice'; +import WindowControls from './WindowControls'; +import { useTiledStyle } from './tileZones'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { QuestionForm } from '@/app/pages/AgentChat/shell/ApprovalBar'; import AgentChat from '@/app/pages/AgentChat/AgentChat'; @@ -620,9 +623,9 @@ const AgentCard: React.FC = ({ (e.target as HTMLElement).releasePointerCapture(e.pointerId); }, [computeResize, dispatch, session.id]); - const handleRemove = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); + const handleRemove = (e?: React.MouseEvent) => { + e?.stopPropagation(); + e?.preventDefault(); if (linkedWorkflowSidecarId) { dispatch(setCardSidecar({ workflowId: linkedWorkflowSidecarId, sessionId: null, kind: null })); } @@ -639,6 +642,27 @@ const AgentCard: React.FC = ({ } }; + const tileZone = useAppSelector((s) => s.dashboardLayout.tiledCards[session.id]); + const isFullscreen = tileZone === 'fullscreen'; + // Fullscreen pins the card to the viewport, so while tiled the geometry must track canvas pan/zoom. + // Chat cards read the camera via a getter (not props) to avoid re-rendering on every pan tick, so + // we subscribe to the pan event ONLY while tiled (one card at most), and read fresh camera then. + 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 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 })); + }; + // ElapsedTimer owns its own 1Hz tick so AgentCard doesn't re-render every second. @@ -692,17 +716,19 @@ const AgentCard: React.FC = ({ onBringToFront?.(session.id, 'agent')} style={{ position: 'absolute', - zIndex: isDragging || isResizing ? 999999 : cardZOrder, + zIndex: tiledStyle ? 999990 : isDragging || isResizing ? 999999 : cardZOrder, }} > = ({ contain: 'layout style', // Each card gets its own compositor layer; hover-cross used to cost 100-200ms PRESENTATION by re-painting the whole canvas. willChange: 'transform', - width: localResize ? activeW : Math.max(cardWidth, MIN_W), - height: localResize ? activeH : (expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : 'auto'), + width: tiledStyle ? tiledStyle.width : (localResize ? activeW : Math.max(cardWidth, MIN_W)), + height: tiledStyle ? tiledStyle.height : (localResize ? activeH : (expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : 'auto')), + transform: tiledStyle ? tiledStyle.transform : undefined, + transformOrigin: tiledStyle ? tiledStyle.transformOrigin : undefined, bgcolor: c.bg.surface, border: isHighlighted ? `2px solid ${c.accent.primary}` @@ -740,7 +768,7 @@ const AgentCard: React.FC = ({ : expanded ? `1px solid ${c.border.strong}` : `1px solid ${c.border.subtle}`, - borderRadius: 3, + borderRadius: isFullscreen ? '12px' : 3, p: 2, cursor: expanded ? 'default' : 'pointer', transition: noTransition @@ -884,14 +912,10 @@ const AgentCard: React.FC = ({ > e.stopPropagation()} + sx={{ display: 'flex', alignItems: 'center', mr: 0.75, flexShrink: 0 }} > - + handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} /> = ({ - e.stopPropagation()} - sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0, ml: 0.5 }} - > - - e.stopPropagation()} - sx={{ - color: c.text.ghost, - p: 0.5, - '&:hover': { color: c.status.error, bgcolor: `${c.status.errorBg}` }, - }} - > - - - - = ({ const previewRef = useRef(null); const activeViewCardId = useAppSelector((s) => s.dashboardLayout.activeViewCardId); const interactive = activeViewCardId === cardKey; + const tileZone = useAppSelector((s) => s.dashboardLayout.tiledCards[cardKey]); + const isMinimized = useAppSelector((s) => !!s.dashboardLayout.minimizedCards[cardKey]); + const tiledStyle = useTiledStyle(tileZone, panX, panY, zoom); + const isFullscreen = tileZone === 'fullscreen'; // Deselecting the card exits interact mode (click anywhere else on canvas). useEffect(() => { @@ -361,11 +366,16 @@ const DashboardViewCard: React.FC = ({ (e.target as HTMLElement).releasePointerCapture(e.pointerId); }, [computeResize, dispatch, cardKey]); - const handleRemove = (e: React.MouseEvent) => { - e.stopPropagation(); + const handleRemove = (e?: React.MouseEvent) => { + e?.stopPropagation(); dispatch(recordClosedCard({ kind: 'view', id: cardKey })); void removeViewCardCleanly(cardKey, dispatch); }; + const onMinimize = () => dispatch(toggleMinimizeCard({ cardId: cardKey })); + const onTile = (zone: string) => { + if (zone === 'restore') dispatch(clearTiledCard(cardKey)); + else dispatch(setTiledCard({ cardId: cardKey, zone })); + }; // Spawn ANOTHER independent instance of this app (own runtime + ports); the reducer picks the next #N and the lifecycle hook fits + highlights it. const handleOpenAnother = (e: React.MouseEvent) => { @@ -415,6 +425,7 @@ const DashboardViewCard: React.FC = ({ data-select-type="view-card" data-select-id={cardKey} data-select-meta={JSON.stringify({ name: output.name, description: output.description, path: output.workspace_path })} + className="osw-card" onPointerDownCapture={() => onBringToFront?.(cardKey, 'view')} onClick={(e: React.MouseEvent) => { if (justDraggedRef.current) return; @@ -429,11 +440,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: displayX, - top: displayY, - width: displayW, - height: displayH, - borderRadius: `${c.radius.lg}px`, + left: tiledStyle ? tiledStyle.left : displayX, + top: tiledStyle ? tiledStyle.top : displayY, + width: tiledStyle ? tiledStyle.width : (isMinimized ? 220 : displayW), + height: tiledStyle ? tiledStyle.height : (isMinimized ? 44 : displayH), + transform: tiledStyle ? tiledStyle.transform : undefined, + transformOrigin: tiledStyle ? tiledStyle.transformOrigin : undefined, + borderRadius: isFullscreen ? '12px' : `${c.radius.lg}px`, border: isHighlighted ? `2px solid ${c.accent.primary}` : interactive @@ -450,7 +463,7 @@ const DashboardViewCard: React.FC = ({ overflow: 'hidden', display: 'flex', flexDirection: 'column', - zIndex: (isDragging || isResizing) ? 999999 : cardZOrder, + zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : cardZOrder, transition: noTransition ? 'none' : 'box-shadow 0.2s', '&:hover .resize-handle': { opacity: 1 }, ...(isHighlighted && { @@ -517,7 +530,10 @@ const DashboardViewCard: React.FC = ({ userSelect: 'none', }} > - + e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center', flexShrink: 0, mr: 0.25 }}> + handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} /> + + {!isMinimized && } = ({ )} - {showControls && ( + {showControls && !isMinimized && ( <> {hasWorkspace && ( = ({ )} - - { 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()} - sx={{ color: c.text.ghost, p: 0.5, '&:hover': { color: c.status.error } }} - > - - - + {!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 } }} + > + + + + )} {/* Preview body */} @@ -677,7 +684,7 @@ const DashboardViewCard: React.FC = ({ {/* Resize handles */} - {HANDLE_DEFS.map(({ dir, sx }) => ( + {!isMinimized && HANDLE_DEFS.map(({ dir, sx }) => ( = ({ const c = useClaudeTokens(); const dispatch = useAppDispatch(); const palette = NOTE_PALETTE[color] || NOTE_PALETTE.yellow; + const isMinimized = useAppSelector((s) => !!s.dashboardLayout.minimizedCards[noteId]); + const tileZone = useAppSelector((s) => s.dashboardLayout.tiledCards[noteId]); const DRAG_THRESHOLD = 3; const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null); @@ -237,11 +244,19 @@ const NoteCard: React.FC = ({ (e.target as HTMLElement).releasePointerCapture(e.pointerId); }, [computeResize, dispatch, noteId]); - const handleRemove = (e: React.MouseEvent) => { - e.stopPropagation(); + const handleRemove = (e?: React.MouseEvent) => { + e?.stopPropagation(); + dispatch(clearCardWindowState(noteId)); dispatch(recordClosedCard({ kind: 'note', id: noteId })); dispatch(removeNote(noteId)); }; + const onMinimize = () => dispatch(toggleMinimizeCard({ cardId: noteId })); + const onTile = (zone: string) => { + if (zone === 'restore') dispatch(clearTiledCard(noteId)); + else dispatch(setTiledCard({ cardId: noteId, zone })); + }; + const tiledStyle = useTiledStyle(tileZone, panX, panY, zoom); + const isFullscreen = tileZone === 'fullscreen'; const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0; const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0; @@ -252,6 +267,7 @@ const NoteCard: React.FC = ({ return ( = ({ }} sx={{ position: 'absolute', - left: displayX, - top: displayY, - width: displayW, - height: displayH, + left: tiledStyle ? tiledStyle.left : displayX, + top: tiledStyle ? tiledStyle.top : displayY, + width: tiledStyle ? tiledStyle.width : (isMinimized ? 190 : displayW), + height: tiledStyle ? tiledStyle.height : (isMinimized ? 32 : displayH), + transform: tiledStyle ? tiledStyle.transform : undefined, + transformOrigin: tiledStyle ? tiledStyle.transformOrigin : undefined, // contain + willChange: own compositor layer so paint stays scoped (see AgentCard for full rationale). contain: 'layout style', willChange: 'transform', - borderRadius: `${c.radius.md}px`, + borderRadius: isFullscreen ? '12px' : `${c.radius.md}px`, bgcolor: palette.bg, border: isHighlighted ? `2px solid ${c.accent.primary}` @@ -285,7 +303,7 @@ const NoteCard: React.FC = ({ : isSelected ? `0 0 0 1px #3b82f6, ${c.shadow.md}` : c.shadow.sm, - zIndex: (isDragging || isResizing) ? 999999 : cardZOrder, + zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : cardZOrder, display: 'flex', flexDirection: 'column', '&:hover .note-controls': { opacity: 1 }, @@ -298,19 +316,27 @@ const NoteCard: React.FC = ({ onPointerUp={handleDragPointerUp} onPointerCancel={handleDragPointerUp} sx={{ - height: HEADER_H, + height: isMinimized ? '100%' : HEADER_H, flexShrink: 0, cursor: isDragging ? 'grabbing' : 'grab', display: 'flex', alignItems: 'center', - justifyContent: 'space-between', + gap: 0.75, px: 0.75, touchAction: 'none', }} > + e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center' }}> + handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} /> + + {isMinimized && ( + + {content.trim() || 'Note'} + + )} e.stopPropagation()} > = ({ - e.stopPropagation()} - > - - - - {showColorPicker && ( @@ -378,8 +391,9 @@ const NoteCard: React.FC = ({ )} - {/* Editable content */} - + {/* Editable content. Fullscreen = focus-writing mode: reading-size type in a centered column, like Bear/Arc, not 12px lost in a 2800px card. */} + {!isMinimized && ( +