feat: add Script Builder page with chat UI, code blocks, preview modal, and save dialog

- ScriptCodeBlock: collapsed code preview with syntax highlighting (first 5 lines)
- ScriptBuilderInput: auto-resize chat input with Enter-to-send
- ScriptBuilderChat: message list with markdown rendering and code blocks
- ScriptPreviewModal: fullscreen script viewer with line numbers
- SaveToLibraryDialog: save script with name, description, category, team sharing
- ScriptBuilderPage: language selector, session management, FlowPilot handoff
- Added route, sidebar nav item (fuchsia), and mobile nav entry

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-03-21 17:11:32 -04:00
parent 77cee8f6f3
commit 689776afd9
11 changed files with 1009 additions and 4 deletions

View File

@@ -0,0 +1,186 @@
import { useState, useEffect } from 'react'
import { X, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { scriptBuilderApi, scriptsApi } from '@/api'
import type { ScriptCategoryResponse } from '@/types'
interface SaveToLibraryDialogProps {
sessionId: string
defaultName: string
defaultDescription?: string
onClose: () => void
onSaved: () => void
}
export function SaveToLibraryDialog({
sessionId,
defaultName,
defaultDescription,
onClose,
onSaved,
}: SaveToLibraryDialogProps) {
const [name, setName] = useState(defaultName)
const [description, setDescription] = useState(defaultDescription || '')
const [categoryId, setCategoryId] = useState('')
const [shareWithTeam, setShareWithTeam] = useState(false)
const [categories, setCategories] = useState<ScriptCategoryResponse[]>([])
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
scriptsApi.getCategories().then(setCategories).catch(() => {})
}, [])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onClose])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return
setIsSaving(true)
setError(null)
try {
await scriptBuilderApi.saveToLibrary(sessionId, {
name: name.trim(),
description: description.trim() || undefined,
category_id: categoryId || undefined,
share_with_team: shareWithTeam,
})
onSaved()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save script. Please try again.')
} finally {
setIsSaving(false)
}
}
return (
<div
className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div className="glass-card-static max-w-md w-full mx-4 rounded-xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[rgba(255,255,255,0.06)]">
<h3 className="text-sm font-heading font-bold text-foreground">Save to Library</h3>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<X size={18} />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-5 space-y-4">
{/* Name */}
<div>
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1.5 block">
Name *
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
className={cn(
"w-full rounded-[10px] px-3 py-2 text-sm",
"border border-border bg-card text-foreground placeholder:text-muted-foreground",
"focus:outline-none focus:border-[rgba(6,182,212,0.3)] transition-colors"
)}
placeholder="Script name"
/>
</div>
{/* Description */}
<div>
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1.5 block">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className={cn(
"w-full rounded-[10px] px-3 py-2 text-sm resize-none",
"border border-border bg-card text-foreground placeholder:text-muted-foreground",
"focus:outline-none focus:border-[rgba(6,182,212,0.3)] transition-colors"
)}
placeholder="What does this script do?"
/>
</div>
{/* Category */}
<div>
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1.5 block">
Category
</label>
<select
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
className={cn(
"w-full rounded-[10px] px-3 py-2 text-sm",
"border border-border bg-card text-foreground",
"focus:outline-none focus:border-[rgba(6,182,212,0.3)] transition-colors"
)}
>
<option value="">No category</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
</div>
{/* Share with team */}
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={shareWithTeam}
onChange={(e) => setShareWithTeam(e.target.checked)}
className="w-4 h-4 rounded border-border bg-card text-cyan-500 focus:ring-cyan-500/20"
/>
<span className="text-sm text-foreground">Share with team</span>
</label>
{/* Error */}
{error && (
<p className="text-xs text-rose-400">{error}</p>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className={cn(
"px-4 py-2 rounded-[10px] text-sm font-medium transition-colors",
"bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground hover:border-[rgba(255,255,255,0.12)]"
)}
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim() || isSaving}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-[10px] text-sm font-semibold transition-all",
"bg-gradient-brand text-[#101114] hover:opacity-90 active:scale-[0.97]",
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
>
{isSaving && <Loader2 size={14} className="animate-spin" />}
{isSaving ? 'Saving...' : 'Save to Library'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,114 @@
import { useEffect, useRef } from 'react'
import { Bot, User, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { ScriptCodeBlock } from './ScriptCodeBlock'
import type { ScriptBuilderMessage } from '@/types'
interface ScriptBuilderChatProps {
messages: ScriptBuilderMessage[]
language: string
onViewScript: (script: string, filename: string | null) => void
onSaveScript: () => void
isLoading: boolean
}
export function ScriptBuilderChat({
messages,
language,
onViewScript,
onSaveScript,
isLoading,
}: ScriptBuilderChatProps) {
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages.length, isLoading])
if (messages.length === 0 && !isLoading) {
return (
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center max-w-md">
<div className="w-14 h-14 rounded-2xl bg-gradient-brand flex items-center justify-center mx-auto mb-4">
<Bot size={28} className="text-[#101114]" />
</div>
<h2 className="text-lg font-heading font-bold text-foreground mb-2">
Script Builder
</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
Describe the script you need and AI will generate it for you. You can iterate on the script,
preview it, and save it to your library.
</p>
</div>
</div>
)
}
return (
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((msg, idx) => (
<div
key={idx}
className={cn(
"flex gap-3",
msg.role === 'user' ? "justify-end" : "justify-start"
)}
>
{msg.role === 'assistant' && (
<div className="shrink-0 w-8 h-8 rounded-lg bg-[rgba(6,182,212,0.1)] flex items-center justify-center mt-0.5">
<Bot size={16} className="text-cyan-400" />
</div>
)}
<div
className={cn(
"max-w-[85%] rounded-xl px-4 py-3 text-sm",
msg.role === 'user'
? "bg-[rgba(6,182,212,0.08)] border border-[rgba(6,182,212,0.15)] text-foreground"
: "glass-card-static"
)}
>
{msg.role === 'assistant' ? (
<>
<MarkdownContent content={msg.content} />
{msg.script && (
<ScriptCodeBlock
script={msg.script}
filename={msg.script_filename ?? null}
lineCount={msg.line_count ?? null}
language={language}
onViewFull={() => onViewScript(msg.script!, msg.script_filename ?? null)}
onSave={onSaveScript}
/>
)}
</>
) : (
<p className="whitespace-pre-wrap">{msg.content}</p>
)}
</div>
{msg.role === 'user' && (
<div className="shrink-0 w-8 h-8 rounded-lg bg-[rgba(255,255,255,0.06)] flex items-center justify-center mt-0.5">
<User size={16} className="text-muted-foreground" />
</div>
)}
</div>
))}
{isLoading && (
<div className="flex gap-3 justify-start">
<div className="shrink-0 w-8 h-8 rounded-lg bg-[rgba(6,182,212,0.1)] flex items-center justify-center">
<Bot size={16} className="text-cyan-400" />
</div>
<div className="glass-card-static rounded-xl px-4 py-3 text-sm flex items-center gap-2">
<Loader2 size={14} className="animate-spin text-cyan-400" />
<span className="text-muted-foreground">Generating script...</span>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
)
}

View File

@@ -0,0 +1,78 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { Send } from 'lucide-react'
import { cn } from '@/lib/utils'
interface ScriptBuilderInputProps {
onSend: (content: string) => void
disabled: boolean
placeholder?: string
}
export function ScriptBuilderInput({
onSend,
disabled,
placeholder = 'Describe the script you need...',
}: ScriptBuilderInputProps) {
const [value, setValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const adjustHeight = useCallback(() => {
const textarea = textareaRef.current
if (!textarea) return
textarea.style.height = 'auto'
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`
}, [])
useEffect(() => {
adjustHeight()
}, [value, adjustHeight])
const handleSend = () => {
const trimmed = value.trim()
if (!trimmed || disabled) return
onSend(trimmed)
setValue('')
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
const canSend = value.trim().length > 0 && !disabled
return (
<div className="flex items-end gap-2 p-3 border-t" style={{ borderColor: 'var(--glass-border)' }}>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
rows={1}
className={cn(
"flex-1 resize-none rounded-xl px-4 py-2.5 text-sm",
"bg-card border border-border text-foreground placeholder:text-muted-foreground",
"focus:outline-none focus:border-[rgba(6,182,212,0.3)] transition-colors",
"disabled:opacity-50"
)}
style={{ maxHeight: 120 }}
/>
<button
onClick={handleSend}
disabled={!canSend}
className={cn(
"shrink-0 flex items-center justify-center w-10 h-10 rounded-xl transition-all",
canSend
? "bg-gradient-brand text-[#101114] hover:opacity-90 active:scale-[0.97]"
: "bg-[rgba(255,255,255,0.04)] text-[#5a6170] cursor-not-allowed"
)}
>
<Send size={18} />
</button>
</div>
)
}

View File

@@ -0,0 +1,129 @@
import { useState } from 'react'
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'
import powershell from 'react-syntax-highlighter/dist/esm/languages/hljs/powershell'
import bash from 'react-syntax-highlighter/dist/esm/languages/hljs/bash'
import python from 'react-syntax-highlighter/dist/esm/languages/hljs/python'
import atomOneDark from 'react-syntax-highlighter/dist/esm/styles/hljs/atom-one-dark'
import { Eye, Copy, Check, BookmarkPlus } from 'lucide-react'
import { cn } from '@/lib/utils'
SyntaxHighlighter.registerLanguage('powershell', powershell)
SyntaxHighlighter.registerLanguage('bash', bash)
SyntaxHighlighter.registerLanguage('python', python)
const LANGUAGE_MAP: Record<string, string> = {
powershell: 'powershell',
bash: 'bash',
python: 'python',
}
interface ScriptCodeBlockProps {
script: string
filename: string | null
lineCount: number | null
language: string
onViewFull: () => void
onSave: () => void
}
export function ScriptCodeBlock({
script,
filename,
lineCount,
language,
onViewFull,
onSave,
}: ScriptCodeBlockProps) {
const [copied, setCopied] = useState(false)
const lines = script.split('\n')
const previewLines = lines.slice(0, 5).join('\n')
const remainingLines = lines.length - 5
const hlLanguage = LANGUAGE_MAP[language] || 'powershell'
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation()
try {
await navigator.clipboard.writeText(script)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// clipboard not available
}
}
return (
<div className="mt-3 rounded-lg border bg-[rgba(0,0,0,0.3)] border-[rgba(255,255,255,0.06)] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-[rgba(255,255,255,0.06)]">
<span className="font-label text-xs text-cyan-400 truncate">
{filename || 'script'}
</span>
{lineCount != null && (
<span className="font-label text-[0.625rem] text-muted-foreground ml-2 shrink-0">
{lineCount} lines
</span>
)}
</div>
{/* Code preview — clickable */}
<button
onClick={onViewFull}
className="block w-full text-left cursor-pointer hover:bg-[rgba(255,255,255,0.02)] transition-colors"
>
<SyntaxHighlighter
language={hlLanguage}
style={atomOneDark}
customStyle={{
background: 'transparent',
padding: '12px',
margin: 0,
fontSize: '0.8125rem',
lineHeight: '1.5',
}}
wrapLongLines
>
{previewLines}
</SyntaxHighlighter>
{remainingLines > 0 && (
<div className="px-3 pb-2 font-label text-[0.625rem] text-[#5a6170]">
{"··· "}{remainingLines} more line{remainingLines !== 1 ? 's' : ''}
</div>
)}
</button>
{/* Action buttons */}
<div className="flex items-center gap-2 px-3 py-2 border-t border-[rgba(255,255,255,0.06)]">
<button
onClick={onViewFull}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-[10px] text-xs font-semibold transition-all",
"bg-gradient-brand text-[#101114] hover:opacity-90 active:scale-[0.97]"
)}
>
<Eye size={14} />
View Full Script
</button>
<button
onClick={handleCopy}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-[10px] text-xs font-medium transition-colors",
"bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground hover:border-[rgba(255,255,255,0.12)]"
)}
>
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
{copied ? 'Copied' : 'Copy'}
</button>
<button
onClick={(e) => { e.stopPropagation(); onSave() }}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-[10px] text-xs font-medium transition-colors",
"bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 hover:bg-emerald-500/15"
)}
>
<BookmarkPlus size={14} />
Save to Library
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,145 @@
import { useEffect, useState } from 'react'
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'
import atomOneDark from 'react-syntax-highlighter/dist/esm/styles/hljs/atom-one-dark'
import { X, Copy, Check, BookmarkPlus } from 'lucide-react'
import { cn } from '@/lib/utils'
const LANGUAGE_MAP: Record<string, string> = {
powershell: 'powershell',
bash: 'bash',
python: 'python',
}
const LANGUAGE_LABELS: Record<string, string> = {
powershell: 'PowerShell',
bash: 'Bash',
python: 'Python',
}
interface ScriptPreviewModalProps {
script: string
filename: string | null
language: string
onClose: () => void
onSave: () => void
}
export function ScriptPreviewModal({
script,
filename,
language,
onClose,
onSave,
}: ScriptPreviewModalProps) {
const [copied, setCopied] = useState(false)
const hlLanguage = LANGUAGE_MAP[language] || 'powershell'
const lineCount = script.split('\n').length
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onClose])
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(script)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// clipboard not available
}
}
return (
<div
className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div className="bg-[#18191f] rounded-xl border border-[rgba(255,255,255,0.08)] max-w-[900px] w-full mx-4 max-h-[85vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[rgba(255,255,255,0.06)]">
<div className="flex items-center gap-3 min-w-0">
<span className="font-label text-sm text-cyan-400 truncate">
{filename || 'script'}
</span>
<span className="shrink-0 font-label text-[0.625rem] uppercase tracking-wider px-2 py-0.5 rounded-full bg-[rgba(255,255,255,0.06)] text-muted-foreground">
{LANGUAGE_LABELS[language] || language}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-[10px] text-xs font-medium transition-colors",
"bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground hover:border-[rgba(255,255,255,0.12)]"
)}
>
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
{copied ? 'Copied' : 'Copy'}
</button>
<button
onClick={onSave}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-[10px] text-xs font-medium transition-colors",
"bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 hover:bg-emerald-500/15"
)}
>
<BookmarkPlus size={14} />
Save to Library
</button>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<X size={18} />
</button>
</div>
</div>
{/* Code body */}
<div className="flex-1 overflow-auto min-h-0">
<SyntaxHighlighter
language={hlLanguage}
style={atomOneDark}
showLineNumbers
customStyle={{
background: 'transparent',
padding: '16px',
margin: 0,
fontSize: '0.8125rem',
lineHeight: '1.6',
}}
lineNumberStyle={{
color: '#5a6170',
minWidth: '2.5em',
paddingRight: '1em',
userSelect: 'none',
}}
wrapLongLines
>
{script}
</SyntaxHighlighter>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-5 py-3 border-t border-[rgba(255,255,255,0.06)]">
<span className="font-label text-[0.625rem] text-muted-foreground">
{lineCount} line{lineCount !== 1 ? 's' : ''}
</span>
<button
onClick={onClose}
className={cn(
"px-4 py-1.5 rounded-[10px] text-xs font-medium transition-colors",
"bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground hover:border-[rgba(255,255,255,0.12)]"
)}
>
Close & Return to Chat
</button>
</div>
</div>
</div>
)
}