Files
resolutionflow/frontend/src/pages/ScriptBuilderPage.tsx
chihlasm cb33787c08 fix: close race conditions in script builder session and slug creation
- script_builder endpoint: pg_advisory_xact_lock on user_id before
  session count check, preventing concurrent creates from both passing
  the MAX_SESSIONS_PER_USER guard
- script_builder_service send_message: pg_advisory_xact_lock on session_id
  before message count check, preventing concurrent sends from both
  passing the MAX_MESSAGES_PER_SESSION guard
- script_builder_service save_to_library: replace check-then-insert slug
  logic with IntegrityError retry loop (3 attempts with fresh UUID suffix);
  add unique constraint on script_templates.slug (migration 070)
- ScriptBuilderPage: add creatingSessionRef to serialize concurrent
  handleSend calls that would otherwise both call createSession() while
  session is still null

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 05:09:42 +00:00

239 lines
8.2 KiB
TypeScript

import { useState, useEffect, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Terminal } from 'lucide-react'
import { cn } from '@/lib/utils'
import { scriptBuilderApi } from '@/api'
import { ScriptBuilderChat } from '@/components/script-builder/ScriptBuilderChat'
import { ScriptBuilderInput } from '@/components/script-builder/ScriptBuilderInput'
import { ScriptPreviewModal } from '@/components/script-builder/ScriptPreviewModal'
import { ParameterizeAndSavePanel } from '@/components/scripts/ParameterizeAndSavePanel'
import type { ScriptBuilderSessionDetail, ScriptBuilderMessage } from '@/types'
const LANGUAGES = [
{ value: 'powershell', label: 'PowerShell' },
{ value: 'bash', label: 'Bash' },
{ value: 'python', label: 'Python' },
] as const
export default function ScriptBuilderPage() {
const [searchParams] = useSearchParams()
const [session, setSession] = useState<ScriptBuilderSessionDetail | null>(null)
const [messages, setMessages] = useState<ScriptBuilderMessage[]>([])
const [language, setLanguage] = useState('powershell')
const [isLoading, setIsLoading] = useState(false)
// Ref-based lock: prevents two concurrent handleSend calls (e.g. FlowPilot
// handoff useEffect + user keystroke) from each calling createSession() and
// creating two orphaned sessions. React state updates are async so isLoading
// alone can't guard across two calls in the same render cycle.
const creatingSessionRef = useRef(false)
const [previewScript, setPreviewScript] = useState<{ script: string; filename: string | null } | null>(null)
const [showSaveDialog, setShowSaveDialog] = useState(false)
const [handoffProcessed, setHandoffProcessed] = useState(false)
const hasMessages = messages.length > 0
// Handle FlowPilot handoff on mount
useEffect(() => {
if (handoffProcessed) return
setHandoffProcessed(true)
const contextRaw = sessionStorage.getItem('scriptBuilderContext')
if (!contextRaw) return
try {
const context = JSON.parse(contextRaw) as {
from_session?: string
prompt?: string
language?: string
}
sessionStorage.removeItem('scriptBuilderContext')
if (context.language) {
setLanguage(context.language)
}
if (context.prompt) {
// Auto-send the prompt
handleSend(context.prompt, context.language || 'powershell')
}
} catch {
sessionStorage.removeItem('scriptBuilderContext')
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Suppress unused searchParams warning — used to detect ?from=flowpilot context
void searchParams
const handleSend = async (content: string, langOverride?: string) => {
const effectiveLanguage = langOverride || language
// Optimistically add user message
const userMessage: ScriptBuilderMessage = {
role: 'user',
content,
created_at: new Date().toISOString(),
}
setMessages((prev) => [...prev, userMessage])
setIsLoading(true)
try {
// Create session if needed
let currentSession = session
if (!currentSession) {
if (creatingSessionRef.current) {
// Another concurrent call is already creating the session; drop this send.
setIsLoading(false)
setMessages((prev) => prev.slice(0, -1))
return
}
creatingSessionRef.current = true
try {
currentSession = await scriptBuilderApi.createSession(effectiveLanguage)
setSession(currentSession)
} finally {
creatingSessionRef.current = false
}
}
// Send message
const response = await scriptBuilderApi.sendMessage(currentSession.id, content)
const assistantMessage: ScriptBuilderMessage = {
role: 'assistant',
content: response.content,
script: response.script,
script_filename: response.script_filename,
line_count: response.line_count,
created_at: response.timestamp,
}
setMessages((prev) => [...prev, assistantMessage])
} catch (err) {
// Add error message
const errorMessage: ScriptBuilderMessage = {
role: 'assistant',
content: `An error occurred: ${err instanceof Error ? err.message : 'Failed to generate response. Please try again.'}`,
created_at: new Date().toISOString(),
}
setMessages((prev) => [...prev, errorMessage])
} finally {
setIsLoading(false)
}
}
const handleViewScript = (script: string, filename: string | null) => {
setPreviewScript({ script, filename })
}
const handleSaveScript = () => {
setShowSaveDialog(true)
}
const handleSaved = async (payload: {
name: string
description: string | undefined
category_id: string | undefined
share_with_team: boolean
script_body: string
parameters_schema: { parameters: import('@/types').ScriptParameter[] }
}) => {
if (!session) return
await scriptBuilderApi.saveToLibrary(session.id, {
name: payload.name,
description: payload.description,
category_id: payload.category_id,
share_with_team: payload.share_with_team,
script_body: payload.script_body,
parameters_schema: payload.parameters_schema,
})
setShowSaveDialog(false)
}
// Derive default name from session title or filename
const defaultSaveName = session?.title
|| session?.latest_script_filename
|| 'Untitled Script'
return (
<div className="flex flex-col h-full min-h-0">
{/* Header with language selector */}
<div className="shrink-0 px-6 py-4 border-b" style={{ borderColor: 'var(--color-border-default)' }}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="flex items-center justify-center w-9 h-9 rounded-xl bg-[rgba(232,121,249,0.1)]">
<Terminal size={18} className="text-fuchsia-400" />
</span>
<div>
<h1 className="text-base font-heading font-bold text-foreground">Script Builder</h1>
<p className="text-xs text-muted-foreground">Describe what you need, AI generates the script</p>
</div>
</div>
{/* Language pills */}
<div className="flex items-center gap-1 p-0.5 rounded-lg bg-[rgba(255,255,255,0.03)] border border-[rgba(255,255,255,0.06)]">
{LANGUAGES.map((lang) => (
<button
key={lang.value}
onClick={() => !hasMessages && setLanguage(lang.value)}
disabled={hasMessages}
className={cn(
"px-3 py-1.5 rounded-md text-xs font-sans text-xs font-medium transition-all",
language === lang.value
? "bg-primary text-white"
: hasMessages
? "text-text-muted cursor-not-allowed"
: "text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)]"
)}
>
{lang.label}
</button>
))}
</div>
</div>
</div>
{/* Chat area */}
<ScriptBuilderChat
messages={messages}
language={language}
onViewScript={handleViewScript}
onSaveScript={handleSaveScript}
isLoading={isLoading}
/>
{/* Input */}
<div className="shrink-0">
<ScriptBuilderInput
onSend={(content) => handleSend(content)}
disabled={isLoading}
showSuggestions={messages.length === 0}
/>
</div>
{/* Preview modal */}
{previewScript && (
<ScriptPreviewModal
script={previewScript.script}
filename={previewScript.filename}
language={language}
onClose={() => setPreviewScript(null)}
onSave={() => {
setPreviewScript(null)
setShowSaveDialog(true)
}}
/>
)}
{/* Save panel */}
{showSaveDialog && session && session.latest_script && (
<ParameterizeAndSavePanel
scriptBody={session.latest_script}
language={session.language}
defaultName={defaultSaveName}
onSave={handleSaved}
onClose={() => setShowSaveDialog(false)}
/>
)}
</div>
)
}