diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx index b1651a42..d4978714 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx @@ -3,6 +3,7 @@ import Box from '@mui/material/Box'; import DashboardToolbar from '../DashboardToolbar'; import CanvasControls from '../controls/CanvasControls'; import HelpPill from '../desktop/HelpPill'; +import CardContextMenu from '../desktop/CardContextMenu'; import CardSearchPalette from '../controls/CardSearchPalette'; import DirectionHints from '../controls/DirectionHints'; import WorkflowRunningToast from '@/app/pages/Workflows/WorkflowRunningToast'; @@ -122,6 +123,7 @@ const DashboardOverlays: React.FC = ({ {!anyFullscreen && ( + )} diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index a4b3ef28..755961f7 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -19,6 +19,7 @@ import { collapseSession, expandSession, closeSession, + deleteSession, fetchSession, renameSession, } from '@/shared/state/agentsSlice'; @@ -38,6 +39,7 @@ import { import WindowControls, { ARC_CHIP_SX } from './WindowControls'; import { useTiledStyle } from './tileZones'; import AgentNarratorPill from '../desktop/AgentNarratorPill'; +import { openCardContextMenu } from '../desktop/CardContextMenu'; import { extractLatestTodos } from '../desktop/agentTodos'; import { extractLatestShowUi, extractPendingAskUi, freezeIfDone } from '@/app/pages/AgentChat/tool-ui/showUiPayload'; import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops'; @@ -802,6 +804,15 @@ const AgentCard: React.FC = ({ e.stopPropagation(); onDoubleClick?.(session.id, 'agent'); }} + onContextMenu={(e: React.MouseEvent) => openCardContextMenu(e, { + rename: { value: displayChatTitle(session), onCommit: (name) => dispatch(renameSession({ sessionId: session.id, name })) }, + items: [ + { label: expanded ? 'Collapse' : 'Open', onClick: () => dispatch(expanded ? collapseSession(session.id) : expandSession(session.id)) }, + { label: 'Full Screen', onClick: () => onTile('fullscreen') }, + { label: 'Close', onClick: () => handleRemove() }, + { label: 'Delete chat', danger: true, onClick: () => { void dispatch(deleteSession({ sessionId: session.id })); } }, + ], + })} sx={{ position: 'relative', // Hover runway for the pop-above header: the header is pointer-events:none until the CARD @@ -813,8 +824,8 @@ const AgentCard: React.FC = ({ position: 'absolute', left: 0, right: 0, - top: -40, - height: 40, + top: -48, + height: 48, }, }), // contain: streaming chat updates inside don't reflow the dashboard. Skipping `paint` here because the highlighted/selected/glow boxShadows legitimately extend past the card border, `paint` containment would clip those visuals. @@ -988,7 +999,7 @@ const AgentCard: React.FC = ({ onPointerUp={handleDragPointerUp} onPointerCancel={abortDrag} onLostPointerCapture={abortDrag} - sx={{ position: 'absolute', top: 0, left: 12, right: 12, height: 14, zIndex: 18, cursor: isDragging ? 'grabbing' : 'grab', touchAction: 'none' }} + sx={{ position: 'absolute', top: 0, left: 8, right: 8, height: 26, zIndex: 18, cursor: isDragging ? 'grabbing' : 'grab', touchAction: 'none' }} /> )} {pillMode && ( diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 1e741356..6b670bea 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -58,10 +58,12 @@ import { registerPendingLoad, wakePendingLoad, type BrowserWebview, + getWebview, } from '@/shared/browserRegistry'; import { setLastInteractedBrowser } from '@/shared/browserFocus'; import { registerCapsuleForRestore } from '@/shared/browserStateCapsule'; import BrowserFindBar from './BrowserFindBar'; +import { openCardContextMenu } from '../desktop/CardContextMenu'; import { useBrowserActivity } from '@/shared/useBrowserActivity'; import { getActionLabel } from '@/shared/browserCommandHandler'; import { resolveInput, isGoogleSearch } from '@/shared/resolveUrl'; @@ -981,6 +983,16 @@ const BrowserCard: React.FC = ({ data-select-meta={JSON.stringify({ name: activeTitle || 'Browser', url: activeUrl })} // Marks a kept-alive card parked off-screen (it belongs to another dashboard); fit-to-view must skip it or it pans the canvas to chase it and the card bleeds onto the dashboard you're viewing. data-keepalive-hidden={keepAliveHidden || isMinimized ? '1' : undefined} + onContextMenu={(e: React.MouseEvent) => openCardContextMenu(e, { + items: [ + { label: 'New Tab', onClick: () => dispatch(addBrowserTab({ browserId, url: browserHomepage })) }, + { label: 'Reload', onClick: () => { try { (getWebview(browserId) as { reload?: () => void } | undefined)?.reload?.(); } catch { /* webview gone */ } } }, + { label: 'Copy URL', onClick: () => { void navigator.clipboard.writeText(activeUrl); } }, + { label: 'Full Screen', onClick: () => onTile('fullscreen') }, + { label: 'Minimize', onClick: handleMinimize }, + { label: 'Close', danger: true, onClick: () => { dispatch(recordClosedCard({ kind: 'browser', id: browserId })); removeBrowserCardCleanly(browserId, dispatch); } }, + ], + })} onPointerDownCapture={(e: React.PointerEvent) => { onBringToFront?.(browserId, 'browser'); // Capture-phase so chrome clicks (tab strip, URL bar) the children swallow still select the card; clicks inside the guest page never reach the host at all. Shift keeps the bubbled toggle path. Pass the target so URL-bar/tab presses select without yanking the camera. diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index ac273bcf..55acf3ce 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -18,6 +18,7 @@ import { Output, SERVE_BASE } from '@/shared/state/outputsSlice'; import { setViewCardPosition, setViewCardSize, setActiveViewCardId, recordClosedCard, addViewCard, setTiledCard, clearTiledCard, toggleMinimizeCard, activateViewCardPreview } from '@/shared/state/dashboardLayoutSlice'; import { removeViewCardCleanly } from '@/shared/viewTeardown'; import WindowControls from './WindowControls'; +import { openCardContextMenu } from '../desktop/CardContextMenu'; import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops'; import { useTiledStyle } from './tileZones'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; @@ -537,6 +538,13 @@ const DashboardViewCard: React.FC = ({ openCardContextMenu(e, { + items: [ + { label: 'Full Screen', onClick: () => onTile('fullscreen') }, + { label: 'Minimize', onClick: onMinimize }, + { label: 'Close', danger: true, onClick: () => handleRemove() }, + ], + })} data-select-meta={JSON.stringify({ name: output.name, description: output.description, path: output.workspace_path })} className="osw-card" onPointerDownCapture={() => onBringToFront?.(cardKey, 'view')} diff --git a/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx b/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx index d8e907c9..f315027e 100644 --- a/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx @@ -18,6 +18,7 @@ import { import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import WindowControls from './WindowControls'; +import { openCardContextMenu } from '../desktop/CardContextMenu'; import { useTiledStyle } from './tileZones'; import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops'; @@ -297,6 +298,13 @@ const NoteCard: React.FC = ({ className="osw-card" data-select-type="note-card" data-select-id={noteId} + onContextMenu={(e: React.MouseEvent) => openCardContextMenu(e, { + items: [ + { label: 'Full Screen', onClick: () => onTile('fullscreen') }, + { label: 'Minimize', onClick: onMinimize }, + { label: 'Delete note', danger: true, onClick: () => handleRemove() }, + ], + })} data-select-meta={JSON.stringify({ name: 'Note', content: content.slice(0, 60) })} onPointerDownCapture={(e: React.PointerEvent) => { onBringToFront?.(noteId, 'note'); diff --git a/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx b/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx index 27d846bc..943d9ee3 100644 --- a/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx +++ b/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx @@ -68,7 +68,7 @@ function WindowControls({ onClose, onMinimize, onTile, tiled, noTileMenu }: Wind if (!menuHot) { setMenuHot(true); requestAnimationFrame(() => setMenuOpen(true)); return; } setMenuOpen(true); }; - const scheduleClose = (): void => { closeTimer.current = window.setTimeout(() => setMenuOpen(false), 180); }; + const scheduleClose = (): void => { closeTimer.current = window.setTimeout(() => setMenuOpen(false), 320); }; const stop = (e: React.PointerEvent | React.MouseEvent): void => { e.stopPropagation(); }; const btn = (color: string, symbol: string, onClick: () => void, label: string): React.ReactElement => ( @@ -79,7 +79,7 @@ function WindowControls({ onClose, onMinimize, onTile, tiled, noTileMenu }: Wind ); return ( - void; +} + +export interface CardMenuRequest { + x: number; + y: number; + items: CardMenuItem[]; + /** Optional inline-rename affordance: shown as the first row with an editable input. */ + rename?: { value: string; onCommit: (next: string) => void }; +} + +const EVENT = 'openswarm:card-context-menu'; + +export function openCardContextMenu(e: { clientX: number; clientY: number; preventDefault: () => void; stopPropagation: () => void }, req: Omit): void { + e.preventDefault(); + e.stopPropagation(); + window.dispatchEvent(new CustomEvent(EVENT, { detail: { ...req, x: e.clientX, y: e.clientY } })); +} + +const MENU_W = 208; + +function CardContextMenu(): React.ReactElement | null { + const [menu, setMenu] = useState(null); + const [renaming, setRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(''); + + useEffect(() => { + const onOpen = (e: Event): void => { + const req = (e as CustomEvent).detail as CardMenuRequest; + setMenu(req); + setRenaming(false); + setRenameValue(req.rename?.value ?? ''); + }; + window.addEventListener(EVENT, onOpen); + return () => window.removeEventListener(EVENT, onOpen); + }, []); + + useEffect(() => { + if (!menu) return undefined; + const onDown = (): void => setMenu(null); + const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') setMenu(null); }; + window.addEventListener('mousedown', onDown); + window.addEventListener('keydown', onKey); + window.addEventListener('wheel', onDown, { passive: true }); + return () => { + window.removeEventListener('mousedown', onDown); + window.removeEventListener('keydown', onKey); + window.removeEventListener('wheel', onDown); + }; + }, [menu]); + + if (!menu) return null; + + const itemSx = { + display: 'flex', alignItems: 'center', width: '100%', px: 1.5, py: 0.75, + border: 'none', background: 'transparent', borderRadius: '7px', + color: 'rgba(255,255,255,0.9)', fontFamily: 'inherit', fontSize: '0.8125rem', + cursor: 'pointer', textAlign: 'left' as const, + '&:hover': { background: 'rgba(255,255,255,0.1)' }, + '&:disabled': { color: 'rgba(255,255,255,0.35)', cursor: 'default', '&:hover': { background: 'transparent' } }, + }; + + const commitRename = (): void => { + const trimmed = renameValue.trim(); + if (trimmed && menu.rename && trimmed !== menu.rename.value) menu.rename.onCommit(trimmed); + setMenu(null); + }; + + return ( + e.stopPropagation()} + onContextMenu={(e: React.MouseEvent) => e.preventDefault()} + sx={{ + position: 'fixed', + top: Math.min(menu.y + 2, window.innerHeight - 44 * (menu.items.length + 1) - 16), + left: Math.min(menu.x, window.innerWidth - MENU_W - 12), + zIndex: 100001, + width: MENU_W, p: 0.5, borderRadius: '10px', + background: 'rgba(28,25,33,0.96)', + backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)', + border: '1px solid rgba(255,255,255,0.12)', + boxShadow: '0 18px 44px rgba(0,0,0,0.5)', + }} + > + {menu.rename && ( + renaming ? ( + ) => setRenameValue(e.target.value)} + onKeyDown={(e: React.KeyboardEvent) => { + e.stopPropagation(); + if (e.key === 'Enter') commitRename(); + if (e.key === 'Escape') setMenu(null); + }} + onBlur={commitRename} + sx={{ + width: '100%', boxSizing: 'border-box', mb: 0.25, px: 1.25, py: 0.6, + border: '1px solid rgba(255,255,255,0.35)', borderRadius: '7px', + background: 'rgba(0,0,0,0.35)', outline: 'none', + color: 'rgba(255,255,255,0.95)', fontFamily: 'inherit', fontSize: '0.8125rem', + }} + /> + ) : ( + setRenaming(true)}> + Rename + + ) + )} + {menu.items.map((item) => ( + { setMenu(null); item.onClick(); }} + > + {item.label} + + ))} + + ); +} + +export default CardContextMenu; diff --git a/frontend/src/app/pages/Dashboard/desktop/HelpPill.tsx b/frontend/src/app/pages/Dashboard/desktop/HelpPill.tsx index 73bacef2..33185d60 100644 --- a/frontend/src/app/pages/Dashboard/desktop/HelpPill.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/HelpPill.tsx @@ -1,22 +1,12 @@ import React, { useEffect, useRef, useState } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; -import Tooltip from '@mui/material/Tooltip'; -import CircularProgress from '@mui/material/CircularProgress'; -import MicNoneOutlinedIcon from '@mui/icons-material/MicNoneOutlined'; -import MicIcon from '@mui/icons-material/Mic'; -import { useVoice } from '@/shared/voice/voiceContext'; import HelpPanel from './HelpPanel'; -/** Top-right desktop pill: Help opens the help panel (ask, report a bug, docs); the mic dictates (local whisper) into the focused field. */ +/** Top-right desktop pill: opens the help panel (ask, report a bug, docs). Dictation lives on the composer mics + F5, not here. */ function HelpPill(): React.ReactElement { - const { state, pct, pressStart, pressEnd } = useVoice(); const [helpOpen, setHelpOpen] = useState(false); const rootRef = useRef(null); - const recording = state === 'recording'; - const transcribing = state === 'transcribing'; - const preparing = state === 'preparing'; - const busy = transcribing || preparing; // Outside click / Esc closes the panel; listeners only live while it's open. useEffect(() => { @@ -36,39 +26,23 @@ function HelpPill(): React.ReactElement { sx={{ display: 'flex', alignItems: 'center', - gap: 0.75, height: 30, - pl: 1.5, - pr: 1, + px: 1.5, borderRadius: 999, - background: recording ? 'rgba(150,30,40,0.72)' : 'rgba(22,12,34,0.66)', + background: 'rgba(22,12,34,0.66)', backdropFilter: 'blur(20px) saturate(160%)', WebkitBackdropFilter: 'blur(20px) saturate(160%)', boxShadow: '0 6px 20px rgba(0,0,0,0.3)', cursor: 'pointer', userSelect: 'none', transition: 'background 0.2s ease', + '&:hover': { background: 'rgba(22,12,34,0.8)' }, }} onClick={() => setHelpOpen((v) => !v)} > - {recording ? 'Listening' : transcribing ? 'Transcribing' : preparing ? `Preparing ${pct}%` : 'Help'} + Help - - { e.stopPropagation(); if (!busy) pressStart(); }} - onPointerUp={(e) => { e.stopPropagation(); pressEnd(); }} - onPointerLeave={() => pressEnd()} - onClick={(e) => e.stopPropagation()} - > - {busy - ? - : recording - ? - : } - - {helpOpen && setHelpOpen(false)} />} diff --git a/frontend/src/app/pages/Dashboard/desktop/desktop.css b/frontend/src/app/pages/Dashboard/desktop/desktop.css index 814e3a74..e4924f8c 100644 --- a/frontend/src/app/pages/Dashboard/desktop/desktop.css +++ b/frontend/src/app/pages/Dashboard/desktop/desktop.css @@ -4,3 +4,11 @@ body.dashboard-marquee-active * { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; } + +/* While a traffic-light tile menu is open, its lights cluster stays visible and interactive even + when the hover that revealed it has moved on (to the menu itself); otherwise the menu fades + mid-approach and the click lands on nothing. */ +.osw-pill-lights:has(.osw-window-lights[data-tilemenu-open="1"]) { + opacity: 1 !important; + pointer-events: auto !important; +} diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts index d7d128f9..7545b30f 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts @@ -73,8 +73,8 @@ export function useDashboardInteractions({ // Clicking a control INSIDE a card (text field, button, browser URL bar/tabs, note textarea) selects + raises it but must NOT re-center the camera onto it: yanking focus to a card just to click into its input is hostile (same reasoning as the guest-page and Workflows carve-outs). Card frame/body clicks still auto-focus. if (pressLandedOnControl(originTarget)) return; - // The Workflows window is an app you click around inside, not a card you re-center every tap. Single-click only raises + selects it; double-click still zoom-to-fits (handleCardDoubleClick). Without this, clicking any button inside it yanked the canvas into a re-zoom. - if (type === 'workflows-hub' || type === 'workflows-monitor') return; + // The Workflows window fits like any card on frame/header presses; pressLandedOnControl above + // already keeps taps on its buttons, rows, and inputs from yanking the camera. // A tiled (fullscreen/snapped) card is pinned Arc-style: clicking inside it must not collapse // it or glide the camera; it leaves the mode via its own controls (yellow, Esc, dock swap). @@ -90,9 +90,15 @@ export function useDashboardInteractions({ dispatch(expandSession(id)); } setFocusedCardId(id); - setTimeout(() => { - // The capture-phase select fires this on pointer DOWN; if the press became a drag (or marquee), re-framing the camera mid-gesture is the "canvas yanks as I start dragging" nudge. The webview shield class is up for exactly that window. - if (document.body.classList.contains('dashboard-marquee-active')) return; + // The capture-phase select fires on pointer DOWN; if the press became a drag (or marquee), + // re-framing mid-gesture is the "canvas yanks" nudge, so the shield class defers the fit. A slow + // CLICK with a few px of jitter also arms the shield briefly, which used to abort the fit + // entirely ("takes multiple clicks to zoom in"), so retry once the gesture settles. + const tryFit = (attempt: number): void => { + if (document.body.classList.contains('dashboard-marquee-active')) { + if (attempt < 3) setTimeout(() => tryFit(attempt + 1), 160); + return; + } const rect = getCardRect(id, type); if (rect) canvas.actions.fitToCards([rect], 1.15, true, type === 'browser' ? 0.8 : undefined); setTimeout(() => { @@ -103,7 +109,8 @@ export function useDashboardInteractions({ if (tag === 'INPUT' || tag === 'TEXTAREA' || active.isContentEditable) return; active.blur?.(); }, 150); - }, 100); + }; + setTimeout(() => tryFit(0), 100); }, [selection, getCardRect, canvas.actions, dispatch, expandedSessionIds]); const handleBringToFront = useCallback((id: string, type: CardType) => { @@ -194,12 +201,22 @@ export function useDashboardInteractions({ selection.handleCanvasMouseUp(e.nativeEvent); }, [canvas.handlers, selection]); - // Double-click empty canvas → fit all cards + // Double-click empty canvas → zoom OUT anchored at the cursor (Google Maps style). It must never + // travel: the old fit-all panned the camera to wherever the cards were, which reads as teleporting. const handleViewportDoubleClick = useCallback((e: React.MouseEvent) => { if (e.button !== 0) return; if (isCardTarget(e.target, e.currentTarget)) return; report('dashboard', 'canvas_double_clicked'); - canvas.actions.fitToView(); + const vp = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const cx = e.clientX - vp.left; + const cy = e.clientY - vp.top; + const cur = canvas.actions.getLiveState(); + const nextZoom = Math.max(0.15, cur.zoom * 0.55); + canvas.actions.animateTo({ + zoom: nextZoom, + panX: cx - ((cx - cur.panX) / cur.zoom) * nextZoom, + panY: cy - ((cy - cur.panY) / cur.zoom) * nextZoom, + }); }, [canvas.actions]); // Double-click a card → always expand + center + zoom (cancels pending collapse from single-click)