diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 16618a66..f259c01d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "@sentry/vite-plugin": "^5.1.1", "@stripe/stripe-js": "^8.7.0", "@tailwindcss/vite": "^4.2.1", + "@types/react-syntax-highlighter": "^15.5.13", "@xyflow/react": "^12.10.0", "axios": "^1.13.4", "class-variance-authority": "^0.7.1", @@ -33,6 +34,7 @@ "react-helmet-async": "^3.0.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.13.0", + "react-syntax-highlighter": "^16.1.1", "recharts": "^3.7.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", @@ -362,7 +364,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2991,6 +2992,12 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.10", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", @@ -3010,6 +3017,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-syntax-highlighter": { + "version": "15.5.13", + "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", + "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -4837,6 +4853,19 @@ "dev": true, "license": "MIT" }, + "node_modules/fault": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -4946,6 +4975,14 @@ "node": ">= 6" } }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -5171,6 +5208,19 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -5211,6 +5261,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -5228,6 +5295,21 @@ "hermes-estree": "0.25.1" } }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/highlightjs-vue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", + "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", + "license": "CC0-1.0" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -5998,6 +6080,20 @@ "loose-envify": "cli.js" } }, + "node_modules/lowlight": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "license": "MIT", + "dependencies": { + "fault": "^1.0.0", + "highlight.js": "~10.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7196,6 +7292,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -7428,6 +7533,26 @@ "react-dom": ">=18" } }, + "node_modules/react-syntax-highlighter": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", + "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", + "lowlight": "^1.17.0", + "prismjs": "^1.30.0", + "refractor": "^5.0.0" + }, + "engines": { + "node": ">= 16.20.2" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, "node_modules/recharts": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", @@ -7497,6 +7622,22 @@ "redux": "^5.0.0" } }, + "node_modules/refractor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", + "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/prismjs": "^1.0.0", + "hastscript": "^9.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 721a2590..16079d27 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "@sentry/vite-plugin": "^5.1.1", "@stripe/stripe-js": "^8.7.0", "@tailwindcss/vite": "^4.2.1", + "@types/react-syntax-highlighter": "^15.5.13", "@xyflow/react": "^12.10.0", "axios": "^1.13.4", "class-variance-authority": "^0.7.1", @@ -46,6 +47,7 @@ "react-helmet-async": "^3.0.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.13.0", + "react-syntax-highlighter": "^16.1.1", "recharts": "^3.7.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", @@ -62,8 +64,8 @@ "@types/node": "^24.10.9", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", - "@vitest/coverage-v8": "^4.0.18", "@vitejs/plugin-react": "^5.1.1", + "@vitest/coverage-v8": "^4.0.18", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index dd813cf9..cc870ab0 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useCallback } from 'react' import { useLocation, useNavigate, Link } from 'react-router-dom' -import { Menu, X, LayoutGrid, Clock, Network, AlertTriangle, Code2, BarChart3, Settings, LogOut, Shield, Library } from 'lucide-react' +import { Menu, X, LayoutGrid, Clock, Network, AlertTriangle, Code2, Wand2, BarChart3, Settings, LogOut, Shield, Library } from 'lucide-react' import { useAuthStore } from '@/store/authStore' import { usePermissions } from '@/hooks/usePermissions' import { useUserPreferencesStore } from '@/store/userPreferencesStore' @@ -57,6 +57,7 @@ export function AppLayout() { { path: '/trees', label: 'Flows', icon: Network }, { path: '/step-library', label: 'Step Library', icon: Library }, { path: '/scripts', label: 'Scripts', icon: Code2 }, + { path: '/script-builder', label: 'Script Builder', icon: Wand2 }, { path: '/analytics', label: 'Analytics', icon: BarChart3 }, { path: '/account', label: 'Account', icon: Settings }, ] diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index abe4cd08..796034f7 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -3,7 +3,7 @@ import { useLocation } from 'react-router-dom' import { LayoutGrid, Network, Clock, FileOutput, BarChart3, TrendingUp, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, ListChecks, - BookOpen, Code2, Library, AlertTriangle, + BookOpen, Code2, Library, AlertTriangle, Wand2, } from 'lucide-react' import { cn } from '@/lib/utils' import { useUserPreferencesStore } from '@/store/userPreferencesStore' @@ -19,6 +19,7 @@ const NAV_COLORS = { exports: '#60a5fa', // blue-400 stepLib: '#fb923c', // orange-400 scripts: '#2dd4bf', // teal-400 + scriptBuilder: '#e879f9', // fuchsia-400 analytics: '#38bdf8', // sky-400 guides: '#a3e635', // lime-400 feedback: '#818cf8', // indigo-400 @@ -83,6 +84,7 @@ export function Sidebar() { + @@ -124,6 +126,7 @@ export function Sidebar() { /> + {/* Insights */} diff --git a/frontend/src/components/script-builder/SaveToLibraryDialog.tsx b/frontend/src/components/script-builder/SaveToLibraryDialog.tsx new file mode 100644 index 00000000..45713177 --- /dev/null +++ b/frontend/src/components/script-builder/SaveToLibraryDialog.tsx @@ -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([]) + const [isSaving, setIsSaving] = useState(false) + const [error, setError] = useState(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 ( + { if (e.target === e.currentTarget) onClose() }} + > + + {/* Header */} + + Save to Library + + + + + + {/* Form */} + + {/* Name */} + + + Name * + + 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" + /> + + + {/* Description */} + + + Description + + 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?" + /> + + + {/* Category */} + + + Category + + 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" + )} + > + No category + {categories.map((cat) => ( + {cat.name} + ))} + + + + {/* Share with team */} + + setShareWithTeam(e.target.checked)} + className="w-4 h-4 rounded border-border bg-card text-cyan-500 focus:ring-cyan-500/20" + /> + Share with team + + + {/* Error */} + {error && ( + {error} + )} + + {/* Actions */} + + + Cancel + + + {isSaving && } + {isSaving ? 'Saving...' : 'Save to Library'} + + + + + + ) +} diff --git a/frontend/src/components/script-builder/ScriptBuilderChat.tsx b/frontend/src/components/script-builder/ScriptBuilderChat.tsx new file mode 100644 index 00000000..5dcb4172 --- /dev/null +++ b/frontend/src/components/script-builder/ScriptBuilderChat.tsx @@ -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(null) + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages.length, isLoading]) + + if (messages.length === 0 && !isLoading) { + return ( + + + + + + + Script Builder + + + 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. + + + + ) + } + + return ( + + {messages.map((msg, idx) => ( + + {msg.role === 'assistant' && ( + + + + )} + + + {msg.role === 'assistant' ? ( + <> + + {msg.script && ( + onViewScript(msg.script!, msg.script_filename ?? null)} + onSave={onSaveScript} + /> + )} + > + ) : ( + {msg.content} + )} + + + {msg.role === 'user' && ( + + + + )} + + ))} + + {isLoading && ( + + + + + + + Generating script... + + + )} + + + + ) +} diff --git a/frontend/src/components/script-builder/ScriptBuilderInput.tsx b/frontend/src/components/script-builder/ScriptBuilderInput.tsx new file mode 100644 index 00000000..23147d9f --- /dev/null +++ b/frontend/src/components/script-builder/ScriptBuilderInput.tsx @@ -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(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) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + const canSend = value.trim().length > 0 && !disabled + + return ( + + 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 }} + /> + + + + + ) +} diff --git a/frontend/src/components/script-builder/ScriptCodeBlock.tsx b/frontend/src/components/script-builder/ScriptCodeBlock.tsx new file mode 100644 index 00000000..be335509 --- /dev/null +++ b/frontend/src/components/script-builder/ScriptCodeBlock.tsx @@ -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 = { + 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 ( + + {/* Header */} + + + {filename || 'script'} + + {lineCount != null && ( + + {lineCount} lines + + )} + + + {/* Code preview — clickable */} + + + {previewLines} + + {remainingLines > 0 && ( + + {"··· "}{remainingLines} more line{remainingLines !== 1 ? 's' : ''} + + )} + + + {/* Action buttons */} + + + + View Full Script + + + {copied ? : } + {copied ? 'Copied' : 'Copy'} + + { 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" + )} + > + + Save to Library + + + + ) +} diff --git a/frontend/src/components/script-builder/ScriptPreviewModal.tsx b/frontend/src/components/script-builder/ScriptPreviewModal.tsx new file mode 100644 index 00000000..f6db9e6f --- /dev/null +++ b/frontend/src/components/script-builder/ScriptPreviewModal.tsx @@ -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 = { + powershell: 'powershell', + bash: 'bash', + python: 'python', +} + +const LANGUAGE_LABELS: Record = { + 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 ( + { if (e.target === e.currentTarget) onClose() }} + > + + {/* Header */} + + + + {filename || 'script'} + + + {LANGUAGE_LABELS[language] || language} + + + + + {copied ? : } + {copied ? 'Copied' : 'Copy'} + + + + Save to Library + + + + + + + + {/* Code body */} + + + {script} + + + + {/* Footer */} + + + {lineCount} line{lineCount !== 1 ? 's' : ''} + + + Close & Return to Chat + + + + + ) +} diff --git a/frontend/src/pages/ScriptBuilderPage.tsx b/frontend/src/pages/ScriptBuilderPage.tsx new file mode 100644 index 00000000..ca567497 --- /dev/null +++ b/frontend/src/pages/ScriptBuilderPage.tsx @@ -0,0 +1,204 @@ +import { useState, useEffect } 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 { SaveToLibraryDialog } from '@/components/script-builder/SaveToLibraryDialog' +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(null) + const [messages, setMessages] = useState([]) + const [language, setLanguage] = useState('powershell') + const [isLoading, setIsLoading] = useState(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, + timestamp: new Date().toISOString(), + } + setMessages((prev) => [...prev, userMessage]) + setIsLoading(true) + + try { + // Create session if needed + let currentSession = session + if (!currentSession) { + currentSession = await scriptBuilderApi.createSession(effectiveLanguage) + setSession(currentSession) + } + + // 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, + timestamp: 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.'}`, + timestamp: 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 = () => { + setShowSaveDialog(false) + } + + // Derive default name from session title or filename + const defaultSaveName = session?.title + || session?.latest_script_filename + || 'Untitled Script' + + return ( + + {/* Header with language selector */} + + + + + + + + Script Builder + Describe what you need, AI generates the script + + + + {/* Language pills */} + + {LANGUAGES.map((lang) => ( + !hasMessages && setLanguage(lang.value)} + disabled={hasMessages} + className={cn( + "px-3 py-1.5 rounded-md text-xs font-label font-medium transition-all", + language === lang.value + ? "bg-gradient-brand text-[#101114]" + : hasMessages + ? "text-[#5a6170] cursor-not-allowed" + : "text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)]" + )} + > + {lang.label} + + ))} + + + + + {/* Chat area */} + + + {/* Input */} + + handleSend(content)} + disabled={isLoading} + /> + + + {/* Preview modal */} + {previewScript && ( + setPreviewScript(null)} + onSave={() => { + setPreviewScript(null) + setShowSaveDialog(true) + }} + /> + )} + + {/* Save dialog */} + {showSaveDialog && session && ( + setShowSaveDialog(false)} + onSaved={handleSaved} + /> + )} + + ) +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index e9d0f8ee..f438cda4 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -52,6 +52,7 @@ const FlowPilotSessionPage = lazy(() => import('@/pages/FlowPilotSessionPage')) const EscalationQueuePage = lazy(() => import('@/pages/EscalationQueuePage')) const ReviewQueuePage = lazy(() => import('@/pages/ReviewQueuePage')) const FlowPilotAnalyticsPage = lazy(() => import('@/pages/FlowPilotAnalyticsPage')) +const ScriptBuilderPage = lazy(() => import('@/pages/ScriptBuilderPage')) const KBAcceleratorPage = lazy(() => import('@/pages/KBAcceleratorPage')) const GuidesHubPage = lazy(() => import('@/pages/GuidesHubPage')) const GuideDetailPage = lazy(() => import('@/pages/GuideDetailPage')) @@ -190,6 +191,7 @@ export const router = sentryCreateBrowserRouter([ { path: 'step-library', element: page(StepLibraryPage) }, { path: 'scripts', element: page(ScriptLibraryPage) }, { path: 'scripts/manage', element: page(ScriptManagePage) }, + { path: 'script-builder', element: page(ScriptBuilderPage) }, { path: 'kb-accelerator', element: page(KBAcceleratorPage) }, { path: 'assistant', element: page(AssistantChatPage) }, { path: 'flow-assist', element: page(FlowAssistPage) },
{error}
+ 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. +
{msg.content}
Describe what you need, AI generates the script