Files
zclaw_openfang/desktop/src/components/ai/TaskProgress.tsx
iven 73ff5e8c5e
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
feat(desktop): DeerFlow visual redesign + stream hang fix + intelligence client
DeerFlow frontend visual overhaul:
- Card-style input box (white rounded card, textarea top, actions bottom)
- Dropdown mode selector (闪速/思考/Pro/Ultra with icons+descriptions)
- Colored quick-action chips (小惊喜/写作/研究/收集/学习)
- Minimal top bar (title + token count + export)
- Warm gray color system (#faf9f6 bg, #f5f4f1 sidebar, #e8e6e1 border)
- DeerFlow-style sidebar (新对话/对话/智能体 nav)
- Reasoning block, tool call chain, task progress visualization
- Streaming text, model selector, suggestion chips components
- Resizable artifact panel with drag handle
- Virtualized message list for 100+ messages

Bug fixes:
- Stream hang: GatewayClient onclose code 1000 now calls onComplete
- WebView2 textarea border: CSS !important override for UA styles
- Gateway stream event handling (response/phase/tool_call types)

Intelligence client:
- Unified client with fallback drivers (compactor/heartbeat/identity/memory/reflection)
- Gateway API types and type conversions
2026-04-01 22:03:07 +08:00

170 lines
5.7 KiB
TypeScript

import { useState, createContext, useContext, useCallback, type ReactNode } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronRight, CheckCircle2, XCircle, Loader2, Circle } from 'lucide-react';
// ---------------------------------------------------------------------------
// TaskContext — shared task state for sub-agent orchestration
// ---------------------------------------------------------------------------
interface TaskContextValue {
tasks: Subtask[];
updateTask: (id: string, updates: Partial<Subtask>) => void;
}
const TaskContext = createContext<TaskContextValue | null>(null);
export function useTaskContext() {
const ctx = useContext(TaskContext);
if (!ctx) {
throw new Error('useTaskContext must be used within TaskProvider');
}
return ctx;
}
export function TaskProvider({
children,
initialTasks = [],
}: {
children: ReactNode;
initialTasks?: Subtask[];
}) {
const [tasks, setTasks] = useState<Subtask[]>(initialTasks);
const updateTask = useCallback((id: string, updates: Partial<Subtask>) => {
setTasks(prev => prev.map(t => (t.id === id ? { ...t, ...updates } : t)));
}, []);
return (
<TaskContext.Provider value={{ tasks, updateTask }}>
{children}
</TaskContext.Provider>
);
}
/**
* Subtask progress display for sub-agent orchestration.
*
* Inspired by DeerFlow's SubtaskCard + ShineBorder pattern:
* - Shows task status with animated indicators
* - Collapsible details with thinking chain
* - Pulsing border animation for active tasks
* - Status icons: running (pulse), completed (green), failed (red)
*/
export interface Subtask {
id: string;
description: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
result?: string;
error?: string;
steps?: Array<{ content: string; status: 'thinking' | 'done' | 'error' }>;
}
interface TaskProgressProps {
tasks: Subtask[];
className?: string;
}
export function TaskProgress({ tasks, className = '' }: TaskProgressProps) {
if (tasks.length === 0) return null;
return (
<div className={`space-y-2 ${className}`}>
{tasks.map(task => (
<SubtaskCard key={task.id} task={task} />
))}
</div>
);
}
function SubtaskCard({ task }: { task: Subtask }) {
const [expanded, setExpanded] = useState(task.status === 'in_progress');
const isActive = task.status === 'in_progress';
return (
<div
className={`
rounded-lg border transition-all overflow-hidden
${isActive
? 'border-orange-300 dark:border-orange-700 bg-orange-50/50 dark:bg-orange-900/10 shadow-[0_0_15px_-3px_rgba(249,115,22,0.15)] dark:shadow-[0_0_15px_-3px_rgba(249,115,22,0.1)]'
: task.status === 'completed'
? 'border-green-200 dark:border-green-800 bg-green-50/30 dark:bg-green-900/10'
: task.status === 'failed'
? 'border-red-200 dark:border-red-800 bg-red-50/30 dark:bg-red-900/10'
: 'border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/50'
}
`}
>
{/* Header */}
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-2 px-3 py-2 text-left"
>
<motion.span animate={{ rotate: expanded ? 90 : 0 }} transition={{ duration: 0.15 }}>
<ChevronRight className="w-3.5 h-3.5 text-gray-400" />
</motion.span>
{/* Status icon */}
{task.status === 'in_progress' ? (
<Loader2 className="w-4 h-4 text-orange-500 animate-spin" />
) : task.status === 'completed' ? (
<CheckCircle2 className="w-4 h-4 text-green-500" />
) : task.status === 'failed' ? (
<XCircle className="w-4 h-4 text-red-500" />
) : (
<Circle className="w-4 h-4 text-gray-400" />
)}
<span className="flex-1 text-xs font-medium text-gray-700 dark:text-gray-300 truncate">
{task.description}
</span>
</button>
{/* Expanded details */}
<AnimatePresence>
{expanded && (task.result || task.error || (task.steps && task.steps.length > 0)) && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-3 pb-2 ml-6 border-l-2 border-gray-200 dark:border-gray-700 space-y-1">
{/* Steps */}
{task.steps?.map((step, i) => (
<div key={i} className="flex items-start gap-2">
{step.status === 'thinking' ? (
<span className="w-1.5 h-1.5 mt-1.5 bg-amber-400 rounded-full animate-pulse flex-shrink-0" />
) : step.status === 'done' ? (
<span className="w-1.5 h-1.5 mt-1.5 bg-green-500 rounded-full flex-shrink-0" />
) : (
<span className="w-1.5 h-1.5 mt-1.5 bg-red-500 rounded-full flex-shrink-0" />
)}
<span className="text-[11px] text-gray-600 dark:text-gray-400 leading-relaxed">
{step.content}
</span>
</div>
))}
{/* Result */}
{task.result && (
<div className="text-xs text-gray-700 dark:text-gray-300 mt-1 whitespace-pre-wrap">
{task.result}
</div>
)}
{/* Error */}
{task.error && (
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
{task.error}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}