Files
IdeaSDK/frontend/src/features/templates/TemplateEditor.tsx
Blomios 9b1ced50be feat(ui): uniformise les droplist dynamiques sur le style du picker d'agent
Introduit SmallDropdown comme composant partagé et le fait adopter par
AgentsPanel, ModelServerSelect, OpenCodeModeFields, TemplateEditor et
TicketViewportSelect, pour que toutes les listes déroulantes dynamiques
(agent, modèle, template, viewport) partagent la même apparence que le
sélecteur d'agent de la création de ticket.

Refs #114

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 12:55:57 +02:00

231 lines
7.8 KiB
TypeScript

/**
* `TemplateEditor` — a fullscreen overlay for creating or editing agent
* templates (L7). Rendered on top of the rest of the UI (`fixed inset-0`).
*
* Provides:
* - Name field and default-profile selector
* - Two tabs: "Edit" (textarea) and "Preview" (react-markdown)
* - Save (create/update) and Cancel/Close buttons
*
* Pure presentation: all mutations are delegated to the callbacks supplied by
* the parent (`TemplatesPanel`). Styled with `@/shared`; no inline styles.
*/
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { AgentProfile, Template } from "@/domain";
import { Button, Input, SmallDropdown, cn } from "@/shared";
export interface TemplateEditorProps {
/**
* When set, the editor is in edit-mode for the given template; otherwise it
* is in create-mode (all fields start empty).
*/
template?: Template | null;
/** Available profiles for the default-profile selector. */
profiles: AgentProfile[];
/** Called when the user submits the form. */
onSave: (name: string, content: string, defaultProfileId: string) => Promise<void>;
/** Called when the user cancels / closes the overlay. */
onClose: () => void;
/** Whether a save operation is in flight. */
busy?: boolean;
}
type EditorTab = "edit" | "preview";
export function TemplateEditor({
template,
profiles,
onSave,
onClose,
busy = false,
}: TemplateEditorProps) {
const [name, setName] = useState(template?.name ?? "");
const [content, setContent] = useState(template?.contentMd ?? "");
const [defaultProfileId, setDefaultProfileId] = useState(
template?.defaultProfileId ?? "",
);
const [tab, setTab] = useState<EditorTab>("edit");
const canSave = name.trim().length > 0 && !busy;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!canSave) return;
await onSave(name.trim(), content, defaultProfileId);
}
return (
<div
className="fixed inset-0 z-50 flex flex-col bg-canvas"
role="dialog"
aria-modal="true"
aria-label="template editor"
>
{/* ── Header bar ── */}
<div className="flex shrink-0 items-center justify-between border-b border-border bg-surface px-4 py-3">
<h2 className="text-sm font-semibold text-content">
{template ? `Edit template — ${template.name}` : "New template"}
</h2>
<Button
type="button"
variant="ghost"
aria-label="close template editor"
onClick={onClose}
disabled={busy}
>
</Button>
</div>
{/* ── Form body ── */}
<form
onSubmit={(e) => void handleSubmit(e)}
className="flex flex-1 flex-col gap-0 overflow-hidden"
>
{/* ── Meta fields ── */}
<div className="flex shrink-0 flex-wrap items-end gap-3 border-b border-border px-4 py-3">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<label
htmlFor="te-name"
className="text-xs font-medium text-muted"
>
Name
</label>
<Input
id="te-name"
aria-label="template name"
placeholder="My template"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={busy}
className="min-w-48"
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<label
htmlFor="te-profile"
className="text-xs font-medium text-muted"
>
Default profile
</label>
{profiles.length > 0 ? (
<SmallDropdown
id="te-profile"
aria-label="template default profile"
value={defaultProfileId}
onChange={setDefaultProfileId}
disabled={busy}
className="w-full"
size="md"
options={[
{ value: "", label: "— none —" },
...profiles.map((p) => ({ value: p.id, label: p.name })),
]}
/>
) : (
<Input
id="te-profile"
aria-label="template default profile"
placeholder="profile-id (optional)"
value={defaultProfileId}
onChange={(e) => setDefaultProfileId(e.target.value)}
disabled={busy}
className="min-w-48"
/>
)}
</div>
</div>
{/* ── Tab strip ── */}
<div className="flex shrink-0 items-center gap-0 border-b border-border px-4">
{(["edit", "preview"] as EditorTab[]).map((t) => (
<button
key={t}
type="button"
role="tab"
aria-label={t === "edit" ? "Edit" : "Preview"}
aria-selected={tab === t}
onClick={() => setTab(t)}
className={cn(
"px-4 py-2 text-sm font-medium capitalize transition-colors",
tab === t
? "border-b-2 border-primary text-content"
: "text-muted hover:text-content",
)}
>
{t === "edit" ? "Edit" : "Preview"}
</button>
))}
</div>
{/* ── Edit / Preview pane ── */}
<div className="flex flex-1 flex-col overflow-hidden px-4 py-3">
{tab === "edit" ? (
<textarea
aria-label="template content"
value={content}
onChange={(e) => setContent(e.target.value)}
disabled={busy}
className={cn(
"flex-1 w-full rounded-md bg-raised px-3 py-2 text-sm text-content font-mono",
"border border-border outline-none transition-colors resize-none",
"focus:border-primary placeholder:text-faint",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
placeholder="# Agent instructions&#10;&#10;..."
/>
) : (
<div
className={cn(
"flex-1 overflow-auto rounded-md border border-border bg-raised px-6 py-4",
"prose prose-sm prose-invert max-w-none",
"[&_h1]:text-content [&_h2]:text-content [&_h3]:text-content",
"[&_p]:text-content/90 [&_li]:text-content/90",
"[&_code]:bg-canvas [&_code]:text-primary [&_code]:rounded [&_code]:px-1",
"[&_pre]:bg-canvas [&_pre]:border [&_pre]:border-border [&_pre]:rounded-md",
"[&_a]:text-primary [&_a:hover]:underline",
"[&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:text-muted",
"[&_hr]:border-border",
)}
>
{content.trim() ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{content}
</ReactMarkdown>
) : (
<p className="text-sm text-muted italic">Nothing to preview yet.</p>
)}
</div>
)}
</div>
{/* ── Footer ── */}
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
<Button
type="button"
variant="ghost"
onClick={onClose}
disabled={busy}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
aria-label="Save template"
disabled={!canSave}
loading={busy}
>
Save
</Button>
</div>
</form>
</div>
);
}