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:
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
import { useGitIdentitiesStore, type GitIdentityProfile, type GitIdentityAuthType } from '@/stores/useGitIdentitiesStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -24,7 +25,7 @@ const PROFILE_COLORS = [
|
||||
{ key: 'type', label: 'Yellow', cssVar: 'var(--syntax-type)' },
|
||||
];
|
||||
|
||||
const PROFILE_ICONS = [
|
||||
const PROFILE_ICONS: Array<{ key: string; Icon: IconName; label: string }> = [
|
||||
{ key: 'branch', Icon: 'git-branch', label: 'Branch' },
|
||||
{ key: 'briefcase', Icon: 'briefcase', label: 'Work' },
|
||||
{ key: 'house', Icon: 'home', label: 'Personal' },
|
||||
|
||||
@@ -22,10 +22,11 @@ import { GitHubSettings } from '@/components/sections/openchamber/GitHubSettings
|
||||
import { GitIdentityEditorDialog } from './GitIdentityEditorDialog';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const ICON_MAP: Record<string, string> = {
|
||||
const ICON_MAP: Record<string, IconName> = {
|
||||
branch: 'git-branch',
|
||||
briefcase: 'briefcase',
|
||||
house: 'home',
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
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 { usePromptTemplatesStore } from '@/stores/usePromptTemplatesStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { RiFileTextLine } from '@remixicon/react';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export const PromptTemplatesPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
selectedTemplateId,
|
||||
templates,
|
||||
updateTemplate,
|
||||
createTemplate,
|
||||
getTemplateById,
|
||||
} = usePromptTemplatesStore(useShallow((s) => ({
|
||||
selectedTemplateId: s.selectedTemplateId,
|
||||
templates: s.templates,
|
||||
updateTemplate: s.updateTemplate,
|
||||
createTemplate: s.createTemplate,
|
||||
getTemplateById: s.getTemplateById,
|
||||
})));
|
||||
|
||||
const selectedTemplate = selectedTemplateId ? getTemplateById(selectedTemplateId) : null;
|
||||
const isNew = Boolean(selectedTemplateId && !selectedTemplate && templates.length > 0);
|
||||
|
||||
const [name, setName] = React.useState('');
|
||||
const [body, setBody] = React.useState('');
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const initialStateRef = React.useRef<{ name: string; body: string } | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedTemplate) {
|
||||
setName(selectedTemplate.name);
|
||||
setBody(selectedTemplate.body);
|
||||
initialStateRef.current = { name: selectedTemplate.name, body: selectedTemplate.body };
|
||||
} else if (isNew && selectedTemplateId) {
|
||||
setName(selectedTemplateId);
|
||||
setBody('');
|
||||
initialStateRef.current = { name: selectedTemplateId, body: '' };
|
||||
}
|
||||
}, [selectedTemplate, isNew, selectedTemplateId, templates]);
|
||||
|
||||
const isDirty = React.useMemo(() => {
|
||||
const initial = initialStateRef.current;
|
||||
if (!initial) return false;
|
||||
return name !== initial.name || body !== initial.body;
|
||||
}, [name, body]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedTemplateId) return;
|
||||
|
||||
const trimmedName = name.trim();
|
||||
const trimmedBody = body.trim();
|
||||
|
||||
if (!trimmedName) {
|
||||
toast.error(t('settings.promptTemplates.page.toast.nameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (selectedTemplate) {
|
||||
const updates: { name?: string; body?: string } = {};
|
||||
if (trimmedName !== selectedTemplate.name) updates.name = trimmedName;
|
||||
if (trimmedBody !== selectedTemplate.body) updates.body = trimmedBody;
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
const success = await updateTemplate(selectedTemplateId, updates);
|
||||
if (success) {
|
||||
toast.success(t('settings.promptTemplates.page.toast.updated'));
|
||||
initialStateRef.current = { name: trimmedName, body: trimmedBody };
|
||||
} else {
|
||||
toast.error(t('settings.promptTemplates.page.toast.updateFailed'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const success = await createTemplate(selectedTemplateId, trimmedName, trimmedBody);
|
||||
if (success) {
|
||||
toast.success(t('settings.promptTemplates.page.toast.created'));
|
||||
initialStateRef.current = { name: trimmedName, body: trimmedBody };
|
||||
} else {
|
||||
toast.error(t('settings.promptTemplates.page.toast.createFailed'));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving prompt template:', error);
|
||||
toast.error(t('settings.promptTemplates.page.toast.saveUnexpectedError'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedTemplateId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<RiFileTextLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="typography-body">{t('settings.promptTemplates.page.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.promptTemplates.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 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">
|
||||
{selectedTemplate ? selectedTemplate.name : t('settings.promptTemplates.page.title.new')}
|
||||
</h2>
|
||||
<p className="typography-meta text-muted-foreground truncate">
|
||||
{selectedTemplate ? t('settings.promptTemplates.page.subtitle.edit') : t('settings.promptTemplates.page.subtitle.new')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.promptTemplates.page.section.identity')}
|
||||
</h3>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-0">
|
||||
<div className="py-1.5">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.promptTemplates.page.field.name')}</span>
|
||||
<div className="mt-1.5">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('settings.promptTemplates.page.field.namePlaceholder')}
|
||||
className="h-7 w-full max-w-sm px-2"
|
||||
disabled={selectedTemplate?.isDefault === true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.promptTemplates.page.section.template')}
|
||||
</h3>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder={t('settings.promptTemplates.page.field.templatePlaceholder')}
|
||||
rows={12}
|
||||
className="w-full font-mono typography-meta min-h-[160px] max-h-[60vh] bg-transparent resize-y"
|
||||
/>
|
||||
</section>
|
||||
<div className="mt-2 px-2">
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{t('settings.promptTemplates.page.templateHint')}
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -1,316 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from '@/components/ui';
|
||||
import { isMobileDeviceViaCSS } from '@/lib/device';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { RiAddLine, RiMore2Line, RiDeleteBinLine, RiFileCopyLine, RiEditLine, RiFileTextLine } from '@remixicon/react';
|
||||
import { usePromptTemplatesStore } from '@/stores/usePromptTemplatesStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { PromptTemplate } from '@/types/prompt-template';
|
||||
|
||||
interface PromptTemplatesSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
}
|
||||
|
||||
export const PromptTemplatesSidebar: React.FC<PromptTemplatesSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const [confirmDeleteTemplate, setConfirmDeleteTemplate] = React.useState<PromptTemplate | null>(null);
|
||||
const [isDeletePending, setIsDeletePending] = React.useState(false);
|
||||
const [openMenuId, setOpenMenuId] = React.useState<string | null>(null);
|
||||
const [renameDialogTemplate, setRenameDialogTemplate] = React.useState<PromptTemplate | null>(null);
|
||||
const [renameNewName, setRenameNewName] = React.useState('');
|
||||
|
||||
const {
|
||||
selectedTemplateId,
|
||||
templates,
|
||||
setSelectedTemplate,
|
||||
deleteTemplate,
|
||||
updateTemplate,
|
||||
loadTemplates,
|
||||
} = usePromptTemplatesStore(useShallow((s) => ({
|
||||
selectedTemplateId: s.selectedTemplateId,
|
||||
templates: s.templates,
|
||||
setSelectedTemplate: s.setSelectedTemplate,
|
||||
deleteTemplate: s.deleteTemplate,
|
||||
updateTemplate: s.updateTemplate,
|
||||
loadTemplates: s.loadTemplates,
|
||||
})));
|
||||
|
||||
React.useEffect(() => {
|
||||
loadTemplates();
|
||||
}, [loadTemplates]);
|
||||
|
||||
const handleCreateNew = async () => {
|
||||
const baseName = 'new-template';
|
||||
let newName = baseName;
|
||||
let counter = 1;
|
||||
const existingIds = new Set(templates.map((t) => t.id));
|
||||
while (existingIds.has(newName.replace(/\s+/g, '-').toLowerCase())) {
|
||||
newName = `${baseName}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
const slug = newName.replace(/\s+/g, '-').toLowerCase();
|
||||
const success = await usePromptTemplatesStore.getState().createTemplate(slug, newName, '');
|
||||
if (!success) {
|
||||
toast.error(t('settings.promptTemplates.page.toast.createFailed'));
|
||||
return;
|
||||
}
|
||||
usePromptTemplatesStore.getState().setSelectedTemplate(slug);
|
||||
onItemSelect?.();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirmDeleteTemplate) return;
|
||||
setIsDeletePending(true);
|
||||
const success = await deleteTemplate(confirmDeleteTemplate.id);
|
||||
if (success) {
|
||||
toast.success(t('settings.promptTemplates.sidebar.toast.deleted', { name: confirmDeleteTemplate.name }));
|
||||
setConfirmDeleteTemplate(null);
|
||||
} else {
|
||||
toast.error(t('settings.promptTemplates.sidebar.toast.deleteFailed'));
|
||||
}
|
||||
setIsDeletePending(false);
|
||||
};
|
||||
|
||||
const handleDuplicate = async (template: PromptTemplate) => {
|
||||
let copyName = `${template.name} Copy`;
|
||||
let copyId = `${template.id}-copy`;
|
||||
let counter = 1;
|
||||
const existingIds = new Set(templates.map((t) => t.id));
|
||||
while (existingIds.has(copyId)) {
|
||||
copyName = `${template.name} Copy ${counter}`;
|
||||
copyId = `${template.id}-copy-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
const success = await usePromptTemplatesStore.getState().createTemplate(copyId, copyName, template.body);
|
||||
if (!success) {
|
||||
toast.error(t('settings.promptTemplates.page.toast.createFailed'));
|
||||
return;
|
||||
}
|
||||
setSelectedTemplate(copyId);
|
||||
onItemSelect?.();
|
||||
};
|
||||
|
||||
const handleOpenRename = (template: PromptTemplate) => {
|
||||
setRenameNewName(template.name);
|
||||
setRenameDialogTemplate(template);
|
||||
};
|
||||
|
||||
const handleRename = async () => {
|
||||
if (!renameDialogTemplate) return;
|
||||
const trimmed = renameNewName.trim();
|
||||
if (!trimmed) {
|
||||
toast.error(t('settings.promptTemplates.sidebar.toast.nameRequired'));
|
||||
return;
|
||||
}
|
||||
if (trimmed === renameDialogTemplate.name) {
|
||||
setRenameDialogTemplate(null);
|
||||
return;
|
||||
}
|
||||
const success = await updateTemplate(renameDialogTemplate.id, { name: trimmed });
|
||||
if (success) {
|
||||
toast.success(t('settings.promptTemplates.sidebar.toast.renamed'));
|
||||
} else {
|
||||
toast.error(t('settings.promptTemplates.sidebar.toast.renameFailed'));
|
||||
}
|
||||
setRenameDialogTemplate(null);
|
||||
};
|
||||
|
||||
const sortedTemplates = React.useMemo(
|
||||
() => [...templates].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[templates],
|
||||
);
|
||||
|
||||
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.promptTemplates.sidebar.title')}</h2>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.promptTemplates.sidebar.total', { count: templates.length })}</span>
|
||||
<Button size="sm" variant="ghost" className="h-7 w-7 px-0 -my-1 text-muted-foreground" onClick={handleCreateNew}>
|
||||
<RiAddLine 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">
|
||||
{sortedTemplates.length === 0 ? (
|
||||
<div className="py-12 px-4 text-center text-muted-foreground">
|
||||
<RiFileTextLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="typography-ui-label font-medium">{t('settings.promptTemplates.sidebar.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.promptTemplates.sidebar.empty.description')}</p>
|
||||
</div>
|
||||
) : (
|
||||
sortedTemplates.map((template) => (
|
||||
<TemplateListItem
|
||||
key={template.id}
|
||||
template={template}
|
||||
isSelected={selectedTemplateId === template.id}
|
||||
onSelect={() => {
|
||||
setSelectedTemplate(template.id);
|
||||
onItemSelect?.();
|
||||
}}
|
||||
onDelete={() => setConfirmDeleteTemplate(template)}
|
||||
onRename={() => handleOpenRename(template)}
|
||||
onDuplicate={() => handleDuplicate(template)}
|
||||
isMenuOpen={openMenuId === template.id}
|
||||
onMenuOpenChange={(open) => setOpenMenuId(open ? template.id : null)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
|
||||
<Dialog
|
||||
open={confirmDeleteTemplate !== null}
|
||||
onOpenChange={(open) => { if (!open && !isDeletePending) setConfirmDeleteTemplate(null); }}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.promptTemplates.sidebar.dialog.deleteTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('settings.promptTemplates.sidebar.dialog.deleteDescription', { name: confirmDeleteTemplate?.name ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="ghost" onClick={() => setConfirmDeleteTemplate(null)} disabled={isDeletePending}>
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleDelete} disabled={isDeletePending}>
|
||||
{t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={renameDialogTemplate !== null} onOpenChange={(open) => !open && setRenameDialogTemplate(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.promptTemplates.sidebar.renameDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('settings.promptTemplates.sidebar.renameDialog.description', { name: renameDialogTemplate?.name ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={renameNewName}
|
||||
onChange={(e) => setRenameNewName(e.target.value)}
|
||||
placeholder={t('settings.promptTemplates.sidebar.renameDialog.placeholder')}
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); }}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="ghost" onClick={() => setRenameDialogTemplate(null)}>
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleRename}>
|
||||
{t('settings.common.actions.rename')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TemplateListItemProps {
|
||||
template: PromptTemplate;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete?: () => void;
|
||||
onRename?: () => void;
|
||||
onDuplicate: () => void;
|
||||
isMenuOpen: boolean;
|
||||
onMenuOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const TemplateListItem: React.FC<TemplateListItemProps> = ({
|
||||
template,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onRename,
|
||||
onDuplicate,
|
||||
isMenuOpen,
|
||||
onMenuOpenChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMobile = isMobileDeviceViaCSS();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none',
|
||||
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover',
|
||||
)}
|
||||
onContextMenu={!isMobile ? (e) => { e.preventDefault(); onMenuOpenChange(true); } : undefined}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<button
|
||||
onClick={onSelect}
|
||||
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"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-ui-label font-normal truncate text-foreground">
|
||||
{template.name}
|
||||
</span>
|
||||
{template.isDefault && (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
{t('settings.promptTemplates.sidebar.badge.default')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{template.body && (
|
||||
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
|
||||
{template.body.substring(0, 80)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!template.isDefault && (
|
||||
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||
<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">
|
||||
<RiMore2Line className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-fit min-w-20">
|
||||
{onRename && (
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onRename(); }}>
|
||||
<RiEditLine className="h-4 w-4 mr-px" />
|
||||
{t('settings.common.actions.rename')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onDuplicate(); }}>
|
||||
<RiFileCopyLine className="h-4 w-4 mr-px" />
|
||||
{t('settings.common.actions.duplicate')}
|
||||
</DropdownMenuItem>
|
||||
{onDelete && (
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onDelete(); }} className="text-destructive focus:text-destructive">
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
{t('settings.common.actions.delete')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { toast } from '@/components/ui';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
@@ -1055,7 +1056,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
const contextTokens = formatTokens(metadata?.limit?.context);
|
||||
const outputTokens = formatTokens(metadata?.limit?.output);
|
||||
|
||||
const capabilityIcons: Array<{ key: string; icon: string; label: string }> = [];
|
||||
const capabilityIcons: Array<{ key: string; icon: IconName; label: string }> = [];
|
||||
if (metadata?.tool_call) capabilityIcons.push({ key: 'tools', icon: "tools", label: t('settings.providers.page.models.capability.toolCalling') });
|
||||
if (metadata?.reasoning) capabilityIcons.push({ key: 'reasoning', icon: "brain-ai-3", label: t('settings.providers.page.models.capability.reasoning') });
|
||||
if (metadata?.attachment) capabilityIcons.push({ key: 'image', icon: "file-image", label: t('settings.providers.page.models.capability.imageInput') });
|
||||
|
||||
@@ -7,13 +7,14 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SettingsSidebarItemAction {
|
||||
/** Label shown in dropdown menu */
|
||||
label: string;
|
||||
/** Icon component to show before label */
|
||||
icon?: string;
|
||||
icon?: IconName;
|
||||
/** Callback when action is clicked */
|
||||
onClick: () => void;
|
||||
/** If true, uses destructive styling (red text) */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user