All checks were successful
Mirror to GitHub / mirror (push) Successful in 10s
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>
209 lines
7.7 KiB
TypeScript
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
|