diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index d03e45c5..1af4b52e 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -58,6 +58,7 @@ import { findBrowserByWebContentsId } from '@/shared/browserRegistry'; import { byPreviewRecency } from '@/shared/previewOrder'; import { useClaudeTokens, useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext'; import SpacesStrip from '@/app/pages/Dashboard/desktop/SpacesStrip'; +import { washBackgroundUrl } from '@/shared/styles/washBackground'; import { ErrorSlime } from '@/app/components/feedback/ErrorSlime'; const SIDEBAR_MIN = 160; @@ -710,7 +711,7 @@ const AppShell: React.FC = () => { return ( `${hex}${Math.round(themeWashOpacity * 255).toString(16).padStart(2, '0')} ${fsWashStops.length > 1 ? (i / (fsWashStops.length - 1)) * 100 : 100}%`).join(', ')})` } : {}), + ...(fsActive && fsWashStops ? { backgroundImage: washBackgroundUrl(fsWashStops, themeWashOpacity), backgroundSize: '100% 100%' } : {}), }}> {/* Sidebar retired: dashboards switch via the macOS-Spaces top strip; a slim band below the spaces hot zone keeps the frameless window draggable (the sidebar's drag strip is gone). */} diff --git a/frontend/src/app/pages/AgentChat/shell/InlineSurfaceEmbeds.tsx b/frontend/src/app/pages/AgentChat/shell/InlineSurfaceEmbeds.tsx index dcc307ac..ac4547c2 100644 --- a/frontend/src/app/pages/AgentChat/shell/InlineSurfaceEmbeds.tsx +++ b/frontend/src/app/pages/AgentChat/shell/InlineSurfaceEmbeds.tsx @@ -117,6 +117,10 @@ const InlineSurfaceEmbeds: React.FC<{ c: ClaudeTokens; sessionId: string; fullsc [outputs, sessionId], ); + // ONE surface at a time: on the canvas the REAL card (beside the chat) is the browser; the chat + // mirror only takes over in full size view, where the canvas is hidden. Both at once read as two + // browsers (they aren't; the embed is a live snapshot of the same webview). + if (!fullscreen) return null; if (linkedBrowsers.length === 0 && linkedApps.length === 0) return null; const popOut = (fire: () => void): void => { diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index 53fc4ca2..0fb54e64 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -14,6 +14,7 @@ import ApplicationsWindow from '../desktop/ApplicationsWindow'; import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; import { useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext'; import { GRAIN_URL } from '@/shared/styles/grainTexture'; +import { washBackgroundUrl } from '@/shared/styles/washBackground'; import type { AgentSession } from '@/shared/state/agentsSlice'; import type { CardPosition, @@ -204,8 +205,9 @@ const DashboardCanvas: React.FC = ({ // Arc-style chrome: the mac traffic lights ride the top-edge hover, in fullscreen too (Arc/Zen both // keep the native buttons reachable in compact/fullscreen; Zen even exempts them from hover-leave). useEffect(() => { - window.openswarm?.setWindowButtonsVisible?.(headerRevealed || chromeDocked); - }, [headerRevealed, chromeDocked]); + // While a card is fullscreen its OWN lights are the window controls; showing the natives too reads as double chrome. + window.openswarm?.setWindowButtonsVisible?.(!anyFullscreen && (headerRevealed || chromeDocked)); + }, [headerRevealed, chromeDocked, anyFullscreen]); // 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. @@ -269,7 +271,7 @@ const DashboardCanvas: React.FC = ({ - {!fullscreenCardId && ( + {!anyFullscreen && ( { @@ -341,7 +343,8 @@ const DashboardCanvas: React.FC = ({ position: 'absolute', inset: 0, pointerEvents: 'none', - background: `linear-gradient(115deg, ${washStops.map((hex, i) => `${hex}${Math.round(washOpacity * 255).toString(16).padStart(2, '0')} ${washStops.length > 1 ? (i / (washStops.length - 1)) * 100 : 100}%`).join(', ')})`, + backgroundImage: washBackgroundUrl(washStops, washOpacity), + backgroundSize: '100% 100%', }} /> )} @@ -430,6 +433,7 @@ const DashboardCanvas: React.FC = ({ {/* 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. */} ; @@ -59,6 +60,7 @@ interface DashboardOverlaysProps { } const DashboardOverlays: React.FC = ({ + anyFullscreen, canvas, dashboardId, sessions, @@ -93,7 +95,8 @@ const DashboardOverlays: React.FC = ({ }) => { return ( <> - {/* Floating bottom toolbar */} + {/* Floating bottom toolbar (all floating chrome steps aside while anything is fullscreen) */} + {!anyFullscreen && ( = ({ prefillMode={toolbarPrefillMode} /> + )} {/* Desktop help pill */} + {!anyFullscreen && ( + )} {/* Arrow navigation hints when zoomed in on a card */} {focusedCardId && canvas.zoom >= 0.4 && ( @@ -131,6 +137,7 @@ const DashboardOverlays: React.FC = ({ )} {/* Floating zoom controls + minimap */} + {!anyFullscreen && ( = ({ onMinimapPan={(px, py) => canvas.actions.setState({ panX: px, panY: py, zoom: canvas.zoom })} /> + )} {/* Card search palette (Cmd+F) */} = ({ onPointerDown={(e) => e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center', mr: 0.75, flexShrink: 0 }} > - handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} /> + handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} noTileMenu={tileZone === 'fullscreen'} /> = ({ onMinimize={handleMinimize} onTile={onTile} tiled={!!tileZone} + noTileMenu={tileZone === 'fullscreen'} /> = ({ }} > e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center', flexShrink: 0, mr: 0.25 }}> - handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} /> + handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} noTileMenu={tileZone === 'fullscreen'} /> {!isMinimized && } = ({ }} > e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center' }}> - handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} /> + handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} noTileMenu={tileZone === 'fullscreen'} /> {isMinimized && ( diff --git a/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx b/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx index 59fad7d8..6fc16437 100644 --- a/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx +++ b/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx @@ -57,10 +57,15 @@ function WindowControls({ onClose, onMinimize, onTile, tiled, noTileMenu }: Wind const [menuOpen, setMenuOpen] = useState(false); // Menu DOM (12 tiles + labels, ~30 nodes) mounts on first green-dot hover, not per card at boot. const [menuHot, setMenuHot] = useState(false); + // Right-edge hosts (the minimized stack) open the menu leftward so it never clips off-window. + const [alignRight, setAlignRight] = useState(false); + const greenRef = useRef(null); const closeTimer = useRef(null); const openMenu = (): void => { if (noTileMenu) return; if (closeTimer.current) window.clearTimeout(closeTimer.current); + const rect = greenRef.current?.getBoundingClientRect(); + if (rect) setAlignRight(rect.left + 224 > window.innerWidth); if (!menuHot) { setMenuHot(true); requestAnimationFrame(() => setMenuOpen(true)); return; } setMenuOpen(true); }; @@ -84,7 +89,7 @@ function WindowControls({ onClose, onMinimize, onTile, tiled, noTileMenu }: Wind }}> {btn(RED, '×', onClose, 'Close')} {btn(YELLOW, '–', onMinimize, 'Minimize')} - { e.stopPropagation(); onTile(tiled ? 'restore' : 'fullscreen'); }} @@ -94,9 +99,10 @@ function WindowControls({ onClose, onMinimize, onTile, tiled, noTileMenu }: Wind {menuHot && diff --git a/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx b/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx index 9570afde..ae8c01be 100644 --- a/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx +++ b/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { setWorkflowsHubPosition, setWorkflowsHubSize } from '@/shared/state/dashboardLayoutSlice'; -import { useTiledStyle } from '@/app/pages/Dashboard/cards/tileZones'; +import { TILE_ZONES, useTiledStyle } from '@/app/pages/Dashboard/cards/tileZones'; import { useWC } from './uiKit'; import WorkflowsAppContent from './WorkflowsAppContent'; @@ -235,6 +235,15 @@ const WorkflowsAppCard: React.FC = ({ onPointerUp: onHeaderPointerUp, dragging: isDragging, }} + onTileZone={(zone) => { + const z = TILE_ZONES[zone]; + const vp = document.querySelector('[data-canvas-viewport]')?.getBoundingClientRect(); + if (!z || !vp) return; + const cam = getCanvasState(); + const GAP = 8; + dispatch(setWorkflowsHubPosition({ x: (z.x * vp.width + GAP - cam.panX) / cam.zoom, y: (z.y * vp.height + GAP - cam.panY) / cam.zoom })); + dispatch(setWorkflowsHubSize({ width: (z.w * vp.width - GAP * 2) / cam.zoom, height: (z.h * vp.height - GAP * 2) / cam.zoom })); + }} /> {!isFullscreen && HANDLE_DEFS.map(({ dir, css }) => ( diff --git a/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx b/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx index d23a8ae4..8f228a8f 100644 --- a/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx +++ b/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx @@ -18,7 +18,7 @@ import ComposeView from './ComposeView'; import TrashView from './TrashView'; // The three-pane Workflows body plus its title bar. The card wraps this with drag/resize geometry and passes the drag handlers in; the title bar lives here because Share needs to know which workflow is open. -const WorkflowsAppContent: React.FC<{ header: CardHeader }> = ({ header }) => { +const WorkflowsAppContent: React.FC<{ header: CardHeader; onTileZone?: (zone: string) => void }> = ({ header, onTileZone }) => { const WC = useWC(); const dispatch = useAppDispatch(); const target = useAppSelector((s) => s.dashboardLayout.workflowsAppTarget); @@ -83,9 +83,13 @@ const WorkflowsAppContent: React.FC<{ header: CardHeader }> = ({ header }) => { dispatch(closeWorkflowsApp())} onMinimize={() => dispatch(closeWorkflowsApp())} - onTile={() => dispatch(toggleWorkflowsHubFullscreen())} + onTile={(zone) => { + if (zone === 'fullscreen' || zone === 'restore') { dispatch(toggleWorkflowsHubFullscreen()); return; } + if (isFullscreen) dispatch(toggleWorkflowsHubFullscreen()); + onTileZone?.(zone); + }} tiled={isFullscreen} - noTileMenu + noTileMenu={isFullscreen} />
diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 713c4dfc..dd0b8d6e 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -714,6 +714,9 @@ const dashboardLayoutSlice = createSlice({ if (!liveIds.has(id)) { state.closedCardPositions[id] = { ...state.cards[id] }; delete state.cards[id]; + // A dead card must never keep owning a tile: an orphaned 'fullscreen' entry hides ALL chrome until reload. + delete state.tiledCards[id]; + delete state.minimizedCards[id]; } } @@ -1900,8 +1903,13 @@ export const reopenLastClosed = createAsyncThunk( ); export const selectFullscreenCardId = (state: { dashboardLayout: DashboardLayoutState }): string | null => { - const entry = Object.entries(state.dashboardLayout.tiledCards).find(([, zone]) => zone === 'fullscreen'); - return entry ? entry[0] : null; + const s = state.dashboardLayout; + const entry = Object.entries(s.tiledCards).find(([, zone]) => zone === 'fullscreen'); + if (!entry) return null; + const id = entry[0]; + // Belt over the reducer hygiene: an entry whose card is gone (any removal path) must not hold the app in fullscreen. + const exists = id in s.cards || id in s.viewCards || id in s.browserCards || id in s.notes || id in s.workflowCards; + return exists ? id : null; }; export default dashboardLayoutSlice.reducer; diff --git a/frontend/src/shared/state/subscriptionsSlice.ts b/frontend/src/shared/state/subscriptionsSlice.ts index 25fc5a58..320a1229 100644 --- a/frontend/src/shared/state/subscriptionsSlice.ts +++ b/frontend/src/shared/state/subscriptionsSlice.ts @@ -112,15 +112,21 @@ export const { setSubscriptionStatus, markSubscriptionConnected, hideProviderHea const EMPTY_CONNECTIONS: SubscriptionConnection[] = []; /** Unwraps the polymorphic `providers` shape (modern object vs legacy array). */ +let p_lastRows: SubscriptionConnection[] | null = null; +let p_lastFiltered: SubscriptionConnection[] | null = null; + export function selectSubscriptionConnections( state: WithSubscriptions, ): SubscriptionConnection[] { const providers = state.subscriptions.status?.providers; if (!providers) return EMPTY_CONNECTIONS; const rows = Array.isArray(providers) ? providers : (providers.connections ?? EMPTY_CONNECTIONS); - // The free-trial/Pro lane is an OpenSwarm-managed router row, not a connection the USER made; counting it made a fresh trial look "connected" (dead Connect-beat rows, premature onConnected, phantom green rings). + // The free-trial/Pro lane is an OpenSwarm-managed router row, not a connection the USER made; counting it made a fresh trial look "connected" (dead Connect-beat rows, premature onConnected, phantom green rings). Memoized per rows identity so useSelector consumers don't churn. + if (rows === p_lastRows && p_lastFiltered) return p_lastFiltered; const filtered = rows.filter((p) => !/\(OpenSwarm-managed\)/.test(p.name ?? '')); - return filtered.length === rows.length ? rows : filtered; + p_lastRows = rows; + p_lastFiltered = filtered.length === rows.length ? rows : filtered; + return p_lastFiltered; } export function hasAnyActiveSubscription(state: WithSubscriptions): boolean { diff --git a/frontend/src/shared/styles/washBackground.ts b/frontend/src/shared/styles/washBackground.ts new file mode 100644 index 00000000..1fd76341 --- /dev/null +++ b/frontend/src/shared/styles/washBackground.ts @@ -0,0 +1,14 @@ +// Theme wash as an SVG IMAGE, not a CSS linear-gradient: Chromium caches a decoded image as a GPU +// texture, while a window-sized procedural gradient re-rasterizes on resize and, under GPU memory +// pressure (webviews, external monitors), those rasters get DROPPED and paint as a half/blank +// rectangle (the same class as the 1.5.9 dot-grid white-patch bug; see DashboardCanvas's grid note). +export function washBackgroundUrl(stops: string[], washOpacity: number): string { + const alpha = Math.max(0, Math.min(1, washOpacity)); + const stopEls = stops.map((hex, i) => { + const offset = stops.length > 1 ? (i / (stops.length - 1)) * 100 : 100; + return ``; + }).join(''); + // x2/y2 approximate the CSS 115deg direction (25 degrees below horizontal). + const svg = `${stopEls}`; + return `url("data:image/svg+xml,${svg.replace(/#/g, '%23').replace(/'/g, '%27')}")`; +}