diff --git a/frontend/src/app/components/editor/CommandPicker.tsx b/frontend/src/app/components/editor/CommandPicker.tsx index ffcb1526..457e260e 100644 --- a/frontend/src/app/components/editor/CommandPicker.tsx +++ b/frontend/src/app/components/editor/CommandPicker.tsx @@ -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> = { - 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 = ({ 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 = ({ trigger, filter, onSelect, onClose, vi icon: , })); - 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: , - }; - }); - - all = [...skillItems, ...modeItems]; + all = skillItems; } else { const atItems: CommandPickerItem[] = [ { @@ -259,7 +232,7 @@ const CommandPicker: React.FC = ({ 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 = ({ 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; } diff --git a/frontend/src/app/pages/Commands/Commands.tsx b/frontend/src/app/pages/Commands/Commands.tsx index 141130d4..6d789c0f 100644 --- a/frontend/src/app/pages/Commands/Commands.tsx +++ b/frontend/src/app/pages/Commands/Commands.tsx @@ -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[] = [ diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 1106af45..a4b3ef28 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -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 = ({ 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 = ({ 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 = ({ } // 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 = ({ 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 = ({ /> )} + {/* 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 && ( + + )} {pillMode && ( = ({ onPointerDown={handleDragPointerDown} onPointerMove={handleDragPointerMove} onPointerUp={handleDragPointerUp} + onPointerCancel={abortDrag} + onLostPointerCapture={abortDrag} sx={{ ...(expanded ? tiledStyle diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index a64caf35..ac273bcf 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ onPointerDown={handleDragPointerDown} onPointerMove={handleDragPointerMove} onPointerUp={handleDragPointerUp} + onPointerCancel={abortDrag} + onLostPointerCapture={abortDrag} onPointerEnter={() => { if (headerCollapsed) setHeaderPeek(true); }} onPointerLeave={() => setHeaderPeek(false)} sx={{ diff --git a/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx b/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx index 148d72b5..d8e907c9 100644 --- a/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ onPointerDown={handleDragPointerDown} onPointerMove={handleDragPointerMove} onPointerUp={handleDragPointerUp} - onPointerCancel={handleDragPointerUp} + onPointerCancel={abortDrag} + onLostPointerCapture={abortDrag} sx={{ height: isMinimized ? '100%' : HEADER_H, flexShrink: 0, diff --git a/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx b/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx index a7de7074..27d846bc 100644 --- a/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx +++ b/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx @@ -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 => ({ diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts index b4744152..d7d128f9 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts @@ -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') { diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useDragEndBackstops.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useDragEndBackstops.ts new file mode 100644 index 00000000..059813ac --- /dev/null +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useDragEndBackstops.ts @@ -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]); +}