From 3ddf79f1e5352ef2d3651c8751d25c5e10ab23a8 Mon Sep 17 00:00:00 2001 From: Sergey Date: Fri, 13 Mar 2026 11:24:19 -0700 Subject: [PATCH 1/5] Add "Copy to clipboard" button for Output Data and Logs in test step panel For more details see issue https://github.com/openutm/verification/issues/108 --- .../components/ScenarioEditor/BottomPanel.tsx | 92 ++++++++++++++----- web-editor/src/styles/BottomPanel.module.css | 34 +++++++ 2 files changed, 104 insertions(+), 22 deletions(-) diff --git a/web-editor/src/components/ScenarioEditor/BottomPanel.tsx b/web-editor/src/components/ScenarioEditor/BottomPanel.tsx index c6e7d211..81d2bb0e 100644 --- a/web-editor/src/components/ScenarioEditor/BottomPanel.tsx +++ b/web-editor/src/components/ScenarioEditor/BottomPanel.tsx @@ -1,5 +1,5 @@ -import { useState, useMemo } from 'react'; -import { X } from 'lucide-react'; +import { useState, useMemo, useCallback } from 'react'; +import { X, Copy, Check } from 'lucide-react'; import layoutStyles from '../../styles/EditorLayout.module.css'; import panelStyles from '../../styles/SidebarPanel.module.css'; import styles from '../../styles/BottomPanel.module.css'; @@ -50,6 +50,7 @@ export const BottomPanel = ({ selectedNode, onClose }: BottomPanelProps) => { const { panelHeight, isResizing, startResizing } = useBottomPanelResize(); const [activeTab, setActiveTab] = useState<'output' | 'logs'>('output'); const [logLevel, setLogLevel] = useState('ALL'); + const [copied, setCopied] = useState(false); const result = selectedNode?.data?.result; const nodeLogs = (selectedNode?.data as { logs?: string[] } | undefined)?.logs; @@ -66,10 +67,41 @@ export const BottomPanel = ({ selectedNode, onClose }: BottomPanelProps) => { }); }, [logs, logLevel]); + const handleCopy = useCallback(() => { + let textToCopy = ''; + if (activeTab === 'output' && result) { + textToCopy = JSON.stringify(result, null, 2); + } else if (activeTab === 'logs' && filteredLogs.length > 0) { + textToCopy = filteredLogs.join('\n'); + } + if (!textToCopy) return; + + navigator.clipboard.writeText(textToCopy).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }, [activeTab, result, filteredLogs]); + + const canCopy = activeTab === 'output' ? !!result : filteredLogs.length > 0; + if (!selectedNode) return null; const { status, label } = selectedNode.data; + // Extract background and text color based on status + let statusBgColor = 'var(--bg-secondary)'; + let statusTextColor = 'var(--text-secondary)'; + if (status === 'success') { + statusBgColor = 'var(--success-bg)'; + statusTextColor = 'var(--success)'; + } else if (status === 'failure') { + statusBgColor = 'var(--danger-bg)'; + statusTextColor = 'var(--danger)'; + } else if (status === 'skipped') { + statusBgColor = 'var(--bg-tertiary)'; + statusTextColor = 'var(--text-tertiary)'; + } + return (
- - +
+ + +
+ {canCopy && ( + + )}
diff --git a/web-editor/src/styles/BottomPanel.module.css b/web-editor/src/styles/BottomPanel.module.css index aec57e3d..8b775e9f 100644 --- a/web-editor/src/styles/BottomPanel.module.css +++ b/web-editor/src/styles/BottomPanel.module.css @@ -28,11 +28,17 @@ .tabContainer { display: flex; + justify-content: space-between; + align-items: center; border-bottom: 1px solid var(--border-color); background-color: var(--bg-primary); padding: 0 16px; } +.tabGroup { + display: flex; +} + .tab { padding: 8px 16px; font-size: 13px; @@ -84,6 +90,34 @@ border-color: var(--accent-primary); } +.copyButton { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-secondary); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.copyButton:hover { + color: var(--text-primary); + background-color: var(--bg-secondary); +} + +.copyButtonSuccess { + color: var(--success); +} + +.copyButtonSuccess:hover { + color: var(--success); +} + .jsonContainer { font-family: 'JetBrains Mono', monospace; font-size: 13px; From 2eb04920f2a4832818e433d65c77e477a0590fc4 Mon Sep 17 00:00:00 2001 From: Sergey Date: Fri, 13 Mar 2026 12:00:34 -0700 Subject: [PATCH 2/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/ScenarioEditor/BottomPanel.tsx | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/web-editor/src/components/ScenarioEditor/BottomPanel.tsx b/web-editor/src/components/ScenarioEditor/BottomPanel.tsx index 81d2bb0e..185ddfc8 100644 --- a/web-editor/src/components/ScenarioEditor/BottomPanel.tsx +++ b/web-editor/src/components/ScenarioEditor/BottomPanel.tsx @@ -76,10 +76,49 @@ export const BottomPanel = ({ selectedNode, onClose }: BottomPanelProps) => { } if (!textToCopy) return; - navigator.clipboard.writeText(textToCopy).then(() => { + const handleSuccess = () => { setCopied(true); setTimeout(() => setCopied(false), 2000); - }); + }; + + const fallbackCopy = () => { + try { + const textarea = document.createElement('textarea'); + textarea.value = textToCopy; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'absolute'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + const selection = document.getSelection(); + const selected = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + textarea.select(); + const successful = document.execCommand('copy'); + document.body.removeChild(textarea); + if (selected && selection) { + selection.removeAllRanges(); + selection.addRange(selected); + } + if (successful) { + handleSuccess(); + } else if (typeof window !== 'undefined' && typeof window.alert === 'function') { + window.alert('Failed to copy to clipboard.'); + } + } catch { + if (typeof window !== 'undefined' && typeof window.alert === 'function') { + window.alert('Failed to copy to clipboard.'); + } + } + }; + + if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + navigator.clipboard.writeText(textToCopy) + .then(handleSuccess) + .catch(() => { + fallbackCopy(); + }); + } else { + fallbackCopy(); + } }, [activeTab, result, filteredLogs]); const canCopy = activeTab === 'output' ? !!result : filteredLogs.length > 0; From d81816aea5bf69f5259e57a71797ee3ce1ce8a14 Mon Sep 17 00:00:00 2001 From: Sergey Date: Fri, 13 Mar 2026 12:12:37 -0700 Subject: [PATCH 3/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/ScenarioEditor/BottomPanel.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/web-editor/src/components/ScenarioEditor/BottomPanel.tsx b/web-editor/src/components/ScenarioEditor/BottomPanel.tsx index 185ddfc8..80a61eee 100644 --- a/web-editor/src/components/ScenarioEditor/BottomPanel.tsx +++ b/web-editor/src/components/ScenarioEditor/BottomPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { X, Copy, Check } from 'lucide-react'; import layoutStyles from '../../styles/EditorLayout.module.css'; import panelStyles from '../../styles/SidebarPanel.module.css'; @@ -51,6 +51,16 @@ export const BottomPanel = ({ selectedNode, onClose }: BottomPanelProps) => { const [activeTab, setActiveTab] = useState<'output' | 'logs'>('output'); const [logLevel, setLogLevel] = useState('ALL'); const [copied, setCopied] = useState(false); + const copyTimeoutRef = useRef(null); + + useEffect(() => { + return () => { + if (copyTimeoutRef.current !== null) { + clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = null; + } + }; + }, []); const result = selectedNode?.data?.result; const nodeLogs = (selectedNode?.data as { logs?: string[] } | undefined)?.logs; @@ -77,8 +87,15 @@ export const BottomPanel = ({ selectedNode, onClose }: BottomPanelProps) => { if (!textToCopy) return; const handleSuccess = () => { + if (copyTimeoutRef.current !== null) { + clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = null; + } setCopied(true); - setTimeout(() => setCopied(false), 2000); + copyTimeoutRef.current = window.setTimeout(() => { + setCopied(false); + copyTimeoutRef.current = null; + }, 2000); }; const fallbackCopy = () => { From 828c8aef9cce32bb0bb2c31caf1f20f425d184c0 Mon Sep 17 00:00:00 2001 From: Sergey Date: Tue, 17 Mar 2026 15:38:00 -0700 Subject: [PATCH 4/5] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/ScenarioEditor/BottomPanel.tsx | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/web-editor/src/components/ScenarioEditor/BottomPanel.tsx b/web-editor/src/components/ScenarioEditor/BottomPanel.tsx index 80a61eee..2103da06 100644 --- a/web-editor/src/components/ScenarioEditor/BottomPanel.tsx +++ b/web-editor/src/components/ScenarioEditor/BottomPanel.tsx @@ -79,7 +79,7 @@ export const BottomPanel = ({ selectedNode, onClose }: BottomPanelProps) => { const handleCopy = useCallback(() => { let textToCopy = ''; - if (activeTab === 'output' && result) { + if (activeTab === 'output' && result !== null && result !== undefined) { textToCopy = JSON.stringify(result, null, 2); } else if (activeTab === 'logs' && filteredLogs.length > 0) { textToCopy = filteredLogs.join('\n'); @@ -98,6 +98,16 @@ export const BottomPanel = ({ selectedNode, onClose }: BottomPanelProps) => { }, 2000); }; + const handleFailure = () => { + if (copyTimeoutRef.current !== null) { + clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = null; + } + // Non-blocking feedback for copy failure + // This avoids using window.alert, which is blocking and inconsistent with the rest of the UI. + console.error('Failed to copy to clipboard.'); + }; + const fallbackCopy = () => { try { const textarea = document.createElement('textarea'); @@ -117,17 +127,15 @@ export const BottomPanel = ({ selectedNode, onClose }: BottomPanelProps) => { } if (successful) { handleSuccess(); - } else if (typeof window !== 'undefined' && typeof window.alert === 'function') { - window.alert('Failed to copy to clipboard.'); + } else { + handleFailure(); } } catch { - if (typeof window !== 'undefined' && typeof window.alert === 'function') { - window.alert('Failed to copy to clipboard.'); - } + handleFailure(); } }; - if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { navigator.clipboard.writeText(textToCopy) .then(handleSuccess) .catch(() => { @@ -148,10 +156,10 @@ export const BottomPanel = ({ selectedNode, onClose }: BottomPanelProps) => { let statusBgColor = 'var(--bg-secondary)'; let statusTextColor = 'var(--text-secondary)'; if (status === 'success') { - statusBgColor = 'var(--success-bg)'; + statusBgColor = 'rgba(34, 197, 94, 0.12)'; // soft green background statusTextColor = 'var(--success)'; } else if (status === 'failure') { - statusBgColor = 'var(--danger-bg)'; + statusBgColor = 'rgba(239, 68, 68, 0.12)'; // soft red background statusTextColor = 'var(--danger)'; } else if (status === 'skipped') { statusBgColor = 'var(--bg-tertiary)'; @@ -211,7 +219,7 @@ export const BottomPanel = ({ selectedNode, onClose }: BottomPanelProps) => {