diff --git a/frontend/src/components/pilot/ScriptBuilderTab.tsx b/frontend/src/components/pilot/ScriptBuilderTab.tsx new file mode 100644 index 00000000..662ca50c --- /dev/null +++ b/frontend/src/components/pilot/ScriptBuilderTab.tsx @@ -0,0 +1,264 @@ +/** + * 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