From aca71d5afeae2e1420fe532f7997e309419e097d Mon Sep 17 00:00:00 2001 From: Willow Lyu Date: Fri, 13 Mar 2026 14:58:53 +0800 Subject: [PATCH 1/8] add tempalte --- plan.md | 232 ++++++++++++++++++ src/backend/ipc/channels.ts | 1 + .../ipc/custom-prompt-settings-handlers.ts | 1 + src/backend/preload.ts | 2 + .../services/mcp/mcp-server-manager.ts | 8 + .../services/storage/custom-prompt-storage.ts | 52 ++-- src/shared/mcp/preset-servers.ts | 47 ++++ .../custom-prompt/CustomPromptManager.tsx | 177 +++++++++++-- .../custom-prompt/HighlightedTextarea.tsx | 99 ++++++++ .../settings/custom-prompt/PromptCard.tsx | 2 +- .../settings/custom-prompt/PromptForm.tsx | 223 ++++++++++------- .../settings/custom-prompt/PromptListView.tsx | 61 ++--- .../settings/custom-prompt/TemplateCard.tsx | 46 ++++ .../settings/custom-prompt/types.ts | 2 +- .../settings/mcp/devops/mcp-devops-card.tsx | 3 +- .../settings/mcp/github/mcp-github-card.tsx | 3 +- .../settings/mcp/jira/mcp-jira-card.tsx | 3 +- src/ui/src/hooks/usePromptManager.ts | 6 +- src/ui/src/services/ipc-client.ts | 1 + src/ui/src/types/index.ts | 1 + 20 files changed, 787 insertions(+), 183 deletions(-) create mode 100644 plan.md create mode 100644 src/shared/mcp/preset-servers.ts create mode 100644 src/ui/src/components/settings/custom-prompt/HighlightedTextarea.tsx create mode 100644 src/ui/src/components/settings/custom-prompt/TemplateCard.tsx diff --git a/plan.md b/plan.md new file mode 100644 index 00000000..b343e4d8 --- /dev/null +++ b/plan.md @@ -0,0 +1,232 @@ +# Custom Prompt Manager UI Improvements + +## Problem + +The Custom Prompt Manager list view has several layout and content display issues. + +## Requirements + +1. **Search + Add at top** — The search bar and "Add New Prompt" button must appear at the very top of the list view, above both the Templates section and the My Prompts section. +2. **Remove Template badge** — The "Template" badge inside `TemplateCard` is redundant (it's already in a "Templates" section) and should be removed. +3. **My Prompts section header** — User prompts need a "My Prompts" section heading styled the same as the "Templates" heading (`text-sm font-semibold text-white/70 uppercase tracking-wide`). +4. **Template shows prompt instructions** — `TemplateCard` currently shows only name and description. It must also show a preview of the prompt instructions content (project placeholder part + default prompt body). +5. **User prompts show prompt instructions** — `PromptCard` similarly shows only name and description. It must also show a preview of the prompt instructions content. + +Concepts After Redesign + +2.1 Template (built-in, read-only) +A single built-in prompt provided by YakShaver. Users cannot edit or delete it. It serves as a reference and starting point for creating their own prompts. +Visible in the UI as a distinct card, separate from user prompts +Contains: prompt text with «placeholder» markers + recommended MCP server list +Actions: Preview (read full prompt), Use (pre-fill a new prompt form) +If more templates are added in future, a selection step is added before the form — current single-template flow needs no changes + +2.2 User Prompts +Prompts created and owned by the user. No concept of "active" — all prompts are equal candidates during processing. +User can create, edit, and delete any prompt +During recording processing, YakShaver auto-matches the best prompt based on the transcription +User confirms or overrides the matched prompt before anything is created +Deleting the last prompt does not revert to "default" — the system falls back to the built-in template behavior + +3. UI Structure + +3.1 Prompt list page +Two sections, clearly separated: + +Section +Contents +Built-in template +Single card with name, description, MCP server tags. Preview + Use buttons. +My prompts +User-created prompts. Each row shows name, connected MCP servers, Edit + Delete actions. No "Active" badge. + + +The page title changes from "Custom Prompt Manager" to "Custom Prompts". Subtitle explains: "Prompts are matched automatically from your recordings. You can confirm or override during processing." + +3.2 Creating a new prompt — two entry points + + + + + + +3.3 Prompt form fields + +Field +Notes +Name +Free text. Required. User gives the prompt a meaningful name, e.g. "Backend issues – acme-api". +Prompt instructions +Full textarea. No length limit. Supports «placeholder» markers (see §4). The template is never auto-applied — user always owns the final text. +MCP servers +Checklist of available servers. Disconnected servers shown at reduced opacity with a "(Not connected)" label. User can still select them. + + + +4. Placeholder System + +The built-in template prompt contains «placeholder» markers to indicate information the user should supply. These are informational only — they do not block saving. + +4.1 Format +Placeholders use the «...» format (guillemet brackets). Example: + +Example template excerpt +You are processing a video for project «your project name».Repo / board: «repo or board URL»Create an issue following these rules:1) Embed the video link at the top... + + +4.2 Rendering in the editor +Placeholders are visually highlighted in orange within the textarea +A hint bar appears below the textarea when placeholders are detected: "«…» marks are prompts to fill in — replace them with your own content, or leave as-is" +If the prompt has no placeholders, the hint bar does not appear + +4.3 Which prompts have required placeholders? + + + + + +4.4 No validation on save +Saving is never blocked by unfilled placeholders. The system trusts the user to fill in what is relevant. A user with no repo simply deletes that line. + +Why not a structured form with labeled inputs? +Project info varies too much between users — some prompts have no repo, some have multiple, some reference a board URL instead. A free-text prompt with visual hints is more flexible than a form with fixed fields. + + +5. MCP Server Selection + +Each prompt carries a list of MCP servers it is allowed to use during processing. This replaces the current "active prompt → server filter" mechanism. + +State +Appearance +Connected +Normal opacity, checkbox enabled +Not connected +50% opacity, label shows "(Not connected)", checkbox still enabled so user can pre-configure +Built-in +Shown with "(Built-in)" label, always available + + +When using the template, recommended servers are pre-checked. User can uncheck any they do not need. + +6. Removing the "Active" Concept + +The "Active" prompt concept is removed entirely. Rationale: +The active prompt's content field is never injected into the MCP system prompt — only its selectedMcpServerIds is read, and even that filter is not currently applied +Removing it simplifies the data model and eliminates the confusing "Select" button +The UI loses nothing functional + +Code changes required: + + + + + + + + + +7. Default Prompt → Backend Only + +The built-in "Default Prompt" is hidden from the UI list. It continues to exist as the defaultCustomPrompt constant and is used as a backend fallback only. + +Safe way to hide it +Filter in getAllPrompts(): return settings.prompts.filter(p => !p.isDefault). Remove activePromptId entirely, replacing all fallback logic with null. The template card in the UI replaces the role the default prompt played as a user-facing reference. + + + +## Proposed Layout (list view) + +``` +[ Search prompts... ] [ Add New Prompt ] +───────────────────────────────────────── +TEMPLATES + ┌─ TemplateCard ──────────────────────┐ + │ Name │ + │ Description │ + │ [View] [Use] │ + └─────────────────────────────────────┘ + +─────────────── (my-4 spacing) ────────── +MY PROMPTS + ┌─ PromptCard ────────────────────────┐ + │ Name │ + │ Description │ + │ [Select] [Edit] │ + └─────────────────────────────────────┘ + +Note: Prompt Instructions are shown in the form/view, NOT as a card preview. +``` + +## Approach + +### Files to change + +| File | Change | +|------|--------| +| `src/shared/mcp/preset-servers.ts` | **NEW** — single source of truth for preset server IDs and default configs | +| `mcp-server-manager.ts` | `mergeWithInternalServers()` now appends preset servers not yet in storage | +| `mcp-github-card.tsx` / `mcp-devops-card.tsx` / `mcp-jira-card.tsx` | Import ID from `@shared/mcp/preset-servers` instead of hardcoding | +| `CustomPromptManager.tsx` | Move search+add controls above Templates; add "MY PROMPTS" heading; "Template" singular heading; `pt-2` spacing after search bar; pass `selectAllServersForNewPrompt` when creating from template | +| `PromptListView.tsx` | Remove search+add from inside this component (they move up); keep only the filtered list of PromptCards | +| `TemplateCard.tsx` | Remove `Template` | +| `PromptCard.tsx` | No content preview on cards | +| `PromptForm.tsx` | Remove "cannot be changed" messages; remove redundant FormDescriptions; add `selectAllServersForNewPrompt` prop; fix "(Disconnected)" → "(Not connected)" | +| `HighlightedTextarea.tsx` | Change `bg-black/40` → `bg-transparent dark:bg-input/30`; fixed height `h-64` | + +### Search/Add refactor + +Currently `PromptListView` owns the search bar and button internally. The cleanest change is to **lift the search state** up to `CustomPromptManager` and render the search+add row at the very top of the list view, before the Templates section. `PromptListView` becomes a pure presentational component that receives `filteredPrompts`. + +Alternatively, keep the search state in `PromptListView` but pass a render-slot or move the search+add row to be rendered by `CustomPromptManager` directly above the full list. + +The simplest approach: **lift search state to `CustomPromptManager`**, pass `searchQuery` + `onSearchChange` as props to `PromptListView`, and render the search+add row in `CustomPromptManager` above the full sections block. + +### Prompt instructions preview + +**Cards do NOT show a prompt instructions preview.** Only name and description are shown on the card. +Prompt instructions are visible only inside the form (View/Edit) — the `HighlightedTextarea` in `PromptForm` shows the full content with a fixed `h-64` height so it is always visible inside the `ScrollArea`. + +Root cause of invisible textarea: the form used `h-full` + `flex-1` on a `FormItem` inside a `ScrollArea`. `ScrollArea` content has unbounded height, so `h-full` resolves to 0 — making the textarea invisible. Fix: removed `h-full` from the `
`, simplified `FormItem` class, and set `containerClassName="h-64"` on `HighlightedTextarea`. + +## Implementation Todos + +### Layout & Structure + +1. **`search-top`** — Lift `searchQuery` state from `PromptListView` to `CustomPromptManager`. Render `[SearchBar + Add New Prompt]` row at the very top of the list view, above the Templates section. `PromptListView` becomes a pure list renderer receiving `filteredPrompts` as a prop. + +2. **`template-remove-badge`** — `TemplateCard.tsx`: remove `Template` and its wrapping div. Redundant since the card is already inside a "TEMPLATES" section. + +3. **`my-prompts-header`** *(depends on `search-top`)* — `CustomPromptManager.tsx`: add a "MY PROMPTS" section heading above `PromptListView`, styled identically to the "TEMPLATES" heading (`text-sm font-semibold text-white/70 uppercase tracking-wide`). + +4. **`template-show-content`** ✅ — No card preview (revised). Prompt instructions are shown in `PromptForm` via `HighlightedTextarea` with fixed `h-64` height. Fixed invisible textarea bug: removed `h-full` from `` and simplified `FormItem` class. + +5. **`prompt-show-content`** ✅ — No card preview (revised). Same fix as above applies. Added `Separator className="my-4"` between Templates and My Prompts sections for extra spacing. + +### Page Title + +6. **`page-title`** — `CustomPromptManager.tsx`: change `

` from "Custom Prompt Manager" to "Custom Prompts". Update subtitle to: *"Prompts are matched automatically from your recordings. You can confirm or override during processing."* + +### Remove Active Concept + +7. **`remove-active`** — Remove the "active prompt" concept from the frontend entirely (§6): + - `PromptCard.tsx`: remove `isActive` prop, Active badge, Select button + - `PromptListView.tsx`: remove `activePromptId` prop and `onSetActive` prop + - `CustomPromptManager.tsx`: remove `handleSetActive`, stop passing `activePromptId` / `onSetActive` + - `usePromptManager.ts`: remove `activePromptId` state, stop calling `getActivePrompt()` and `setActivePrompt()` + - `PromptForm.tsx`: remove "Save & Use" button (only keep "Save") + +### Placeholder Format + +8. **`placeholder-format`** — Change placeholder style from `` to `«...»` guillemet brackets (§4): + - `default-custom-prompt.ts`: replace `` → `«your project name»`, `` → `«repo or board URL»` + - `custom-prompt-storage.ts` `TEMPLATE_PROMPT.content`: same replacements in the template header + - `HighlightedTextarea.tsx`: update `PLACEHOLDER_PATTERN` from `//g` to `/«[^»]+»/g` + - `PromptForm.tsx`: update `hasPlaceholders` regex to `/«[^»]+»/`; change hint text to *"«…» marks are prompts to fill in — replace them with your own content, or leave as-is"*; **remove save-blocking validation** on placeholders (saving must never be blocked) + +### MCP Labels + +9. **`mcp-label-fix`** — `PromptForm.tsx` MCP server list: change label text from "(Disconnected)" to "(Not connected)". Checkboxes already stay enabled — only the label text changes. + +### Backend + +10. **`hide-default-backend`** — `custom-prompt-storage.ts` `getAllPrompts()`: add `!p.isDefault` filter so the backend default prompt is never exposed to the UI list (currently only `!p.isTemplate` is filtered). The default prompt remains as a backend-only constant fallback. diff --git a/src/backend/ipc/channels.ts b/src/backend/ipc/channels.ts index 80cf0de0..fb137e4d 100644 --- a/src/backend/ipc/channels.ts +++ b/src/backend/ipc/channels.ts @@ -64,6 +64,7 @@ export const IPC_CHANNELS = { // Settings SETTINGS_GET_ALL_PROMPTS: "settings:get-all-prompts", SETTINGS_GET_ACTIVE_PROMPT: "settings:get-active-prompt", + SETTINGS_GET_TEMPLATES: "settings:get-templates", SETTINGS_ADD_PROMPT: "settings:add-prompt", SETTINGS_UPDATE_PROMPT: "settings:update-prompt", SETTINGS_DELETE_PROMPT: "settings:delete-prompt", diff --git a/src/backend/ipc/custom-prompt-settings-handlers.ts b/src/backend/ipc/custom-prompt-settings-handlers.ts index 69f49c89..0d17b112 100644 --- a/src/backend/ipc/custom-prompt-settings-handlers.ts +++ b/src/backend/ipc/custom-prompt-settings-handlers.ts @@ -8,6 +8,7 @@ export class CustomPromptSettingsIPCHandlers { constructor() { ipcMain.handle(IPC_CHANNELS.SETTINGS_GET_ALL_PROMPTS, () => this.store.getAllPrompts()); + ipcMain.handle(IPC_CHANNELS.SETTINGS_GET_TEMPLATES, () => this.store.getTemplates()); ipcMain.handle(IPC_CHANNELS.SETTINGS_GET_ACTIVE_PROMPT, () => this.store.getActivePrompt()); ipcMain.handle( IPC_CHANNELS.SETTINGS_ADD_PROMPT, diff --git a/src/backend/preload.ts b/src/backend/preload.ts index 46bc1e7c..653a0c29 100644 --- a/src/backend/preload.ts +++ b/src/backend/preload.ts @@ -82,6 +82,7 @@ const IPC_CHANNELS = { // Settings SETTINGS_GET_ALL_PROMPTS: "settings:get-all-prompts", SETTINGS_GET_ACTIVE_PROMPT: "settings:get-active-prompt", + SETTINGS_GET_TEMPLATES: "settings:get-templates", SETTINGS_ADD_PROMPT: "settings:add-prompt", SETTINGS_UPDATE_PROMPT: "settings:update-prompt", SETTINGS_DELETE_PROMPT: "settings:delete-prompt", @@ -270,6 +271,7 @@ const electronAPI = { settings: { getAllPrompts: () => ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_GET_ALL_PROMPTS), getActivePrompt: () => ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_GET_ACTIVE_PROMPT), + getTemplates: () => ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_GET_TEMPLATES), addPrompt: (prompt: { name: string; content: string }) => ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_ADD_PROMPT, prompt), updatePrompt: (id: string, updates: { name?: string; content?: string }) => diff --git a/src/backend/services/mcp/mcp-server-manager.ts b/src/backend/services/mcp/mcp-server-manager.ts index 7d3aff47..1100211c 100644 --- a/src/backend/services/mcp/mcp-server-manager.ts +++ b/src/backend/services/mcp/mcp-server-manager.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import type { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import type { ToolSet } from "ai"; import type { HealthStatusInfo } from "../../types/index.js"; +import { PRESET_MCP_SERVERS } from "../../../shared/mcp/preset-servers"; import { McpStorage } from "../storage/mcp-storage"; import { type CreateClientOptions, MCPServerClient } from "./mcp-server-client"; import type { MCPServerConfig } from "./types"; @@ -229,6 +230,13 @@ export class MCPServerManager { result.push(s); } } + // Add preset servers not yet stored by the user (they appear with enabled: false) + for (const preset of PRESET_MCP_SERVERS) { + if (preset.id && !seen.has(preset.id)) { + seen.add(preset.id); + result.push({ ...preset }); + } + } return result; } diff --git a/src/backend/services/storage/custom-prompt-storage.ts b/src/backend/services/storage/custom-prompt-storage.ts index 432865bb..f6ad42c3 100644 --- a/src/backend/services/storage/custom-prompt-storage.ts +++ b/src/backend/services/storage/custom-prompt-storage.ts @@ -8,6 +8,7 @@ export interface CustomPrompt { description?: string; content: string; isDefault?: boolean; + isTemplate?: boolean; selectedMcpServerIds?: string[]; createdAt: number; updatedAt: number; @@ -20,19 +21,19 @@ interface CustomPromptData { const SETTINGS_FILE = "custom-settings.enc"; -const DEFAULT_PROMPT: CustomPrompt = { +const TEMPLATE_PROMPT: CustomPrompt = { id: "default", - name: "Default Prompt", - description: "This is the default prompt for YakShaver", - content: defaultCustomPrompt, - + name: "Create Issues from Video Recordings", + description: "Default template for creating issues from video recordings", + content: `Project Name: \nProject URL: \n\n${defaultCustomPrompt}`, isDefault: true, + isTemplate: true, createdAt: Date.now(), updatedAt: Date.now(), }; const DEFAULT_SETTINGS: CustomPromptData = { - prompts: [DEFAULT_PROMPT], + prompts: [TEMPLATE_PROMPT], activePromptId: "default", }; @@ -63,15 +64,23 @@ export class CustomPromptStorage extends BaseSecureStorage { const data = await this.decryptAndLoad(this.getSettingsPath()); this.cache = data || DEFAULT_SETTINGS; - // Migrate default prompt to new default if there are changes + // Migrate the built-in template prompt when content or metadata changes if (this.cache) { - const defaultPromptIndex = this.cache.prompts.findIndex((p) => p.id === "default"); - if (defaultPromptIndex !== -1) { - const currentDefaultPrompt = this.cache.prompts[defaultPromptIndex]; - if (currentDefaultPrompt.content !== DEFAULT_PROMPT.content) { - this.cache.prompts[defaultPromptIndex] = { - ...currentDefaultPrompt, - content: DEFAULT_PROMPT.content, + const templateIndex = this.cache.prompts.findIndex((p) => p.id === "default"); + if (templateIndex !== -1) { + const current = this.cache.prompts[templateIndex]; + const needsUpdate = + current.content !== TEMPLATE_PROMPT.content || + current.name !== TEMPLATE_PROMPT.name || + !current.isTemplate; + if (needsUpdate) { + this.cache.prompts[templateIndex] = { + ...current, + content: TEMPLATE_PROMPT.content, + name: TEMPLATE_PROMPT.name, + description: TEMPLATE_PROMPT.description, + isDefault: true, + isTemplate: true, updatedAt: Date.now(), }; await this.saveSettings(this.cache); @@ -89,7 +98,12 @@ export class CustomPromptStorage extends BaseSecureStorage { async getAllPrompts(): Promise { const settings = await this.loadSettings(); - return settings.prompts; + return settings.prompts.filter((p) => !p.isTemplate); + } + + async getTemplates(): Promise { + const settings = await this.loadSettings(); + return settings.prompts.filter((p) => p.isTemplate); } async getActivePrompt(): Promise { @@ -144,8 +158,8 @@ export class CustomPromptStorage extends BaseSecureStorage { const settings = await this.loadSettings(); const prompt = settings.prompts.find((p) => p.id === id); - // Prevent deleting default prompt - if (!prompt || prompt.isDefault) return false; + // Prevent deleting template or default prompts + if (!prompt || prompt.isDefault || prompt.isTemplate) return false; settings.prompts = settings.prompts.filter((p) => p.id !== id); @@ -171,8 +185,8 @@ export class CustomPromptStorage extends BaseSecureStorage { async clearCustomPrompts(): Promise { const settings = await this.loadSettings(); - settings.prompts = [DEFAULT_PROMPT]; - settings.activePromptId = DEFAULT_PROMPT.id; + settings.prompts = [TEMPLATE_PROMPT]; + settings.activePromptId = TEMPLATE_PROMPT.id; await this.saveSettings(settings); } diff --git a/src/shared/mcp/preset-servers.ts b/src/shared/mcp/preset-servers.ts new file mode 100644 index 00000000..934620d1 --- /dev/null +++ b/src/shared/mcp/preset-servers.ts @@ -0,0 +1,47 @@ +import type { MCPServerConfig } from "../types/mcp"; + +/** + * IDs for well-known preset MCP servers. + * These must stay in sync with the card components in the frontend. + */ +export const PRESET_SERVER_IDS = { + GITHUB: "f12980ac-f80c-47e0-b4ac-181a54122d61", + AZURE_DEVOPS: "483d49a4-0902-415a-a987-832a21bd3d63", + JIRA: "0f03a50c-219b-46e9-9ce3-54f925c44479", +} as const; + +/** + * Default configs for preset MCP servers. + * Included in listAvailableServers() even before the user connects. + * Once a user saves a server with the same ID, the stored version takes precedence. + */ +export const PRESET_MCP_SERVERS: readonly MCPServerConfig[] = [ + { + id: PRESET_SERVER_IDS.GITHUB, + name: "GitHub", + transport: "streamableHttp", + url: "https://api.githubcopilot.com/mcp/", + description: "GitHub MCP Server", + toolWhitelist: [], + enabled: false, + }, + { + id: PRESET_SERVER_IDS.AZURE_DEVOPS, + name: "Azure_DevOps", + transport: "stdio", + command: "npx", + args: ["-y", "@azure-devops/mcp", "ssw2"], + description: "Azure DevOps MCP Server", + toolWhitelist: [], + enabled: false, + }, + { + id: PRESET_SERVER_IDS.JIRA, + name: "Jira", + transport: "streamableHttp", + url: "https://mcp.atlassian.com/v1/mcp", + description: "Atlassian MCP Server", + toolWhitelist: [], + enabled: false, + }, +] as MCPServerConfig[]; diff --git a/src/ui/src/components/settings/custom-prompt/CustomPromptManager.tsx b/src/ui/src/components/settings/custom-prompt/CustomPromptManager.tsx index c1e6c7b4..533586bb 100644 --- a/src/ui/src/components/settings/custom-prompt/CustomPromptManager.tsx +++ b/src/ui/src/components/settings/custom-prompt/CustomPromptManager.tsx @@ -1,12 +1,16 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import type { CustomPrompt } from "@/types"; import { usePromptManager } from "../../../hooks/usePromptManager"; +import { SearchBar } from "../../common/SearchBar"; import { DeleteConfirmDialog } from "../../dialogs/DeleteConfirmDialog"; import { UnsavedChangesDialog } from "../../dialogs/UnsavedChangesDialog"; +import { Button } from "../../ui/button"; import { ScrollArea } from "../../ui/scroll-area"; +import { Separator } from "../../ui/separator"; import { PromptForm } from "./PromptForm"; import { PromptListView } from "./PromptListView"; import type { PromptFormValues } from "./schema"; +import { TemplateCard } from "./TemplateCard"; import type { ViewMode } from "./types"; interface CustomPromptSettingsPanelProps { @@ -22,6 +26,8 @@ export function CustomPromptSettingsPanel({ const [viewMode, setViewMode] = useState("list"); const [editingPrompt, setEditingPrompt] = useState(null); + const [viewingTemplate, setViewingTemplate] = useState(null); + const [templatePrefillContent, setTemplatePrefillContent] = useState(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [promptToDelete, setPromptToDelete] = useState(null); const [unsavedChangesDialogOpen, setUnsavedChangesDialogOpen] = useState(false); @@ -30,6 +36,15 @@ export function CustomPromptSettingsPanel({ ((result: boolean) => void) | null >(null); const [isFormDirty, setIsFormDirty] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + + const filteredPrompts = useMemo(() => { + if (!searchQuery.trim()) return promptManager.prompts; + const query = searchQuery.toLowerCase(); + return promptManager.prompts.filter( + (p) => p.name.toLowerCase().includes(query) || p.description?.toLowerCase().includes(query), + ); + }, [promptManager.prompts, searchQuery]); useEffect(() => { if (isActive) { @@ -42,6 +57,8 @@ export function CustomPromptSettingsPanel({ if (!isActive) { setViewMode("list"); setEditingPrompt(null); + setViewingTemplate(null); + setTemplatePrefillContent(undefined); setIsFormDirty(false); } }, [isActive]); @@ -58,15 +75,53 @@ export function CustomPromptSettingsPanel({ if (hasUnsavedChanges()) { setPendingAction(() => () => { setEditingPrompt(null); + setTemplatePrefillContent(undefined); setViewMode("create"); }); setUnsavedChangesDialogOpen(true); return; } setEditingPrompt(null); + setTemplatePrefillContent(undefined); setViewMode("create"); }, [hasUnsavedChanges]); + const handleViewTemplate = useCallback( + (template: CustomPrompt) => { + if (hasUnsavedChanges()) { + setPendingAction(() => () => { + setViewingTemplate(template); + setEditingPrompt(null); + setViewMode("view-template"); + }); + setUnsavedChangesDialogOpen(true); + return; + } + setViewingTemplate(template); + setEditingPrompt(null); + setViewMode("view-template"); + }, + [hasUnsavedChanges], + ); + + const handleUseTemplate = useCallback( + (template: CustomPrompt) => { + if (hasUnsavedChanges()) { + setPendingAction(() => () => { + setEditingPrompt(null); + setTemplatePrefillContent(template.content); + setViewMode("create"); + }); + setUnsavedChangesDialogOpen(true); + return; + } + setEditingPrompt(null); + setTemplatePrefillContent(template.content); + setViewMode("create"); + }, + [hasUnsavedChanges], + ); + const handleEdit = useCallback( (prompt: CustomPrompt) => { if (hasUnsavedChanges()) { @@ -126,12 +181,16 @@ export function CustomPromptSettingsPanel({ setPendingAction(() => () => { setViewMode("list"); setEditingPrompt(null); + setViewingTemplate(null); + setTemplatePrefillContent(undefined); }); setUnsavedChangesDialogOpen(true); return; } setViewMode("list"); setEditingPrompt(null); + setViewingTemplate(null); + setTemplatePrefillContent(undefined); }, [hasUnsavedChanges]); const handleConfirmUnsavedChanges = useCallback(() => { @@ -155,28 +214,102 @@ export function CustomPromptSettingsPanel({ } }, [pendingLeaveResolver]); - const defaultValues = useMemo( - () => - editingPrompt - ? { - name: editingPrompt.name, - description: editingPrompt.description || "", - content: editingPrompt.content, - selectedMcpServerIds: editingPrompt.selectedMcpServerIds, - } - : undefined, - [editingPrompt], - ); + const defaultValues = useMemo(() => { + if (viewMode === "view-template" && viewingTemplate) { + return { + name: viewingTemplate.name, + description: viewingTemplate.description || "", + content: viewingTemplate.content, + selectedMcpServerIds: viewingTemplate.selectedMcpServerIds, + }; + } + if (editingPrompt) { + return { + name: editingPrompt.name, + description: editingPrompt.description || "", + content: editingPrompt.content, + selectedMcpServerIds: editingPrompt.selectedMcpServerIds, + }; + } + if (templatePrefillContent !== undefined) { + return { + name: "", + description: "", + content: templatePrefillContent, + selectedMcpServerIds: [], + }; + } + return undefined; + }, [viewMode, viewingTemplate, editingPrompt, templatePrefillContent]); const renderContent = () => { if (viewMode === "list") { return ( - + {/* Search + Add row always at the top */} +
+ + +
+ + + {/* Templates section */} + {promptManager.templates.length > 0 && ( + <> +
+

+ Template +

+
+ {promptManager.templates.map((template) => ( + + ))} +
+
+ + + )} + + {/* My Prompts section */} +
+

+ My Prompts +

+ +
+ + ); + } + + if (viewMode === "view-template" && viewingTemplate) { + return ( + ); } @@ -190,6 +323,8 @@ export function CustomPromptSettingsPanel({ loading={promptManager.loading} isDefault={editingPrompt?.isDefault} isNewPrompt={viewMode === "create"} + selectAllServersForNewPrompt={viewMode === "create" && templatePrefillContent !== undefined} + templateContent={promptManager.templates[0]?.content} onDirtyChange={handleDirtyChange} /> ); @@ -212,6 +347,8 @@ export function CustomPromptSettingsPanel({ setPendingAction(() => () => { setViewMode("list"); setEditingPrompt(null); + setViewingTemplate(null); + setTemplatePrefillContent(undefined); }); setPendingLeaveResolver(() => resolve); setUnsavedChangesDialogOpen(true); @@ -231,8 +368,8 @@ export function CustomPromptSettingsPanel({

Custom Prompt Manager

- Manage your prompt templates. The active prompt is the default template YakShaver uses - when writing issues from your recordings. + Manage your custom prompts. Use a template to get started quickly, or create your own + prompt with custom MCP server selections.

diff --git a/src/ui/src/components/settings/custom-prompt/HighlightedTextarea.tsx b/src/ui/src/components/settings/custom-prompt/HighlightedTextarea.tsx new file mode 100644 index 00000000..8dc45f10 --- /dev/null +++ b/src/ui/src/components/settings/custom-prompt/HighlightedTextarea.tsx @@ -0,0 +1,99 @@ +import { forwardRef, useCallback, useRef } from "react"; +import { cn } from "@/lib/utils"; + +const PLACEHOLDER_PATTERN = //g; + +function renderHighlighted(text: string): React.ReactNode[] { + const parts: React.ReactNode[] = []; + let lastIndex = 0; + + for (const match of text.matchAll(PLACEHOLDER_PATTERN)) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + parts.push( + + {match[0]} + , + ); + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return parts; +} + +// Shared text styles — must be identical on both backdrop and textarea for pixel-perfect overlay +const SHARED_TEXT_CLASSES = + "font-mono text-sm leading-relaxed p-3 whitespace-pre-wrap [word-break:break-word] break-words"; + +interface HighlightedTextareaProps extends React.TextareaHTMLAttributes { + containerClassName?: string; +} + +export const HighlightedTextarea = forwardRef( + function HighlightedTextarea( + { value = "", onChange, className, containerClassName, disabled, onScroll, ...props }, + ref, + ) { + const backdropRef = useRef(null); + + const handleScroll = useCallback( + (e: React.UIEvent) => { + if (backdropRef.current) { + // Sync backdrop scroll so highlights stay aligned with visible text + backdropRef.current.scrollTop = e.currentTarget.scrollTop; + } + onScroll?.(e); + }, + [onScroll], + ); + + const textValue = typeof value === "string" ? value : String(value ?? ""); + + return ( +
+ {/* Backdrop: renders highlighted placeholders behind the transparent textarea */} + + + {/* Actual editable textarea — transparent background lets backdrop highlights show through */} +