feat(orchestrator): redesign orchestrator page as template editor with terminal execution

Phase 1: Orchestrator Simplification
- Remove ExecutionMonitor from OrchestratorPage
- Replace "Run Workflow" button with "Send to Terminal" button
- Update i18n texts for template editor context

Phase 2: Session Lock Mechanism
- Add 'locked' status to TerminalStatus type
- Extend TerminalMeta with isLocked, lockReason, lockedByExecutionId, lockedAt
- Implement lockSession/unlockSession in sessionManagerStore
- Create SessionLockConfirmDialog component for input interception

Phase 3: Execution Monitor Panel
- Create executionMonitorStore for execution state management
- Create ExecutionMonitorPanel component with step progress display
- Add execution panel to DashboardToolbar and TerminalDashboardPage
- Support WebSocket message handling for execution updates

Phase 4: Execution Bridge
- Add POST /api/orchestrator/flows/:id/execute-in-session endpoint
- Create useExecuteFlowInSession hook for frontend API calls
- Broadcast EXECUTION_STARTED and CLI_SESSION_LOCKED WebSocket messages
- Lock session when execution starts, unlock on completion
This commit is contained in:
catlog22
2026-02-20 21:49:05 +08:00
parent b38750f0cf
commit f8ff9eaa7f
13 changed files with 1156 additions and 234 deletions

View File

@@ -34,6 +34,7 @@ import { useTerminalGridStore, selectTerminalGridFocusedPaneId } from '@/stores/
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { toast } from '@/stores/notificationStore';
import { useExecutionMonitorStore, selectActiveExecutionCount } from '@/stores/executionMonitorStore';
import { useSessionManagerStore } from '@/stores/sessionManagerStore';
import { CliConfigModal, type CliSessionConfig } from './CliConfigModal';
// ========== Types ==========
@@ -106,6 +107,7 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
const projectPath = useWorkflowStore(selectProjectPath);
const focusedPaneId = useTerminalGridStore(selectTerminalGridFocusedPaneId);
const createSessionAndAssign = useTerminalGridStore((s) => s.createSessionAndAssign);
const updateTerminalMeta = useSessionManagerStore((s) => s.updateTerminalMeta);
const [isCreating, setIsCreating] = useState(false);
const [isConfigOpen, setIsConfigOpen] = useState(false);
@@ -131,7 +133,7 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
const targetPaneId = getOrCreateFocusedPane();
if (!targetPaneId) throw new Error('Failed to create pane');
await createSessionAndAssign(
const result = await createSessionAndAssign(
targetPaneId,
{
workingDir: config.workingDir || projectPath,
@@ -142,6 +144,14 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
},
projectPath
);
// Store tag in terminalMetas for grouping
if (result?.session?.sessionKey) {
updateTerminalMeta(result.session.sessionKey, {
tag: config.tag,
title: config.tag,
});
}
} catch (error: unknown) {
const message = error instanceof Error
? error.message
@@ -153,7 +163,7 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
} finally {
setIsCreating(false);
}
}, [projectPath, createSessionAndAssign, getOrCreateFocusedPane]);
}, [projectPath, createSessionAndAssign, getOrCreateFocusedPane, updateTerminalMeta]);
return (
<>

View File

@@ -0,0 +1,284 @@
// ========================================
// Execution Monitor Panel
// ========================================
// Panel for monitoring workflow executions in Terminal Dashboard.
// Displays execution progress, step list, and control buttons.
import { useIntl } from 'react-intl';
import {
Play,
Pause,
Square,
CheckCircle2,
XCircle,
Circle,
Loader2,
Clock,
Terminal,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Progress } from '@/components/ui/Progress';
import {
useExecutionMonitorStore,
selectCurrentExecution,
selectActiveExecutions,
} from '@/stores/executionMonitorStore';
import type { ExecutionStatus, StepInfo } from '@/stores/executionMonitorStore';
// ========== Status Config ==========
const statusConfig: Record<ExecutionStatus, { label: string; color: string; bgColor: string }> = {
pending: { label: 'Pending', color: 'text-muted-foreground', bgColor: 'bg-muted' },
running: { label: 'Running', color: 'text-primary', bgColor: 'bg-primary/10' },
paused: { label: 'Paused', color: 'text-amber-500', bgColor: 'bg-amber-500/10' },
completed: { label: 'Completed', color: 'text-green-500', bgColor: 'bg-green-500/10' },
failed: { label: 'Failed', color: 'text-destructive', bgColor: 'bg-destructive/10' },
cancelled: { label: 'Cancelled', color: 'text-muted-foreground', bgColor: 'bg-muted' },
};
// ========== Step Status Icon ==========
function StepStatusIcon({ status }: { status: ExecutionStatus }) {
switch (status) {
case 'pending':
return <Circle className="w-4 h-4 text-muted-foreground" />;
case 'running':
return <Loader2 className="w-4 h-4 text-primary animate-spin" />;
case 'completed':
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
case 'failed':
return <XCircle className="w-4 h-4 text-destructive" />;
case 'paused':
return <Pause className="w-4 h-4 text-amber-500" />;
case 'cancelled':
return <Square className="w-4 h-4 text-muted-foreground" />;
default:
return <Circle className="w-4 h-4 text-muted-foreground opacity-50" />;
}
}
// ========== Step List Item ==========
interface StepListItemProps {
step: StepInfo;
isCurrent: boolean;
}
function StepListItem({ step, isCurrent }: StepListItemProps) {
return (
<div
className={cn(
'flex items-start gap-3 px-3 py-2 rounded-md transition-colors',
isCurrent && step.status === 'running' && 'bg-primary/5 border border-primary/20',
step.status === 'failed' && 'bg-destructive/5'
)}
>
<div className="pt-0.5 shrink-0">
<StepStatusIcon status={step.status} />
</div>
<div className="flex-1 min-w-0">
<span
className={cn(
'text-sm font-medium truncate block',
step.status === 'completed' && 'text-muted-foreground',
step.status === 'running' && 'text-foreground',
step.status === 'failed' && 'text-destructive'
)}
>
{step.name}
</span>
{step.error && (
<p className="text-xs text-destructive mt-1 truncate">{step.error}</p>
)}
</div>
</div>
);
}
// ========== Main Component ==========
export function ExecutionMonitorPanel() {
const { formatMessage } = useIntl();
const currentExecution = useExecutionMonitorStore(selectCurrentExecution);
const activeExecutions = useExecutionMonitorStore(selectActiveExecutions);
const selectExecution = useExecutionMonitorStore((s) => s.selectExecution);
const pauseExecution = useExecutionMonitorStore((s) => s.pauseExecution);
const resumeExecution = useExecutionMonitorStore((s) => s.resumeExecution);
const stopExecution = useExecutionMonitorStore((s) => s.stopExecution);
const clearExecution = useExecutionMonitorStore((s) => s.clearExecution);
const executions = Object.values(activeExecutions);
const hasExecutions = executions.length > 0;
if (!hasExecutions) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground p-8">
<Terminal className="w-10 h-10 mb-3 opacity-30" />
<p className="text-sm font-medium">
{formatMessage({ id: 'executionMonitor.noExecutions', defaultMessage: 'No active executions' })}
</p>
<p className="text-xs mt-1 opacity-70">
{formatMessage({ id: 'executionMonitor.sendToTerminal', defaultMessage: 'Send a workflow to terminal to start' })}
</p>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Execution selector (if multiple) */}
{executions.length > 1 && (
<div className="border-b border-border p-2 shrink-0">
<div className="flex flex-wrap gap-1">
{executions.map((exec) => (
<button
key={exec.executionId}
onClick={() => selectExecution(exec.executionId)}
className={cn(
'px-2 py-1 text-xs rounded-md transition-colors truncate max-w-[120px]',
currentExecution?.executionId === exec.executionId
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80 text-muted-foreground'
)}
>
{exec.flowName}
</button>
))}
</div>
</div>
)}
{currentExecution && (
<>
{/* Header */}
<div className="p-4 border-b border-border space-y-3 shrink-0">
<div className="flex items-center gap-3">
<h3 className="text-sm font-semibold text-foreground truncate flex-1">
{currentExecution.flowName}
</h3>
<Badge
variant="secondary"
className={cn('shrink-0', statusConfig[currentExecution.status].bgColor)}
>
{statusConfig[currentExecution.status].label}
</Badge>
</div>
{/* Progress */}
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{formatMessage(
{ id: 'executionMonitor.progress', defaultMessage: '{completed}/{total} steps' },
{ completed: currentExecution.completedSteps, total: currentExecution.totalSteps || currentExecution.steps.length }
)}
</span>
<span>
{Math.round(
(currentExecution.completedSteps / (currentExecution.totalSteps || currentExecution.steps.length || 1)) * 100
)}%
</span>
</div>
<Progress
value={
(currentExecution.completedSteps / (currentExecution.totalSteps || currentExecution.steps.length || 1)) * 100
}
className="h-2"
/>
</div>
{/* Meta info */}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(currentExecution.startedAt).toLocaleTimeString()}
</span>
<span className="flex items-center gap-1">
<Terminal className="w-3 h-3" />
<span className="truncate max-w-[100px]">{currentExecution.sessionKey.slice(0, 20)}...</span>
</span>
</div>
</div>
{/* Step list */}
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{currentExecution.steps.map((step) => (
<StepListItem
key={step.id}
step={step}
isCurrent={step.id === currentExecution.currentStepId}
/>
))}
</div>
{/* Control bar */}
<div className="p-3 border-t border-border flex items-center gap-2 shrink-0">
{currentExecution.status === 'running' && (
<>
<Button
variant="outline"
size="sm"
onClick={() => pauseExecution(currentExecution.executionId)}
className="gap-1.5"
>
<Pause className="w-3.5 h-3.5" />
{formatMessage({ id: 'executionMonitor.pause', defaultMessage: 'Pause' })}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => stopExecution(currentExecution.executionId)}
className="gap-1.5"
>
<Square className="w-3.5 h-3.5" />
{formatMessage({ id: 'executionMonitor.stop', defaultMessage: 'Stop' })}
</Button>
</>
)}
{currentExecution.status === 'paused' && (
<>
<Button
variant="default"
size="sm"
onClick={() => resumeExecution(currentExecution.executionId)}
className="gap-1.5"
>
<Play className="w-3.5 h-3.5" />
{formatMessage({ id: 'executionMonitor.resume', defaultMessage: 'Resume' })}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => stopExecution(currentExecution.executionId)}
className="gap-1.5"
>
<Square className="w-3.5 h-3.5" />
{formatMessage({ id: 'executionMonitor.stop', defaultMessage: 'Stop' })}
</Button>
</>
)}
{(currentExecution.status === 'completed' ||
currentExecution.status === 'failed' ||
currentExecution.status === 'cancelled') && (
<Button
variant="outline"
size="sm"
onClick={() => clearExecution(currentExecution.executionId)}
className="gap-1.5 ml-auto"
>
{formatMessage({ id: 'executionMonitor.clear', defaultMessage: 'Clear' })}
</Button>
)}
</div>
</>
)}
</div>
);
}
export default ExecutionMonitorPanel;

View File

@@ -1,28 +1,18 @@
// ========================================
// SessionGroupTree Component
// ========================================
// Tree view for session groups with drag-and-drop support.
// Sessions can be dragged between groups. Groups are expandable sections.
// Uses @hello-pangea/dnd for drag-and-drop, sessionManagerStore for state.
// Tree view for CLI sessions grouped by tag.
// Sessions are automatically grouped by their tag (e.g., "gemini-143052").
import { useState, useCallback, useMemo } from 'react';
import { useIntl } from 'react-intl';
import {
DragDropContext,
Droppable,
Draggable,
type DropResult,
} from '@hello-pangea/dnd';
import {
ChevronRight,
FolderOpen,
Folder,
Plus,
Terminal,
GripVertical,
Tag,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useSessionManagerStore, selectGroups, selectSessionManagerActiveTerminalId, selectTerminalMetas } from '@/stores';
import { useSessionManagerStore, selectSessionManagerActiveTerminalId, selectTerminalMetas } from '@/stores';
import { useCliSessionStore } from '@/stores/cliSessionStore';
import { useTerminalGridStore, selectTerminalGridPanes } from '@/stores/terminalGridStore';
import { Badge } from '@/components/ui/Badge';
@@ -36,17 +26,15 @@ const statusDotStyles: Record<TerminalStatus, string> = {
error: 'bg-red-500',
paused: 'bg-yellow-500',
resuming: 'bg-blue-400 animate-pulse',
locked: 'bg-purple-500',
};
// ========== SessionGroupTree Component ==========
export function SessionGroupTree() {
const { formatMessage } = useIntl();
const groups = useSessionManagerStore(selectGroups);
const activeTerminalId = useSessionManagerStore(selectSessionManagerActiveTerminalId);
const terminalMetas = useSessionManagerStore(selectTerminalMetas);
const createGroup = useSessionManagerStore((s) => s.createGroup);
const moveSessionToGroup = useSessionManagerStore((s) => s.moveSessionToGroup);
const setActiveTerminal = useSessionManagerStore((s) => s.setActiveTerminal);
const sessions = useCliSessionStore((s) => s.sessions);
@@ -55,25 +43,20 @@ export function SessionGroupTree() {
const assignSession = useTerminalGridStore((s) => s.assignSession);
const setFocused = useTerminalGridStore((s) => s.setFocused);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
const toggleGroup = useCallback((groupId: string) => {
setExpandedGroups((prev) => {
const toggleTag = useCallback((tag: string) => {
setExpandedTags((prev) => {
const next = new Set(prev);
if (next.has(groupId)) {
next.delete(groupId);
if (next.has(tag)) {
next.delete(tag);
} else {
next.add(groupId);
next.add(tag);
}
return next;
});
}, []);
const handleCreateGroup = useCallback(() => {
const name = formatMessage({ id: 'terminalDashboard.sessionTree.defaultGroupName' });
createGroup(name);
}, [createGroup, formatMessage]);
const handleSessionClick = useCallback(
(sessionId: string) => {
// Set active terminal in session manager
@@ -100,44 +83,55 @@ export function SessionGroupTree() {
[setActiveTerminal, panes, setFocused, assignSession]
);
const handleDragEnd = useCallback(
(result: DropResult) => {
const { draggableId, destination } = result;
if (!destination) return;
// Group sessions by tag
const sessionsByTag = useMemo(() => {
const groups: Record<string, { tag: string; sessionIds: string[] }> = {};
const untagged: string[] = [];
// destination.droppableId is the target group ID
const targetGroupId = destination.droppableId;
moveSessionToGroup(draggableId, targetGroupId);
},
[moveSessionToGroup]
);
for (const sessionKey of Object.keys(sessions)) {
const meta = terminalMetas[sessionKey];
const tag = meta?.tag;
if (tag) {
if (!groups[tag]) {
groups[tag] = { tag, sessionIds: [] };
}
groups[tag].sessionIds.push(sessionKey);
} else {
untagged.push(sessionKey);
}
}
// Convert to array and sort by tag name (newest first by time suffix)
const result = Object.values(groups).sort((a, b) => b.tag.localeCompare(a.tag));
// Add untagged sessions at the end
if (untagged.length > 0) {
result.push({ tag: '__untagged__', sessionIds: untagged });
}
return result;
}, [sessions, terminalMetas]);
// Build a lookup for session display names
const sessionNames = useMemo(() => {
const map: Record<string, string> = {};
for (const [key, meta] of Object.entries(sessions)) {
map[key] = meta.tool ? `${meta.tool} - ${meta.shellKind}` : meta.shellKind;
map[key] = meta.tool ?? meta.shellKind;
}
return map;
}, [sessions]);
if (groups.length === 0) {
if (Object.keys(sessions).length === 0) {
return (
<div className="flex flex-col h-full">
<div className="px-3 py-2 border-b border-border">
<button
onClick={handleCreateGroup}
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
{formatMessage({ id: 'terminalDashboard.sessionTree.createGroup' })}
</button>
</div>
<div className="flex-1 flex flex-col items-center justify-center gap-1.5 text-muted-foreground p-4">
<Folder className="w-6 h-6 opacity-30" />
<Terminal className="w-6 h-6 opacity-30" />
<p className="text-xs text-center">
{formatMessage({ id: 'terminalDashboard.sessionTree.noGroups' })}
</p>
<p className="text-[10px] text-muted-foreground/70">
Click "New Session" to create one
</p>
</div>
</div>
);
@@ -145,117 +139,69 @@ export function SessionGroupTree() {
return (
<div className="flex flex-col h-full">
{/* Create group button */}
<div className="px-3 py-2 border-b border-border shrink-0">
<button
onClick={handleCreateGroup}
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
{formatMessage({ id: 'terminalDashboard.sessionTree.createGroup' })}
</button>
</div>
{/* Groups with drag-and-drop */}
{/* Session list grouped by tag */}
<div className="flex-1 overflow-y-auto">
<DragDropContext onDragEnd={handleDragEnd}>
{groups.map((group) => {
const isExpanded = expandedGroups.has(group.id);
return (
<div key={group.id} className="border-b border-border/50 last:border-b-0">
{/* Group header */}
<button
onClick={() => toggleGroup(group.id)}
className={cn(
'flex items-center gap-1.5 w-full px-3 py-2 text-left',
'hover:bg-muted/50 transition-colors text-sm'
)}
>
<ChevronRight
className={cn(
'w-3.5 h-3.5 text-muted-foreground transition-transform shrink-0',
isExpanded && 'rotate-90'
)}
/>
{isExpanded ? (
<FolderOpen className="w-4 h-4 text-blue-500 shrink-0" />
) : (
<Folder className="w-4 h-4 text-blue-400 shrink-0" />
)}
<span className="flex-1 truncate font-medium">{group.name}</span>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{group.sessionIds.length}
</Badge>
</button>
{sessionsByTag.map((group) => {
const isExpanded = expandedTags.has(group.tag);
const isUntagged = group.tag === '__untagged__';
const displayName = isUntagged ? 'Other Sessions' : group.tag;
{/* Expanded: droppable session list */}
{isExpanded && (
<Droppable droppableId={group.id}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={cn(
'min-h-[32px] pb-1',
snapshot.isDraggingOver && 'bg-primary/5'
)}
>
{group.sessionIds.length === 0 ? (
<p className="px-8 py-2 text-xs text-muted-foreground italic">
{formatMessage({ id: 'terminalDashboard.sessionTree.emptyGroup' })}
</p>
) : (
group.sessionIds.map((sessionId, index) => {
const meta = terminalMetas[sessionId];
const sessionStatus: TerminalStatus = meta?.status ?? 'idle';
return (
<Draggable
key={sessionId}
draggableId={sessionId}
index={index}
>
{(dragProvided, dragSnapshot) => (
<div
ref={dragProvided.innerRef}
{...dragProvided.draggableProps}
className={cn(
'flex items-center gap-1.5 mx-1 px-2 py-1.5 rounded-sm cursor-pointer',
'hover:bg-muted/50 transition-colors text-sm',
activeTerminalId === sessionId && 'bg-primary/10 text-primary',
dragSnapshot.isDragging && 'bg-muted shadow-md'
)}
onClick={() => handleSessionClick(sessionId)}
>
<span
{...dragProvided.dragHandleProps}
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0"
>
<GripVertical className="w-3 h-3" />
</span>
{/* Status indicator dot */}
<span
className={cn('w-2 h-2 rounded-full shrink-0', statusDotStyles[sessionStatus])}
title={sessionStatus}
/>
<Terminal className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span className="flex-1 truncate text-xs">
{sessionNames[sessionId] ?? sessionId}
</span>
</div>
)}
</Draggable>
);
})
)}
{provided.placeholder}
</div>
)}
</Droppable>
return (
<div key={group.tag} className="border-b border-border/50 last:border-b-0">
{/* Tag header */}
<button
onClick={() => toggleTag(group.tag)}
className={cn(
'flex items-center gap-1.5 w-full px-3 py-2 text-left',
'hover:bg-muted/50 transition-colors text-sm'
)}
</div>
);
})}
</DragDropContext>
>
<ChevronRight
className={cn(
'w-3.5 h-3.5 text-muted-foreground transition-transform shrink-0',
isExpanded && 'rotate-90'
)}
/>
<Tag className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span className="flex-1 truncate font-medium text-xs">{displayName}</span>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{group.sessionIds.length}
</Badge>
</button>
{/* Expanded: session list */}
{isExpanded && (
<div className="pb-1">
{group.sessionIds.map((sessionId) => {
const meta = terminalMetas[sessionId];
const sessionStatus: TerminalStatus = meta?.status ?? 'idle';
return (
<div
key={sessionId}
className={cn(
'flex items-center gap-1.5 mx-1 px-2 py-1.5 rounded-sm cursor-pointer',
'hover:bg-muted/50 transition-colors text-sm',
activeTerminalId === sessionId && 'bg-primary/10 text-primary'
)}
onClick={() => handleSessionClick(sessionId)}
>
{/* Status indicator dot */}
<span
className={cn('w-2 h-2 rounded-full shrink-0', statusDotStyles[sessionStatus])}
title={sessionStatus}
/>
<Terminal className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span className="flex-1 truncate text-xs">
{sessionNames[sessionId] ?? sessionId}
</span>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
</div>
);

View File

@@ -0,0 +1,121 @@
// ========================================
// Session Lock Confirm Dialog
// ========================================
// Dialog shown when user tries to input in a locked session.
// Displays execution info and offers options to wait or unlock.
import { useIntl } from 'react-intl';
import { Lock, AlertTriangle } from 'lucide-react';
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogAction,
AlertDialogCancel,
} from '@/components/ui/AlertDialog';
import { Progress } from '@/components/ui/Progress';
interface SessionLockConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
lockInfo: {
reason: string;
executionName?: string;
currentStep?: string;
progress?: number;
};
}
export function SessionLockConfirmDialog({
isOpen,
onClose,
onConfirm,
lockInfo,
}: SessionLockConfirmDialogProps) {
const { formatMessage } = useIntl();
return (
<AlertDialog open={isOpen}>
<AlertDialogContent className="sm:max-w-md">
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Lock className="w-5 h-5 text-amber-500" />
{formatMessage({ id: 'sessionLock.title', defaultMessage: '会话正在执行任务' })}
</AlertDialogTitle>
<AlertDialogDescription>
{formatMessage({
id: 'sessionLock.description',
defaultMessage: '此会话当前正在执行工作流,手动输入可能会中断执行。'
})}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-3 py-4">
{/* Execution info */}
<div className="bg-muted/50 rounded-lg p-3 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{formatMessage({ id: 'sessionLock.workflow', defaultMessage: '工作流:' })}
</span>
<span className="font-medium">
{lockInfo.executionName || lockInfo.reason}
</span>
</div>
{lockInfo.currentStep && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{formatMessage({ id: 'sessionLock.currentStep', defaultMessage: '当前步骤:' })}
</span>
<span>{lockInfo.currentStep}</span>
</div>
)}
{lockInfo.progress !== undefined && (
<div className="space-y-1">
<Progress value={lockInfo.progress} className="h-2" />
<p className="text-xs text-muted-foreground text-right">
{lockInfo.progress}% {formatMessage({ id: 'sessionLock.completed', defaultMessage: '完成' })}
</p>
</div>
)}
</div>
{/* Warning alert */}
<div className="flex items-start gap-2 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg">
<AlertTriangle className="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium text-amber-600 dark:text-amber-400">
{formatMessage({ id: 'sessionLock.warning', defaultMessage: '注意' })}
</p>
<p className="text-muted-foreground">
{formatMessage({
id: 'sessionLock.warningMessage',
defaultMessage: '继续输入将解锁会话,可能会影响正在执行的工作流。'
})}
</p>
</div>
</div>
</div>
<AlertDialogFooter className="flex-col sm:flex-row gap-2">
<AlertDialogCancel onClick={onClose} className="w-full sm:w-auto">
{formatMessage({ id: 'sessionLock.cancel', defaultMessage: '取消,等待完成' })}
</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="w-full sm:w-auto bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{formatMessage({ id: 'sessionLock.confirm', defaultMessage: '解锁并继续输入' })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export default SessionLockConfirmDialog;