From 0ccc3af040330cca43e9645668f4f38bb7ad2041 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 20 Aug 2026 22:22:48 -0700 Subject: [PATCH] [eric] canvas: a resize ends when the hand lets go, webview or not (ENG-377) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01En8dRGsJPLrJCQBEkTH4Mp --- .../app/pages/Dashboard/cards/AgentCard.tsx | 109 ++-------------- .../app/pages/Dashboard/cards/BrowserCard.tsx | 85 ++---------- .../Dashboard/cards/DashboardViewCard.tsx | 85 ++---------- .../Dashboard/cards/useCanvasWindowResize.ts | 123 ++++++++++++------ 4 files changed, 118 insertions(+), 284 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 6e99b8b7..81b01eb1 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -61,7 +61,7 @@ import { isCanvasInteractionActive, onCanvasInteractionEnd } from '@/shared/canv import { setCardSidecar } from '@/shared/state/workflowsSlice'; import { openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice'; import { friendlyStatusLabel } from '@/shared/statusLabel'; -import { RESIZE_HANDLE_DEFS, RESIZE_CURSOR, type ResizeDir } from './cardResizeHandles'; +import { useCanvasWindowResize } from './useCanvasWindowResize'; /** Extract up to 3 substantive user-prompt steps to seed a workflow. */ function isWorkflowSuggestionTool(toolName: unknown, mcpServer?: unknown): boolean { @@ -557,93 +557,16 @@ const AgentCard: React.FC = ({ }, [finalizeDrag]); useDragEndBackstops(isDragging, finalizeDrag, abortDrag); - const resizeRef = useRef<{ - dir: ResizeDir; - startX: number; - startY: number; - origX: number; - origY: number; - origW: number; - origH: number; - } | null>(null); - const [isResizing, setIsResizing] = useState(false); - const [localResize, setLocalResize] = useState<{ x: number; y: number; w: number; h: number } | null>(null); - - const handleResizeDown = useCallback( - (dir: ResizeDir) => (e: React.PointerEvent) => { - if (e.button !== 0) return; - e.preventDefault(); - e.stopPropagation(); - let effectiveX = cardX; - let effectiveY = cardY; - let effectiveW = Math.max(cardWidth, MIN_W); - let effectiveH = expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : cardHeight; - const popped = tiling.untileForResize(); - if (popped) { - effectiveX = popped.x; effectiveY = popped.y; effectiveW = popped.w; effectiveH = popped.h; - setLocalResize({ x: effectiveX, y: effectiveY, w: effectiveW, h: effectiveH }); - } - resizeRef.current = { - dir, - startX: e.clientX, - startY: e.clientY, - origX: effectiveX, - origY: effectiveY, - origW: effectiveW, - origH: effectiveH, - }; - setIsResizing(true); - (e.target as HTMLElement).setPointerCapture(e.pointerId); - }, - [cardX, cardY, cardWidth, cardHeight, expanded, tiling], - ); - - const computeResize = useCallback( - (e: React.PointerEvent) => { - if (!resizeRef.current) return null; - const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current; - const z = getCanvasState().zoom; - const dx = (e.clientX - startX) / z; - const dy = (e.clientY - startY) / z; - - let newX = origX, newY = origY, newW = origW, newH = origH; - - if (dir.includes('e')) newW = origW + dx; - if (dir.includes('w')) { newW = origW - dx; newX = origX + dx; } - if (dir.includes('s')) newH = origH + dy; - if (dir.includes('n')) { newH = origH - dy; newY = origY + dy; } - - // An enlarged card can't be shrunk below its content-showing height, else the user resizes the - // chat down until the transcript vanishes (which felt broken). Collapsed cards keep the tiny floor. - const minH = expanded ? EXPANDED_OVERLAY_H : MIN_H; - if (newW < MIN_W) { if (dir.includes('w')) newX = origX + origW - MIN_W; newW = MIN_W; } - if (newH < minH) { if (dir.includes('n')) newY = origY + origH - minH; newH = minH; } - - return { x: newX, y: newY, w: newW, h: newH }; - }, - [getCanvasState, expanded], - ); - - const handleResizeMove = useCallback( - (e: React.PointerEvent) => { - const result = computeResize(e); - if (result) setLocalResize(result); - }, - [computeResize], - ); - - const handleResizeUp = useCallback((e: React.PointerEvent) => { - if (!resizeRef.current) return; - const result = computeResize(e); - if (result) { - dispatch(setCardPosition({ sessionId: session.id, x: result.x, y: result.y })); - dispatch(setCardSize({ sessionId: session.id, width: result.w, height: result.h })); - } - resizeRef.current = null; - setLocalResize(null); - setIsResizing(false); - (e.target as HTMLElement).releasePointerCapture(e.pointerId); - }, [computeResize, dispatch, session.id]); + const commitResizePosition = useCallback((x: number, y: number) => { dispatch(setCardPosition({ sessionId: session.id, x, y })); }, [dispatch, session.id]); + const commitResizeSize = useCallback((w: number, h: number) => { dispatch(setCardSize({ sessionId: session.id, width: w, height: h })); }, [dispatch, session.id]); + // An enlarged card can't be shrunk below its content-showing height, else the user resizes the chat down until the transcript vanishes. Collapsed cards keep the tiny floor. + const { isResizing, live: localResize, handles: resizeHandles } = useCanvasWindowResize({ + cardX, cardY, + cardWidth: Math.max(cardWidth, MIN_W), + cardHeight: expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : cardHeight, + minWidth: MIN_W, minHeight: expanded ? EXPANDED_OVERLAY_H : MIN_H, + getCanvasState, onCommitPosition: commitResizePosition, onCommitSize: commitResizeSize, untileForResize: tiling.untileForResize, + }); const handleRemove = (e?: React.MouseEvent) => { e?.stopPropagation(); @@ -1003,17 +926,13 @@ const AgentCard: React.FC = ({ }), }} > - {!pillMode && RESIZE_HANDLE_DEFS.map(({ dir, css }) => ( + {!pillMode && resizeHandles.map(({ dir, style, ...handlers }) => ( e.stopPropagation()} sx={{ - position: 'absolute', - ...css, - cursor: RESIZE_CURSOR[dir], + ...style, zIndex: 20, userSelect: 'none', touchAction: 'none', diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 18cba498..8d242f17 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -89,7 +89,7 @@ const CHROME_TEXT = '#3c3744'; const CHROME_TEXT_MUTED = '#8a8494'; import { useElementSelection } from '@/app/components/editor/ElementSelectionContext'; -import { RESIZE_HANDLE_DEFS, RESIZE_CURSOR, type ResizeDir } from './cardResizeHandles'; +import { useCanvasWindowResize } from './useCanvasWindowResize'; // Pill-preview capture cadence: fast until the card has handed the pill a frame, slow upkeep after. @@ -1041,69 +1041,12 @@ const BrowserCard: React.FC = ({ }; }, [isDragging, finalizeDrag, abortDrag]); - const resizeRef = useRef<{ - dir: ResizeDir; startX: number; startY: number; - origX: number; origY: number; origW: number; origH: number; - } | null>(null); - const [isResizing, setIsResizing] = useState(false); - const [localResize, setLocalResize] = useState<{ x: number; y: number; w: number; h: number } | null>(null); - - const handleResizeDown = useCallback( - (dir: ResizeDir) => (e: React.PointerEvent) => { - if (e.button !== 0) return; - e.preventDefault(); - e.stopPropagation(); - const popped = tiling.untileForResize(); - if (popped) setLocalResize(popped); - resizeRef.current = { - dir, startX: e.clientX, startY: e.clientY, - origX: popped?.x ?? cardX, origY: popped?.y ?? cardY, origW: popped?.w ?? cardWidth, origH: popped?.h ?? cardHeight, - }; - setIsResizing(true); - (e.target as HTMLElement).setPointerCapture(e.pointerId); - }, - [cardX, cardY, cardWidth, cardHeight, tiling], - ); - - const computeResize = useCallback( - (e: React.PointerEvent) => { - if (!resizeRef.current) return null; - const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current; - const zoom = getCanvasState().zoom; - const dx = (e.clientX - startX) / zoom; - const dy = (e.clientY - startY) / zoom; - let newX = origX, newY = origY, newW = origW, newH = origH; - if (dir.includes('e')) newW = origW + dx; - if (dir.includes('w')) { newW = origW - dx; newX = origX + dx; } - if (dir.includes('s')) newH = origH + dy; - if (dir.includes('n')) { newH = origH - dy; newY = origY + dy; } - if (newW < MIN_W) { if (dir.includes('w')) newX = origX + origW - MIN_W; newW = MIN_W; } - if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; } - return { x: newX, y: newY, w: newW, h: newH }; - }, - [getCanvasState], - ); - - const handleResizeMove = useCallback( - (e: React.PointerEvent) => { - const result = computeResize(e); - if (result) setLocalResize(result); - }, - [computeResize], - ); - - const handleResizeUp = useCallback((e: React.PointerEvent) => { - if (!resizeRef.current) return; - const result = computeResize(e); - if (result) { - dispatch(setBrowserCardPosition({ browserId, x: result.x, y: result.y })); - dispatch(setBrowserCardSize({ browserId, width: result.w, height: result.h })); - } - resizeRef.current = null; - setLocalResize(null); - setIsResizing(false); - (e.target as HTMLElement).releasePointerCapture(e.pointerId); - }, [computeResize, dispatch, browserId]); + const commitResizePosition = useCallback((x: number, y: number) => { dispatch(setBrowserCardPosition({ browserId, x, y })); }, [dispatch, browserId]); + const commitResizeSize = useCallback((w: number, h: number) => { dispatch(setBrowserCardSize({ browserId, width: w, height: h })); }, [dispatch, browserId]); + const { isResizing, live: localResize, handles: resizeHandles } = useCanvasWindowResize({ + cardX, cardY, cardWidth, cardHeight, minWidth: MIN_W, minHeight: MIN_H, + getCanvasState, onCommitPosition: commitResizePosition, onCommitSize: commitResizeSize, untileForResize: tiling.untileForResize, + }); const displayX = localResize?.x ?? localDragPos?.x ?? cardX; const displayY = localResize?.y ?? localDragPos?.y ?? cardY; @@ -2000,20 +1943,12 @@ const BrowserCard: React.FC = ({ {/* Resize handles; a docked mini's size follows the slot, so grabbing an edge used to pop it out of the chat mid-gesture. */} - {!dockActive && RESIZE_HANDLE_DEFS.map(({ dir, css }) => ( + {!dockActive && resizeHandles.map(({ dir, style, ...handlers }) => ( ))} diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index 3c3e2776..384c7ab6 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -40,7 +40,7 @@ import { RuntimeLogLine, } from '@/shared/hooks/useRuntimePreviewUrl'; import { postAppConsoleLine, terminalLineFromStream } from '@/shared/appTerminal'; -import { RESIZE_HANDLE_DEFS, RESIZE_CURSOR, type ResizeDir } from './cardResizeHandles'; +import { useCanvasWindowResize } from './useCanvasWindowResize'; type AppCardView = 'preview' | 'code' | 'terminal' | 'history'; @@ -514,69 +514,12 @@ const DashboardViewCard: React.FC = ({ }, [finalizeDrag]); useDragEndBackstops(isDragging, finalizeDrag, abortDrag); - const resizeRef = useRef<{ - dir: ResizeDir; startX: number; startY: number; - origX: number; origY: number; origW: number; origH: number; - } | null>(null); - const [isResizing, setIsResizing] = useState(false); - const [localResize, setLocalResize] = useState<{ x: number; y: number; w: number; h: number } | null>(null); - - const handleResizeDown = useCallback( - (dir: ResizeDir) => (e: React.PointerEvent) => { - if (e.button !== 0) return; - e.preventDefault(); - e.stopPropagation(); - const popped = tiling.untileForResize(); - if (popped) setLocalResize(popped); - resizeRef.current = { - dir, startX: e.clientX, startY: e.clientY, - origX: popped?.x ?? cardX, origY: popped?.y ?? cardY, origW: popped?.w ?? cardWidth, origH: popped?.h ?? cardHeight, - }; - setIsResizing(true); - (e.target as HTMLElement).setPointerCapture(e.pointerId); - }, - [cardX, cardY, cardWidth, cardHeight, tiling], - ); - - const computeResize = useCallback( - (e: React.PointerEvent) => { - if (!resizeRef.current) return null; - const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current; - const zoom = getCanvasState().zoom; - const dx = (e.clientX - startX) / zoom; - const dy = (e.clientY - startY) / zoom; - let newX = origX, newY = origY, newW = origW, newH = origH; - if (dir.includes('e')) newW = origW + dx; - if (dir.includes('w')) { newW = origW - dx; newX = origX + dx; } - if (dir.includes('s')) newH = origH + dy; - if (dir.includes('n')) { newH = origH - dy; newY = origY + dy; } - if (newW < MIN_W) { if (dir.includes('w')) newX = origX + origW - MIN_W; newW = MIN_W; } - if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; } - return { x: newX, y: newY, w: newW, h: newH }; - }, - [getCanvasState], - ); - - const handleResizeMove = useCallback( - (e: React.PointerEvent) => { - const result = computeResize(e); - if (result) setLocalResize(result); - }, - [computeResize], - ); - - const handleResizeUp = useCallback((e: React.PointerEvent) => { - if (!resizeRef.current) return; - const result = computeResize(e); - if (result) { - dispatch(setViewCardPosition({ outputId: cardKey, x: result.x, y: result.y })); - dispatch(setViewCardSize({ outputId: cardKey, width: result.w, height: result.h })); - } - resizeRef.current = null; - setLocalResize(null); - setIsResizing(false); - (e.target as HTMLElement).releasePointerCapture(e.pointerId); - }, [computeResize, dispatch, cardKey]); + const commitResizePosition = useCallback((x: number, y: number) => { dispatch(setViewCardPosition({ outputId: cardKey, x, y })); }, [dispatch, cardKey]); + const commitResizeSize = useCallback((w: number, h: number) => { dispatch(setViewCardSize({ outputId: cardKey, width: w, height: h })); }, [dispatch, cardKey]); + const { isResizing, live: localResize, handles: resizeHandles } = useCanvasWindowResize({ + cardX, cardY, cardWidth, cardHeight, minWidth: MIN_W, minHeight: MIN_H, + getCanvasState, onCommitPosition: commitResizePosition, onCommitSize: commitResizeSize, untileForResize: tiling.untileForResize, + }); const handleRemove = (e?: React.MouseEvent) => { e?.stopPropagation(); @@ -944,20 +887,12 @@ const DashboardViewCard: React.FC = ({ {/* Resize handles */} - {!isMinimized && RESIZE_HANDLE_DEFS.map(({ dir, css }) => ( + {!isMinimized && resizeHandles.map(({ dir, style, ...handlers }) => ( ))} diff --git a/frontend/src/app/pages/Dashboard/cards/useCanvasWindowResize.ts b/frontend/src/app/pages/Dashboard/cards/useCanvasWindowResize.ts index 949441ec..a3b777ee 100644 --- a/frontend/src/app/pages/Dashboard/cards/useCanvasWindowResize.ts +++ b/frontend/src/app/pages/Dashboard/cards/useCanvasWindowResize.ts @@ -1,21 +1,25 @@ -import React, { useCallback, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { RESIZE_HANDLE_DEFS, RESIZE_CURSOR, type ResizeDir } from './cardResizeHandles'; +export interface LiveRect { x: number; y: number; w: number; h: number } - - -export interface CanvasWindowResizeHandle { - dir: string; - style: React.CSSProperties; +export interface CanvasWindowResizeHandlers { onPointerDown: (e: React.PointerEvent) => void; onPointerMove: (e: React.PointerEvent) => void; onPointerUp: (e: React.PointerEvent) => void; + onPointerCancel: (e: React.PointerEvent) => void; + onLostPointerCapture: (e: React.PointerEvent) => void; +} + +export interface CanvasWindowResizeHandle extends CanvasWindowResizeHandlers { + dir: ResizeDir; + style: React.CSSProperties; } export interface CanvasWindowResizeState { isResizing: boolean; /** Live geometry while the pointer is down; null once committed to the slice. */ - live: { x: number; y: number; w: number; h: number } | null; + live: LiveRect | null; handles: CanvasWindowResizeHandle[]; } @@ -26,38 +30,32 @@ interface CanvasWindowResizeArgs { onCommitPosition: (x: number, y: number) => void; onCommitSize: (width: number, height: number) => void; /** Tiling rule 5: grabbing a grip breaks the tile and resizes from the rect the card was filling. */ - untileForResize?: () => { x: number; y: number; w: number; h: number } | null; + untileForResize?: () => LiveRect | null; } -/** The 8 edge/corner grips of a canvas window: preview the new rect locally, commit it on release. */ +// Same body class the card drag raises: webviews/iframes go pointer-events:none so a grip dragged inward over a browser page still hears its own release. +const GESTURE_SHIELD_CLASS = 'dashboard-marquee-active'; + +/** The 8 edge/corner grips of a canvas card: preview the new rect locally, commit it on release. + Every way a gesture can end (release on the grip, release over a webview or outside the window, + pointercancel, capture lost to a remount, app blur) funnels into one finish that commits the last + rect; a move with no button held proves the release was missed and finishes too, so a resize can + never keep tracking the cursor after the hand let go. */ export function useCanvasWindowResize({ cardX, cardY, cardWidth, cardHeight, minWidth, minHeight, getCanvasState, onCommitPosition, onCommitSize, untileForResize, }: CanvasWindowResizeArgs): CanvasWindowResizeState { const resizeRef = useRef<{ dir: ResizeDir; sx0: number; sy0: number; ox: number; oy: number; ow: number; oh: number } | null>(null); + const lastRectRef = useRef(null); const [isResizing, setIsResizing] = useState(false); - const [live, setLive] = useState<{ x: number; y: number; w: number; h: number } | null>(null); + const [live, setLive] = useState(null); - const onResizeDown = useCallback((dir: ResizeDir) => (e: React.PointerEvent) => { - if (e.button !== 0) return; - e.preventDefault(); - e.stopPropagation(); - const popped = untileForResize?.() ?? null; - if (popped) setLive(popped); - resizeRef.current = { - dir, sx0: e.clientX, sy0: e.clientY, - ox: popped?.x ?? cardX, oy: popped?.y ?? cardY, ow: popped?.w ?? cardWidth, oh: popped?.h ?? cardHeight, - }; - setIsResizing(true); - (e.target as HTMLElement).setPointerCapture(e.pointerId); - }, [cardX, cardY, cardWidth, cardHeight, untileForResize]); - - const compute = useCallback((e: React.PointerEvent) => { + const compute = useCallback((clientX: number, clientY: number): LiveRect | null => { if (!resizeRef.current) return null; const { dir, sx0, sy0, ox, oy, ow, oh } = resizeRef.current; const zoom = getCanvasState().zoom; - const dx = (e.clientX - sx0) / zoom; - const dy = (e.clientY - sy0) / zoom; + const dx = (clientX - sx0) / zoom; + const dy = (clientY - sy0) / zoom; let nx = ox, ny = oy, nw = ow, nh = oh; if (dir.includes('e')) nw = ow + dx; if (dir.includes('w')) { nw = ow - dx; nx = ox + dx; } @@ -68,23 +66,68 @@ export function useCanvasWindowResize({ return { x: nx, y: ny, w: nw, h: nh }; }, [getCanvasState, minWidth, minHeight]); - const onResizeMove = useCallback((e: React.PointerEvent) => { - const r = compute(e); - if (r) setLive(r); - }, [compute]); - - const onResizeUp = useCallback((e: React.PointerEvent) => { + const finish = useCallback(() => { if (!resizeRef.current) return; - const r = compute(e); + const r = lastRectRef.current; + resizeRef.current = null; + lastRectRef.current = null; + setLive(null); + setIsResizing(false); + document.body.classList.remove(GESTURE_SHIELD_CLASS); if (r) { onCommitPosition(r.x, r.y); onCommitSize(r.w, r.h); } - resizeRef.current = null; - setLive(null); - setIsResizing(false); - (e.target as HTMLElement).releasePointerCapture(e.pointerId); - }, [compute, onCommitPosition, onCommitSize]); + }, [onCommitPosition, onCommitSize]); + + const onResizeDown = useCallback((dir: ResizeDir) => (e: React.PointerEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + const popped = untileForResize?.() ?? null; + const origin: LiveRect = popped ?? { x: cardX, y: cardY, w: cardWidth, h: cardHeight }; + if (popped) setLive(popped); + resizeRef.current = { dir, sx0: e.clientX, sy0: e.clientY, ox: origin.x, oy: origin.y, ow: origin.w, oh: origin.h }; + lastRectRef.current = origin; + setIsResizing(true); + document.body.classList.add(GESTURE_SHIELD_CLASS); + try { (e.target as HTMLElement).setPointerCapture(e.pointerId); } catch { /* pointer already gone; the window backstops end it */ } + }, [cardX, cardY, cardWidth, cardHeight, untileForResize]); + + const onResizeMove = useCallback((e: React.PointerEvent) => { + if (!resizeRef.current) return; + if (e.buttons === 0) { finish(); return; } + const r = compute(e.clientX, e.clientY); + if (r) { lastRectRef.current = r; setLive(r); } + }, [compute, finish]); + + const onResizeUp = useCallback((e: React.PointerEvent) => { + if (!resizeRef.current) return; + const r = compute(e.clientX, e.clientY); + if (r) lastRectRef.current = r; + try { (e.target as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* capture already gone */ } + finish(); + }, [compute, finish]); + + useEffect(() => { + if (!isResizing) return undefined; + // Re-raised here because a re-render mid-gesture re-runs this effect and its cleanup lowers the shield. + document.body.classList.add(GESTURE_SHIELD_CLASS); + const onUp = (e: PointerEvent): void => { + const r = compute(e.clientX, e.clientY); + if (r) lastRectRef.current = r; + finish(); + }; + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', finish); + window.addEventListener('blur', finish); + return () => { + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', finish); + window.removeEventListener('blur', finish); + document.body.classList.remove(GESTURE_SHIELD_CLASS); + }; + }, [isResizing, compute, finish]); const handles = RESIZE_HANDLE_DEFS.map(({ dir, css }) => ({ dir, @@ -92,6 +135,8 @@ export function useCanvasWindowResize({ onPointerDown: onResizeDown(dir), onPointerMove: onResizeMove, onPointerUp: onResizeUp, + onPointerCancel: finish, + onLostPointerCapture: finish, })); return { isResizing, live, handles };