diff --git a/backend/src/components/components.controller.ts b/backend/src/components/components.controller.ts index 141c95be..61e525f2 100644 --- a/backend/src/components/components.controller.ts +++ b/backend/src/components/components.controller.ts @@ -42,7 +42,10 @@ function serializeComponent(component: ReturnType) runner: component.runner, inputs: metadata.inputs ?? [], outputs: metadata.outputs ?? [], - parameters: metadata.parameters ?? [], + parameters: [ + ...(component.parameters ?? []), + ...(metadata.parameters ?? []), + ], examples: metadata.examples ?? [], }; } diff --git a/bun.lock b/bun.lock index b2b6c03e..ed26a74e 100644 --- a/bun.lock +++ b/bun.lock @@ -160,6 +160,19 @@ "typescript": "^5.9.3", }, }, + "packages/browser-harness": { + "name": "@shipsec/browser-harness", + "version": "0.1.0", + "dependencies": { + "@shipsec/shared": "*", + }, + "devDependencies": { + "@types/node": "^20.16.11", + "bun-types": "^1.2.23", + "playwright": "^1.49.1", + "typescript": "^5.6.3", + }, + }, "packages/component-sdk": { "name": "@shipsec/component-sdk", "version": "0.1.0", @@ -762,6 +775,8 @@ "@shipsec/backend-client": ["@shipsec/backend-client@workspace:packages/backend-client"], + "@shipsec/browser-harness": ["@shipsec/browser-harness@workspace:packages/browser-harness"], + "@shipsec/component-sdk": ["@shipsec/component-sdk@workspace:packages/component-sdk"], "@shipsec/shared": ["@shipsec/shared@workspace:packages/shared"], @@ -2272,6 +2287,10 @@ "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + "playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], + + "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], "pm2": ["pm2@6.0.14", "", { "dependencies": { "@pm2/agent": "~2.1.1", "@pm2/blessed": "0.1.81", "@pm2/io": "~6.1.0", "@pm2/js-api": "~0.8.0", "@pm2/pm2-version-check": "^1.0.4", "ansis": "4.0.0-node10", "async": "3.2.6", "chokidar": "3.6.0", "cli-tableau": "2.0.1", "commander": "2.15.1", "croner": "4.1.97", "dayjs": "1.11.15", "debug": "4.4.3", "enquirer": "2.3.6", "eventemitter2": "5.0.1", "fclone": "1.0.11", "js-yaml": "4.1.1", "mkdirp": "1.0.4", "needle": "2.4.0", "pidusage": "3.0.2", "pm2-axon": "~4.0.1", "pm2-axon-rpc": "~0.7.1", "pm2-deploy": "~1.0.2", "pm2-multimeter": "^0.1.2", "promptly": "2.2.0", "semver": "7.7.2", "source-map-support": "0.5.21", "sprintf-js": "1.1.2", "vizion": "~2.2.1" }, "optionalDependencies": { "pm2-sysmonit": "^1.2.8" }, "bin": { "pm2": "bin/pm2", "pm2-dev": "bin/pm2-dev", "pm2-docker": "bin/pm2-docker", "pm2-runtime": "bin/pm2-runtime" } }, "sha512-wX1FiFkzuT2H/UUEA8QNXDAA9MMHDsK/3UHj6Dkd5U7kxyigKDA5gyDw78ycTQZAuGCLWyUX5FiXEuVQWafukA=="], @@ -2956,6 +2975,10 @@ "@redocly/openapi-core/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], + "@shipsec/browser-harness/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + + "@shipsec/browser-harness/bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + "@shipsec/component-sdk/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], "@shipsec/component-sdk/bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], @@ -3094,6 +3117,8 @@ "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "pm2-sysmonit/pidusage": ["pidusage@2.0.21", "", { "dependencies": { "safe-buffer": "^5.2.1" } }, "sha512-cv3xAQos+pugVX+BfXpHsbyz/dLzX+lr44zNMsYiGxUw+kV5sgQCIcLd1z+0vq+KyC7dJ+/ts2PsfgWfSC3WXA=="], "postcss-import/resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], @@ -3240,6 +3265,10 @@ "@redocly/openapi-core/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@shipsec/browser-harness/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "@shipsec/browser-harness/bun-types/@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + "@shipsec/component-sdk/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@shipsec/component-sdk/bun-types/@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], diff --git a/frontend/src/components/browser/BrowserPanel.tsx b/frontend/src/components/browser/BrowserPanel.tsx new file mode 100644 index 00000000..b312d5b2 --- /dev/null +++ b/frontend/src/components/browser/BrowserPanel.tsx @@ -0,0 +1,654 @@ +import { useState, useCallback, useEffect, useRef } from 'react' +import { + Globe, + X, + Download, + Maximize2, + Minimize2, + ChevronLeft, + ChevronRight, + Image as ImageIcon, + Terminal, + AlertCircle, + CheckCircle2, + Loader2, + ExternalLink, +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' + +// ============================================================================ +// Types +// ============================================================================ + +export interface BrowserScreenshot { + name: string + artifactId?: string + fileId?: string + timestamp: string + url?: string // Download URL +} + +export interface BrowserActionStep { + action: string + success: boolean + timestamp: string + duration: number + error?: string + selector?: string + url?: string + text?: string +} + +export interface BrowserConsoleLog { + level: 'log' | 'warn' | 'error' | 'debug' | 'info' + text: string + timestamp: string +} + +export interface BrowserOutput { + success: boolean + results: BrowserActionStep[] + screenshots: BrowserScreenshot[] + consoleLogs: BrowserConsoleLog[] + finalUrl?: string + pageTitle?: string + error?: string +} + +export interface BrowserResultData { + output: BrowserOutput + status: 'pending' | 'running' | 'completed' | 'failed' +} + +// ============================================================================ +// Props +// ============================================================================ + +interface BrowserPanelProps { + nodeId: string + runId: string | null + data: BrowserResultData | null + onClose: () => void + /** + * Callback to download an artifact by ID + */ + onDownloadArtifact?: (fileId: string, fileName: string) => Promise + /** + * Callback to get artifact download URL + */ + getArtifactUrl?: (fileId: string) => Promise + /** + * Called when the panel is focused (for z-index stacking) + */ + onFocus?: () => void +} + +// ============================================================================ +// Helpers +// ============================================================================ + +const formatDuration = (ms: number): string => { + if (ms < 1000) return `${ms}ms` + return `${(ms / 1000).toFixed(1)}s` +} + +const formatTimestamp = (iso: string): string => { + try { + const date = new Date(iso) + return date.toLocaleTimeString() + } catch { + return iso + } +} + +const getActionLabel = (action: string): string => { + const labels: Record = { + goto: 'Navigate', + click: 'Click', + fill: 'Fill', + screenshot: 'Screenshot', + getHTML: 'Get HTML', + getText: 'Get Text', + waitFor: 'Wait', + evaluate: 'Evaluate', + select: 'Select', + hover: 'Hover', + scroll: 'Scroll', + } + return labels[action] || action +} + +const getActionIcon = (action: string): string => { + const icons: Record = { + goto: '🌐', + click: '👆', + fill: '✏️', + screenshot: '📸', + getHTML: '📄', + getText: '📝', + waitFor: '⏳', + evaluate: '🔧', + select: '📋', + hover: '🖱️', + scroll: '📜', + } + return icons[action] || '⚙️' +} + +const getConsoleLevelColor = (level: string): string => { + const colors: Record = { + log: 'text-foreground', + warn: 'text-yellow-500 dark:text-yellow-400', + error: 'text-red-500 dark:text-red-400', + debug: 'text-blue-500 dark:text-blue-400', + info: 'text-cyan-500 dark:text-cyan-400', + } + return colors[level] || 'text-foreground' +} + +// ============================================================================ +// Main Component +// ============================================================================ + +export function BrowserPanel({ + nodeId, + data, + onClose, + onDownloadArtifact, + getArtifactUrl, + onFocus, +}: BrowserPanelProps) { + const panelRef = useRef(null) + const [activeTab, setActiveTab] = useState<'screenshots' | 'steps' | 'console'>('screenshots') + const [selectedScreenshotIndex, setSelectedScreenshotIndex] = useState(0) + const [screenshotUrls, setScreenshotUrls] = useState>({}) + const [isFullscreen, setIsFullscreen] = useState(false) + const [isLoadingUrls, setIsLoadingUrls] = useState(false) + + const output = data?.output + const screenshots = output?.screenshots ?? [] + const steps = output?.results ?? [] + const consoleLogs = output?.consoleLogs ?? [] + const isRunning = data?.status === 'running' + const isCompleted = data?.status === 'completed' + const isFailed = data?.status === 'failed' + + // Load screenshot URLs + useEffect(() => { + const loadUrls = async () => { + if (!getArtifactUrl || screenshots.length === 0) return + + setIsLoadingUrls(true) + const urls: Record = {} + + for (const shot of screenshots) { + if (shot.fileId && !urls[shot.fileId]) { + try { + const url = await getArtifactUrl(shot.fileId) + if (url) { + urls[shot.fileId] = url + } + } catch (e) { + console.error(`Failed to load screenshot ${shot.fileId}:`, e) + } + } + } + + setScreenshotUrls(urls) + setIsLoadingUrls(false) + } + + void loadUrls() + }, [screenshots, getArtifactUrl]) + + // Focus handling for z-index stacking + useEffect(() => { + const panel = panelRef.current + if (!panel || !onFocus) return + + const handlePointerDown = (event: PointerEvent) => { + if (panel.contains(event.target as Node)) { + onFocus() + } + } + + document.addEventListener('pointerdown', handlePointerDown, { capture: true }) + return () => { + document.removeEventListener('pointerdown', handlePointerDown, { capture: true }) + } + }, [onFocus]) + + // Handle keyboard navigation + useEffect(() => { + if (!isFullscreen) return + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setIsFullscreen(false) + } else if (e.key === 'ArrowLeft') { + setSelectedScreenshotIndex(i => Math.max(0, i - 1)) + } else if (e.key === 'ArrowRight') { + setSelectedScreenshotIndex(i => Math.min(screenshots.length - 1, i + 1)) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [isFullscreen, screenshots.length]) + + // Reset selected screenshot when screenshots change + useEffect(() => { + if (screenshots.length > 0 && selectedScreenshotIndex >= screenshots.length) { + setSelectedScreenshotIndex(Math.max(0, screenshots.length - 1)) + } + }, [screenshots.length, selectedScreenshotIndex]) + + // Download handler + const handleDownload = useCallback(async (screenshot: BrowserScreenshot) => { + if (!screenshot.fileId || !onDownloadArtifact) return + + try { + await onDownloadArtifact(screenshot.fileId, `${screenshot.name}.png`) + } catch (e) { + console.error('Failed to download screenshot:', e) + } + }, [onDownloadArtifact]) + + // Status badge + const statusBadge = isRunning ? ( + + + Running + + ) : isFailed ? ( + + + Failed + + ) : isCompleted ? ( + + + Completed + + ) : ( + + Pending + + ) + + const selectedScreenshot = screenshots[selectedScreenshotIndex] + const selectedScreenshotUrl = selectedScreenshot?.fileId + ? screenshotUrls[selectedScreenshot.fileId] + : null + + const content = ( + <> + {/* Header */} +
+
+
+ + Browser Session +
+ {nodeId} + {statusBadge} +
+ +
+ + {/* Tab Navigation */} +
+ setActiveTab('screenshots')} + icon={} + label="Screenshots" + count={screenshots.length} + /> + setActiveTab('steps')} + icon={} + label="Steps" + count={steps.length} + /> + setActiveTab('console')} + icon={} + label="Console" + count={consoleLogs.length} + /> +
+ + {/* Content */} +
+ {activeTab === 'screenshots' && ( + setIsFullscreen(!isFullscreen)} + /> + )} + + {activeTab === 'steps' && ( + + )} + + {activeTab === 'console' && ( + + )} +
+ + ) + + return ( + <> +
+ {content} +
+ + {/* Fullscreen backdrop */} + {isFullscreen && ( +
setIsFullscreen(false)} + /> + )} + + ) +} + +// ============================================================================ +// Tab Components +// ============================================================================ + +interface TabButtonProps { + active: boolean + onClick: () => void + icon: React.ReactNode + label: string + count?: number +} + +function TabButton({ active, onClick, icon, label, count }: TabButtonProps) { + return ( + + ) +} + +interface ScreenshotsTabProps { + screenshots: BrowserScreenshot[] + selected: number + onSelect: (index: number) => void + selectedUrl: string | null + screenshotUrls: Record + isLoading: boolean + onDownload: (screenshot: BrowserScreenshot) => void + isFullscreen: boolean + onToggleFullscreen: () => void +} + +function ScreenshotsTab({ + screenshots, + selected, + onSelect, + selectedUrl, + screenshotUrls, + isLoading, + onDownload, + isFullscreen, + onToggleFullscreen, +}: ScreenshotsTabProps) { + if (screenshots.length === 0) { + return ( +
+ {isLoading ? ( +
+ + Loading screenshots... +
+ ) : ( +
+ + No screenshots captured +
+ )} +
+ ) + } + + const selectedScreenshot = screenshots[selected] + + return ( +
+ {/* Main screenshot preview */} +
+ {isLoading && !selectedUrl ? ( +
+ + Loading screenshot... +
+ ) : selectedUrl ? ( + {selectedScreenshot.name} + ) : ( +
Screenshot not available
+ )} + + {/* Navigation arrows */} + {screenshots.length > 1 && ( + <> + + + + )} + + {/* Toolbar */} +
+ + {selected + 1} / {screenshots.length} + + + +
+
+ + {/* Thumbnail strip */} +
+ {screenshots.map((shot, index) => ( + + ))} +
+
+ ) +} + +interface StepsTabProps { + steps: BrowserActionStep[] +} + +function StepsTab({ steps }: StepsTabProps) { + if (steps.length === 0) { + return ( +
+ No action steps recorded +
+ ) + } + + return ( +
+
+ {steps.map((step, index) => ( +
+
+ {getActionIcon(step.action)} +
+
+
+ {getActionLabel(step.action)} + + {formatDuration(step.duration)} + + {!step.success && ( + + Failed + + )} +
+ {step.selector && ( +
+ {step.selector} +
+ )} + {step.url && ( +
+ {step.url} +
+ )} + {step.text && ( +
+ {step.text.slice(0, 200)} + {step.text.length > 200 ? '...' : ''} +
+ )} + {step.error && ( +
+ {step.error} +
+ )} +
+
+ {formatTimestamp(step.timestamp)} +
+
+ ))} +
+
+ ) +} + +interface ConsoleTabProps { + logs: BrowserConsoleLog[] +} + +function ConsoleTab({ logs }: ConsoleTabProps) { + if (logs.length === 0) { + return ( +
+ No console logs captured +
+ ) + } + + return ( +
+
+ {logs.map((log, index) => ( +
+ + {formatTimestamp(log.timestamp)} + + + [{log.level.toUpperCase()}] + + {log.text} +
+ ))} +
+
+ ) +} diff --git a/frontend/src/components/browser/index.ts b/frontend/src/components/browser/index.ts new file mode 100644 index 00000000..8dfb43e2 --- /dev/null +++ b/frontend/src/components/browser/index.ts @@ -0,0 +1,8 @@ +export { BrowserPanel } from './BrowserPanel' +export type { + BrowserScreenshot, + BrowserActionStep, + BrowserConsoleLog, + BrowserOutput, + BrowserResultData, +} from './BrowserPanel' diff --git a/frontend/src/components/parameter-editors/BrowserActionsEditor.tsx b/frontend/src/components/parameter-editors/BrowserActionsEditor.tsx new file mode 100644 index 00000000..b4745a6e --- /dev/null +++ b/frontend/src/components/parameter-editors/BrowserActionsEditor.tsx @@ -0,0 +1,464 @@ +import { useState } from 'react' +import { + Trash2, + Globe, + MousePointer2, + Type, + Camera, + Code2, + Search, + FileJson, + ChevronDown, + ChevronUp, +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import { + Card, + CardContent, +} from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { cn } from '@/lib/utils' + +export interface BrowserAction { + type: string + [key: string]: any +} + +interface BrowserActionsEditorProps { + value: BrowserAction[] + onChange: (value: BrowserAction[]) => void +} + +const ACTION_TYPES = [ + { value: 'goto', label: 'Navigate', icon: Globe, description: 'Go to a URL' }, + { value: 'click', label: 'Click', icon: MousePointer2, description: 'Click an element' }, + { value: 'fill', label: 'Fill', icon: Type, description: 'Enter text into a field' }, + { value: 'screenshot', label: 'Screenshot', icon: Camera, description: 'Capture the page' }, + { value: 'waitFor', label: 'Wait for Element', icon: Search, description: 'Wait for a selector' }, + { value: 'hover', label: 'Hover', icon: MousePointer2, description: 'Hover over an element' }, + { value: 'scroll', label: 'Scroll', icon: ChevronDown, description: 'Scroll the page or to an element' }, + { value: 'select', label: 'Select', icon: FileJson, description: 'Select a dropdown option' }, + { value: 'evaluate', label: 'Script', icon: Code2, description: 'Run custom JavaScript' }, + { value: 'getText', label: 'Get Text', icon: Type, description: 'Extract text from element' }, + { value: 'getHTML', label: 'Get HTML', icon: Code2, description: 'Extract HTML from element' }, +] + +export function BrowserActionsEditor({ value = [], onChange }: BrowserActionsEditorProps) { + const [expandedIndex, setExpandedIndex] = useState(value.length > 0 ? value.length - 1 : null) + + const addAction = (type: string) => { + const defaults: Record = { + goto: { type: 'goto', url: '', waitUntil: 'load' }, + click: { type: 'click', selector: '', waitForSelector: true }, + fill: { type: 'fill', selector: '', value: '' }, + screenshot: { type: 'screenshot', name: 'screenshot', fullPage: false }, + waitFor: { type: 'waitFor', selector: '', state: 'visible' }, + hover: { type: 'hover', selector: '' }, + scroll: { type: 'scroll', position: 'bottom' }, + select: { type: 'select', selector: '', value: '' }, + evaluate: { type: 'evaluate', script: '' }, + getText: { type: 'getText', selector: '' }, + getHTML: { type: 'getHTML', selector: '' }, + } + + const newActions = [...value, defaults[type] || { type }] + onChange(newActions) + setExpandedIndex(newActions.length - 1) + } + + const removeAction = (index: number) => { + const newActions = value.filter((_, i) => i !== index) + onChange(newActions) + if (expandedIndex === index) setExpandedIndex(null) + else if (expandedIndex !== null && expandedIndex > index) setExpandedIndex(expandedIndex - 1) + } + + const updateAction = (index: number, updates: Partial) => { + const newActions = value.map((a, i) => (i === index ? { ...a, ...updates } : a)) + onChange(newActions) + } + + const moveAction = (index: number, direction: 'up' | 'down') => { + if (direction === 'up' && index === 0) return + if (direction === 'down' && index === value.length - 1) return + + const newActions = [...value] + const targetIndex = direction === 'up' ? index - 1 : index + 1 + ;[newActions[index], newActions[targetIndex]] = [newActions[targetIndex], newActions[index]] + + onChange(newActions) + if (expandedIndex === index) setExpandedIndex(targetIndex) + else if (expandedIndex === targetIndex) setExpandedIndex(index) + } + + return ( +
+ {/* Actions List */} +
+ {value.map((action, index) => ( + setExpandedIndex(expandedIndex === index ? null : index)} + onRemove={() => removeAction(index)} + onUpdate={(updates) => updateAction(index, updates)} + onMoveUp={() => moveAction(index, 'up')} + onMoveDown={() => moveAction(index, 'down')} + isFirst={index === 0} + isLast={index === value.length - 1} + /> + ))} + {value.length === 0 && ( +
+

No actions defined yet.

+

Add your first action using the buttons below.

+
+ )} +
+ + {/* Add Action Buttons */} +
+

Add Action

+
+ {ACTION_TYPES.map((type) => { + const Icon = type.icon + return ( + + ) + })} +
+
+
+ ) +} + +interface ActionItemProps { + index: number + action: BrowserAction + isExpanded: boolean + onToggle: () => void + onRemove: () => void + onUpdate: (updates: Partial) => void + onMoveUp: () => void + onMoveDown: () => void + isFirst: boolean + isLast: boolean +} + +function ActionItem({ + index, + action, + isExpanded, + onToggle, + onRemove, + onUpdate, + onMoveUp, + onMoveDown, + isFirst, + isLast +}: ActionItemProps) { + const typeConfig = ACTION_TYPES.find(t => t.value === action.type) || ACTION_TYPES[0] + const Icon = typeConfig.icon + + return ( + +
+
+ + +
+ +
+ +
+ +
+
+ {typeConfig.label} + #{index + 1} +
+
+ {getActionSummary(action)} +
+
+ +
+ + {isExpanded ? : } +
+
+ + {isExpanded && ( + + + + )} +
+ ) +} + +function getActionSummary(action: BrowserAction): string { + switch (action.type) { + case 'goto': return action.url || 'No URL specified' + case 'click': return action.selector || 'No selector' + case 'fill': return `${action.selector || '?'} ➔ ${action.value || '?'}` + case 'screenshot': return action.name || 'screenshot' + case 'waitFor': return `Wait for ${action.selector || '?'}` + case 'evaluate': return action.script ? `Execute: ${action.script.slice(0, 30)}...` : 'No script' + default: return action.selector || action.url || '' + } +} + +function ActionForm({ action, onUpdate }: { action: BrowserAction, onUpdate: (updates: Partial) => void }) { + const common = ( +
+
+ + onUpdate({ timeout: e.target.value ? parseInt(e.target.value) : undefined })} + placeholder="Use default" + className="h-8 text-xs" + /> +
+
+ ) + + switch (action.type) { + case 'goto': + return ( +
+
+ + onUpdate({ url: e.target.value })} + placeholder="https://example.com" + className="h-8 text-xs" + /> +
+
+ + +
+ {common} +
+ ) + case 'click': + case 'hover': + case 'getText': + case 'getHTML': + return ( +
+
+ + onUpdate({ selector: e.target.value })} + placeholder="e.g. button#login" + className="h-8 text-xs font-mono" + /> +
+ {action.type === 'click' && ( +
+ + onUpdate({ waitForSelector: v })} + /> +
+ )} + {common} +
+ ) + case 'fill': + case 'select': + return ( +
+
+ + onUpdate({ selector: e.target.value })} + placeholder="e.g. input[name='username']" + className="h-8 text-xs font-mono" + /> +
+
+ + onUpdate({ value: e.target.value })} + placeholder={action.type === 'fill' ? 'Text to type...' : 'Option value to select'} + className="h-8 text-xs" + /> +
+ {common} +
+ ) + case 'screenshot': + return ( +
+
+ + onUpdate({ name: e.target.value })} + placeholder="screenshot" + className="h-8 text-xs" + /> +
+
+ + onUpdate({ fullPage: v })} + /> +
+
+ ) + case 'waitFor': + return ( +
+
+ + onUpdate({ selector: e.target.value })} + placeholder="e.g. .success-message" + className="h-8 text-xs font-mono" + /> +
+
+ + +
+ {common} +
+ ) + case 'scroll': + return ( +
+
+ + +
+ {(!action.position || action.selector !== undefined) && ( +
+ + onUpdate({ selector: e.target.value, position: undefined })} + placeholder="e.g. #footer" + className="h-8 text-xs font-mono" + /> +
+ )} + {common} +
+ ) + case 'evaluate': + return ( +
+
+ +