diff --git a/packages/ui/src/components/chat/DraftPresetChips.tsx b/packages/ui/src/components/chat/DraftPresetChips.tsx index 6388e3a7..a31e1c9d 100644 --- a/packages/ui/src/components/chat/DraftPresetChips.tsx +++ b/packages/ui/src/components/chat/DraftPresetChips.tsx @@ -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 = ({ 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 ( -
- {DRAFT_PRESETS.map((preset) => ( +
+ + +
+ ); +}; + +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 ( + + i.id)} strategy={rectSortingStrategy}> + {items.map((item) => ( + onRemove(group, item.ref)} + /> + ))} + + + ); +}; + +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`. + */ +export const DraftPresetChips: React.FC = ({ onSubmit, className }) => { + const { global, project, pinnable, ensureLoaded, addStarter, removeStarter, reorder } = useDraftStarters(); + + return ( +
+ {global.length > 0 ? ( + + ) : null} + {project.length > 0 ? ( + + ) : null} +
); }; diff --git a/packages/ui/src/components/chat/draftPresets.ts b/packages/ui/src/components/chat/draftPresets.ts deleted file mode 100644 index c5a19469..00000000 --- a/packages/ui/src/components/chat/draftPresets.ts +++ /dev/null @@ -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) : ''); diff --git a/packages/ui/src/components/chat/useDraftStarters.ts b/packages/ui/src/components/chat/useDraftStarters.ts new file mode 100644 index 00000000..619f1baf --- /dev/null +++ b/packages/ui/src/components/chat/useDraftStarters.ts @@ -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([]); + + 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( + () => 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(); + 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(() => { + 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 }; +} diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index b69e42e0..edff5ee9 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -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; } diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 82568132..a64f3d99 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -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 = { diff --git a/packages/ui/src/lib/draftStarters.ts b/packages/ui/src/lib/draftStarters.ts new file mode 100644 index 00000000..e1ae732a --- /dev/null +++ b/packages/ui/src/lib/draftStarters.ts @@ -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(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(); + for (const entry of value) { + if (!entry || typeof entry !== 'object') continue; + const record = entry as Record; + 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; +}; diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 6b03ae52..3839be7d 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 47a6c563..e12b3022 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1425,6 +1425,13 @@ export const dict: Record = { "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", diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index ed0cb3db..8fe44ca9 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1461,6 +1461,13 @@ export const dict: Record = { '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}분 전', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 99d96b5f..e5c6c307 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -451,6 +451,13 @@ export const dict: Record = { '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', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 99999e84..e339a8a3 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1425,6 +1425,13 @@ export const dict: Record = { "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", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index d434ff74..fecb02a2 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1425,6 +1425,13 @@ export const dict: Record = { "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} хв тому", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index cbdd7452..8a8b8d1b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1425,6 +1425,13 @@ export const dict: Record = { '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} 分钟前', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 3ae7a22c..221a6474 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1422,6 +1422,13 @@ export const dict: Record = { '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} 分鐘前', diff --git a/packages/ui/src/lib/openchamberConfig.ts b/packages/ui/src/lib/openchamberConfig.ts index 38b04f70..cb8f6a89 100644 --- a/packages/ui/src/lib/openchamberConfig.ts +++ b/packages/ui/src/lib/openchamberConfig.ts @@ -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 { + const config = await readOpenChamberConfig(project); + return sanitizeStarterRefs(config?.draftStarters); +} + +export async function saveProjectDraftStarters(project: ProjectRef, starters: DraftStarterRef[]): Promise { + return updateOpenChamberConfig(project, { draftStarters: sanitizeStarterRefs(starters) }); +} + export async function getProjectNotesAndTodos(project: ProjectRef): Promise { const config = await readOpenChamberConfig(project); return sanitizeProjectNotesAndTodos({ diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 7e88718a..96faf9aa 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -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; } diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index c205c188..9911914a 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -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()( 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()( 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()( autoDeleteLastRunAt: state.autoDeleteLastRunAt, messageLimit: state.messageLimit, fontSize: state.fontSize, + globalDraftStarters: state.globalDraftStarters, terminalFontSize: state.terminalFontSize, uiFont: state.uiFont, monoFont: state.monoFont, diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index e5a365db..66e276d4 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -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) {