feat: user-customizable draft welcome starters

Let users curate the draft welcome chips: pin existing commands and skills
as starters, remove them, and drag to reorder — all inline on the draft
screen via a '+' picker dialog and per-chip remove, with no separate
settings UI.

A starter references a command or skill; its scope is inherited from the
item (user-scope -> global, project-scope -> per-project). Global starters
persist to settings.json (useUIStore + client/server sanitizers); project
starters persist to the project config alongside worktree setup commands.
The two scopes form ordered namespaces shown global-first then project,
reorderable only within each group.

The six built-in Session magic-prompt commands are the default global set
and stay available in the picker for re-pinning if removed; they keep their
bespoke icons, while user commands/skills fall back to the Commands/Skills
section icons. Chip labels are normalized (/simplify-code -> 'Simplify
code'). Missing commands/skills are skipped rather than shown broken.

Drag-to-reorder works on desktop and mobile: rectSortingStrategy for the
wrapping multi-row layout, CSS.Translate (no scale) so the lifted chip
doesn't stretch, and MouseSensor + long-press TouchSensor so taps still
submit and swipes still scroll. The '+' picker is a searchable dialog on
every surface.
This commit is contained in:
Bohdan Triapitsyn
2026-05-30 02:04:32 +03:00
parent af615df719
commit 6d4f070d91
18 changed files with 590 additions and 50 deletions
@@ -1,47 +1,233 @@
import React from 'react';
import { Icon } from "@/components/icon/Icon";
import {
DndContext,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
closestCenter,
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 { cn } from '@/lib/utils';
import { DRAFT_PRESETS, resolveDraftPresetText } from './draftPresets';
import {
useDraftStarters,
type ResolvedStarter,
type PinnableItem,
type PinnableSection,
type StarterGroup,
} from './useDraftStarters';
type DraftPresetChipsProps = {
/** Called with the resolved text (command or prompt) when a chip is clicked. */
/** Called with the resolved text (command or skill invocation) when a chip is clicked. */
onSubmit: (text: string) => void;
/** Extra classes for the wrapper (e.g. width/spacing per surface). */
className?: string;
};
/**
* The row of starter preset chips shown on the draft welcome screen.
* Rendering is shared between the desktop layout (under the composer) and the
* narrow/compact layout (under the centered welcome message); the surface owns
* how clicking a chip is submitted via `onSubmit`.
*/
export const DraftPresetChips: React.FC<DraftPresetChipsProps> = ({ onSubmit, className }) => {
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: (text: string) => void;
onRemove: () => void;
}> = ({ item, onSubmit, onRemove }) => {
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 (
<div className={cn('flex flex-wrap items-center justify-center gap-2', className)}>
{DRAFT_PRESETS.map((preset) => (
<div
ref={setNodeRef}
// Translate only (no scaleX/scaleY) so the lifted chip keeps its own
// width instead of stretching to the target slot.
style={{ transform: CSS.Translate.toString(transform), transition }}
className={cn('group/chip relative', isDragging && 'z-10 opacity-60')}
>
<button
type="button"
{...attributes}
{...listeners}
onClick={() => onSubmit(item.submitText)}
className="group inline-flex touch-none select-none items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-[var(--interactive-hover)] hover:text-foreground"
style={chipStyle}
>
<Icon name={item.icon} className="h-3.5 w-3.5 shrink-0 opacity-70 transition-opacity group-hover:opacity-100" />
<span className="whitespace-nowrap">{item.label}</span>
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onRemove(); }}
aria-label={t('chat.draftStarters.remove')}
title={t('chat.draftStarters.remove')}
className="absolute -right-1.5 -top-1.5 hidden h-4 w-4 items-center justify-center rounded-full border text-muted-foreground shadow-sm hover:text-foreground group-hover/chip:flex"
style={chipStyle}
>
<Icon name="close" className="h-2.5 w-2.5" />
</button>
</div>
);
};
const StarterGroupRow: React.FC<{
group: StarterGroup;
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));
}
};
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items.map((i) => i.id)} strategy={rectSortingStrategy}>
{items.map((item) => (
<SortableChip
key={item.id}
item={item}
onSubmit={onSubmit}
onRemove={() => onRemove(group, item.ref)}
/>
))}
</SortableContext>
</DndContext>
);
};
const StarterPickerList: React.FC<{
pinnable: PinnableItem[];
onPick: (item: PinnableItem) => void;
className?: string;
}> = ({ pinnable, onPick, className }) => {
const { t } = useI18n();
return (
<Command className={cn('min-h-0', className)}>
<CommandInput placeholder={t('chat.draftStarters.searchPlaceholder')} />
<CommandList>
<CommandEmpty>{t('chat.draftStarters.empty')}</CommandEmpty>
{PICKER_SECTIONS.map((section) => {
const list = pinnable.filter((item) => item.section === section.key);
if (list.length === 0) return null;
return (
<CommandGroup key={section.key} heading={t(section.headingKey)}>
{list.map((item) => (
<CommandItem
key={`${item.type}:${item.name}`}
value={`${item.section} ${item.label} ${item.name}`}
onSelect={() => onPick(item)}
>
{/* No per-row icon: the section heading already conveys the type. */}
<span className="truncate">{item.label}</span>
</CommandItem>
))}
</CommandGroup>
);
})}
</CommandList>
</Command>
);
};
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 (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) onOpen();
}}
>
<DialogTrigger asChild>
<button
key={preset.id}
type="button"
onClick={() => {
const text = resolveDraftPresetText(preset, t);
if (text) onSubmit(text);
}}
className="group inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-[var(--interactive-hover)] hover:text-foreground"
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"
style={{
backgroundColor: currentTheme?.colors?.surface?.elevated,
borderColor: currentTheme?.colors?.interactive?.border,
}}
>
<Icon name={preset.icon} className="h-3.5 w-3.5 shrink-0 opacity-70 transition-opacity group-hover:opacity-100" />
<span>{t(preset.labelKey)}</span>
<Icon name="add" className="h-4 w-4" />
</button>
))}
</DialogTrigger>
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-sm">
<DialogHeader className="px-4 pb-2 pt-4 text-left">
<DialogTitle>{t('chat.draftStarters.add')}</DialogTitle>
</DialogHeader>
<StarterPickerList
pinnable={pinnable}
onPick={(item) => { onAdd(item); setOpen(false); }}
className="flex max-h-[60vh] flex-col"
/>
</DialogContent>
</Dialog>
);
};
/**
* 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`.
*/
export const DraftPresetChips: React.FC<DraftPresetChipsProps> = ({ onSubmit, className }) => {
const { global, project, pinnable, ensureLoaded, addStarter, removeStarter, reorder } = useDraftStarters();
return (
<div className={cn('flex flex-wrap items-center justify-center gap-2', className)}>
{global.length > 0 ? (
<StarterGroupRow group="global" items={global} onSubmit={onSubmit} onRemove={removeStarter} onReorder={reorder} />
) : null}
{project.length > 0 ? (
<StarterGroupRow group="project" items={project} onSubmit={onSubmit} onRemove={removeStarter} onReorder={reorder} />
) : null}
<AddStarterPicker pinnable={pinnable} onOpen={ensureLoaded} onAdd={addStarter} />
</div>
);
};
@@ -1,29 +0,0 @@
import type { IconName } from "@/components/icon/icons";
import type { I18nKey } from "@/lib/i18n";
// Starter presets shown on the draft welcome screen — under the composer on
// desktop, and under the centered welcome message on narrow surfaces
// (mobile/vscode). `command` presets send a built-in slash command through the
// normal submit path; `promptKey` presets send a plain natural-language prompt.
export type DraftPreset = {
id: string;
icon: IconName;
labelKey: I18nKey;
promptKey?: I18nKey;
command?: string;
};
export const DRAFT_PRESETS: readonly DraftPreset[] = [
{ id: 'explore', icon: 'compass-3', labelKey: 'chat.draftPresets.explore.label', command: '/explore' },
{ id: 'catchup', icon: 'history', labelKey: 'chat.draftPresets.catchup.label', command: '/catch-up' },
{ id: 'weigh', icon: 'scales-3', labelKey: 'chat.draftPresets.weigh.label', command: '/weigh' },
{ id: 'plan', icon: 'survey', labelKey: 'chat.draftPresets.plan.label', command: '/plan-feature' },
{ id: 'debug', icon: 'bug', labelKey: 'chat.draftPresets.debug.label', command: '/debug' },
{ id: 'review', icon: 'search-eye', labelKey: 'chat.draftPresets.review.label', command: '/workspace-review' },
];
// Resolve the text a preset submits: a slash command, or a translated prompt.
export const resolveDraftPresetText = (
preset: DraftPreset,
t: (key: I18nKey) => string,
): string => preset.command ?? (preset.promptKey ? t(preset.promptKey) : '');
@@ -0,0 +1,190 @@
import React from 'react';
import { arrayMove } from '@dnd-kit/sortable';
import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore';
import { useCommandsStore } from '@/stores/useCommandsStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { updateDesktopSettings } from '@/lib/persistence';
import { getProjectDraftStarters, saveProjectDraftStarters } from '@/lib/openchamberConfig';
import type { IconName } from '@/components/icon/icons';
import {
BUILTIN_STARTERS,
DEFAULT_GLOBAL_STARTERS,
COMMAND_FALLBACK_ICON,
SKILL_FALLBACK_ICON,
getBuiltInStarter,
normalizeStarterLabel,
sameStarter,
starterKey,
type DraftStarterRef,
type DraftStarterType,
} from '@/lib/draftStarters';
export type StarterGroup = 'global' | 'project';
export type ResolvedStarter = {
id: string;
ref: DraftStarterRef;
group: StarterGroup;
label: string;
icon: IconName;
submitText: string;
};
export type PinnableSection = 'built-in' | 'command' | 'skill';
export type PinnableItem = {
type: DraftStarterType;
name: string;
label: string;
icon: IconName;
section: PinnableSection;
scope: 'user' | 'project';
};
const chipId = (group: StarterGroup, ref: DraftStarterRef): string => `${group}:${starterKey(ref)}`;
export type UseDraftStartersResult = {
global: ResolvedStarter[];
project: ResolvedStarter[];
pinnable: PinnableItem[];
hasProject: boolean;
ensureLoaded: () => void;
addStarter: (item: PinnableItem) => void;
removeStarter: (group: StarterGroup, ref: DraftStarterRef) => void;
reorder: (group: StarterGroup, fromId: string, toId: string) => void;
};
export function useDraftStarters(): UseDraftStartersResult {
const { t } = useI18n();
const globalRaw = useUIStore((s) => s.globalDraftStarters);
const commands = useCommandsStore((s) => s.commands);
const skills = useSkillsStore((s) => s.skills);
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
const projects = useProjectsStore((s) => s.projects);
const projectRef = React.useMemo(() => {
if (!activeProjectId) return null;
const found = projects.find((p) => p.id === activeProjectId);
if (!found?.path) return null;
return { id: found.id, path: found.path };
}, [activeProjectId, projects]);
const [projectStarters, setProjectStarters] = React.useState<DraftStarterRef[]>([]);
React.useEffect(() => {
let cancelled = false;
if (!projectRef) {
setProjectStarters([]);
return;
}
getProjectDraftStarters(projectRef)
.then((refs) => { if (!cancelled) setProjectStarters(refs); })
.catch(() => { if (!cancelled) setProjectStarters([]); });
return () => { cancelled = true; };
// Keyed on project id to avoid reloading when the memoized ref object
// changes identity but still points at the same project.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectRef?.id]);
const ensureLoaded = React.useCallback(() => {
void useCommandsStore.getState().loadCommands?.();
void useSkillsStore.getState().loadSkills?.();
}, []);
const commandNames = React.useMemo(() => new Set(commands.map((c) => c.name)), [commands]);
const skillNames = React.useMemo(() => new Set(skills.map((s) => s.name)), [skills]);
const resolve = React.useCallback((ref: DraftStarterRef, group: StarterGroup): ResolvedStarter | null => {
if (ref.type === 'command') {
const builtin = getBuiltInStarter(ref.name);
if (builtin) {
return { id: chipId(group, ref), ref, group, label: t(builtin.labelKey), icon: builtin.icon, submitText: builtin.command };
}
if (!commandNames.has(ref.name)) return null;
return { id: chipId(group, ref), ref, group, label: normalizeStarterLabel(ref.name), icon: COMMAND_FALLBACK_ICON, submitText: `/${ref.name}` };
}
if (!skillNames.has(ref.name)) return null;
return { id: chipId(group, ref), ref, group, label: normalizeStarterLabel(ref.name), icon: SKILL_FALLBACK_ICON, submitText: `/${ref.name}` };
}, [t, commandNames, skillNames]);
const globalRefs = React.useMemo<readonly DraftStarterRef[]>(
() => globalRaw ?? DEFAULT_GLOBAL_STARTERS,
[globalRaw],
);
const global = React.useMemo(
() => globalRefs.map((r) => resolve(r, 'global')).filter((x): x is ResolvedStarter => x !== null),
[globalRefs, resolve],
);
const project = React.useMemo(
() => projectStarters.map((r) => resolve(r, 'project')).filter((x): x is ResolvedStarter => x !== null),
[projectStarters, resolve],
);
const pinnedKeys = React.useMemo(() => {
const set = new Set<string>();
for (const r of globalRefs) set.add(starterKey(r));
for (const r of projectStarters) set.add(starterKey(r));
return set;
}, [globalRefs, projectStarters]);
const pinnable = React.useMemo<PinnableItem[]>(() => {
const items: PinnableItem[] = [];
for (const b of BUILTIN_STARTERS) {
items.push({ type: 'command', name: b.name, label: t(b.labelKey), icon: b.icon, section: 'built-in', scope: 'user' });
}
for (const c of commands) {
if (c.isBuiltIn || c.source === 'skill' || getBuiltInStarter(c.name)) continue;
items.push({ type: 'command', name: c.name, label: normalizeStarterLabel(c.name), icon: COMMAND_FALLBACK_ICON, section: 'command', scope: c.scope === 'project' ? 'project' : 'user' });
}
for (const sk of skills) {
items.push({ type: 'skill', name: sk.name, label: normalizeStarterLabel(sk.name), icon: SKILL_FALLBACK_ICON, section: 'skill', scope: sk.scope === 'project' ? 'project' : 'user' });
}
// Only offer items that are not already pinned (removed built-ins reappear here).
return items.filter((item) => !pinnedKeys.has(`${item.type}:${item.name}`));
}, [t, commands, skills, pinnedKeys]);
const persistGlobal = React.useCallback((next: DraftStarterRef[]) => {
useUIStore.getState().setGlobalDraftStarters(next);
void updateDesktopSettings({ draftStarters: next });
}, []);
const persistProject = React.useCallback((next: DraftStarterRef[]) => {
setProjectStarters(next);
if (projectRef) void saveProjectDraftStarters(projectRef, next);
}, [projectRef]);
const addStarter = React.useCallback((item: PinnableItem) => {
const ref: DraftStarterRef = { type: item.type, name: item.name };
if (item.scope === 'project') {
if (!projectRef || projectStarters.some((r) => sameStarter(r, ref))) return;
persistProject([...projectStarters, ref]);
} else {
const base = globalRaw ?? DEFAULT_GLOBAL_STARTERS;
if (base.some((r) => sameStarter(r, ref))) return;
persistGlobal([...base, ref]);
}
}, [projectRef, projectStarters, globalRaw, persistProject, persistGlobal]);
const removeStarter = React.useCallback((group: StarterGroup, ref: DraftStarterRef) => {
if (group === 'project') {
persistProject(projectStarters.filter((r) => !sameStarter(r, ref)));
} else {
const base = globalRaw ?? DEFAULT_GLOBAL_STARTERS;
persistGlobal(base.filter((r) => !sameStarter(r, ref)));
}
}, [projectStarters, globalRaw, persistProject, persistGlobal]);
const reorder = React.useCallback((group: StarterGroup, fromId: string, toId: string) => {
const base = group === 'project' ? projectStarters : (globalRaw ?? DEFAULT_GLOBAL_STARTERS);
const from = base.findIndex((r) => chipId(group, r) === fromId);
const to = base.findIndex((r) => chipId(group, r) === toId);
if (from < 0 || to < 0 || from === to) return;
const next = arrayMove([...base], from, to);
if (group === 'project') persistProject(next); else persistGlobal(next);
}, [projectStarters, globalRaw, persistProject, persistGlobal]);
return { global, project, pinnable, hasProject: !!projectRef, ensureLoaded, addStarter, removeStarter, reorder };
}