mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 04:37:44 +02:00
[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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user