import React from 'react'; import { DndContext, MouseSensor, TouchSensor, useSensor, useSensors, useDroppable, closestCenter, MeasuringStrategy, type DragEndEvent, } from '@dnd-kit/core'; import { SortableContext, useSortable, rectSortingStrategy } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { Icon } from '@/components/icon/Icon'; import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, } from '@/components/ui/command'; import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, } 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 { useUIStore } from '@/stores/useUIStore'; import { useDraftStarters, type ResolvedStarter, type PinnableItem, type PinnableSection, } from './useDraftStarters'; type DraftPresetChipsProps = { /** Called with the resolved starter invocation when a chip is clicked. */ onSubmit: (starter: ResolvedStarter) => void; /** Extra classes for the wrapper (e.g. width/spacing per surface). */ 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' }, { key: 'skill', headingKey: 'chat.draftStarters.sectionSkills' }, ]; const SortableChip: React.FC<{ item: ResolvedStarter; onSubmit: (starter: ResolvedStarter) => void; onRemove: () => void; /** 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 }); const chipStyle: React.CSSProperties = { backgroundColor: currentTheme?.colors?.surface?.elevated, borderColor: currentTheme?.colors?.interactive?.border, }; return (
{hideRemove ? null : ( )}
); }; const StarterGroup: React.FC<{ items: ResolvedStarter[]; onSubmit: (starter: ResolvedStarter) => void; 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 ( ); }; const StarterPickerList: React.FC<{ pinnable: PinnableItem[]; onPick: (item: PinnableItem) => void; className?: string; }> = ({ pinnable, onPick, className }) => { const { t } = useI18n(); return ( {t('chat.draftStarters.empty')} {PICKER_SECTIONS.map((section) => { const list = pinnable.filter((item) => item.section === section.key); if (list.length === 0) return null; return ( {list.map((item) => ( onPick(item)} > {/* No per-row icon: the section heading already conveys the type. */} {item.label} ))} ); })} ); }; const AddStarterPicker: React.FC<{ pinnable: PinnableItem[]; onOpen: () => void; onAdd: (item: PinnableItem) => void; }> = ({ pinnable, onOpen, onAdd }) => { const { t } = useI18n(); const { currentTheme } = useThemeSystem(); const [open, setOpen] = React.useState(false); return ( { setOpen(next); if (next) onOpen(); }} > {t('chat.draftStarters.add')} { onAdd(item); setOpen(false); }} className="flex max-h-[60vh] flex-col" /> ); }; /** * The editable row of starter chips on the draft welcome screen. Shows the * 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. */ const DraftPresetChipsContent: 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 ? ( removeStarter('global', item.ref)} hideRemove={isMobile} /> ) : null} {project.length > 0 ? ( removeStarter('project', item.ref)} hideRemove={isMobile} /> ) : null} {isMobile && isDragging ? ( ) : ( )}
); }; export const DraftPresetChips: React.FC = (props) => { const visible = useUIStore((state) => state.draftStartersVisible); return visible ? : null; };