Files
resolutionflow/frontend/src/components/script-editor/ScriptTemplateEditor.tsx
chihlasm 96602a6676 fix: return pending_task_lane in session detail API response + debug logging
_build_session_detail was omitting pending_task_lane, is_branching, and
active_branch_id from the GET /ai-sessions/{id} response. The fields
existed on the schema and model but were never passed in the manual
constructor, so task lane state could never be restored on navigation.

Also adds console logging to AssistantChatPage selectChat flow to
diagnose message restoration, and fixes ScriptTemplateEditor stepper
dismiss firing during programmatic script_body updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:44:04 +00:00

555 lines
20 KiB
TypeScript

import { useState, useEffect, useRef } from 'react'
import { ArrowLeft, Loader2, Save, Scan, Trash2 } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Textarea } from '@/components/ui/Textarea'
import { usePermissions } from '@/hooks/usePermissions'
import { scriptsApi } from '@/api'
import { ScriptBodyEditor } from './ScriptBodyEditor'
import { ParameterSchemaBuilder } from './ParameterSchemaBuilder'
import { detectParameterCandidates } from '@/lib/scriptParameterDetector'
import { ParameterDetectorStepper } from './ParameterDetectorStepper'
import type {
ScriptTemplateDetail,
ScriptCategoryResponse,
ScriptParametersSchema,
ScriptTemplateCreateRequest,
ScriptTemplateUpdateRequest,
ParameterCandidate,
ScriptParameter,
} from '@/types'
interface Props {
templateId: string | null // null = create mode
onBack: () => void
onSaved: () => void
}
interface FormState {
name: string
description: string
use_case: string
category_id: string
complexity: 'beginner' | 'intermediate' | 'advanced'
tags: string
estimated_runtime: string
requires_elevation: boolean
requires_modules: string
script_body: string
parameters_schema: ScriptParametersSchema
}
const EMPTY_FORM: FormState = {
name: '',
description: '',
use_case: '',
category_id: '',
complexity: 'beginner',
tags: '',
estimated_runtime: '',
requires_elevation: false,
requires_modules: '',
script_body: '',
parameters_schema: { parameters: [] },
}
export function ScriptTemplateEditor({ templateId, onBack, onSaved }: Props) {
const [form, setForm] = useState<FormState>(EMPTY_FORM)
const [categories, setCategories] = useState<ScriptCategoryResponse[]>([])
const [isLoading, setIsLoading] = useState(!!templateId)
const [isSaving, setIsSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [isDirty, setIsDirty] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [template, setTemplate] = useState<ScriptTemplateDetail | null>(null)
const [detectedCandidates, setDetectedCandidates] = useState<ParameterCandidate[]>([])
const [showStepper, setShowStepper] = useState(false)
const [detectionSummary, setDetectionSummary] = useState<string | null>(null)
const acceptingCandidateRef = useRef(false)
const { canShareScriptTemplate } = usePermissions()
// Dismiss stepper if user manually edits the script body during detection
// (but NOT when handleAcceptCandidate programmatically updates script_body)
const scriptBodyRef = form.script_body
useEffect(() => {
if (showStepper && !acceptingCandidateRef.current) {
setShowStepper(false)
setDetectedCandidates([])
}
acceptingCandidateRef.current = false
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scriptBodyRef])
// Load categories + template detail (if editing)
useEffect(() => {
const load = async () => {
try {
const cats = await scriptsApi.getCategories()
setCategories(cats)
if (templateId) {
const detail = await scriptsApi.getTemplateDetail(templateId)
setTemplate(detail)
const schema = detail.parameters_schema as ScriptParametersSchema
setForm({
name: detail.name,
description: detail.description ?? '',
use_case: detail.use_case ?? '',
category_id: detail.category_id,
complexity: detail.complexity,
tags: detail.tags.join(', '),
estimated_runtime: detail.estimated_runtime ?? '',
requires_elevation: detail.requires_elevation,
requires_modules: detail.requires_modules.join(', '),
script_body: detail.script_body,
parameters_schema: schema ?? { parameters: [] },
})
} else if (cats.length > 0) {
setForm(f => ({ ...f, category_id: cats[0].id }))
}
} catch {
setSaveError('Failed to load data')
} finally {
setIsLoading(false)
}
}
load()
}, [templateId])
const updateField = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm(f => ({ ...f, [key]: value }))
setIsDirty(true)
}
const handleSave = async () => {
if (!form.name.trim()) {
setSaveError('Name is required')
return
}
if (!form.script_body.trim()) {
setSaveError('Script body is required')
return
}
if (!form.category_id) {
setSaveError('Category is required')
return
}
setIsSaving(true)
setSaveError(null)
const tags = form.tags.split(',').map(t => t.trim()).filter(Boolean)
const requires_modules = form.requires_modules.split(',').map(m => m.trim()).filter(Boolean)
try {
if (templateId) {
const data: ScriptTemplateUpdateRequest = {
name: form.name,
description: form.description || null,
use_case: form.use_case || null,
script_body: form.script_body,
parameters_schema: form.parameters_schema as unknown as ScriptParametersSchema,
tags,
complexity: form.complexity,
estimated_runtime: form.estimated_runtime || null,
requires_elevation: form.requires_elevation,
requires_modules,
}
await scriptsApi.updateTemplate(templateId, data)
} else {
const data: ScriptTemplateCreateRequest = {
category_id: form.category_id,
name: form.name,
description: form.description || null,
use_case: form.use_case || null,
script_body: form.script_body,
parameters_schema: form.parameters_schema as unknown as ScriptParametersSchema,
tags,
complexity: form.complexity,
estimated_runtime: form.estimated_runtime || null,
requires_elevation: form.requires_elevation,
requires_modules,
}
await scriptsApi.createTemplate(data)
}
setIsDirty(false)
onSaved()
} catch (err: unknown) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
setSaveError(axiosErr.response?.data?.detail ?? 'Failed to save template')
} finally {
setIsSaving(false)
}
}
const handleDelete = async () => {
if (!templateId) return
try {
await scriptsApi.deleteTemplate(templateId)
onSaved()
} catch {
setSaveError('Failed to delete template')
}
}
const handleShare = async (shared: boolean) => {
if (!templateId) return
try {
const updated = await scriptsApi.shareTemplate(templateId, shared)
setTemplate(updated)
} catch {
setSaveError('Failed to update sharing')
}
}
const handleBack = () => {
if (isDirty && !confirm('You have unsaved changes. Leave anyway?')) return
onBack()
}
const handleDetectParameters = () => {
const candidates = detectParameterCandidates(form.script_body)
if (candidates.length === 0) {
setDetectionSummary('No parameter candidates detected in the script body.')
setShowStepper(false)
setTimeout(() => setDetectionSummary(null), 4000)
return
}
setDetectedCandidates(candidates)
setDetectionSummary(null)
setShowStepper(true)
}
const handleAcceptCandidate = (
candidate: ParameterCandidate,
overrides: {
key: string
label: string
type: ScriptParameter['type']
sensitive: boolean
required: boolean
defaultValue: string | boolean | number | null
}
) => {
let updatedScript = form.script_body
if (candidate.source === 'param_block') {
const defaultMatch = candidate.matchedLine.match(/=\s*(.+?)(?:\s*,?\s*$)/)
if (defaultMatch) {
updatedScript = updatedScript.replace(
candidate.matchedLine,
candidate.matchedLine.replace(defaultMatch[1], `'{{${overrides.key}}}'`)
)
}
} else {
const assignMatch = candidate.matchedLine.match(/=\s*(.+)$/)
if (assignMatch) {
updatedScript = updatedScript.replace(
candidate.matchedLine,
candidate.matchedLine.replace(assignMatch[1], `'{{${overrides.key}}}'`)
)
}
}
const existingParams = form.parameters_schema.parameters
const newParam: ScriptParameter = {
key: overrides.key,
label: overrides.label,
type: overrides.type,
required: overrides.required,
placeholder: null,
group: null,
order: existingParams.length + 1,
help_text: null,
options: null,
default: overrides.defaultValue,
validation: null,
sensitive: overrides.sensitive,
}
acceptingCandidateRef.current = true
setForm(f => ({
...f,
script_body: updatedScript,
parameters_schema: {
parameters: [...f.parameters_schema.parameters, newParam],
},
}))
setIsDirty(true)
}
const handleSkipCandidate = () => {
// Nothing to do — stepper advances internally
}
const handleDetectionFinish = (acceptedCount: number, totalCount: number) => {
setShowStepper(false)
setDetectedCandidates([])
setDetectionSummary(
acceptedCount === 0
? 'No parameters were added.'
: `Added ${acceptedCount} of ${totalCount} detected parameter${totalCount !== 1 ? 's' : ''}.`
)
setTimeout(() => setDetectionSummary(null), 5000)
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 size={28} className="text-primary animate-spin" />
</div>
)
}
return (
<div className="flex flex-col gap-6 pb-24">
{/* Back link */}
<button
type="button"
onClick={handleBack}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-fit"
>
<ArrowLeft size={12} />
Back to templates
</button>
<h1 className="text-2xl font-heading font-bold text-foreground">
{templateId ? 'Edit Template' : 'New Template'}
</h1>
{/* ── Metadata ──────────────────────────────────────────────── */}
<section className="card-flat p-5 space-y-4">
<p className="font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Metadata</p>
<div>
<label className="text-sm font-medium text-foreground mb-1 block">
Name <span className="text-red-400">*</span>
</label>
<Input
value={form.name}
onChange={e => updateField('name', e.target.value)}
placeholder="e.g. Create AD User"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-foreground mb-1 block">Description</label>
<Textarea
value={form.description}
onChange={e => updateField('description', e.target.value)}
placeholder="What does this script do?"
rows={3}
/>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-1 block">Use Case</label>
<Textarea
value={form.use_case}
onChange={e => updateField('use_case', e.target.value)}
placeholder="When would you use this?"
rows={3}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium text-foreground mb-1 block">
Category <span className="text-red-400">*</span>
</label>
<select
value={form.category_id}
onChange={e => updateField('category_id', e.target.value)}
className="w-full rounded-lg border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(249,115,22,0.3)]"
>
<option value="">Select category</option>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-1 block">Complexity</label>
<select
value={form.complexity}
onChange={e => updateField('complexity', e.target.value as FormState['complexity'])}
className="w-full rounded-lg border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(249,115,22,0.3)]"
>
<option value="beginner">Beginner</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced</option>
</select>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-1 block">Estimated Runtime</label>
<Input
value={form.estimated_runtime}
onChange={e => updateField('estimated_runtime', e.target.value)}
placeholder="e.g. 30 seconds"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-foreground mb-1 block">Tags (comma-separated)</label>
<Input
value={form.tags}
onChange={e => updateField('tags', e.target.value)}
placeholder="active-directory, user, onboarding"
/>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-1 block">Required Modules (comma-separated)</label>
<Input
value={form.requires_modules}
onChange={e => updateField('requires_modules', e.target.value)}
placeholder="ActiveDirectory, GroupPolicy"
/>
</div>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={form.requires_elevation}
onChange={e => updateField('requires_elevation', e.target.checked)}
className="rounded border-border"
/>
Requires elevation (Run as Administrator)
</label>
{/* Share toggle — only for owners/admins editing an existing template */}
{templateId && template && canShareScriptTemplate && (
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={template.team_id !== null}
onChange={e => handleShare(e.target.checked)}
className="rounded border-border"
/>
Share with team
<span className="text-xs text-muted-foreground">(visible to all team members)</span>
</label>
)}
</div>
</section>
{/* ── Script Body ───────────────────────────────────────────── */}
<section className="card-flat p-5 space-y-3">
<p className="font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Script Body <span className="text-red-400">*</span>
</p>
<p className="text-xs text-muted-foreground">
Use <code className="font-sans text-xs text-amber-400">{'{{param_key}}'}</code> for parameter placeholders.
Supports <code className="font-sans text-xs text-amber-400">{'{% if param %} ... {% endif %}'}</code> conditionals
and filters like <code className="font-sans text-xs text-amber-400">{'{{ param | as_secure_string }}'}</code>.
</p>
<ScriptBodyEditor
value={form.script_body}
onChange={v => updateField('script_body', v)}
/>
{/* Detect Parameters button + stepper */}
{form.script_body.trim() && !showStepper && (
<button
type="button"
onClick={handleDetectParameters}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)] px-3 py-1.5 rounded-lg transition-all"
>
<Scan size={14} />
Detect Parameters
</button>
)}
{detectionSummary && (
<p className="text-xs text-muted-foreground italic">{detectionSummary}</p>
)}
{showStepper && detectedCandidates.length > 0 && (
<ParameterDetectorStepper
candidates={detectedCandidates}
existingKeys={form.parameters_schema.parameters.map(p => p.key)}
onAccept={handleAcceptCandidate}
onSkip={handleSkipCandidate}
onFinish={handleDetectionFinish}
/>
)}
</section>
{/* ── Parameters Schema ─────────────────────────────────────── */}
<section className="card-flat p-5 space-y-3">
<p className="font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Parameters</p>
<p className="text-xs text-muted-foreground">
Define form fields that users fill in when generating a script. Each parameter maps to a <code className="font-sans text-xs text-amber-400">{'{{key}}'}</code> placeholder in the script body.
</p>
<ParameterSchemaBuilder
schema={form.parameters_schema}
onChange={v => updateField('parameters_schema', v)}
/>
</section>
{/* ── Fixed Action Bar ──────────────────────────────────────── */}
<div className="fixed bottom-0 left-0 right-0 z-20 border-t border-border bg-background/80 px-6 py-3">
<div className="max-w-5xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleSave}
disabled={isSaving}
className="flex items-center gap-1.5 bg-primary text-white font-semibold text-sm px-5 py-2 rounded-lg hover:brightness-110 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
{templateId ? 'Save Changes' : 'Create Template'}
</button>
<button
type="button"
onClick={handleBack}
className="text-sm text-muted-foreground hover:text-foreground transition-colors px-4 py-2"
>
Cancel
</button>
</div>
{templateId && (
deleteConfirm ? (
<div className="flex items-center gap-2">
<span className="text-xs text-rose-500">Delete this template?</span>
<button
type="button"
onClick={handleDelete}
className="text-xs font-sans text-xs text-rose-500 hover:text-rose-400 px-2 py-1"
>
Confirm
</button>
<button
type="button"
onClick={() => setDeleteConfirm(false)}
className="text-xs font-sans text-xs text-muted-foreground hover:text-foreground px-2 py-1"
>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setDeleteConfirm(true)}
className="flex items-center gap-1.5 text-sm text-rose-500 hover:text-rose-400 transition-colors px-3 py-2"
>
<Trash2 size={14} />
Delete
</button>
)
)}
</div>
</div>
{/* Save error */}
{saveError && (
<p className="text-sm text-rose-500 text-center">{saveError}</p>
)}
</div>
)
}