diff --git a/packages/ui/src/components/chat/DraftPresetChips.tsx b/packages/ui/src/components/chat/DraftPresetChips.tsx index a31e1c9d..79d4b207 100644 --- a/packages/ui/src/components/chat/DraftPresetChips.tsx +++ b/packages/ui/src/components/chat/DraftPresetChips.tsx @@ -5,7 +5,9 @@ import { TouchSensor, useSensor, useSensors, + useDroppable, closestCenter, + MeasuringStrategy, type DragEndEvent, } from '@dnd-kit/core'; import { SortableContext, useSortable, rectSortingStrategy } from '@dnd-kit/sortable'; @@ -28,13 +30,13 @@ import { } from '@/components/ui/dialog'; import { useI18n } from '@/lib/i18n'; import { useThemeSystem } from '@/contexts/useThemeSystem'; +import { useDeviceInfo } from '@/lib/device'; import { cn } from '@/lib/utils'; import { useDraftStarters, type ResolvedStarter, type PinnableItem, type PinnableSection, - type StarterGroup, } from './useDraftStarters'; type DraftPresetChipsProps = { @@ -44,6 +46,17 @@ type DraftPresetChipsProps = { className?: string; }; +// Droppable id for the mobile "drag a chip here to delete" target. Kept distinct +// from any chip id (which are `group:type:name`) so collisions never alias. +const TRASH_DROPPABLE_ID = '__draft-starter-trash__'; + +// Shared box for the round icon buttons in the "+" slot (add picker and the +// mobile trash drop-zone). Identical so swapping one for the other never shifts +// layout or changes the circle size. shrink-0 keeps it from being compressed by +// the wrapping flex row. +const ROUND_ICON_BUTTON_CLASS = + 'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border transition-colors'; + const PICKER_SECTIONS: { key: PinnableSection; headingKey: 'chat.draftStarters.sectionBuiltIn' | 'chat.draftStarters.sectionCommands' | 'chat.draftStarters.sectionSkills' }[] = [ { key: 'built-in', headingKey: 'chat.draftStarters.sectionBuiltIn' }, { key: 'command', headingKey: 'chat.draftStarters.sectionCommands' }, @@ -54,7 +67,9 @@ const SortableChip: React.FC<{ item: ResolvedStarter; onSubmit: (text: string) => void; onRemove: () => void; -}> = ({ item, onSubmit, onRemove }) => { + /** Hide the per-chip hover "x" (mobile uses the trash drop-zone instead). */ + hideRemove?: boolean; +}> = ({ item, onSubmit, onRemove, hideRemove }) => { const { t } = useI18n(); const { currentTheme } = useThemeSystem(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id }); @@ -82,52 +97,70 @@ const SortableChip: React.FC<{ {item.label} - + {hideRemove ? null : ( + + )} ); }; -const StarterGroupRow: React.FC<{ - group: StarterGroup; +const StarterGroup: React.FC<{ items: ResolvedStarter[]; onSubmit: (text: string) => void; - onRemove: (group: StarterGroup, ref: ResolvedStarter['ref']) => void; - onReorder: (group: StarterGroup, fromId: string, toId: string) => void; -}> = ({ group, items, onSubmit, onRemove, onReorder }) => { - const sensors = useSensors( - // Desktop: start dragging after a small move so a click still submits. - useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), - // Touch: long-press to drag (tap submits, a quick swipe scrolls instead). - useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }), - ); - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - if (over && active.id !== over.id) { - onReorder(group, String(active.id), String(over.id)); - } - }; + onRemove: (item: ResolvedStarter) => void; + hideRemove?: boolean; +}> = ({ items, onSubmit, onRemove, hideRemove }) => ( + i.id)} strategy={rectSortingStrategy}> + {items.map((item) => ( + onRemove(item)} + hideRemove={hideRemove} + /> + ))} + +); + +/** + * Mobile delete target. Sits in the "+" slot and is only mounted while a chip is + * being dragged; dropping a chip on it removes that starter. Styled to match the + * add ("+") button so the swap reads as the same affordance toggling purpose. + */ +const TrashDropZone: React.FC = () => { + const { t } = useI18n(); + const { currentTheme } = useThemeSystem(); + const { setNodeRef, isOver } = useDroppable({ id: TRASH_DROPPABLE_ID }); + return ( - - i.id)} strategy={rectSortingStrategy}> - {items.map((item) => ( - onRemove(group, item.ref)} - /> - ))} - - + ); }; @@ -187,7 +220,7 @@ const AddStarterPicker: React.FC<{ type="button" aria-label={t('chat.draftStarters.add')} title={t('chat.draftStarters.add')} - className="inline-flex h-7 w-7 items-center justify-center rounded-full border text-muted-foreground transition-colors hover:bg-[var(--interactive-hover)] hover:text-foreground" + className={cn(ROUND_ICON_BUTTON_CLASS, 'text-muted-foreground hover:bg-[var(--interactive-hover)] hover:text-foreground')} style={{ backgroundColor: currentTheme?.colors?.surface?.elevated, borderColor: currentTheme?.colors?.interactive?.border, @@ -215,19 +248,86 @@ const AddStarterPicker: React.FC<{ * global group then the project group (each reorderable within itself), plus a * "+" picker to pin existing commands/skills. The surface owns how a chip click * is submitted via `onSubmit`. + * + * Both groups share a single DndContext so the mobile trash drop-zone (which + * replaces the "+" while dragging) is reachable from either group's drag. + * Reorder is constrained to within a chip's own group; cross-group hovers are + * ignored. */ export const DraftPresetChips: React.FC = ({ onSubmit, className }) => { const { global, project, pinnable, ensureLoaded, addStarter, removeStarter, reorder } = useDraftStarters(); + const { isMobile } = useDeviceInfo(); + const [isDragging, setIsDragging] = React.useState(false); + + const sensors = useSensors( + // Desktop: start dragging after a small move so a click still submits. + useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), + // Touch: long-press to drag (tap submits, a quick swipe scrolls instead). + useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }), + ); + + const chipById = React.useCallback( + (id: string): ResolvedStarter | undefined => + global.find((i) => i.id === id) ?? project.find((i) => i.id === id), + [global, project], + ); + + const handleDragStart = () => setIsDragging(true); + const handleDragCancel = () => setIsDragging(false); + const handleDragEnd = (event: DragEndEvent) => { + setIsDragging(false); + const { active, over } = event; + if (!over) return; + const activeId = String(active.id); + const chip = chipById(activeId); + if (!chip) return; + if (String(over.id) === TRASH_DROPPABLE_ID) { + removeStarter(chip.group, chip.ref); + return; + } + const overId = String(over.id); + if (activeId === overId) return; + const overChip = chipById(overId); + // Reorder only within the same group; ignore cross-group hovers. + if (overChip && overChip.group === chip.group) { + reorder(chip.group, activeId, overId); + } + }; return ( -
- {global.length > 0 ? ( - - ) : null} - {project.length > 0 ? ( - - ) : null} - -
+ +
+ {global.length > 0 ? ( + removeStarter('global', item.ref)} + hideRemove={isMobile} + /> + ) : null} + {project.length > 0 ? ( + removeStarter('project', item.ref)} + hideRemove={isMobile} + /> + ) : null} + {isMobile && isDragging ? ( + + ) : ( + + )} +
+
); };