- {/* ── Config grid: 2-column on sm+, single column on narrow ── */}
- {/* Project */}
{t('multirun.launcher.project.label')}
{projects.length > 0 ? (
-
- {/* Group name */}
- {t('multirun.launcher.groupName.info')}}
- >
+ {t('multirun.launcher.groupName.info')}}>
{t('multirun.launcher.groupName.label')}
= ({
/>
- {/* Base branch */}
- {t('multirun.launcher.baseBranch.info')}}
- >
+ {t('multirun.launcher.baseBranch.info')}}>
{t('multirun.launcher.baseBranch.label')}
= ({
- {/* Agent */}
-
{t('multirun.launcher.agent.info')}}
- >
+ {t('multirun.launcher.agent.info')}}>
{t('multirun.launcher.agent.label')}
-
+
- {/* ── Setup commands (collapsible, full width) ── */}
-
{t('multirun.launcher.prompt.label')}
-
-
-
- {/* File attachments inline */}
+
{t('multirun.launcher.attachments.label')}
- {/* ── Models ── */}
-
- {t('multirun.launcher.models.info', { max: MAX_MODELS })}}
- >
- {t('multirun.launcher.models.label')}
-
- (
+ 1}
+ onUpdate={updateGroup}
+ onRemove={removeGroup}
+ onTemplateChange={handleTemplateChange}
/>
-
+ ))}
+
+
- {/* ── Error ── */}
{error && (
= ({
- {/* ── Fixed footer ── */}
{!effectiveIsolateRuns && isGitRepository !== null ? (
@@ -977,15 +696,11 @@ export const MultiRunLauncher: React.FC = ({
>
{t('multirun.launcher.actions.cancel')}
-
@@ -993,3 +708,104 @@ export const MultiRunLauncher: React.FC
= ({
);
};
+
+interface RunGroupCardProps {
+ group: RunGroupState;
+ groupIndex: number;
+ templates: { id: string; name: string; body: string; isDefault: boolean }[];
+ canRemove: boolean;
+ onUpdate: (groupId: string, updates: Partial) => void;
+ onRemove: (groupId: string) => void;
+ onTemplateChange: (groupId: string, templateId: string) => void;
+}
+
+const RunGroupCard: React.FC = ({
+ group,
+ groupIndex,
+ templates,
+ canRemove,
+ onUpdate,
+ onRemove,
+ onTemplateChange,
+}) => {
+ const { t } = useI18n();
+
+ const handleAddModel = React.useCallback((model: ModelSelectionWithId) => {
+ if (group.models.length >= MAX_MODELS_PER_GROUP) return;
+ onUpdate(group.id, { models: [...group.models, model] });
+ }, [group.id, group.models, onUpdate]);
+
+ const handleRemoveModel = React.useCallback((index: number) => {
+ onUpdate(group.id, { models: group.models.filter((_, i) => i !== index) });
+ }, [group.id, group.models, onUpdate]);
+
+ const handleUpdateModel = React.useCallback((index: number, model: ModelSelectionWithId) => {
+ onUpdate(group.id, { models: group.models.map((item, i) => (i === index ? model : item)) });
+ }, [group.id, group.models, onUpdate]);
+
+ return (
+
+
+
+ {t('multirun.launcher.groups.groupLabel', { index: groupIndex + 1 })}
+
+ {canRemove && (
+ onRemove(group.id)}
+ className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10"
+ aria-label={t('multirun.launcher.groups.removeGroup')}
+ >
+
+
+ )}
+
+
+
+ {t('multirun.launcher.groups.template.label')}
+ onTemplateChange(group.id, v === '__custom__' ? '' : v)}
+ >
+
+
+
+
+ {t('multirun.launcher.groups.template.custom')}
+ {templates.map((tpl) => (
+ {tpl.name}
+ ))}
+
+
+
+
+
+ {t('multirun.launcher.groups.prompt.label')}
+
+
+
+ {t('multirun.launcher.models.info', { max: MAX_MODELS_PER_GROUP })}}
+ >
+ {t('multirun.launcher.models.label')}
+
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/sections/prompt-templates/PromptTemplatesPage.tsx b/packages/ui/src/components/sections/prompt-templates/PromptTemplatesPage.tsx
new file mode 100644
index 00000000..971a0560
--- /dev/null
+++ b/packages/ui/src/components/sections/prompt-templates/PromptTemplatesPage.tsx
@@ -0,0 +1,181 @@
+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 (
+
+
+
+
{t('settings.promptTemplates.page.empty.title')}
+
{t('settings.promptTemplates.page.empty.description')}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {selectedTemplate ? selectedTemplate.name : t('settings.promptTemplates.page.title.new')}
+
+
+ {selectedTemplate ? t('settings.promptTemplates.page.subtitle.edit') : t('settings.promptTemplates.page.subtitle.new')}
+
+
+
+
+
+
+
+ {t('settings.promptTemplates.page.section.identity')}
+
+
+
+
+
{t('settings.promptTemplates.page.field.name')}
+
+ 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}
+ />
+
+
+
+
+
+
+
+
+ {t('settings.promptTemplates.page.section.template')}
+
+
+
+
+
+ {t('settings.promptTemplates.page.templateHint')}
+
+
+
+
+
+
+ {isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')}
+
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/sections/prompt-templates/PromptTemplatesSidebar.tsx b/packages/ui/src/components/sections/prompt-templates/PromptTemplatesSidebar.tsx
new file mode 100644
index 00000000..64d355fb
--- /dev/null
+++ b/packages/ui/src/components/sections/prompt-templates/PromptTemplatesSidebar.tsx
@@ -0,0 +1,316 @@
+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 = ({ onItemSelect }) => {
+ const { t } = useI18n();
+ const [confirmDeleteTemplate, setConfirmDeleteTemplate] = React.useState(null);
+ const [isDeletePending, setIsDeletePending] = React.useState(false);
+ const [openMenuId, setOpenMenuId] = React.useState(null);
+ const [renameDialogTemplate, setRenameDialogTemplate] = React.useState(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 (
+
+
+
{t('settings.promptTemplates.sidebar.title')}
+
+ {t('settings.promptTemplates.sidebar.total', { count: templates.length })}
+
+
+
+
+
+
+
+ {sortedTemplates.length === 0 ? (
+
+
+
{t('settings.promptTemplates.sidebar.empty.title')}
+
{t('settings.promptTemplates.sidebar.empty.description')}
+
+ ) : (
+ sortedTemplates.map((template) => (
+ {
+ setSelectedTemplate(template.id);
+ onItemSelect?.();
+ }}
+ onDelete={() => setConfirmDeleteTemplate(template)}
+ onRename={() => handleOpenRename(template)}
+ onDuplicate={() => handleDuplicate(template)}
+ isMenuOpen={openMenuId === template.id}
+ onMenuOpenChange={(open) => setOpenMenuId(open ? template.id : null)}
+ />
+ ))
+ )}
+
+
+
+
+
+
+ );
+};
+
+interface TemplateListItemProps {
+ template: PromptTemplate;
+ isSelected: boolean;
+ onSelect: () => void;
+ onDelete?: () => void;
+ onRename?: () => void;
+ onDuplicate: () => void;
+ isMenuOpen: boolean;
+ onMenuOpenChange: (open: boolean) => void;
+}
+
+const TemplateListItem: React.FC = ({
+ template,
+ isSelected,
+ onSelect,
+ onDelete,
+ onRename,
+ onDuplicate,
+ isMenuOpen,
+ onMenuOpenChange,
+}) => {
+ const { t } = useI18n();
+ const isMobile = isMobileDeviceViaCSS();
+ return (
+ { e.preventDefault(); onMenuOpenChange(true); } : undefined}
+ >
+
+
+
+
+ {template.name}
+
+ {template.isDefault && (
+
+ {t('settings.promptTemplates.sidebar.badge.default')}
+
+ )}
+
+ {template.body && (
+
+ {template.body.substring(0, 80)}
+
+ )}
+
+
+ {!template.isDefault && (
+
+
+
+
+
+
+
+ {onRename && (
+ { e.stopPropagation(); onRename(); }}>
+
+ {t('settings.common.actions.rename')}
+
+ )}
+ { e.stopPropagation(); onDuplicate(); }}>
+
+ {t('settings.common.actions.duplicate')}
+
+ {onDelete && (
+ { e.stopPropagation(); onDelete(); }} className="text-destructive focus:text-destructive">
+
+ {t('settings.common.actions.delete')}
+
+ )}
+
+
+ )}
+
+
+ );
+};
diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx
index 7bc42a4c..ea70127d 100644
--- a/packages/ui/src/components/views/SettingsView.tsx
+++ b/packages/ui/src/components/views/SettingsView.tsx
@@ -5,6 +5,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { useAgentsStore } from '@/stores/useAgentsStore';
import { useCommandsStore } from '@/stores/useCommandsStore';
import { useMcpConfigStore } from '@/stores/useMcpConfigStore';
+import { usePromptTemplatesStore } from '@/stores/usePromptTemplatesStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -28,6 +29,8 @@ import { UsageSidebar } from '@/components/sections/usage/UsageSidebar';
import { UsagePage } from '@/components/sections/usage/UsagePage';
import { MagicPromptsSidebar } from '@/components/sections/magic-prompts/MagicPromptsSidebar';
import { MagicPromptsPage } from '@/components/sections/magic-prompts/MagicPromptsPage';
+import { PromptTemplatesSidebar } from '@/components/sections/prompt-templates/PromptTemplatesSidebar';
+import { PromptTemplatesPage } from '@/components/sections/prompt-templates/PromptTemplatesPage';
import { GitPage } from '@/components/sections/git-identities/GitPage';
import type { OpenChamberSection } from '@/components/sections/openchamber/types';
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
@@ -73,6 +76,7 @@ const pageOrder: SettingsPageSlug[] = [
'shortcuts',
'git',
'magic-prompts',
+ 'prompt-templates',
'projects',
'remote-instances',
'agents',
@@ -113,6 +117,8 @@ export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
return 'chat-ai-3';
case 'magic-prompts':
return 'ai-generate-2';
+ case 'prompt-templates':
+ return 'file-text';
case 'notifications':
return 'notification-3';
case 'shortcuts':
@@ -357,6 +363,9 @@ export const SettingsView: React.FC = ({ onClose, forceMobile
void useSkillsStore.getState().loadSkills();
void useSkillsCatalogStore.getState().loadCatalog();
}
+ if (settingsSlug === 'prompt-templates') {
+ void usePromptTemplatesStore.getState().loadTemplates();
+ }
}, [activeProjectId, isSettingsDialogOpen, isWindowed, runtimeCtx.isVSCode, settingsSlug]);
const openPage = React.useCallback((slug: SettingsPageSlug) => {
@@ -423,6 +432,8 @@ export const SettingsView: React.FC = ({ onClose, forceMobile
return t('settings.page.sessions.title');
case 'magic-prompts':
return t('settings.page.magicPrompts.title');
+ case 'prompt-templates':
+ return t('settings.page.promptTemplates.title');
case 'notifications':
return t('settings.page.notifications.title');
case 'voice':
@@ -466,6 +477,8 @@ export const SettingsView: React.FC = ({ onClose, forceMobile
return ;
case 'magic-prompts':
return ;
+ case 'prompt-templates':
+ return ;
default:
return null;
}
@@ -502,6 +515,8 @@ export const SettingsView: React.FC = ({ onClose, forceMobile
return ;
case 'magic-prompts':
return ;
+ case 'prompt-templates':
+ return ;
case 'git':
return ;
case 'appearance':
diff --git a/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx b/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx
index 31b766b8..0e734283 100644
--- a/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx
+++ b/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx
@@ -347,8 +347,7 @@ export const AgentManagerEmptyState: React.FC = ({
await onCreateGroup?.({
name: groupName.trim(),
- prompt: prompt.trim(),
- models,
+ groups: [{ prompt: prompt.trim(), models }],
agent: selectedAgent || undefined,
worktreeBaseBranch: baseBranch,
files,
diff --git a/packages/ui/src/components/views/agent-manager/AgentManagerView.tsx b/packages/ui/src/components/views/agent-manager/AgentManagerView.tsx
index c8f49502..1720c8e4 100644
--- a/packages/ui/src/components/views/agent-manager/AgentManagerView.tsx
+++ b/packages/ui/src/components/views/agent-manager/AgentManagerView.tsx
@@ -98,7 +98,8 @@ export const AgentManagerView: React.FC = ({ className })
}, [selectGroup]);
const handleCreateGroup = React.useCallback(async (params: CreateMultiRunParams) => {
- toast.info(`Creating agent group "${params.name}" with ${params.models.length} model(s)...`);
+ const totalModels = params.groups.reduce((sum, g) => sum + g.models.length, 0);
+ toast.info(`Creating agent group "${params.name}" with ${totalModels} run(s)...`);
const result = await createMultiRun(params);
diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts
index f78039f9..81db2b54 100644
--- a/packages/ui/src/lib/i18n/messages/en.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/en.settings.ts
@@ -44,6 +44,39 @@ export const settingsDict = {
'settings.page.notifications.title': 'Notifications',
'settings.page.voice.title': 'Voice',
'settings.page.tunnel.title': 'Remote Tunnel',
+ 'settings.page.promptTemplates.title': 'Prompt Templates',
+ 'settings.promptTemplates.sidebar.title': 'Prompt Templates',
+ 'settings.promptTemplates.sidebar.total': 'Total: {count}',
+ 'settings.promptTemplates.sidebar.empty.title': 'No prompt templates',
+ 'settings.promptTemplates.sidebar.empty.description': 'Create a prompt template to get started.',
+ 'settings.promptTemplates.sidebar.toast.deleted': 'Template "{name}" deleted',
+ 'settings.promptTemplates.sidebar.toast.deleteFailed': 'Failed to delete template',
+ 'settings.promptTemplates.sidebar.toast.nameRequired': 'Template name is required',
+ 'settings.promptTemplates.sidebar.toast.renamed': 'Template renamed',
+ 'settings.promptTemplates.sidebar.toast.renameFailed': 'Failed to rename template',
+ 'settings.promptTemplates.sidebar.dialog.deleteTitle': 'Delete template?',
+ 'settings.promptTemplates.sidebar.dialog.deleteDescription': 'This will permanently delete "{name}". This action cannot be undone.',
+ 'settings.promptTemplates.sidebar.renameDialog.title': 'Rename template',
+ 'settings.promptTemplates.sidebar.renameDialog.description': 'Enter a new name for "{name}".',
+ 'settings.promptTemplates.sidebar.renameDialog.placeholder': 'Template name',
+ 'settings.promptTemplates.sidebar.badge.default': 'default',
+ 'settings.promptTemplates.page.empty.title': 'Select a template',
+ 'settings.promptTemplates.page.empty.description': 'Choose a template from the sidebar to edit it.',
+ 'settings.promptTemplates.page.title.new': 'New template',
+ 'settings.promptTemplates.page.subtitle.edit': 'Edit template',
+ 'settings.promptTemplates.page.subtitle.new': 'Create a new prompt template',
+ 'settings.promptTemplates.page.section.identity': 'Identity',
+ 'settings.promptTemplates.page.field.name': 'Name',
+ 'settings.promptTemplates.page.field.namePlaceholder': 'Template name',
+ 'settings.promptTemplates.page.section.template': 'Template',
+ 'settings.promptTemplates.page.field.templatePlaceholder': 'Enter the prompt template text...',
+ 'settings.promptTemplates.page.templateHint': 'This text will be prepended to the user\'s prompt when selected in a multi-run group.',
+ 'settings.promptTemplates.page.toast.nameRequired': 'Template name is required',
+ 'settings.promptTemplates.page.toast.updated': 'Template updated',
+ 'settings.promptTemplates.page.toast.updateFailed': 'Failed to update template',
+ 'settings.promptTemplates.page.toast.created': 'Template created',
+ 'settings.promptTemplates.page.toast.createFailed': 'Failed to create template',
+ 'settings.promptTemplates.page.toast.saveUnexpectedError': 'An unexpected error occurred while saving',
'settings.openchamber.tunnel.title': 'Remote Tunnel',
'settings.openchamber.tunnel.description': 'Configure secure remote access with quick links or your own managed remote Cloudflare tunnel.',
'settings.openchamber.tunnel.note.serverSideEnforced': 'Secure tunnel access is enforced server-side.',
diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts
index 6cd51cba..867af961 100644
--- a/packages/ui/src/lib/i18n/messages/en.ts
+++ b/packages/ui/src/lib/i18n/messages/en.ts
@@ -191,6 +191,16 @@ export const dict = {
'multirun.fusion.actions.starting': 'Starting...',
'multirun.fusion.toast.noOutputs': 'No assistant outputs found to fuse.',
'multirun.fusion.toast.failed': 'Failed to start fusion.',
+ 'multirun.launcher.attachments.label': 'Files',
+ 'multirun.launcher.actions.startWithRunCount': 'Start ({count} runs)',
+ 'multirun.launcher.groups.addGroup': 'Add run group',
+ 'multirun.launcher.groups.removeGroup': 'Remove group',
+ 'multirun.launcher.groups.groupLabel': 'Run group {index}',
+ 'multirun.launcher.groups.template.label': 'Template',
+ 'multirun.launcher.groups.template.placeholder': 'Select a template...',
+ 'multirun.launcher.groups.template.custom': 'Custom prompt',
+ 'multirun.launcher.groups.prompt.label': 'Prompt',
+ 'multirun.launcher.groups.prompt.placeholder': 'Enter the prompt for this group...',
'sessions.sidebar.header.actions.searchSessions': 'Search sessions',
'sessions.sidebar.header.actions.exitSelection': 'Exit selection',
'sessions.sidebar.header.actions.selectSessions': 'Select sessions',
diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts
index b9405d26..25c84108 100644
--- a/packages/ui/src/lib/i18n/messages/es.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/es.settings.ts
@@ -1570,4 +1570,37 @@ export const settingsDict = {
"settings.magicPrompts.page.toast.resetFailed": "No se pudo restablecer el prompt",
"settings.magicPrompts.page.toast.resetAllSuccess": "Se restablecieron todos los prompts sobrescritos",
"settings.magicPrompts.page.toast.resetAllFailed": "No se pudieron restablecer todos los prompts",
+ "settings.page.promptTemplates.title": "Plantillas de prompt",
+ "settings.promptTemplates.sidebar.title": "Plantillas de prompt",
+ "settings.promptTemplates.sidebar.total": "Total: {count}",
+ "settings.promptTemplates.sidebar.empty.title": "Sin plantillas de prompt",
+ "settings.promptTemplates.sidebar.empty.description": "Crea una plantilla de prompt para comenzar.",
+ "settings.promptTemplates.sidebar.toast.deleted": 'Plantilla "{name}" eliminada',
+ "settings.promptTemplates.sidebar.toast.deleteFailed": "Error al eliminar la plantilla",
+ "settings.promptTemplates.sidebar.toast.nameRequired": "El nombre de la plantilla es obligatorio",
+ "settings.promptTemplates.sidebar.toast.renamed": "Plantilla renombrada",
+ "settings.promptTemplates.sidebar.toast.renameFailed": "Error al renombrar la plantilla",
+ "settings.promptTemplates.sidebar.dialog.deleteTitle": "¿Eliminar plantilla?",
+ "settings.promptTemplates.sidebar.dialog.deleteDescription": 'Se eliminará permanentemente "{name}". Esta acción no se puede deshacer.',
+ "settings.promptTemplates.sidebar.renameDialog.title": "Renombrar plantilla",
+ "settings.promptTemplates.sidebar.renameDialog.description": 'Introduce un nuevo nombre para "{name}".',
+ "settings.promptTemplates.sidebar.renameDialog.placeholder": "Nombre de la plantilla",
+ "settings.promptTemplates.sidebar.badge.default": "predeterminada",
+ "settings.promptTemplates.page.empty.title": "Selecciona una plantilla",
+ "settings.promptTemplates.page.empty.description": "Elige una plantilla de la barra lateral para editarla.",
+ "settings.promptTemplates.page.title.new": "Nueva plantilla",
+ "settings.promptTemplates.page.subtitle.edit": "Editar plantilla",
+ "settings.promptTemplates.page.subtitle.new": "Crear una nueva plantilla de prompt",
+ "settings.promptTemplates.page.section.identity": "Identidad",
+ "settings.promptTemplates.page.field.name": "Nombre",
+ "settings.promptTemplates.page.field.namePlaceholder": "Nombre de la plantilla",
+ "settings.promptTemplates.page.section.template": "Plantilla",
+ "settings.promptTemplates.page.field.templatePlaceholder": "Introduce el texto de la plantilla de prompt...",
+ "settings.promptTemplates.page.templateHint": "Este texto se antepondrá al prompt del usuario cuando se seleccione en un grupo multi-run.",
+ "settings.promptTemplates.page.toast.nameRequired": "El nombre de la plantilla es obligatorio",
+ "settings.promptTemplates.page.toast.updated": "Plantilla actualizada",
+ "settings.promptTemplates.page.toast.updateFailed": "Error al actualizar la plantilla",
+ "settings.promptTemplates.page.toast.created": "Plantilla creada",
+ "settings.promptTemplates.page.toast.createFailed": "Error al crear la plantilla",
+ "settings.promptTemplates.page.toast.saveUnexpectedError": "Ocurrió un error inesperado al guardar",
} as const;
diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts
index 583da0c2..7b034754 100644
--- a/packages/ui/src/lib/i18n/messages/es.ts
+++ b/packages/ui/src/lib/i18n/messages/es.ts
@@ -192,6 +192,16 @@ export const dict: Record = {
"multirun.fusion.actions.starting": "Iniciando...",
"multirun.fusion.toast.noOutputs": "No se encontraron respuestas del asistente para fusionar.",
"multirun.fusion.toast.failed": "No se pudo iniciar fusion.",
+ "multirun.launcher.attachments.label": "Archivos",
+ "multirun.launcher.actions.startWithRunCount": "Iniciar ({count} ejecuciones)",
+ "multirun.launcher.groups.addGroup": "Añadir grupo de ejecución",
+ "multirun.launcher.groups.removeGroup": "Eliminar grupo",
+ "multirun.launcher.groups.groupLabel": "Grupo de ejecución {index}",
+ "multirun.launcher.groups.template.label": "Plantilla",
+ "multirun.launcher.groups.template.placeholder": "Selecciona una plantilla...",
+ "multirun.launcher.groups.template.custom": "Prompt personalizado",
+ "multirun.launcher.groups.prompt.label": "Prompt",
+ "multirun.launcher.groups.prompt.placeholder": "Introduce el prompt para este grupo...",
"sessions.sidebar.header.actions.searchSessions": "Buscar sesiones",
"sessions.sidebar.header.actions.exitSelection": "Salir de selección",
"sessions.sidebar.header.actions.selectSessions": "Seleccionar sesiones",
diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts
index 9e6470db..fc07466b 100644
--- a/packages/ui/src/lib/i18n/messages/ko.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts
@@ -1570,4 +1570,37 @@ export const settingsDict = {
'settings.magicPrompts.page.toast.resetFailed': '프롬프트를 초기화하지 못했습니다',
'settings.magicPrompts.page.toast.resetAllSuccess': '모든 프롬프트 오버라이드가 초기화되었습니다',
'settings.magicPrompts.page.toast.resetAllFailed': '모든 프롬프트를 초기화하지 못했습니다',
+ 'settings.page.promptTemplates.title': '프롬프트 템플릿',
+ 'settings.promptTemplates.sidebar.title': '프롬프트 템플릿',
+ 'settings.promptTemplates.sidebar.total': '총 {count}개',
+ 'settings.promptTemplates.sidebar.empty.title': '프롬프트 템플릿 없음',
+ 'settings.promptTemplates.sidebar.empty.description': '프롬프트 템플릿을 만들어 보세요.',
+ 'settings.promptTemplates.sidebar.toast.deleted': '템플릿 "{name}"이(가) 삭제되었습니다',
+ 'settings.promptTemplates.sidebar.toast.deleteFailed': '템플릿 삭제 실패',
+ 'settings.promptTemplates.sidebar.toast.nameRequired': '템플릿 이름은 필수입니다',
+ 'settings.promptTemplates.sidebar.toast.renamed': '템플릿 이름이 변경되었습니다',
+ 'settings.promptTemplates.sidebar.toast.renameFailed': '템플릿 이름 변경 실패',
+ 'settings.promptTemplates.sidebar.dialog.deleteTitle': '템플릿을 삭제하시겠습니까?',
+ 'settings.promptTemplates.sidebar.dialog.deleteDescription': '"{name}"이(가) 영구적으로 삭제됩니다. 이 작업은 취소할 수 없습니다.',
+ 'settings.promptTemplates.sidebar.renameDialog.title': '템플릿 이름 변경',
+ 'settings.promptTemplates.sidebar.renameDialog.description': '"{name}"의 새 이름을 입력하세요.',
+ 'settings.promptTemplates.sidebar.renameDialog.placeholder': '템플릿 이름',
+ 'settings.promptTemplates.sidebar.badge.default': '기본',
+ 'settings.promptTemplates.page.empty.title': '템플릿을 선택하세요',
+ 'settings.promptTemplates.page.empty.description': '사이드바에서 템플릿을 선택하여 편집하세요.',
+ 'settings.promptTemplates.page.title.new': '새 템플릿',
+ 'settings.promptTemplates.page.subtitle.edit': '템플릿 편집',
+ 'settings.promptTemplates.page.subtitle.new': '새 프롬프트 템플릿 만들기',
+ 'settings.promptTemplates.page.section.identity': '식별 정보',
+ 'settings.promptTemplates.page.field.name': '이름',
+ 'settings.promptTemplates.page.field.namePlaceholder': '템플릿 이름',
+ 'settings.promptTemplates.page.section.template': '템플릿',
+ 'settings.promptTemplates.page.field.templatePlaceholder': '프롬프트 템플릿 텍스트를 입력하세요...',
+ 'settings.promptTemplates.page.templateHint': '멀티런 그룹에서 선택하면 이 텍스트가 사용자 프롬프트 앞에 추가됩니다.',
+ 'settings.promptTemplates.page.toast.nameRequired': '템플릿 이름은 필수입니다',
+ 'settings.promptTemplates.page.toast.updated': '템플릿이 업데이트되었습니다',
+ 'settings.promptTemplates.page.toast.updateFailed': '템플릿 업데이트 실패',
+ 'settings.promptTemplates.page.toast.created': '템플릿이 생성되었습니다',
+ 'settings.promptTemplates.page.toast.createFailed': '템플릿 생성 실패',
+ 'settings.promptTemplates.page.toast.saveUnexpectedError': '저장 중 예기치 않은 오류가 발생했습니다',
} as const;
diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts
index fb2bf00b..21642d5b 100644
--- a/packages/ui/src/lib/i18n/messages/ko.ts
+++ b/packages/ui/src/lib/i18n/messages/ko.ts
@@ -192,6 +192,16 @@ export const dict: Record = {
'multirun.fusion.actions.starting': '시작 중...',
'multirun.fusion.toast.noOutputs': 'fusion할 어시스턴트 응답을 찾지 못했습니다.',
'multirun.fusion.toast.failed': 'fusion을 시작하지 못했습니다.',
+ 'multirun.launcher.attachments.label': '파일',
+ 'multirun.launcher.actions.startWithRunCount': '시작 ({count}개 실행)',
+ 'multirun.launcher.groups.addGroup': '실행 그룹 추가',
+ 'multirun.launcher.groups.removeGroup': '그룹 제거',
+ 'multirun.launcher.groups.groupLabel': '실행 그룹 {index}',
+ 'multirun.launcher.groups.template.label': '템플릿',
+ 'multirun.launcher.groups.template.placeholder': '템플릿 선택...',
+ 'multirun.launcher.groups.template.custom': '사용자 지정 프롬프트',
+ 'multirun.launcher.groups.prompt.label': '프롬프트',
+ 'multirun.launcher.groups.prompt.placeholder': '이 그룹의 프롬프트를 입력하세요...',
'sessions.sidebar.header.actions.searchSessions': '세션 검색',
'sessions.sidebar.header.actions.exitSelection': '선택 종료',
'sessions.sidebar.header.actions.selectSessions': '세션 선택',
diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts
index f44e5fb9..c1190a11 100644
--- a/packages/ui/src/lib/i18n/messages/pl.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts
@@ -963,6 +963,39 @@ export const settingsDict = {
'settings.page.skills.title': 'Umiejętności',
'settings.page.skillsCatalog.title': 'Katalog umiejętności',
'settings.page.tunnel.title': 'Zdalny Tunel',
+ 'settings.page.promptTemplates.title': 'Szablony promptów',
+ 'settings.promptTemplates.sidebar.title': 'Szablony promptów',
+ 'settings.promptTemplates.sidebar.total': 'Suma: {count}',
+ 'settings.promptTemplates.sidebar.empty.title': 'Brak szablonów promptów',
+ 'settings.promptTemplates.sidebar.empty.description': 'Utwórz szablon promptu, aby rozpocząć.',
+ 'settings.promptTemplates.sidebar.toast.deleted': 'Szablon "{name}" usunięty',
+ 'settings.promptTemplates.sidebar.toast.deleteFailed': 'Nie udało się usunąć szablonu',
+ 'settings.promptTemplates.sidebar.toast.nameRequired': 'Nazwa szablonu jest wymagana',
+ 'settings.promptTemplates.sidebar.toast.renamed': 'Szablon zmienił nazwę',
+ 'settings.promptTemplates.sidebar.toast.renameFailed': 'Nie udało się zmienić nazwy szablonu',
+ 'settings.promptTemplates.sidebar.dialog.deleteTitle': 'Usunąć szablon?',
+ 'settings.promptTemplates.sidebar.dialog.deleteDescription': 'To trwale usunie "{name}". Tej operacji nie można cofnąć.',
+ 'settings.promptTemplates.sidebar.renameDialog.title': 'Zmień nazwę szablonu',
+ 'settings.promptTemplates.sidebar.renameDialog.description': 'Wpisz nową nazwę dla "{name}".',
+ 'settings.promptTemplates.sidebar.renameDialog.placeholder': 'Nazwa szablonu',
+ 'settings.promptTemplates.sidebar.badge.default': 'domyślny',
+ 'settings.promptTemplates.page.empty.title': 'Wybierz szablon',
+ 'settings.promptTemplates.page.empty.description': 'Wybierz szablon z paska bocznego, aby go edytować.',
+ 'settings.promptTemplates.page.title.new': 'Nowy szablon',
+ 'settings.promptTemplates.page.subtitle.edit': 'Edytuj szablon',
+ 'settings.promptTemplates.page.subtitle.new': 'Utwórz nowy szablon promptu',
+ 'settings.promptTemplates.page.section.identity': 'Tożsamość',
+ 'settings.promptTemplates.page.field.name': 'Nazwa',
+ 'settings.promptTemplates.page.field.namePlaceholder': 'Nazwa szablonu',
+ 'settings.promptTemplates.page.section.template': 'Szablon',
+ 'settings.promptTemplates.page.field.templatePlaceholder': 'Wpisz tekst szablonu promptu...',
+ 'settings.promptTemplates.page.templateHint': 'Ten tekst zostanie dodany przed promptem użytkownika po wybraniu w grupie wielokrotnego uruchomienia.',
+ 'settings.promptTemplates.page.toast.nameRequired': 'Nazwa szablonu jest wymagana',
+ 'settings.promptTemplates.page.toast.updated': 'Szablon zaktualizowany',
+ 'settings.promptTemplates.page.toast.updateFailed': 'Nie udało się zaktualizować szablonu',
+ 'settings.promptTemplates.page.toast.created': 'Szablon utworzony',
+ 'settings.promptTemplates.page.toast.createFailed': 'Nie udało się utworzyć szablonu',
+ 'settings.promptTemplates.page.toast.saveUnexpectedError': 'Wystąpił nieoczekiwany błąd podczas zapisywania',
'settings.page.usage.title': 'Użycie',
'settings.page.voice.title': 'Głos',
'settings.projects.actions.actions.add': 'Dodaj akcję',
diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts
index e204fa86..190e3ce2 100644
--- a/packages/ui/src/lib/i18n/messages/pl.ts
+++ b/packages/ui/src/lib/i18n/messages/pl.ts
@@ -262,6 +262,16 @@ export const dict: Record = {
'multirun.modelMultiSelect.validation.minOnly': 'Wybierz przynajmniej {min} modele.',
'multirun.modelMultiSelect.validation.minToMax': 'Wybierz od {min} do {max} modeli.',
'multirun.agentSelector.placeholder': 'Wybierz agenta',
+ 'multirun.launcher.attachments.label': 'Pliki',
+ 'multirun.launcher.actions.startWithRunCount': 'Uruchom ({count} uruchomień)',
+ 'multirun.launcher.groups.addGroup': 'Dodaj grupę uruchomień',
+ 'multirun.launcher.groups.removeGroup': 'Usuń grupę',
+ 'multirun.launcher.groups.groupLabel': 'Grupa uruchomień {index}',
+ 'multirun.launcher.groups.template.label': 'Szablon',
+ 'multirun.launcher.groups.template.placeholder': 'Wybierz szablon...',
+ 'multirun.launcher.groups.template.custom': 'Niestandardowy prompt',
+ 'multirun.launcher.groups.prompt.label': 'Prompt',
+ 'multirun.launcher.groups.prompt.placeholder': 'Wpisz prompt dla tej grupy...',
'multirun.fusion.title': 'Uruchom fusion',
'multirun.fusion.description': 'Połącz wybrane wyniki multi-run w jedną mocniejszą odpowiedź.',
'multirun.fusion.provider.placeholder': 'Provider',
diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts
index ba8714b3..03b94f95 100644
--- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts
@@ -1570,4 +1570,37 @@ export const settingsDict = {
"settings.magicPrompts.page.toast.resetFailed": "Não foi possível redefinir o prompt",
"settings.magicPrompts.page.toast.resetAllSuccess": "Todos os prompts sobrescritos foram redefinidos",
"settings.magicPrompts.page.toast.resetAllFailed": "Não foi possível redefinir todos os prompts",
+ "settings.page.promptTemplates.title": "Templates de Prompt",
+ "settings.promptTemplates.sidebar.title": "Templates de Prompt",
+ "settings.promptTemplates.sidebar.total": "Total: {count}",
+ "settings.promptTemplates.sidebar.empty.title": "Nenhum template de prompt",
+ "settings.promptTemplates.sidebar.empty.description": "Crie um template de prompt para começar.",
+ "settings.promptTemplates.sidebar.toast.deleted": 'Template "{name}" excluído',
+ "settings.promptTemplates.sidebar.toast.deleteFailed": "Falha ao excluir template",
+ "settings.promptTemplates.sidebar.toast.nameRequired": "O nome do template é obrigatório",
+ "settings.promptTemplates.sidebar.toast.renamed": "Template renomeado",
+ "settings.promptTemplates.sidebar.toast.renameFailed": "Falha ao renomear template",
+ "settings.promptTemplates.sidebar.dialog.deleteTitle": "Excluir template?",
+ "settings.promptTemplates.sidebar.dialog.deleteDescription": 'Isso excluirá permanentemente "{name}". Esta ação não pode ser desfeita.',
+ "settings.promptTemplates.sidebar.renameDialog.title": "Renomear template",
+ "settings.promptTemplates.sidebar.renameDialog.description": 'Digite um novo nome para "{name}".',
+ "settings.promptTemplates.sidebar.renameDialog.placeholder": "Nome do template",
+ "settings.promptTemplates.sidebar.badge.default": "padrão",
+ "settings.promptTemplates.page.empty.title": "Selecione um template",
+ "settings.promptTemplates.page.empty.description": "Escolha um template na barra lateral para editá-lo.",
+ "settings.promptTemplates.page.title.new": "Novo template",
+ "settings.promptTemplates.page.subtitle.edit": "Editar template",
+ "settings.promptTemplates.page.subtitle.new": "Criar um novo template de prompt",
+ "settings.promptTemplates.page.section.identity": "Identidade",
+ "settings.promptTemplates.page.field.name": "Nome",
+ "settings.promptTemplates.page.field.namePlaceholder": "Nome do template",
+ "settings.promptTemplates.page.section.template": "Template",
+ "settings.promptTemplates.page.field.templatePlaceholder": "Digite o texto do template de prompt...",
+ "settings.promptTemplates.page.templateHint": "Este texto será adicionado antes do prompt do usuário quando selecionado em um grupo multi-run.",
+ "settings.promptTemplates.page.toast.nameRequired": "O nome do template é obrigatório",
+ "settings.promptTemplates.page.toast.updated": "Template atualizado",
+ "settings.promptTemplates.page.toast.updateFailed": "Falha ao atualizar template",
+ "settings.promptTemplates.page.toast.created": "Template criado",
+ "settings.promptTemplates.page.toast.createFailed": "Falha ao criar template",
+ "settings.promptTemplates.page.toast.saveUnexpectedError": "Ocorreu um erro inesperado ao salvar",
} as const;
diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts
index 6274d841..6f499159 100644
--- a/packages/ui/src/lib/i18n/messages/pt-BR.ts
+++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts
@@ -192,6 +192,16 @@ export const dict: Record = {
"multirun.fusion.actions.starting": "Iniciando...",
"multirun.fusion.toast.noOutputs": "Nenhuma resposta do assistente encontrada para fusion.",
"multirun.fusion.toast.failed": "Falha ao iniciar fusion.",
+ "multirun.launcher.attachments.label": "Arquivos",
+ "multirun.launcher.actions.startWithRunCount": "Iniciar ({count} execuções)",
+ "multirun.launcher.groups.addGroup": "Adicionar grupo de execução",
+ "multirun.launcher.groups.removeGroup": "Remover grupo",
+ "multirun.launcher.groups.groupLabel": "Grupo de execução {index}",
+ "multirun.launcher.groups.template.label": "Template",
+ "multirun.launcher.groups.template.placeholder": "Selecione um template...",
+ "multirun.launcher.groups.template.custom": "Prompt personalizado",
+ "multirun.launcher.groups.prompt.label": "Prompt",
+ "multirun.launcher.groups.prompt.placeholder": "Insira o prompt para este grupo...",
"sessions.sidebar.header.actions.searchSessions": "Pesquisar sessões",
"sessions.sidebar.header.actions.exitSelection": "Sair da seleção",
"sessions.sidebar.header.actions.selectSessions": "Selecionar sessões",
diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts
index 6e219e34..8fb0e674 100644
--- a/packages/ui/src/lib/i18n/messages/uk.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts
@@ -1570,4 +1570,37 @@ export const settingsDict = {
"settings.magicPrompts.page.toast.resetFailed": "Не вдалося скинути промпт",
"settings.magicPrompts.page.toast.resetAllSuccess": "Усі перевизначення промптів скинуто",
"settings.magicPrompts.page.toast.resetAllFailed": "Не вдалося скинути всі промпти",
+ "settings.page.promptTemplates.title": "Шаблони промптів",
+ "settings.promptTemplates.sidebar.title": "Шаблони промптів",
+ "settings.promptTemplates.sidebar.total": "Всього: {count}",
+ "settings.promptTemplates.sidebar.empty.title": "Немає шаблонів промптів",
+ "settings.promptTemplates.sidebar.empty.description": "Створіть шаблон промпту для початку.",
+ "settings.promptTemplates.sidebar.toast.deleted": 'Шаблон "{name}" видалено',
+ "settings.promptTemplates.sidebar.toast.deleteFailed": "Не вдалося видалити шаблон",
+ "settings.promptTemplates.sidebar.toast.nameRequired": "Назва шаблону обов'язкова",
+ "settings.promptTemplates.sidebar.toast.renamed": "Шаблон перейменовано",
+ "settings.promptTemplates.sidebar.toast.renameFailed": "Не вдалося перейменувати шаблон",
+ "settings.promptTemplates.sidebar.dialog.deleteTitle": "Видалити шаблон?",
+ "settings.promptTemplates.sidebar.dialog.deleteDescription": 'Це назавжди видалить "{name}". Цю дію не можна скасувати.',
+ "settings.promptTemplates.sidebar.renameDialog.title": "Перейменувати шаблон",
+ "settings.promptTemplates.sidebar.renameDialog.description": 'Введіть нову назву для "{name}".',
+ "settings.promptTemplates.sidebar.renameDialog.placeholder": "Назва шаблону",
+ "settings.promptTemplates.sidebar.badge.default": "за замовчуванням",
+ "settings.promptTemplates.page.empty.title": "Оберіть шаблон",
+ "settings.promptTemplates.page.empty.description": "Оберіть шаблон на бічній панелі для редагування.",
+ "settings.promptTemplates.page.title.new": "Новий шаблон",
+ "settings.promptTemplates.page.subtitle.edit": "Редагувати шаблон",
+ "settings.promptTemplates.page.subtitle.new": "Створити новий шаблон промпту",
+ "settings.promptTemplates.page.section.identity": "Ідентифікація",
+ "settings.promptTemplates.page.field.name": "Назва",
+ "settings.promptTemplates.page.field.namePlaceholder": "Назва шаблону",
+ "settings.promptTemplates.page.section.template": "Шаблон",
+ "settings.promptTemplates.page.field.templatePlaceholder": "Введіть текст шаблону промпту...",
+ "settings.promptTemplates.page.templateHint": "Цей текст буде додано перед промптом користувача при виборі в групі мультизапуску.",
+ "settings.promptTemplates.page.toast.nameRequired": "Назва шаблону обов'язкова",
+ "settings.promptTemplates.page.toast.updated": "Шаблон оновлено",
+ "settings.promptTemplates.page.toast.updateFailed": "Не вдалося оновити шаблон",
+ "settings.promptTemplates.page.toast.created": "Шаблон створено",
+ "settings.promptTemplates.page.toast.createFailed": "Не вдалося створити шаблон",
+ "settings.promptTemplates.page.toast.saveUnexpectedError": "Сталася неочікувана помилка під час збереження",
} as const;
diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts
index 7dd35ad2..98127cbc 100644
--- a/packages/ui/src/lib/i18n/messages/uk.ts
+++ b/packages/ui/src/lib/i18n/messages/uk.ts
@@ -192,6 +192,16 @@ export const dict: Record = {
"multirun.fusion.actions.starting": "Запуск...",
"multirun.fusion.toast.noOutputs": "Не знайдено відповідей асистента для fusion.",
"multirun.fusion.toast.failed": "Не вдалося запустити fusion.",
+ "multirun.launcher.attachments.label": "Файли",
+ "multirun.launcher.actions.startWithRunCount": "Запустити ({count} запусків)",
+ "multirun.launcher.groups.addGroup": "Додати групу запусків",
+ "multirun.launcher.groups.removeGroup": "Видалити групу",
+ "multirun.launcher.groups.groupLabel": "Група запусків {index}",
+ "multirun.launcher.groups.template.label": "Шаблон",
+ "multirun.launcher.groups.template.placeholder": "Оберіть шаблон...",
+ "multirun.launcher.groups.template.custom": "Довільний промпт",
+ "multirun.launcher.groups.prompt.label": "Промпт",
+ "multirun.launcher.groups.prompt.placeholder": "Введіть промпт для цієї групи...",
"sessions.sidebar.header.actions.searchSessions": "Пошук сесій",
"sessions.sidebar.header.actions.exitSelection": "Вийти з вибору",
"sessions.sidebar.header.actions.selectSessions": "Вибрати сесії",
diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts
index 741fe7ae..283b7539 100644
--- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts
@@ -1570,4 +1570,37 @@ export const settingsDict = {
'settings.magicPrompts.page.toast.resetFailed': '重置提示词失败',
'settings.magicPrompts.page.toast.resetAllSuccess': '所有提示词覆盖已重置',
'settings.magicPrompts.page.toast.resetAllFailed': '重置全部提示词失败',
+ 'settings.page.promptTemplates.title': '提示词模板',
+ 'settings.promptTemplates.sidebar.title': '提示词模板',
+ 'settings.promptTemplates.sidebar.total': '共 {count} 个',
+ 'settings.promptTemplates.sidebar.empty.title': '暂无提示词模板',
+ 'settings.promptTemplates.sidebar.empty.description': '创建一个提示词模板开始使用。',
+ 'settings.promptTemplates.sidebar.toast.deleted': '模板"{name}"已删除',
+ 'settings.promptTemplates.sidebar.toast.deleteFailed': '删除模板失败',
+ 'settings.promptTemplates.sidebar.toast.nameRequired': '模板名称为必填项',
+ 'settings.promptTemplates.sidebar.toast.renamed': '模板已重命名',
+ 'settings.promptTemplates.sidebar.toast.renameFailed': '重命名模板失败',
+ 'settings.promptTemplates.sidebar.dialog.deleteTitle': '删除模板?',
+ 'settings.promptTemplates.sidebar.dialog.deleteDescription': '将永久删除"{name}"。此操作无法撤销。',
+ 'settings.promptTemplates.sidebar.renameDialog.title': '重命名模板',
+ 'settings.promptTemplates.sidebar.renameDialog.description': '为"{name}"输入新名称。',
+ 'settings.promptTemplates.sidebar.renameDialog.placeholder': '模板名称',
+ 'settings.promptTemplates.sidebar.badge.default': '默认',
+ 'settings.promptTemplates.page.empty.title': '选择一个模板',
+ 'settings.promptTemplates.page.empty.description': '从侧边栏选择一个模板进行编辑。',
+ 'settings.promptTemplates.page.title.new': '新建模板',
+ 'settings.promptTemplates.page.subtitle.edit': '编辑模板',
+ 'settings.promptTemplates.page.subtitle.new': '创建新的提示词模板',
+ 'settings.promptTemplates.page.section.identity': '标识',
+ 'settings.promptTemplates.page.field.name': '名称',
+ 'settings.promptTemplates.page.field.namePlaceholder': '模板名称',
+ 'settings.promptTemplates.page.section.template': '模板',
+ 'settings.promptTemplates.page.field.templatePlaceholder': '输入提示词模板文本...',
+ 'settings.promptTemplates.page.templateHint': '在多运行组中选择此模板时,此文本将添加到用户提示词之前。',
+ 'settings.promptTemplates.page.toast.nameRequired': '模板名称为必填项',
+ 'settings.promptTemplates.page.toast.updated': '模板已更新',
+ 'settings.promptTemplates.page.toast.updateFailed': '更新模板失败',
+ 'settings.promptTemplates.page.toast.created': '模板已创建',
+ 'settings.promptTemplates.page.toast.createFailed': '创建模板失败',
+ 'settings.promptTemplates.page.toast.saveUnexpectedError': '保存时发生意外错误',
} as const;
diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts
index 62e57924..2f2b8e07 100644
--- a/packages/ui/src/lib/i18n/messages/zh-CN.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts
@@ -192,6 +192,16 @@ export const dict: Record = {
'multirun.fusion.actions.starting': '正在开始...',
'multirun.fusion.toast.noOutputs': '未找到可用于融合的助手输出。',
'multirun.fusion.toast.failed': '启动融合失败。',
+ 'multirun.launcher.attachments.label': '文件',
+ 'multirun.launcher.actions.startWithRunCount': '开始({count} 次运行)',
+ 'multirun.launcher.groups.addGroup': '添加运行组',
+ 'multirun.launcher.groups.removeGroup': '移除运行组',
+ 'multirun.launcher.groups.groupLabel': '运行组 {index}',
+ 'multirun.launcher.groups.template.label': '模板',
+ 'multirun.launcher.groups.template.placeholder': '选择模板...',
+ 'multirun.launcher.groups.template.custom': '自定义提示词',
+ 'multirun.launcher.groups.prompt.label': '提示词',
+ 'multirun.launcher.groups.prompt.placeholder': '输入此运行组的提示词...',
'sessions.sidebar.header.actions.searchSessions': '搜索会话',
'sessions.sidebar.header.actions.exitSelection': '退出选择',
'sessions.sidebar.header.actions.selectSessions': '选择会话',
diff --git a/packages/ui/src/lib/settings/metadata.ts b/packages/ui/src/lib/settings/metadata.ts
index 41ae772e..49359526 100644
--- a/packages/ui/src/lib/settings/metadata.ts
+++ b/packages/ui/src/lib/settings/metadata.ts
@@ -18,6 +18,7 @@ export type SettingsPageSlug =
| 'shortcuts'
| 'sessions'
| 'magic-prompts'
+ | 'prompt-templates'
| 'notifications'
| 'voice'
| 'tunnel';
@@ -184,6 +185,13 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
keywords: ['prompts', 'templates', 'git', 'github', 'review', 'commit', 'pull request'],
isAvailable: (ctx) => !ctx.isVSCode,
},
+ {
+ slug: 'prompt-templates',
+ title: 'Prompt Templates',
+ group: 'general',
+ kind: 'split',
+ keywords: ['prompt', 'templates', 'multi-run', 'strategy', 'approach'],
+ },
{ slug: 'notifications', title: 'Notifications', group: 'general', kind: 'single', keywords: ['alerts', 'native', 'summary', 'summarization'], },
{ slug: 'voice', title: 'Voice', group: 'advanced', kind: 'single', keywords: ['tts', 'speech', 'voice'], isAvailable: (ctx) => !ctx.isVSCode },
diff --git a/packages/ui/src/stores/useMultiRunStore.ts b/packages/ui/src/stores/useMultiRunStore.ts
index afdbccbc..bbe80757 100644
--- a/packages/ui/src/stores/useMultiRunStore.ts
+++ b/packages/ui/src/stores/useMultiRunStore.ts
@@ -8,13 +8,9 @@ import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
import { createWorktreeWithDefaults, resolveRootTrackingRemote } from '@/lib/worktrees/worktreeCreate';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { checkIsGitRepository } from '@/lib/gitApi';
-// sessionStore removed — sync bootstrap handles session loading
import { useDirectoryStore } from './useDirectoryStore';
import { useProjectsStore } from './useProjectsStore';
-/**
- * Generate a git-safe slug from a string.
- */
const toGitSafeSlug = (value: string): string => {
return value
.toLowerCase()
@@ -23,19 +19,12 @@ const toGitSafeSlug = (value: string): string => {
.substring(0, 50);
};
-/**
- * Generate a model slug from provider and model IDs.
- */
const toModelSlug = (providerID: string, modelID: string): string => {
const provider = toGitSafeSlug(providerID);
const model = toGitSafeSlug(modelID);
return `${provider}-${model}`.substring(0, 60);
};
-/**
- * Seed name for worktree creation.
- * Uses slashes for readability; create payload will slugify.
- */
const generateWorktreeNameSeed = (groupSlug: string, modelSlug: string): string => {
return `${groupSlug}/${modelSlug}`;
};
@@ -43,16 +32,11 @@ const generateWorktreeNameSeed = (groupSlug: string, modelSlug: string): string
const resolveActiveProject = (): ProjectRef | null => {
const projectsState = useProjectsStore.getState();
const activeProjectId = projectsState.activeProjectId;
- if (!activeProjectId) {
- return null;
- }
+ if (!activeProjectId) return null;
const project = projectsState.projects.find((entry) => entry.id === activeProjectId);
- if (project?.path) {
- return { id: project.id, path: project.path };
- }
+ if (project?.path) return { id: project.id, path: project.path };
- // Fall back to current directory only when active project is missing.
const currentDirectory = useDirectoryStore.getState().currentDirectory ?? null;
if (currentDirectory && currentDirectory.trim().length > 0) {
const normalized = currentDirectory.replace(/\\/g, '/').replace(/\/+$/, '') || currentDirectory;
@@ -68,7 +52,6 @@ interface MultiRunState {
}
interface MultiRunActions {
- /** Create worktrees/sessions and immediately start all runs */
createMultiRun: (params: CreateMultiRunParams) => Promise;
clearError: () => void;
}
@@ -83,27 +66,31 @@ export const useMultiRunStore = create()(
createMultiRun: async (params: CreateMultiRunParams) => {
const groupName = params.name.trim();
- const prompt = params.prompt.trim();
- const { models, agent, files, setupCommands } = params;
+ const { groups, agent, files, setupCommands } = params;
if (!groupName) {
set({ error: 'Group name is required' });
return null;
}
- if (!prompt) {
- set({ error: 'Prompt is required' });
+ if (!groups || groups.length === 0) {
+ set({ error: 'At least one run group is required' });
return null;
}
- if (models.length < 1) {
- set({ error: 'Select at least 1 model' });
- return null;
- }
-
- if (models.length > 5) {
- set({ error: 'Maximum 5 models allowed' });
- return null;
+ for (let gi = 0; gi < groups.length; gi++) {
+ if (!groups[gi].prompt.trim()) {
+ set({ error: `Group ${gi + 1}: prompt is required` });
+ return null;
+ }
+ if (groups[gi].models.length < 1) {
+ set({ error: `Group ${gi + 1}: select at least 1 model` });
+ return null;
+ }
+ if (groups[gi].models.length > 5) {
+ set({ error: `Group ${gi + 1}: maximum 5 models allowed` });
+ return null;
+ }
}
set({ isLoading: true, error: null });
@@ -130,94 +117,101 @@ export const useMultiRunStore = create()(
providerID: string;
modelID: string;
variant?: string;
+ prompt: string;
}> = [];
const commandsToRun = setupCommands?.filter((cmd) => cmd.trim().length > 0) ?? [];
- // Count occurrences of each model to handle duplicates
- const modelCounts = new Map();
- for (const model of models) {
- const key = `${model.providerID}:${model.modelID}`;
- modelCounts.set(key, (modelCounts.get(key) || 0) + 1);
- }
+ for (let gi = 0; gi < groups.length; gi++) {
+ const group = groups[gi];
+ const prompt = group.prompt;
- // Track current index per model during iteration
- const modelIndexes = new Map();
+ const modelCounts = new Map();
+ for (const model of group.models) {
+ const key = `${model.providerID}:${model.modelID}`;
+ modelCounts.set(key, (modelCounts.get(key) || 0) + 1);
+ }
- // 1) Create isolated worktrees for Git projects, or same-directory sessions otherwise.
- for (const model of models) {
- const key = `${model.providerID}:${model.modelID}`;
- const count = modelCounts.get(key) || 1;
- const index = (modelIndexes.get(key) || 0) + 1;
- modelIndexes.set(key, index);
+ const modelIndexes = new Map();
+
+ for (const model of group.models) {
+ const key = `${model.providerID}:${model.modelID}`;
+ const count = modelCounts.get(key) || 1;
+ const index = (modelIndexes.get(key) || 0) + 1;
+ modelIndexes.set(key, index);
+
+ const modelSlug = toModelSlug(model.providerID, model.modelID);
+ const groupPart = groups.length > 1 ? `g${gi + 1}` : '';
+ const modelPart = count > 1
+ ? generateWorktreeNameSeed(groupSlug, `${modelSlug}/${index}`)
+ : generateWorktreeNameSeed(groupSlug, modelSlug);
+ const preferredName = groupPart
+ ? `${groupPart}/${modelPart}`
+ : modelPart;
- const modelSlug = toModelSlug(model.providerID, model.modelID);
- // Append index only when same model is selected multiple times
- const preferredName = count > 1
- ? generateWorktreeNameSeed(groupSlug, `${modelSlug}/${index}`)
- : generateWorktreeNameSeed(groupSlug, modelSlug);
- try {
- // Session title format: groupSlug/provider/model (or groupSlug/provider/model/index for duplicates)
const sessionTitle = count > 1
- ? `${groupSlug}/${model.providerID}/${model.modelID}/${index}`
- : `${groupSlug}/${model.providerID}/${model.modelID}`;
+ ? `${groupSlug}/${groupPart}/${model.providerID}/${model.modelID}/${index}`
+ : groupPart
+ ? `${groupSlug}/${groupPart}/${model.providerID}/${model.modelID}`
+ : `${groupSlug}/${model.providerID}/${model.modelID}`;
+
+ try {
+ if (!shouldIsolateRuns) {
+ const session = await opencodeClient.withDirectory(
+ directory,
+ () => opencodeClient.createSession({ title: sessionTitle }),
+ );
+
+ createdRuns.push({
+ sessionId: session.id,
+ worktreePath: directory,
+ providerID: model.providerID,
+ modelID: model.modelID,
+ variant: model.variant,
+ prompt,
+ });
+ continue;
+ }
+
+ const worktreeMetadata = await createWorktreeWithDefaults(project, {
+ preferredName,
+ mode: 'new',
+ branchName: preferredName,
+ worktreeName: preferredName,
+ startRef: params.worktreeBaseBranch || 'HEAD',
+ setupCommands: commandsToRun,
+ }, {
+ resolvedRootTrackingRemote: rootTrackingRemote,
+ });
+
+ const enrichedMetadata = {
+ ...worktreeMetadata,
+ createdFromBranch: rootBranch,
+ kind: 'standard' as const,
+ };
- if (!shouldIsolateRuns) {
const session = await opencodeClient.withDirectory(
- directory,
- () => opencodeClient.createSession({ title: sessionTitle })
+ worktreeMetadata.path,
+ () => opencodeClient.createSession({ title: sessionTitle }),
);
+ useSessionUIStore.getState().setWorktreeMetadata(session.id, enrichedMetadata);
+
createdRuns.push({
sessionId: session.id,
- worktreePath: directory,
+ worktreePath: worktreeMetadata.path,
providerID: model.providerID,
modelID: model.modelID,
variant: model.variant,
+ prompt,
});
- continue;
+ } catch (err) {
+ console.warn('[MultiRun] Failed to create session:', err);
}
-
- const worktreeMetadata = await createWorktreeWithDefaults(project, {
- preferredName,
- mode: 'new',
- branchName: preferredName,
- worktreeName: preferredName,
- startRef: params.worktreeBaseBranch || 'HEAD',
- setupCommands: commandsToRun,
- }, {
- resolvedRootTrackingRemote: rootTrackingRemote,
- });
-
- const enrichedMetadata = {
- ...worktreeMetadata,
- createdFromBranch: rootBranch,
- kind: 'standard' as const,
- };
-
- const session = await opencodeClient.withDirectory(
- worktreeMetadata.path,
- () => opencodeClient.createSession({ title: sessionTitle })
- );
-
- useSessionUIStore.getState().setWorktreeMetadata(session.id, enrichedMetadata);
-
- createdRuns.push({
- sessionId: session.id,
- worktreePath: worktreeMetadata.path,
- providerID: model.providerID,
- modelID: model.modelID,
- variant: model.variant,
- });
-
- } catch (error) {
- // Best-effort: allow partial success
- console.warn('[MultiRun] Failed to create session:', error);
}
}
- // Save setup commands to config if any were provided (for future worktree creation)
- const commandsToSave = setupCommands?.filter(cmd => cmd.trim().length > 0) ?? [];
+ const commandsToSave = setupCommands?.filter((cmd) => cmd.trim().length > 0) ?? [];
if (commandsToSave.length > 0) {
saveWorktreeSetupCommands(project, commandsToSave).catch(() => {
console.warn('[MultiRun] Failed to save worktree setup commands');
@@ -232,9 +226,6 @@ export const useMultiRunStore = create()(
return null;
}
- // 2) Start all runs with the same prompt.
- // IMPORTANT: do not await model/agent execution here; only worktree + session creation.
- // Convert files to the format expected by sendMessage
const filesForMessage = files?.map((f) => ({
type: 'file' as const,
mime: f.mime,
@@ -242,33 +233,29 @@ export const useMultiRunStore = create()(
url: f.url,
}));
- // Session list refresh handled by sync bootstrap via SSE events
-
- // Setup commands run via SDK worktree startCommand.
-
void (async () => {
try {
await Promise.allSettled(
createdRuns.map(async (run) => {
try {
- await opencodeClient.withDirectory(run.worktreePath, () =>
- opencodeClient.sendMessage({
- id: run.sessionId,
- providerID: run.providerID,
- modelID: run.modelID,
- variant: run.variant,
- text: prompt,
- agent,
- files: filesForMessage,
- })
- );
- } catch (error) {
- console.warn('[MultiRun] Failed to start run:', error);
+ await opencodeClient.withDirectory(run.worktreePath, () =>
+ opencodeClient.sendMessage({
+ id: run.sessionId,
+ providerID: run.providerID,
+ modelID: run.modelID,
+ variant: run.variant,
+ text: run.prompt,
+ agent,
+ files: filesForMessage,
+ }),
+ );
+ } catch (err) {
+ console.warn('[MultiRun] Failed to start run:', err);
}
- })
+ }),
);
- } catch (error) {
- console.warn('[MultiRun] Failed to start runs:', error);
+ } catch (err) {
+ console.warn('[MultiRun] Failed to start runs:', err);
}
})();
@@ -287,6 +274,6 @@ export const useMultiRunStore = create()(
set({ error: null });
},
}),
- { name: 'multirun-store' }
- )
+ { name: 'multirun-store' },
+ ),
);
diff --git a/packages/ui/src/stores/usePromptTemplatesStore.ts b/packages/ui/src/stores/usePromptTemplatesStore.ts
new file mode 100644
index 00000000..aa8a8003
--- /dev/null
+++ b/packages/ui/src/stores/usePromptTemplatesStore.ts
@@ -0,0 +1,138 @@
+import { create } from 'zustand';
+import { devtools } from 'zustand/middleware';
+import type { PromptTemplate } from '@/types/prompt-template';
+
+interface PromptTemplatesStore {
+ templates: PromptTemplate[];
+ isLoading: boolean;
+ selectedTemplateId: string | null;
+
+ setSelectedTemplate: (id: string | null) => void;
+ loadTemplates: () => Promise;
+ createTemplate: (id: string, name: string, body: string) => Promise;
+ updateTemplate: (id: string, updates: { name?: string; body?: string }) => Promise;
+ deleteTemplate: (id: string) => Promise;
+ getTemplateById: (id: string) => PromptTemplate | undefined;
+}
+
+const TEMPLATES_LOAD_CACHE_TTL_MS = 5000;
+let lastLoadedAt = 0;
+let loadInFlight: Promise | null = null;
+
+export const usePromptTemplatesStore = create()(
+ devtools(
+ (set, get) => ({
+ templates: [],
+ isLoading: false,
+ selectedTemplateId: null,
+
+ setSelectedTemplate: (id: string | null) => {
+ set({ selectedTemplateId: id });
+ },
+
+ loadTemplates: async () => {
+ const now = Date.now();
+ if (get().templates.length > 0 && now - lastLoadedAt < TEMPLATES_LOAD_CACHE_TTL_MS) {
+ return true;
+ }
+
+ if (loadInFlight) {
+ return loadInFlight;
+ }
+
+ const request = (async () => {
+ set({ isLoading: true });
+ try {
+ const response = await fetch('/api/config/prompt-templates', {
+ headers: { 'Cache-Control': 'no-cache' },
+ });
+ if (!response.ok) {
+ throw new Error('Failed to load prompt templates');
+ }
+ const templates: PromptTemplate[] = await response.json();
+ set({ templates, isLoading: false });
+ lastLoadedAt = Date.now();
+ return true;
+ } catch (error) {
+ console.error('[PromptTemplatesStore] Failed to load:', error);
+ set({ isLoading: false });
+ return false;
+ }
+ })();
+
+ loadInFlight = request;
+ try {
+ return await request;
+ } finally {
+ loadInFlight = null;
+ }
+ },
+
+ createTemplate: async (id: string, name: string, body: string) => {
+ try {
+ const response = await fetch(`/api/config/prompt-templates/${encodeURIComponent(id)}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name, body }),
+ });
+ if (!response.ok) {
+ const payload = await response.json().catch(() => null);
+ throw new Error(payload?.error || 'Failed to create prompt template');
+ }
+ lastLoadedAt = 0;
+ await get().loadTemplates();
+ return true;
+ } catch (error) {
+ console.error('[PromptTemplatesStore] Failed to create:', error);
+ return false;
+ }
+ },
+
+ updateTemplate: async (id: string, updates: { name?: string; body?: string }) => {
+ try {
+ const response = await fetch(`/api/config/prompt-templates/${encodeURIComponent(id)}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(updates),
+ });
+ if (!response.ok) {
+ const payload = await response.json().catch(() => null);
+ throw new Error(payload?.error || 'Failed to update prompt template');
+ }
+ lastLoadedAt = 0;
+ await get().loadTemplates();
+ return true;
+ } catch (error) {
+ console.error('[PromptTemplatesStore] Failed to update:', error);
+ return false;
+ }
+ },
+
+ deleteTemplate: async (id: string) => {
+ try {
+ const response = await fetch(`/api/config/prompt-templates/${encodeURIComponent(id)}`, {
+ method: 'DELETE',
+ });
+ if (!response.ok) {
+ const payload = await response.json().catch(() => null);
+ throw new Error(payload?.error || 'Failed to delete prompt template');
+ }
+ if (get().selectedTemplateId === id) {
+ set({ selectedTemplateId: null });
+ }
+ lastLoadedAt = 0;
+ await get().loadTemplates();
+ return true;
+ } catch (error) {
+ console.error('[PromptTemplatesStore] Failed to delete:', error);
+ return false;
+ }
+ },
+
+ getTemplateById: (id: string) => {
+ return get().templates.find((t) => t.id === id);
+ },
+ }),
+ { name: 'prompt-templates-store' },
+ ),
+);
diff --git a/packages/ui/src/types/multirun.ts b/packages/ui/src/types/multirun.ts
index 8fa2a051..2e99d61e 100644
--- a/packages/ui/src/types/multirun.ts
+++ b/packages/ui/src/types/multirun.ts
@@ -1,10 +1,3 @@
-/**
- * Multi-Run Types
- *
- * Multi-Run starts the same prompt against multiple models in parallel,
- * each in its own git worktree and OpenCode session.
- */
-
export interface MultiRunModelSelection {
providerID: string;
modelID: string;
@@ -13,38 +6,29 @@ export interface MultiRunModelSelection {
}
export interface MultiRunFileAttachment {
- /** MIME type of the file */
mime: string;
- /** Original filename */
filename: string;
- /** Data URL (base64 encoded) */
url: string;
}
-export interface CreateMultiRunParams {
- /** Group name used for worktree directory and branch naming */
- name: string;
- /** Prompt sent to all sessions */
+export interface MultiRunGroup {
prompt: string;
- /** Models to run against (must have at least 2) */
models: MultiRunModelSelection[];
- /** Optional agent to use for all runs */
+ templateId?: string;
+}
+
+export interface CreateMultiRunParams {
+ name: string;
+ groups: MultiRunGroup[];
agent?: string;
- /** Base branch for new branches (defaults to `HEAD`). */
worktreeBaseBranch?: string;
- /** Whether Git projects should isolate each run in its own worktree. */
isolateRuns?: boolean;
- /** Files to attach to all runs */
files?: MultiRunFileAttachment[];
- /** Setup commands to run in each new worktree after creation */
setupCommands?: string[];
}
export interface CreateMultiRunResult {
- /** Canonical group slug used in session titles */
groupSlug: string;
- /** Session IDs created successfully (in selection order) */
sessionIds: string[];
- /** First successfully created session ID, if any */
firstSessionId: string | null;
}
diff --git a/packages/ui/src/types/prompt-template.ts b/packages/ui/src/types/prompt-template.ts
new file mode 100644
index 00000000..b8f11788
--- /dev/null
+++ b/packages/ui/src/types/prompt-template.ts
@@ -0,0 +1,6 @@
+export interface PromptTemplate {
+ id: string;
+ name: string;
+ body: string;
+ isDefault: boolean;
+}
diff --git a/packages/web/server/lib/opencode/config-entity-routes.js b/packages/web/server/lib/opencode/config-entity-routes.js
index c790bc20..e48bac58 100644
--- a/packages/web/server/lib/opencode/config-entity-routes.js
+++ b/packages/web/server/lib/opencode/config-entity-routes.js
@@ -18,6 +18,11 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
createMcpConfig,
updateMcpConfig,
deleteMcpConfig,
+ listPromptTemplates,
+ getPromptTemplate,
+ createPromptTemplate,
+ updatePromptTemplate,
+ deletePromptTemplate,
} = dependencies;
const completeMcpMutation = async (res, action, name, applyChange) => {
@@ -367,4 +372,95 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
res.status(500).json({ error: error.message || 'Failed to delete command' });
}
});
+
+ app.get('/api/config/prompt-templates', async (req, res) => {
+ try {
+ const { directory, error } = await resolveOptionalProjectDirectory(req);
+ if (error) {
+ return res.status(400).json({ error });
+ }
+ const templates = listPromptTemplates(directory);
+ res.json(templates);
+ } catch (error) {
+ console.error('[API:GET /api/config/prompt-templates] Failed:', error);
+ res.status(500).json({ error: error.message || 'Failed to list prompt templates' });
+ }
+ });
+
+ app.get('/api/config/prompt-templates/:id', async (req, res) => {
+ try {
+ const id = req.params.id;
+ const { directory, error } = await resolveOptionalProjectDirectory(req);
+ if (error) {
+ return res.status(400).json({ error });
+ }
+ const template = getPromptTemplate(id, directory);
+ if (!template) {
+ return res.status(404).json({ error: `Prompt template "${id}" not found` });
+ }
+ res.json(template);
+ } catch (error) {
+ console.error('[API:GET /api/config/prompt-templates/:id] Failed:', error);
+ res.status(500).json({ error: error.message || 'Failed to get prompt template' });
+ }
+ });
+
+ app.post('/api/config/prompt-templates/:id', async (req, res) => {
+ try {
+ const id = req.params.id;
+ const config = req.body || {};
+ const { directory, error } = await resolveOptionalProjectDirectory(req);
+ if (error) {
+ return res.status(400).json({ error });
+ }
+ console.log(`[API:POST /api/config/prompt-templates] Creating prompt template: ${id}`);
+ const template = createPromptTemplate(id, config, directory);
+ res.json({ success: true, template });
+ } catch (error) {
+ console.error('[API:POST /api/config/prompt-templates/:id] Failed:', error);
+ if (error.message?.includes('already exists')) {
+ return res.status(409).json({ error: error.message });
+ }
+ res.status(500).json({ error: error.message || 'Failed to create prompt template' });
+ }
+ });
+
+ app.patch('/api/config/prompt-templates/:id', async (req, res) => {
+ try {
+ const id = req.params.id;
+ const updates = req.body;
+ const { directory, error } = await resolveOptionalProjectDirectory(req);
+ if (error) {
+ return res.status(400).json({ error });
+ }
+ console.log(`[API:PATCH /api/config/prompt-templates] Updating prompt template: ${id}`);
+ const template = updatePromptTemplate(id, updates, directory);
+ res.json({ success: true, template });
+ } catch (error) {
+ console.error('[API:PATCH /api/config/prompt-templates/:id] Failed:', error);
+ if (error.message?.includes('not found')) {
+ return res.status(404).json({ error: error.message });
+ }
+ res.status(500).json({ error: error.message || 'Failed to update prompt template' });
+ }
+ });
+
+ app.delete('/api/config/prompt-templates/:id', async (req, res) => {
+ try {
+ const id = req.params.id;
+ const { directory, error } = await resolveOptionalProjectDirectory(req);
+ if (error) {
+ return res.status(400).json({ error });
+ }
+ console.log(`[API:DELETE /api/config/prompt-templates] Deleting prompt template: ${id}`);
+ deletePromptTemplate(id, directory);
+ res.json({ success: true });
+ } catch (error) {
+ console.error('[API:DELETE /api/config/prompt-templates/:id] Failed:', error);
+ if (error.message?.includes('not found')) {
+ return res.status(404).json({ error: error.message });
+ }
+ res.status(500).json({ error: error.message || 'Failed to delete prompt template' });
+ }
+ });
};
diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js
index 2d96d213..222becec 100644
--- a/packages/web/server/lib/opencode/feature-routes-runtime.js
+++ b/packages/web/server/lib/opencode/feature-routes-runtime.js
@@ -123,6 +123,11 @@ export const createFeatureRoutesRuntime = (dependencies) => {
createMcpConfig,
updateMcpConfig,
deleteMcpConfig,
+ listPromptTemplates,
+ getPromptTemplate,
+ createPromptTemplate,
+ updatePromptTemplate,
+ deletePromptTemplate,
} = await import('./index.js');
registerConfigEntityRoutes(app, {
@@ -144,6 +149,11 @@ export const createFeatureRoutesRuntime = (dependencies) => {
createMcpConfig,
updateMcpConfig,
deleteMcpConfig,
+ listPromptTemplates,
+ getPromptTemplate,
+ createPromptTemplate,
+ updatePromptTemplate,
+ deletePromptTemplate,
});
const {
diff --git a/packages/web/server/lib/opencode/index.js b/packages/web/server/lib/opencode/index.js
index fe08e310..9a0ca400 100644
--- a/packages/web/server/lib/opencode/index.js
+++ b/packages/web/server/lib/opencode/index.js
@@ -64,3 +64,12 @@ export {
updateMcpConfig,
deleteMcpConfig,
} from './mcp.js';
+
+export {
+ listPromptTemplates,
+ getPromptTemplate,
+ createPromptTemplate,
+ updatePromptTemplate,
+ deletePromptTemplate,
+ slugify as slugifyPromptTemplate,
+} from './prompt-templates.js';
diff --git a/packages/web/server/lib/opencode/prompt-templates.js b/packages/web/server/lib/opencode/prompt-templates.js
new file mode 100644
index 00000000..7892877f
--- /dev/null
+++ b/packages/web/server/lib/opencode/prompt-templates.js
@@ -0,0 +1,159 @@
+import {
+ readConfigLayers,
+ writeConfig,
+ getJsonWriteTarget,
+ CONFIG_FILE,
+} from './shared.js';
+
+const SECTION_KEY = 'promptTemplates';
+
+const DEFAULT_TEMPLATES = {
+ simple: {
+ name: 'Simple',
+ body: 'Implement this task using the simplest possible approach. Prefer readability and straightforward solutions over clever abstractions. Keep the code easy to understand and maintain.',
+ isDefault: true,
+ },
+ fast: {
+ name: 'Fast',
+ body: 'Implement this task as quickly as possible. Optimize for speed of development. Use the most direct path to a working solution. Favor existing libraries and proven patterns.',
+ isDefault: true,
+ },
+ 'memory-efficient': {
+ name: 'Memory Efficient',
+ body: 'Implement this task with memory efficiency in mind. Minimize memory allocations, use streaming where possible, avoid holding large data structures in memory, and prefer lazy evaluation.',
+ isDefault: true,
+ },
+ 'cpu-efficient': {
+ name: 'CPU Efficient',
+ body: 'Implement this task with CPU efficiency in mind. Optimize algorithms, minimize unnecessary computations, use efficient data structures, and avoid redundant work.',
+ isDefault: true,
+ },
+ 'tests-first': {
+ name: 'Tests First',
+ body: 'Implement this task using a test-driven approach. Write tests first, then implement the minimum code to pass them. Ensure comprehensive test coverage including edge cases.',
+ isDefault: true,
+ },
+ 'spec-first': {
+ name: 'Spec First',
+ body: 'Implement this task by first creating a detailed specification, then implementing according to the spec. Start by documenting the requirements, interfaces, and expected behavior before writing any implementation code.',
+ isDefault: true,
+ },
+};
+
+function slugify(name) {
+ return name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .substring(0, 60);
+}
+
+function ensureDefaults(templates) {
+ if (!templates || typeof templates !== 'object') {
+ return { ...DEFAULT_TEMPLATES };
+ }
+
+ let changed = false;
+ const result = { ...templates };
+
+ for (const [id, template] of Object.entries(DEFAULT_TEMPLATES)) {
+ if (!(id in result)) {
+ result[id] = { ...template };
+ changed = true;
+ }
+ }
+
+ return changed ? result : templates;
+}
+
+function readTemplatesFromConfig(workingDirectory) {
+ const layers = readConfigLayers(workingDirectory);
+ const merged = layers.mergedConfig || {};
+ const raw = merged[SECTION_KEY];
+
+ if (!raw || typeof raw !== 'object') {
+ return ensureDefaults(null);
+ }
+
+ return ensureDefaults(raw);
+}
+
+function writeTemplatesToConfig(templates, workingDirectory) {
+ const layers = readConfigLayers(workingDirectory);
+ const target = getJsonWriteTarget(layers, 'user');
+ const config = { ...target.config };
+ config[SECTION_KEY] = templates;
+ writeConfig(config, target.path || CONFIG_FILE);
+}
+
+export function listPromptTemplates(workingDirectory) {
+ const templates = readTemplatesFromConfig(workingDirectory);
+ return Object.entries(templates).map(([id, value]) => ({
+ id,
+ name: value.name || id,
+ body: value.body || '',
+ isDefault: value.isDefault === true,
+ }));
+}
+
+export function getPromptTemplate(id, workingDirectory) {
+ const templates = readTemplatesFromConfig(workingDirectory);
+ const entry = templates[id];
+ if (!entry) {
+ return null;
+ }
+ return {
+ id,
+ name: entry.name || id,
+ body: entry.body || '',
+ isDefault: entry.isDefault === true,
+ };
+}
+
+export function createPromptTemplate(id, config, workingDirectory) {
+ const templates = readTemplatesFromConfig(workingDirectory);
+
+ if (templates[id]) {
+ throw new Error(`Prompt template "${id}" already exists`);
+ }
+
+ templates[id] = {
+ name: config.name || id,
+ body: config.body || '',
+ isDefault: false,
+ };
+
+ writeTemplatesToConfig(templates, workingDirectory);
+ return getPromptTemplate(id, workingDirectory);
+}
+
+export function updatePromptTemplate(id, updates, workingDirectory) {
+ const templates = readTemplatesFromConfig(workingDirectory);
+ const existing = templates[id];
+
+ if (!existing) {
+ throw new Error(`Prompt template "${id}" not found`);
+ }
+
+ templates[id] = {
+ ...existing,
+ ...(updates.name !== undefined ? { name: updates.name } : {}),
+ ...(updates.body !== undefined ? { body: updates.body } : {}),
+ };
+
+ writeTemplatesToConfig(templates, workingDirectory);
+ return getPromptTemplate(id, workingDirectory);
+}
+
+export function deletePromptTemplate(id, workingDirectory) {
+ const templates = readTemplatesFromConfig(workingDirectory);
+
+ if (!templates[id]) {
+ throw new Error(`Prompt template "${id}" not found`);
+ }
+
+ delete templates[id];
+ writeTemplatesToConfig(templates, workingDirectory);
+}
+
+export { slugify };