/** * ScriptBuilderTab — inline Script Builder controller mounted in the * FlowPilot chat region when a fix needs a script drafted. * * Owns: * - The inline `script_builder_sessions` row (get-or-create via the * POST endpoint with origin='pilot_inline' + ai_session_id). * - AI message state (reuses existing ScriptBuilderChat + ScriptBuilderInput). * - Monaco buffer for the "Write it myself" mode (via ScriptBodyEditor). * - Submit → PATCH /suggested-fixes/:id/script (no applied_at stamp). * * ScriptBuilderChat stays a pure display component — this controller * wires its onSaveScript to the inline submit path. * * NOTE: CodeModeEditor is NOT used here — it's tightly coupled to * treeEditorStore. ScriptBodyEditor is the generic Monaco wrapper. * * NOTE: `onProgressChange` is expected to be wrapped in useCallback at * the call site so the dependency array in the relay effect is stable. * * Language is hardcoded to 'powershell' for Phase 9 v1. Future: derive * from fix metadata or let the engineer pick. */ import { useEffect, useState } from 'react' import { Sparkles, Pencil } from 'lucide-react' import { cn } from '@/lib/utils' import { ScriptBuilderChat } from '@/components/script-builder/ScriptBuilderChat' import { ScriptBuilderInput } from '@/components/script-builder/ScriptBuilderInput' import { ScriptBodyEditor } from '@/components/script-editor/ScriptBodyEditor' import { sessionSuggestedFixesApi } from '@/api/sessionSuggestedFixes' import { scriptBuilderApi } from '@/api' import type { SessionSuggestedFix } from '@/api/sessionSuggestedFixes' import type { ScriptBuilderMessage } from '@/types' export interface ScriptBuilderTabProps { fix: SessionSuggestedFix pilotSessionId: string /** Fires whenever in-progress state changes (for the ChatTabStrip dot). * Wrap in useCallback at the call site to keep the relay effect stable. */ onProgressChange: (hasProgress: boolean) => void /** Fires on successful submit; parent uses this to refresh the fix and hide the tab. */ onScriptDrafted: (updated: SessionSuggestedFix) => void } type Mode = 'ai' | 'editor' const LANGUAGE = 'powershell' export function ScriptBuilderTab({ fix, pilotSessionId, onProgressChange, onScriptDrafted, }: ScriptBuilderTabProps) { const [builderSessionId, setBuilderSessionId] = useState(null) const [mode, setMode] = useState('ai') const [messages, setMessages] = useState([]) const [editorBuffer, setEditorBuffer] = useState( () => scaffoldForLanguage(LANGUAGE, fix.description), ) const [aiLoading, setAiLoading] = useState(false) const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) const [latestScript, setLatestScript] = useState(null) // Relay in-progress state to parent. Stable as long as onProgressChange // is wrapped in useCallback at the call site. useEffect(() => { const initialScaffold = scaffoldForLanguage(LANGUAGE, fix.description).trim() const hasProgress = messages.length > 0 || editorBuffer.trim() !== initialScaffold onProgressChange(hasProgress) }, [messages.length, editorBuffer, fix.description, onProgressChange]) // Get-or-create the inline session on mount (keyed to pilotSessionId so // a new pilot session doesn't reuse a stale builder session). useEffect(() => { let cancelled = false ;(async () => { try { const s = await scriptBuilderApi.createSession(LANGUAGE, { origin: 'pilot_inline', aiSessionId: pilotSessionId, }) if (cancelled) return setBuilderSessionId(s.id) // Resume existing messages if the session was already started // (e.g. page refresh). getSession() returns the detail with messages[]. // listMessages() does NOT exist in the API client — use getSession instead. if (s.messages && s.messages.length > 0) { setMessages(s.messages) } if (s.latest_script) setLatestScript(s.latest_script) } catch { if (!cancelled) setError('Failed to start Script Builder session.') } })() return () => { cancelled = true } }, [pilotSessionId]) const handleSendMessage = async (content: string) => { if (!builderSessionId) return setAiLoading(true) setError(null) // Optimistically add the user message to the list immediately. const userMsg: ScriptBuilderMessage = { role: 'user', content, created_at: new Date().toISOString(), } setMessages((prev) => [...prev, userMsg]) try { // sendMessage returns ScriptBuilderMessageResponse (single assistant reply). const reply = await scriptBuilderApi.sendMessage(builderSessionId, content) const assistantMsg: ScriptBuilderMessage = { role: 'assistant', content: reply.content, script: reply.script, script_filename: reply.script_filename, line_count: reply.line_count, created_at: reply.timestamp, } setMessages((prev) => [...prev, assistantMsg]) if (reply.script) setLatestScript(reply.script) } catch { // Replace optimistic user message with an error reply so the user sees it. const errMsg: ScriptBuilderMessage = { role: 'assistant', content: 'Something went wrong generating the script. Please try again.', created_at: new Date().toISOString(), } setMessages((prev) => [...prev, errMsg]) } finally { setAiLoading(false) } } const handleSubmit = async (script: string) => { if (!script.trim() || submitting) return setSubmitting(true) setError(null) try { const updated = await sessionSuggestedFixesApi.patchScript( pilotSessionId, fix.id, script, ) onScriptDrafted(updated) } catch { setError('Failed to save the drafted script.') } finally { setSubmitting(false) } } // AI mode: save the latest script produced by the AI. const handleAiSave = () => { if (latestScript) void handleSubmit(latestScript) } // onViewScript is required by ScriptBuilderChat — provide a no-op for now // (inline preview is a future extension). const handleViewScript = (_script: string, _filename: string | null) => { // Future: open inline preview panel } return (
{/* Mode switcher header */}
Script Builder · {fix.title}
{mode === 'ai' ? ( ) : ( )}
{error && (
{error}
)} {/* Content area — display:none on inactive mode so state persists */}
{/* AI chat mode */}
{/* Editor (Monaco) mode */}
) } function scaffoldForLanguage(language: string, fixDescription: string): string { if (language === 'bash' || language === 'python') { return `# ${fixDescription}\n\n` } // PowerShell uses CRLF line endings by convention return `# ${fixDescription}\r\n\r\n` } export default ScriptBuilderTab