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

@@ -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')}