_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>
555 lines
20 KiB
TypeScript
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>
|
|
)
|
|
}
|