[eric] desktop: orphaned fullscreen tile sealed, all chrome hides while fullscreen, native lights defer to card lights, wash is a GPU-cached texture, tile menu clamps at edges, chat embed exclusive to full view, workflows one-click zones

This commit is contained in:
ciregenz
2026-07-27 20:18:39 -07:00
parent cf37c76331
commit e2af99ea91
14 changed files with 85 additions and 20 deletions
@@ -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 (
<Box sx={{
display: 'flex', flexDirection: 'column', height: '100vh', bgcolor: c.bg.secondary,
...(fsActive && fsWashStops ? { backgroundImage: `linear-gradient(115deg, ${fsWashStops.map((hex, i) => `${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). */}
@@ -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 => {
@@ -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<DashboardCanvasProps> = ({
// 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<DashboardCanvasProps> = ({
</Box>
</Box>
{!fullscreenCardId && (
{!anyFullscreen && (
<MinimizedStack
browserCards={browserCards}
onRestore={(cardId, rect) => {
@@ -341,7 +343,8 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
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<DashboardCanvasProps> = ({
{/* 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. */}
<Box sx={{ display: fullscreenCardId ? 'none' : 'contents' }}>
<DashboardOverlays
anyFullscreen={anyFullscreen}
canvas={canvas}
dashboardId={dashboardId}
sessions={sessions}
@@ -25,6 +25,7 @@ type Direction = 'left' | 'right' | 'up' | 'down';
type NeighborDirections = { left: boolean; right: boolean; up: boolean; down: boolean };
interface DashboardOverlaysProps {
anyFullscreen: boolean;
canvas: Canvas;
dashboardId: string;
sessions: Record<string, AgentSession>;
@@ -59,6 +60,7 @@ interface DashboardOverlaysProps {
}
const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
anyFullscreen,
canvas,
dashboardId,
sessions,
@@ -93,7 +95,8 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
}) => {
return (
<>
{/* Floating bottom toolbar */}
{/* Floating bottom toolbar (all floating chrome steps aside while anything is fullscreen) */}
{!anyFullscreen && (
<Box sx={{ position: 'absolute', bottom: 16, left: '50%', transform: 'translateX(-50%)', zIndex: 10 }}>
<DashboardToolbar
ref={toolbarRef}
@@ -113,11 +116,14 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
prefillMode={toolbarPrefillMode}
/>
</Box>
)}
{/* Desktop help pill */}
{!anyFullscreen && (
<Box sx={{ position: 'absolute', top: 14, right: 16, zIndex: 10 }}>
<HelpPill />
</Box>
)}
{/* Arrow navigation hints when zoomed in on a card */}
{focusedCardId && canvas.zoom >= 0.4 && (
@@ -131,6 +137,7 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
)}
{/* Floating zoom controls + minimap */}
{!anyFullscreen && (
<Box sx={{ position: 'absolute', bottom: 16, right: 16, zIndex: 10 }}>
<CanvasControls
zoom={canvas.zoom}
@@ -153,6 +160,7 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
onMinimapPan={(px, py) => canvas.actions.setState({ panX: px, panY: py, zoom: canvas.zoom })}
/>
</Box>
)}
{/* Card search palette (Cmd+F) */}
<CardSearchPalette
@@ -1061,7 +1061,7 @@ const AgentCard: React.FC<Props> = ({
onPointerDown={(e) => e.stopPropagation()}
sx={{ display: 'flex', alignItems: 'center', mr: 0.75, flexShrink: 0 }}
>
<WindowControls onClose={() => handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} />
<WindowControls onClose={() => handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} noTileMenu={tileZone === 'fullscreen'} />
</Box>
<Box
sx={{
@@ -1002,6 +1002,7 @@ const BrowserCard: React.FC<Props> = ({
onMinimize={handleMinimize}
onTile={onTile}
tiled={!!tileZone}
noTileMenu={tileZone === 'fullscreen'}
/>
</Box>
<Box
@@ -636,7 +636,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} />
<WindowControls onClose={() => handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} noTileMenu={tileZone === 'fullscreen'} />
</Box>
{!isMinimized && <GridViewRoundedIcon sx={{ fontSize: 16, color: c.accent.primary, flexShrink: 0 }} />}
<Typography
@@ -342,7 +342,7 @@ const NoteCard: React.FC<Props> = ({
}}
>
<Box onPointerDown={(e) => e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center' }}>
<WindowControls onClose={() => handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} />
<WindowControls onClose={() => handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} noTileMenu={tileZone === 'fullscreen'} />
</Box>
{isMinimized && (
<Box sx={{ flex: 1, minWidth: 0, fontSize: '0.8125rem', color: palette.text, opacity: 0.75, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
@@ -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<HTMLDivElement | null>(null);
const closeTimer = useRef<number | null>(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')}
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}
<Box ref={greenRef} sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}
onMouseEnter={openMenu} onMouseLeave={scheduleClose}>
<Box component="button" type="button" aria-label={tiled ? 'Exit Full Screen' : 'Full Screen'}
onClick={(e: React.MouseEvent) => { e.stopPropagation(); onTile(tiled ? 'restore' : 'fullscreen'); }}
@@ -94,9 +99,10 @@ function WindowControls({ onClose, onMinimize, onTile, tiled, noTileMenu }: Wind
{menuHot && <Box className="osw-tilemenu" onPointerDown={stop} onClick={stop}
onMouseEnter={openMenu} onMouseLeave={scheduleClose}
sx={{
position: 'absolute', top: 19, left: -8, width: 216, background: '#FFFFFF',
position: 'absolute', top: 19, width: 216, background: '#FFFFFF',
...(alignRight ? { right: -8 } : { left: -8 }),
border: '1px solid rgba(0,0,0,0.06)', borderRadius: '12px', boxShadow: '0 .5rem 2rem rgba(0,0,0,.14)',
p: 1.25, zIndex: 1200, transformOrigin: 'top left',
p: 1.25, zIndex: 1200, transformOrigin: alignRight ? 'top right' : 'top left',
opacity: menuOpen ? 1 : 0, transform: menuOpen ? 'none' : 'translateY(-6px) scale(0.96)',
pointerEvents: menuOpen ? 'auto' : 'none', transition: 'opacity .16s, transform .18s cubic-bezier(.3,.9,.3,1)',
}}>
@@ -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<Props> = ({
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 }) => (
@@ -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 }) => {
<WindowControls
onClose={() => 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}
/>
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
@@ -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;
@@ -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 {
@@ -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 `<stop offset='${offset}%' stop-color='${hex}' stop-opacity='${alpha}'/>`;
}).join('');
// x2/y2 approximate the CSS 115deg direction (25 degrees below horizontal).
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' preserveAspectRatio='none'><defs><linearGradient id='w' x1='0%' y1='0%' x2='90%' y2='42%'>${stopEls}</linearGradient></defs><rect width='100' height='100' fill='url(%23w)'/></svg>`;
return `url("data:image/svg+xml,${svg.replace(/#/g, '%23').replace(/'/g, '%27')}")`;
}