TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { Star, X } from "lucide-react";4import { ResponsiveDialog } from "@/components/ui/sheet";5import { Button } from "@/components/ui/button";6import { Input, Textarea, Field, Label } from "@/components/ui/input";7import { Switch } from "@/components/ui/switch";8import { Segmented } from "@/components/ui/segmented";9import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";10import { toast } from "@/components/ui/toast";11import { ModelSelect } from "@/components/presets/model-select";12import { api, useApi } from "@/lib/client/api";13import { errorMessage } from "@/lib/client/humanize";14import { mergeVariables, labelFor, type PromptVariable } from "@/lib/prompts/variables";15import type { PublicPrompt, PromptKind, PublicProject } from "@/lib/client/types";16import { PROMPT_KIND_META, PROMPT_KIND_ORDER } from "./prompt-kind";17import { cn } from "@/lib/utils";1819interface Draft {20 kind: PromptKind;21 name: string;22 description: string;23 content: string;24 variables: PromptVariable[];25 schemaText: string;26 folder: string;27 tags: string[];28 favorite: boolean;29 defaultModelKey: string | null;30 projectId: string | null;31}3233const NONE = "__none__";34const EMPTY: Draft = { kind: "user", name: "", description: "", content: "", variables: [], schemaText: "", folder: "", tags: [], favorite: false, defaultModelKey: null, projectId: null };3536function toDraft(p: PublicPrompt): Draft {37 return { kind: p.kind, name: p.name, description: p.description ?? "", content: p.content, variables: p.variables, schemaText: p.schema ? JSON.stringify(p.schema, null, 2) : "", folder: p.folder ?? "", tags: p.tags, favorite: p.favorite, defaultModelKey: p.defaultModelKey, projectId: p.projectId };38}3940export interface PromptEditorSheetProps {41 open: boolean;42 onOpenChange: (o: boolean) => void;43 /** Existing prompt to edit; omit for a new one. */44 prompt?: PublicPrompt | null;45 /** Pre-filled values for a new prompt (e.g. `{ projectId }`, or `{ kind: "system", content }` from "Save as prompt"). */46 initial?: Partial<Pick<Draft, "kind" | "name" | "content" | "projectId" | "folder">>;47 folders?: string[];48 onSaved?: (p: PublicPrompt) => void;49}5051/**52 * Prompt editor: kind, name, description, body with live `{{variable}}` detection (label / default / options /53 * required per variable), JSON schema for structured prompts, folder, tags, favourite, default model, project.54 */55export function PromptEditorSheet({ open, onOpenChange, prompt, initial, folders = [], onSaved }: PromptEditorSheetProps) {56 const [d, setD] = React.useState<Draft>(EMPTY);57 const [saving, setSaving] = React.useState(false);58 const [tagInput, setTagInput] = React.useState("");59 const nameRef = React.useRef<HTMLInputElement>(null);60 const { data: projData } = useApi<{ projects: PublicProject[] }>(open ? "/api/projects" : null);61 const projects = React.useMemo(() => (projData?.projects ?? []).filter((p) => !p.archived), [projData]);6263 React.useEffect(() => {64 if (!open) return;65 const base = prompt ? toDraft(prompt) : { ...EMPTY, ...initial, variables: mergeVariables(initial?.content ?? "", []) };66 // eslint-disable-next-line react-hooks/set-state-in-effect67 setD(base);68 setTagInput("");69 const t = setTimeout(() => nameRef.current?.focus(), 80);70 return () => clearTimeout(t);71 }, [open, prompt, initial]);7273 const set = (patch: Partial<Draft>) => setD((s) => ({ ...s, ...patch }));74 const setContent = (content: string) => setD((s) => ({ ...s, content, variables: mergeVariables(content, s.variables) }));75 const setVar = (name: string, patch: Partial<PromptVariable>) => setD((s) => ({ ...s, variables: s.variables.map((v) => (v.name === name ? { ...v, ...patch } : v)) }));7677 const schemaError = React.useMemo(() => {78 if (d.kind !== "structured" || !d.schemaText.trim()) return null;79 try {80 const v = JSON.parse(d.schemaText);81 return v && typeof v === "object" && !Array.isArray(v) ? null : "Schema must be a JSON object";82 } catch (e) {83 return `Invalid JSON: ${(e as Error).message}`;84 }85 }, [d.kind, d.schemaText]);8687 const valid = d.name.trim().length > 0 && d.content.trim().length > 0 && !schemaError;8889 const addTag = (raw: string) => {90 const tags = raw91 .split(/[,\n]/)92 .map((t) => t.trim().toLowerCase())93 .filter(Boolean);94 if (!tags.length) return;95 set({ tags: Array.from(new Set([...d.tags, ...tags])).slice(0, 12) });96 setTagInput("");97 };9899 const save = async () => {100 if (!valid || saving) return;101 setSaving(true);102 const body = {103 kind: d.kind,104 name: d.name.trim(),105 description: d.description.trim() || null,106 content: d.content,107 variables: d.variables.map((v) => ({ name: v.name, label: v.label?.trim() || undefined, default: v.default || undefined, required: v.required || undefined, options: v.options?.length ? v.options : undefined })),108 schema: d.kind === "structured" && d.schemaText.trim() ? (JSON.parse(d.schemaText) as Record<string, unknown>) : null,109 folder: d.folder.trim() || null,110 tags: d.tags,111 favorite: d.favorite,112 defaultModelKey: d.defaultModelKey,113 projectId: d.projectId,114 };115 try {116 const res = prompt ? await api<{ prompt: PublicPrompt }>(`/api/prompts/${prompt.id}`, { method: "PATCH", json: body }) : await api<{ prompt: PublicPrompt }>("/api/prompts", { method: "POST", json: body });117 toast.success(prompt ? "Prompt updated" : "Prompt saved to your library");118 onOpenChange(false);119 onSaved?.(res.prompt);120 } catch (e) {121 toast.error("Could not save prompt", errorMessage(e));122 } finally {123 setSaving(false);124 }125 };126127 const meta = PROMPT_KIND_META[d.kind];128129 return (130 <ResponsiveDialog131 open={open}132 onOpenChange={onOpenChange}133 title={prompt ? "Edit prompt" : "New prompt"}134 description={meta.description}135 size="lg"136 snap="full"137 footer={138 <div className="flex items-center gap-2">139 <button type="button" onClick={() => set({ favorite: !d.favorite })} className={cn("tap inline-flex h-11 items-center gap-1.5 rounded-md px-2 text-[13px] sm:h-9", d.favorite ? "text-warning" : "text-fg-muted hover:text-fg")} aria-pressed={d.favorite} aria-label="Favourite">140 <Star className={cn("size-4", d.favorite && "fill-current")} /> <span className="hidden sm:inline">{d.favorite ? "Favourite" : "Add to favourites"}</span>141 </button>142 <div className="flex-1" />143 <Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving}>144 Cancel145 </Button>146 <Button loading={saving} disabled={!valid} onClick={save}>147 {prompt ? "Save changes" : "Save prompt"}148 </Button>149 </div>150 }151 >152 <form153 className="space-y-4 pt-1"154 onSubmit={(e) => {155 e.preventDefault();156 void save();157 }}158 >159 <Segmented ariaLabel="Prompt kind" value={d.kind} onChange={(k) => set({ kind: k })} options={PROMPT_KIND_ORDER.map((k) => ({ value: k, label: PROMPT_KIND_META[k].short, icon: PROMPT_KIND_META[k].icon }))} fill className="w-full" />160161 <Field label="Name" htmlFor="prompt-name">162 <Input ref={nameRef} id="prompt-name" value={d.name} onChange={(e) => set({ name: e.target.value })} placeholder={d.kind === "system" ? "Senior code reviewer" : "Summarize an article"} maxLength={120} required className="h-11 sm:h-9" />163 </Field>164 <Field label="Description" htmlFor="prompt-desc" hint="Optional — one line about when to use it.">165 <Input id="prompt-desc" value={d.description} onChange={(e) => set({ description: e.target.value })} placeholder="Reviews diffs for correctness, then style" maxLength={600} className="h-11 sm:h-9" />166 </Field>167 <Field label={d.kind === "system" ? "System prompt" : "Prompt"} htmlFor="prompt-content" hint={`${d.content.length.toLocaleString()} characters · use {{variable}} for fill-in fields`}>168 <Textarea id="prompt-content" value={d.content} onChange={(e) => setContent(e.target.value)} rows={8} maxLength={100_000} required className="min-h-[160px] font-mono text-[13px] leading-5" placeholder={d.kind === "system" ? "You are a senior engineer reviewing pull requests…" : "Summarize {{article}} in {{length}} bullet points for a {{audience}} audience."} spellCheck={false} />169 </Field>170171 {d.variables.length ? (172 <div className="space-y-2 rounded-xl bg-bg-subtle p-3">173 <div className="flex items-center justify-between">174 <Label>Variables ({d.variables.length})</Label>175 <span className="text-[11px] text-fg-subtle">Detected from the prompt</span>176 </div>177 <ul className="space-y-2">178 {d.variables.map((v) => (179 <li key={v.name} className="rounded-lg border border-border bg-bg-elevated p-2.5">180 <div className="flex items-center gap-2">181 <code className="rounded bg-bg-muted px-1.5 py-0.5 font-mono text-[12px] text-fg">{`{{${v.name}}}`}</code>182 <div className="flex-1" />183 <label className="inline-flex items-center gap-1.5 text-[12px] text-fg-muted">184 Required <Switch size="sm" checked={Boolean(v.required)} onCheckedChange={(c) => setVar(v.name, { required: c })} aria-label={`${v.name} required`} />185 </label>186 </div>187 <div className="mt-2 grid gap-2 sm:grid-cols-3">188 <Input value={v.label ?? ""} onChange={(e) => setVar(v.name, { label: e.target.value })} placeholder={`Label (${labelFor({ name: v.name })})`} maxLength={80} className="h-10 sm:h-8 sm:text-[13px]" aria-label={`${v.name} label`} />189 <Input value={v.default ?? ""} onChange={(e) => setVar(v.name, { default: e.target.value })} placeholder="Default value" maxLength={4000} className="h-10 sm:h-8 sm:text-[13px]" aria-label={`${v.name} default`} />190 <Input191 value={(v.options ?? []).join(", ")}192 onChange={(e) =>193 setVar(v.name, {194 options: e.target.value195 .split(",")196 .map((o) => o.trim())197 .filter(Boolean),198 })199 }200 onBlur={(e) => setVar(v.name, { options: Array.from(new Set(e.target.value.split(",").map((o) => o.trim()).filter(Boolean))) })}201 placeholder="Options (comma-separated)"202 className="h-10 sm:h-8 sm:text-[13px]"203 aria-label={`${v.name} options`}204 />205 </div>206 </li>207 ))}208 </ul>209 </div>210 ) : null}211212 {d.kind === "structured" ? (213 <Field label="JSON schema" htmlFor="prompt-schema" error={schemaError} hint={schemaError ? undefined : "Requested as response_format json_schema on models that support structured output."}>214 <Textarea id="prompt-schema" value={d.schemaText} onChange={(e) => set({ schemaText: e.target.value })} rows={6} className="min-h-[120px] font-mono text-[12.5px] leading-5" placeholder={'{\n "type": "object",\n "properties": { "summary": { "type": "string" } },\n "required": ["summary"],\n "additionalProperties": false\n}'} spellCheck={false} />215 </Field>216 ) : null}217218 <div className="grid gap-4 sm:grid-cols-2">219 <Field label="Folder" htmlFor="prompt-folder" hint="Type a new name or pick an existing folder.">220 <Input id="prompt-folder" list="prompt-folders" value={d.folder} onChange={(e) => set({ folder: e.target.value })} placeholder="Writing, Coding, Clients…" maxLength={60} className="h-11 sm:h-9" />221 <datalist id="prompt-folders">222 {folders.map((f) => (223 <option key={f} value={f} />224 ))}225 </datalist>226 </Field>227 <Field label="Tags" htmlFor="prompt-tags" hint="Press Enter or comma to add.">228 <div className="flex min-h-11 flex-wrap items-center gap-1 rounded-md border border-border bg-bg-elevated px-2 py-1 focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/25 sm:min-h-9">229 {d.tags.map((t) => (230 <span key={t} className="inline-flex items-center gap-0.5 rounded-full bg-bg-muted px-2 py-0.5 text-[12px]">231 #{t}232 <button type="button" onClick={() => set({ tags: d.tags.filter((x) => x !== t) })} className="tap -mr-1 rounded-full p-0.5 text-fg-subtle hover:text-fg" aria-label={`Remove tag ${t}`}>233 <X className="size-3" />234 </button>235 </span>236 ))}237 <input238 id="prompt-tags"239 value={tagInput}240 onChange={(e) => (e.target.value.includes(",") ? addTag(e.target.value) : setTagInput(e.target.value))}241 onKeyDown={(e) => {242 if (e.key === "Enter") {243 e.preventDefault();244 addTag(tagInput);245 } else if (e.key === "Backspace" && !tagInput && d.tags.length) set({ tags: d.tags.slice(0, -1) });246 }}247 onBlur={() => addTag(tagInput)}248 placeholder={d.tags.length ? "" : "review, email, fr"}249 className="min-w-[80px] flex-1 bg-transparent text-[15px] outline-none placeholder:text-fg-subtle sm:text-sm"250 maxLength={32}251 />252 </div>253 </Field>254 </div>255256 <div className="grid gap-4 sm:grid-cols-2">257 <Field label="Default model" hint="Optional. Only connected providers are listed.">258 <ModelSelect value={d.defaultModelKey} onChange={(k) => set({ defaultModelKey: k })} allowNone noneLabel="Current chat model" className="h-11 sm:h-9" />259 </Field>260 <Field label="Project" hint="Optional. Project prompts show up on the project page.">261 <Select value={d.projectId ?? NONE} onValueChange={(v) => set({ projectId: v === NONE ? null : v })}>262 <SelectTrigger className="h-11 sm:h-9" aria-label="Project">263 <SelectValue />264 </SelectTrigger>265 <SelectContent>266 <SelectItem value={NONE}>No project</SelectItem>267 {projects.map((p) => (268 <SelectItem key={p.id} value={p.id}>269 <span className="flex items-center gap-2">270 <span aria-hidden>{p.icon ?? "◆"}</span> {p.name}271 </span>272 </SelectItem>273 ))}274 </SelectContent>275 </Select>276 </Field>277 </div>278 </form>279 </ResponsiveDialog>280 );281}282