[eric] canvas: lost-release drag seal on chat/note/app cards (shared backstops), click never collapses an expanded chat, top grab band, thirds tiling dropped, mode slash commands removed

This commit is contained in:
ciregenz
2026-07-28 13:29:59 -07:00
parent 1a0a2c0b2c
commit 8afb1bcc4d
8 changed files with 104 additions and 72 deletions
@@ -3,11 +3,6 @@ import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Paper from '@mui/material/Paper';
import PsychologyIcon from '@mui/icons-material/Psychology';
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
import QuestionAnswerOutlinedIcon from '@mui/icons-material/QuestionAnswerOutlined';
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
import CategoryOutlinedIcon from '@mui/icons-material/CategoryOutlined';
import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined';
import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined';
import LanguageIcon from '@mui/icons-material/Language';
import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined';
@@ -66,14 +61,6 @@ interface Props {
visible: boolean;
}
const MODE_ICON_MAP: Record<string, React.ComponentType<{ sx?: object }>> = {
smart_toy: SmartToyOutlinedIcon,
question_answer: QuestionAnswerOutlinedIcon,
map: MapOutlinedIcon,
category: CategoryOutlinedIcon,
tune: TuneOutlinedIcon,
};
function highlightMatch(text: string, query: string, color: string): React.ReactNode {
if (!query) return text;
const idx = text.toLowerCase().indexOf(query.toLowerCase());
@@ -91,7 +78,6 @@ const CommandPicker: React.FC<Props> = ({ trigger, filter, onSelect, onClose, vi
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const skills = useAppSelector((s) => s.skills.items);
const modesMap = useAppSelector((s) => s.modes.items);
const builtinTools = useAppSelector((s) => s.tools.builtinTools);
const customTools = useAppSelector((s) => s.tools.items);
const [selectedIndex, setSelectedIndex] = useState(0);
@@ -121,20 +107,7 @@ const CommandPicker: React.FC<Props> = ({ trigger, filter, onSelect, onClose, vi
icon: <PsychologyIcon sx={{ fontSize: 15 }} />,
}));
const modeItems: CommandPickerItem[] = Object.values(modesMap).map((m) => {
const IconComp = MODE_ICON_MAP[m.icon] || SmartToyOutlinedIcon;
return {
id: m.id,
type: 'mode' as const,
category: 'Modes',
name: m.name,
description: m.description || 'Switch to this mode',
command: m.name.toLowerCase().replace(/\s+/g, '-'),
icon: <IconComp sx={{ fontSize: 15 }} />,
};
});
all = [...skillItems, ...modeItems];
all = skillItems;
} else {
const atItems: CommandPickerItem[] = [
{
@@ -259,7 +232,7 @@ const CommandPicker: React.FC<Props> = ({ trigger, filter, onSelect, onClose, vi
item.command.toLowerCase().includes(lower) ||
item.description.toLowerCase().includes(lower),
);
}, [trigger, skills, modesMap, builtinTools, customTools, filter]);
}, [trigger, skills, builtinTools, customTools, filter]);
const flatItems = useMemo(() => {
const result: { item: CommandPickerItem; isGroupStart: boolean; category: string }[] = [];
@@ -274,10 +247,6 @@ const CommandPicker: React.FC<Props> = ({ trigger, filter, onSelect, onClose, vi
const getIconColor = (item: CommandPickerItem): string => {
switch (item.type) {
case 'skill': return c.status.success;
case 'mode': {
const mode = modesMap[item.id];
return mode?.color || c.accent.primary;
}
case 'context': return c.text.tertiary;
default: return c.text.tertiary;
}
+1 -8
View File
@@ -86,14 +86,7 @@ export const CommandsContent: React.FC = () => {
description: s.description || 'Skill',
command: s.command || s.id,
})),
...Object.values(modesMap).map((m) => ({
id: m.id,
type: 'mode' as const,
name: m.name,
description: m.description || 'Switch to this mode',
command: m.name.toLowerCase().replace(/\s+/g, '-'),
})),
], [skills, modesMap]);
], [skills]);
const atCommands: AtCommand[] = useMemo(() => {
const items: AtCommand[] = [
@@ -40,6 +40,7 @@ import { useTiledStyle } from './tileZones';
import AgentNarratorPill from '../desktop/AgentNarratorPill';
import { extractLatestTodos } from '../desktop/agentTodos';
import { extractLatestShowUi, extractPendingAskUi, freezeIfDone } from '@/app/pages/AgentChat/tool-ui/showUiPayload';
import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops';
import { getWebview } from '@/shared/browserRegistry';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { QuestionForm } from '@/app/pages/AgentChat/shell/ApprovalBar';
@@ -453,7 +454,7 @@ const AgentCard: React.FC<Props> = ({
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
didDrag.current = false;
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); } catch { /* pointer already gone */ }
onDragStart?.(session.id, 'agent');
}, [cardX, cardY, onDragStart, session.id, getCanvasState, tileZone]);
@@ -493,14 +494,14 @@ const AgentCard: React.FC<Props> = ({
recomputeDragPos();
}, [recomputeDragPos]);
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
const finalizeDrag = useCallback((clientX: number, clientY: number, shiftKey: boolean) => {
if (!dragState.current) return;
const cs = getCanvasState();
const z = cs.zoom;
const panDx = (cs.panX - dragState.current.startPanX) / z;
const panDy = (cs.panY - dragState.current.startPanY) / z;
const dx = (e.clientX - dragState.current.startX) / z - panDx;
const dy = (e.clientY - dragState.current.startY) / z - panDy;
const dx = (clientX - dragState.current.startX) / z - panDx;
const dy = (clientY - dragState.current.startY) / z - panDy;
if (didDrag.current) {
let finalX = dragState.current.origX + dx;
let finalY = dragState.current.origY + dy;
@@ -511,7 +512,7 @@ const AgentCard: React.FC<Props> = ({
}
// Snap to 24px grid (Shift bypasses).
if (!e.shiftKey) {
if (!shiftKey) {
finalX = Math.round(finalX / 24) * 24;
finalY = Math.round(finalY / 24) * 24;
}
@@ -525,9 +526,20 @@ const AgentCard: React.FC<Props> = ({
didDrag.current = false;
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, session.id, onDragEnd, snapColumn, cardHeight, getCanvasState]);
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
finalizeDrag(e.clientX, e.clientY, e.shiftKey);
try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* capture already gone */ }
}, [finalizeDrag]);
const abortDrag = useCallback(() => {
if (!dragState.current) return;
finalizeDrag(lastPointerRef.current.clientX, lastPointerRef.current.clientY, true);
}, [finalizeDrag]);
useDragEndBackstops(isDragging, finalizeDrag, abortDrag);
const resizeRef = useRef<{
dir: ResizeDir;
startX: number;
@@ -967,6 +979,18 @@ const AgentCard: React.FC<Props> = ({
/>
)}
{/* Grab band: the top sliver of an expanded card drags it, matching the "grab the window by
its top edge" instinct; the pop-above header remains the labeled handle. */}
{expanded && !tiledStyle && !pillMode && (
<Box
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
onPointerUp={handleDragPointerUp}
onPointerCancel={abortDrag}
onLostPointerCapture={abortDrag}
sx={{ position: 'absolute', top: 0, left: 12, right: 12, height: 14, zIndex: 18, cursor: isDragging ? 'grabbing' : 'grab', touchAction: 'none' }}
/>
)}
{pillMode && (
<Box
onPointerDown={handleDragPointerDown}
@@ -1013,6 +1037,8 @@ const AgentCard: React.FC<Props> = ({
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
onPointerUp={handleDragPointerUp}
onPointerCancel={abortDrag}
onLostPointerCapture={abortDrag}
sx={{
...(expanded
? tiledStyle
@@ -18,6 +18,7 @@ import { Output, SERVE_BASE } from '@/shared/state/outputsSlice';
import { setViewCardPosition, setViewCardSize, setActiveViewCardId, recordClosedCard, addViewCard, setTiledCard, clearTiledCard, toggleMinimizeCard, activateViewCardPreview } from '@/shared/state/dashboardLayoutSlice';
import { removeViewCardCleanly } from '@/shared/viewTeardown';
import WindowControls from './WindowControls';
import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops';
import { useTiledStyle } from './tileZones';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { API_BASE, getAuthToken } from '@/shared/config';
@@ -329,7 +330,7 @@ const DashboardViewCard: React.FC<Props> = ({
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
didDrag.current = false;
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); } catch { /* pointer already gone */ }
onDragStart?.(cardKey, 'view');
}, [cardX, cardY, onDragStart, cardKey, getCanvasState, tileZone]);
@@ -369,19 +370,19 @@ const DashboardViewCard: React.FC<Props> = ({
recomputeDragPos();
}, [recomputeDragPos]);
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
const finalizeDrag = useCallback((clientX: number, clientY: number, shiftKey: boolean) => {
if (!dragState.current) return;
const cs = getCanvasState();
const z = cs.zoom;
const panDx = (cs.panX - dragState.current.startPanX) / z;
const panDy = (cs.panY - dragState.current.startPanY) / z;
const dx = (e.clientX - dragState.current.startX) / z - panDx;
const dy = (e.clientY - dragState.current.startY) / z - panDy;
const dx = (clientX - dragState.current.startX) / z - panDx;
const dy = (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; Shift bypasses.
if (!e.shiftKey) {
if (!shiftKey) {
finalX = Math.round(finalX / 24) * 24;
finalY = Math.round(finalY / 24) * 24;
}
@@ -398,9 +399,20 @@ const DashboardViewCard: React.FC<Props> = ({
didDrag.current = false;
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, cardKey, onDragEnd, getCanvasState]);
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
finalizeDrag(e.clientX, e.clientY, e.shiftKey);
try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* capture already gone */ }
}, [finalizeDrag]);
const abortDrag = useCallback(() => {
if (!dragState.current) return;
finalizeDrag(lastPointerRef.current.clientX, lastPointerRef.current.clientY, true);
}, [finalizeDrag]);
useDragEndBackstops(isDragging, finalizeDrag, abortDrag);
const resizeRef = useRef<{
dir: ResizeDir; startX: number; startY: number;
origX: number; origY: number; origW: number; origH: number;
@@ -612,6 +624,8 @@ const DashboardViewCard: React.FC<Props> = ({
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
onPointerUp={handleDragPointerUp}
onPointerCancel={abortDrag}
onLostPointerCapture={abortDrag}
onPointerEnter={() => { if (headerCollapsed) setHeaderPeek(true); }}
onPointerLeave={() => setHeaderPeek(false)}
sx={{
@@ -19,6 +19,7 @@ import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import WindowControls from './WindowControls';
import { useTiledStyle } from './tileZones';
import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops';
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
@@ -120,7 +121,7 @@ const NoteCard: React.FC<Props> = ({
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
didDrag.current = false;
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); } catch { /* pointer already gone */ }
onDragStart?.(noteId, 'note');
}, [cardX, cardY, noteId, onDragStart, getCanvasState, tileZone]);
@@ -160,18 +161,18 @@ const NoteCard: React.FC<Props> = ({
recomputeDragPos();
}, [recomputeDragPos]);
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
const finalizeDrag = useCallback((clientX: number, clientY: number, shiftKey: boolean) => {
if (!dragState.current) return;
const cs = getCanvasState();
const z = cs.zoom;
const panDx = (cs.panX - dragState.current.startPanX) / z;
const panDy = (cs.panY - dragState.current.startPanY) / z;
const dx = (e.clientX - dragState.current.startX) / z - panDx;
const dy = (e.clientY - dragState.current.startY) / z - panDy;
const dx = (clientX - dragState.current.startX) / z - panDx;
const dy = (clientY - dragState.current.startY) / z - panDy;
if (didDrag.current) {
let finalX = dragState.current.origX + dx;
let finalY = dragState.current.origY + dy;
if (!e.shiftKey) {
if (!shiftKey) {
finalX = Math.round(finalX / 24) * 24;
finalY = Math.round(finalY / 24) * 24;
}
@@ -184,9 +185,20 @@ const NoteCard: React.FC<Props> = ({
didDrag.current = false;
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, noteId, onDragEnd, getCanvasState]);
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
finalizeDrag(e.clientX, e.clientY, e.shiftKey);
try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* capture already gone */ }
}, [finalizeDrag]);
const abortDrag = useCallback(() => {
if (!dragState.current) return;
finalizeDrag(lastPointerRef.current.clientX, lastPointerRef.current.clientY, true);
}, [finalizeDrag]);
useDragEndBackstops(isDragging, finalizeDrag, abortDrag);
const resizeRef = useRef<{
dir: ResizeDir; startX: number; startY: number;
origX: number; origY: number; origW: number; origH: number;
@@ -329,7 +341,8 @@ const NoteCard: React.FC<Props> = ({
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
onPointerUp={handleDragPointerUp}
onPointerCancel={handleDragPointerUp}
onPointerCancel={abortDrag}
onLostPointerCapture={abortDrag}
sx={{
height: isMinimized ? '100%' : HEADER_H,
flexShrink: 0,
@@ -23,7 +23,6 @@ const GREEN = '#28c840';
const GROUPS: { label: string; zones: string[] }[] = [
{ label: 'Fill & Halves', zones: ['fill', 'left', 'right', 'top', 'bottom'] },
{ label: 'Quarters', zones: ['tl', 'tr', 'bl', 'br'] },
{ label: 'Thirds', zones: ['t3l', 't3c', 't3r'] },
];
const dotSx = (color: string): Record<string, unknown> => ({
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef, type Dispatch, type SetStateActi
import { report } from '@/shared/serviceClient';
import { useAppDispatch } from '@/shared/hooks';
import { store } from '@/shared/state/store';
import { collapseSession, expandSession } from '@/shared/state/agentsSlice';
import { expandSession } from '@/shared/state/agentsSlice';
import { bringToFront } from '@/shared/state/dashboardLayoutSlice';
import { setScrollFocusedCard } from '@/shared/cardScrollFocus';
import type { CardType, useDashboardSelection } from '../state/useDashboardSelection';
@@ -80,16 +80,10 @@ export function useDashboardInteractions({
// it or glide the camera; it leaves the mode via its own controls (yellow, Esc, dock swap).
if (store.getState().dashboardLayout.tiledCards[id]) return;
const alreadyExpanded = type === 'agent' && expandedSessionIds.includes(id);
if (alreadyExpanded) {
// Delay single-click collapse so double-click can override. Double-click handler (handleCardDoubleClick) clears clickTimerRef.
clickTimerRef.current = setTimeout(() => {
clickTimerRef.current = null;
dispatch(collapseSession(id));
}, 250);
return;
}
// Single-click on an already-expanded chat is focus, never collapse: the old delayed-collapse
// toggle made a click land, collapse the chat, and force a second click to reopen ("takes
// multiple clicks"). Collapse lives on the yellow light / Esc / canvas click instead.
if (type === 'agent' && expandedSessionIds.includes(id)) return;
// Expand (if not already) + center + zoom + bring to front
if (type === 'agent') {
@@ -0,0 +1,24 @@
import { useEffect } from 'react';
/** Window-level insurance for a card's local drag: a release the drag handle never hears
(pointercancel, capture lost to a mid-drag remount, mouseup outside the window, app blur)
still commits the drag. Without this the stuck drag state re-pins the card to the cursor on
every camera pan, the "card glued to the POV until reload" bug. */
export function useDragEndBackstops(
active: boolean,
finalize: (clientX: number, clientY: number, shiftKey: boolean) => void,
abort: () => void,
): void {
useEffect(() => {
if (!active) return undefined;
const onUp = (e: PointerEvent): void => finalize(e.clientX, e.clientY, e.shiftKey);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', abort);
window.addEventListener('blur', abort);
return () => {
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', abort);
window.removeEventListener('blur', abort);
};
}, [active, finalize, abort]);
}