mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 17:57:43 +02:00
[eric] dashboard: traffic-light window controls on chat/app/note cards + top glare scrim removed
This commit is contained in:
@@ -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<DashboardCanvasProps> = ({
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Box sx={{ position: 'relative', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Floating header overlay */}
|
||||
<Box
|
||||
sx={{
|
||||
display: fullscreenCardId ? 'none' : undefined,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
@@ -176,7 +194,8 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
// 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.
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', pointerEvents: 'auto' }}>
|
||||
@@ -201,6 +220,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
{/* Canvas viewport */}
|
||||
<Box
|
||||
ref={canvas.viewportRef}
|
||||
data-canvas-viewport
|
||||
onMouseDown={onViewportMouseDown}
|
||||
onMouseMove={onViewportMouseMove}
|
||||
onMouseUp={onViewportMouseUp}
|
||||
@@ -229,14 +249,14 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{gradient && gradient.length > 1 && grain > 0 && (
|
||||
{grain > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
opacity: grain * 0.6,
|
||||
backgroundImage: "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E\")",
|
||||
opacity: grain,
|
||||
backgroundImage: GRAIN_URL,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -304,11 +324,13 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{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 && (
|
||||
<DashboardEmptyState c={c} onLaunch={onToolbarSend} onStarter={onStarter} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 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
|
||||
canvas={canvas}
|
||||
dashboardId={dashboardId}
|
||||
@@ -339,6 +361,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
toolbarPrefill={toolbarPrefill}
|
||||
toolbarPrefillMode={toolbarPrefillMode}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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<Props> = ({
|
||||
(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<Props> = ({
|
||||
}
|
||||
};
|
||||
|
||||
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<Props> = ({
|
||||
<motion.div
|
||||
layout={false}
|
||||
initial={spawnInitial}
|
||||
animate={{ opacity: 1, scale: 1, left: activeX, top: activeY }}
|
||||
animate={{ opacity: 1, scale: 1, left: tiledStyle ? tiledStyle.left : activeX, top: tiledStyle ? tiledStyle.top : activeY }}
|
||||
exit={exitAnimation}
|
||||
transition={spawnTransition}
|
||||
// While tiled the card is pinned to the viewport: position must track pan instantly, never spring.
|
||||
transition={tiledStyle ? { ...spawnTransition, left: { duration: 0 }, top: { duration: 0 } } : spawnTransition}
|
||||
onPointerDownCapture={() => onBringToFront?.(session.id, 'agent')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
zIndex: isDragging || isResizing ? 999999 : cardZOrder,
|
||||
zIndex: tiledStyle ? 999990 : isDragging || isResizing ? 999999 : cardZOrder,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
ref={cardBoxRef}
|
||||
className="osw-card"
|
||||
data-select-type="agent-card"
|
||||
data-select-id={session.id}
|
||||
data-select-meta={JSON.stringify({ name: session.name || session.id, status: session.status, model: session.model, mode: session.mode })}
|
||||
@@ -726,8 +752,10 @@ const AgentCard: React.FC<Props> = ({
|
||||
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<Props> = ({
|
||||
: 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<Props> = ({
|
||||
>
|
||||
<Box
|
||||
className="drag-handle"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 0.5,
|
||||
color: c.text.ghost,
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ display: 'flex', alignItems: 'center', mr: 0.75, flexShrink: 0 }}
|
||||
>
|
||||
<DragIndicatorIcon sx={{ fontSize: 16 }} />
|
||||
<WindowControls onClose={() => handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} />
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
@@ -958,25 +982,6 @@ const AgentCard: React.FC<Props> = ({
|
||||
</Tooltip>
|
||||
</Fade>
|
||||
</Box>
|
||||
<Box
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0, ml: 0.5 }}
|
||||
>
|
||||
<Tooltip title={isDraft ? 'Remove' : 'Close chat'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
sx={{
|
||||
color: c.text.ghost,
|
||||
p: 0.5,
|
||||
'&:hover': { color: c.status.error, bgcolor: `${c.status.errorBg}` },
|
||||
}}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
|
||||
@@ -7,7 +7,6 @@ import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
|
||||
import VisibilityRoundedIcon from '@mui/icons-material/VisibilityRounded';
|
||||
import CodeRoundedIcon from '@mui/icons-material/CodeRounded';
|
||||
@@ -16,8 +15,10 @@ import HistoryRoundedIcon from '@mui/icons-material/HistoryRounded';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import KeyboardArrowUpRounded from '@mui/icons-material/KeyboardArrowUpRounded';
|
||||
import { Output, SERVE_BASE } from '@/shared/state/outputsSlice';
|
||||
import { setViewCardPosition, setViewCardSize, setActiveViewCardId, recordClosedCard, addViewCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { setViewCardPosition, setViewCardSize, setActiveViewCardId, recordClosedCard, addViewCard, setTiledCard, clearTiledCard, toggleMinimizeCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { removeViewCardCleanly } from '@/shared/viewTeardown';
|
||||
import WindowControls from './WindowControls';
|
||||
import { useTiledStyle } from './tileZones';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { API_BASE, getAuthToken } from '@/shared/config';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
@@ -135,6 +136,10 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
const previewRef = useRef<ViewPreviewHandle>(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<Props> = ({
|
||||
(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<Props> = ({
|
||||
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<Props> = ({
|
||||
// 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<Props> = ({
|
||||
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<Props> = ({
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<GridViewRoundedIcon sx={{ fontSize: 16, color: c.accent.primary, flexShrink: 0 }} />
|
||||
<Box onPointerDown={(e) => e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center', flexShrink: 0, mr: 0.25 }}>
|
||||
<WindowControls onClose={() => handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} />
|
||||
</Box>
|
||||
{!isMinimized && <GridViewRoundedIcon sx={{ fontSize: 16, color: c.accent.primary, flexShrink: 0 }} />}
|
||||
<Typography
|
||||
sx={{
|
||||
flex: 1,
|
||||
@@ -537,7 +553,7 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{showControls && (
|
||||
{showControls && !isMinimized && (
|
||||
<>
|
||||
{hasWorkspace && (
|
||||
<Box
|
||||
@@ -616,27 +632,18 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tooltip title={headerCollapsed ? 'Show toolbar' : 'Hide toolbar'} placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => { e.stopPropagation(); setHeaderPeek(false); setHeaderCollapsed((v) => !v); }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ color: c.text.ghost, p: 0.5, '&:hover': { color: c.text.primary } }}
|
||||
>
|
||||
<KeyboardArrowUpRounded sx={{ fontSize: 18, transition: 'transform 0.15s', transform: headerCollapsed ? 'rotate(180deg)' : 'none' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Remove from dashboard" placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ color: c.text.ghost, p: 0.5, '&:hover': { color: c.status.error } }}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{!isMinimized && (
|
||||
<Tooltip title={headerCollapsed ? 'Show toolbar' : 'Hide toolbar'} placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => { e.stopPropagation(); setHeaderPeek(false); setHeaderCollapsed((v) => !v); }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ color: c.text.ghost, p: 0.5, '&:hover': { color: c.text.primary } }}
|
||||
>
|
||||
<KeyboardArrowUpRounded sx={{ fontSize: 18, transition: 'transform 0.15s', transform: headerCollapsed ? 'rotate(180deg)' : 'none' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Preview body */}
|
||||
@@ -677,7 +684,7 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
</Box>
|
||||
|
||||
{/* Resize handles */}
|
||||
{HANDLE_DEFS.map(({ dir, sx }) => (
|
||||
{!isMinimized && HANDLE_DEFS.map(({ dir, sx }) => (
|
||||
<Box
|
||||
key={dir}
|
||||
className="resize-handle"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import PaletteOutlinedIcon from '@mui/icons-material/PaletteOutlined';
|
||||
import {
|
||||
setNotePosition,
|
||||
@@ -10,10 +9,16 @@ import {
|
||||
updateNoteContent,
|
||||
setNoteColor,
|
||||
recordClosedCard,
|
||||
toggleMinimizeCard,
|
||||
setTiledCard,
|
||||
clearTiledCard,
|
||||
clearCardWindowState,
|
||||
NoteColor,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import WindowControls from './WindowControls';
|
||||
import { useTiledStyle } from './tileZones';
|
||||
|
||||
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
|
||||
|
||||
@@ -81,6 +86,8 @@ const NoteCard: React.FC<Props> = ({
|
||||
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<Props> = ({
|
||||
(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<Props> = ({
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="osw-card"
|
||||
data-select-type="note-card"
|
||||
data-select-id={noteId}
|
||||
data-select-meta={JSON.stringify({ name: 'Note', content: content.slice(0, 60) })}
|
||||
@@ -266,14 +282,16 @@ const NoteCard: React.FC<Props> = ({
|
||||
}}
|
||||
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<Props> = ({
|
||||
: 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<Props> = ({
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<Box onPointerDown={(e) => e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<WindowControls onClose={() => handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} />
|
||||
</Box>
|
||||
{isMinimized && (
|
||||
<Box sx={{ flex: 1, minWidth: 0, fontSize: '0.8rem', color: palette.text, opacity: 0.75, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{content.trim() || 'Note'}
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
className="note-controls"
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.25, opacity: 0, transition: 'opacity 0.15s' }}
|
||||
sx={{ ml: 'auto', opacity: 0, transition: 'opacity 0.15s', display: isMinimized ? 'none' : 'flex' }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<IconButton
|
||||
@@ -321,19 +347,6 @@ const NoteCard: React.FC<Props> = ({
|
||||
<PaletteOutlinedIcon sx={{ fontSize: 13 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box
|
||||
className="note-controls"
|
||||
sx={{ opacity: 0, transition: 'opacity 0.15s' }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
sx={{ p: 0.25, color: palette.text, opacity: 0.55, '&:hover': { opacity: 1, bgcolor: 'rgba(0,0,0,0.06)' } }}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 13 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{showColorPicker && (
|
||||
@@ -378,8 +391,9 @@ const NoteCard: React.FC<Props> = ({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Editable content */}
|
||||
<Box sx={{ flex: 1, p: 1, pt: 0.25, display: 'flex', minHeight: 0 }}>
|
||||
{/* Editable content. Fullscreen = focus-writing mode: reading-size type in a centered column, like Bear/Arc, not 12px lost in a 2800px card. */}
|
||||
{!isMinimized && (
|
||||
<Box sx={{ flex: 1, p: 1, pt: 0.25, display: 'flex', justifyContent: 'center', minHeight: 0 }}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
@@ -396,15 +410,17 @@ const NoteCard: React.FC<Props> = ({
|
||||
background: 'transparent',
|
||||
color: palette.text,
|
||||
fontFamily: c.font.sans,
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.45,
|
||||
padding: 0,
|
||||
fontSize: isFullscreen ? 'clamp(1.1rem, 1.3vw, 1.5rem)' : '0.85rem',
|
||||
lineHeight: isFullscreen ? 1.6 : 1.45,
|
||||
padding: isFullscreen ? '4vh 0 0' : 0,
|
||||
maxWidth: isFullscreen ? 'min(72ch, 82%)' : undefined,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Resize handles */}
|
||||
{HANDLE_DEFS.map(({ dir, sx }) => (
|
||||
{!isMinimized && HANDLE_DEFS.map(({ dir, sx }) => (
|
||||
<Box
|
||||
key={dir}
|
||||
onPointerDown={handleResizeDown(dir)}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { TILE_ZONES } from './tileZones';
|
||||
|
||||
interface WindowControlsProps {
|
||||
onClose: () => void;
|
||||
onMinimize: () => void;
|
||||
onTile: (zone: string) => void; // a TILE_ZONES key, or 'restore'
|
||||
tiled?: boolean;
|
||||
}
|
||||
|
||||
// macOS-style traffic lights on every card = the "AI OS" window feel. Grey at rest so a canvas
|
||||
// full of cards isn't a wall of color; they colorize when the parent .osw-card is hovered, and the
|
||||
// × – + symbols reveal on hovering the group. Hovering the GREEN dot opens the tiling menu (Fill,
|
||||
// Halves, Quarters, Thirds), exactly like macOS; clicking green = Full Screen (or restore if tiled).
|
||||
const RED = '#ff5f57';
|
||||
const YELLOW = '#febc2e';
|
||||
const GREEN = '#28c840';
|
||||
|
||||
const GROUPS: { label: string; zones: string[] }[] = [
|
||||
{ label: 'Fill & Halves', zones: ['fill', 'left', 'right', 'top', 'bottom'] },
|
||||
{ label: 'Quarters', zones: ['tl', 'tr', 'bl', 'br'] },
|
||||
{ label: 'Thirds', zones: ['t3l', 't3c', 't3r'] },
|
||||
];
|
||||
|
||||
const dotSx = (color: string): Record<string, unknown> => ({
|
||||
width: 12, height: 12, p: 0, m: 0, borderRadius: '50%', border: '0.5px solid rgba(0,0,0,0.06)',
|
||||
background: '#cccac4', cursor: 'pointer', position: 'relative', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', lineHeight: 1, transition: 'background 150ms',
|
||||
'.osw-card:hover &': { background: color },
|
||||
'& > span': { fontSize: 9, fontWeight: 800, lineHeight: 1, color: 'rgba(0,0,0,0.5)', opacity: 0, transition: 'opacity 120ms', pointerEvents: 'none' },
|
||||
});
|
||||
|
||||
function WindowControls({ onClose, onMinimize, onTile, tiled }: WindowControlsProps): React.ReactElement {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const closeTimer = useRef<number | null>(null);
|
||||
const openMenu = (): void => { if (closeTimer.current) window.clearTimeout(closeTimer.current); setMenuOpen(true); };
|
||||
const scheduleClose = (): void => { closeTimer.current = window.setTimeout(() => setMenuOpen(false), 180); };
|
||||
const stop = (e: React.PointerEvent | React.MouseEvent): void => { e.stopPropagation(); };
|
||||
|
||||
const btn = (color: string, symbol: string, onClick: () => void, label: string): React.ReactElement => (
|
||||
<Box component="button" type="button" aria-label={label}
|
||||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); onClick(); }} onPointerDown={stop} sx={dotSx(color)}>
|
||||
<span>{symbol}</span>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box className="osw-window-lights" onPointerDown={stop}
|
||||
sx={{ display: 'flex', gap: '8px', alignItems: 'center', flex: 'none', '&:hover span': { opacity: 1 } }}>
|
||||
{btn(RED, '×', onClose, 'Close')}
|
||||
{btn(YELLOW, '–', onMinimize, 'Minimize')}
|
||||
<Box 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'); }}
|
||||
onPointerDown={stop} sx={dotSx(GREEN)}>
|
||||
<span>{tiled ? '–' : '+'}</span>
|
||||
</Box>
|
||||
<Box className="osw-tilemenu" onPointerDown={stop} onClick={stop}
|
||||
onMouseEnter={openMenu} onMouseLeave={scheduleClose}
|
||||
sx={{
|
||||
position: 'absolute', top: 19, left: -8, width: 216, background: '#FFFFFF',
|
||||
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',
|
||||
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)',
|
||||
}}>
|
||||
{GROUPS.map((g) => (
|
||||
<Box key={g.label} sx={{ mb: 0.75, '&:last-of-type': { mb: 0 } }}>
|
||||
<Box sx={{ fontSize: '0.62rem', fontWeight: 700, letterSpacing: '0.08em', color: 'rgba(115,114,108,0.65)', textTransform: 'uppercase', mb: 0.5 }}>{g.label}</Box>
|
||||
<Box sx={{ display: 'flex', gap: '7px' }}>
|
||||
{g.zones.map((zone) => {
|
||||
const z = TILE_ZONES[zone];
|
||||
return (
|
||||
<Box key={zone} role="button" aria-label={zone}
|
||||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); setMenuOpen(false); onTile(zone); }}
|
||||
sx={{ position: 'relative', flex: 1, height: 32, border: '1px solid rgba(0,0,0,0.08)', borderRadius: '6px', background: '#F5F4ED', cursor: 'pointer', overflow: 'hidden', transition: 'border-color .12s, background .12s', '&:hover': { borderColor: '#ae5630', background: '#ae56300d' } }}>
|
||||
<Box sx={{ position: 'absolute', left: `${z.x * 100 + 8}%`, top: `${z.y * 100 + 14}%`, width: `${z.w * 100 - 16}%`, height: `${z.h * 100 - 28}%`, background: '#ae5630', opacity: 0.8, borderRadius: '2px' }} />
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default WindowControls;
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
|
||||
// macOS-style tiling zones (fractions of the workspace) + the math to render a tiled card at a
|
||||
// screen-space viewport region WITHOUT leaving the transformed canvas layer, so a browser/app
|
||||
// card's webview is never remounted (which would log the user out). The card stays a child of the
|
||||
// pan/zoom layer; we counter-transform (scale 1/zoom) and place it in canvas coords so it lands
|
||||
// pixel-exact at the viewport region at 100% content scale. Recompute on pan/zoom to stay put.
|
||||
|
||||
export const TILE_ZONES: Record<string, { x: number; y: number; w: number; h: number }> = {
|
||||
fill: { x: 0, y: 0, w: 1, h: 1 },
|
||||
left: { x: 0, y: 0, w: 0.5, h: 1 },
|
||||
right: { x: 0.5, y: 0, w: 0.5, h: 1 },
|
||||
top: { x: 0, y: 0, w: 1, h: 0.5 },
|
||||
bottom: { x: 0, y: 0.5, w: 1, h: 0.5 },
|
||||
tl: { x: 0, y: 0, w: 0.5, h: 0.5 },
|
||||
tr: { x: 0.5, y: 0, w: 0.5, h: 0.5 },
|
||||
bl: { x: 0, y: 0.5, w: 0.5, h: 0.5 },
|
||||
br: { x: 0.5, y: 0.5, w: 0.5, h: 0.5 },
|
||||
t3l: { x: 0, y: 0, w: 1 / 3, h: 1 },
|
||||
t3c: { x: 1 / 3, y: 0, w: 1 / 3, h: 1 },
|
||||
t3r: { x: 2 / 3, y: 0, w: 1 / 3, h: 1 },
|
||||
};
|
||||
|
||||
// macOS Sequoia leaves a small gap between tiled windows; we match it.
|
||||
const GAP = 8;
|
||||
|
||||
export interface TiledStyle {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
transform: string;
|
||||
transformOrigin: string;
|
||||
}
|
||||
|
||||
// The workspace = the canvas viewport element (already below the app header, dock floats over it),
|
||||
// measured live so we never hardcode chrome sizes that drift. Its screen origin cancels out of the
|
||||
// math below because the card shares the viewport's coordinate system, so we only need its size.
|
||||
function workspaceSize(): { w: number; h: number } {
|
||||
const el = document.querySelector('[data-canvas-viewport]');
|
||||
if (el) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0) return { w: r.width, h: r.height };
|
||||
}
|
||||
return { w: window.innerWidth, h: window.innerHeight };
|
||||
}
|
||||
|
||||
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
|
||||
// the viewport origin does NOT cancel; once the chrome collapses that origin goes to ~0 anyway.
|
||||
if (zone === 'fullscreen') {
|
||||
const PEEK = 10;
|
||||
const el = document.querySelector('[data-canvas-viewport]');
|
||||
const r = el ? el.getBoundingClientRect() : null;
|
||||
const ox = r ? r.left : 0;
|
||||
const oy = r ? r.top : 0;
|
||||
return {
|
||||
left: (PEEK - ox - panX) / zoom,
|
||||
top: (PEEK - oy - panY) / zoom,
|
||||
width: window.innerWidth - PEEK * 2,
|
||||
height: window.innerHeight - PEEK * 2,
|
||||
transform: `scale(${1 / zoom})`,
|
||||
transformOrigin: 'top left',
|
||||
};
|
||||
}
|
||||
const z = TILE_ZONES[zone];
|
||||
if (!z) return null;
|
||||
const { w: vpW, h: vpH } = workspaceSize();
|
||||
// Screen region (vpX + GAP, vpY + GAP, ...) converted to canvas coords: card lives inside the
|
||||
// pan/zoom layer, so screen = viewportOrigin + pan + canvasPos*zoom, and viewportOrigin cancels.
|
||||
return {
|
||||
left: (z.x * vpW + GAP - panX) / zoom,
|
||||
top: (z.y * vpH + GAP - panY) / zoom,
|
||||
width: z.w * vpW - GAP * 2,
|
||||
height: z.h * vpH - GAP * 2,
|
||||
transform: `scale(${1 / zoom})`,
|
||||
transformOrigin: 'top left',
|
||||
};
|
||||
}
|
||||
|
||||
// Tiled geometry depends on live DOM measurements, so re-render when the workspace resizes:
|
||||
// the chrome collapsing on fullscreen-enter, a window resize, a banner appearing. The initial
|
||||
// ResizeObserver fire also re-measures right after the same-commit layout change that set the zone.
|
||||
export function useTiledStyle(zone: string | undefined, panX: number, panY: number, zoom: number): TiledStyle | null {
|
||||
const [, bump] = React.useReducer((n: number) => n + 1, 0);
|
||||
React.useEffect(() => {
|
||||
if (!zone) return undefined;
|
||||
const el = document.querySelector('[data-canvas-viewport]');
|
||||
const ro = new ResizeObserver(() => bump());
|
||||
if (el) ro.observe(el);
|
||||
const onResize = (): void => bump();
|
||||
window.addEventListener('resize', onResize);
|
||||
// The chrome collapse commits in the same flush that set the zone, so the first compute
|
||||
// measures the pre-collapse viewport; re-measure after layout settles. Timeouts, not rAF:
|
||||
// rAF (and ResizeObserver delivery, which rides it) freezes in non-focused tabs.
|
||||
// 700ms outlives the banner Collapse (350ms) plus easing tail; RO covers focused tabs live.
|
||||
const timers = [60, 250, 700].map((ms) => window.setTimeout(() => bump(), ms));
|
||||
return () => { ro.disconnect(); window.removeEventListener('resize', onResize); timers.forEach((t) => window.clearTimeout(t)); };
|
||||
}, [zone]);
|
||||
return zone ? computeTiledStyle(zone, panX, panY, zoom) : null;
|
||||
}
|
||||
@@ -144,6 +144,10 @@ export interface DashboardLayoutState {
|
||||
recentlyClosed: ClosedCard[];
|
||||
glowingBrowserCards: Record<string, { sourceId: string; fading: boolean; label?: string }>;
|
||||
glowingAgentCards: Record<string, { sourceId: string; fading: boolean; sourceYRatio?: number; label?: string }>;
|
||||
/** Window controls: cards collapsed to a title pill (many at once). Keyed by any card id (session/note/browser/view/workflow). */
|
||||
minimizedCards: Record<string, boolean>;
|
||||
/** macOS-style tiling: card id -> zone ('fullscreen' | 'fill' | 'left'|'right'|'top'|'bottom' | 'tl'|'tr'|'bl'|'br' | 't3l'|'t3c'|'t3r'). A tiled card renders at that viewport region (webview stays mounted); 'fullscreen' also hides the app chrome. */
|
||||
tiledCards: Record<string, string>;
|
||||
persistedExpandedSessionIds: string[];
|
||||
nextZOrder: number;
|
||||
loading: boolean;
|
||||
@@ -193,6 +197,8 @@ const initialState: DashboardLayoutState = {
|
||||
recentlyClosed: [],
|
||||
glowingBrowserCards: {},
|
||||
glowingAgentCards: {},
|
||||
minimizedCards: {},
|
||||
tiledCards: {},
|
||||
persistedExpandedSessionIds: [],
|
||||
nextZOrder: 1,
|
||||
loading: false,
|
||||
@@ -530,6 +536,34 @@ const dashboardLayoutSlice = createSlice({
|
||||
name: 'dashboardLayout',
|
||||
initialState,
|
||||
reducers: {
|
||||
// Window controls (traffic lights). Minimize toggles a per-card pill; tiling snaps a card to a
|
||||
// macOS-style viewport zone (green = 'fill'). Minimizing an un-tiles and vice-versa, so a card
|
||||
// is never both pill'd and tiled at once.
|
||||
toggleMinimizeCard(state, action: PayloadAction<{ cardId: string }>) {
|
||||
const id = action.payload.cardId;
|
||||
if (state.minimizedCards[id]) {
|
||||
delete state.minimizedCards[id];
|
||||
} else {
|
||||
state.minimizedCards[id] = true;
|
||||
if (state.tiledCards[id]) delete state.tiledCards[id];
|
||||
}
|
||||
},
|
||||
setTiledCard(state, action: PayloadAction<{ cardId: string; zone: string }>) {
|
||||
const { cardId, zone } = action.payload;
|
||||
state.tiledCards[cardId] = zone;
|
||||
if (state.minimizedCards[cardId]) delete state.minimizedCards[cardId];
|
||||
},
|
||||
clearTiledCard(state, action: PayloadAction<string>) {
|
||||
if (state.tiledCards[action.payload]) delete state.tiledCards[action.payload];
|
||||
},
|
||||
clearAllTiles(state) {
|
||||
state.tiledCards = {};
|
||||
},
|
||||
clearCardWindowState(state, action: PayloadAction<string>) {
|
||||
const id = action.payload;
|
||||
if (state.minimizedCards[id]) delete state.minimizedCards[id];
|
||||
if (state.tiledCards[id]) delete state.tiledCards[id];
|
||||
},
|
||||
setCardPosition(
|
||||
state,
|
||||
action: PayloadAction<{ sessionId: string; x: number; y: number }>
|
||||
@@ -1729,6 +1763,11 @@ export const {
|
||||
setGlowingAgentCard,
|
||||
fadeGlowingAgentCard,
|
||||
clearGlowingAgentCard,
|
||||
toggleMinimizeCard,
|
||||
setTiledCard,
|
||||
clearTiledCard,
|
||||
clearAllTiles,
|
||||
clearCardWindowState,
|
||||
clearPendingFocusBrowserId,
|
||||
clearPendingFocusViewCardId,
|
||||
addWorkflowCard,
|
||||
@@ -1783,4 +1822,9 @@ 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;
|
||||
};
|
||||
|
||||
export default dashboardLayoutSlice.reducer;
|
||||
|
||||
Reference in New Issue
Block a user