From 94ccc6548b62377f03d58edb680fc27587d48678 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 20 Jul 2026 01:12:34 +0800 Subject: [PATCH] feat: add execution control interactions --- frontend/src/components/CustomIcons.tsx | 96 ++++++ .../src/components/InteractiveControls.tsx | 175 ++++++++++ .../pages/PlaygroundSubmissionDetailPage.tsx | 313 ++++++++++++++---- frontend/src/styles/interactive-controls.css | 287 ++++++++++++++++ 4 files changed, 807 insertions(+), 64 deletions(-) create mode 100644 frontend/src/components/CustomIcons.tsx create mode 100644 frontend/src/components/InteractiveControls.tsx create mode 100644 frontend/src/styles/interactive-controls.css diff --git a/frontend/src/components/CustomIcons.tsx b/frontend/src/components/CustomIcons.tsx new file mode 100644 index 0000000..da8c53e --- /dev/null +++ b/frontend/src/components/CustomIcons.tsx @@ -0,0 +1,96 @@ +// 简化的图标组件 - 使用内联SVG +// 这些是为了演示暂停/继续/回溯功能所需要的基本图标 + +export function PauseCircleIcon({ size = 24 }: { size?: number }) { + return ( + + + + + + ); +} + +export function PlayCircleIcon({ size = 24 }: { size?: number }) { + return ( + + + + + ); +} + +export function HistoryIcon({ size = 24 }: { size?: number }) { + return ( + + + + + + ); +} + +export function CodeIcon({ size = 24 }: { size?: number }) { + return ( + + + + + ); +} + +export function EditIcon({ size = 24 }: { size?: number }) { + return ( + + + + + ); +} + +export function RefreshIcon({ size = 24 }: { size?: number }) { + return ( + + + + + + ); +} + +export function InfoIcon({ size = 24 }: { size?: number }) { + return ( + + + + + + ); +} + +export function XIcon({ size = 24 }: { size?: number }) { + return ( + + + + + ); +} + +export function CheckCircleIcon({ size = 24 }: { size?: number }) { + return ( + + + + + ); +} + +export function PanelChevronIcon({ direction = "left", size = 16 }: { direction?: "left" | "right"; size?: number }) { + return ( + + {direction === "left" && } + {direction === "right" && } + + ); +} diff --git a/frontend/src/components/InteractiveControls.tsx b/frontend/src/components/InteractiveControls.tsx new file mode 100644 index 0000000..e2db8ca --- /dev/null +++ b/frontend/src/components/InteractiveControls.tsx @@ -0,0 +1,175 @@ +import { useState, useEffect } from "react"; +import { + HistoryIcon, + CodeIcon, + EditIcon, + InfoIcon, + CheckCircleIcon, + PauseCircleIcon, + XIcon, +} from "./CustomIcons"; + +/** + * Workflow Controls - provides quick access to useful actions when execution is paused + */ +export function InteractiveControls({ + status, + onRewind, + onEditCode, + onEditRequirements, +}: { + status: string; + onRewind?: () => void; + onEditCode?: () => void; + onEditRequirements?: () => void; +}) { + const isPaused = status === "PAUSED"; + const isFinished = status === "PASSED" || status === "FAILED" || status === "INTERRUPTED"; + + if (!isPaused && !isFinished) return null; + + return ( +
+
+

Workflow Actions

+
+ +
+ {isPaused && ( +
+ +

Execution is paused. What would you like to do?

+
+ )} +
+ +
+ {isPaused && ( + <> + + + + + + + )} + + {isFinished && ( + + )} +
+
+ ); +} + +/** + * Confirmation dialog for important actions + */ +export function ConfirmDialog({ + title, + message, + confirmText, + cancelText, + onConfirm, + onCancel, + isOpen, + type = "info", +}: { + title: string; + message: string; + confirmText: string; + cancelText: string; + onConfirm: () => void; + onCancel: () => void; + isOpen: boolean; + type?: "info" | "warning" | "danger"; +}) { + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> +

{title}

+

{message}

+ +
+ + +
+
+
+ ); +} + +/** + * Simple toast notifications + */ +export function Toast({ + message, + type = "info", + isVisible, + onClose, +}: { + message: string; + type?: "info" | "success" | "error" | "warning"; + isVisible: boolean; + onClose: () => void; +}) { + useEffect(() => { + if (isVisible) { + const timer = setTimeout(onClose, 4000); + return () => clearTimeout(timer); + } + }, [isVisible, onClose]); + + if (!isVisible) return null; + + return ( +
+ {type === "success" && } + {type === "error" && } + {type === "warning" && } + {type === "info" && } + {message} + +
+ ); +} diff --git a/frontend/src/pages/PlaygroundSubmissionDetailPage.tsx b/frontend/src/pages/PlaygroundSubmissionDetailPage.tsx index e3dfd5f..4353796 100644 --- a/frontend/src/pages/PlaygroundSubmissionDetailPage.tsx +++ b/frontend/src/pages/PlaygroundSubmissionDetailPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { DownOutlined, MinusOutlined, PlusOutlined, RadarChartOutlined, UpOutlined } from "@ant-design/icons"; import type { FocusEvent as ReactFocusEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react"; import { createPortal } from "react-dom"; @@ -9,7 +9,9 @@ import SubmissionFactoryCanvas, { type SubmissionFactoryCanvasHandle } from "../ import RequirementNodeDetailContent from "../components/requirements/RequirementNodeDetailContent"; import SubmissionResultCard from "../components/submissions/SubmissionResultCard"; import SubmissionStepList from "../components/submissions/SubmissionStepList"; +import { InteractiveControls, ConfirmDialog, Toast } from "../components/InteractiveControls"; import { ApiError, api } from "../lib/api"; +import "../styles/interactive-controls.css"; import { cloneRequirementTree, findNodeById, @@ -1394,27 +1396,44 @@ function CommitHistoryPanel({
{orderedCommits.map((commit) => { const isCommitSelected = selectedCommitOid === commit.oid; + const canRewind = onRewind && commit.node_id && commit.phase; return ( - +
+ + )} + +
); })}
@@ -1541,6 +1560,19 @@ export default function PlaygroundSubmissionDetailPage() { const [editableDetailExpanded, setEditableDetailExpanded] = useState(false); const [savingEditableTask, setSavingEditableTask] = useState(false); const [editableReloadToken, setEditableReloadToken] = useState(0); + const [isPausing, setIsPausing] = useState(false); + const [isResuming, setIsResuming] = useState(false); + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [confirmDialogData, setConfirmDialogData] = useState<{ + title: string; + message: string; + confirmText: string; + cancelText: string; + type: "info" | "warning" | "danger"; + onConfirm: () => void; + } | null>(null); + const [toastVisible, setToastVisible] = useState(false); + const [toastData, setToastData] = useState<{ message: string; type: "info" | "success" | "error" | "warning" } | null>(null); const taskType = normalizeTaskType(requirement?.category ?? rawTaskType); const executionPaused = submission?.status === "PAUSED"; const executionPausePending = submission?.status === "PAUSE_REQUESTED"; @@ -1590,6 +1622,41 @@ export default function PlaygroundSubmissionDetailPage() { error: error.message, }); + const showToast = (message: string, type: "info" | "success" | "error" | "warning" = "info") => { + setToastData({ message, type }); + setToastVisible(true); + }; + + const hideToast = () => { + setToastVisible(false); + setTimeout(() => setToastData(null), 300); + }; + + const showConfirm = ( + title: string, + message: string, + confirmText: string, + cancelText: string, + type: "info" | "warning" | "danger", + onConfirm: () => void + ) => { + setConfirmDialogData({ title, message, confirmText, cancelText, type, onConfirm }); + setShowConfirmDialog(true); + }; + + const handleConfirmCancel = () => { + setShowConfirmDialog(false); + setTimeout(() => setConfirmDialogData(null), 300); + }; + + const handleConfirmOk = () => { + if (confirmDialogData) { + confirmDialogData.onConfirm(); + } + setShowConfirmDialog(false); + setTimeout(() => setConfirmDialogData(null), 300); + }; + const loadPreviewStatus = async (silent = false) => { if (!silent) { setPreviewLoading(true); @@ -2135,8 +2202,17 @@ export default function PlaygroundSubmissionDetailPage() { if (!submissionId) { return; } - editableTreeDirtyRef.current = false; - setSubmission(await api.pauseSubmission(submissionId)); + setIsPausing(true); + try { + editableTreeDirtyRef.current = false; + const result = await api.pauseSubmission(submissionId); + setSubmission(result); + showToast("Execution paused successfully", "success"); + } catch (error) { + showToast("Failed to pause execution", "error"); + } finally { + setIsPausing(false); + } }; const persistEditableTask = async () => { @@ -2166,6 +2242,7 @@ export default function PlaygroundSubmissionDetailPage() { if (!submissionId) { return; } + if (submission?.can_manual_edit) { try { const preview = await api.getManualEditCommitPreview(submissionId); @@ -2173,40 +2250,66 @@ export default function PlaygroundSubmissionDetailPage() { const scope = preview.node_id && preview.phase ? `${preview.node_id} (${preview.phase})` : "the current paused step"; - const confirmed = window.confirm( - `Resume will create one manual commit for ${scope} before continuing.`, + + // Use our nice confirm dialog instead of window.confirm + showConfirm( + "Confirm Resume", + `Resume will create one manual commit for ${scope} before continuing. Do you want to proceed?`, + "Yes, Continue", + "Cancel", + "warning", + async () => { + await doResume(); + } ); - if (!confirmed) { - return; - } + return; } } catch (error) { const fallbackMessage = error instanceof Error ? error.message : "Failed to prepare resume preview."; console.error("Failed to load manual edit commit preview", error); - const confirmed = window.confirm( - `${fallbackMessage}\n\nResume will still try to continue from the paused workspace. Continue?`, + + showConfirm( + "Resume Anyway?", + `${fallbackMessage}\n\nResume will still try to continue from the paused workspace. Do you want to proceed?`, + "Yes, Continue", + "Cancel", + "info", + async () => { + await doResume(); + } ); - if (!confirmed) { - return; - } + return; } } - if (submission?.status === "PAUSED" && editableTreeDirtyRef.current) { - setSavingEditableTask(true); - try { - await persistEditableTask(); - } finally { - setSavingEditableTask(false); + + await doResume(); + }; + + const doResume = async () => { + setIsResuming(true); + try { + if (submission?.status === "PAUSED" && editableTreeDirtyRef.current) { + setSavingEditableTask(true); + try { + await persistEditableTask(); + } finally { + setSavingEditableTask(false); + } } - } - const nextSubmission = await api.resumeSubmission(submissionId); - setSubmission(nextSubmission); - if (nextSubmission.status !== "PAUSED") { - editableTreeDirtyRef.current = false; - setEditableTask(null); - setEditableTree(null); - setEditableNodeId("ROOT"); - setEditableDetailExpanded(false); + const nextSubmission = await api.resumeSubmission(submissionId); + setSubmission(nextSubmission); + if (nextSubmission.status !== "PAUSED") { + editableTreeDirtyRef.current = false; + setEditableTask(null); + setEditableTree(null); + setEditableNodeId("ROOT"); + setEditableDetailExpanded(false); + } + showToast("Execution resumed successfully", "success"); + } catch (error) { + showToast("Failed to resume execution", "error"); + } finally { + setIsResuming(false); } }; @@ -2214,24 +2317,76 @@ export default function PlaygroundSubmissionDetailPage() { if (!submissionId) { return; } - const nextSubmission = await api.rewindSubmission(submissionId, { commit_oid: commit.oid }); - const targetNodeId = commit.node_id ?? "ROOT"; - editableTreeDirtyRef.current = false; - visualEventCountRef.current = 0; - setSubmission(nextSubmission); - setTraceabilityNodeId(targetNodeId); - setEditableTask(null); - setEditableTree(null); + + showConfirm( + "Rewind to This Commit?", + `Rewind this submission to commit ${commit.short_oid}? Execution will pause and later commits in the workspace history will be discarded.`, + "Rewind", + "Cancel", + "danger", + async () => { + try { + const nextSubmission = await api.rewindSubmission(submissionId, { commit_oid: commit.oid }); + const targetNodeId = commit.node_id ?? "ROOT"; + editableTreeDirtyRef.current = false; + visualEventCountRef.current = 0; + setSubmission(nextSubmission); + setTraceabilityNodeId(targetNodeId); + setEditableTask(null); + setEditableTree(null); + setActiveTab("canvas"); + setSelectedCommitOid(commit.oid); + setSelectedNodeId(targetNodeId); + setEditableNodeId(targetNodeId); + setEditableReloadToken((current) => current + 1); + setFocusNodeId(targetNodeId); + setPulseNodeId(null); + if (useQuickStartSubmission) { + quickStart.setSelectedNode(targetNodeId); + } + showToast("Rewound to previous commit successfully", "success"); + } catch (error) { + showToast("Failed to rewind submission", "error"); + } + } + ); + }; + + const handleRewindClick = () => { + // Switch to history tab + setPreviewTab("history"); + // Expand the sidebar if necessary + if (sidebarMinimized) { + setSidebarMinimized(false); + } + showToast("Select a commit to rewind to from the history panel", "info"); + }; + + const handleEditCodeClick = () => { + // Switch to file tab and open the first file + setActiveTab("file"); + if (sidebarMinimized) { + setSidebarMinimized(false); + } + setSidebarTab("status"); + showToast("You can now edit code and tests from the workspace panel", "info"); + }; + + const handleEditRequirementsClick = () => { + // Activate edit mode for requirements + if (submission?.status !== "PAUSED") { + showToast("Execution must be paused to edit requirements", "warning"); + return; + } + // Select the root node and show edit panel + setSelectedNodeId("ROOT"); + setEditableNodeId("ROOT"); setActiveTab("canvas"); - setSelectedCommitOid(commit.oid); - setSelectedNodeId(targetNodeId); - setEditableNodeId(targetNodeId); - setEditableReloadToken((current) => current + 1); - setFocusNodeId(targetNodeId); - setPulseNodeId(null); - if (useQuickStartSubmission) { - quickStart.setSelectedNode(targetNodeId); + setDetailExpanded(true); + if (sidebarMinimized) { + setSidebarMinimized(false); } + showToast("You can now edit requirements from the canvas", "info"); }; const handleSaveFix = async () => { @@ -2430,6 +2585,14 @@ export default function PlaygroundSubmissionDetailPage() { + {/* Interactive Controls Panel */} + +
+ + {showConfirmDialog && confirmDialogData && ( + + )} + + {toastData && ( + + )}
); } diff --git a/frontend/src/styles/interactive-controls.css b/frontend/src/styles/interactive-controls.css new file mode 100644 index 0000000..b218aae --- /dev/null +++ b/frontend/src/styles/interactive-controls.css @@ -0,0 +1,287 @@ +/* Interactive Controls Panel - Clean and Simple */ + +.interactive-controls-panel { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; +} + +.control-header { + margin-bottom: 12px; +} + +.control-header h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--text); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.control-tips { + margin-bottom: 12px; +} + +.control-tip-item { + display: flex; + gap: 8px; + align-items: flex-start; + padding: 10px 12px; + border-radius: 6px; + background: rgba(59, 130, 246, 0.1); + color: #3b82f6; +} + +.control-tip-item p { + margin: 0; + font-size: 13px; + line-height: 1.4; +} + +.control-buttons-group { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.control-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.control-button:hover { + background: var(--bg-hover); + border-color: var(--border-hover); + transform: translateY(-1px); +} + +/* Confirm Dialog */ + +.confirm-dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; +} + +.confirm-dialog { + background: white; + padding: 24px; + border-radius: 12px; + max-width: 400px; + width: 90%; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15); + text-align: center; +} + +.confirm-dialog h3 { + margin: 0 0 12px 0; + font-size: 18px; + font-weight: 600; + color: #1f2937; +} + +.confirm-dialog p { + margin: 0 0 24px 0; + color: #6b7280; + font-size: 14px; + line-height: 1.5; +} + +.confirm-actions { + display: flex; + gap: 12px; + justify-content: center; +} + +.confirm-button { + padding: 10px 20px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + border: none; + cursor: pointer; + transition: all 0.15s; +} + +.confirm-button.secondary { + background: #f3f4f6; + color: #374151; +} + +.confirm-button.secondary:hover { + background: #e5e7eb; +} + +.confirm-button.info { + background: #3b82f6; + color: white; +} + +.confirm-button.warning { + background: #f59e0b; + color: white; +} + +.confirm-button.danger { + background: #dc2626; + color: white; +} + +/* Toast Notification */ + +.toast-notification { + position: fixed; + bottom: 24px; + right: 24px; + background: white; + padding: 14px 18px; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0,0,0,0.12); + display: flex; + align-items: center; + gap: 12px; + min-width: 280px; + max-width: 400px; + z-index: 3000; + border-left: 4px solid #3b82f6; +} + +.toast-notification.success { + border-left-color: #10b981; +} + +.toast-notification.error { + border-left-color: #ef4444; +} + +.toast-notification.warning { + border-left-color: #f59e0b; +} + +.toast-notification span { + flex: 1; + font-size: 14px; + color: #1f2937; + line-height: 1.4; +} + +.toast-close { + border: none; + background: transparent; + cursor: pointer; + color: #9ca3af; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.15s; +} + +.toast-close:hover { + color: #4b5563; +} + +/* Responsive */ + +@media (max-width: 768px) { + .toast-notification { + left: 12px; + right: 12px; + bottom: 12px; + } +} + +/* Commit History Rewind Button */ + +.commit-history-item-wrapper { + display: flex; + flex-direction: column; +} + +.commit-history-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + text-align: left; + border: none; + background: transparent; + cursor: pointer; + transition: background 0.15s ease; +} + +.commit-history-item:hover { + background: var(--bg-hover); +} + +.commit-history-item.commit-active { + background: var(--bg-elevated); + border-left: 3px solid var(--accent-color, #3b82f6); +} + +.commit-history-left { + flex: 1; + min-width: 0; +} + +.commit-history-main { + display: block; + font-size: 14px; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; +} + +.commit-history-secondary { + display: block; + font-size: 12px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.commit-rewind-button { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + background: #fef3c7; + color: #92400e; + border: 1px solid #fcd34d; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; +} + +.commit-rewind-button:hover { + background: #fde68a; + transform: scale(1.02); + box-shadow: 0 2px 8px rgba(245, 158, 11, 0.2); +}