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>
231 lines
7.8 KiB
TypeScript
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 ..."
|
|
/>
|
|
) : (
|
|
<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>
|
|
);
|
|
}
|