Files
resolutionflow/frontend/src/components/pilot/script/NoTemplateDialog.tsx
Michael Chihlas fa61376303
All checks were successful
Mirror to GitHub / mirror (push) Successful in 10s
feat(pilot): Phase 5 — inline Script Generator integration
Wires the SuggestedFix card to an inline panel that handles both cases:
template-matched fixes open the Script Library generator with parameters
pre-filled from session context; un-matched fixes open the three-option
dialog (one_off / draft_template / build_template). The decision endpoint
records the path choice with side effects: draft_template persists a
draft_templates row via a Sonnet-driven TemplateExtractionService;
build_template returns a redirect to the Script Builder; one_off just
records the choice.

Backend:
- TemplateExtractionService: drafts a parameter schema from a concrete
  rendered script. Conservative by default ("prefer fewer parameters").
  Round-trip-validates that templated_body only references declared
  parameters; missing-key mismatch falls back to the original script
  with no params. LLM/parse failures fall back identically — the
  engineer can still create a draft and refine in the post-resolve
  prompt (Phase 6).
- /suggested-fixes/{fix_id}/decision side effects:
  * one_off → returns rendered_script (engineer's edited version or the
    fix's ai_drafted_script verbatim)
  * draft_template → same + creates draft_templates row with extracted
    params, returns draft_template_id
  * build_template → returns redirect_path=/scripts/builder?from_session=
    &fix= so the frontend can navigate to the builder pre-loaded
- 400 when a non-template fix has no ai_drafted_script (template-matched
  fixes take the dedicated /scripts/generate path, not this endpoint).
- 12 tests: TemplateExtractionService parse + fallback paths, all four
  decision branches, edited_script override, missing-script 400.

Frontend:
- src/components/pilot/script/{TemplateMatchPanel, NoTemplateDialog,
  ParameterizationPreview}.tsx — inline panels rendered in the task
  lane's bottom slot when the engineer clicks a SuggestedFix card.
- TemplateMatchPanel: loads template via /scripts/templates/{id},
  pre-fills params from fix.ai_drafted_parameters with cyan "from
  session" tags, generates via existing /scripts/generate (already
  bumps state_version on ai_session_id from Phase 3). 404 falls back
  with a clear message instead of erroring.
- NoTemplateDialog: shows the AI-drafted script with proposed parameter
  values highlighted in amber via ParameterizationPreview; three option
  cards with the middle (draft_template) flagged Recommended; inline
  edit on the script body before deciding.
- SuggestedFix card now clickable: onActivate toggles the inline panel.
- AssistantChatPage: scriptPanelOpen state + handleScriptDecision that
  navigates on build_template and toasts on the other paths. Active fix
  changes auto-close the panel so engineers don't act on stale state.
- Cmd+K → "Open inline Script Generator" palette entry surfaces only on
  /pilot/:id routes; fires a window event the chat page subscribes to.
  No Resolve shortcut added per Section 14 decision (browser ⌘R conflict).

Verified 2026-04-22 against the dev stack:
- one_off / draft_template / build_template all return the right shape
  with real Sonnet TemplateExtractionService for the draft path.
- Conservative extraction confirmed: cmdkey + Restart-Process script
  yielded zero proposed parameters as intended.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:15:29 -04:00

209 lines
7.7 KiB
TypeScript

/**
* NoTemplateDialog — three-option dialog when a Suggested Fix has no matching
* Script Library template (per FLOWPILOT-MIGRATION.md mockup 03 + Section 3.3).
*
* The AI has drafted a session-specific script (`fix.ai_drafted_script`); the
* engineer picks one of:
*
* 1. Run as one-off — no template created, script captured in session
* 2. Run now, templatize after — RECOMMENDED; draft_templates row queued for
* the post-resolve TemplatizePrompt (Phase 6)
* 3. Build as template now — redirect to /scripts/builder pre-loaded
*
* The drafted script is shown above the option cards with AI-proposed
* parameter values highlighted in amber via ParameterizationPreview.
*
* Inline-edit on the script body is supported so the engineer can tweak
* before deciding — the edited body is sent to the decision endpoint.
*/
import { useState } from 'react'
import { Loader2, Pencil, Check, X, Terminal, FileText, Hammer } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { SessionSuggestedFix, UserDecision } from '@/api/sessionSuggestedFixes'
import { ParameterizationPreview } from './ParameterizationPreview'
interface NoTemplateDialogProps {
fix: SessionSuggestedFix
onClose: () => void
// Returns the rendered script (or null if the engineer chose build_template
// and is being redirected away).
onDecide: (
decision: UserDecision,
options: { editedScript: string; parametersUsed: Record<string, string> },
) => Promise<void>
busy: boolean
}
interface OptionCardProps {
label: string
description: string
tradeoffs: string
recommended?: boolean
tone: 'neutral' | 'cyan' | 'purple'
icon: typeof Terminal
onClick: () => void
disabled: boolean
}
function OptionCard({
label, description, tradeoffs, recommended, tone, icon: Icon, onClick, disabled,
}: OptionCardProps) {
const toneClasses = {
neutral: 'border-default hover:border-hover bg-card',
cyan: 'border-accent/40 hover:border-accent/70 bg-accent-dim/15',
purple: 'border-purple/40 hover:border-purple/70 bg-purple/5',
}[tone]
const accentText = {
neutral: 'text-heading',
cyan: 'text-accent-text',
purple: 'text-purple',
}[tone]
return (
<button
onClick={onClick}
disabled={disabled}
className={cn(
'relative w-full rounded-lg border p-3 text-left transition-colors',
toneClasses,
disabled && 'opacity-50 cursor-not-allowed',
)}
>
{recommended && (
<span className="absolute -top-2 left-3 rounded-full bg-accent px-2 py-0.5 text-[0.625rem] font-bold uppercase tracking-wider text-white">
Recommended
</span>
)}
<div className="flex items-start gap-2">
<Icon size={14} className={cn('shrink-0 mt-0.5', accentText)} />
<div className="min-w-0 flex-1">
<div className={cn('text-[0.8125rem] font-semibold', accentText)}>{label}</div>
<div className="mt-0.5 text-[0.75rem] text-muted-foreground leading-snug">{description}</div>
<div className="mt-1 text-[0.6875rem] text-muted italic">{tradeoffs}</div>
</div>
</div>
</button>
)
}
export function NoTemplateDialog({ fix, onClose, onDecide, busy }: NoTemplateDialogProps) {
const [editing, setEditing] = useState(false)
const [draftBody, setDraftBody] = useState(fix.ai_drafted_script ?? '')
// Surface the AI's proposed parameters as highlight values in the preview,
// and pass them along to the decision endpoint as parameters_used so the
// draft_templates row records what the first run used.
const proposedParams: Record<string, string> = {}
const params = fix.ai_drafted_parameters ?? {}
for (const [k, v] of Object.entries(params)) {
if (typeof v === 'string') proposedParams[k] = v
}
const decide = (decision: UserDecision) => {
onDecide(decision, {
editedScript: draftBody.trim(),
parametersUsed: proposedParams,
})
}
return (
<div className="rounded-lg border border-warning/40 bg-bg-page mx-3 mb-3 overflow-hidden shadow-lg">
<div className="flex items-center justify-between px-3 py-2 border-b border-default">
<div className="flex items-center gap-2">
<Terminal size={13} className="text-warning" />
<span className="text-[0.75rem] font-semibold text-heading">
No matching template draft script below
</span>
</div>
<button
onClick={onClose}
disabled={busy}
className="p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors"
title="Close dialog"
>
<X size={11} />
</button>
</div>
<div className="p-3 space-y-3">
<div className="text-[0.75rem] text-muted-foreground leading-relaxed">
{fix.description}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[0.6875rem] uppercase tracking-wider font-semibold text-muted-foreground">
Drafted script
</span>
{!editing && (
<button
onClick={() => setEditing(true)}
disabled={busy}
className="flex items-center gap-1 text-[0.6875rem] text-muted-foreground hover:text-heading"
>
<Pencil size={10} /> Edit
</button>
)}
{editing && (
<button
onClick={() => setEditing(false)}
disabled={busy}
className="flex items-center gap-1 text-[0.6875rem] text-success hover:text-success/80"
>
<Check size={10} /> Done
</button>
)}
</div>
{editing ? (
<textarea
value={draftBody}
onChange={(e) => setDraftBody(e.target.value)}
className="w-full rounded-lg border border-default bg-input px-3 py-2 text-[0.75rem] font-mono text-heading resize-y min-h-[160px] max-h-[40vh] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
/>
) : (
<ParameterizationPreview body={draftBody} highlightValues={proposedParams} />
)}
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<OptionCard
label="Run as one-off"
description="Use this script for this ticket only."
tradeoffs="Fastest path, but the team won't benefit next time."
tone="neutral"
icon={Terminal}
onClick={() => decide('one_off')}
disabled={busy || !draftBody.trim()}
/>
<OptionCard
label="Run now, templatize after"
description="Use it now; review parameters after Resolve."
tradeoffs="~30 seconds of review later — only what worked becomes a template."
recommended
tone="cyan"
icon={FileText}
onClick={() => decide('draft_template')}
disabled={busy || !draftBody.trim()}
/>
<OptionCard
label="Build as template now"
description="Open the Script Builder pre-loaded."
tradeoffs="Adds time mid-ticket; immediate team benefit."
tone="purple"
icon={Hammer}
onClick={() => decide('build_template')}
disabled={busy || !draftBody.trim()}
/>
</div>
{busy && (
<div className="flex items-center justify-center gap-2 text-[0.75rem] text-muted-foreground">
<Loader2 size={12} className="animate-spin" /> Recording decision...
</div>
)}
</div>
</div>
)
}
export default NoTemplateDialog