diff --git a/drizzle/0011_light_zeigeist.sql b/drizzle/0011_light_zeigeist.sql new file mode 100644 index 0000000..2b30e6c --- /dev/null +++ b/drizzle/0011_light_zeigeist.sql @@ -0,0 +1,8 @@ +CREATE TABLE `prompts` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `title` text NOT NULL, + `description` text, + `content` text NOT NULL, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL +); diff --git a/drizzle/meta/0011_snapshot.json b/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..162b858 --- /dev/null +++ b/drizzle/meta/0011_snapshot.json @@ -0,0 +1,578 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6ac2fe61-675b-4e3f-baf7-0f7d5f76bb2c", + "prevId": "a7f4a6e1-2a38-4dc8-a37e-b473b6304bab", + "tables": { + "apps": { + "name": "apps", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "github_org": { + "name": "github_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_repo": { + "name": "github_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_branch": { + "name": "github_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supabase_project_id": { + "name": "supabase_project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "neon_project_id": { + "name": "neon_project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "neon_development_branch_id": { + "name": "neon_development_branch_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "neon_preview_branch_id": { + "name": "neon_preview_branch_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vercel_project_id": { + "name": "vercel_project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vercel_project_name": { + "name": "vercel_project_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vercel_team_id": { + "name": "vercel_team_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vercel_deployment_url": { + "name": "vercel_deployment_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "install_command": { + "name": "install_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_command": { + "name": "start_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chat_context": { + "name": "chat_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chats": { + "name": "chats", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "app_id": { + "name": "app_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "initial_commit_hash": { + "name": "initial_commit_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "chats_app_id_apps_id_fk": { + "name": "chats_app_id_apps_id_fk", + "tableFrom": "chats", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "language_model_providers": { + "name": "language_model_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_base_url": { + "name": "api_base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "env_var_name": { + "name": "env_var_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "language_models": { + "name": "language_models", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_name": { + "name": "api_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "builtin_provider_id": { + "name": "builtin_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_provider_id": { + "name": "custom_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_output_tokens": { + "name": "max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "language_models_custom_provider_id_language_model_providers_id_fk": { + "name": "language_models_custom_provider_id_language_model_providers_id_fk", + "tableFrom": "language_models", + "tableTo": "language_model_providers", + "columnsFrom": [ + "custom_provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approval_state": { + "name": "approval_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_hash": { + "name": "commit_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "messages_chat_id_chats_id_fk": { + "name": "messages_chat_id_chats_id_fk", + "tableFrom": "messages", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "prompts": { + "name": "prompts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "app_id": { + "name": "app_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "commit_hash": { + "name": "commit_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "neon_db_timestamp": { + "name": "neon_db_timestamp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "versions_app_commit_unique": { + "name": "versions_app_commit_unique", + "columns": [ + "app_id", + "commit_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "versions_app_id_apps_id_fk": { + "name": "versions_app_id_apps_id_fk", + "tableFrom": "versions", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 1d65b1f..6662c49 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1755110011615, "tag": "0010_nappy_fat_cobra", "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1755545060076, + "tag": "0011_light_zeigeist", + "breakpoints": true } ] } \ No newline at end of file diff --git a/e2e-tests/helpers/test_helper.ts b/e2e-tests/helpers/test_helper.ts index 26b61bf..4c8b1b8 100644 --- a/e2e-tests/helpers/test_helper.ts +++ b/e2e-tests/helpers/test_helper.ts @@ -727,6 +727,30 @@ export class PageObject { await this.page.getByRole("link", { name: "Settings" }).click(); } + async goToLibraryTab() { + await this.page.getByRole("link", { name: "Library" }).click(); + } + + async createPrompt({ + title, + description, + content, + }: { + title: string; + description?: string; + content: string; + }) { + await this.page.getByRole("button", { name: "New Prompt" }).click(); + await this.page.getByRole("textbox", { name: "Title" }).fill(title); + if (description) { + await this.page + .getByRole("textbox", { name: "Description (optional)" }) + .fill(description); + } + await this.page.getByRole("textbox", { name: "Content" }).fill(content); + await this.page.getByRole("button", { name: "Save" }).click(); + } + getTitleBarAppNameButton() { return this.page.getByTestId("title-bar-app-name-button"); } diff --git a/e2e-tests/prompt_library.spec.ts b/e2e-tests/prompt_library.spec.ts new file mode 100644 index 0000000..5aec6e5 --- /dev/null +++ b/e2e-tests/prompt_library.spec.ts @@ -0,0 +1,56 @@ +import { test } from "./helpers/test_helper"; +import { expect } from "@playwright/test"; + +test("create and edit prompt", async ({ po }) => { + await po.setUp(); + await po.goToLibraryTab(); + await po.createPrompt({ + title: "title1", + description: "desc", + content: "prompt1content", + }); + + await expect(po.page.getByTestId("prompt-card")).toMatchAriaSnapshot(); + + await po.page.getByTestId("edit-prompt-button").click(); + await po.page + .getByRole("textbox", { name: "Content" }) + .fill("prompt1content-edited"); + await po.page.getByRole("button", { name: "Save" }).click(); + + await expect(po.page.getByTestId("prompt-card")).toMatchAriaSnapshot(); +}); + +test("delete prompt", async ({ po }) => { + await po.setUp(); + await po.goToLibraryTab(); + await po.createPrompt({ + title: "title1", + description: "desc", + content: "prompt1content", + }); + + await po.page.getByTestId("delete-prompt-button").click(); + await po.page.getByRole("button", { name: "Delete" }).click(); + + await expect(po.page.getByTestId("prompt-card")).not.toBeVisible(); +}); + +test("use prompt", async ({ po }) => { + await po.setUp(); + await po.goToLibraryTab(); + await po.createPrompt({ + title: "title1", + description: "desc", + content: "prompt1content", + }); + + await po.goToAppsTab(); + await po.getChatInput().click(); + await po.getChatInput().fill("[dump] @"); + await po.page.getByRole("menuitem", { name: "Choose title1" }).click(); + await po.page.getByRole("button", { name: "Send message" }).click(); + await po.waitForChatCompletion(); + + await po.snapshotServerDump("last-message"); +}); diff --git a/e2e-tests/snapshots/prompt_library.spec.ts_create-and-edit-prompt-1.aria.yml b/e2e-tests/snapshots/prompt_library.spec.ts_create-and-edit-prompt-1.aria.yml new file mode 100644 index 0000000..ed9c34d --- /dev/null +++ b/e2e-tests/snapshots/prompt_library.spec.ts_create-and-edit-prompt-1.aria.yml @@ -0,0 +1,7 @@ +- heading "title1" [level=3] +- paragraph: desc +- button: + - img +- button: + - img +- text: prompt1content \ No newline at end of file diff --git a/e2e-tests/snapshots/prompt_library.spec.ts_create-and-edit-prompt-2.aria.yml b/e2e-tests/snapshots/prompt_library.spec.ts_create-and-edit-prompt-2.aria.yml new file mode 100644 index 0000000..a01a5c5 --- /dev/null +++ b/e2e-tests/snapshots/prompt_library.spec.ts_create-and-edit-prompt-2.aria.yml @@ -0,0 +1,7 @@ +- heading "title1" [level=3] +- paragraph: desc +- button: + - img +- button: + - img +- text: prompt1content-edited \ No newline at end of file diff --git a/e2e-tests/snapshots/prompt_library.spec.ts_use-prompt-1.txt b/e2e-tests/snapshots/prompt_library.spec.ts_use-prompt-1.txt new file mode 100644 index 0000000..6f6c29b --- /dev/null +++ b/e2e-tests/snapshots/prompt_library.spec.ts_use-prompt-1.txt @@ -0,0 +1,3 @@ +=== +role: user +message: [dump] prompt1content \ No newline at end of file diff --git a/src/__tests__/replacePromptReference.test.ts b/src/__tests__/replacePromptReference.test.ts new file mode 100644 index 0000000..87fd149 --- /dev/null +++ b/src/__tests__/replacePromptReference.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { replacePromptReference } from "@/ipc/utils/replacePromptReference"; + +describe("replacePromptReference", () => { + it("returns original when no references present", () => { + const input = "Hello world"; + const output = replacePromptReference(input, {}); + expect(output).toBe(input); + }); + + it("replaces a single @prompt:id with content", () => { + const input = "Use this: @prompt:42"; + const prompts = { 42: "Meaning of life" }; + const output = replacePromptReference(input, prompts); + expect(output).toBe("Use this: Meaning of life"); + }); + + it("replaces multiple occurrences and keeps surrounding text", () => { + const input = "A @prompt:1 and B @prompt:2 end"; + const prompts = { 1: "One", 2: "Two" }; + const output = replacePromptReference(input, prompts); + expect(output).toBe("A One and B Two end"); + }); + + it("leaves unknown references intact", () => { + const input = "Unknown @prompt:99 here"; + const prompts = { 1: "One" }; + const output = replacePromptReference(input, prompts); + expect(output).toBe("Unknown @prompt:99 here"); + }); + + it("supports string keys in map as well as numeric", () => { + const input = "Mix @prompt:7 and @prompt:8"; + const prompts = { "7": "Seven", 8: "Eight" } as Record< + string | number, + string + >; + const output = replacePromptReference(input, prompts); + expect(output).toBe("Mix Seven and Eight"); + }); +}); diff --git a/src/components/CreatePromptDialog.tsx b/src/components/CreatePromptDialog.tsx new file mode 100644 index 0000000..3a23f03 --- /dev/null +++ b/src/components/CreatePromptDialog.tsx @@ -0,0 +1,235 @@ +import React, { useState, useEffect, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Plus, Save, Edit2 } from "lucide-react"; + +interface CreateOrEditPromptDialogProps { + mode: "create" | "edit"; + prompt?: { + id: number; + title: string; + description: string | null; + content: string; + }; + onCreatePrompt?: (prompt: { + title: string; + description?: string; + content: string; + }) => Promise; + onUpdatePrompt?: (prompt: { + id: number; + title: string; + description?: string; + content: string; + }) => Promise; + trigger?: React.ReactNode; +} + +export function CreateOrEditPromptDialog({ + mode, + prompt, + onCreatePrompt, + onUpdatePrompt, + trigger, +}: CreateOrEditPromptDialogProps) { + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState({ + title: "", + description: "", + content: "", + }); + const textareaRef = useRef(null); + + // Auto-resize textarea function + const adjustTextareaHeight = () => { + const textarea = textareaRef.current; + if (textarea) { + // Store current height to avoid flicker + const currentHeight = textarea.style.height; + textarea.style.height = "auto"; + const scrollHeight = textarea.scrollHeight; + const maxHeight = window.innerHeight * 0.6 - 100; // 60vh in pixels + const minHeight = 150; // 150px minimum + const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight); + + // Only update if height actually changed to reduce reflows + if (`${newHeight}px` !== currentHeight) { + textarea.style.height = `${newHeight}px`; + } + } + }; + + // Initialize draft with prompt data when editing + useEffect(() => { + if (mode === "edit" && prompt) { + setDraft({ + title: prompt.title, + description: prompt.description || "", + content: prompt.content, + }); + } else { + setDraft({ title: "", description: "", content: "" }); + } + }, [mode, prompt, open]); + + // Auto-resize textarea when content changes + useEffect(() => { + adjustTextareaHeight(); + }, [draft.content]); + + // Trigger resize when dialog opens + useEffect(() => { + if (open) { + // Small delay to ensure the dialog is fully rendered + setTimeout(adjustTextareaHeight, 0); + } + }, [open]); + + const resetDraft = () => { + if (mode === "edit" && prompt) { + setDraft({ + title: prompt.title, + description: prompt.description || "", + content: prompt.content, + }); + } else { + setDraft({ title: "", description: "", content: "" }); + } + }; + + const onSave = async () => { + if (!draft.title.trim() || !draft.content.trim()) return; + + if (mode === "create" && onCreatePrompt) { + await onCreatePrompt({ + title: draft.title.trim(), + description: draft.description.trim() || undefined, + content: draft.content, + }); + } else if (mode === "edit" && onUpdatePrompt && prompt) { + await onUpdatePrompt({ + id: prompt.id, + title: draft.title.trim(), + description: draft.description.trim() || undefined, + content: draft.content, + }); + } + + setOpen(false); + }; + + const handleCancel = () => { + resetDraft(); + setOpen(false); + }; + + return ( + + {trigger ? ( + {trigger} + ) : mode === "create" ? ( + + + + ) : ( + + + + + + + +

Edit prompt

+
+
+ )} + + + + {mode === "create" ? "Create New Prompt" : "Edit Prompt"} + + + {mode === "create" + ? "Create a new prompt template for your library." + : "Edit your prompt template."} + + +
+ setDraft((d) => ({ ...d, title: e.target.value }))} + /> + + setDraft((d) => ({ ...d, description: e.target.value })) + } + /> +