[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

This commit is contained in:
ciregenz
2026-07-20 21:42:24 -07:00
parent b20541d0ea
commit 0b19797f7f
17 changed files with 224 additions and 123 deletions
+9
View File
@@ -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());
+2
View File
@@ -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'),
@@ -628,7 +628,7 @@ const AppShell: React.FC = () => {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh', bgcolor: c.bg.secondary }}>
{sidebarAway && !sidePeek && (
{sidebarAway && !sidePeek && !fullscreenCardId && (
<Box onMouseEnter={() => { 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. */}
@@ -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<HTMLDivElement | null>(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 (
<div
ref={ref}
title="Intensity"
onPointerDown={(e) => { 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,
}}
>
<div style={{ position: 'absolute', inset: 0, borderRadius: 999, border: '2px dotted currentColor', opacity: 0.4 }} />
@@ -2375,7 +2375,6 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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'}
@@ -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<ChatInputHandle, Props>(({ 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<ChatInputHandle, Props>(({ 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<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -322,7 +320,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
editorRef={editorRef}
generalFileInputRef={generalFileInputRef}
embedded={embedded}
quietComposer={quietComposer}
isDragOver={isDragOver}
isUploading={isUploading}
handleDragOver={handleDragOver}
@@ -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<Props> = (p) => {
@@ -60,7 +58,7 @@ export const ChatInputToolbar: React.FC<Props> = (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<Props> = (p) => {
pt: 0,
}}
>
{!restMode && (
<ModelControl
c={c}
setModelAnchor={setModelAnchor}
allModelFlat={allModelFlat}
model={model}
/>
)}
<ModelControl
c={c}
setModelAnchor={setModelAnchor}
allModelFlat={allModelFlat}
model={model}
/>
<ModelPickerMenu
c={c}
@@ -138,7 +134,7 @@ export const ChatInputToolbar: React.FC<Props> = (p) => {
pendingPayloadEstimate={pendingPayloadEstimate}
/>
{!hideForTrial && !restMode && (
{!hideForTrial && (
<ThinkingLevelControl
c={c}
model={model}
@@ -153,7 +149,7 @@ export const ChatInputToolbar: React.FC<Props> = (p) => {
<Box sx={{ flex: 1 }} />
{contextEstimate && !restMode && (
{contextEstimate && (
<ContextRing
used={contextEstimate.used}
limit={contextEstimate.limit}
@@ -164,7 +160,6 @@ export const ChatInputToolbar: React.FC<Props> = (p) => {
<ToolbarActions
c={c}
restMode={restMode}
elementSelection={elementSelection}
autoRunMode={autoRunMode}
ownerId={ownerId}
@@ -24,16 +24,15 @@ interface Props {
isRunning?: boolean;
onStop?: () => void;
handleSend: () => void;
restMode?: boolean;
}
export const ToolbarActions: React.FC<Props> = ({
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 (
<Tooltip title={isMySelectMode ? 'Exit select mode' : 'Select UI element'}>
@@ -28,7 +28,6 @@ interface Props {
editorRef: RefObject<HTMLDivElement>;
generalFileInputRef: RefObject<HTMLInputElement>;
embedded?: boolean;
quietComposer?: boolean;
isDragOver: boolean;
isUploading: boolean;
handleDragOver: (e: React.DragEvent) => void;
@@ -107,18 +106,9 @@ interface Props {
export const ChatInputView: React.FC<Props> = (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 (
<Box
ref={p.containerRef}
onFocusCapture={() => 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<Props> = (p) => {
<ChatInputToolbar
c={c}
restMode={restMode}
modeConf={p.modeConf}
modesArr={p.modesArr}
mode={p.mode}
@@ -1,7 +1,8 @@
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 { clearTiledCard, setTiledCard, selectFullscreenCardId } from '@/shared/state/dashboardLayoutSlice';
import { expandSession } from '@/shared/state/agentsSlice';
import DashboardHeader from './DashboardHeader';
import TetherLayer from './TetherLayer';
import DashboardCardLayer from './DashboardCardLayer';
@@ -185,6 +186,40 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
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<DashboardCanvasProps> = ({
return (
<>
<Box sx={{ position: 'relative', height: '100%', overflow: 'hidden' }}>
{/* Top-edge hover strip: the desktop shell keeps the top chromeless; grazing it reveals the header. */}
<Box
onMouseEnter={() => setHeaderRevealed(true)}
sx={{ position: 'absolute', top: 0, left: 0, right: 0, height: 22, zIndex: 9 }}
/>
{/* Floating header overlay */}
<Box
onMouseLeave={() => setHeaderRevealed(false)}
@@ -251,25 +281,35 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
/>
)}
{!fullscreenCardId && (
<DesktopDock
sessions={sessions}
cards={cards}
viewCards={viewCards}
browserCards={browserCards}
notes={notes}
workflowCards={workflowCards}
outputs={outputs}
selectedIds={Array.from(selection.selectedIds.keys())}
onFocusCard={(cardId, rect) => {
canvas.actions.fitToCards([rect], 1.15, true);
onHighlightCard?.(cardId);
}}
onApplications={() => setAppsWindowOpen((v) => !v)}
onNewAgent={onNewAgent}
onAddBrowser={onAddBrowser}
onAddNote={onAddNote}
/>
{(!fullscreenCardId || fsDockRevealed) && (
<Box
sx={fullscreenCardId ? {
position: 'absolute', left: 0, top: 0, bottom: 0, zIndex: 999995,
display: 'flex', alignItems: 'center', pl: '4px',
animation: 'osw-fsdock-in 160ms ease-out',
'@keyframes osw-fsdock-in': { from: { transform: 'translateX(-52px)', opacity: 0 }, to: { transform: 'translateX(0)', opacity: 1 } },
} : undefined}
>
<DesktopDock
sessions={sessions}
cards={cards}
viewCards={viewCards}
browserCards={browserCards}
notes={notes}
workflowCards={workflowCards}
outputs={outputs}
selectedIds={Array.from(selection.selectedIds.keys())}
onFocusCard={(cardId, rect) => {
if (fullscreenCardId) { swapFullscreen(cardId); return; }
canvas.actions.fitToCards([rect], 1.15, true);
onHighlightCard?.(cardId);
}}
onApplications={() => setAppsWindowOpen((v) => !v)}
onNewAgent={onNewAgent}
onAddBrowser={onAddBrowser}
onAddNote={onAddNote}
/>
</Box>
)}
{appsWindowOpen && !fullscreenCardId && (
@@ -65,7 +65,7 @@ const KEYWORDS: Record<string, LucideIcon> = {
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);
@@ -17,6 +17,7 @@ import {
AgentSession,
handleApproval,
collapseSession,
expandSession,
closeSession,
fetchSession,
renameSession,
@@ -670,10 +671,18 @@ const AgentCard: React.FC<Props> = ({
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<Props> = ({
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<Props> = ({
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' } }}
>
<Box
className="osw-pill-lights osw-card"
onPointerDown={(e: React.PointerEvent) => 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',
}}
>
<WindowControls
onClose={() => handleRemove()}
onMinimize={() => dispatch(expandSession(session.id))}
onTile={(zone: string) => { dispatch(expandSession(session.id)); onTile(zone); }}
tiled={false}
/>
</Box>
<AgentNarratorPill
label={pillLabel}
running={pillRunning}
@@ -38,8 +38,12 @@ import {
moveBrowserTab,
recordClosedCard,
toggleMinimizeCard,
setTiledCard,
clearTiledCard,
type BrowserTab,
} from '@/shared/state/dashboardLayoutSlice';
import WindowControls from './WindowControls';
import { useTiledStyle } from './tileZones';
import { saveMinimizedShot } from '../desktop/minimizedShots';
import { removeBrowserCardCleanly } from '@/shared/browserTeardown';
import { createSelector } from '@reduxjs/toolkit';
@@ -71,17 +75,6 @@ const CHROME_BORDER = 'rgba(0,0,0,0.08)';
const CHROME_TEXT = '#3c3744';
const CHROME_TEXT_MUTED = '#8a8494';
const browserLightSx = (color: string): Record<string, unknown> => ({
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<Props> = ({
);
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<Props> = ({
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<unknown>).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<Props> = ({
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<Props> = ({
>
<Box
onPointerDown={(e: React.PointerEvent) => 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 }}
>
<Box
component="button"
type="button"
aria-label="Close browser"
onClick={handleRemove}
sx={{ ...browserLightSx('#ff5f57'), }}
/>
<Box
component="button"
type="button"
aria-label="Minimize browser"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleMinimize(); }}
sx={{ ...browserLightSx('#febc2e'), }}
<WindowControls
onClose={() => { dispatch(recordClosedCard({ kind: 'browser', id: browserId })); removeBrowserCardCleanly(browserId, dispatch); }}
onMinimize={handleMinimize}
onTile={onTile}
tiled={!!tileZone}
/>
</Box>
<Box
@@ -47,11 +47,10 @@ function workspaceSize(): { w: number; h: number } {
export function computeTiledStyle(zone: string, panX: number, panY: number, zoom: number): TiledStyle | null {
// 'fullscreen' = macOS full screen: the app chrome hides (AppShell/DashboardCanvas react to
// selectFullscreenCardId) and the card covers the window minus a thin PEEK sliver, the Zen/Arc
// touch where the surroundings stay ever-so-slightly visible. Target is WINDOW space here, so
// selectFullscreenCardId) and the card covers the WHOLE window. Target is WINDOW space here, so
// the viewport origin does NOT cancel; once the chrome collapses that origin goes to ~0 anyway.
if (zone === 'fullscreen') {
const PEEK = 10;
const PEEK = 0;
const el = document.querySelector('[data-canvas-viewport]');
const r = el ? el.getBoundingClientRect() : null;
const ox = r ? r.left : 0;
@@ -7,7 +7,8 @@ import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutline';
import EventRepeatIcon from '@mui/icons-material/EventRepeat';
import StickyNote2OutlinedIcon from '@mui/icons-material/StickyNote2Outlined';
import HistoryIcon from '@mui/icons-material/History';
import DashboardGlyph from '../canvas/DashboardGlyph';
import { pickIcon } from '../canvas/DashboardGlyph';
import { MessageSquare } from 'lucide-react';
import { openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
import SettingsIcon from '@mui/icons-material/Settings';
import AppsRoundedIcon from '@mui/icons-material/AppsRounded';
@@ -107,12 +108,13 @@ function DesktopDock({
const session = sessions[card.session_id];
if (!session) continue;
const title = displayChatTitle(session);
const ChatIcon = pickIcon(title) || MessageSquare;
list.push({
id: card.session_id,
label: title,
rect: card,
tileBg: hueFor(title),
icon: <DashboardGlyph name={title} size={16} color="#fff" />,
icon: <ChatIcon size={16} strokeWidth={1.75} color="#fff" />,
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 && (
<Box
component="img"
src={entry.faviconUrl}
alt=""
sx={{ width: 18, height: 18, borderRadius: '4px' }}
onError={(e: React.SyntheticEvent<HTMLImageElement>) => { e.currentTarget.style.display = 'none'; }}
sx={{ position: 'absolute', width: 18, height: 18, borderRadius: '4px' }}
/>
) : (
entry.icon
)}
</Box>
);
@@ -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 (
<Box
key={bc.browser_id}
onClick={() => {
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' },
}}
>
<Box
className="osw-pill-lights"
onClick={(e: React.MouseEvent) => 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',
}}
>
<WindowControls
onClose={() => { 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}
/>
</Box>
{shot ? (
<Box component="img" src={shot} alt="" sx={{ width: '100%', display: 'block' }} />
<Box component="img" src={shot} alt="" sx={{ width: '100%', display: 'block', borderRadius: '8px' }} />
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5, py: 1.5, px: 1 }}>
{activeTab?.favicon ? (
+1
View File
@@ -35,6 +35,7 @@ declare global {
getBackendPort: () => number;
getWebviewPreloadPath: () => string;
getAppVersion: () => Promise<string>;
setWindowButtonsVisible?: (visible: boolean) => Promise<void>;
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>;