[eric] canvas: right-click menus on every card, click-zoom retry beats the shield race, dbl-click zooms out in place, tile menu can't bait, bigger chat grab band, help pill sheds the mic

This commit is contained in:
ciregenz
2026-07-28 13:52:56 -07:00
parent a0de2f5e3e
commit 28844f2ab6
10 changed files with 223 additions and 44 deletions
@@ -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<DashboardOverlaysProps> = ({
{!anyFullscreen && (
<Box sx={{ position: 'absolute', top: 14, right: 16, zIndex: 10 }}>
<HelpPill />
<CardContextMenu />
</Box>
)}
@@ -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<Props> = ({
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<Props> = ({
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<Props> = ({
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 && (
@@ -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<Props> = ({
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.
@@ -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<Props> = ({
<Box
data-select-type="view-card"
data-select-id={cardKey}
onContextMenu={(e: React.MouseEvent) => 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')}
@@ -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<Props> = ({
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');
@@ -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 (
<Box className="osw-window-lights" onPointerDown={stop}
<Box className="osw-window-lights" data-tilemenu-open={menuOpen ? '1' : undefined} onPointerDown={stop}
sx={{
display: 'flex', gap: '8px', alignItems: 'center', flex: 'none', '&:hover span': { opacity: 1 },
// Inert until the card is hovered: crossing a card can't hit-test or fire React enter/leave
@@ -0,0 +1,139 @@
import React, { useEffect, useState } from 'react';
import Box from '@mui/material/Box';
// One right-click menu for every canvas entity (chats, browsers, notes, apps, workflow cards,
// minimized pills). Cards call openCardContextMenu with their items; this overlay renders the
// native-feeling glass menu (SpacesStrip grammar) and closes on outside press / Esc / item click.
export interface CardMenuItem {
label: string;
danger?: boolean;
disabled?: boolean;
onClick: () => 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<CardMenuRequest, 'x' | 'y'>): 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<CardMenuRequest | null>(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 (
<Box
onMouseDown={(e: React.MouseEvent) => 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 ? (
<Box
component="input"
autoFocus
value={renameValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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',
}}
/>
) : (
<Box component="button" sx={itemSx} onClick={() => setRenaming(true)}>
Rename
</Box>
)
)}
{menu.items.map((item) => (
<Box
key={item.label}
component="button"
disabled={item.disabled}
sx={{
...itemSx,
...(item.danger && { color: '#ff7b72', '&:hover': { background: 'rgba(255,123,114,0.12)' } }),
}}
onClick={() => { setMenu(null); item.onClick(); }}
>
{item.label}
</Box>
))}
</Box>
);
}
export default CardContextMenu;
@@ -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<HTMLDivElement | null>(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)}
>
<Typography sx={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.72)', fontWeight: 500 }}>
{recording ? 'Listening' : transcribing ? 'Transcribing' : preparing ? `Preparing ${pct}%` : 'Help'}
Help
</Typography>
<Tooltip title={recording ? 'Stop dictation' : preparing ? 'Downloading voice model' : 'Dictate (F5)'} placement="bottom" arrow>
<Box
sx={{ display: 'flex', alignItems: 'center', color: recording ? '#fff' : 'rgba(255,255,255,0.55)' }}
onPointerDown={(e) => { e.stopPropagation(); if (!busy) pressStart(); }}
onPointerUp={(e) => { e.stopPropagation(); pressEnd(); }}
onPointerLeave={() => pressEnd()}
onClick={(e) => e.stopPropagation()}
>
{busy
? <CircularProgress size={13} thickness={5} sx={{ color: 'rgba(255,255,255,0.7)' }} />
: recording
? <MicIcon sx={{ fontSize: 16 }} />
: <MicNoneOutlinedIcon sx={{ fontSize: 15 }} />}
</Box>
</Tooltip>
</Box>
{helpOpen && <HelpPanel onClose={() => setHelpOpen(false)} />}
</Box>
@@ -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;
}
@@ -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)