[eric] canvas: a resize ends when the hand lets go, webview or not (ENG-377)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01En8dRGsJPLrJCQBEkTH4Mp
This commit is contained in:
ciregenz
2026-08-20 22:22:48 -07:00
co-authored by Claude Fable 5
parent 8e0d10f49b
commit 0ccc3af040
4 changed files with 118 additions and 284 deletions
@@ -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<Props> = ({
}, [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<Props> = ({
}),
}}
>
{!pillMode && RESIZE_HANDLE_DEFS.map(({ dir, css }) => (
{!pillMode && resizeHandles.map(({ dir, style, ...handlers }) => (
<Box
key={dir}
onPointerDown={handleResizeDown(dir)}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeUp}
{...handlers}
onClick={(e) => e.stopPropagation()}
sx={{
position: 'absolute',
...css,
cursor: RESIZE_CURSOR[dir],
...style,
zIndex: 20,
userSelect: 'none',
touchAction: 'none',
@@ -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<Props> = ({
};
}, [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<Props> = ({
</Box>
{/* 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 }) => (
<Box
key={dir}
className="resize-handle"
onPointerDown={handleResizeDown(dir)}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeUp}
sx={{
position: 'absolute',
cursor: RESIZE_CURSOR[dir],
opacity: 0,
zIndex: 20,
...css,
}}
{...handlers}
sx={{ ...style, opacity: 0, zIndex: 20 }}
/>
))}
@@ -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<Props> = ({
}, [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<Props> = ({
</Box>
{/* Resize handles */}
{!isMinimized && RESIZE_HANDLE_DEFS.map(({ dir, css }) => (
{!isMinimized && resizeHandles.map(({ dir, style, ...handlers }) => (
<Box
key={dir}
className="resize-handle"
onPointerDown={handleResizeDown(dir)}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeUp}
sx={{
position: 'absolute',
cursor: RESIZE_CURSOR[dir],
opacity: 0,
zIndex: RESIZE_HANDLE_Z,
...css,
}}
{...handlers}
sx={{ ...style, opacity: 0, zIndex: RESIZE_HANDLE_Z }}
/>
))}
@@ -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<LiveRect | null>(null);
const [isResizing, setIsResizing] = useState(false);
const [live, setLive] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const [live, setLive] = useState<LiveRect | null>(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 };