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 (
);
};
/**
* 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;
};