mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-23 05:02:21 +02:00
[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:
@@ -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')}")`;
|
||||
}
|
||||
Reference in New Issue
Block a user