From 6652309f4276d6d6d73905e42e3e3402645a1041 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 19 Jul 2026 11:18:58 -0700 Subject: [PATCH] [eric] dashboard: control/input clicks select card without yanking camera focus --- .../pages/Dashboard/canvas/DashboardCanvas.tsx | 2 +- .../Dashboard/canvas/DashboardCardLayer.tsx | 2 +- .../app/pages/Dashboard/cards/BrowserCard.tsx | 6 +++--- .../src/app/pages/Dashboard/cards/NoteCard.tsx | 6 +++--- .../interaction/useDashboardInteractions.ts | 18 +++++++++++++++++- 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index 44e94c2a..a13e13df 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -66,7 +66,7 @@ interface DashboardCanvasProps { onViewportMouseMove: (e: React.MouseEvent) => void; onViewportMouseUp: (e: React.MouseEvent) => void; onViewportDoubleClick: (e: React.MouseEvent) => void; - onCardSelect: (id: string, type: CardType, shiftKey: boolean) => void; + onCardSelect: (id: string, type: CardType, shiftKey: boolean, originTarget?: EventTarget | null) => void; onDragStart: (id: string, type: CardType) => void; onDragMove: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void; onDragEnd: (dx: number, dy: number, didDrag: boolean) => void; diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx index d8e71759..469875bd 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx @@ -51,7 +51,7 @@ interface DashboardCardLayerProps { revealSpawnedRef: RefObject>; measuredHeightsRef: RefObject>; getCanvasState: () => { panX: number; panY: number; zoom: number }; - onCardSelect: (id: string, type: CardType, shiftKey: boolean) => void; + onCardSelect: (id: string, type: CardType, shiftKey: boolean, originTarget?: EventTarget | null) => void; onDragStart: (id: string, type: CardType) => void; onDragMove: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void; onDragEnd: (dx: number, dy: number, didDrag: boolean) => void; diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index cb3cb154..e8524d25 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -164,7 +164,7 @@ interface Props { multiDragDelta?: { dx: number; dy: number } | null; // Belongs to a non-active dashboard but kept mounted-hidden so its webContents + sessionStorage survive the switch. keepAliveHidden?: boolean; - onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser', shiftKey: boolean) => void; + onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser', shiftKey: boolean, originTarget?: EventTarget | null) => void; onDragStart?: (id: string, type: 'agent' | 'view' | 'browser') => void; onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void; onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void; @@ -843,8 +843,8 @@ const BrowserCard: React.FC = ({ data-keepalive-hidden={keepAliveHidden ? '1' : undefined} 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. - if (e.button === 0 && !e.shiftKey) onCardSelect?.(browserId, 'browser', false); + // 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. + if (e.button === 0 && !e.shiftKey) onCardSelect?.(browserId, 'browser', false, e.target); }} onClick={(e: React.MouseEvent) => { if (justDraggedRef.current) return; diff --git a/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx b/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx index a6ad16b4..31741580 100644 --- a/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx @@ -64,7 +64,7 @@ interface Props { color: NoteColor; cardZOrder?: number; autoFocus?: boolean; - onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser' | 'note', shiftKey: boolean) => void; + onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser' | 'note', shiftKey: boolean, originTarget?: EventTarget | null) => void; onDragStart?: (id: string, type: 'agent' | 'view' | 'browser' | 'note') => void; onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void; onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void; @@ -261,8 +261,8 @@ const NoteCard: React.FC = ({ data-select-meta={JSON.stringify({ name: 'Note', content: content.slice(0, 60) })} onPointerDownCapture={(e: React.PointerEvent) => { onBringToFront?.(noteId, 'note'); - // Capture-phase so a click the textarea swallows still selects the note; shift keeps the bubbled toggle path. - if (e.button === 0 && !e.shiftKey) onCardSelect?.(noteId, 'note', false); + // Capture-phase so a click the textarea swallows still selects the note; shift keeps the bubbled toggle path. Pass the target so a textarea press selects without yanking the camera. + if (e.button === 0 && !e.shiftKey) onCardSelect?.(noteId, 'note', false, e.target); }} onClick={(e: React.MouseEvent) => { if (justDraggedRef.current) return; diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts index ba88f8f1..1215fca2 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts @@ -22,6 +22,19 @@ function isCardTarget(target: EventTarget | null, boundary: EventTarget | null): return false; } +const CONTROL_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A', 'WEBVIEW']); + +// True when the press landed on a real control (text field, button, browser URL bar/tabs, note textarea, webview) rather than the card's frame. Walk up ONLY to the card root so a button living above the card never counts. +function pressLandedOnControl(target: EventTarget | null | undefined): boolean { + let el = target as HTMLElement | null; + while (el) { + if (el.hasAttribute(SELECT_ATTR)) return false; + if (CONTROL_TAGS.has(el.tagName) || el.isContentEditable || el.getAttribute('role') === 'button') return true; + el = el.parentElement; + } + return false; +} + interface UseDashboardInteractionsArgs { canvas: Canvas; selection: Selection; @@ -44,7 +57,7 @@ export function useDashboardInteractions({ // Delay single-click collapse so double-click can override const clickTimerRef = useRef | null>(null); - const handleCardSelect = useCallback((id: string, type: CardType, shiftKey: boolean) => { + const handleCardSelect = useCallback((id: string, type: CardType, shiftKey: boolean, originTarget?: EventTarget | null) => { report('dashboard', 'card_clicked', { card_type: type, shift: shiftKey }); if (shiftKey) { selection.selectCard(id, type, true); @@ -54,6 +67,9 @@ export function useDashboardInteractions({ selection.selectCard(id, type, false); dispatch(bringToFront({ id, type })); + // 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;