From 9cf0a49b6403c6ab14a5b2939f518b72712acf87 Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Wed, 4 Mar 2026 17:34:16 +0100 Subject: [PATCH 01/12] feat: saved prompts context provider and data model (closes #27) --- app-prefixable/src/app.tsx | 9 +- app-prefixable/src/context/saved-prompts.tsx | 105 +++++++++++++++++++ 2 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 app-prefixable/src/context/saved-prompts.tsx diff --git a/app-prefixable/src/app.tsx b/app-prefixable/src/app.tsx index dd07f3b..4e72086 100644 --- a/app-prefixable/src/app.tsx +++ b/app-prefixable/src/app.tsx @@ -3,6 +3,7 @@ import { BasePathProvider, useBasePath } from "./context/base-path" import { BrandingProvider } from "./context/branding" import { CommandProvider } from "./context/command" import { RecentProjectsProvider } from "./context/recent-projects" +import { SavedPromptsProvider } from "./context/saved-prompts" import { DirectoryLayout } from "./pages/directory-layout" import { HomeLayout } from "./pages/home-layout" import { Session } from "./pages/session" @@ -62,9 +63,11 @@ export function App() { - - - + + + + + diff --git a/app-prefixable/src/context/saved-prompts.tsx b/app-prefixable/src/context/saved-prompts.tsx new file mode 100644 index 0000000..6277b4b --- /dev/null +++ b/app-prefixable/src/context/saved-prompts.tsx @@ -0,0 +1,105 @@ +import { createContext, useContext, createSignal, type ParentProps } from "solid-js" + +interface SavedPrompt { + id: string + title: string + text: string + createdAt: number +} + +interface SavedPromptsContextValue { + prompts: () => SavedPrompt[] + add: (title: string, text: string) => void + update: (id: string, fields: Partial>) => void + remove: (id: string) => void + reorder: (ids: string[]) => void +} + +const STORAGE_KEY = "opencode.savedPrompts" + +const SavedPromptsContext = createContext() + +function loadFromStorage(): SavedPrompt[] { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (!stored) return [] + const parsed = JSON.parse(stored) + if (!Array.isArray(parsed)) return [] + return parsed.filter( + (p): p is SavedPrompt => + typeof p.id === "string" && + typeof p.title === "string" && + typeof p.text === "string" && + typeof p.createdAt === "number", + ) + } catch { + return [] + } +} + +function saveToStorage(prompts: SavedPrompt[]) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(prompts)) + } catch { + // Ignore storage errors + } +} + +export function SavedPromptsProvider(props: ParentProps) { + const initial = loadFromStorage().sort((a, b) => b.createdAt - a.createdAt) + const [prompts, setPrompts] = createSignal(initial) + + function add(title: string, text: string) { + setPrompts((prev) => { + const prompt: SavedPrompt = { + id: crypto.randomUUID(), + title, + text, + createdAt: Date.now(), + } + const updated = [prompt, ...prev] + saveToStorage(updated) + return updated + }) + } + + function update(id: string, fields: Partial>) { + setPrompts((prev) => { + const updated = prev.map((p) => (p.id === id ? { ...p, ...fields } : p)) + saveToStorage(updated) + return updated + }) + } + + function remove(id: string) { + setPrompts((prev) => { + const filtered = prev.filter((p) => p.id !== id) + saveToStorage(filtered) + return filtered + }) + } + + function reorder(ids: string[]) { + setPrompts((prev) => { + const map = new Map(prev.map((p) => [p.id, p])) + const reordered = ids.map((id) => map.get(id)).filter(Boolean) as SavedPrompt[] + // Append any prompts not in the ids list (shouldn't happen, but be safe) + const remaining = prev.filter((p) => !ids.includes(p.id)) + const updated = [...reordered, ...remaining] + saveToStorage(updated) + return updated + }) + } + + return ( + + {props.children} + + ) +} + +export function useSavedPrompts() { + const ctx = useContext(SavedPromptsContext) + if (!ctx) throw new Error("useSavedPrompts must be used within SavedPromptsProvider") + return ctx +} From 70d3871bbb050d8b749df1f02352588c46e0f52a Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Wed, 4 Mar 2026 17:34:57 +0100 Subject: [PATCH 02/12] feat: show saved prompts on welcome screen (closes #28) --- app-prefixable/src/pages/session.tsx | 67 ++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/app-prefixable/src/pages/session.tsx b/app-prefixable/src/pages/session.tsx index 6553f91..3dadc0a 100644 --- a/app-prefixable/src/pages/session.tsx +++ b/app-prefixable/src/pages/session.tsx @@ -19,6 +19,7 @@ import { useMCP } from "../context/mcp"; import { usePermission } from "../context/permission"; import { useLayout } from "../context/layout"; import { useBranding } from "../context/branding"; +import { useSavedPrompts } from "../context/saved-prompts"; import { MessageTimeline } from "../components/message-timeline"; import { MCPDialog } from "../components/mcp-dialog"; import { MCPAddDialog } from "../components/mcp-add-dialog"; @@ -851,6 +852,22 @@ export function Session() { // Welcome screen component for when no session is selected function WelcomeScreen() { + const savedPrompts = useSavedPrompts(); + + async function sendSavedPrompt(text: string) { + if (!directory) return; + const res = await client.session.create({}); + if (!res.data) return; + const id = res.data.id; + navigate(`/${dirSlug()}/session/${id}`); + await client.session.promptAsync({ + sessionID: id, + parts: [{ type: "text", text }], + agent: providers.selectedAgent || "build", + ...(providers.selectedModel ? { model: providers.selectedModel } : {}), + }); + } + return (
+ {/* Saved Prompts */} + 0}> +
+

+ Saved Prompts +

+
+ + {(prompt) => ( + + )} + +
+
+
+

Date: Wed, 4 Mar 2026 17:36:21 +0100 Subject: [PATCH 03/12] feat: /prompt slash command to pick and send saved prompt (closes #29) --- app-prefixable/src/pages/session.tsx | 45 ++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/app-prefixable/src/pages/session.tsx b/app-prefixable/src/pages/session.tsx index 3dadc0a..ff5584b 100644 --- a/app-prefixable/src/pages/session.tsx +++ b/app-prefixable/src/pages/session.tsx @@ -76,12 +76,22 @@ export function Session() { const permission = usePermission(); const layout = useLayout(); const branding = useBranding(); + const savedPrompts = useSavedPrompts(); // Helper to get the current directory slug const dirSlug = createMemo(() => directory ? base64Encode(directory) : params.dir, ); + // Saved prompt picker items for /prompt command + const promptPickerItems = createMemo(() => + savedPrompts.prompts().map((p) => ({ + id: p.id, + title: p.title, + description: p.text.length > 80 ? p.text.slice(0, 80) + "..." : p.text, + })), + ); + const [input, setInput] = createSignal(""); const [optimisticMessage, setOptimisticMessage] = createSignal(null); @@ -96,6 +106,7 @@ export function Session() { const [showMCPAddDialog, setShowMCPAddDialog] = createSignal(false); const [showModelPicker, setShowModelPicker] = createSignal(false); const [showAgentPicker, setShowAgentPicker] = createSignal(false); + const [showPromptPicker, setShowPromptPicker] = createSignal(false); const [showFilePicker, setShowFilePicker] = createSignal(false); const [fileContext, setFileContext] = createSignal([]); const [imageAttachments, setImageAttachments] = createSignal< @@ -263,6 +274,15 @@ export function Session() { setShowMCPDialog(true); }, }, + { + id: "prompt.pick", + title: "Send Saved Prompt", + description: "Send a saved prompt in a new session", + slash: "prompt", + onSelect: () => { + setShowPromptPicker(true); + }, + }, ]; // Filtered slash commands based on query @@ -1514,6 +1534,31 @@ export function Session() { /> + {/* Saved Prompt Picker Dialog */} + + { + const found = savedPrompts.prompts().find((p) => p.id === item.id); + if (!found) return; + const res = await client.session.create({}); + if (!res.data) return; + const sid = res.data.id; + navigate(`/${dirSlug()}/session/${sid}`); + await client.session.promptAsync({ + sessionID: sid, + parts: [{ type: "text", text: found.text }], + agent: providers.selectedAgent || "build", + ...(providers.selectedModel ? { model: providers.selectedModel } : {}), + }); + }} + onClose={() => setShowPromptPicker(false)} + /> + + {/* File Picker Dialog */} Date: Wed, 4 Mar 2026 17:39:03 +0100 Subject: [PATCH 04/12] feat: saved prompts management in settings + quick save from input (closes #30) --- app-prefixable/src/pages/session.tsx | 161 +++++++++++++++- app-prefixable/src/pages/settings.tsx | 257 +++++++++++++++++++++++++- 2 files changed, 414 insertions(+), 4 deletions(-) diff --git a/app-prefixable/src/pages/session.tsx b/app-prefixable/src/pages/session.tsx index ff5584b..b25fd57 100644 --- a/app-prefixable/src/pages/session.tsx +++ b/app-prefixable/src/pages/session.tsx @@ -33,7 +33,8 @@ import { SessionHeader } from "../components/session-header"; import { ResizeHandle } from "../components/resize-handle"; import { base64Encode, base64Decode } from "../utils/path"; import type { Part, QuestionRequest } from "../sdk/client"; -import { Plus, Settings, Paperclip, Upload } from "lucide-solid"; +import { Plus, Settings, Paperclip, Upload, Bookmark } from "lucide-solid"; +import { Portal } from "solid-js/web"; import { ContextItems, type FileContext } from "../components/context-items"; import { FilePickerDialog } from "../components/file-picker-dialog"; import { @@ -108,6 +109,9 @@ export function Session() { const [showAgentPicker, setShowAgentPicker] = createSignal(false); const [showPromptPicker, setShowPromptPicker] = createSignal(false); const [showFilePicker, setShowFilePicker] = createSignal(false); + const [showSavePrompt, setShowSavePrompt] = createSignal(false); + const [savePromptTitle, setSavePromptTitle] = createSignal(""); + const [savePromptSuccess, setSavePromptSuccess] = createSignal(false); const [fileContext, setFileContext] = createSignal([]); const [imageAttachments, setImageAttachments] = createSignal< ImageAttachment[] @@ -1414,6 +1418,33 @@ export function Session() { /> {/* Attach buttons - inside input area */}

+ {/* Save as prompt button */} + + + {/* Upload from device button */} + +
+ + + + + + {/* Save Prompt Success Toast */} + +
+ Prompt saved +
+
); } diff --git a/app-prefixable/src/pages/settings.tsx b/app-prefixable/src/pages/settings.tsx index fa5e87b..df7bbac 100644 --- a/app-prefixable/src/pages/settings.tsx +++ b/app-prefixable/src/pages/settings.tsx @@ -1,4 +1,5 @@ import { createSignal, For, Show, type JSX, createMemo, onMount } from "solid-js" +import { Portal } from "solid-js/web" import { Spinner } from "../components/ui/spinner" import { useProviders } from "../context/providers" import { useMCP } from "../context/mcp" @@ -6,7 +7,8 @@ import { useSDK } from "../context/sdk" import { MCPAddDialog } from "../components/mcp-add-dialog" import { ConfirmDialog } from "../components/confirm-dialog" import { Button } from "../components/ui/button" -import { Check, Copy, Plug, GitBranch, Server, ExternalLink, Key, Search, X, Plus, Trash2 } from "lucide-solid" +import { Check, Copy, Plug, GitBranch, Server, ExternalLink, Key, Search, X, Plus, Trash2, BookmarkPlus, Pencil } from "lucide-solid" +import { useSavedPrompts } from "../context/saved-prompts" export function Settings() { const providers = useProviders() @@ -20,7 +22,7 @@ export function Settings() { // Initialize tab from URL hash, default to "providers" const getInitialTab = () => { const hash = window.location.hash.slice(1) - const validTabs = ["providers", "git", "mcp"] + const validTabs = ["providers", "git", "mcp", "prompts"] return validTabs.includes(hash) ? hash : "providers" } const [activeTab, setActiveTab] = createSignal(getInitialTab()) @@ -29,6 +31,14 @@ export function Settings() { const [mcpDeleting, setMcpDeleting] = createSignal(null) const [mcpToDelete, setMcpToDelete] = createSignal(null) + // Saved prompts + const savedPrompts = useSavedPrompts() + const [promptDialogOpen, setPromptDialogOpen] = createSignal(false) + const [editingPromptId, setEditingPromptId] = createSignal(null) + const [promptTitle, setPromptTitle] = createSignal("") + const [promptText, setPromptText] = createSignal("") + const [promptToDelete, setPromptToDelete] = createSignal(null) + // Provider search const [providerSearch, setProviderSearch] = createSignal("") @@ -428,11 +438,50 @@ export function Settings() { } } + function openAddPromptDialog() { + setEditingPromptId(null) + setPromptTitle("") + setPromptText("") + setPromptDialogOpen(true) + } + + function openEditPromptDialog(id: string) { + const prompt = savedPrompts.prompts().find((p) => p.id === id) + if (!prompt) return + setEditingPromptId(id) + setPromptTitle(prompt.title) + setPromptText(prompt.text) + setPromptDialogOpen(true) + } + + function savePromptDialog() { + const title = promptTitle().trim() + const text = promptText().trim() + if (!title || !text) return + const editing = editingPromptId() + if (editing) { + savedPrompts.update(editing, { title, text }) + } else { + savedPrompts.add(title, text) + } + setPromptDialogOpen(false) + setEditingPromptId(null) + setPromptTitle("") + setPromptText("") + } + + function confirmPromptDelete() { + const id = promptToDelete() + if (!id) return + savedPrompts.remove(id) + setPromptToDelete(null) + } + const tabs: Array<{ id: string; label: string; icon: () => JSX.Element }> = [ { id: "providers", label: "Providers", icon: () => }, { id: "git", label: "Git", icon: () => }, { id: "mcp", label: "MCP Servers", icon: () => }, - // Model/Agent selection happens via /model and /agent slash commands + { id: "prompts", label: "Prompts", icon: () => }, ] return ( @@ -1295,6 +1344,103 @@ export function Settings() { + + {/* Prompts Tab */} + +
+
+

+ Saved Prompts +

+

+ Create reusable prompts for quick access from the welcome screen or /prompt command +

+
+ +
+
+

+ Prompts ({savedPrompts.prompts().length}) +

+ +
+ + +
+

+ No saved prompts yet. +

+ +
+
+ + 0}> +
+ + {(prompt) => ( +
+
+
+ {prompt.title} +
+

+ {prompt.text.length > 120 ? prompt.text.slice(0, 120) + "..." : prompt.text} +

+
+
+ + +
+
+ )} +
+
+
+
+
+
@@ -1313,6 +1459,111 @@ export function Settings() { onConfirm={confirmMcpDelete} onCancel={() => setMcpToDelete(null)} /> + + {/* Prompt Add/Edit Dialog */} + + +
{ + if (e.target === e.currentTarget) setPromptDialogOpen(false) + }} + role="presentation" + > +
+
+

+ {editingPromptId() ? "Edit Prompt" : "Add Prompt"} +

+
+
+
+ + setPromptTitle(e.currentTarget.value)} + placeholder="e.g. Code Review" + class="w-full px-3 py-2 rounded-md text-sm" + style={{ + background: "var(--background-base)", + border: "1px solid var(--border-base)", + color: "var(--text-base)", + }} + autofocus + /> +
+
+ +