Leon4gr45/builder
0
1'use client';2 3import React, { useState } from 'react';4import type { InterviewTemplate } from '@/lib/interview/types';5import {6 emptyForm,7 templateToForm,8 formToTemplate,9 validateTemplateForm,10 slugify,11 type TemplateForm,12} from '@/lib/interview/template-form';13import { interviewTemplatesService } from '@/lib/interview/templates-service';14import { track } from '@/lib/telemetry';15import { Button } from '@/components/ui/button';16import { Input } from '@/components/ui/input';17import { Textarea } from '@/components/ui/textarea';18import { Label } from '@/components/ui/label';19import { Checkbox } from '@/components/ui/checkbox';20import {21 Select,22 SelectContent,23 SelectItem,24 SelectTrigger,25 SelectValue,26} from '@/components/ui/select';27import { toast } from 'sonner';28import { ArrowLeft, Save, Plus, Trash2 } from 'lucide-react';29 30interface InterviewTemplateEditorProps {31 template: InterviewTemplate | null; // null = create32 onSaved: () => void;33 onCancel: () => void;34}35 36function derivedArtifactPath(title: string): string {37 return `/.interviews/${slugify(title) || 'untitled'}.md`;38}39 40export function InterviewTemplateEditor({ template, onSaved, onCancel }: InterviewTemplateEditorProps) {41 const isCreate = template === null;42 const readOnly = template?.isBuiltIn === true;43 44 const [form, setForm] = useState<TemplateForm>(() =>45 template ? templateToForm(template) : emptyForm()46 );47 const [saving, setSaving] = useState(false);48 49 const handleTitleChange = (title: string) => {50 setForm(prev => {51 const next: TemplateForm = { ...prev, title };52 // Only auto-derive the artifact path when creating and the user hasn't53 // manually edited it away from the previously-derived value.54 if (isCreate) {55 const prevDerived = derivedArtifactPath(prev.title);56 if (prev.artifactPath === prevDerived || prev.artifactPath === '/.interviews/untitled.md') {57 next.artifactPath = derivedArtifactPath(title);58 }59 }60 return next;61 });62 };63 64 const updateItem = (index: number, patch: Partial<TemplateForm['items'][number]>) => {65 setForm(prev => ({66 ...prev,67 items: prev.items.map((it, i) => (i === index ? { ...it, ...patch } : it)),68 }));69 };70 71 const addItem = () => {72 setForm(prev => ({73 ...prev,74 items: [...prev.items, { question: '', criteria: '', required: true }],75 }));76 };77 78 const removeItem = (index: number) => {79 setForm(prev => ({ ...prev, items: prev.items.filter((_, i) => i !== index) }));80 };81 82 const handoffEnabled = form.handoff !== null;83 const setHandoffEnabled = (enabled: boolean) => {84 setForm(prev => ({85 ...prev,86 handoff: enabled ? (prev.handoff ?? { label: '', prompt: '', mode: 'code' }) : null,87 }));88 };89 const updateHandoff = (patch: Partial<NonNullable<TemplateForm['handoff']>>) => {90 setForm(prev => ({91 ...prev,92 handoff: prev.handoff ? { ...prev.handoff, ...patch } : prev.handoff,93 }));94 };95 96 const handleSave = async () => {97 const err = validateTemplateForm(form);98 if (err) {99 toast.error(err);100 return;101 }102 setSaving(true);103 try {104 if (isCreate) {105 const id = await interviewTemplatesService.generateId(form.title);106 await interviewTemplatesService.createTemplate(formToTemplate(form, id));107 track('interview_template_created');108 toast.success(`Created interview template: ${form.title.trim()}`);109 } else {110 await interviewTemplatesService.updateTemplate(template.id, formToTemplate(form, template.id));111 toast.success(`Updated interview template: ${form.title.trim()}`);112 }113 onSaved();114 } catch (e) {115 const message = e instanceof Error ? e.message : 'Failed to save interview template';116 toast.error(message);117 } finally {118 setSaving(false);119 }120 };121 122 return (123 <div className="flex flex-col bg-background h-[inherit]">124 {/* Header */}125 <div className="border-b px-6 py-4 shrink-0">126 <div className="flex items-center justify-between gap-3">127 <div className="flex items-center gap-3 min-w-0">128 <Button variant="ghost" size="sm" onClick={onCancel}>129 <ArrowLeft className="w-4 h-4" />130 </Button>131 <div className="min-w-0">132 <h1 className="text-xl font-bold truncate">133 {readOnly ? 'View Interview Template' : isCreate ? 'Create Interview Template' : 'Edit Interview Template'}134 </h1>135 <p className="text-sm text-muted-foreground">136 {readOnly137 ? 'Built-in templates cannot be edited. Duplicate it to make your own version.'138 : 'Define the questions and completion criteria for a guided interview.'}139 </p>140 </div>141 </div>142 <div className="flex gap-2 shrink-0">143 <Button variant="outline" onClick={onCancel} disabled={saving}>144 {readOnly ? 'Close' : 'Cancel'}145 </Button>146 {!readOnly && (147 <Button onClick={handleSave} disabled={saving}>148 <Save className="w-4 h-4 mr-2" />149 {saving ? 'Saving...' : 'Save'}150 </Button>151 )}152 </div>153 </div>154 </div>155 156 {/* Body */}157 <div className="flex-1 overflow-y-auto px-6 py-4">158 <div className="space-y-6 max-w-3xl">159 {readOnly && (160 <div className="rounded-lg border border-border bg-muted/50 px-4 py-3 text-sm text-muted-foreground">161 This is a built-in template shown for reference. To customize it, use Duplicate from the list.162 </div>163 )}164 165 {!readOnly && (166 <div className="rounded-lg border border-border bg-muted/40 px-4 py-3 text-xs text-muted-foreground leading-relaxed space-y-2">167 <p>168 Interview mode is one of the workspace interaction modes, alongside Chat and Code, picked169 from the mode selector in the chat panel. In it you choose a template like this one, and170 the agent works through your items as a conversation, generally one at a time. It cannot171 finish until every required item is covered.172 </p>173 <p>174 It records what it learns into an <span className="font-medium text-foreground">artifact</span>:175 a Markdown notes file under <code className="font-mono">/.interviews/</code>. The interview176 agent reads the whole project freely but can only write inside that one folder, so an177 interview never changes your actual files. Turning those notes into real work is a178 separate step, which you can offer as a one-tap handoff (below).179 </p>180 </div>181 )}182 183 <div>184 <Label htmlFor="template-title">Title *</Label>185 <Input186 id="template-title"187 placeholder="e.g. Plan a website"188 value={form.title}189 onChange={(e) => handleTitleChange(e.target.value)}190 disabled={readOnly}191 className="mt-1.5"192 />193 <p className="text-xs text-muted-foreground mt-1">194 Name it by what it produces, like the built-ins: "Understand a company", "Plan a feature". Shown in the picker.195 </p>196 </div>197 198 <div>199 <Label htmlFor="template-description">Description</Label>200 <Input201 id="template-description"202 placeholder="e.g. Turn an idea into a buildable plan for a site: its purpose, audience, pages, and the action it should drive."203 value={form.description}204 onChange={(e) => setForm(prev => ({ ...prev, description: e.target.value }))}205 disabled={readOnly}206 className="mt-1.5"207 />208 <p className="text-xs text-muted-foreground mt-1">209 One sentence: what the interview gathers and what it is for.210 </p>211 </div>212 213 <div>214 <Label htmlFor="template-artifact">Artifact path *</Label>215 <Input216 id="template-artifact"217 placeholder="/.interviews/site-plan.md"218 value={form.artifactPath}219 onChange={(e) => setForm(prev => ({ ...prev, artifactPath: e.target.value }))}220 disabled={readOnly}221 className="mt-1.5 font-mono text-sm"222 />223 <p className="text-xs text-muted-foreground mt-1">224 The artifact: the Markdown file this interview writes its findings into. Give it a meaningful name. Must live under /.interviews/ and end in .md.225 </p>226 </div>227 228 {/* Items */}229 <div>230 <div className="flex items-center justify-between mb-2">231 <Label>Items *</Label>232 {!readOnly && (233 <Button variant="outline" size="sm" onClick={addItem}>234 <Plus className="w-4 h-4 mr-2" />235 Add item236 </Button>237 )}238 </div>239 <p className="text-xs text-muted-foreground mb-3">240 Each item is one thing to gather. The question is what to learn from the user; the241 "done when" criteria is the checkable condition the completion check reads the artifact242 against. Order the items the way the conversation should flow.243 </p>244 <div className="space-y-4">245 {form.items.map((item, i) => (246 <div key={i} className="rounded-lg border border-border p-4 space-y-3">247 <div className="flex items-center justify-between">248 <span className="text-xs font-medium text-muted-foreground">Item {i + 1}</span>249 {!readOnly && (250 <Button251 variant="ghost"252 size="sm"253 onClick={() => removeItem(i)}254 title="Remove item"255 >256 <Trash2 className="w-4 h-4" />257 </Button>258 )}259 </div>260 <div>261 <Label htmlFor={`item-question-${i}`} className="text-xs">Question</Label>262 <Textarea263 id={`item-question-${i}`}264 placeholder="e.g. The pages or main sections the site needs (home, about, services, contact)"265 value={item.question}266 onChange={(e) => updateItem(i, { question: e.target.value })}267 disabled={readOnly}268 className="mt-1.5 min-h-[64px]"269 />270 <p className="text-xs text-muted-foreground mt-1">271 What to gather, not a literal script. The agent asks it in its own words, and can inspect the project first (e.g. "check what is already there with ls").272 </p>273 </div>274 <div>275 <Label htmlFor={`item-criteria-${i}`} className="text-xs">276 Done when (the artifact records...)277 </Label>278 <Textarea279 id={`item-criteria-${i}`}280 placeholder="e.g. The artifact lists the pages or main sections the site needs"281 value={item.criteria}282 onChange={(e) => updateItem(i, { criteria: e.target.value })}283 disabled={readOnly}284 className="mt-1.5 min-h-[64px]"285 />286 <p className="text-xs text-muted-foreground mt-1">287 A checkable statement about the artifact. Phrase it as "The artifact records / lists / describes ...". A model reads the artifact and decides if this is met.288 </p>289 </div>290 <label className="flex items-center gap-2 cursor-pointer w-fit">291 <Checkbox292 checked={item.required}293 onCheckedChange={(checked) => updateItem(i, { required: checked === true })}294 disabled={readOnly}295 />296 <span className="text-sm">Required</span>297 </label>298 </div>299 ))}300 {form.items.length === 0 && (301 <div className="rounded-lg border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">302 No items yet. Add at least one item.303 </div>304 )}305 </div>306 </div>307 308 {/* Handoff */}309 <div className="rounded-lg border border-border p-4 space-y-3">310 <label className="flex items-center gap-2 cursor-pointer w-fit">311 <Checkbox312 checked={handoffEnabled}313 onCheckedChange={(checked) => setHandoffEnabled(checked === true)}314 disabled={readOnly}315 />316 <span className="text-sm font-medium">Handoff action</span>317 </label>318 <p className="text-xs text-muted-foreground">319 Optional. A convenient way to let the user act on the result right away. Enable it to show320 the user a button, once the interview finishes, that starts a normal generation from what321 was recorded, without retyping anything. For example, the built-in "Plan a website"322 interview offers "Build this site", which hands its site plan to the agent to build from.323 Configure the button below.324 </p>325 {handoffEnabled && form.handoff && (326 <div className="space-y-3 pt-1">327 <div>328 <Label htmlFor="handoff-label" className="text-xs">Button label</Label>329 <Input330 id="handoff-label"331 placeholder="e.g. Build this site"332 value={form.handoff.label}333 onChange={(e) => updateHandoff({ label: e.target.value })}334 disabled={readOnly}335 className="mt-1.5"336 />337 <p className="text-xs text-muted-foreground mt-1">338 The text on the button shown when the interview finishes.339 </p>340 </div>341 <div>342 <Label htmlFor="handoff-prompt" className="text-xs">Prompt</Label>343 <Textarea344 id="handoff-prompt"345 placeholder="e.g. Build the website described in /.interviews/site-plan.md"346 value={form.handoff.prompt}347 onChange={(e) => updateHandoff({ prompt: e.target.value })}348 disabled={readOnly}349 className="mt-1.5 min-h-[64px]"350 />351 <p className="text-xs text-muted-foreground mt-1">352 The message sent to the agent when the user taps the button. Reference the artifact path so it reads what the interview recorded.353 </p>354 </div>355 <div>356 <Label className="text-xs">Mode</Label>357 <Select358 value={form.handoff.mode}359 onValueChange={(v) => updateHandoff({ mode: v as 'code' | 'chat' })}360 disabled={readOnly}361 >362 <SelectTrigger className="mt-1.5">363 <SelectValue />364 </SelectTrigger>365 <SelectContent>366 <SelectItem value="code">Code</SelectItem>367 <SelectItem value="chat">Chat</SelectItem>368 </SelectContent>369 </Select>370 <p className="text-xs text-muted-foreground mt-1">371 Code lets the agent edit the project. Chat is read-only. Most handoffs that build something use Code.372 </p>373 </div>374 </div>375 )}376 </div>377 </div>378 </div>379 </div>380 );381}382 