feat(pilot): Phase 5 — inline Script Generator integration
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>
This commit is contained in:
2026-04-22 00:15:29 -04:00
parent 8fd2c1bac6
commit fa61376303
13 changed files with 1368 additions and 24 deletions

View File

@@ -26,6 +26,7 @@ export interface DecisionResponse {
id: string
user_decision: UserDecision
rendered_script: string | null
draft_template_id: string | null
redirect_path: string | null
}
@@ -69,10 +70,18 @@ export const sessionSuggestedFixesApi = {
sessionId: string,
fixId: string,
decision: UserDecision,
options?: {
editedScript?: string
parametersUsed?: Record<string, unknown>
},
): Promise<DecisionResponse> {
const r = await apiClient.post<DecisionResponse>(
`/ai-sessions/${sessionId}/suggested-fixes/${fixId}/decision`,
{ decision },
{
decision,
edited_script: options?.editedScript,
parameters_used: options?.parametersUsed,
},
)
return r.data
},

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import {
Search, Loader2, ArrowRight, FileText, Clock,
Sparkles, LayoutDashboard, Tag, Plus, BookOpen, Terminal, Zap,
@@ -60,6 +60,17 @@ const QUICK_ACTIONS: PaletteItem[] = [
{ id: 'action-scripts', group: 'quick-actions', title: 'Open Script Generator', subtitle: 'Generate automation scripts', path: '/scripts', icon: 'action' },
]
// Phase 5: only surfaced when on a /pilot/:id route. Fires the inline-script
// open event instead of navigating away to /scripts.
const SCRIPTS_INLINE_QUICK_ACTION: PaletteItem = {
id: 'action-scripts-inline',
group: 'quick-actions',
title: 'Open inline Script Generator',
subtitle: 'For the active suggested fix in this session',
path: PILOT_INLINE_SCRIPT_PATH,
icon: 'action',
}
function ItemIcon({ icon, className }: { icon: PaletteItem['icon'], className?: string }) {
const cls = cn('shrink-0', className)
switch (icon) {
@@ -75,9 +86,21 @@ function ItemIcon({ icon, className }: { icon: PaletteItem['icon'], className?:
}
}
// Phase 5: sentinel path the palette uses to fire the inline-script-generator
// open event instead of navigating. Listened for by AssistantChatPage when
// the user is in an active session.
export const PILOT_INLINE_SCRIPT_EVENT = 'flowpilot:open-inline-script'
const PILOT_INLINE_SCRIPT_PATH = '__pilot_inline_script__'
export function CommandPalette({ open, onClose }: CommandPaletteProps) {
const navigate = useNavigate()
const location = useLocation()
const user = useAuthStore(s => s.user)
// True when the user is currently on a FlowPilot session deep-link.
// Used to surface the "Open inline Script Generator" palette entry only
// when it's actually actionable (the chat page listens for the event;
// dispatching it from /trees would do nothing).
const onPilotSession = location.pathname.startsWith('/pilot/')
const inputRef = useRef<HTMLInputElement>(null)
const [query, setQuery] = useState('')
const [isSearching, setIsSearching] = useState(false)
@@ -167,7 +190,10 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
if (recentItems.length > 0) {
result.push({ type: 'recent-flows', label: 'Recent Flows', items: recentItems })
}
result.push({ type: 'quick-actions', label: 'Quick Actions', items: QUICK_ACTIONS })
const quickActions = onPilotSession
? [SCRIPTS_INLINE_QUICK_ACTION, ...QUICK_ACTIONS]
: QUICK_ACTIONS
result.push({ type: 'quick-actions', label: 'Quick Actions', items: quickActions })
return result
}
@@ -278,6 +304,12 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
const handleSelect = useCallback((item: PaletteItem) => {
onClose()
if (item.path === PILOT_INLINE_SCRIPT_PATH) {
// Phase 5: window event lets the chat page open the inline panel
// without coupling the global palette to chat-page state.
window.dispatchEvent(new CustomEvent(PILOT_INLINE_SCRIPT_EVENT))
return
}
if (item.group === 'flowpilot') {
navigate(item.path, { state: { prefill: query.trim() } })
} else {

View File

@@ -0,0 +1,208 @@
/**
* 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

View File

@@ -0,0 +1,136 @@
/**
* ParameterizationPreview — read-only script display with placeholders /
* AI-proposed parameter values highlighted.
*
* Used inside both NoTemplateDialog (showing the AI-drafted script with
* proposed parameter values flagged in amber) and TemplatizePrompt (Phase 6,
* showing the templated body with `{{ key }}` placeholders).
*
* Highlight rules:
* - `{{ key }}` placeholders → cyan accent (template view)
* - Substrings matching an entry in `highlightValues` → amber pill
* (proposed-parameterization view)
*
* Not a syntax-aware highlighter — accuracy of the underlying script is the
* AI's responsibility.
*/
import { Fragment, useMemo } from 'react'
interface ParameterizationPreviewProps {
body: string
// Map of {paramKey: paramValue} to highlight as proposed values inline.
// Longer values match first to avoid partial-overlap surprises.
highlightValues?: Record<string, string>
}
type Token =
| { kind: 'text'; text: string }
| { kind: 'placeholder'; text: string; key: string }
| { kind: 'value'; text: string; key: string }
function tokenize(body: string, highlightValues: Record<string, string> | undefined): Token[] {
// Pass 1: split on {{ key }} placeholders so we never highlight inside one.
const placeholderRe = /\{\{\s*(\w+)\s*\}\}/g
const segments: Array<{ text: string; placeholderKey?: string }> = []
let lastIndex = 0
let m: RegExpExecArray | null
while ((m = placeholderRe.exec(body)) !== null) {
if (m.index > lastIndex) {
segments.push({ text: body.slice(lastIndex, m.index) })
}
segments.push({ text: m[0], placeholderKey: m[1] })
lastIndex = m.index + m[0].length
}
if (lastIndex < body.length) {
segments.push({ text: body.slice(lastIndex) })
}
if (segments.length === 0) {
segments.push({ text: body })
}
// Pass 2: in each non-placeholder segment, tokenize highlight values.
const valueEntries = highlightValues
? Object.entries(highlightValues)
.filter(([, v]) => typeof v === 'string' && v.length > 0)
.sort((a, b) => b[1].length - a[1].length) // longest match wins
: []
const tokens: Token[] = []
for (const seg of segments) {
if (seg.placeholderKey !== undefined) {
tokens.push({ kind: 'placeholder', text: seg.text, key: seg.placeholderKey })
continue
}
if (valueEntries.length === 0) {
tokens.push({ kind: 'text', text: seg.text })
continue
}
// Walk the segment one char at a time; at each position, try the longest
// matching value. This is O(n * m) where m is the number of values —
// fine for the small param sets we see in MSP scripts.
let cursor = 0
let pending = ''
const flushPending = () => {
if (pending) {
tokens.push({ kind: 'text', text: pending })
pending = ''
}
}
while (cursor < seg.text.length) {
let matched: { key: string; value: string } | null = null
for (const [key, value] of valueEntries) {
if (seg.text.startsWith(value, cursor)) {
matched = { key, value }
break
}
}
if (matched) {
flushPending()
tokens.push({ kind: 'value', text: matched.value, key: matched.key })
cursor += matched.value.length
} else {
pending += seg.text[cursor]
cursor++
}
}
flushPending()
}
return tokens
}
export function ParameterizationPreview({ body, highlightValues }: ParameterizationPreviewProps) {
const tokens = useMemo(() => tokenize(body, highlightValues), [body, highlightValues])
return (
<pre className="text-[0.75rem] font-mono text-heading whitespace-pre-wrap overflow-x-auto rounded-lg border border-default bg-code px-3 py-2.5 max-h-[40vh] leading-relaxed">
{tokens.map((t, i) => {
if (t.kind === 'placeholder') {
return (
<span
key={i}
className="rounded px-1 bg-accent-dim text-accent-text font-semibold"
title={`Parameter: ${t.key}`}
>
{t.text}
</span>
)
}
if (t.kind === 'value') {
return (
<span
key={i}
className="rounded px-1 bg-warning-dim text-warning font-semibold"
title={`Proposed parameter ${t.key}`}
>
{t.text}
</span>
)
}
return <Fragment key={i}>{t.text}</Fragment>
})}
</pre>
)
}
export default ParameterizationPreview

View File

@@ -0,0 +1,254 @@
/**
* TemplateMatchPanel — inline Script Generator panel for the case where a
* Suggested Fix matches an existing Script Library template.
*
* Per FLOWPILOT-MIGRATION.md mockup 02 + Section 3.2:
* - "Verified template" badge above the parameter form
* - Parameters pre-filled from `fix.ai_drafted_parameters` get a cyan
* `from session` tag and a cyan-tinted input background
* - Hint line per pre-filled parameter explains the source
* - Engineer can edit any value before generating
* - Generate posts to /scripts/generate (existing endpoint, already wired
* in Phase 3 to bump state_version on `ai_session_id`)
*
* The actual run happens outside the app — engineers copy the generated
* script and execute it via their RMM / shell. The panel ends with a
* Copy button + a "Mark as completed" affordance that's wired to the
* decision endpoint (one_off, since the engineer used the existing template).
*/
import { useEffect, useState } from 'react'
import { Loader2, Copy, Check, ShieldCheck, Sparkles, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { scriptsApi } from '@/api/scripts'
import { toast } from '@/lib/toast'
import type { ScriptTemplateDetail } from '@/types'
import type { SessionSuggestedFix } from '@/api/sessionSuggestedFixes'
interface TemplateMatchPanelProps {
fix: SessionSuggestedFix
sessionId: string
onClose: () => void
}
interface ParamSchemaEntry {
key: string
label?: string
field_type?: string
variable_name?: string
required?: boolean
options?: Array<{ label: string; value: string }>
}
export function TemplateMatchPanel({ fix, sessionId, onClose }: TemplateMatchPanelProps) {
const [template, setTemplate] = useState<ScriptTemplateDetail | null>(null)
const [templateLoading, setTemplateLoading] = useState(true)
const [templateError, setTemplateError] = useState<string | null>(null)
const [params, setParams] = useState<Record<string, string>>({})
const [generated, setGenerated] = useState<string | null>(null)
const [generating, setGenerating] = useState(false)
const [copied, setCopied] = useState(false)
const aiPrefilled: Record<string, string> = {}
if (fix.ai_drafted_parameters) {
for (const [k, v] of Object.entries(fix.ai_drafted_parameters)) {
if (typeof v === 'string') aiPrefilled[k] = v
}
}
useEffect(() => {
if (!fix.script_template_id) return
setTemplateLoading(true)
setTemplateError(null)
scriptsApi
.getTemplateDetail(fix.script_template_id)
.then((tpl) => {
setTemplate(tpl)
// Seed params from AI's pre-fill, then template defaults for any
// unset keys so required fields get a starting value.
const seeded: Record<string, string> = { ...aiPrefilled }
const defaults = (tpl as { default_values?: Record<string, unknown> }).default_values ?? {}
for (const [k, v] of Object.entries(defaults)) {
if (seeded[k] === undefined && typeof v === 'string') {
seeded[k] = v
}
}
setParams(seeded)
})
.catch((err: unknown) => {
const status = (err as { response?: { status?: number } })?.response?.status
// Phase 5 edge case: template was deleted between SUGGEST_FIX emission
// and the engineer clicking it. The doc says fall back to the
// three-option dialog with the AI-drafted script — surface that to
// the parent so it can swap UIs.
if (status === 404) setTemplateError('template_deleted')
else setTemplateError('Could not load template')
})
.finally(() => setTemplateLoading(false))
}, [fix.script_template_id]) // eslint-disable-line react-hooks/exhaustive-deps
const handleGenerate = async () => {
if (!fix.script_template_id) return
setGenerating(true)
try {
const result = await scriptsApi.generate({
template_id: fix.script_template_id,
parameters: params,
ai_session_id: sessionId,
})
setGenerated(result.script)
} catch {
toast.error('Failed to generate script')
} finally {
setGenerating(false)
}
}
const handleCopy = async () => {
if (!generated) return
await navigator.clipboard.writeText(generated)
setCopied(true)
toast.success('Script copied to clipboard')
setTimeout(() => setCopied(false), 1500)
}
const paramSchema = (template?.parameters_schema?.parameters as ParamSchemaEntry[] | undefined) ?? []
if (templateError === 'template_deleted') {
return (
<div className="rounded-lg border border-warning/40 bg-warning-dim/15 mx-3 mb-3 p-3">
<div className="flex items-center justify-between mb-1">
<span className="text-[0.75rem] font-semibold text-warning">
Template no longer in library
</span>
<button onClick={onClose} className="text-muted-foreground hover:text-heading" title="Close">
<X size={11} />
</button>
</div>
<p className="text-[0.75rem] text-muted-foreground leading-relaxed">
The template the AI suggested was deleted. Switch to the no-template
flow to use the drafted script instead.
</p>
</div>
)
}
return (
<div className="rounded-lg border border-success/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">
<ShieldCheck size={13} className="text-success" />
<span className="text-[0.75rem] font-semibold text-heading">Verified template</span>
{template && (
<span className="text-[0.6875rem] font-mono text-muted-foreground">
{template.slug}
</span>
)}
</div>
<button
onClick={onClose}
disabled={generating}
className="p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors"
title="Close panel"
>
<X size={11} />
</button>
</div>
<div className="p-3 space-y-3">
{templateLoading && (
<div className="flex items-center gap-2 text-[0.75rem] text-muted-foreground">
<Loader2 size={12} className="animate-spin" /> Loading template...
</div>
)}
{templateError && templateError !== 'template_deleted' && (
<div className="text-[0.75rem] text-danger">{templateError}</div>
)}
{template && !generated && (
<>
<div className="text-[0.75rem] text-muted-foreground leading-relaxed">
{template.description ?? fix.description}
</div>
<div className="space-y-2">
{paramSchema.map((p) => {
const key = p.key || p.variable_name || ''
if (!key) return null
const isPrefilled = aiPrefilled[key] !== undefined
const value = params[key] ?? ''
const isPassword = p.field_type === 'password'
return (
<div key={key}>
<div className="flex items-center justify-between mb-1">
<label className="text-[0.6875rem] font-semibold text-heading">
{p.label || key}
{p.required && <span className="text-danger">*</span>}
</label>
{isPrefilled && (
<span className="flex items-center gap-1 text-[0.625rem] font-semibold uppercase tracking-wider text-accent">
<Sparkles size={9} /> from session
</span>
)}
</div>
<input
type={isPassword ? 'password' : 'text'}
value={value}
onChange={(e) => setParams((prev) => ({ ...prev, [key]: e.target.value }))}
className={cn(
'w-full rounded-md border px-2.5 py-1.5 text-[0.8125rem] text-heading focus:outline-none focus:ring-1',
isPrefilled
? 'border-accent/40 bg-accent-dim/15 focus:border-accent focus:ring-accent/30'
: 'border-default bg-input focus:border-accent focus:ring-accent/30',
)}
/>
{isPrefilled && (
<div className="mt-0.5 text-[0.625rem] text-accent-text italic">
Pulled from session context
</div>
)}
</div>
)
})}
</div>
<button
onClick={handleGenerate}
disabled={generating}
className="w-full flex items-center justify-center gap-1.5 rounded-md bg-accent px-4 py-2 text-[0.8125rem] font-semibold text-white hover:bg-accent-hover transition-colors disabled:opacity-50"
>
{generating ? (
<><Loader2 size={12} className="animate-spin" /> Generating...</>
) : (
'Generate script'
)}
</button>
</>
)}
{generated && (
<>
<pre className="text-[0.75rem] font-mono text-heading whitespace-pre-wrap overflow-x-auto rounded-lg border border-default bg-code px-3 py-2.5 max-h-[40vh] leading-relaxed">
{generated}
</pre>
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
className="flex items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-[0.75rem] font-semibold text-white hover:bg-accent-hover transition-colors"
>
{copied ? <Check size={11} /> : <Copy size={11} />} Copy
</button>
<button
onClick={() => setGenerated(null)}
className="text-[0.75rem] text-muted-foreground hover:text-heading"
>
Edit parameters
</button>
</div>
</>
)}
</div>
</div>
)
}
export default TemplateMatchPanel

View File

@@ -11,11 +11,18 @@
*/
import { useState } from 'react'
import { Sparkles, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { SessionSuggestedFix } from '@/api/sessionSuggestedFixes'
interface SuggestedFixProps {
fix: SessionSuggestedFix
onDismiss: () => Promise<void> | void
// Phase 5: clicking the card body opens the inline Script Generator panel
// (TemplateMatchPanel for template-matched fixes, NoTemplateDialog otherwise).
onActivate?: () => void
// Whether the script panel is currently open for THIS fix — controls the
// "Open" / "Close" affordance label on the card.
panelOpen?: boolean
}
function confidenceBucket(pct: number): { label: string; tone: string } {
@@ -24,11 +31,12 @@ function confidenceBucket(pct: number): { label: string; tone: string } {
return { label: 'low', tone: 'text-muted-foreground' }
}
export function SuggestedFix({ fix, onDismiss }: SuggestedFixProps) {
export function SuggestedFix({ fix, onDismiss, onActivate, panelOpen }: SuggestedFixProps) {
const [busy, setBusy] = useState(false)
const conf = confidenceBucket(fix.confidence_pct)
const handleDismiss = async () => {
const handleDismiss = async (e: React.MouseEvent) => {
e.stopPropagation() // don't trigger the card-body activation
setBusy(true)
try { await onDismiss() } finally { setBusy(false) }
}
@@ -44,7 +52,14 @@ export function SuggestedFix({ fix, onDismiss }: SuggestedFixProps) {
</div>
</div>
<div className="rounded-lg border-l-[3px] border-l-warning border border-warning/25 bg-warning-dim/15 p-3 mb-2">
<div
onClick={onActivate}
className={cn(
'rounded-lg border-l-[3px] border-l-warning border border-warning/25 bg-warning-dim/15 p-3 mb-2 transition-colors',
onActivate && 'cursor-pointer hover:border-warning/50 hover:bg-warning-dim/25',
panelOpen && 'border-warning/60 bg-warning-dim/30',
)}
>
<div className="flex items-start gap-2">
<Sparkles size={14} className="text-warning shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
@@ -56,12 +71,12 @@ export function SuggestedFix({ fix, onDismiss }: SuggestedFixProps) {
</div>
{fix.script_template_id && (
<div className="mt-1.5 text-[0.6875rem] text-success">
Matches an existing Script Library template
Matches an existing Script Library template click to use
</div>
)}
{!fix.script_template_id && fix.ai_drafted_script && (
<div className="mt-1.5 text-[0.6875rem] text-accent-text">
Custom script drafted (no template match)
Custom script drafted click to review options
</div>
)}
</div>

View File

@@ -16,11 +16,15 @@ import { TaskLane, clearTaskState } from '@/components/assistant/TaskLane'
import { WhatWeKnow } from '@/components/pilot/sections/WhatWeKnow'
import { SuggestedFix } from '@/components/pilot/sections/SuggestedFix'
import { ResolutionNotePreview as ResolutionNotePreviewPopover } from '@/components/pilot/ResolutionNotePreview'
import { TemplateMatchPanel } from '@/components/pilot/script/TemplateMatchPanel'
import { NoTemplateDialog } from '@/components/pilot/script/NoTemplateDialog'
import { PILOT_INLINE_SCRIPT_EVENT } from '@/components/layout/CommandPalette'
import { sessionFactsApi, type SessionFact } from '@/api/sessionFacts'
import {
sessionSuggestedFixesApi,
type SessionSuggestedFix,
type ResolutionNotePreview as ResolutionNotePreviewData,
type UserDecision,
} from '@/api/sessionSuggestedFixes'
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
@@ -100,6 +104,11 @@ export default function AssistantChatPage() {
// dups, but the request itself still costs HTTP RTT).
const previewDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const previewOpen = previewKind !== null
// Phase 5: inline Script Generator panel state. Open <=> the engineer
// clicked the Suggested Fix card. Which panel renders is decided by
// whether the active fix has a script_template_id.
const [scriptPanelOpen, setScriptPanelOpen] = useState(false)
const [scriptDecisionBusy, setScriptDecisionBusy] = useState(false)
const [showOverflow, setShowOverflow] = useState(false)
const toggleSidebarCollapse = () => {
const next = !sidebarCollapsed
@@ -234,6 +243,21 @@ export default function AssistantChatPage() {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// Phase 5: Cmd+K → "Open inline Script Generator". Only acts when there
// is an active suggested fix on this session — otherwise we'd open an
// empty panel.
useEffect(() => {
const handler = () => {
if (activeFix) {
setScriptPanelOpen(true)
} else {
toast.info('No active suggested fix yet — wait for the AI to propose a resolution.')
}
}
window.addEventListener(PILOT_INLINE_SCRIPT_EVENT, handler as EventListener)
return () => window.removeEventListener(PILOT_INLINE_SCRIPT_EVENT, handler as EventListener)
}, [activeFix])
const loadChats = async () => {
try {
const sessions = await aiSessionsApi.listSessions({ session_type: 'chat', limit: 100 })
@@ -277,7 +301,13 @@ export default function AssistantChatPage() {
try {
const fix = await sessionSuggestedFixesApi.getActive(chatId)
if (currentChatRef.current !== chatId) return
setActiveFix(fix)
setActiveFix((prev) => {
// If the active fix changed (AI emitted a new SUGGEST_FIX that
// superseded the prior), close the script panel so the engineer
// isn't acting on stale draft state.
if (prev?.id !== fix?.id) setScriptPanelOpen(false)
return fix
})
} catch {
// No-fix-yet (404) is normalized to null inside the client. Genuine
// failures stay silent — accessory state, not load-bearing.
@@ -368,6 +398,7 @@ export default function AssistantChatPage() {
try {
await sessionSuggestedFixesApi.recordDecision(activeChatId, activeFix.id, 'dismissed')
setActiveFix(null)
setScriptPanelOpen(false)
// Dismissal bumps state_version on the server; reflect in preview.
schedulePreviewRefresh(activeChatId)
} catch {
@@ -375,6 +406,41 @@ export default function AssistantChatPage() {
}
}
// Phase 5: handle a path choice from NoTemplateDialog. one_off and
// draft_template just record the decision (returning the rendered script
// for display); build_template returns a redirect_path to the Script
// Builder, which we navigate to.
const handleScriptDecision = async (
decision: UserDecision,
options: { editedScript: string; parametersUsed: Record<string, string> },
) => {
if (!activeChatId || !activeFix) return
setScriptDecisionBusy(true)
try {
const out = await sessionSuggestedFixesApi.recordDecision(
activeChatId, activeFix.id, decision,
{ editedScript: options.editedScript, parametersUsed: options.parametersUsed },
)
// Decision endpoint bumps state_version — reflect in preview.
schedulePreviewRefresh(activeChatId)
if (decision === 'build_template' && out.redirect_path) {
navigate(out.redirect_path)
return
}
if (decision === 'one_off') {
toast.success('Recorded as one-off — script not added to library')
} else if (decision === 'draft_template') {
toast.success('Draft template queued — review after Resolve')
}
// Keep the panel open so the engineer can copy the rendered script.
} catch {
toast.error('Failed to record decision')
} finally {
setScriptDecisionBusy(false)
}
}
const handleOpenPreview = (kind: 'resolve' | 'escalate') => {
if (!activeChatId) return
// Opening a different kind clobbers the cached markdown so the popover
@@ -447,6 +513,7 @@ export default function AssistantChatPage() {
setPreviewData(null)
setPreviewError(null)
setPreviewKind(null)
setScriptPanelOpen(false)
// Fire facts + active-fix fetches in parallel with session detail.
refreshSessionDerived(chatId)
try {
@@ -496,6 +563,7 @@ export default function AssistantChatPage() {
setActiveQuestions([])
setActiveActions([])
setFacts([])
setScriptPanelOpen(false)
setMessages([])
setActiveSessionStatus('active')
setActivePsaTicketId(null)
@@ -1260,11 +1328,32 @@ export default function AssistantChatPage() {
}
suggestedFixSlot={
activeFix && (
<SuggestedFix fix={activeFix} onDismiss={handleDismissFix} />
<SuggestedFix
fix={activeFix}
onDismiss={handleDismissFix}
onActivate={() => setScriptPanelOpen((prev) => !prev)}
panelOpen={scriptPanelOpen}
/>
)
}
bottomSlot={
<>
{scriptPanelOpen && activeFix && activeChatId && (
activeFix.script_template_id ? (
<TemplateMatchPanel
fix={activeFix}
sessionId={activeChatId}
onClose={() => setScriptPanelOpen(false)}
/>
) : (
<NoTemplateDialog
fix={activeFix}
onClose={() => setScriptPanelOpen(false)}
onDecide={handleScriptDecision}
busy={scriptDecisionBusy}
/>
)
)}
<div className="flex items-center gap-3 px-3 mt-1">
<button
onClick={() => handleOpenPreview('resolve')}