[eric] Canvas UX overhaul: minimap, momentum panning, arrow navigation, smooth transitions, and card focus behavior + slight title prompt tweak

This commit is contained in:
ciregenz
2026-04-03 18:41:05 -07:00
parent cc5b3da0cd
commit 2412b981b4
13 changed files with 1505 additions and 291 deletions
+3 -3
View File
@@ -1591,9 +1591,9 @@ class AgentManager:
global_settings = load_settings()
client = get_anthropic_client(global_settings)
resp = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=30,
system="Generate a 2-4 word title for what the user wants. Be terse. No filler words. No quotes, no punctuation. Return only the title.",
model="claude-haiku-4-5-20251001",
max_tokens=20,
system="Generate a short 2-4 word title summarizing the user's request. Examples: 'Travel Planning', 'Code Review', 'Simple Greeting'. No quotes, no punctuation, no explanation. Return ONLY the title.",
messages=[{"role": "user", "content": first_prompt}],
)
generated = resp.content[0].text.strip().strip('"\'')
+7 -7
View File
@@ -160,22 +160,22 @@ async def generate_name(dashboard_id: str):
if len(prompts) == 1:
system = (
"Generate a short, clear 2-4 word workspace name based on this task. "
"Use plain language like 'Travel Planning', 'Code Review', 'Sales Dashboard'. "
"No quotes, no punctuation, no emojis. Return only the name."
"Generate a short 2-4 word workspace name summarizing this task. "
"Examples: 'Travel Planning', 'Code Review', 'Sales Dashboard'. "
"No quotes, no punctuation, no emojis, no explanation. Return ONLY the name."
)
user_content = prompts[0]
else:
system = (
"Generate a short, clear 2-4 word workspace name that captures the theme of these tasks. "
"Use plain language like 'Research & Analysis', 'Content Creation', 'Project Setup'. "
"No quotes, no punctuation, no emojis. Return only the name."
"Generate a short 2-4 word workspace name capturing the theme of these tasks. "
"Examples: 'Research & Analysis', 'Content Creation', 'Project Setup'. "
"No quotes, no punctuation, no emojis, no explanation. Return ONLY the name."
)
user_content = "\n".join(f"- {p}" for p in prompts)
resp = await client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=30,
max_tokens=20,
system=system,
messages=[{"role": "user", "content": user_content}],
)
+86 -21
View File
@@ -15,7 +15,6 @@ import { motion } from 'framer-motion';
import {
AgentSession,
handleApproval,
toggleExpandSession,
collapseSession,
closeSession,
} from '@/shared/state/agentsSlice';
@@ -175,6 +174,8 @@ interface Props {
cardWidth: number;
cardHeight: number;
zoom?: number;
panX?: number;
panY?: number;
spawnFrom?: { x: number; y: number; type?: 'branch' };
exitTarget?: { x: number; y: number };
isSelected?: boolean;
@@ -182,14 +183,16 @@ interface Props {
multiDragDelta?: { dx: number; dy: number } | null;
onCardSelect?: (id: string, type: 'agent' | 'view', shiftKey: boolean) => void;
onDragStart?: (id: string, type: 'agent' | 'view') => void;
onDragMove?: (dx: number, dy: number) => void;
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
onBranch?: (sourceSessionId: string, newSessionId: string) => void;
onMeasuredHeight?: (sessionId: string, height: number) => void;
snapColumn?: { x: number; width: number };
autoFocusInput?: boolean;
cardZOrder?: number;
onDoubleClick?: (id: string, type: 'agent' | 'view' | 'browser') => void;
onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser') => void;
shakeDirection?: 'left' | 'right' | 'up' | 'down' | null;
}
const MIN_W = 480;
@@ -204,9 +207,10 @@ const GLOW_FADE_MS = 2500;
const SNAP_THRESHOLD = 60;
const AgentCard: React.FC<Props> = ({
session, expanded, cardX, cardY, cardWidth, cardHeight, zoom = 1, spawnFrom, exitTarget,
session, expanded, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0, spawnFrom, exitTarget,
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
onBranch, onMeasuredHeight, snapColumn, autoFocusInput, cardZOrder = 0, onBringToFront,
onBranch, onMeasuredHeight, snapColumn, autoFocusInput, cardZOrder = 0, onDoubleClick, onBringToFront,
shakeDirection,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
@@ -261,51 +265,86 @@ const AgentCard: React.FC<Props> = ({
// ---- Drag via header (pointer events) ----
const DRAG_THRESHOLD = 3;
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null);
const didDrag = useRef(false);
const justDraggedRef = useRef(false);
const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 });
// Use refs for pan so drag callbacks don't recreate on every pan frame
const panRef = useRef({ panX, panY });
panRef.current = { panX, panY };
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY };
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: panRef.current.panX, startPanY: panRef.current.panY };
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
didDrag.current = false;
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
onDragStart?.(session.id, 'agent');
}, [cardX, cardY, onDragStart, session.id]);
// Recompute localDragPos from latest pointer + pan (shared by move handler and pan-change effect)
const recomputeDragPos = useCallback(() => {
const ds = dragState.current;
if (!ds || !didDrag.current) return;
const { clientX, clientY } = lastPointerRef.current;
const rawDx = clientX - ds.startX;
const rawDy = clientY - ds.startY;
const z = zoomRef.current;
const panDx = (panRef.current.panX - ds.startPanX) / z;
const panDy = (panRef.current.panY - ds.startPanY) / z;
const dx = rawDx / z - panDx;
const dy = rawDy / z - panDy;
setLocalDragPos({ x: ds.origX + dx, y: ds.origY + dy });
onDragMove?.(dx, dy, clientX, clientY);
}, [onDragMove]);
// When pan changes during an active drag, recompute position so card tracks cursor
useEffect(() => {
if (isDragging && didDrag.current) {
recomputeDragPos();
}
}, [panX, panY, isDragging, recomputeDragPos]);
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const rawDx = e.clientX - dragState.current.startX;
const rawDy = e.clientY - dragState.current.startY;
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
didDrag.current = true;
const dx = rawDx / zoom;
const dy = rawDy / zoom;
setLocalDragPos({
x: dragState.current.origX + dx,
y: dragState.current.origY + dy,
});
onDragMove?.(dx, dy);
}, [zoom, onDragMove]);
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
recomputeDragPos();
}, [recomputeDragPos]);
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const dx = (e.clientX - dragState.current.startX) / zoom;
const dy = (e.clientY - dragState.current.startY) / zoom;
const z = zoomRef.current;
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
const dx = (e.clientX - dragState.current.startX) / z - panDx;
const dy = (e.clientY - dragState.current.startY) / z - panDy;
if (didDrag.current) {
let finalX = dragState.current.origX + dx;
const finalY = dragState.current.origY + dy;
let finalY = dragState.current.origY + dy;
if (snapColumn && Math.abs(finalX - snapColumn.x) < SNAP_THRESHOLD) {
finalX = snapColumn.x;
dispatch(setCardSize({ sessionId: session.id, width: snapColumn.width, height: cardHeight }));
}
// Snap to 24px grid (hold Shift to bypass)
if (!e.shiftKey) {
finalX = Math.round(finalX / 24) * 24;
finalY = Math.round(finalY / 24) * 24;
}
dispatch(setCardPosition({ sessionId: session.id, x: finalX, y: finalY }));
justDraggedRef.current = true;
requestAnimationFrame(() => { justDraggedRef.current = false; });
@@ -316,7 +355,7 @@ const AgentCard: React.FC<Props> = ({
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [zoom, dispatch, session.id, onDragEnd, snapColumn, cardHeight]);
}, [dispatch, session.id, onDragEnd, snapColumn, cardHeight]);
// ---- Unified edge / corner resize ----
const resizeRef = useRef<{
@@ -484,11 +523,12 @@ const AgentCard: React.FC<Props> = ({
onClick={(e: React.MouseEvent) => {
if (justDraggedRef.current) return;
if (!isSelected && !e.shiftKey) {
dispatch(toggleExpandSession(session.id));
}
onCardSelect?.(session.id, 'agent', e.shiftKey);
}}
onDoubleClick={(e: React.MouseEvent) => {
e.stopPropagation();
onDoubleClick?.(session.id, 'agent');
}}
sx={{
position: 'relative',
width: localResize ? activeW : Math.max(cardWidth, MIN_W),
@@ -527,6 +567,31 @@ const AgentCard: React.FC<Props> = ({
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
...(shakeDirection && {
animation: `card-shake-${shakeDirection} 0.3s ease 2`,
border: `2px solid ${c.status.error}90`,
boxShadow: `0 0 0 2px ${c.status.error}30, ${c.shadow.md}`,
'@keyframes card-shake-left': {
'0%,100%': { transform: 'translateX(0)' },
'25%': { transform: 'translateX(-6px)' },
'75%': { transform: 'translateX(4px)' },
},
'@keyframes card-shake-right': {
'0%,100%': { transform: 'translateX(0)' },
'25%': { transform: 'translateX(6px)' },
'75%': { transform: 'translateX(-4px)' },
},
'@keyframes card-shake-up': {
'0%,100%': { transform: 'translateY(0)' },
'25%': { transform: 'translateY(-6px)' },
'75%': { transform: 'translateY(4px)' },
},
'@keyframes card-shake-down': {
'0%,100%': { transform: 'translateY(0)' },
'25%': { transform: 'translateY(6px)' },
'75%': { transform: 'translateY(-4px)' },
},
}),
...(isHighlighted && {
animation: 'card-highlight-pulse 2s ease-out forwards',
'@keyframes card-highlight-pulse': {
@@ -93,23 +93,26 @@ interface Props {
cardWidth: number;
cardHeight: number;
zoom?: number;
panX?: number;
panY?: number;
cmdHeld?: boolean;
isSelected?: boolean;
isHighlighted?: boolean;
multiDragDelta?: { dx: number; dy: number } | null;
onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser', shiftKey: boolean) => void;
onDragStart?: (id: string, type: 'agent' | 'view' | 'browser') => void;
onDragMove?: (dx: number, dy: number) => void;
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
cardZOrder?: number;
onDoubleClick?: (id: string, type: 'agent' | 'view' | 'browser') => void;
onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser') => void;
}
const BrowserCard: React.FC<Props> = ({
browserId, tabs, activeTabId, cardX, cardY, cardWidth, cardHeight, zoom = 1, cmdHeld = false,
browserId, tabs, activeTabId, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0, cmdHeld = false,
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
cardZOrder = 0, onBringToFront,
cardZOrder = 0, onDoubleClick, onBringToFront,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
@@ -374,47 +377,78 @@ const BrowserCard: React.FC<Props> = ({
// ---- Card drag via tab bar background ----
const DRAG_THRESHOLD = 3;
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null);
const didDrag = useRef(false);
const justDraggedRef = useRef(false);
const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 });
const panRef = useRef({ panX, panY });
panRef.current = { panX, panY };
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY };
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: panRef.current.panX, startPanY: panRef.current.panY };
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
didDrag.current = false;
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
onDragStart?.(browserId, 'browser');
}, [cardX, cardY, onDragStart, browserId]);
const recomputeDragPos = useCallback(() => {
const ds = dragState.current;
if (!ds || !didDrag.current) return;
const { clientX, clientY } = lastPointerRef.current;
const rawDx = clientX - ds.startX;
const rawDy = clientY - ds.startY;
const z = zoomRef.current;
const panDx = (panRef.current.panX - ds.startPanX) / z;
const panDy = (panRef.current.panY - ds.startPanY) / z;
const dx = rawDx / z - panDx;
const dy = rawDy / z - panDy;
setLocalDragPos({ x: ds.origX + dx, y: ds.origY + dy });
onDragMove?.(dx, dy, clientX, clientY);
}, [onDragMove]);
useEffect(() => {
if (isDragging && didDrag.current) recomputeDragPos();
}, [panX, panY, isDragging, recomputeDragPos]);
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const rawDx = e.clientX - dragState.current.startX;
const rawDy = e.clientY - dragState.current.startY;
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
didDrag.current = true;
const dx = rawDx / zoom;
const dy = rawDy / zoom;
setLocalDragPos({
x: dragState.current.origX + dx,
y: dragState.current.origY + dy,
});
onDragMove?.(dx, dy);
}, [zoom, onDragMove]);
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
recomputeDragPos();
}, [recomputeDragPos]);
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const dx = (e.clientX - dragState.current.startX) / zoom;
const dy = (e.clientY - dragState.current.startY) / zoom;
const z = zoomRef.current;
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
const dx = (e.clientX - dragState.current.startX) / z - panDx;
const dy = (e.clientY - dragState.current.startY) / z - panDy;
if (didDrag.current) {
let finalX = dragState.current.origX + dx;
let finalY = dragState.current.origY + dy;
// Snap to 24px grid (hold Shift to bypass)
if (!e.shiftKey) {
finalX = Math.round(finalX / 24) * 24;
finalY = Math.round(finalY / 24) * 24;
}
dispatch(setBrowserCardPosition({
browserId,
x: dragState.current.origX + dx,
y: dragState.current.origY + dy,
x: finalX,
y: finalY,
}));
justDraggedRef.current = true;
requestAnimationFrame(() => { justDraggedRef.current = false; });
@@ -425,7 +459,7 @@ const BrowserCard: React.FC<Props> = ({
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [zoom, dispatch, browserId, onDragEnd]);
}, [dispatch, browserId, onDragEnd]);
// ---- Resize ----
const resizeRef = useRef<{
@@ -545,6 +579,10 @@ const BrowserCard: React.FC<Props> = ({
if (justDraggedRef.current) return;
onCardSelect?.(browserId, 'browser', e.shiftKey);
}}
onDoubleClick={(e: React.MouseEvent) => {
e.stopPropagation();
onDoubleClick?.(browserId, 'browser');
}}
sx={{
position: 'absolute',
left: displayX,
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
@@ -7,79 +7,116 @@ import RemoveIcon from '@mui/icons-material/Remove';
import AddIcon from '@mui/icons-material/Add';
import FitScreenIcon from '@mui/icons-material/FitScreen';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import MapIcon from '@mui/icons-material/Map';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { CanvasActions } from './useCanvasControls';
import Minimap from './Minimap';
import type { MinimapProps } from './Minimap';
interface Props {
zoom: number;
actions: CanvasActions;
onFitToView: () => void;
onTidy: () => void;
minimapProps: Omit<MinimapProps, 'onPan'>;
onMinimapPan: (panX: number, panY: number) => void;
}
const CanvasControls: React.FC<Props> = ({ zoom, actions, onTidy }) => {
const CanvasControls: React.FC<Props> = ({ zoom, actions, onFitToView, onTidy, minimapProps, onMinimapPan }) => {
const c = useClaudeTokens();
const pct = Math.round(zoom * 100);
const [minimapOpen, setMinimapOpen] = useState(true);
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.25,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`,
borderRadius: `${c.radius.lg}px`,
boxShadow: c.shadow.sm,
py: 0.25,
px: 0.5,
userSelect: 'none',
}}
>
<Tooltip title="Zoom out" placement="top">
<IconButton size="small" onClick={actions.zoomOut} sx={{ color: c.text.muted }}>
<RemoveIcon sx={{ fontSize: '1rem' }} />
</IconButton>
</Tooltip>
<Tooltip title="Reset to 100%" placement="top">
<Typography
onClick={actions.resetZoom}
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0.75 }}>
{/* Minimap panel — sits above the toolbar */}
{minimapOpen && (
<Box
sx={{
fontSize: '0.75rem',
fontWeight: 500,
color: c.text.secondary,
minWidth: 40,
textAlign: 'center',
cursor: 'pointer',
lineHeight: 1,
'&:hover': { color: c.text.primary },
width: 200,
height: 140,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`,
borderRadius: `${c.radius.lg}px`,
boxShadow: c.shadow.md,
overflow: 'hidden',
}}
>
{pct}%
</Typography>
</Tooltip>
<Minimap {...minimapProps} onPan={onMinimapPan} />
</Box>
)}
<Tooltip title="Zoom in" placement="top">
<IconButton size="small" onClick={actions.zoomIn} sx={{ color: c.text.muted }}>
<AddIcon sx={{ fontSize: '1rem' }} />
</IconButton>
</Tooltip>
{/* Toolbar */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.25,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`,
borderRadius: `${c.radius.lg}px`,
boxShadow: c.shadow.sm,
py: 0.25,
px: 0.5,
userSelect: 'none',
}}
>
<Tooltip title="Zoom out" placement="top">
<IconButton size="small" onClick={actions.zoomOut} sx={{ color: c.text.muted }}>
<RemoveIcon sx={{ fontSize: '1rem' }} />
</IconButton>
</Tooltip>
<Box sx={{ width: 1, height: 16, bgcolor: c.border.medium, mx: 0.5 }} />
<Tooltip title="Reset to 100%" placement="top">
<Typography
onClick={actions.resetZoom}
sx={{
fontSize: '0.75rem',
fontWeight: 500,
color: c.text.secondary,
minWidth: 40,
textAlign: 'center',
cursor: 'pointer',
lineHeight: 1,
'&:hover': { color: c.text.primary },
}}
>
{pct}%
</Typography>
</Tooltip>
<Tooltip title="Fit to view" placement="top">
<IconButton size="small" onClick={actions.fitToView} sx={{ color: c.text.muted }}>
<FitScreenIcon sx={{ fontSize: '1rem' }} />
</IconButton>
</Tooltip>
<Tooltip title="Zoom in" placement="top">
<IconButton size="small" onClick={actions.zoomIn} sx={{ color: c.text.muted }}>
<AddIcon sx={{ fontSize: '1rem' }} />
</IconButton>
</Tooltip>
<Box sx={{ width: 1, height: 16, bgcolor: c.border.medium, mx: 0.5 }} />
<Box sx={{ width: 1, height: 16, bgcolor: c.border.medium, mx: 0.5 }} />
<Tooltip title="Tidy layout" placement="top">
<IconButton size="small" onClick={onTidy} sx={{ color: c.text.muted }}>
<AutoAwesomeIcon sx={{ fontSize: '1rem' }} />
</IconButton>
</Tooltip>
<Tooltip title="Fit to view" placement="top">
<IconButton size="small" onClick={onFitToView} sx={{ color: c.text.muted }}>
<FitScreenIcon sx={{ fontSize: '1rem' }} />
</IconButton>
</Tooltip>
<Tooltip title="Tidy layout" placement="top">
<IconButton size="small" onClick={onTidy} sx={{ color: c.text.muted }}>
<AutoAwesomeIcon sx={{ fontSize: '1rem' }} />
</IconButton>
</Tooltip>
<Box sx={{ width: 1, height: 16, bgcolor: c.border.medium, mx: 0.5 }} />
<Tooltip title={minimapOpen ? 'Hide minimap' : 'Show minimap'} placement="top">
<IconButton
size="small"
onClick={() => setMinimapOpen((v) => !v)}
sx={{ color: minimapOpen ? c.accent.primary : c.text.muted }}
>
<MapIcon sx={{ fontSize: '1rem' }} />
</IconButton>
</Tooltip>
</Box>
</Box>
);
};
@@ -0,0 +1,236 @@
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import Box from '@mui/material/Box';
import InputBase from '@mui/material/InputBase';
import Typography from '@mui/material/Typography';
import SearchIcon from '@mui/icons-material/Search';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { CardPosition, ViewCardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice';
import type { AgentSession } from '@/shared/state/agentsSlice';
interface CardSearchItem {
id: string;
label: string;
type: 'agent' | 'view' | 'browser';
rect: { x: number; y: number; width: number; height: number };
}
interface Props {
open: boolean;
onClose: () => void;
onNavigate: (rect: { x: number; y: number; width: number; height: number }) => void;
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
sessions: Record<string, AgentSession>;
}
const CardSearchPalette: React.FC<Props> = ({
open, onClose, onNavigate,
cards, viewCards, browserCards, sessions,
}) => {
const c = useClaudeTokens();
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
// Build searchable items
const items = useMemo((): CardSearchItem[] => {
const result: CardSearchItem[] = [];
for (const card of Object.values(cards)) {
const session = sessions[card.session_id];
result.push({
id: card.session_id,
label: session?.name || `Agent ${card.session_id.slice(0, 8)}`,
type: 'agent',
rect: { x: card.x, y: card.y, width: card.width, height: card.height },
});
}
for (const vc of Object.values(viewCards)) {
result.push({
id: vc.output_id,
label: `View: ${vc.output_id.slice(0, 12)}`,
type: 'view',
rect: { x: vc.x, y: vc.y, width: vc.width, height: vc.height },
});
}
for (const bc of Object.values(browserCards)) {
const activeTab = bc.tabs.find((t) => t.id === bc.activeTabId);
result.push({
id: bc.browser_id,
label: activeTab?.title || activeTab?.url || `Browser ${bc.browser_id.slice(0, 8)}`,
type: 'browser',
rect: { x: bc.x, y: bc.y, width: bc.width, height: bc.height },
});
}
return result;
}, [cards, viewCards, browserCards, sessions]);
const filtered = useMemo(() => {
if (!query.trim()) return items;
const q = query.toLowerCase();
return items.filter((item) => item.label.toLowerCase().includes(q));
}, [items, query]);
useEffect(() => {
setSelectedIndex(0);
}, [query]);
useEffect(() => {
if (open) {
setQuery('');
setSelectedIndex(0);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
const handleSelect = useCallback((item: CardSearchItem) => {
onNavigate(item.rect);
onClose();
}, [onNavigate, onClose]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
if (filtered[selectedIndex]) {
handleSelect(filtered[selectedIndex]);
}
}
}, [filtered, selectedIndex, handleSelect, onClose]);
if (!open) return null;
const typeLabel = (type: string) => {
switch (type) {
case 'agent': return 'Agent';
case 'view': return 'View';
case 'browser': return 'Browser';
default: return type;
}
};
const typeColor = (type: string) => {
switch (type) {
case 'agent': return c.accent.primary;
case 'view': return c.status.info;
case 'browser': return c.status.success;
default: return c.text.muted;
}
};
return (
<>
{/* Backdrop */}
<Box
onClick={onClose}
sx={{
position: 'fixed',
inset: 0,
zIndex: 1000,
bgcolor: 'rgba(0,0,0,0.2)',
}}
/>
{/* Palette */}
<Box
sx={{
position: 'fixed',
top: '20%',
left: '50%',
transform: 'translateX(-50%)',
width: 440,
maxHeight: 400,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`,
borderRadius: `${c.radius.xl}px`,
boxShadow: c.shadow.lg,
zIndex: 1001,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
onKeyDown={handleKeyDown}
>
{/* Search input */}
<Box sx={{ display: 'flex', alignItems: 'center', px: 2, py: 1.5, borderBottom: `1px solid ${c.border.subtle}` }}>
<SearchIcon sx={{ fontSize: '1.25rem', color: c.text.muted, mr: 1.5 }} />
<InputBase
inputRef={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search cards..."
fullWidth
sx={{
fontSize: '0.9375rem',
fontFamily: c.font.sans,
color: c.text.primary,
'& input::placeholder': { color: c.text.muted, opacity: 1 },
}}
/>
</Box>
{/* Results */}
<Box sx={{ overflowY: 'auto', maxHeight: 320 }}>
{filtered.length === 0 ? (
<Typography sx={{ px: 2, py: 2, fontSize: '0.875rem', color: c.text.muted, textAlign: 'center' }}>
No cards found
</Typography>
) : (
filtered.map((item, i) => (
<Box
key={item.id}
onClick={() => handleSelect(item)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 2,
py: 1,
cursor: 'pointer',
bgcolor: i === selectedIndex ? c.bg.secondary : 'transparent',
'&:hover': { bgcolor: c.bg.secondary },
}}
>
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: typeColor(item.type),
flexShrink: 0,
}}
/>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
sx={{
fontSize: '0.875rem',
fontWeight: 500,
color: c.text.primary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.label}
</Typography>
</Box>
<Typography sx={{ fontSize: '0.75rem', color: c.text.muted, flexShrink: 0 }}>
{typeLabel(item.type)}
</Typography>
</Box>
))
)}
</Box>
</Box>
</>
);
};
export default CardSearchPalette;
+380 -15
View File
@@ -10,6 +10,7 @@ import {
fetchSessions,
fetchHistory,
collapseSession,
collapseAllSessions,
closeSession,
duplicateSession,
expandSession,
@@ -53,6 +54,8 @@ import AgentCard from './AgentCard';
import DashboardViewCard from './DashboardViewCard';
import BrowserCard from './BrowserCard';
import CanvasControls from './CanvasControls';
import CardSearchPalette from './CardSearchPalette';
import DirectionHints from './DirectionHints';
import DashboardToolbar from './DashboardToolbar';
import { captureDashboardThumbnail } from './captureDashboardThumbnail';
import { useCanvasControls } from './useCanvasControls';
@@ -107,7 +110,24 @@ const DashboardInner: React.FC = () => {
const glowingBrowserCards = useAppSelector((state) => state.dashboardLayout.glowingBrowserCards);
const sessionList = Object.values(sessions);
const canvas = useCanvasControls(zoomSensitivity);
const contentBounds = useMemo(() => {
const allRects = [
...Object.values(cards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })),
...Object.values(viewCards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })),
...Object.values(browserCards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })),
];
if (allRects.length === 0) return undefined;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const r of allRects) {
minX = Math.min(minX, r.x);
minY = Math.min(minY, r.y);
maxX = Math.max(maxX, r.x + r.w);
maxY = Math.max(maxY, r.y + r.h);
}
return { minX, minY, maxX, maxY };
}, [cards, viewCards, browserCards]);
const canvas = useCanvasControls(zoomSensitivity, contentBounds);
const selection = useDashboardSelection(
{ panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom, viewportRef: canvas.viewportRef },
cards,
@@ -117,10 +137,12 @@ const DashboardInner: React.FC = () => {
const toolbarRef = useRef<HTMLDivElement>(null);
const [toolbarOpen, setToolbarOpen] = useState(false);
const [searchPaletteOpen, setSearchPaletteOpen] = useState(false);
const [highlightedCardId, setHighlightedCardId] = useState<string | null>(null);
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [autoFocusSessionId, setAutoFocusSessionId] = useState<string | null>(null);
const [pendingSelectSessionId, setPendingSelectSessionId] = useState<string | null>(null);
const [focusedCardId, setFocusedCardId] = useState<string | null>(null);
const handleHighlightCard = useCallback((cardId: string) => {
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
@@ -165,14 +187,63 @@ const DashboardInner: React.FC = () => {
const canvasStateRef = useRef({ panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom });
canvasStateRef.current = { panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom };
// ---- Edge panning during card drag ----
const EDGE_ZONE = 60;
const EDGE_MAX_SPEED = 8;
const edgePanFrameRef = useRef<number | null>(null);
const lastMousePosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
// Track pan at drag start so cards can compensate for edge-pan offset
const dragStartPanRef = useRef<{ panX: number; panY: number }>({ panX: 0, panY: 0 });
const stopEdgePan = useCallback(() => {
if (edgePanFrameRef.current) {
cancelAnimationFrame(edgePanFrameRef.current);
edgePanFrameRef.current = null;
}
}, []);
const tickEdgePan = useCallback(() => {
const vp = canvas.viewportRef.current;
if (!vp) return;
const rect = vp.getBoundingClientRect();
const { x: mx, y: my } = lastMousePosRef.current;
let dx = 0;
let dy = 0;
if (mx < rect.left + EDGE_ZONE) {
dx = EDGE_MAX_SPEED * ((rect.left + EDGE_ZONE - mx) / EDGE_ZONE);
} else if (mx > rect.right - EDGE_ZONE) {
dx = -EDGE_MAX_SPEED * ((mx - (rect.right - EDGE_ZONE)) / EDGE_ZONE);
}
if (my < rect.top + EDGE_ZONE) {
dy = EDGE_MAX_SPEED * ((rect.top + EDGE_ZONE - my) / EDGE_ZONE);
} else if (my > rect.bottom - EDGE_ZONE) {
dy = -EDGE_MAX_SPEED * ((my - (rect.bottom - EDGE_ZONE)) / EDGE_ZONE);
}
if (dx !== 0 || dy !== 0) {
canvas.actions.setState((prev: { panX: number; panY: number; zoom: number }) => ({
...prev,
panX: prev.panX + dx,
panY: prev.panY + dy,
}));
}
edgePanFrameRef.current = requestAnimationFrame(tickEdgePan);
}, [canvas.viewportRef, canvas.actions]);
// ---- Multi-drag coordination ----
const [multiDragDelta, setMultiDragDelta] = useState<{ dx: number; dy: number } | null>(null);
const [liveDragInfo, setLiveDragInfo] = useState<{ cardId: string; dx: number; dy: number } | null>(null);
const activeDragCardRef = useRef<string | null>(null);
const isMultiDragRef = useRef(false);
const edgePanStartedRef = useRef(false);
const handleCardDragStart = useCallback((id: string, _type: CardType) => {
activeDragCardRef.current = id;
edgePanStartedRef.current = false;
if (selection.isSelected(id)) {
isMultiDragRef.current = true;
} else {
@@ -181,16 +252,25 @@ const DashboardInner: React.FC = () => {
}
}, [selection]);
const handleCardDragMove = useCallback((dx: number, dy: number) => {
const handleCardDragMove = useCallback((dx: number, dy: number, mouseX?: number, mouseY?: number) => {
if (mouseX !== undefined && mouseY !== undefined) {
lastMousePosRef.current = { x: mouseX, y: mouseY };
}
// Start edge panning only once actual dragging begins
if (!edgePanStartedRef.current) {
edgePanStartedRef.current = true;
edgePanFrameRef.current = requestAnimationFrame(tickEdgePan);
}
if (isMultiDragRef.current) {
setMultiDragDelta({ dx, dy });
}
if (activeDragCardRef.current) {
setLiveDragInfo({ cardId: activeDragCardRef.current, dx, dy });
}
}, []);
}, [tickEdgePan]);
const handleCardDragEnd = useCallback((dx: number, dy: number, didDrag: boolean) => {
stopEdgePan();
if (isMultiDragRef.current && didDrag) {
const items = selection.selectedArray()
.filter((s) => s.id !== activeDragCardRef.current);
@@ -202,11 +282,62 @@ const DashboardInner: React.FC = () => {
isMultiDragRef.current = false;
setMultiDragDelta(null);
setLiveDragInfo(null);
}, [selection, dispatch]);
}, [selection, dispatch, stopEdgePan]);
// Helper: get a card's rect from Redux state (uses collapsed height for zoom calculation)
const getCardRect = useCallback((id: string, type: CardType) => {
const layoutState = store.getState().dashboardLayout;
if (type === 'agent') {
const card = layoutState.cards[id];
if (!card) return undefined;
return { x: card.x, y: card.y, width: card.width, height: card.height };
} else if (type === 'view') {
const vc = layoutState.viewCards[id];
if (!vc) return undefined;
return { x: vc.x, y: vc.y, width: vc.width, height: vc.height };
} else if (type === 'browser') {
const bc = layoutState.browserCards[id];
if (!bc) return undefined;
return { x: bc.x, y: bc.y, width: bc.width, height: bc.height };
}
return undefined;
}, []);
// Delay single-click collapse so double-click can override
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleCardSelect = useCallback((id: string, type: CardType, shiftKey: boolean) => {
selection.selectCard(id, type, shiftKey);
}, [selection]);
if (shiftKey) {
selection.selectCard(id, type, true);
return;
}
selection.selectCard(id, type, false);
const alreadyExpanded = type === 'agent' && expandedSessionIds.includes(id);
if (alreadyExpanded) {
// Delay collapse so double-click can cancel it
if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
clickTimerRef.current = setTimeout(() => {
dispatch(collapseSession(id));
setFocusedCardId(null);
clickTimerRef.current = null;
}, 250);
} else {
// Expand + center + zoom + bring to front
if (type === 'agent') {
dispatch(expandSession(id));
}
dispatch(bringToFront({ id, type }));
setFocusedCardId(id);
setTimeout(() => {
const rect = getCardRect(id, type);
if (rect) canvas.actions.fitToCards([rect], 1.15, true);
// Blur after expansion settles so arrow keys work for navigation
setTimeout(() => (document.activeElement as HTMLElement)?.blur?.(), 150);
}, 100);
}
}, [selection, getCardRect, canvas.actions, dispatch, expandedSessionIds]);
const handleBringToFront = useCallback((id: string, type: CardType) => {
dispatch(bringToFront({ id, type }));
@@ -253,6 +384,31 @@ const DashboardInner: React.FC = () => {
selection.handleCanvasMouseUp(e.nativeEvent);
}, [canvas.handlers, selection]);
// Double-click empty canvas → fit all cards
const handleViewportDoubleClick = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return;
if (isCardTarget(e.target, e.currentTarget)) return;
canvas.actions.fitToView();
}, [canvas.actions]);
// Double-click a card → always expand + center + zoom (cancels pending collapse from single-click)
const handleCardDoubleClick = useCallback((id: string, type: CardType) => {
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = null;
}
if (type === 'agent') {
dispatch(expandSession(id));
}
dispatch(bringToFront({ id, type }));
setFocusedCardId(id);
setTimeout(() => {
const rect = getCardRect(id, type);
if (rect) canvas.actions.fitToCards([rect], 1.15, true);
setTimeout(() => (document.activeElement as HTMLElement)?.blur?.(), 150);
}, 100);
}, [getCardRect, canvas.actions, dispatch]);
useEffect(() => {
if (!dashboardId) return;
hasFittedRef.current = false;
@@ -342,7 +498,7 @@ const DashboardInner: React.FC = () => {
setTimeout(() => {
const card = store.getState().dashboardLayout.cards[agentId];
if (card) {
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.0, true);
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true);
handleHighlightCard(agentId);
}
}, 350);
@@ -354,6 +510,19 @@ const DashboardInner: React.FC = () => {
dispatch(setExpandedSessionIds(persistedExpandedSessionIds));
}, [layoutInitialized, persistedExpandedSessionIds, dispatch]);
// Auto-collapse all expanded sessions when zooming out
const prevZoomRef = useRef(canvas.zoom);
useEffect(() => {
const wasZoomedIn = prevZoomRef.current >= 0.9;
const isZoomedOut = canvas.zoom < 0.9;
prevZoomRef.current = canvas.zoom;
if (wasZoomedIn && isZoomedOut && expandedSessionIds.length > 0) {
dispatch(collapseAllSessions());
setFocusedCardId(null);
}
}, [canvas.zoom, expandedSessionIds.length, dispatch]);
const prevSessionIdsRef = useRef<string>('');
useEffect(() => {
@@ -553,6 +722,19 @@ const DashboardInner: React.FC = () => {
return () => window.removeEventListener('keydown', handleDelete);
}, [selection, dispatch]);
// Cmd+F to open card search palette
useEffect(() => {
const handleSearch = (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'f') return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
e.preventDefault();
setSearchPaletteOpen(true);
};
window.addEventListener('keydown', handleSearch);
return () => window.removeEventListener('keydown', handleSearch);
}, []);
useEffect(() => {
const handleCopy = (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'c') return;
@@ -656,6 +838,127 @@ const DashboardInner: React.FC = () => {
return () => window.removeEventListener('keydown', handlePaste);
}, [dispatch, dashboardId, expandedSessionIds, selection]);
// ---- Arrow key card navigation (when zoomed in on a card) ----
const findNearestCard = useCallback((
currentId: string,
direction: 'left' | 'right' | 'up' | 'down',
): { id: string; type: CardType } | null => {
const allCardEntries: Array<{ id: string; type: CardType; cx: number; cy: number }> = [];
for (const card of Object.values(cards)) {
allCardEntries.push({ id: card.session_id, type: 'agent', cx: card.x + card.width / 2, cy: card.y + card.height / 2 });
}
for (const vc of Object.values(viewCards)) {
allCardEntries.push({ id: vc.output_id, type: 'view', cx: vc.x + vc.width / 2, cy: vc.y + vc.height / 2 });
}
for (const bc of Object.values(browserCards)) {
allCardEntries.push({ id: bc.browser_id, type: 'browser', cx: bc.x + bc.width / 2, cy: bc.y + bc.height / 2 });
}
const current = allCardEntries.find((c) => c.id === currentId);
if (!current) return null;
let best: typeof allCardEntries[0] | null = null;
let bestScore = Infinity;
for (const card of allCardEntries) {
if (card.id === currentId) continue;
const dx = card.cx - current.cx;
const dy = card.cy - current.cy;
// Filter to the correct half-plane
let inDirection = false;
let primary = 0;
let secondary = 0;
switch (direction) {
case 'right': inDirection = dx > 20; primary = dx; secondary = Math.abs(dy); break;
case 'left': inDirection = dx < -20; primary = -dx; secondary = Math.abs(dy); break;
case 'down': inDirection = dy > 20; primary = dy; secondary = Math.abs(dx); break;
case 'up': inDirection = dy < -20; primary = -dy; secondary = Math.abs(dx); break;
}
if (!inDirection) continue;
const score = primary + secondary * 0.3;
if (score < bestScore) {
bestScore = score;
best = card;
}
}
return best ? { id: best.id, type: best.type } : null;
}, [cards, viewCards, browserCards]);
// Compute which directions have neighbors from the focused card
const neighborDirections = useMemo(() => {
if (!focusedCardId || canvas.zoom < 0.9) return { left: false, right: false, up: false, down: false };
return {
left: !!findNearestCard(focusedCardId, 'left'),
right: !!findNearestCard(focusedCardId, 'right'),
up: !!findNearestCard(focusedCardId, 'up'),
down: !!findNearestCard(focusedCardId, 'down'),
};
}, [focusedCardId, canvas.zoom, findNearestCard]);
// Shake animation state: direction + timer
const [shakeDirection, setShakeDirection] = useState<'left' | 'right' | 'up' | 'down' | null>(null);
const shakeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Use refs for values read inside the keydown handler to avoid stale closures
const focusedCardIdRef = useRef(focusedCardId);
focusedCardIdRef.current = focusedCardId;
const canvasZoomRef = useRef(canvas.zoom);
canvasZoomRef.current = canvas.zoom;
useEffect(() => {
const handleArrowNav = (e: KeyboardEvent) => {
const currentFocused = focusedCardIdRef.current;
if (!currentFocused || canvasZoomRef.current < 0.9) return;
// Skip if typing in an input
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
let direction: 'left' | 'right' | 'up' | 'down' | null = null;
switch (e.key) {
case 'ArrowLeft': direction = 'left'; break;
case 'ArrowRight': direction = 'right'; break;
case 'ArrowUp': direction = 'up'; break;
case 'ArrowDown': direction = 'down'; break;
default: return;
}
e.preventDefault();
const target = findNearestCard(currentFocused, direction);
if (!target) {
// No card in that direction — shake
if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current);
setShakeDirection(direction);
shakeTimerRef.current = setTimeout(() => {
setShakeDirection(null);
shakeTimerRef.current = null;
}, 400);
return;
}
// Collapse current, expand + navigate to target + bring to front
dispatch(collapseSession(currentFocused));
if (target.type === 'agent') {
dispatch(expandSession(target.id));
}
dispatch(bringToFront({ id: target.id, type: target.type }));
setFocusedCardId(target.id);
setTimeout(() => {
const rect = getCardRect(target.id, target.type);
if (rect) canvas.actions.fitToCards([rect], 1.15, true);
setTimeout(() => (document.activeElement as HTMLElement)?.blur?.(), 150);
}, 100);
};
window.addEventListener('keydown', handleArrowNav);
return () => window.removeEventListener('keydown', handleArrowNav);
}, [findNearestCard, getCardRect, canvas.actions, dispatch]);
const handleBranchFromCard = useCallback(
(sourceSessionId: string, newSessionId: string) => {
const sourceCard = cards[sourceSessionId];
@@ -776,12 +1079,13 @@ const DashboardInner: React.FC = () => {
setPendingSelectSessionId(realId);
}
// Expand the chat so user can see responses
dispatch(expandSession(realId));
setTimeout(() => {
const card = store.getState().dashboardLayout.cards[realId];
if (card) {
const isExp = store.getState().agents.expandedSessionIds.includes(realId);
const height = isExp ? Math.max(EXPANDED_CARD_MIN_H, card.height) : card.height;
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height }], 1.0, true);
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true);
handleHighlightCard(realId);
}
}, 200);
@@ -814,7 +1118,7 @@ const DashboardInner: React.FC = () => {
setTimeout(() => {
const card = store.getState().dashboardLayout.viewCards[outputId];
if (card) {
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.0, true);
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true);
handleHighlightCard(outputId);
}
}, 200);
@@ -828,7 +1132,7 @@ const DashboardInner: React.FC = () => {
const newId = Object.keys(allBrowserCards).find((id) => !prevIds.has(id));
if (newId) {
const card = allBrowserCards[newId];
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.0, true);
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true);
handleHighlightCard(newId);
}
}, 200);
@@ -842,7 +1146,7 @@ const DashboardInner: React.FC = () => {
setTimeout(() => {
const card = store.getState().dashboardLayout.cards[sessionId];
if (card) {
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.0, true);
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true);
handleHighlightCard(sessionId);
}
}, 200);
@@ -850,6 +1154,19 @@ const DashboardInner: React.FC = () => {
});
}, [dispatch, canvas.actions, handleHighlightCard, setAutoFocusSessionId]);
// Context-aware fit: if a card is selected, zoom to it; otherwise fit all
const handleFitToView = useCallback(() => {
if (selection.selectedIds.size === 1) {
const [[id, type]] = selection.selectedIds;
const rect = getCardRect(id, type);
if (rect) {
canvas.actions.fitToCards([rect], 1.15, true);
return;
}
}
canvas.actions.fitToView();
}, [selection.selectedIds, getCardRect, canvas.actions]);
const handleTidy = useCallback(() => {
const currentExpanded = store.getState().agents.expandedSessionIds;
dispatch(tidyLayout({ expandedSessionIds: currentExpanded }));
@@ -1136,6 +1453,7 @@ const DashboardInner: React.FC = () => {
onMouseDown={handleViewportMouseDown}
onMouseMove={handleViewportMouseMove}
onMouseUp={handleViewportMouseUp}
onDoubleClick={handleViewportDoubleClick}
onContextMenu={(e) => e.preventDefault()}
sx={{
position: 'absolute',
@@ -1364,6 +1682,8 @@ const DashboardInner: React.FC = () => {
cardHeight={card.height}
cardZOrder={card.zOrder ?? 0}
zoom={canvas.zoom}
panX={canvas.panX}
panY={canvas.panY}
spawnFrom={origin}
exitTarget={exitTarget}
isSelected={selection.isSelected(session.id)}
@@ -1377,7 +1697,9 @@ const DashboardInner: React.FC = () => {
onMeasuredHeight={handleMeasuredHeight}
snapColumn={snapColumn}
autoFocusInput={autoFocusSessionId === session.id}
onDoubleClick={handleCardDoubleClick}
onBringToFront={handleBringToFront}
shakeDirection={focusedCardId === session.id ? shakeDirection : null}
/>
);
})}
@@ -1395,6 +1717,8 @@ const DashboardInner: React.FC = () => {
cardHeight={vc.height}
cardZOrder={vc.zOrder ?? 0}
zoom={canvas.zoom}
panX={canvas.panX}
panY={canvas.panY}
cmdHeld={canvas.cmdHeld}
isSelected={selection.isSelected(vc.output_id)}
isHighlighted={highlightedCardId === vc.output_id}
@@ -1403,6 +1727,7 @@ const DashboardInner: React.FC = () => {
onDragStart={handleCardDragStart}
onDragMove={handleCardDragMove}
onDragEnd={handleCardDragEnd}
onDoubleClick={handleCardDoubleClick}
onBringToFront={handleBringToFront}
/>
);
@@ -1419,6 +1744,8 @@ const DashboardInner: React.FC = () => {
cardHeight={bc.height}
cardZOrder={bc.zOrder ?? 0}
zoom={canvas.zoom}
panX={canvas.panX}
panY={canvas.panY}
cmdHeld={canvas.cmdHeld}
isSelected={selection.isSelected(bc.browser_id)}
isHighlighted={highlightedCardId === bc.browser_id}
@@ -1427,6 +1754,7 @@ const DashboardInner: React.FC = () => {
onDragStart={handleCardDragStart}
onDragMove={handleCardDragMove}
onDragEnd={handleCardDragEnd}
onDoubleClick={handleCardDoubleClick}
onBringToFront={handleBringToFront}
/>
))}
@@ -1466,11 +1794,48 @@ const DashboardInner: React.FC = () => {
/>
</Box>
{/* Floating zoom controls */}
{/* Arrow navigation hints when zoomed in on a card */}
{focusedCardId && canvas.zoom >= 0.9 && (
<DirectionHints
hasLeft={neighborDirections.left}
hasRight={neighborDirections.right}
hasUp={neighborDirections.up}
hasDown={neighborDirections.down}
shakeDirection={shakeDirection}
/>
)}
{/* Floating zoom controls + minimap */}
<Box sx={{ position: 'absolute', bottom: 16, right: 16, zIndex: 10 }}>
<CanvasControls zoom={canvas.zoom} actions={canvas.actions} onTidy={handleTidy} />
<CanvasControls
zoom={canvas.zoom}
actions={canvas.actions}
onFitToView={handleFitToView}
onTidy={handleTidy}
minimapProps={{
panX: canvas.panX,
panY: canvas.panY,
zoom: canvas.zoom,
viewportRef: canvas.viewportRef,
cards,
viewCards,
browserCards,
}}
onMinimapPan={(px, py) => canvas.actions.setState({ panX: px, panY: py, zoom: canvas.zoom })}
/>
</Box>
</Box>
{/* Card search palette (Cmd+F) */}
<CardSearchPalette
open={searchPaletteOpen}
onClose={() => setSearchPaletteOpen(false)}
onNavigate={(rect) => canvas.actions.fitToCards([rect], 1.15, true)}
cards={cards}
viewCards={viewCards}
browserCards={browserCards}
sessions={sessions}
/>
</>
);
};
@@ -92,7 +92,7 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
const handleFocus = useCallback(
(cardId: string, card: { x: number; y: number; width: number; height: number }) => {
canvasActions.fitToCards([card], 1.0, true);
canvasActions.fitToCards([card], 1.15, true);
onHighlightCard?.(cardId);
setExpanded(false);
},
@@ -46,22 +46,25 @@ interface Props {
cardWidth: number;
cardHeight: number;
zoom?: number;
panX?: number;
panY?: number;
cmdHeld?: boolean;
isSelected?: boolean;
isHighlighted?: boolean;
multiDragDelta?: { dx: number; dy: number } | null;
onCardSelect?: (id: string, type: 'agent' | 'view', shiftKey: boolean) => void;
onDragStart?: (id: string, type: 'agent' | 'view') => void;
onDragMove?: (dx: number, dy: number) => void;
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
cardZOrder?: number;
onDoubleClick?: (id: string, type: 'agent' | 'view' | 'browser') => void;
onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser') => void;
}
const DashboardViewCard: React.FC<Props> = ({
output, cardX, cardY, cardWidth, cardHeight, zoom = 1, cmdHeld = false,
output, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0, cmdHeld = false,
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
cardZOrder = 0, onBringToFront,
cardZOrder = 0, onDoubleClick, onBringToFront,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
@@ -76,47 +79,78 @@ const DashboardViewCard: React.FC<Props> = ({
// ---- Drag via header ----
const DRAG_THRESHOLD = 3;
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null);
const didDrag = useRef(false);
const justDraggedRef = useRef(false);
const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 });
const panRef = useRef({ panX, panY });
panRef.current = { panX, panY };
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY };
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: panRef.current.panX, startPanY: panRef.current.panY };
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
didDrag.current = false;
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
onDragStart?.(output.id, 'view');
}, [cardX, cardY, onDragStart, output.id]);
const recomputeDragPos = useCallback(() => {
const ds = dragState.current;
if (!ds || !didDrag.current) return;
const { clientX, clientY } = lastPointerRef.current;
const rawDx = clientX - ds.startX;
const rawDy = clientY - ds.startY;
const z = zoomRef.current;
const panDx = (panRef.current.panX - ds.startPanX) / z;
const panDy = (panRef.current.panY - ds.startPanY) / z;
const dx = rawDx / z - panDx;
const dy = rawDy / z - panDy;
setLocalDragPos({ x: ds.origX + dx, y: ds.origY + dy });
onDragMove?.(dx, dy, clientX, clientY);
}, [onDragMove]);
useEffect(() => {
if (isDragging && didDrag.current) recomputeDragPos();
}, [panX, panY, isDragging, recomputeDragPos]);
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const rawDx = e.clientX - dragState.current.startX;
const rawDy = e.clientY - dragState.current.startY;
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
didDrag.current = true;
const dx = rawDx / zoom;
const dy = rawDy / zoom;
setLocalDragPos({
x: dragState.current.origX + dx,
y: dragState.current.origY + dy,
});
onDragMove?.(dx, dy);
}, [zoom, onDragMove]);
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
recomputeDragPos();
}, [recomputeDragPos]);
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const dx = (e.clientX - dragState.current.startX) / zoom;
const dy = (e.clientY - dragState.current.startY) / zoom;
const z = zoomRef.current;
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
const dx = (e.clientX - dragState.current.startX) / z - panDx;
const dy = (e.clientY - dragState.current.startY) / z - panDy;
if (didDrag.current) {
let finalX = dragState.current.origX + dx;
let finalY = dragState.current.origY + dy;
// Snap to 24px grid (hold Shift to bypass)
if (!e.shiftKey) {
finalX = Math.round(finalX / 24) * 24;
finalY = Math.round(finalY / 24) * 24;
}
dispatch(setViewCardPosition({
outputId: output.id,
x: dragState.current.origX + dx,
y: dragState.current.origY + dy,
x: finalX,
y: finalY,
}));
justDraggedRef.current = true;
requestAnimationFrame(() => { justDraggedRef.current = false; });
@@ -127,7 +161,7 @@ const DashboardViewCard: React.FC<Props> = ({
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [zoom, dispatch, output.id, onDragEnd]);
}, [dispatch, output.id, onDragEnd]);
// ---- Resize ----
const resizeRef = useRef<{
@@ -267,6 +301,10 @@ const DashboardViewCard: React.FC<Props> = ({
if (justDraggedRef.current) return;
onCardSelect?.(output.id, 'view', e.shiftKey);
}}
onDoubleClick={(e: React.MouseEvent) => {
e.stopPropagation();
onDoubleClick?.(output.id, 'view');
}}
sx={{
position: 'absolute',
left: displayX,
@@ -0,0 +1,108 @@
import React from 'react';
import Box from '@mui/material/Box';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
interface Props {
hasLeft: boolean;
hasRight: boolean;
hasUp: boolean;
hasDown: boolean;
shakeDirection: 'left' | 'right' | 'up' | 'down' | null;
}
const shakeKeyframes: Record<string, string> = {
left: `
@keyframes shake-left { 0%,100% { transform: translateY(-50%) translateX(0); } 25% { transform: translateY(-50%) translateX(-6px); } 75% { transform: translateY(-50%) translateX(4px); } }
`,
right: `
@keyframes shake-right { 0%,100% { transform: translateY(-50%) translateX(0); } 25% { transform: translateY(-50%) translateX(6px); } 75% { transform: translateY(-50%) translateX(-4px); } }
`,
up: `
@keyframes shake-up { 0%,100% { transform: translateX(-50%) translateY(0); } 25% { transform: translateX(-50%) translateY(-6px); } 75% { transform: translateX(-50%) translateY(4px); } }
`,
down: `
@keyframes shake-down { 0%,100% { transform: translateX(-50%) translateY(0); } 25% { transform: translateX(-50%) translateY(6px); } 75% { transform: translateX(-50%) translateY(-4px); } }
`,
};
const DirectionHints: React.FC<Props> = ({ hasLeft, hasRight, hasUp, hasDown, shakeDirection }) => {
const c = useClaudeTokens();
const hintSx = {
position: 'absolute' as const,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 28,
height: 28,
borderRadius: '50%',
bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`,
boxShadow: c.shadow.sm,
color: c.text.muted,
opacity: 0.5,
transition: 'opacity 0.2s',
pointerEvents: 'none' as const,
};
const shakingSx = (dir: string) => ({
...hintSx,
opacity: 1,
color: c.accent.primary,
animation: `shake-${dir} 0.3s ease 2`,
});
// Show shake indicator even when there's no neighbor in that direction
const showLeft = hasLeft || shakeDirection === 'left';
const showRight = hasRight || shakeDirection === 'right';
const showUp = hasUp || shakeDirection === 'up';
const showDown = hasDown || shakeDirection === 'down';
return (
<>
{/* Inject shake keyframes */}
{shakeDirection && (
<style>{shakeKeyframes[shakeDirection]}</style>
)}
{showLeft && (
<Box sx={{
...(shakeDirection === 'left' ? shakingSx('left') : hintSx),
left: 16, top: '50%', transform: 'translateY(-50%)',
}}>
<ChevronLeftIcon sx={{ fontSize: '1.1rem' }} />
</Box>
)}
{showRight && (
<Box sx={{
...(shakeDirection === 'right' ? shakingSx('right') : hintSx),
right: 16, top: '50%', transform: 'translateY(-50%)',
}}>
<ChevronRightIcon sx={{ fontSize: '1.1rem' }} />
</Box>
)}
{showUp && (
<Box sx={{
...(shakeDirection === 'up' ? shakingSx('up') : hintSx),
top: 16, left: '50%', transform: 'translateX(-50%)',
}}>
<KeyboardArrowUpIcon sx={{ fontSize: '1.1rem' }} />
</Box>
)}
{showDown && (
<Box sx={{
...(shakeDirection === 'down' ? shakingSx('down') : hintSx),
bottom: 56, left: '50%', transform: 'translateX(-50%)',
}}>
<KeyboardArrowDownIcon sx={{ fontSize: '1.1rem' }} />
</Box>
)}
</>
);
};
export default DirectionHints;
@@ -0,0 +1,174 @@
import React, { useRef, useCallback, useMemo } from 'react';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { CardPosition, ViewCardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice';
const MINIMAP_W = 200;
const MINIMAP_H = 140;
const PADDING = 20;
export interface MinimapProps {
panX: number;
panY: number;
zoom: number;
viewportRef: React.RefObject<HTMLDivElement | null>;
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
onPan: (panX: number, panY: number) => void;
}
interface CardRect {
x: number;
y: number;
width: number;
height: number;
type: 'agent' | 'view' | 'browser';
}
const Minimap: React.FC<MinimapProps> = ({
panX, panY, zoom, viewportRef,
cards, viewCards, browserCards,
onPan,
}) => {
const c = useClaudeTokens();
const svgRef = useRef<SVGSVGElement>(null);
const isDraggingRef = useRef(false);
const allCards = useMemo((): CardRect[] => {
const result: CardRect[] = [];
for (const card of Object.values(cards)) {
result.push({ x: card.x, y: card.y, width: card.width, height: card.height, type: 'agent' });
}
for (const vc of Object.values(viewCards)) {
result.push({ x: vc.x, y: vc.y, width: vc.width, height: vc.height, type: 'view' });
}
for (const bc of Object.values(browserCards)) {
result.push({ x: bc.x, y: bc.y, width: bc.width, height: bc.height, type: 'browser' });
}
return result;
}, [cards, viewCards, browserCards]);
const layout = useMemo(() => {
const vp = viewportRef.current;
const vpW = vp ? vp.clientWidth : 1200;
const vpH = vp ? vp.clientHeight : 800;
const vpRect = {
x: -panX / zoom,
y: -panY / zoom,
width: vpW / zoom,
height: vpH / zoom,
};
if (allCards.length === 0) {
const scale = Math.min(
(MINIMAP_W - PADDING * 2) / vpRect.width,
(MINIMAP_H - PADDING * 2) / vpRect.height,
);
return {
scale,
offsetX: MINIMAP_W / 2 - (vpRect.x + vpRect.width / 2) * scale,
offsetY: MINIMAP_H / 2 - (vpRect.y + vpRect.height / 2) * scale,
vpRect,
};
}
let minX = vpRect.x, minY = vpRect.y;
let maxX = vpRect.x + vpRect.width, maxY = vpRect.y + vpRect.height;
for (const card of allCards) {
minX = Math.min(minX, card.x);
minY = Math.min(minY, card.y);
maxX = Math.max(maxX, card.x + card.width);
maxY = Math.max(maxY, card.y + card.height);
}
const contentW = maxX - minX;
const contentH = maxY - minY;
const scale = Math.min(
(MINIMAP_W - PADDING * 2) / contentW,
(MINIMAP_H - PADDING * 2) / contentH,
);
return {
scale,
offsetX: (MINIMAP_W - contentW * scale) / 2 - minX * scale,
offsetY: (MINIMAP_H - contentH * scale) / 2 - minY * scale,
vpRect,
};
}, [allCards, panX, panY, zoom, viewportRef]);
const minimapToCanvas = useCallback((clientX: number, clientY: number) => {
const svg = svgRef.current;
if (!svg) return;
const rect = svg.getBoundingClientRect();
const mx = clientX - rect.left;
const my = clientY - rect.top;
const canvasX = (mx - layout.offsetX) / layout.scale;
const canvasY = (my - layout.offsetY) / layout.scale;
onPan(
-(canvasX - layout.vpRect.width / 2) * zoom,
-(canvasY - layout.vpRect.height / 2) * zoom,
);
}, [layout, zoom, onPan]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
isDraggingRef.current = true;
minimapToCanvas(e.clientX, e.clientY);
const onMove = (ev: MouseEvent) => {
if (isDraggingRef.current) minimapToCanvas(ev.clientX, ev.clientY);
};
const onUp = () => {
isDraggingRef.current = false;
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}, [minimapToCanvas]);
const typeColor = (type: 'agent' | 'view' | 'browser') => {
switch (type) {
case 'agent': return c.accent.primary;
case 'view': return c.status.info;
case 'browser': return c.status.success;
}
};
return (
<svg
ref={svgRef}
width={MINIMAP_W}
height={MINIMAP_H}
onMouseDown={handleMouseDown}
style={{ cursor: 'pointer', display: 'block' }}
>
{allCards.map((card, i) => (
<rect
key={i}
x={card.x * layout.scale + layout.offsetX}
y={card.y * layout.scale + layout.offsetY}
width={card.width * layout.scale}
height={card.height * layout.scale}
fill={typeColor(card.type)}
opacity={0.6}
rx={1}
/>
))}
<rect
x={layout.vpRect.x * layout.scale + layout.offsetX}
y={layout.vpRect.y * layout.scale + layout.offsetY}
width={layout.vpRect.width * layout.scale}
height={layout.vpRect.height * layout.scale}
fill="none"
stroke={c.accent.primary}
strokeWidth={1.5}
opacity={0.8}
rx={1}
/>
</svg>
);
};
export default Minimap;
@@ -22,7 +22,14 @@ function clamp(val: number, min: number, max: number) {
return Math.min(max, Math.max(min, val));
}
export function useCanvasControls(zoomSensitivity: number = 50) {
export interface ContentBounds {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: ContentBounds) {
const viewportRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
@@ -38,7 +45,125 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
const cmdRef = useRef(false);
const sensitivityRef = useRef(zoomSensitivity);
sensitivityRef.current = zoomSensitivity;
const contentBoundsRef = useRef(contentBounds);
contentBoundsRef.current = contentBounds;
const animFrameRef = useRef<number | null>(null);
const inertiaFrameRef = useRef<number | null>(null);
// ---- Velocity tracking for momentum panning ----
const velocityHistoryRef = useRef<Array<{ x: number; y: number; t: number }>>([]);
const FRICTION = 0.93;
const MIN_VELOCITY = 0.5;
const cancelInertia = useCallback(() => {
if (inertiaFrameRef.current) {
cancelAnimationFrame(inertiaFrameRef.current);
inertiaFrameRef.current = null;
}
}, []);
const startInertia = useCallback((vx: number, vy: number) => {
cancelInertia();
let velocityX = vx;
let velocityY = vy;
const step = () => {
velocityX *= FRICTION;
velocityY *= FRICTION;
if (Math.abs(velocityX) < MIN_VELOCITY && Math.abs(velocityY) < MIN_VELOCITY) {
inertiaFrameRef.current = null;
springBackIfNeeded();
return;
}
setState((prev) => ({
...prev,
panX: prev.panX + velocityX,
panY: prev.panY + velocityY,
}));
inertiaFrameRef.current = requestAnimationFrame(step);
};
inertiaFrameRef.current = requestAnimationFrame(step);
}, [cancelInertia]);
// ---- Soft pan boundaries: spring back if viewport drifts too far from content ----
const BOUNDARY_MARGIN = 800; // extra px beyond content bounds before spring-back
const springBackIfNeeded = useCallback(() => {
const bounds = contentBoundsRef.current;
const vp = viewportRef.current;
if (!bounds || !vp) return;
const cur = stateRef.current;
const vpW = vp.clientWidth;
const vpH = vp.clientHeight;
// Viewport in canvas coords
const vpLeft = -cur.panX / cur.zoom;
const vpTop = -cur.panY / cur.zoom;
const vpRight = vpLeft + vpW / cur.zoom;
const vpBottom = vpTop + vpH / cur.zoom;
const bLeft = bounds.minX - BOUNDARY_MARGIN;
const bTop = bounds.minY - BOUNDARY_MARGIN;
const bRight = bounds.maxX + BOUNDARY_MARGIN;
const bBottom = bounds.maxY + BOUNDARY_MARGIN;
let newPanX = cur.panX;
let newPanY = cur.panY;
// If viewport is completely outside bounds, nudge it back
if (vpRight < bLeft) {
newPanX = -(bLeft - vpW / cur.zoom) * cur.zoom;
} else if (vpLeft > bRight) {
newPanX = -bRight * cur.zoom;
}
if (vpBottom < bTop) {
newPanY = -(bTop - vpH / cur.zoom) * cur.zoom;
} else if (vpTop > bBottom) {
newPanY = -bBottom * cur.zoom;
}
if (newPanX !== cur.panX || newPanY !== cur.panY) {
// animateTo will be available by the time this runs
animateToRef.current?.({ panX: newPanX, panY: newPanY, zoom: cur.zoom }, 250);
}
}, []);
// ---- Reusable animation helper ----
const cancelAnimation = useCallback(() => {
if (animFrameRef.current) {
cancelAnimationFrame(animFrameRef.current);
animFrameRef.current = null;
}
}, []);
const animateToRef = useRef<((target: CanvasState, duration?: number) => void) | null>(null);
const animateTo = useCallback((target: CanvasState, duration: number = 320) => {
cancelAnimation();
const start = { ...stateRef.current };
const startTime = performance.now();
const step = (now: number) => {
const t = Math.min((now - startTime) / duration, 1);
const ease = 1 - Math.pow(1 - t, 3); // cubic ease-out
setState({
panX: start.panX + (target.panX - start.panX) * ease,
panY: start.panY + (target.panY - start.panY) * ease,
zoom: start.zoom + (target.zoom - start.zoom) * ease,
});
if (t < 1) {
animFrameRef.current = requestAnimationFrame(step);
} else {
animFrameRef.current = null;
}
};
animFrameRef.current = requestAnimationFrame(step);
}, [cancelAnimation]);
animateToRef.current = animateTo;
// Wheel zoom centered on cursor
useEffect(() => {
@@ -49,7 +174,10 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
// Pinch-to-zoom on trackpads sets ctrlKey; plain scroll does not
const isPinchZoom = e.ctrlKey || e.metaKey;
// Let scrollable children handle the event when appropriate
// Let scrollable children handle the event when appropriate,
// but fall through to canvas pan if the child is at its scroll boundary.
const dy = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY;
const dx = e.deltaMode === 1 ? e.deltaX * 40 : e.deltaX;
let target = e.target as HTMLElement | null;
while (target && target !== el) {
const style = getComputedStyle(target);
@@ -64,12 +192,29 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
(overflowX === 'auto' || overflowX === 'scroll');
if ((canScrollY || canScrollX) && !isPinchZoom) {
// Check if at scroll boundary in the scroll direction
const atYBoundary = !canScrollY ||
(dy > 0 && target.scrollTop + target.clientHeight >= target.scrollHeight - 1) ||
(dy < 0 && target.scrollTop <= 1);
const atXBoundary = !canScrollX ||
(dx > 0 && target.scrollLeft + target.clientWidth >= target.scrollWidth - 1) ||
(dx < 0 && target.scrollLeft <= 1);
if (atYBoundary && atXBoundary) {
// At boundary — fall through to canvas pan
target = target.parentElement;
continue;
}
return;
}
target = target.parentElement;
}
e.preventDefault();
if (inertiaFrameRef.current) {
cancelAnimationFrame(inertiaFrameRef.current);
inertiaFrameRef.current = null;
}
if (isPinchZoom) {
// Pinch gesture → zoom centered on cursor
@@ -78,8 +223,7 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
const cy = e.clientY - rect.top;
setState((prev) => {
const delta = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY;
const factor = Math.pow(2, -delta * sensitivityToMultiplier(sensitivityRef.current));
const factor = Math.pow(2, -dy * sensitivityToMultiplier(sensitivityRef.current));
const newZoom = clamp(prev.zoom * factor, MIN_ZOOM, MAX_ZOOM);
const ratio = newZoom / prev.zoom;
return {
@@ -90,9 +234,6 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
});
} else {
// Two-finger scroll → pan
const dx = e.deltaMode === 1 ? e.deltaX * 40 : e.deltaX;
const dy = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY;
setState((prev) => ({
...prev,
panX: prev.panX - dx,
@@ -105,6 +246,120 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
return () => el.removeEventListener('wheel', onWheel);
}, []);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
cancelAnimation();
cancelInertia();
setIsPanning(true);
velocityHistoryRef.current = [{ x: e.clientX, y: e.clientY, t: performance.now() }];
panStartRef.current = {
x: e.clientX,
y: e.clientY,
panX: stateRef.current.panX,
panY: stateRef.current.panY,
};
}, [cancelAnimation, cancelInertia]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
const start = panStartRef.current;
if (!start) return;
const dx = e.clientX - start.x;
const dy = e.clientY - start.y;
// Track velocity (keep last 5 positions)
const now = performance.now();
const history = velocityHistoryRef.current;
history.push({ x: e.clientX, y: e.clientY, t: now });
if (history.length > 5) history.shift();
setState((prev) => ({
...prev,
panX: start.panX + dx,
panY: start.panY + dy,
}));
}, []);
const handleMouseUp = useCallback(() => {
const wasPanning = !!panStartRef.current;
let didInertia = false;
if (wasPanning) {
// Compute velocity from recent mouse history
const history = velocityHistoryRef.current;
if (history.length >= 2) {
const oldest = history[0];
const newest = history[history.length - 1];
const dt = newest.t - oldest.t;
if (dt > 0 && dt < 200) {
const vx = (newest.x - oldest.x) / (dt / 16.67); // px per frame
const vy = (newest.y - oldest.y) / (dt / 16.67);
if (Math.abs(vx) > MIN_VELOCITY || Math.abs(vy) > MIN_VELOCITY) {
startInertia(vx, vy);
didInertia = true;
}
}
}
velocityHistoryRef.current = [];
}
panStartRef.current = null;
setIsPanning(false);
// Only spring back if we were actually panning (not on simple clicks)
if (wasPanning && !didInertia) {
springBackIfNeeded();
}
}, [startInertia, springBackIfNeeded]);
// Clean up panning if mouse leaves the window
useEffect(() => {
const onUp = () => {
if (panStartRef.current) {
panStartRef.current = null;
setIsPanning(false);
}
};
window.addEventListener('mouseup', onUp);
return () => window.removeEventListener('mouseup', onUp);
}, []);
useEffect(() => {
return () => { cancelAnimation(); cancelInertia(); };
}, [cancelAnimation, cancelInertia]);
const zoomIn = useCallback(() => {
const prev = stateRef.current;
const newZoom = clamp(prev.zoom * ZOOM_IN_FACTOR, MIN_ZOOM, MAX_ZOOM);
const el = viewportRef.current;
if (!el) { animateTo({ ...prev, zoom: newZoom }, 150); return; }
const rect = el.getBoundingClientRect();
const cx = rect.width / 2;
const cy = rect.height / 2;
const ratio = newZoom / prev.zoom;
animateTo({ panX: cx - (cx - prev.panX) * ratio, panY: cy - (cy - prev.panY) * ratio, zoom: newZoom }, 150);
}, [animateTo]);
const zoomOut = useCallback(() => {
const prev = stateRef.current;
const newZoom = clamp(prev.zoom * ZOOM_OUT_FACTOR, MIN_ZOOM, MAX_ZOOM);
const el = viewportRef.current;
if (!el) { animateTo({ ...prev, zoom: newZoom }, 150); return; }
const rect = el.getBoundingClientRect();
const cx = rect.width / 2;
const cy = rect.height / 2;
const ratio = newZoom / prev.zoom;
animateTo({ panX: cx - (cx - prev.panX) * ratio, panY: cy - (cy - prev.panY) * ratio, zoom: newZoom }, 150);
}, [animateTo]);
const resetZoom = useCallback(() => {
animateTo({ panX: 0, panY: 0, zoom: 1 });
}, [animateTo]);
// Stable refs for keyboard handler (avoids re-registering keydown listener)
const zoomInRef = useRef(zoomIn);
zoomInRef.current = zoomIn;
const zoomOutRef = useRef(zoomOut);
zoomOutRef.current = zoomOut;
const resetZoomRef = useRef(resetZoom);
resetZoomRef.current = resetZoom;
// Space key tracking
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
@@ -121,31 +376,13 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
if (e.ctrlKey || e.metaKey) {
if (e.key === '0') {
e.preventDefault();
setState({ panX: 0, panY: 0, zoom: 1 });
resetZoomRef.current();
} else if (e.key === '=' || e.key === '+') {
e.preventDefault();
setState((prev) => {
const newZoom = clamp(prev.zoom * ZOOM_IN_FACTOR, MIN_ZOOM, MAX_ZOOM);
const el = viewportRef.current;
if (!el) return { ...prev, zoom: newZoom };
const rect = el.getBoundingClientRect();
const cx = rect.width / 2;
const cy = rect.height / 2;
const ratio = newZoom / prev.zoom;
return { panX: cx - (cx - prev.panX) * ratio, panY: cy - (cy - prev.panY) * ratio, zoom: newZoom };
});
zoomInRef.current();
} else if (e.key === '-') {
e.preventDefault();
setState((prev) => {
const newZoom = clamp(prev.zoom * ZOOM_OUT_FACTOR, MIN_ZOOM, MAX_ZOOM);
const el = viewportRef.current;
if (!el) return { ...prev, zoom: newZoom };
const rect = el.getBoundingClientRect();
const cx = rect.width / 2;
const cy = rect.height / 2;
const ratio = newZoom / prev.zoom;
return { panX: cx - (cx - prev.panX) * ratio, panY: cy - (cy - prev.panY) * ratio, zoom: newZoom };
});
zoomOutRef.current();
}
}
};
@@ -168,80 +405,6 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
};
}, []);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsPanning(true);
panStartRef.current = {
x: e.clientX,
y: e.clientY,
panX: stateRef.current.panX,
panY: stateRef.current.panY,
};
}, []);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
const start = panStartRef.current;
if (!start) return;
const dx = e.clientX - start.x;
const dy = e.clientY - start.y;
setState((prev) => ({
...prev,
panX: start.panX + dx,
panY: start.panY + dy,
}));
}, []);
const handleMouseUp = useCallback(() => {
panStartRef.current = null;
setIsPanning(false);
}, []);
// Clean up panning if mouse leaves the window
useEffect(() => {
const onUp = () => {
if (panStartRef.current) {
panStartRef.current = null;
setIsPanning(false);
}
};
window.addEventListener('mouseup', onUp);
return () => window.removeEventListener('mouseup', onUp);
}, []);
useEffect(() => {
return () => { if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); };
}, []);
const zoomIn = useCallback(() => {
setState((prev) => {
const newZoom = clamp(prev.zoom * ZOOM_IN_FACTOR, MIN_ZOOM, MAX_ZOOM);
const el = viewportRef.current;
if (!el) return { ...prev, zoom: newZoom };
const rect = el.getBoundingClientRect();
const cx = rect.width / 2;
const cy = rect.height / 2;
const ratio = newZoom / prev.zoom;
return { panX: cx - (cx - prev.panX) * ratio, panY: cy - (cy - prev.panY) * ratio, zoom: newZoom };
});
}, []);
const zoomOut = useCallback(() => {
setState((prev) => {
const newZoom = clamp(prev.zoom * ZOOM_OUT_FACTOR, MIN_ZOOM, MAX_ZOOM);
const el = viewportRef.current;
if (!el) return { ...prev, zoom: newZoom };
const rect = el.getBoundingClientRect();
const cx = rect.width / 2;
const cy = rect.height / 2;
const ratio = newZoom / prev.zoom;
return { panX: cx - (cx - prev.panX) * ratio, panY: cy - (cy - prev.panY) * ratio, zoom: newZoom };
});
}, []);
const resetZoom = useCallback(() => {
setState({ panX: 0, panY: 0, zoom: 1 });
}, []);
const fitToView = useCallback(() => {
const viewport = viewportRef.current;
const content = contentRef.current;
@@ -250,39 +413,38 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
const vRect = viewport.getBoundingClientRect();
const children = content.children;
if (children.length === 0) {
setState({ panX: 0, panY: 0, zoom: 1 });
animateTo({ panX: 0, panY: 0, zoom: 1 });
return;
}
setState((prev) => {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let i = 0; i < children.length; i++) {
const r = children[i].getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
const sx = (r.left - vRect.left - prev.panX) / prev.zoom;
const sy = (r.top - vRect.top - prev.panY) / prev.zoom;
minX = Math.min(minX, sx);
minY = Math.min(minY, sy);
maxX = Math.max(maxX, sx + r.width / prev.zoom);
maxY = Math.max(maxY, sy + r.height / prev.zoom);
}
const prev = stateRef.current;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let i = 0; i < children.length; i++) {
const r = children[i].getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
const sx = (r.left - vRect.left - prev.panX) / prev.zoom;
const sy = (r.top - vRect.top - prev.panY) / prev.zoom;
minX = Math.min(minX, sx);
minY = Math.min(minY, sy);
maxX = Math.max(maxX, sx + r.width / prev.zoom);
maxY = Math.max(maxY, sy + r.height / prev.zoom);
}
if (!isFinite(minX)) return { panX: 0, panY: 0, zoom: 1 };
if (!isFinite(minX)) { animateTo({ panX: 0, panY: 0, zoom: 1 }); return; }
const contentWidth = maxX - minX;
const contentHeight = maxY - minY;
const availW = vRect.width - FIT_PADDING * 2;
const availH = vRect.height - FIT_PADDING * 2;
const newZoom = clamp(Math.min(availW / contentWidth, availH / contentHeight), MIN_ZOOM, MAX_ZOOM);
const newPanX = (vRect.width - contentWidth * newZoom) / 2 - minX * newZoom;
const newPanY = (vRect.height - contentHeight * newZoom) / 2 - minY * newZoom;
const contentWidth = maxX - minX;
const contentHeight = maxY - minY;
const availW = vRect.width - FIT_PADDING * 2;
const availH = vRect.height - FIT_PADDING * 2;
const newZoom = clamp(Math.min(availW / contentWidth, availH / contentHeight), MIN_ZOOM, MAX_ZOOM);
const newPanX = (vRect.width - contentWidth * newZoom) / 2 - minX * newZoom;
const newPanY = (vRect.height - contentHeight * newZoom) / 2 - minY * newZoom;
return { panX: newPanX, panY: newPanY, zoom: newZoom };
});
}, []);
animateTo({ panX: newPanX, panY: newPanY, zoom: newZoom });
}, [animateTo]);
const fitToCards = useCallback((cardRects: Array<{ x: number; y: number; width: number; height: number }>, maxZoom?: number, animate?: boolean) => {
if (animFrameRef.current) { cancelAnimationFrame(animFrameRef.current); animFrameRef.current = null; }
cancelAnimation();
const viewport = viewportRef.current;
if (!viewport || cardRects.length === 0) {
@@ -312,33 +474,24 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
const ceiling = maxZoom ?? MAX_ZOOM;
const targetZoom = clamp(Math.min(availW / contentWidth, availH / contentHeight), MIN_ZOOM, ceiling);
const targetPanX = (vRect.width - contentWidth * targetZoom) / 2 - minX * targetZoom;
const targetPanY = (vRect.height - contentHeight * targetZoom) / 2 - minY * targetZoom;
// For single cards, position near top of viewport (80px padding) instead of dead center
const topBiased = cardRects.length === 1;
const targetPanY = topBiased
? (FIT_PADDING * 0.4) - minY * targetZoom
: (vRect.height - contentHeight * targetZoom) / 2 - minY * targetZoom;
if (!animate) {
setState({ panX: targetPanX, panY: targetPanY, zoom: targetZoom });
return;
const target = { panX: targetPanX, panY: targetPanY, zoom: targetZoom };
if (animate) {
// Skip if already at target (avoids jitter on re-click)
const cur = stateRef.current;
const dPan = Math.abs(cur.panX - target.panX) + Math.abs(cur.panY - target.panY);
const dZoom = Math.abs(cur.zoom - target.zoom);
if (dPan < 5 && dZoom < 0.01) return;
animateTo(target);
} else {
setState(target);
}
const start = { ...stateRef.current };
const startTime = performance.now();
const duration = 320;
const step = (now: number) => {
const t = Math.min((now - startTime) / duration, 1);
const ease = 1 - Math.pow(1 - t, 3);
setState({
panX: start.panX + (targetPanX - start.panX) * ease,
panY: start.panY + (targetPanY - start.panY) * ease,
zoom: start.zoom + (targetZoom - start.zoom) * ease,
});
if (t < 1) {
animFrameRef.current = requestAnimationFrame(step);
} else {
animFrameRef.current = null;
}
};
animFrameRef.current = requestAnimationFrame(step);
}, []);
}, [cancelAnimation, animateTo]);
const handlers = useMemo(() => ({
onMouseDown: handleMouseDown,
@@ -347,8 +500,8 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
}), [handleMouseDown, handleMouseMove, handleMouseUp]);
const actions = useMemo(() => ({
zoomIn, zoomOut, resetZoom, fitToView, fitToCards,
}), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards]);
zoomIn, zoomOut, resetZoom, fitToView, fitToCards, animateTo, cancelAnimation, setState,
}), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards, animateTo, cancelAnimation]);
return {
...state,
File diff suppressed because one or more lines are too long