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:
2026-04-24 02:50:27 -04:00
parent f92cbefed9
commit 04fbfe3b8f

View 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