mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
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:
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user