CoolFace
Apppublic

Leon4gr45/builder

sourceHugging Facemitupdated 2d agoView on Hugging Face
0likes
InterviewTemplateEditor.tsx382 linesDownload Raw Back to interview
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: &quot;Understand a company&quot;, &quot;Plan a feature&quot;. 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              &quot;done when&quot; 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. &quot;check what is already there with ls&quot;).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 &quot;The artifact records / lists / describes ...&quot;. 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 &quot;Plan a website&quot;322              interview offers &quot;Build this site&quot;, 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