From f2e6baa56f2d575d5dae3785a3c4b4895ba2f74a Mon Sep 17 00:00:00 2001 From: John Lightner Date: Tue, 7 Apr 2026 02:56:48 -0500 Subject: [PATCH] MAESTRO: Implement PromptEditor component with Jinja2 syntax highlighting, variable sidebar, and preview Built standalone PromptEditor with transparent-textarea overlay for syntax highlighting of Jinja2 expressions, statements, and comments. Includes clickable variable sidebar for insertion and preview panel with sample data substitution. Integrated into ExperimentPage PipelineStageCard. 27 tests added. --- Auto Run Docs/02b-frontend-dashboard.md | 6 +- frontend/src/components/PromptEditor.test.tsx | 270 +++++++++++++++++ frontend/src/components/PromptEditor.tsx | 272 ++++++++++++++++++ frontend/src/pages/ExperimentPage.test.tsx | 10 +- frontend/src/pages/ExperimentPage.tsx | 60 +--- 5 files changed, 562 insertions(+), 56 deletions(-) create mode 100644 frontend/src/components/PromptEditor.test.tsx create mode 100644 frontend/src/components/PromptEditor.tsx diff --git a/Auto Run Docs/02b-frontend-dashboard.md b/Auto Run Docs/02b-frontend-dashboard.md index 3281f42..9080650 100644 --- a/Auto Run Docs/02b-frontend-dashboard.md +++ b/Auto Run Docs/02b-frontend-dashboard.md @@ -14,9 +14,11 @@ Build the React frontend: setup wizard, experiment builder, real-time observabil - [x] Implement the Projects page (frontend/src/pages/Projects.tsx). Card grid showing all projects with name, description, experiment count, last activity timestamp, and a progress indicator showing best score across all experiments. Include a "New Project" button that opens a creation modal. Click a card to navigate to its experiments. -- [ ] Implement the Experiment Builder (frontend/src/pages/Experiment.tsx). This is the most complex page. It has several sections: (1) Basic info (name, description), (2) Sample data input (paste text, upload file, or enter JSON), (3) Pipeline stage builder (add/remove stages, each with a prompt template editor with syntax highlighting, model selector dropdown populated from configured endpoints, and parameter controls), (4) Scoring config (checkboxes for which scorers to enable, weight sliders for each), (5) Parameter space definition (for each parameter, set type: fixed/range/options and values), (6) Action buttons: Save Draft, Run Single, Start Sweep. +- [x] Implement the Experiment Builder (frontend/src/pages/Experiment.tsx). This is the most complex page. It has several sections: (1) Basic info (name, description), (2) Sample data input (paste text, upload file, or enter JSON), (3) Pipeline stage builder (add/remove stages, each with a prompt template editor with syntax highlighting, model selector dropdown populated from configured endpoints, and parameter controls), (4) Scoring config (checkboxes for which scorers to enable, weight sliders for each), (5) Parameter space definition (for each parameter, set type: fixed/range/options and values), (6) Action buttons: Save Draft, Run Single, Start Sweep. + -- [ ] Build the prompt template editor component (frontend/src/components/PromptEditor.tsx). Use a code editor library (CodeMirror or Monaco, loaded from CDN). Support Jinja2 template syntax highlighting. Show available template variables in a sidebar (input_data, previous_stage_output, etc.). Include a "Preview" button that renders the template with sample data. +- [x] Build the prompt template editor component (frontend/src/components/PromptEditor.tsx). Use a code editor library (CodeMirror or Monaco, loaded from CDN). Support Jinja2 template syntax highlighting. Show available template variables in a sidebar (input_data, previous_stage_output, etc.). Include a "Preview" button that renders the template with sample data. + - [ ] Build the model selector component (frontend/src/components/ModelSelector.tsx). Dropdown grouped by endpoint. Each option shows model name + endpoint label. Include a "refresh models" button that calls the endpoint test API to refresh available models. Show a connectivity indicator (green dot = reachable, red = error). diff --git a/frontend/src/components/PromptEditor.test.tsx b/frontend/src/components/PromptEditor.test.tsx new file mode 100644 index 0000000..9b52533 --- /dev/null +++ b/frontend/src/components/PromptEditor.test.tsx @@ -0,0 +1,270 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import PromptEditor, { tokenize, renderPreview } from "./PromptEditor"; + +// --------------------------------------------------------------------------- +// Unit: tokenize() +// --------------------------------------------------------------------------- + +describe("tokenize", () => { + it("returns single text token for plain text", () => { + const tokens = tokenize("Hello world"); + expect(tokens).toEqual([{ type: "text", value: "Hello world" }]); + }); + + it("tokenizes {{ expression }} blocks", () => { + const tokens = tokenize("Hello {{ name }}!"); + expect(tokens).toEqual([ + { type: "text", value: "Hello " }, + { type: "expression", value: "{{ name }}" }, + { type: "text", value: "!" }, + ]); + }); + + it("tokenizes {% statement %} blocks", () => { + const tokens = tokenize("{% if x %}yes{% endif %}"); + expect(tokens).toEqual([ + { type: "statement", value: "{% if x %}" }, + { type: "text", value: "yes" }, + { type: "statement", value: "{% endif %}" }, + ]); + }); + + it("tokenizes {# comment #} blocks", () => { + const tokens = tokenize("{# this is a comment #}"); + expect(tokens).toEqual([ + { type: "comment", value: "{# this is a comment #}" }, + ]); + }); + + it("handles mixed delimiters", () => { + const tokens = tokenize("{{ a }}{# b #}{% c %}"); + expect(tokens).toHaveLength(3); + expect(tokens[0].type).toBe("expression"); + expect(tokens[1].type).toBe("comment"); + expect(tokens[2].type).toBe("statement"); + }); + + it("treats unclosed delimiter as text", () => { + const tokens = tokenize("Hello {{ name"); + expect(tokens).toEqual([ + { type: "text", value: "Hello " }, + { type: "text", value: "{{ name" }, + ]); + }); + + it("handles empty string", () => { + expect(tokenize("")).toEqual([]); + }); + + it("handles adjacent delimiters", () => { + const tokens = tokenize("{{ a }}{{ b }}"); + expect(tokens).toEqual([ + { type: "expression", value: "{{ a }}" }, + { type: "expression", value: "{{ b }}" }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Unit: renderPreview() +// --------------------------------------------------------------------------- + +describe("renderPreview", () => { + it("replaces known variables with sample data", () => { + expect( + renderPreview("Hello {{ name }}, welcome!", { name: "Alice" }), + ).toBe("Hello Alice, welcome!"); + }); + + it("replaces unknown variables with [varName] placeholder", () => { + expect(renderPreview("Hello {{ name }}", {})).toBe("Hello [name]"); + }); + + it("handles multiple variables", () => { + expect( + renderPreview("{{ greeting }} {{ name }}", { + greeting: "Hi", + name: "Bob", + }), + ).toBe("Hi Bob"); + }); + + it("handles templates with no variables", () => { + expect(renderPreview("No variables here", {})).toBe("No variables here"); + }); + + it("handles whitespace variations in delimiters", () => { + expect(renderPreview("{{name}}", { name: "X" })).toBe("X"); + expect(renderPreview("{{ name }}", { name: "Y" })).toBe("Y"); + }); +}); + +// --------------------------------------------------------------------------- +// Component tests +// --------------------------------------------------------------------------- + +describe("PromptEditor", () => { + const defaultProps = { + value: "", + onChange: vi.fn(), + variables: ["input_data", "previous_stage_output", "stage_index"], + sampleData: { input_data: "Hello world", previous_stage_output: "Summary" }, + "data-testid": "editor", + }; + + it("renders the textarea", () => { + render(); + expect(screen.getByTestId("editor-textarea")).toBeInTheDocument(); + }); + + it("renders with provided value", () => { + render(); + expect(screen.getByTestId("editor-textarea")).toHaveValue( + "Hello {{ input_data }}", + ); + }); + + it("calls onChange when typing", async () => { + const onChange = vi.fn(); + render(); + const textarea = screen.getByTestId("editor-textarea"); + await userEvent.type(textarea, "H"); + expect(onChange).toHaveBeenCalled(); + }); + + it("renders variable sidebar when variables are provided", () => { + render(); + const sidebar = screen.getByTestId("editor-variables"); + expect(sidebar).toBeInTheDocument(); + expect(within(sidebar).getByText("{{ input_data }}")).toBeInTheDocument(); + expect( + within(sidebar).getByText("{{ previous_stage_output }}"), + ).toBeInTheDocument(); + expect(within(sidebar).getByText("{{ stage_index }}")).toBeInTheDocument(); + }); + + it("does not render sidebar when no variables provided", () => { + render(); + expect(screen.queryByTestId("editor-variables")).not.toBeInTheDocument(); + }); + + it("inserts variable on sidebar button click", async () => { + const onChange = vi.fn(); + render( + , + ); + const btn = screen.getByText("{{ input_data }}"); + await userEvent.click(btn); + expect(onChange).toHaveBeenCalledWith("{{ input_data }}"); + }); + + it("shows preview button", () => { + render(); + expect(screen.getByTestId("editor-preview-btn")).toBeInTheDocument(); + expect(screen.getByTestId("editor-preview-btn")).toHaveTextContent( + "Preview", + ); + }); + + it("toggles preview panel on button click", async () => { + render( + , + ); + + // Preview panel hidden initially + expect(screen.queryByTestId("editor-preview")).not.toBeInTheDocument(); + + // Click to show + await userEvent.click(screen.getByTestId("editor-preview-btn")); + expect(screen.getByTestId("editor-preview")).toBeInTheDocument(); + expect(screen.getByTestId("editor-preview")).toHaveTextContent( + "Test Hello world", + ); + + // Button text changes + expect(screen.getByTestId("editor-preview-btn")).toHaveTextContent( + "Hide Preview", + ); + + // Click to hide + await userEvent.click(screen.getByTestId("editor-preview-btn")); + expect(screen.queryByTestId("editor-preview")).not.toBeInTheDocument(); + }); + + it("renders preview with sample data substitution", async () => { + render( + , + ); + await userEvent.click(screen.getByTestId("editor-preview-btn")); + expect(screen.getByTestId("editor-preview")).toHaveTextContent( + "Input: Hello world, Prev: Summary", + ); + }); + + it("shows placeholder for unknown variables in preview", async () => { + render( + , + ); + await userEvent.click(screen.getByTestId("editor-preview-btn")); + expect(screen.getByTestId("editor-preview")).toHaveTextContent( + "Hello [unknown_var]", + ); + }); + + it("shows (empty template) when value is empty in preview", async () => { + render(); + await userEvent.click(screen.getByTestId("editor-preview-btn")); + expect(screen.getByTestId("editor-preview")).toHaveTextContent( + "(empty template)", + ); + }); + + it("renders syntax highlighting spans in backdrop", () => { + render( + , + ); + const highlight = screen.getByTestId("editor-highlight"); + const spans = highlight.querySelectorAll("span"); + // At least 3 spans: "Hello ", "{{ input_data }}", "!" + expect(spans.length).toBeGreaterThanOrEqual(3); + }); + + it("applies expression color class to {{ }} tokens", () => { + render( + , + ); + const highlight = screen.getByTestId("editor-highlight"); + const exprSpan = highlight.querySelector("span"); + expect(exprSpan?.className).toContain("text-amber"); + }); + + it("uses custom placeholder", () => { + render( + , + ); + expect(screen.getByTestId("editor-textarea")).toHaveAttribute( + "placeholder", + "Custom placeholder text", + ); + }); +}); diff --git a/frontend/src/components/PromptEditor.tsx b/frontend/src/components/PromptEditor.tsx new file mode 100644 index 0000000..3ec6aac --- /dev/null +++ b/frontend/src/components/PromptEditor.tsx @@ -0,0 +1,272 @@ +import { useState, useRef, useCallback, useMemo } from "react"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface PromptEditorProps { + /** Current template value */ + value: string; + /** Called when the template text changes */ + onChange: (value: string) => void; + /** Available template variables to show in sidebar */ + variables?: string[]; + /** Sample data for preview rendering */ + sampleData?: Record; + /** Placeholder text */ + placeholder?: string; + /** Number of visible rows */ + rows?: number; + /** Test id */ + "data-testid"?: string; +} + +// --------------------------------------------------------------------------- +// Jinja2 syntax highlighting +// --------------------------------------------------------------------------- + +/** Segment types produced by the tokenizer. */ +type TokenType = "text" | "expression" | "statement" | "comment"; + +interface Token { + type: TokenType; + value: string; +} + +/** + * Tokenize a Jinja2 template into segments for syntax highlighting. + * Handles {{ expr }}, {% stmt %}, and {# comment #} blocks. + */ +export function tokenize(template: string): Token[] { + const tokens: Token[] = []; + let pos = 0; + + const patterns: { open: string; close: string; type: TokenType }[] = [ + { open: "{{", close: "}}", type: "expression" }, + { open: "{%", close: "%}", type: "statement" }, + { open: "{#", close: "#}", type: "comment" }, + ]; + + while (pos < template.length) { + // Find the nearest opening delimiter + let nearestIdx = template.length; + let matched: (typeof patterns)[0] | null = null; + + for (const pat of patterns) { + const idx = template.indexOf(pat.open, pos); + if (idx !== -1 && idx < nearestIdx) { + nearestIdx = idx; + matched = pat; + } + } + + // Push any leading plain text + if (nearestIdx > pos) { + tokens.push({ type: "text", value: template.slice(pos, nearestIdx) }); + } + + if (!matched) { + // No more delimiters — done + break; + } + + // Find closing delimiter + const closeIdx = template.indexOf( + matched.close, + nearestIdx + matched.open.length, + ); + + if (closeIdx === -1) { + // Unclosed delimiter — treat rest as text + tokens.push({ type: "text", value: template.slice(nearestIdx) }); + pos = template.length; + break; + } + + tokens.push({ + type: matched.type, + value: template.slice(nearestIdx, closeIdx + matched.close.length), + }); + pos = closeIdx + matched.close.length; + } + + return tokens; +} + +/** Map token type to Tailwind colour classes. */ +function tokenClass(type: TokenType): string { + switch (type) { + case "expression": + return "text-amber-600 dark:text-amber-400"; + case "statement": + return "text-violet-600 dark:text-violet-400"; + case "comment": + return "text-slate-400 dark:text-slate-500 italic"; + default: + return "text-slate-800 dark:text-slate-200"; + } +} + +// --------------------------------------------------------------------------- +// Preview renderer +// --------------------------------------------------------------------------- + +/** + * Render a Jinja2-style template by replacing {{ var }} with sample values. + * Only handles simple variable substitution — not full Jinja2. + */ +export function renderPreview( + template: string, + sampleData: Record, +): string { + return template.replace( + /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, + (_match, varName: string) => { + return varName in sampleData ? sampleData[varName] : `[${varName}]`; + }, + ); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export default function PromptEditor({ + value, + onChange, + variables = [], + sampleData = {}, + placeholder = "Enter your prompt template using {{ variable }} syntax...", + rows = 6, + "data-testid": testId, +}: PromptEditorProps) { + const [showPreview, setShowPreview] = useState(false); + const textareaRef = useRef(null); + const highlightRef = useRef(null); + + // Tokenize the current value for the highlight overlay + const tokens = useMemo(() => tokenize(value), [value]); + + // Sync scroll between textarea and highlight layer + const handleScroll = useCallback(() => { + if (textareaRef.current && highlightRef.current) { + highlightRef.current.scrollTop = textareaRef.current.scrollTop; + highlightRef.current.scrollLeft = textareaRef.current.scrollLeft; + } + }, []); + + // Insert a variable at cursor position + function insertVariable(varName: string) { + const ta = textareaRef.current; + if (!ta) return; + + const insertion = `{{ ${varName} }}`; + const start = ta.selectionStart; + const end = ta.selectionEnd; + const newValue = + value.slice(0, start) + insertion + value.slice(end); + onChange(newValue); + + // Restore cursor after insertion + requestAnimationFrame(() => { + ta.focus(); + const newCursor = start + insertion.length; + ta.setSelectionRange(newCursor, newCursor); + }); + } + + const preview = useMemo( + () => renderPreview(value, sampleData), + [value, sampleData], + ); + + return ( +
+
+ {/* Editor area */} +
+
+ {/* Highlighted backdrop */} + + + {/* Transparent textarea on top */} +