From 0be1980362422993efe8571e68cc89cb34f694a2 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 2 Feb 2026 15:03:06 +0000 Subject: [PATCH] feat: add saved prompts feature Implements the Saved Prompts feature as requested in issue #11151. Features: - Create, edit, and delete saved prompts - Associate prompts with specific API configurations - Auto-switch API config when using a prompt - Import/export saved prompts as JSON - Bookmark icon dropdown near chat input for quick access - Full CRUD management in Settings Files added/modified: - packages/types/src/saved-prompt.ts: Zod schema and types - packages/types/src/global-settings.ts: Add savedPrompts to schema - packages/types/src/vscode-extension-host.ts: Message types - src/core/webview/webviewMessageHandler.ts: CRUD handlers - webview-ui/src/components/settings/SavedPromptsSettings.tsx: Settings UI - webview-ui/src/components/chat/SavedPromptsDropdown.tsx: Chat dropdown - webview-ui/src/context/ExtensionStateContext.tsx: State management - webview-ui/src/i18n/locales/en/settings.json: Translations - webview-ui/src/i18n/locales/en/chat.json: Translations Closes #11151 --- packages/types/src/global-settings.ts | 6 + packages/types/src/index.ts | 1 + packages/types/src/saved-prompt.ts | 82 ++++ packages/types/src/vscode-extension-host.ts | 17 + src/core/webview/webviewMessageHandler.ts | 193 ++++++++++ .../components/chat/SavedPromptsDropdown.tsx | 109 ++++++ .../settings/SavedPromptsSettings.tsx | 353 ++++++++++++++++++ .../src/context/ExtensionStateContext.tsx | 6 + webview-ui/src/i18n/locales/en/chat.json | 5 + webview-ui/src/i18n/locales/en/settings.json | 26 ++ 10 files changed, 798 insertions(+) create mode 100644 packages/types/src/saved-prompt.ts create mode 100644 webview-ui/src/components/chat/SavedPromptsDropdown.tsx create mode 100644 webview-ui/src/components/settings/SavedPromptsSettings.tsx diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 11b9fe148d1..01d62145f55 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -14,6 +14,7 @@ import { telemetrySettingsSchema } from "./telemetry.js" import { modeConfigSchema } from "./mode.js" import { customModePromptsSchema, customSupportPromptsSchema } from "./mode.js" import { languagesSchema } from "./vscode.js" +import { savedPromptSchema } from "./saved-prompt.js" /** * Default delay in milliseconds after writes to allow diagnostics to detect potential problems. @@ -232,6 +233,11 @@ export const globalSettingsSchema = z.object({ * @default true */ showWorktreesInHomeScreen: z.boolean().optional(), + + /** + * User-saved prompts that can be quickly inserted into chat + */ + savedPrompts: z.array(savedPromptSchema).optional(), }) export type GlobalSettings = z.infer diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index ad012b3761f..51271e5c792 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -19,6 +19,7 @@ export * from "./message.js" export * from "./mode.js" export * from "./model.js" export * from "./provider-settings.js" +export * from "./saved-prompt.js" export * from "./skills.js" export * from "./task.js" export * from "./todo.js" diff --git a/packages/types/src/saved-prompt.ts b/packages/types/src/saved-prompt.ts new file mode 100644 index 00000000000..8fbdcec6372 --- /dev/null +++ b/packages/types/src/saved-prompt.ts @@ -0,0 +1,82 @@ +import { z } from "zod" + +/** + * SavedPrompt + * + * Represents a user-saved prompt that can be quickly inserted into the chat input. + * Users can optionally associate a prompt with a specific API configuration, + * which will be automatically selected when the prompt is used. + */ +export const savedPromptSchema = z.object({ + /** + * Unique identifier for the saved prompt + */ + id: z.string(), + + /** + * Display name for the prompt (used in UI and slash commands) + */ + name: z.string(), + + /** + * The actual prompt content to be inserted + */ + content: z.string(), + + /** + * Optional description for the prompt + */ + description: z.string().optional(), + + /** + * Optional API configuration ID to auto-select when using this prompt + */ + apiConfigId: z.string().optional(), + + /** + * Timestamp when the prompt was created + */ + createdAt: z.number(), + + /** + * Timestamp when the prompt was last updated + */ + updatedAt: z.number(), +}) + +export type SavedPrompt = z.infer + +/** + * SavedPromptCreate + * + * Payload for creating a new saved prompt (without id and timestamps) + */ +export const savedPromptCreateSchema = savedPromptSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, +}) + +export type SavedPromptCreate = z.infer + +/** + * SavedPromptUpdate + * + * Payload for updating an existing saved prompt + */ +export const savedPromptUpdateSchema = savedPromptSchema.partial().required({ id: true }) + +export type SavedPromptUpdate = z.infer + +/** + * SavedPromptsExport + * + * Format for exporting/importing saved prompts + */ +export const savedPromptsExportSchema = z.object({ + version: z.literal(1), + exportedAt: z.number(), + prompts: z.array(savedPromptSchema), +}) + +export type SavedPromptsExport = z.infer diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 21bc59092a1..5c486a9bb3e 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -22,6 +22,7 @@ import type { SkillMetadata } from "./skills.js" import type { ModelRecord, RouterModels } from "./model.js" import type { OpenAiCodexRateLimitInfo } from "./providers/openai-codex-rate-limits.js" import type { WorktreeIncludeStatus } from "./worktree.js" +import type { SavedPrompt, SavedPromptCreate, SavedPromptUpdate, SavedPromptsExport } from "./saved-prompt.js" /** * ExtensionMessage @@ -109,6 +110,7 @@ export interface ExtensionMessage { | "branchWorktreeIncludeResult" | "folderSelected" | "skills" + | "savedPrompts" text?: string payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any checkpointWarning?: { @@ -334,6 +336,7 @@ export type ExtensionState = Pick< | "maxGitStatusFiles" | "requestDelaySeconds" | "showWorktreesInHomeScreen" + | "savedPrompts" > & { version: string clineMessages: ClineMessage[] @@ -606,6 +609,14 @@ export interface WebviewMessage { | "deleteSkill" | "moveSkill" | "openSkillFile" + // Saved prompts messages + | "requestSavedPrompts" + | "createSavedPrompt" + | "updateSavedPrompt" + | "deleteSavedPrompt" + | "exportSavedPrompts" + | "importSavedPrompts" + | "useSavedPrompt" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" @@ -707,6 +718,12 @@ export interface WebviewMessage { worktreeForce?: boolean worktreeNewWindow?: boolean worktreeIncludeContent?: string + // Saved prompts properties + savedPrompt?: SavedPrompt + savedPromptCreate?: SavedPromptCreate + savedPromptUpdate?: SavedPromptUpdate + savedPromptsExport?: SavedPromptsExport + savedPromptId?: string } export interface RequestOpenAiCodexRateLimitsMessage { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 73ca3c60bf6..818ac66be04 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -3167,6 +3167,199 @@ export const webviewMessageHandler = async ( } break } + + /** + * Saved Prompts + */ + + case "requestSavedPrompts": { + const savedPrompts = getGlobalState("savedPrompts") || [] + await provider.postMessageToWebview({ + type: "savedPrompts", + savedPrompts, + }) + break + } + + case "createSavedPrompt": { + try { + const promptData = message.savedPromptCreate + if (!promptData) { + provider.log("Missing savedPromptCreate data") + break + } + + const savedPrompts = getGlobalState("savedPrompts") || [] + const newPrompt = { + ...promptData, + id: `prompt-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + createdAt: Date.now(), + updatedAt: Date.now(), + } + + await updateGlobalState("savedPrompts", [...savedPrompts, newPrompt]) + await provider.postMessageToWebview({ + type: "savedPrompts", + savedPrompts: [...savedPrompts, newPrompt], + }) + } catch (error) { + provider.log(`Error creating saved prompt: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) + } + break + } + + case "updateSavedPrompt": { + try { + const promptUpdate = message.savedPromptUpdate + if (!promptUpdate?.id) { + provider.log("Missing savedPromptUpdate data or id") + break + } + + const savedPrompts = getGlobalState("savedPrompts") || [] + const updatedPrompts = savedPrompts.map((prompt: any) => + prompt.id === promptUpdate.id + ? { ...prompt, ...promptUpdate, updatedAt: Date.now() } + : prompt, + ) + + await updateGlobalState("savedPrompts", updatedPrompts) + await provider.postMessageToWebview({ + type: "savedPrompts", + savedPrompts: updatedPrompts, + }) + } catch (error) { + provider.log(`Error updating saved prompt: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) + } + break + } + + case "deleteSavedPrompt": { + try { + const promptId = message.savedPromptId + if (!promptId) { + provider.log("Missing savedPromptId") + break + } + + const savedPrompts = getGlobalState("savedPrompts") || [] + const filteredPrompts = savedPrompts.filter((prompt: any) => prompt.id !== promptId) + + await updateGlobalState("savedPrompts", filteredPrompts) + await provider.postMessageToWebview({ + type: "savedPrompts", + savedPrompts: filteredPrompts, + }) + } catch (error) { + provider.log(`Error deleting saved prompt: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) + } + break + } + + case "exportSavedPrompts": { + try { + const savedPrompts = getGlobalState("savedPrompts") || [] + const exportData = { + version: 1 as const, + exportedAt: Date.now(), + prompts: savedPrompts, + } + + const defaultUri = await resolveDefaultSaveUri(provider, "savedPrompts", "json", "lastSavedPromptsExportPath") + const saveUri = await vscode.window.showSaveDialog({ + defaultUri, + filters: { JSON: ["json"] }, + title: t("common:savedPrompts.exportTitle"), + }) + + if (saveUri) { + await fs.writeFile(saveUri.fsPath, JSON.stringify(exportData, null, 2), "utf8") + vscode.window.showInformationMessage(t("common:savedPrompts.exportSuccess")) + await saveLastExportPath(provider, "lastSavedPromptsExportPath", saveUri.fsPath) + } + } catch (error) { + provider.log(`Error exporting saved prompts: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) + vscode.window.showErrorMessage(t("common:savedPrompts.exportError")) + } + break + } + + case "importSavedPrompts": { + try { + const openUri = await vscode.window.showOpenDialog({ + canSelectMany: false, + filters: { JSON: ["json"] }, + title: t("common:savedPrompts.importTitle"), + }) + + if (openUri && openUri[0]) { + const content = await fs.readFile(openUri[0].fsPath, "utf8") + const importData = JSON.parse(content) + + // Validate the import data structure + if (!importData.version || !Array.isArray(importData.prompts)) { + vscode.window.showErrorMessage(t("common:savedPrompts.invalidFormat")) + break + } + + const existingPrompts = getGlobalState("savedPrompts") || [] + + // Merge prompts, avoiding duplicates by ID + const existingIds = new Set(existingPrompts.map((p: any) => p.id)) + const newPrompts = importData.prompts.filter((p: any) => !existingIds.has(p.id)) + const mergedPrompts = [...existingPrompts, ...newPrompts] + + await updateGlobalState("savedPrompts", mergedPrompts) + await provider.postMessageToWebview({ + type: "savedPrompts", + savedPrompts: mergedPrompts, + }) + + vscode.window.showInformationMessage( + t("common:savedPrompts.importSuccess", { count: newPrompts.length }), + ) + } + } catch (error) { + provider.log(`Error importing saved prompts: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) + vscode.window.showErrorMessage(t("common:savedPrompts.importError")) + } + break + } + + case "useSavedPrompt": { + try { + const promptId = message.savedPromptId + if (!promptId) { + provider.log("Missing savedPromptId") + break + } + + const savedPrompts = getGlobalState("savedPrompts") || [] + const prompt = savedPrompts.find((p: any) => p.id === promptId) + + if (prompt) { + // If the prompt has an associated API config, switch to it + if (prompt.apiConfigId) { + const listApiConfigMeta = getGlobalState("listApiConfigMeta") || [] + const targetConfig = listApiConfigMeta.find((config: any) => config.id === prompt.apiConfigId) + if (targetConfig) { + await updateGlobalState("currentApiConfigName", targetConfig.name) + await provider.postStateToWebview() + } + } + + // Insert the prompt content into the textarea (replacing current content) + await provider.postMessageToWebview({ + type: "insertTextIntoTextarea", + text: prompt.content, + }) + } + } catch (error) { + provider.log(`Error using saved prompt: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) + } + break + } + case "showMdmAuthRequiredNotification": { // Show notification that organization requires authentication vscode.window.showWarningMessage(t("common:mdm.info.organization_requires_auth")) diff --git a/webview-ui/src/components/chat/SavedPromptsDropdown.tsx b/webview-ui/src/components/chat/SavedPromptsDropdown.tsx new file mode 100644 index 00000000000..6cc4d7efda4 --- /dev/null +++ b/webview-ui/src/components/chat/SavedPromptsDropdown.tsx @@ -0,0 +1,109 @@ +import React, { useEffect, useRef, useState } from "react" +import { Bookmark, ChevronDown, Settings } from "lucide-react" + +import type { SavedPrompt } from "@roo-code/types" + +import { useAppTranslation } from "@/i18n/TranslationContext" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { + Button, + Popover, + PopoverContent, + PopoverTrigger, + StandardTooltip, +} from "@/components/ui" +import { vscode } from "@/utils/vscode" + +interface SavedPromptsDropdownProps { + disabled?: boolean +} + +export const SavedPromptsDropdown: React.FC = ({ disabled }) => { + const { t } = useAppTranslation() + const { savedPrompts, listApiConfigMeta } = useExtensionState() + const [open, setOpen] = useState(false) + + // Request saved prompts when component mounts + useEffect(() => { + vscode.postMessage({ type: "requestSavedPrompts" }) + }, []) + + const handleSelectPrompt = (prompt: SavedPrompt) => { + vscode.postMessage({ + type: "useSavedPrompt", + savedPromptId: prompt.id, + }) + setOpen(false) + } + + const handleOpenSettings = () => { + vscode.postMessage({ + type: "action", + action: "settingsButtonClicked", + }) + // Note: User will need to navigate to Saved Prompts settings manually + setOpen(false) + } + + const promptsList = savedPrompts || [] + + if (promptsList.length === 0) { + return null // Don't show the dropdown if there are no saved prompts + } + + return ( + + + + + + + +
+
+ {t("chat:savedPrompts.title")} +
+
+ {promptsList.map((prompt: SavedPrompt) => { + const apiConfig = listApiConfigMeta?.find( + (config) => config.id === prompt.apiConfigId, + ) + return ( + + ) + })} +
+
+ +
+
+
+
+ ) +} diff --git a/webview-ui/src/components/settings/SavedPromptsSettings.tsx b/webview-ui/src/components/settings/SavedPromptsSettings.tsx new file mode 100644 index 00000000000..a18def7eac1 --- /dev/null +++ b/webview-ui/src/components/settings/SavedPromptsSettings.tsx @@ -0,0 +1,353 @@ +import React, { useState, useEffect } from "react" +import { Plus, Trash2, Edit2, Download, Upload, Bookmark } from "lucide-react" +import { Trans } from "react-i18next" + +import type { SavedPrompt } from "@roo-code/types" + +import { useAppTranslation } from "@/i18n/TranslationContext" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Textarea, +} from "@/components/ui" +import { vscode } from "@/utils/vscode" + +import { SectionHeader } from "./SectionHeader" +import { Section } from "./Section" +import { SearchableSetting } from "./SearchableSetting" + +interface SavedPromptFormData { + name: string + content: string + description: string + apiConfigId: string +} + +const emptyFormData: SavedPromptFormData = { + name: "", + content: "", + description: "", + apiConfigId: "", +} + +export const SavedPromptsSettings: React.FC = () => { + const { t } = useAppTranslation() + const { savedPrompts, listApiConfigMeta } = useExtensionState() + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [promptToDelete, setPromptToDelete] = useState(null) + const [editDialogOpen, setEditDialogOpen] = useState(false) + const [editingPrompt, setEditingPrompt] = useState(null) + const [formData, setFormData] = useState(emptyFormData) + + // Request saved prompts when component mounts + useEffect(() => { + vscode.postMessage({ type: "requestSavedPrompts" }) + }, []) + + const handleDeleteClick = (prompt: SavedPrompt) => { + setPromptToDelete(prompt) + setDeleteDialogOpen(true) + } + + const handleDeleteConfirm = () => { + if (promptToDelete) { + vscode.postMessage({ + type: "deleteSavedPrompt", + savedPromptId: promptToDelete.id, + }) + setDeleteDialogOpen(false) + setPromptToDelete(null) + } + } + + const handleDeleteCancel = () => { + setDeleteDialogOpen(false) + setPromptToDelete(null) + } + + const handleEditClick = (prompt: SavedPrompt) => { + setEditingPrompt(prompt) + setFormData({ + name: prompt.name, + content: prompt.content, + description: prompt.description || "", + apiConfigId: prompt.apiConfigId || "", + }) + setEditDialogOpen(true) + } + + const handleCreateClick = () => { + setEditingPrompt(null) + setFormData(emptyFormData) + setEditDialogOpen(true) + } + + const handleSave = () => { + if (!formData.name.trim() || !formData.content.trim()) { + return + } + + if (editingPrompt) { + // Update existing prompt + vscode.postMessage({ + type: "updateSavedPrompt", + savedPromptUpdate: { + id: editingPrompt.id, + name: formData.name.trim(), + content: formData.content.trim(), + description: formData.description.trim() || undefined, + apiConfigId: formData.apiConfigId || undefined, + }, + }) + } else { + // Create new prompt + vscode.postMessage({ + type: "createSavedPrompt", + savedPromptCreate: { + name: formData.name.trim(), + content: formData.content.trim(), + description: formData.description.trim() || undefined, + apiConfigId: formData.apiConfigId || undefined, + }, + }) + } + + setEditDialogOpen(false) + setEditingPrompt(null) + setFormData(emptyFormData) + } + + const handleExport = () => { + vscode.postMessage({ type: "exportSavedPrompts" }) + } + + const handleImport = () => { + vscode.postMessage({ type: "importSavedPrompts" }) + } + + const promptsList = savedPrompts || [] + + return ( +
+ {t("settings:sections.savedPrompts")} + +
+ {/* Description section */} + +

+ {t("settings:savedPrompts.description")} +

+
+ + {/* Action buttons */} +
+ + + {promptsList.length > 0 && ( + + )} +
+ + {/* Saved prompts list */} + +
+ +

{t("settings:savedPrompts.listTitle")}

+
+
+ {promptsList.length === 0 ? ( +
+ {t("settings:savedPrompts.noPrompts")} +
+ ) : ( + promptsList.map((prompt: SavedPrompt) => { + const apiConfig = listApiConfigMeta?.find( + (config) => config.id === prompt.apiConfigId, + ) + return ( +
+
+
{prompt.name}
+ {prompt.description && ( +
+ {prompt.description} +
+ )} + {apiConfig && ( +
+ {t("settings:savedPrompts.apiConfig")}: {apiConfig.name} +
+ )} +
+
+ + +
+
+ ) + }) + )} +
+
+
+ + {/* Delete confirmation dialog */} + + + + {t("settings:savedPrompts.deleteTitle")} + + {t("settings:savedPrompts.deleteDescription", { name: promptToDelete?.name })} + + + + + {t("common:cancel")} + + + {t("common:delete")} + + + + + + {/* Edit/Create dialog */} + + + + + {editingPrompt + ? t("settings:savedPrompts.editTitle") + : t("settings:savedPrompts.createTitle")} + + + {editingPrompt + ? t("settings:savedPrompts.editDescription") + : t("settings:savedPrompts.createDescription")} + + +
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder={t("settings:savedPrompts.namePlaceholder")} + /> +
+
+ + setFormData({ ...formData, description: e.target.value })} + placeholder={t("settings:savedPrompts.descriptionPlaceholder")} + /> +
+
+ +