feat: replace prompt templates with snippets

Replace the prompt-template workflow with snippet support that is compatible with opencode snippet conventions. Snippets are now stored and loaded from global and project snippet directories, including legacy pluralized paths, with frontmatter metadata for aliases and descriptions. Snippet expansion supports recursive references plus prepend and append sections, while inject sections are treated as unsupported no-ops so OpenChamber remains compatible without requiring an external plugin.

Add the snippets settings experience and remove the old prompt-template settings surface. The new settings page and sidebar support creating, editing, deleting, selecting, and describing snippets, with localized copy across every supported locale. The settings navigation now exposes Snippets with a dedicated icon and metadata.

Wire snippets into all prompt-entry surfaces that need them. Chat, multi-run groups, and scheduled task prompts now offer hash-trigger snippet autocomplete and expand snippets before sending work to OpenCode. Chat also uses an adaptive compact placeholder on mobile or narrow composer widths so helper trigger guidance stays readable in constrained layouts.

Keep multi-run aligned with grouped prompts. Multi-run sessions now use a shared title builder that handles both legacy titles and the newer g1, g2 prompt-group title format. Fusion parsing now recognizes grouped multi-run titles, scopes fusion sources to the same prompt group, and creates fusion sessions under the matching group so outputs from different prompts are not mixed accidentally.

Harden the icon sprite pipeline. The sprite generator now discovers icon names used through typed icon maps, JSX icon props, IconName returns, and generated-value flows without scanning unrelated string literals or the generated sprite itself. The generated sprite is strictly typed so invalid icon names are caught by type checking, and existing invalid or unsafe icon references were cleaned up across settings, provider, Git identity, scheduled task, voice, header, and sidebar surfaces.

Update backend configuration routes and documentation for snippets. The OpenCode config route layer now exposes snippet CRUD and expansion endpoints, accepts JSON bodies for snippet writes, and removes the old prompt-template provider. Scheduled task runtime expansion now uses snippets before dispatching messages.

Add regression coverage for snippet storage and expansion, config-route JSON handling, and multi-run title parsing. Validated with full type checking, full linting, targeted multi-run title tests, and targeted OpenCode snippet/config route tests.
This commit is contained in:
Bohdan Triapitsyn
2026-05-21 20:00:35 +03:00
parent 7d98f388c0
commit 6fd3afd25a
53 changed files with 2037 additions and 1054 deletions
@@ -0,0 +1,169 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { useSnippetsStore, type SnippetScope } from '@/stores/useSnippetsStore';
import { useShallow } from 'zustand/react/shallow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from '@/components/icon/Icon';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useI18n } from '@/lib/i18n';
export const SnippetsPage: React.FC = () => {
const { t } = useI18n();
const { selectedSnippetName, snippets, snippetDraft, setSnippetDraft, updateSnippet, createSnippet } = useSnippetsStore(useShallow((s) => ({
selectedSnippetName: s.selectedSnippetName,
snippets: s.snippets,
snippetDraft: s.snippetDraft,
setSnippetDraft: s.setSnippetDraft,
updateSnippet: s.updateSnippet,
createSnippet: s.createSnippet,
})));
const selectedSnippet = React.useMemo(
() => selectedSnippetName
? snippets.find((snippet) => snippet.name === selectedSnippetName || snippet.aliases.includes(selectedSnippetName)) ?? null
: null,
[selectedSnippetName, snippets],
);
const isNew = Boolean(snippetDraft && snippetDraft.name === selectedSnippetName && !selectedSnippet);
const [draftName, setDraftName] = React.useState('');
const [draftScope, setDraftScope] = React.useState<SnippetScope>('global');
const [description, setDescription] = React.useState('');
const [aliases, setAliases] = React.useState('');
const [content, setContent] = React.useState('');
const [isSaving, setIsSaving] = React.useState(false);
const initialStateRef = React.useRef<{ draftName: string; draftScope: SnippetScope; description: string; aliases: string; content: string } | null>(null);
React.useEffect(() => {
if (isNew && snippetDraft) {
const next = {
draftName: snippetDraft.name || '',
draftScope: snippetDraft.scope || 'global',
description: snippetDraft.description || '',
aliases: (snippetDraft.aliases || []).join(', '),
content: snippetDraft.content || '',
};
setDraftName(next.draftName);
setDraftScope(next.draftScope);
setDescription(next.description);
setAliases(next.aliases);
setContent(next.content);
initialStateRef.current = next;
} else if (selectedSnippet) {
const next = {
draftName: '',
draftScope: 'global' as SnippetScope,
description: selectedSnippet.description ?? '',
aliases: selectedSnippet.aliases.join(', '),
content: selectedSnippet.content,
};
setDescription(next.description);
setAliases(next.aliases);
setContent(next.content);
initialStateRef.current = next;
}
}, [selectedSnippet, isNew, selectedSnippetName, snippetDraft]);
const isDirty = React.useMemo(() => {
const initial = initialStateRef.current;
if (!initial) return false;
if (isNew && draftName !== initial.draftName) return true;
if (isNew && draftScope !== initial.draftScope) return true;
return description !== initial.description || aliases !== initial.aliases || content !== initial.content;
}, [aliases, content, description, draftName, draftScope, isNew]);
const handleSave = async () => {
const snippetName = isNew ? draftName.trim().replace(/\s+/g, '-') : selectedSnippetName?.trim();
if (!snippetName) {
toast.error(t('settings.snippets.page.toast.nameRequired'));
return;
}
if (!content.trim()) {
toast.error(t('settings.snippets.page.toast.contentRequired'));
return;
}
const parsedAliases = aliases.split(',').map((alias) => alias.trim()).filter(Boolean);
setIsSaving(true);
try {
const success = isNew
? await createSnippet(snippetName, content, { aliases: parsedAliases, description, scope: draftScope })
: await updateSnippet(snippetName, { content, aliases: parsedAliases, description });
if (!success) {
toast.error(t('settings.snippets.page.toast.saveFailed'));
return;
}
toast.success(t('settings.snippets.page.toast.saved'));
if (isNew) setSnippetDraft(null);
} finally {
setIsSaving(false);
}
};
if (!selectedSnippetName) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<Icon name="file-text" className="mx-auto mb-3 h-12 w-12 opacity-50" />
<p className="typography-body">{t('settings.snippets.page.empty.title')}</p>
<p className="typography-meta mt-1 opacity-75">{t('settings.snippets.page.empty.description')}</p>
</div>
</div>
);
}
return (
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
<div className="mb-4 min-w-0">
<h2 className="typography-ui-header font-semibold text-foreground truncate">
{isNew ? t('settings.snippets.page.title.new') : `#${selectedSnippetName}`}
</h2>
{selectedSnippet ? <p className="typography-meta text-muted-foreground truncate">{selectedSnippet.filePath}</p> : null}
</div>
<div className="mb-8 space-y-3 px-2">
<div>
{isNew ? (
<div className="mb-3 flex items-center gap-2">
<span className="typography-ui-label text-foreground">#</span>
<Input value={draftName} onChange={(e) => setDraftName(e.target.value)} placeholder={t('settings.snippets.page.field.namePlaceholder')} className="h-7 w-44 px-2" />
<Select value={draftScope} onValueChange={(value) => setDraftScope(value as SnippetScope)}>
<SelectTrigger className="w-fit min-w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="global">{t('settings.common.scope.global')}</SelectItem>
<SelectItem value="project">{t('settings.common.scope.project')}</SelectItem>
</SelectContent>
</Select>
</div>
) : null}
<span className="typography-ui-label text-foreground">{t('settings.common.field.description')}</span>
<Input value={description} onChange={(e) => setDescription(e.target.value)} placeholder={t('settings.snippets.page.field.descriptionPlaceholder')} className="mt-1.5 h-7 w-full max-w-sm px-2" />
</div>
<div>
<span className="typography-ui-label text-foreground">{t('settings.snippets.page.field.aliases')}</span>
<Input value={aliases} onChange={(e) => setAliases(e.target.value)} placeholder={t('settings.snippets.page.field.aliasesPlaceholder')} className="mt-1.5 h-7 w-full max-w-sm px-2" />
</div>
</div>
<div className="mb-2 px-2">
<span className="typography-ui-label text-foreground">{t('settings.snippets.page.field.content')}</span>
<Textarea value={content} onChange={(e) => setContent(e.target.value)} placeholder={t('settings.snippets.page.field.contentPlaceholder')} rows={12} className="mt-1.5 w-full font-mono typography-meta min-h-[160px] max-h-[60vh] bg-transparent" />
<p className="mt-2 typography-meta text-muted-foreground">{t('settings.snippets.page.hint')}</p>
</div>
<div className="px-2 py-1">
<Button onClick={handleSave} disabled={isSaving || !isDirty} size="xs" className="!font-normal">
{isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')}
</Button>
</div>
</div>
</ScrollableOverlay>
);
};
@@ -0,0 +1,115 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { useSnippetsStore } from '@/stores/useSnippetsStore';
import { useShallow } from 'zustand/react/shallow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { cn } from '@/lib/utils';
import type { Snippet } from '@/types/snippet';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
interface SnippetsSidebarProps {
onItemSelect?: () => void;
}
export const SnippetsSidebar: React.FC<SnippetsSidebarProps> = ({ onItemSelect }) => {
const { t } = useI18n();
const [confirmDeleteSnippet, setConfirmDeleteSnippet] = React.useState<Snippet | null>(null);
const [openMenuName, setOpenMenuName] = React.useState<string | null>(null);
const { selectedSnippetName, snippets, setSelectedSnippet, setSnippetDraft, deleteSnippet, loadSnippets } = useSnippetsStore(useShallow((s) => ({
selectedSnippetName: s.selectedSnippetName,
snippets: s.snippets,
setSelectedSnippet: s.setSelectedSnippet,
setSnippetDraft: s.setSnippetDraft,
deleteSnippet: s.deleteSnippet,
loadSnippets: s.loadSnippets,
})));
React.useEffect(() => {
loadSnippets();
}, [loadSnippets]);
const handleCreateNew = async () => {
const existing = new Set(snippets.map((snippet) => snippet.name));
let name = 'new-snippet';
let counter = 1;
while (existing.has(name)) {
name = `new-snippet-${counter++}`;
}
setSnippetDraft({ name, scope: 'global' });
setSelectedSnippet(name);
onItemSelect?.();
};
const handleDelete = async () => {
if (!confirmDeleteSnippet) return;
const success = await deleteSnippet(confirmDeleteSnippet.name);
if (success) {
toast.success(t('settings.snippets.sidebar.toast.deleted'));
setConfirmDeleteSnippet(null);
} else {
toast.error(t('settings.snippets.sidebar.toast.deleteFailed'));
}
};
const sortedSnippets = React.useMemo(() => [...snippets].sort((a, b) => a.name.localeCompare(b.name)), [snippets]);
return (
<div className={cn('flex h-full flex-col', 'bg-background')}>
<div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.snippets.sidebar.title')}</h2>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">{t('settings.snippets.sidebar.total', { count: snippets.length })}</span>
<Button size="sm" variant="ghost" className="h-7 w-7 px-0 -my-1 text-muted-foreground" onClick={handleCreateNew} aria-label={t('settings.snippets.sidebar.actions.create')}>
<Icon name="add" className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2">
{sortedSnippets.map((snippet) => (
<div key={`${snippet.source}:${snippet.filePath}`} className={cn('group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none', selectedSnippetName === snippet.name ? 'bg-interactive-selection' : 'hover:bg-interactive-hover')}>
<button onClick={() => { setSelectedSnippet(snippet.name); onItemSelect?.(); }} className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50">
<div className="flex items-center gap-2">
<span className="typography-ui-label font-normal truncate text-foreground">#{snippet.name}</span>
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">{t(`snippets.source.${snippet.source}`)}</span>
</div>
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
{snippet.description || snippet.content.replace(/\s+/g, ' ').substring(0, 80)}
</div>
</button>
<DropdownMenu open={openMenuName === snippet.name} onOpenChange={(open) => setOpenMenuName(open ? snippet.name : null)}>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="ghost" className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100" aria-label={t('settings.snippets.sidebar.actions.more', { name: snippet.name })}>
<Icon name="more-2" className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); setConfirmDeleteSnippet(snippet); }} className="text-destructive focus:text-destructive">
<Icon name="delete-bin" className="h-4 w-4 mr-px" />
{t('settings.common.actions.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</ScrollableOverlay>
<Dialog open={confirmDeleteSnippet !== null} onOpenChange={(open) => { if (!open) setConfirmDeleteSnippet(null); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('settings.snippets.sidebar.dialog.deleteTitle')}</DialogTitle>
<DialogDescription>{t('settings.snippets.sidebar.dialog.deleteDescription', { name: confirmDeleteSnippet?.name ?? '' })}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button size="sm" variant="ghost" onClick={() => setConfirmDeleteSnippet(null)}>{t('settings.common.actions.cancel')}</Button>
<Button size="sm" onClick={handleDelete}>{t('settings.common.actions.delete')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};