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:
@@ -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 };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import type { DraftStarterRef } from '@/lib/draftStarters';
|
||||
|
||||
export type RuntimePlatform = 'web' | 'desktop' | 'vscode';
|
||||
|
||||
@@ -672,6 +673,7 @@ export interface SettingsPayload {
|
||||
gitModelId?: string;
|
||||
pwaAppName?: string;
|
||||
mobileKeyboardMode?: 'native' | 'resize-content';
|
||||
draftStarters?: DraftStarterRef[];
|
||||
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
|
||||
import type { DraftStarterRef } from '@/lib/draftStarters';
|
||||
|
||||
export type AssistantNotificationPayload = {
|
||||
title?: string;
|
||||
@@ -177,6 +178,8 @@ export type DesktopSettings = {
|
||||
sttSilenceThresholdDb?: number;
|
||||
sttSilenceHoldMs?: number;
|
||||
sttTranscribeOnStop?: boolean;
|
||||
// Global draft welcome starters (pinned commands/skills), persisted to settings.json
|
||||
draftStarters?: DraftStarterRef[];
|
||||
};
|
||||
|
||||
type TauriGlobal = {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
import type { I18nKey } from "@/lib/i18n";
|
||||
|
||||
// A draft starter is a reference to an existing command or skill, pinned to the
|
||||
// onboarding/draft welcome screen as a one-click chip. Scope (global vs project)
|
||||
// is NOT stored here — it is encoded by which list the ref lives in (global =
|
||||
// settings.json, project = project config), derived from the command/skill's own
|
||||
// scope when pinned.
|
||||
export type DraftStarterType = 'command' | 'skill';
|
||||
|
||||
export type DraftStarterRef = {
|
||||
type: DraftStarterType;
|
||||
name: string;
|
||||
};
|
||||
|
||||
// Our built-in openchamber commands (Session magic prompts). They are always
|
||||
// available to pin, keep their bespoke icons, and seed the default global set.
|
||||
export type BuiltInStarter = {
|
||||
name: string;
|
||||
icon: IconName;
|
||||
labelKey: I18nKey;
|
||||
command: string;
|
||||
};
|
||||
|
||||
export const BUILTIN_STARTERS: readonly BuiltInStarter[] = [
|
||||
{ name: 'explore', icon: 'compass-3', labelKey: 'chat.draftPresets.explore.label', command: '/explore' },
|
||||
{ name: 'catch-up', icon: 'history', labelKey: 'chat.draftPresets.catchup.label', command: '/catch-up' },
|
||||
{ name: 'weigh', icon: 'scales-3', labelKey: 'chat.draftPresets.weigh.label', command: '/weigh' },
|
||||
{ name: 'plan-feature', icon: 'survey', labelKey: 'chat.draftPresets.plan.label', command: '/plan-feature' },
|
||||
{ name: 'debug', icon: 'bug', labelKey: 'chat.draftPresets.debug.label', command: '/debug' },
|
||||
{ name: 'review', icon: 'search-eye', labelKey: 'chat.draftPresets.review.label', command: '/workspace-review' },
|
||||
];
|
||||
|
||||
const BUILTIN_BY_NAME = new Map<string, BuiltInStarter>(BUILTIN_STARTERS.map((s) => [s.name, s]));
|
||||
|
||||
export const getBuiltInStarter = (name: string): BuiltInStarter | undefined => BUILTIN_BY_NAME.get(name);
|
||||
export const isBuiltInStarter = (ref: DraftStarterRef): boolean =>
|
||||
ref.type === 'command' && BUILTIN_BY_NAME.has(ref.name);
|
||||
|
||||
// Default global starter set (used until the user customizes the global list).
|
||||
export const DEFAULT_GLOBAL_STARTERS: readonly DraftStarterRef[] = BUILTIN_STARTERS.map((s) => ({
|
||||
type: 'command' as const,
|
||||
name: s.name,
|
||||
}));
|
||||
|
||||
// Fallback icons for user-defined starters, matching the Settings sections.
|
||||
export const COMMAND_FALLBACK_ICON: IconName = 'terminal-box';
|
||||
export const SKILL_FALLBACK_ICON: IconName = 'book-open';
|
||||
|
||||
export const starterKey = (ref: DraftStarterRef): string => `${ref.type}:${ref.name}`;
|
||||
|
||||
export const sameStarter = (a: DraftStarterRef, b: DraftStarterRef): boolean =>
|
||||
a.type === b.type && a.name === b.name;
|
||||
|
||||
// Turn a command/skill name into a human chip label: "/simplify-code" -> "Simplify code".
|
||||
export const normalizeStarterLabel = (name: string): string => {
|
||||
const base = name
|
||||
.replace(/^\//, '')
|
||||
.replace(/[-_]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!base) return name;
|
||||
return base.charAt(0).toUpperCase() + base.slice(1);
|
||||
};
|
||||
|
||||
// Parse persisted starter refs (from settings.json or project config) defensively.
|
||||
export const sanitizeStarterRefs = (value: unknown): DraftStarterRef[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const out: DraftStarterRef[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const record = entry as Record<string, unknown>;
|
||||
const type = record.type === 'command' || record.type === 'skill' ? record.type : null;
|
||||
const name = typeof record.name === 'string' ? record.name.trim() : '';
|
||||
if (!type || !name) continue;
|
||||
const key = `${type}:${name}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push({ type, name });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
@@ -1459,6 +1459,13 @@ export const dict = {
|
||||
'chat.draftPresets.plan.label': 'Start feature planning',
|
||||
'chat.draftPresets.debug.label': 'Debug an issue',
|
||||
'chat.draftPresets.review.label': 'Review my changes',
|
||||
'chat.draftStarters.add': 'Add a starter',
|
||||
'chat.draftStarters.searchPlaceholder': 'Search commands and skills…',
|
||||
'chat.draftStarters.empty': 'Nothing to add',
|
||||
'chat.draftStarters.sectionBuiltIn': 'Built-in',
|
||||
'chat.draftStarters.sectionCommands': 'Commands',
|
||||
'chat.draftStarters.sectionSkills': 'Skills',
|
||||
'chat.draftStarters.remove': 'Remove',
|
||||
'chat.scrollToBottom.aria': 'Scroll to bottom',
|
||||
'chat.timeline.relative.justNow': 'just now',
|
||||
'chat.timeline.relative.minutesAgo': '{count}m ago',
|
||||
|
||||
@@ -1425,6 +1425,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.draftPresets.plan.label": "Start feature planning",
|
||||
"chat.draftPresets.debug.label": "Debug an issue",
|
||||
"chat.draftPresets.review.label": "Review my changes",
|
||||
"chat.draftStarters.add": "Add a starter",
|
||||
"chat.draftStarters.searchPlaceholder": "Search commands and skills…",
|
||||
"chat.draftStarters.empty": "Nothing to add",
|
||||
"chat.draftStarters.sectionBuiltIn": "Built-in",
|
||||
"chat.draftStarters.sectionCommands": "Commands",
|
||||
"chat.draftStarters.sectionSkills": "Skills",
|
||||
"chat.draftStarters.remove": "Remove",
|
||||
"chat.scrollToBottom.aria": "Ir al final",
|
||||
"chat.timeline.relative.justNow": "ahora mismo",
|
||||
"chat.timeline.relative.minutesAgo": "hace {count}m",
|
||||
|
||||
@@ -1461,6 +1461,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.draftPresets.plan.label': 'Start feature planning',
|
||||
'chat.draftPresets.debug.label': 'Debug an issue',
|
||||
'chat.draftPresets.review.label': 'Review my changes',
|
||||
'chat.draftStarters.add': 'Add a starter',
|
||||
'chat.draftStarters.searchPlaceholder': 'Search commands and skills…',
|
||||
'chat.draftStarters.empty': 'Nothing to add',
|
||||
'chat.draftStarters.sectionBuiltIn': 'Built-in',
|
||||
'chat.draftStarters.sectionCommands': 'Commands',
|
||||
'chat.draftStarters.sectionSkills': 'Skills',
|
||||
'chat.draftStarters.remove': 'Remove',
|
||||
'chat.scrollToBottom.aria': '맨 아래로 스크롤',
|
||||
'chat.timeline.relative.justNow': '방금 전',
|
||||
'chat.timeline.relative.minutesAgo': '{count}분 전',
|
||||
|
||||
@@ -451,6 +451,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.draftPresets.plan.label': 'Start feature planning',
|
||||
'chat.draftPresets.debug.label': 'Debug an issue',
|
||||
'chat.draftPresets.review.label': 'Review my changes',
|
||||
'chat.draftStarters.add': 'Add a starter',
|
||||
'chat.draftStarters.searchPlaceholder': 'Search commands and skills…',
|
||||
'chat.draftStarters.empty': 'Nothing to add',
|
||||
'chat.draftStarters.sectionBuiltIn': 'Built-in',
|
||||
'chat.draftStarters.sectionCommands': 'Commands',
|
||||
'chat.draftStarters.sectionSkills': 'Skills',
|
||||
'chat.draftStarters.remove': 'Remove',
|
||||
'chat.scrollToBottom.aria': 'Przewiń na dół',
|
||||
'chat.timeline.relative.justNow': 'przed chwilą',
|
||||
'chat.timeline.relative.minutesAgo': '{count}m temu',
|
||||
|
||||
@@ -1425,6 +1425,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.draftPresets.plan.label": "Start feature planning",
|
||||
"chat.draftPresets.debug.label": "Debug an issue",
|
||||
"chat.draftPresets.review.label": "Review my changes",
|
||||
"chat.draftStarters.add": "Add a starter",
|
||||
"chat.draftStarters.searchPlaceholder": "Search commands and skills…",
|
||||
"chat.draftStarters.empty": "Nothing to add",
|
||||
"chat.draftStarters.sectionBuiltIn": "Built-in",
|
||||
"chat.draftStarters.sectionCommands": "Commands",
|
||||
"chat.draftStarters.sectionSkills": "Skills",
|
||||
"chat.draftStarters.remove": "Remove",
|
||||
"chat.scrollToBottom.aria": "Ir ao final",
|
||||
"chat.timeline.relative.justNow": "agora mesmo",
|
||||
"chat.timeline.relative.minutesAgo": "há {count}m",
|
||||
|
||||
@@ -1425,6 +1425,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.draftPresets.plan.label": "Розпочати планування фічі",
|
||||
"chat.draftPresets.debug.label": "Дебаг проблеми",
|
||||
"chat.draftPresets.review.label": "Переглянути мої зміни",
|
||||
"chat.draftStarters.add": "Додати стартер",
|
||||
"chat.draftStarters.searchPlaceholder": "Пошук команд і скілів…",
|
||||
"chat.draftStarters.empty": "Немає що додати",
|
||||
"chat.draftStarters.sectionBuiltIn": "Вбудовані",
|
||||
"chat.draftStarters.sectionCommands": "Команди",
|
||||
"chat.draftStarters.sectionSkills": "Скіли",
|
||||
"chat.draftStarters.remove": "Прибрати",
|
||||
"chat.scrollToBottom.aria": "Прокрутити вниз",
|
||||
"chat.timeline.relative.justNow": "щойно",
|
||||
"chat.timeline.relative.minutesAgo": "{count} хв тому",
|
||||
|
||||
@@ -1425,6 +1425,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.draftPresets.plan.label': 'Start feature planning',
|
||||
'chat.draftPresets.debug.label': 'Debug an issue',
|
||||
'chat.draftPresets.review.label': 'Review my changes',
|
||||
'chat.draftStarters.add': 'Add a starter',
|
||||
'chat.draftStarters.searchPlaceholder': 'Search commands and skills…',
|
||||
'chat.draftStarters.empty': 'Nothing to add',
|
||||
'chat.draftStarters.sectionBuiltIn': 'Built-in',
|
||||
'chat.draftStarters.sectionCommands': 'Commands',
|
||||
'chat.draftStarters.sectionSkills': 'Skills',
|
||||
'chat.draftStarters.remove': 'Remove',
|
||||
'chat.scrollToBottom.aria': '滚动到底部',
|
||||
'chat.timeline.relative.justNow': '刚刚',
|
||||
'chat.timeline.relative.minutesAgo': '{count} 分钟前',
|
||||
|
||||
@@ -1422,6 +1422,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.draftPresets.plan.label': 'Start feature planning',
|
||||
'chat.draftPresets.debug.label': 'Debug an issue',
|
||||
'chat.draftPresets.review.label': 'Review my changes',
|
||||
'chat.draftStarters.add': 'Add a starter',
|
||||
'chat.draftStarters.searchPlaceholder': 'Search commands and skills…',
|
||||
'chat.draftStarters.empty': 'Nothing to add',
|
||||
'chat.draftStarters.sectionBuiltIn': 'Built-in',
|
||||
'chat.draftStarters.sectionCommands': 'Commands',
|
||||
'chat.draftStarters.sectionSkills': 'Skills',
|
||||
'chat.draftStarters.remove': 'Remove',
|
||||
'chat.scrollToBottom.aria': '捲動到底部',
|
||||
'chat.timeline.relative.justNow': '剛剛',
|
||||
'chat.timeline.relative.minutesAgo': '{count} 分鐘前',
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { FilesAPI, RuntimeAPIs } from './api/types';
|
||||
import { getDesktopHomeDirectory } from './desktop';
|
||||
import { isVSCodeRuntime } from './desktop';
|
||||
import { createProjectIdFromPath } from './projectId';
|
||||
import { sanitizeStarterRefs, type DraftStarterRef } from './draftStarters';
|
||||
|
||||
type ProjectRef = { id: string; path: string };
|
||||
|
||||
@@ -36,6 +37,7 @@ export interface OpenChamberConfig {
|
||||
projectPlanFiles?: OpenChamberProjectPlanFileLink[];
|
||||
projectActions?: OpenChamberProjectAction[];
|
||||
projectActionsPrimaryId?: string;
|
||||
draftStarters?: DraftStarterRef[];
|
||||
}
|
||||
|
||||
export type OpenChamberProjectActionPlatform = 'macos' | 'linux' | 'windows';
|
||||
@@ -696,6 +698,18 @@ export async function saveWorktreeSetupCommands(project: ProjectRef, commands: s
|
||||
return updateOpenChamberConfig(project, { 'setup-worktree': filtered });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get this project's pinned draft welcome starters.
|
||||
*/
|
||||
export async function getProjectDraftStarters(project: ProjectRef): Promise<DraftStarterRef[]> {
|
||||
const config = await readOpenChamberConfig(project);
|
||||
return sanitizeStarterRefs(config?.draftStarters);
|
||||
}
|
||||
|
||||
export async function saveProjectDraftStarters(project: ProjectRef, starters: DraftStarterRef[]): Promise<boolean> {
|
||||
return updateOpenChamberConfig(project, { draftStarters: sanitizeStarterRefs(starters) });
|
||||
}
|
||||
|
||||
export async function getProjectNotesAndTodos(project: ProjectRef): Promise<OpenChamberProjectNotesTodos> {
|
||||
const config = await readOpenChamberConfig(project);
|
||||
return sanitizeProjectNotesAndTodos({
|
||||
|
||||
@@ -8,6 +8,7 @@ import { setFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||
import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { normalizeMobileKeyboardMode, setStoredMobileKeyboardMode } from '@/lib/mobileKeyboardMode';
|
||||
import { sanitizeStarterRefs } from '@/lib/draftStarters';
|
||||
|
||||
const persistToLocalStorage = (settings: DesktopSettings) => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -484,6 +485,12 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
||||
if (typeof settings.fontSize === 'number' && Number.isFinite(settings.fontSize) && settings.fontSize !== store.fontSize) {
|
||||
store.setFontSize(settings.fontSize);
|
||||
}
|
||||
if (Array.isArray(settings.draftStarters)) {
|
||||
const nextStarters = sanitizeStarterRefs(settings.draftStarters);
|
||||
if (JSON.stringify(store.globalDraftStarters) !== JSON.stringify(nextStarters)) {
|
||||
store.setGlobalDraftStarters(nextStarters);
|
||||
}
|
||||
}
|
||||
if (typeof settings.terminalFontSize === 'number' && Number.isFinite(settings.terminalFontSize) && settings.terminalFontSize !== store.terminalFontSize) {
|
||||
store.setTerminalFontSize(settings.terminalFontSize);
|
||||
}
|
||||
@@ -646,6 +653,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
)
|
||||
);
|
||||
}
|
||||
if (Array.isArray(candidate.draftStarters)) {
|
||||
result.draftStarters = sanitizeStarterRefs(candidate.draftStarters);
|
||||
}
|
||||
if (typeof candidate.showReasoningTraces === 'boolean') {
|
||||
result.showReasoningTraces = candidate.showReasoningTraces;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SidebarSection } from '@/constants/sidebar';
|
||||
import { getSafeStorage } from './utils/safeStorage';
|
||||
import { SEMANTIC_TYPOGRAPHY, getTypographyVariable, type SemanticTypographyKey } from '@/lib/typography';
|
||||
import type { ShortcutCombo } from '@/lib/shortcuts';
|
||||
import type { DraftStarterRef } from '@/lib/draftStarters';
|
||||
import { DEFAULT_MONO_FONT, DEFAULT_UI_FONT, type MonoFontOption, type UiFontOption } from '@/lib/fontOptions';
|
||||
import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
|
||||
|
||||
@@ -544,6 +545,8 @@ interface UIStore {
|
||||
autoDeleteLastRunAt: number | null;
|
||||
messageLimit: number;
|
||||
fontSize: number;
|
||||
// Global draft welcome starters; null = unset (use the default built-in set).
|
||||
globalDraftStarters: DraftStarterRef[] | null;
|
||||
terminalFontSize: number;
|
||||
uiFont: UiFontOption;
|
||||
monoFont: MonoFontOption;
|
||||
@@ -676,6 +679,7 @@ interface UIStore {
|
||||
setAutoDeleteLastRunAt: (timestamp: number | null) => void;
|
||||
setMessageLimit: (value: number) => void;
|
||||
setFontSize: (size: number) => void;
|
||||
setGlobalDraftStarters: (refs: DraftStarterRef[]) => void;
|
||||
setTerminalFontSize: (size: number) => void;
|
||||
setUiFont: (font: UiFontOption) => void;
|
||||
setMonoFont: (font: MonoFontOption) => void;
|
||||
@@ -811,6 +815,7 @@ export const useUIStore = create<UIStore>()(
|
||||
autoDeleteLastRunAt: null,
|
||||
messageLimit: 200,
|
||||
fontSize: 100,
|
||||
globalDraftStarters: null,
|
||||
terminalFontSize: 13,
|
||||
uiFont: DEFAULT_UI_FONT,
|
||||
monoFont: DEFAULT_MONO_FONT,
|
||||
@@ -1502,6 +1507,10 @@ export const useUIStore = create<UIStore>()(
|
||||
get().applyTypography();
|
||||
},
|
||||
|
||||
setGlobalDraftStarters: (refs) => {
|
||||
set({ globalDraftStarters: refs });
|
||||
},
|
||||
|
||||
setTerminalFontSize: (size) => {
|
||||
const rounded = Math.round(size);
|
||||
const clamped = Math.max(9, Math.min(52, rounded));
|
||||
@@ -2110,6 +2119,7 @@ export const useUIStore = create<UIStore>()(
|
||||
autoDeleteLastRunAt: state.autoDeleteLastRunAt,
|
||||
messageLimit: state.messageLimit,
|
||||
fontSize: state.fontSize,
|
||||
globalDraftStarters: state.globalDraftStarters,
|
||||
terminalFontSize: state.terminalFontSize,
|
||||
uiFont: state.uiFont,
|
||||
monoFont: state.monoFont,
|
||||
|
||||
@@ -145,6 +145,21 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
||||
);
|
||||
}
|
||||
if (Array.isArray(candidate.draftStarters)) {
|
||||
const seenStarters = new Set();
|
||||
const starters = [];
|
||||
for (const entry of candidate.draftStarters) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const type = entry.type === 'command' || entry.type === 'skill' ? entry.type : null;
|
||||
const name = typeof entry.name === 'string' ? entry.name.trim() : '';
|
||||
if (!type || !name) continue;
|
||||
const key = `${type}:${name}`;
|
||||
if (seenStarters.has(key)) continue;
|
||||
seenStarters.add(key);
|
||||
starters.push({ type, name });
|
||||
}
|
||||
result.draftStarters = starters;
|
||||
}
|
||||
|
||||
|
||||
if (typeof candidate.uiFont === 'string' && candidate.uiFont.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user