diff --git a/src/frontend/src/components/common/PlanCancellationDialog.tsx b/src/frontend/src/components/common/PlanCancellationDialog.tsx new file mode 100644 index 000000000..5b7c8084a --- /dev/null +++ b/src/frontend/src/components/common/PlanCancellationDialog.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { + Dialog, + DialogTrigger, + DialogSurface, + DialogTitle, + DialogContent, + DialogBody, + DialogActions, + Button, +} from '@fluentui/react-components'; +import { Warning20Regular } from '@fluentui/react-icons'; +import "../../styles/Panel.css"; + +interface PlanCancellationDialogProps { + isOpen: boolean; + onConfirm: () => void; + onCancel: () => void; + loading?: boolean; +} + +/** + * Confirmation dialog for plan cancellation when navigating during active plans + */ +const PlanCancellationDialog: React.FC = ({ + isOpen, + onConfirm, + onCancel, + loading = false +}) => { + return ( + !data.open && onCancel()}> + + + +
+ + Confirm Plan Cancellation +
+
+ + If you continue, the plan process will be stopped and the plan will be cancelled. + + + + + + + +
+
+
+ ); +}; + +export default PlanCancellationDialog; \ No newline at end of file diff --git a/src/frontend/src/components/content/PlanPanelLeft.tsx b/src/frontend/src/components/content/PlanPanelLeft.tsx index 4962b21b9..e7c1424cd 100644 --- a/src/frontend/src/components/content/PlanPanelLeft.tsx +++ b/src/frontend/src/components/content/PlanPanelLeft.tsx @@ -30,11 +30,13 @@ import TeamService from "@/services/TeamService"; const PlanPanelLeft: React.FC = ({ reloadTasks, + onNewTaskButton, restReload, onTeamSelect, onTeamUpload, isHomePage, - selectedTeam: parentSelectedTeam + selectedTeam: parentSelectedTeam, + onNavigationWithAlert }) => { const { dispatchToast } = useToastController("toast"); const navigate = useNavigate(); @@ -116,16 +118,36 @@ const PlanPanelLeft: React.FC = ({ const handleTaskSelect = useCallback( (taskId: string) => { - const selectedPlan = plans?.find( - (plan: Plan) => plan.session_id === taskId - ); - if (selectedPlan) { - navigate(`/plan/${selectedPlan.id}`); + const performNavigation = () => { + const selectedPlan = plans?.find( + (plan: Plan) => plan.session_id === taskId + ); + if (selectedPlan) { + navigate(`/plan/${selectedPlan.id}`); + } + }; + + if (onNavigationWithAlert) { + onNavigationWithAlert(performNavigation); + } else { + performNavigation(); } }, - [plans, navigate] + [plans, navigate, onNavigationWithAlert] ); + const handleLogoClick = useCallback(() => { + const performNavigation = () => { + navigate("/"); + }; + + if (onNavigationWithAlert) { + onNavigationWithAlert(performNavigation); + } else { + performNavigation(); + } + }, [navigate, onNavigationWithAlert]); + const handleTeamSelect = useCallback( (team: TeamConfig | null) => { // Use parent's team select handler if provided, otherwise use local state @@ -163,10 +185,11 @@ const PlanPanelLeft: React.FC = ({ ); return ( -
+
} > @@ -175,7 +198,7 @@ const PlanPanelLeft: React.FC = ({ {/* Team Selector right under the toolbar */} -
+
{isHomePage && ( = ({
navigate("/", { state: { focusInput: true } })} + onClick={onNewTaskButton} tabIndex={0} // ✅ allows tab focus role="button" // ✅ announces as button onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); - navigate("/", { state: { focusInput: true } }); + onNewTaskButton(); } }} > @@ -219,7 +242,7 @@ const PlanPanelLeft: React.FC = ({ /> -
+
{/* User Card */} void; children?: ReactNode; } @@ -13,39 +15,18 @@ const PanelLeftToolbar: React.FC = ({ panelIcon, panelTitle, linkTo, + onTitleClick, children, }) => { const TitleContent = ( -
+
{panelIcon && ( -
+
{panelIcon}
)} {panelTitle && ( - + {panelTitle} )} @@ -53,45 +34,34 @@ const PanelLeftToolbar: React.FC = ({ ); return ( -
+
{(panelIcon || panelTitle) && - (linkTo ? ( + (onTitleClick ? ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onTitleClick(); + } + }} + > + {TitleContent} +
+ ) : linkTo ? ( {TitleContent} ) : ( TitleContent ))} -
+
{children}
diff --git a/src/frontend/src/hooks/usePlanCancellationAlert.tsx b/src/frontend/src/hooks/usePlanCancellationAlert.tsx new file mode 100644 index 000000000..49f366836 --- /dev/null +++ b/src/frontend/src/hooks/usePlanCancellationAlert.tsx @@ -0,0 +1,75 @@ +import { useCallback } from 'react'; +import { PlanStatus } from '../models'; +import { APIService } from '../api/apiService'; + +interface UsePlanCancellationAlertProps { + planData: any; + planApprovalRequest: any; + onNavigate: () => void; +} + +/** + * Custom hook to handle plan cancellation alerts when navigating during active plans + */ +export const usePlanCancellationAlert = ({ + planData, + planApprovalRequest, + onNavigate +}: UsePlanCancellationAlertProps) => { + const apiService = new APIService(); + + /** + * Check if a plan is currently active/running + */ + const isPlanActive = useCallback(() => { + return planData?.plan?.overall_status === PlanStatus.IN_PROGRESS; + }, [planData]); + + /** + * Handle the confirmation dialog and plan cancellation + */ + const handleNavigationWithConfirmation = useCallback(async () => { + if (!isPlanActive()) { + // Plan is not active, proceed with navigation + onNavigate(); + return; + } + + // Show confirmation dialog + const userConfirmed = window.confirm( + "If you continue, the plan process will be stopped and the plan will be cancelled." + ); + + if (!userConfirmed) { + // User cancelled, do nothing + return; + } + + try { + // User confirmed, cancel the plan + if (planApprovalRequest?.id) { + await apiService.approvePlan({ + m_plan_id: planApprovalRequest.id, + plan_id: planData?.plan?.id, + approved: false, + feedback: 'Plan cancelled by user navigation' + }); + } + + // Navigate after successful cancellation + onNavigate(); + } catch (error) { + console.error('❌ Failed to cancel plan:', error); + // Show error but still allow navigation + alert('Failed to cancel the plan properly, but navigation will continue.'); + onNavigate(); + } + }, [isPlanActive, onNavigate, planApprovalRequest, planData, apiService]); + + return { + isPlanActive, + handleNavigationWithConfirmation + }; +}; + +export default usePlanCancellationAlert; \ No newline at end of file diff --git a/src/frontend/src/models/planPanelLeft.tsx b/src/frontend/src/models/planPanelLeft.tsx index 4bae471ef..7c9d744c7 100644 --- a/src/frontend/src/models/planPanelLeft.tsx +++ b/src/frontend/src/models/planPanelLeft.tsx @@ -8,4 +8,5 @@ export interface PlanPanelLefProps { onTeamUpload?: () => Promise; isHomePage: boolean; selectedTeam?: TeamConfig | null; + onNavigationWithAlert?: (navigationFn: () => void) => void; } \ No newline at end of file diff --git a/src/frontend/src/pages/PlanPage.tsx b/src/frontend/src/pages/PlanPage.tsx index a6eafd0f1..6f6988cef 100644 --- a/src/frontend/src/pages/PlanPage.tsx +++ b/src/frontend/src/pages/PlanPage.tsx @@ -20,6 +20,8 @@ import LoadingMessage, { loadingMessages } from "../coral/components/LoadingMess import webSocketService from "../services/WebSocketService"; import { APIService } from "../api/apiService"; import { StreamMessage, StreamingPlanUpdate } from "../models"; +import { usePlanCancellationAlert } from "../hooks/usePlanCancellationAlert"; +import PlanCancellationDialog from "../components/common/PlanCancellationDialog"; import "../styles/PlanPage.css" @@ -59,8 +61,70 @@ const PlanPage: React.FC = () => { // Plan approval state - track when plan is approved const [planApproved, setPlanApproved] = useState(false); + // Plan cancellation dialog state + const [showCancellationDialog, setShowCancellationDialog] = useState(false); + const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null); + const [cancellingPlan, setCancellingPlan] = useState(false); + const [loadingMessage, setLoadingMessage] = useState(loadingMessages[0]); + // Plan cancellation alert hook + const { isPlanActive, handleNavigationWithConfirmation } = usePlanCancellationAlert({ + planData, + planApprovalRequest, + onNavigate: pendingNavigation || (() => {}) + }); + + // Handle navigation with plan cancellation check + const handleNavigationWithAlert = useCallback((navigationFn: () => void) => { + if (!isPlanActive()) { + // Plan is not active, proceed with navigation + navigationFn(); + return; + } + + // Plan is active, show confirmation dialog + setPendingNavigation(() => navigationFn); + setShowCancellationDialog(true); + }, [isPlanActive]); + + // Handle confirmation dialog response + const handleConfirmCancellation = useCallback(async () => { + setCancellingPlan(true); + + try { + if (planApprovalRequest?.id) { + await apiService.approvePlan({ + m_plan_id: planApprovalRequest.id, + plan_id: planData?.plan?.id, + approved: false, + feedback: 'Plan cancelled by user navigation' + }); + } + + // Execute the pending navigation + if (pendingNavigation) { + pendingNavigation(); + } + } catch (error) { + console.error('❌ Failed to cancel plan:', error); + showToast('Failed to cancel the plan properly, but navigation will continue.', 'error'); + // Still proceed with navigation even if cancellation failed + if (pendingNavigation) { + pendingNavigation(); + } + } finally { + setCancellingPlan(false); + setShowCancellationDialog(false); + setPendingNavigation(null); + } + }, [planApprovalRequest, planData, pendingNavigation, showToast]); + + const handleCancelDialog = useCallback(() => { + setShowCancellationDialog(false); + setPendingNavigation(null); + }, []); + const processAgentMessage = useCallback((agentMessageData: AgentMessageData, planData: ProcessedPlanData, is_final: boolean = false, streaming_message: string = '') => { @@ -542,10 +606,12 @@ const PlanPage: React.FC = () => { ); - // ✅ Handlers for PlanPanelLeft + // ✅ Handlers for PlanPanelLeft with plan cancellation protection const handleNewTaskButton = useCallback(() => { - navigate("/", { state: { focusInput: true } }); - }, [navigate]); + handleNavigationWithAlert(() => { + navigate("/", { state: { focusInput: true } }); + }); + }, [navigate, handleNavigationWithAlert]); const resetReload = useCallback(() => { @@ -582,13 +648,10 @@ const PlanPage: React.FC = () => { onTeamUpload={async () => { }} isHomePage={false} selectedTeam={selectedTeam} + onNavigationWithAlert={handleNavigationWithAlert} /> -
+
{"An error occurred while loading the plan"} @@ -611,18 +674,13 @@ const PlanPage: React.FC = () => { onTeamUpload={async () => { }} isHomePage={false} selectedTeam={selectedTeam} + onNavigationWithAlert={handleNavigationWithAlert} /> {loading || !planData ? ( <> -
+
Loading plan data...
@@ -674,6 +732,14 @@ const PlanPage: React.FC = () => { planApprovalRequest={planApprovalRequest} /> + + {/* Plan Cancellation Confirmation Dialog */} + ); }; diff --git a/src/frontend/src/styles/Panel.css b/src/frontend/src/styles/Panel.css new file mode 100644 index 000000000..9c56a0418 --- /dev/null +++ b/src/frontend/src/styles/Panel.css @@ -0,0 +1,74 @@ +/* Panel Toolbar Styles */ +.panel-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 16px; + box-sizing: border-box; + height: 56px; +} + +.panel-title { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 1; + overflow: hidden; + min-width: 0; +} + +.panel-title-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.panel-title-clickable { + text-decoration: none; + color: inherit; + display: flex; + align-items: center; + min-width: 0; + flex-shrink: 1; + cursor: pointer; +} + +.panel-title-icon { + flex-shrink: 0; + display: flex; + align-items: center; +} + +.panel-tools { + display: flex; + align-items: center; + flex-grow: 1; + justify-content: flex-end; + min-width: 0; +} + +/* Plan Cancellation Dialog Styles */ +.plan-cancellation-dialog-title { + display: flex; + align-items: center; + gap: 8px; +} + +.plan-cancellation-warning-icon { + color: var(--colorPaletteYellowForeground1); +} + +/* Button styles for consistency */ +.clickable-element { + cursor: pointer; + transition: background-color 0.2s ease-in-out; +} + +.clickable-element:hover { + background-color: var(--colorSubtleBackgroundHover); +} + +.clickable-element:focus-visible { + outline: 2px solid var(--colorStrokeFocus2); + outline-offset: 2px; +} \ No newline at end of file diff --git a/src/frontend/src/styles/PlanPage.css b/src/frontend/src/styles/PlanPage.css index 0739eb742..4c465c5ca 100644 --- a/src/frontend/src/styles/PlanPage.css +++ b/src/frontend/src/styles/PlanPage.css @@ -53,6 +53,20 @@ min-height: 200px; } +.plan-loading-spinner { + display: flex; + align-items: center; + gap: 12px; + justify-content: center; + padding: 20px; +} + +.plan-error-message { + text-align: center; + padding: 40px 20px; + color: var(--colorNeutralForeground2); +} + .loadingWrapper { height: 100%; display: flex; diff --git a/src/frontend/src/styles/PlanPanelLeft.css b/src/frontend/src/styles/PlanPanelLeft.css index 5a8831b44..ad8d3ea6f 100644 --- a/src/frontend/src/styles/PlanPanelLeft.css +++ b/src/frontend/src/styles/PlanPanelLeft.css @@ -39,6 +39,25 @@ background: radial-gradient(circle, rgba(238, 174, 221, 1) 0%, rgba(117, 121, 23 color: #2F2F4A; */ } +/* Panel Layout Styles */ +.panel-left-container { + flex-shrink: 0; + display: flex; + overflow: hidden; +} + +.team-selector-container { + margin-top: 8px; + margin-bottom: 8px; +} + +.panel-footer-content { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; +} + /* TASKLIST */ .task-tab { @@ -87,4 +106,4 @@ color: #2F2F4A; */ .task-tab:hover .task-menu-button { opacity: 1; -} +} \ No newline at end of file