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,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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user