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/components/picker-dialog.tsx b/app-prefixable/src/components/picker-dialog.tsx index 681d76f..58824bc 100644 --- a/app-prefixable/src/components/picker-dialog.tsx +++ b/app-prefixable/src/components/picker-dialog.tsx @@ -16,10 +16,11 @@ interface Props { onClose: () => void emptyMessage?: string placeholder?: string + initialFilter?: string } export function PickerDialog(props: Props) { - const [filter, setFilter] = createSignal("") + const [filter, setFilter] = createSignal(props.initialFilter ?? "") const [activeIndex, setActiveIndex] = createSignal(0) let inputRef: HTMLInputElement | undefined let listRef: HTMLDivElement | undefined diff --git a/app-prefixable/src/context/saved-prompts.tsx b/app-prefixable/src/context/saved-prompts.tsx new file mode 100644 index 0000000..db51629 --- /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 stored = loadFromStorage().sort((a, b) => b.createdAt - a.createdAt) + const [prompts, setPrompts] = createSignal(stored) + + 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 +} diff --git a/app-prefixable/src/pages/session.tsx b/app-prefixable/src/pages/session.tsx index 6553f91..94bc38c 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"; @@ -32,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 { @@ -75,12 +77,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); @@ -95,7 +107,13 @@ export function Session() { const [showMCPAddDialog, setShowMCPAddDialog] = createSignal(false); const [showModelPicker, setShowModelPicker] = createSignal(false); const [showAgentPicker, setShowAgentPicker] = createSignal(false); + const [showPromptPicker, setShowPromptPicker] = createSignal(false); + const [promptPickerFilter, setPromptPickerFilter] = createSignal(""); const [showFilePicker, setShowFilePicker] = createSignal(false); + const [showSavePrompt, setShowSavePrompt] = createSignal(false); + const [savePromptTitle, setSavePromptTitle] = createSignal(""); + const [savePromptBody, setSavePromptBody] = createSignal(""); + const [savePromptSuccess, setSavePromptSuccess] = createSignal(false); const [fileContext, setFileContext] = createSignal([]); const [imageAttachments, setImageAttachments] = createSignal< ImageAttachment[] @@ -106,6 +124,8 @@ export function Session() { const [pendingUserMessageText, setPendingUserMessageText] = createSignal< string | null >(null); + const toastTimer = { id: 0 as ReturnType }; + onCleanup(() => clearTimeout(toastTimer.id)); // Keep sessionId in sync with URL params and sync session data createEffect(() => { @@ -262,6 +282,16 @@ export function Session() { setShowMCPDialog(true); }, }, + { + id: "prompt.pick", + title: "Send Saved Prompt", + description: "Send a saved prompt in a new session", + slash: "prompt", + onSelect: () => { + setPromptPickerFilter(""); + setShowPromptPicker(true); + }, + }, ]; // Filtered slash commands based on query @@ -304,11 +334,21 @@ export function Session() { function handleInputChange(value: string) { setInput(value); - // Detect slash command pattern: starts with / followed by command name (no spaces) + // Detect `/prompt ` — auto-open prompt picker with filter + const promptMatch = value.match(/^\/prompt\s+(.*)$/i); + if (promptMatch) { + setInput(""); + setShowSlashPopover(false); + setSlashQuery(""); + setPromptPickerFilter(promptMatch[1].trim()); + setShowPromptPicker(true); + return; + } + + // Detect slash command pattern: /command (no spaces — popover only for partial commands) const slashMatch = value.match(/^\/(\S*)$/); if (slashMatch) { - const query = slashMatch[1]; - setSlashQuery(query); + setSlashQuery(slashMatch[1]); setShowSlashPopover(true); setSlashIndex(0); } else { @@ -849,8 +889,37 @@ export function Session() { } } + async function createSessionAndSendPrompt(text: string) { + if (!providers.selectedModel) { + setError("Please select a model before sending messages. Click the model button in the header."); + return; + } + if (!providers.connected.includes(providers.selectedModel.providerID)) { + setError(`Provider "${providers.selectedModel.providerID}" is not connected. Please configure it in Settings.`); + return; + } + setError(null); + try { + const res = await client.session.create({}); + if (!res.data) return; + const sid = res.data.id; + setSessionId(sid); + navigate(`/${dirSlug()}/session/${sid}`, { replace: true }); + await client.session.promptAsync({ + sessionID: sid, + parts: [{ type: "text", text }], + agent: providers.selectedAgent || "build", + model: providers.selectedModel, + }); + } catch (err) { + setError(`Failed to send saved prompt: ${err instanceof Error ? err.message : String(err)}`); + } + } + // Welcome screen component for when no session is selected function WelcomeScreen() { + const savedPrompts = useSavedPrompts(); + return (
+ {/* Saved Prompts */} + 0}> +
+

+ Saved Prompts +

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

{/* Attach buttons - inside input area */}

+ {/* Save as prompt button */} + + + {/* Upload from device button */}
); } @@ -1516,3 +1712,154 @@ export function Session() { ); } + +function SavePromptDialog(props: { + title: () => string + setTitle: (v: string) => void + onSave: () => void + onClose: () => void +}) { + const [container, setContainer] = createSignal(); + let titleRef: HTMLInputElement | undefined; + + createEffect(() => { + const el = container(); + if (!el) return; + + // Focus title input on open + titleRef?.focus(); + + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") { + e.preventDefault(); + props.onClose(); + return; + } + if (e.key !== "Tab") return; + + const focusable = el!.querySelectorAll( + 'input, textarea, button:not([disabled]), [tabindex]:not([tabindex="-1"])', + ); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last?.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first?.focus(); + } + } + + document.addEventListener("keydown", handleKey); + onCleanup(() => document.removeEventListener("keydown", handleKey)); + }); + + return ( + +
{ + if (e.target === e.currentTarget) props.onClose(); + }} + role="presentation" + > + +
+
+ ); +} + diff --git a/app-prefixable/src/pages/settings.tsx b/app-prefixable/src/pages/settings.tsx index fa5e87b..95e68cb 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 { createSignal, For, Show, type JSX, createMemo, onMount, onCleanup, createEffect } 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,105 @@ 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 +1461,176 @@ export function Settings() { onConfirm={confirmMcpDelete} onCancel={() => setMcpToDelete(null)} /> + + {/* Prompt Add/Edit Dialog */} + + setPromptDialogOpen(false)} + /> + + + {/* Prompt Delete Confirmation */} + setPromptToDelete(null)} + /> ) } + +function PromptDialog(props: { + editing: string | null + title: () => string + setTitle: (v: string) => void + text: () => string + setText: (v: string) => void + onSave: () => void + onClose: () => void +}) { + const [container, setContainer] = createSignal() + let titleRef: HTMLInputElement | undefined + + createEffect(() => { + const el = container() + if (!el) return + + // Focus title input on open + titleRef?.focus() + + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") { + e.preventDefault() + props.onClose() + return + } + if (e.key !== "Tab") return + + const focusable = el!.querySelectorAll( + 'input, textarea, button:not([disabled]), [tabindex]:not([tabindex="-1"])', + ) + if (focusable.length === 0) return + + const first = focusable[0] + const last = focusable[focusable.length - 1] + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault() + last?.focus() + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault() + first?.focus() + } + } + + document.addEventListener("keydown", handleKey) + onCleanup(() => document.removeEventListener("keydown", handleKey)) + }) + + return ( + +
{ + if (e.target === e.currentTarget) props.onClose() + }} + role="presentation" + > +