Prompt gallery (#957)

- [x] show prompt instead of app in autocomplete
- [x] use proper array/list for db (tags)
- [x] don't do <dyad-prompt> - replace inline
This commit is contained in:
Will Chen
2025-08-18 13:25:11 -07:00
committed by GitHub
parent a547735714
commit 573642ae5f
26 changed files with 1540 additions and 42 deletions

View File

@@ -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<any>;
onUpdatePrompt?: (prompt: {
id: number;
title: string;
description?: string;
content: string;
}) => Promise<any>;
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<HTMLTextAreaElement>(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 (
<Dialog open={open} onOpenChange={setOpen}>
{trigger ? (
<DialogTrigger asChild>{trigger}</DialogTrigger>
) : mode === "create" ? (
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" /> New Prompt
</Button>
</DialogTrigger>
) : (
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
size="icon"
variant="ghost"
data-testid="edit-prompt-button"
>
<Edit2 className="h-4 w-4" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Edit prompt</p>
</TooltipContent>
</Tooltip>
)}
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{mode === "create" ? "Create New Prompt" : "Edit Prompt"}
</DialogTitle>
<DialogDescription>
{mode === "create"
? "Create a new prompt template for your library."
: "Edit your prompt template."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Input
placeholder="Title"
value={draft.title}
onChange={(e) => setDraft((d) => ({ ...d, title: e.target.value }))}
/>
<Input
placeholder="Description (optional)"
value={draft.description}
onChange={(e) =>
setDraft((d) => ({ ...d, description: e.target.value }))
}
/>
<Textarea
ref={textareaRef}
placeholder="Content"
value={draft.content}
onChange={(e) => {
setDraft((d) => ({ ...d, content: e.target.value }));
// Use requestAnimationFrame for smoother updates
requestAnimationFrame(adjustTextareaHeight);
}}
className="resize-none overflow-y-auto"
style={{ minHeight: "150px" }}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button
onClick={onSave}
disabled={!draft.title.trim() || !draft.content.trim()}
>
<Save className="mr-2 h-4 w-4" /> Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Backward compatibility wrapper for create mode
export function CreatePromptDialog({
onCreatePrompt,
}: {
onCreatePrompt: (prompt: {
title: string;
description?: string;
content: string;
}) => Promise<any>;
}) {
return (
<CreateOrEditPromptDialog mode="create" onCreatePrompt={onCreatePrompt} />
);
}

View File

@@ -0,0 +1,71 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface DeleteConfirmationDialogProps {
itemName: string;
itemType?: string;
onDelete: () => void | Promise<void>;
trigger?: React.ReactNode;
}
export function DeleteConfirmationDialog({
itemName,
itemType = "item",
onDelete,
trigger,
}: DeleteConfirmationDialogProps) {
return (
<AlertDialog>
{trigger ? (
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
) : (
<Tooltip>
<TooltipTrigger asChild>
<AlertDialogTrigger asChild>
<Button
size="icon"
variant="ghost"
data-testid="delete-prompt-button"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Delete {itemType.toLowerCase()}</p>
</TooltipContent>
</Tooltip>
)}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {itemType}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{itemName}"? This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onDelete}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -1,4 +1,11 @@
import { Home, Inbox, Settings, HelpCircle, Store } from "lucide-react";
import {
Home,
Inbox,
Settings,
HelpCircle,
Store,
BookOpen,
} from "lucide-react";
import { Link, useRouterState } from "@tanstack/react-router";
import { useSidebar } from "@/components/ui/sidebar"; // import useSidebar hook
import { useEffect, useState, useRef } from "react";
@@ -39,6 +46,11 @@ const items = [
to: "/settings",
icon: Settings,
},
{
title: "Library",
to: "/library",
icon: BookOpen,
},
{
title: "Hub",
to: "/hub",
@@ -51,6 +63,7 @@ type HoverState =
| "start-hover:app"
| "start-hover:chat"
| "start-hover:settings"
| "start-hover:library"
| "clear-hover"
| "no-hover";
@@ -92,6 +105,8 @@ export function AppSidebar() {
selectedItem = "Chat";
} else if (hoverState === "start-hover:settings") {
selectedItem = "Settings";
} else if (hoverState === "start-hover:library") {
selectedItem = "Library";
} else if (state === "expanded") {
if (isAppRoute) {
selectedItem = "Apps";
@@ -195,6 +210,8 @@ function AppIcons({
onHoverChange("start-hover:chat");
} else if (item.title === "Settings") {
onHoverChange("start-hover:settings");
} else if (item.title === "Library") {
onHoverChange("start-hover:library");
}
}}
>

View File

@@ -21,6 +21,7 @@ import {
} from "lexical-beautiful-mentions";
import { KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH } from "lexical";
import { useLoadApps } from "@/hooks/useLoadApps";
import { usePrompts } from "@/hooks/usePrompts";
import { forwardRef } from "react";
import { useAtomValue } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
@@ -36,26 +37,35 @@ const beautifulMentionsTheme: BeautifulMentionsTheme = {
const CustomMenuItem = forwardRef<
HTMLLIElement,
BeautifulMentionsMenuItemProps
>(({ selected, item, ...props }, ref) => (
<li
className={`m-0 flex items-center px-3 py-2 cursor-pointer whitespace-nowrap ${
selected
? "bg-accent text-accent-foreground"
: "bg-popover text-popover-foreground hover:bg-accent/50"
}`}
{...props}
ref={ref}
>
<div className="flex items-center space-x-2 min-w-0">
<span className="px-2 py-0.5 text-xs font-medium bg-primary text-primary-foreground rounded-md flex-shrink-0">
App
</span>
<span className="truncate text-sm">
{typeof item === "string" ? item : item.value}
</span>
</div>
</li>
));
>(({ selected, item, ...props }, ref) => {
const isPrompt = typeof item !== "string" && item.data?.type === "prompt";
const label = isPrompt ? "Prompt" : "App";
const value = typeof item === "string" ? item : (item as any)?.value;
return (
<li
className={`m-0 flex items-center px-3 py-2 cursor-pointer whitespace-nowrap ${
selected
? "bg-accent text-accent-foreground"
: "bg-popover text-popover-foreground hover:bg-accent/50"
}`}
{...props}
ref={ref}
>
<div className="flex items-center space-x-2 min-w-0">
<span
className={`px-2 py-0.5 text-xs font-medium rounded-md flex-shrink-0 ${
isPrompt
? "bg-purple-500 text-white"
: "bg-primary text-primary-foreground"
}`}
>
{label}
</span>
<span className="truncate text-sm">{value}</span>
</div>
</li>
);
});
// Custom menu component
function CustomMenu({ loading: _loading, ...props }: any) {
@@ -136,13 +146,24 @@ function ClearEditorPlugin({
}
// Plugin to sync external value prop into the editor
function ExternalValueSyncPlugin({ value }: { value: string }) {
function ExternalValueSyncPlugin({
value,
promptsById,
}: {
value: string;
promptsById: Record<number, string>;
}) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
// Derive the display text that should appear in the editor (@Name) from the
// internal value representation (@app:Name)
const displayText = (value || "").replace(MENTION_REGEX, "@$1");
let displayText = (value || "").replace(MENTION_REGEX, "@$1");
displayText = displayText.replace(/@prompt:(\d+)/g, (_m, idStr) => {
const id = Number(idStr);
const title = promptsById[id];
return title ? `@${title}` : _m;
});
const currentText = editor.getEditorState().read(() => {
const root = $getRoot();
@@ -157,34 +178,32 @@ function ExternalValueSyncPlugin({ value }: { value: string }) {
const paragraph = $createParagraphNode();
// Build nodes from the internal value, turning @app:Name into a mention node
const mentionRegex = /@app:([a-zA-Z0-9_-]+)/g;
// Build nodes from internal value, turning @app:Name and @prompt:<id> into mention nodes
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = mentionRegex.exec(value)) !== null) {
const [full, name] = match;
const combined = /@app:([a-zA-Z0-9_-]+)|@prompt:(\d+)/g;
while ((match = combined.exec(value)) !== null) {
const start = match.index;
// Append any text before the mention
const full = match[0];
if (start > lastIndex) {
const textBefore = value.slice(lastIndex, start);
if (textBefore) paragraph.append($createTextNode(textBefore));
}
// Append the actual mention node (@ trigger with value = Name)
paragraph.append($createBeautifulMentionNode("@", name));
if (match[1]) {
const appName = match[1];
paragraph.append($createBeautifulMentionNode("@", appName));
} else if (match[2]) {
const id = Number(match[2]);
const title = promptsById[id] || `prompt:${id}`;
paragraph.append($createBeautifulMentionNode("@", title));
}
lastIndex = start + full.length;
}
// Append any trailing text after the last mention
if (lastIndex < value.length) {
const trailing = value.slice(lastIndex);
if (trailing) paragraph.append($createTextNode(trailing));
}
// If there were no mentions at all, just append the raw value as text
if (value && paragraph.getTextContent() === "") {
paragraph.append($createTextNode(value));
}
@@ -192,7 +211,7 @@ function ExternalValueSyncPlugin({ value }: { value: string }) {
root.append(paragraph);
paragraph.selectEnd();
});
}, [editor, value]);
}, [editor, value, promptsById]);
return null;
}
@@ -221,6 +240,7 @@ export function LexicalChatInput({
disabled = false,
}: LexicalChatInputProps) {
const { apps } = useLoadApps();
const { prompts } = usePrompts();
const [shouldClear, setShouldClear] = useState(false);
const selectedAppId = useAtomValue(selectedAppIdAtom);
@@ -252,10 +272,17 @@ export function LexicalChatInput({
});
const appMentions = filteredApps.map((app) => app.name);
const promptItems = (prompts || []).map((p) => ({
value: p.title,
type: "prompt",
id: p.id,
}));
return {
"@": appMentions,
"@": [...appMentions, ...promptItems],
};
}, [apps, selectedAppId, value, excludeCurrentApp]);
}, [apps, selectedAppId, value, excludeCurrentApp, prompts]);
const initialConfig = {
namespace: "ChatInput",
@@ -291,11 +318,18 @@ export function LexicalChatInput({
);
textContent = textContent.replace(mentionRegex, "@app:$1");
}
// Convert @PromptTitle to @prompt:<id>
const map = new Map((prompts || []).map((p) => [p.title, p.id]));
for (const [title, id] of map.entries()) {
const escapedTitle = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`@(${escapedTitle})(?![\\w-])`, "g");
textContent = textContent.replace(regex, `@prompt:${id}`);
}
}
onChange(textContent);
});
},
[onChange, apps],
[onChange, apps, prompts],
);
const handleSubmit = useCallback(() => {
@@ -343,7 +377,12 @@ export function LexicalChatInput({
<OnChangePlugin onChange={handleEditorChange} />
<HistoryPlugin />
<EnterKeyPlugin onSubmit={handleSubmit} />
<ExternalValueSyncPlugin value={value} />
<ExternalValueSyncPlugin
value={value}
promptsById={Object.fromEntries(
(prompts || []).map((p) => [p.id, p.title]),
)}
/>
<ClearEditorPlugin
shouldClear={shouldClear}
onCleared={handleCleared}

View File

@@ -0,0 +1,26 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background",
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";
export { Textarea };