feat(pilot): ScriptBuilderTab controller
Owns the inline Script Builder session lifecycle: - Get-or-create (origin='pilot_inline', ai_session_id) on mount. - Renders ScriptBuilderChat in AI mode and CodeModeEditor (Monaco) in 'Write it myself' mode. Mode toggles via display:none so buffer and messages persist across switches. - Submit → sessionSuggestedFixesApi.patchScript; emits onScriptDrafted to parent, which refreshes the fix and hides the tab strip. - Relays in-progress state to the parent via onProgressChange for the ChatTabStrip's indicator dot. ScriptBuilderChat is untouched (stays presentational). Persistence semantics live on the controller, not the display component. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
264
frontend/src/components/pilot/ScriptBuilderTab.tsx
Normal file
264
frontend/src/components/pilot/ScriptBuilderTab.tsx
Normal file
@@ -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<string | null>(null)
|
||||
const [mode, setMode] = useState<Mode>('ai')
|
||||
const [messages, setMessages] = useState<ScriptBuilderMessage[]>([])
|
||||
const [editorBuffer, setEditorBuffer] = useState<string>(
|
||||
() => scaffoldForLanguage(LANGUAGE, fix.description),
|
||||
)
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [latestScript, setLatestScript] = useState<string | null>(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 (
|
||||
<div className="flex flex-col h-full bg-bg-page">
|
||||
{/* Mode switcher header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-default shrink-0">
|
||||
<div className="text-[12.5px] text-heading font-medium truncate pr-2">
|
||||
Script Builder · {fix.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{mode === 'ai' ? (
|
||||
<button
|
||||
onClick={() => setMode('editor')}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-[11.5px] text-muted-foreground border border-default hover:text-primary hover:border-hover transition-colors"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
Write it myself
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setMode('ai')}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-[11.5px] text-muted-foreground border border-default hover:text-primary hover:border-hover transition-colors"
|
||||
>
|
||||
<Sparkles size={11} />
|
||||
Back to AI
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mx-4 mt-2 text-[11.5px] text-danger bg-danger-dim border border-danger/30 rounded px-2 py-1 shrink-0">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content area — display:none on inactive mode so state persists */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{/* AI chat mode */}
|
||||
<div className={cn('flex flex-col h-full', mode !== 'ai' && 'hidden')}>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
|
||||
<ScriptBuilderChat
|
||||
messages={messages}
|
||||
language={LANGUAGE}
|
||||
onViewScript={handleViewScript}
|
||||
onSaveScript={handleAiSave}
|
||||
isLoading={aiLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<ScriptBuilderInput
|
||||
onSend={handleSendMessage}
|
||||
disabled={aiLoading || !builderSessionId}
|
||||
placeholder="Describe the script you need…"
|
||||
showSuggestions={messages.length === 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor (Monaco) mode */}
|
||||
<div className={cn('flex flex-col h-full', mode !== 'editor' && 'hidden')}>
|
||||
<div className="flex-1 min-h-0 p-4">
|
||||
<ScriptBodyEditor
|
||||
value={editorBuffer}
|
||||
onChange={setEditorBuffer}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 py-2 border-t border-default flex justify-end shrink-0">
|
||||
<button
|
||||
onClick={() => void handleSubmit(editorBuffer)}
|
||||
disabled={submitting || !editorBuffer.trim()}
|
||||
className={cn(
|
||||
'px-3.5 py-[7px] rounded text-[12.5px] font-semibold transition-colors',
|
||||
'bg-accent text-[#0a0d14] hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{submitting ? 'Saving…' : 'Submit script'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user