flowyml 1.1.0__py3-none-any.whl → 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. flowyml/__init__.py +3 -0
  2. flowyml/assets/base.py +10 -0
  3. flowyml/assets/metrics.py +6 -0
  4. flowyml/cli/main.py +108 -2
  5. flowyml/cli/run.py +9 -2
  6. flowyml/core/execution_status.py +52 -0
  7. flowyml/core/hooks.py +106 -0
  8. flowyml/core/observability.py +210 -0
  9. flowyml/core/orchestrator.py +274 -0
  10. flowyml/core/pipeline.py +193 -231
  11. flowyml/core/project.py +34 -2
  12. flowyml/core/remote_orchestrator.py +109 -0
  13. flowyml/core/resources.py +22 -5
  14. flowyml/core/retry_policy.py +80 -0
  15. flowyml/core/step.py +18 -1
  16. flowyml/core/submission_result.py +53 -0
  17. flowyml/core/versioning.py +2 -2
  18. flowyml/integrations/keras.py +95 -22
  19. flowyml/monitoring/alerts.py +2 -2
  20. flowyml/stacks/__init__.py +15 -0
  21. flowyml/stacks/aws.py +599 -0
  22. flowyml/stacks/azure.py +295 -0
  23. flowyml/stacks/components.py +24 -2
  24. flowyml/stacks/gcp.py +158 -11
  25. flowyml/stacks/local.py +5 -0
  26. flowyml/storage/artifacts.py +15 -5
  27. flowyml/storage/materializers/__init__.py +2 -0
  28. flowyml/storage/materializers/cloudpickle.py +74 -0
  29. flowyml/storage/metadata.py +166 -5
  30. flowyml/ui/backend/main.py +41 -1
  31. flowyml/ui/backend/routers/assets.py +356 -15
  32. flowyml/ui/backend/routers/client.py +46 -0
  33. flowyml/ui/backend/routers/execution.py +13 -2
  34. flowyml/ui/backend/routers/experiments.py +48 -12
  35. flowyml/ui/backend/routers/metrics.py +213 -0
  36. flowyml/ui/backend/routers/pipelines.py +63 -7
  37. flowyml/ui/backend/routers/projects.py +33 -7
  38. flowyml/ui/backend/routers/runs.py +150 -8
  39. flowyml/ui/frontend/dist/assets/index-DcYwrn2j.css +1 -0
  40. flowyml/ui/frontend/dist/assets/index-Dlz_ygOL.js +592 -0
  41. flowyml/ui/frontend/dist/index.html +2 -2
  42. flowyml/ui/frontend/src/App.jsx +4 -1
  43. flowyml/ui/frontend/src/app/assets/page.jsx +260 -230
  44. flowyml/ui/frontend/src/app/dashboard/page.jsx +38 -7
  45. flowyml/ui/frontend/src/app/experiments/page.jsx +61 -314
  46. flowyml/ui/frontend/src/app/observability/page.jsx +277 -0
  47. flowyml/ui/frontend/src/app/pipelines/page.jsx +79 -402
  48. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectArtifactsList.jsx +151 -0
  49. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectExperimentsList.jsx +145 -0
  50. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectHeader.jsx +45 -0
  51. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectHierarchy.jsx +467 -0
  52. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectMetricsPanel.jsx +253 -0
  53. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectPipelinesList.jsx +105 -0
  54. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectRelations.jsx +189 -0
  55. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectRunsList.jsx +136 -0
  56. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectTabs.jsx +95 -0
  57. flowyml/ui/frontend/src/app/projects/[projectId]/page.jsx +326 -0
  58. flowyml/ui/frontend/src/app/projects/page.jsx +13 -3
  59. flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +79 -10
  60. flowyml/ui/frontend/src/app/runs/page.jsx +82 -424
  61. flowyml/ui/frontend/src/app/settings/page.jsx +1 -0
  62. flowyml/ui/frontend/src/app/tokens/page.jsx +62 -16
  63. flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +373 -0
  64. flowyml/ui/frontend/src/components/AssetLineageGraph.jsx +291 -0
  65. flowyml/ui/frontend/src/components/AssetStatsDashboard.jsx +302 -0
  66. flowyml/ui/frontend/src/components/AssetTreeHierarchy.jsx +477 -0
  67. flowyml/ui/frontend/src/components/ExperimentDetailsPanel.jsx +227 -0
  68. flowyml/ui/frontend/src/components/NavigationTree.jsx +401 -0
  69. flowyml/ui/frontend/src/components/PipelineDetailsPanel.jsx +239 -0
  70. flowyml/ui/frontend/src/components/PipelineGraph.jsx +67 -3
  71. flowyml/ui/frontend/src/components/ProjectSelector.jsx +115 -0
  72. flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +298 -0
  73. flowyml/ui/frontend/src/components/header/Header.jsx +48 -1
  74. flowyml/ui/frontend/src/components/plugins/ZenMLIntegration.jsx +106 -0
  75. flowyml/ui/frontend/src/components/sidebar/Sidebar.jsx +52 -26
  76. flowyml/ui/frontend/src/components/ui/DataView.jsx +35 -17
  77. flowyml/ui/frontend/src/components/ui/ErrorBoundary.jsx +118 -0
  78. flowyml/ui/frontend/src/contexts/ProjectContext.jsx +2 -2
  79. flowyml/ui/frontend/src/contexts/ToastContext.jsx +116 -0
  80. flowyml/ui/frontend/src/layouts/MainLayout.jsx +5 -1
  81. flowyml/ui/frontend/src/router/index.jsx +4 -0
  82. flowyml/ui/frontend/src/utils/date.js +10 -0
  83. flowyml/ui/frontend/src/utils/downloads.js +11 -0
  84. flowyml/utils/config.py +6 -0
  85. flowyml/utils/stack_config.py +45 -3
  86. {flowyml-1.1.0.dist-info → flowyml-1.3.0.dist-info}/METADATA +113 -12
  87. {flowyml-1.1.0.dist-info → flowyml-1.3.0.dist-info}/RECORD +90 -53
  88. {flowyml-1.1.0.dist-info → flowyml-1.3.0.dist-info}/licenses/LICENSE +1 -1
  89. flowyml/ui/frontend/dist/assets/index-DFNQnrUj.js +0 -448
  90. flowyml/ui/frontend/dist/assets/index-pWI271rZ.css +0 -1
  91. {flowyml-1.1.0.dist-info → flowyml-1.3.0.dist-info}/WHEEL +0 -0
  92. {flowyml-1.1.0.dist-info → flowyml-1.3.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,239 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { fetchApi } from '../utils/api';
3
+ import {
4
+ Layers,
5
+ Calendar,
6
+ Activity,
7
+ Clock,
8
+ CheckCircle,
9
+ XCircle,
10
+ PlayCircle,
11
+ ArrowRight,
12
+ MoreHorizontal,
13
+ Download,
14
+ Trash2,
15
+ X,
16
+ TrendingUp,
17
+ Zap
18
+ } from 'lucide-react';
19
+ import { Card } from './ui/Card';
20
+ import { Badge } from './ui/Badge';
21
+ import { Button } from './ui/Button';
22
+ import { format } from 'date-fns';
23
+ import { Link } from 'react-router-dom';
24
+ import { motion } from 'framer-motion';
25
+ import { StatusBadge } from './ui/ExecutionStatus';
26
+ import { ProjectSelector } from './ProjectSelector';
27
+ import { useToast } from '../contexts/ToastContext';
28
+
29
+ export function PipelineDetailsPanel({ pipeline, onClose, onProjectUpdate }) {
30
+ const [runs, setRuns] = useState([]);
31
+ const [loading, setLoading] = useState(false);
32
+ const [currentProject, setCurrentProject] = useState(pipeline?.project);
33
+ const toast = useToast();
34
+
35
+ useEffect(() => {
36
+ if (pipeline) {
37
+ fetchRuns();
38
+ setCurrentProject(pipeline.project);
39
+ }
40
+ }, [pipeline]);
41
+
42
+ const fetchRuns = async () => {
43
+ setLoading(true);
44
+ try {
45
+ const url = `/api/runs?pipeline=${encodeURIComponent(pipeline.name)}&limit=50`;
46
+ const res = await fetchApi(url);
47
+ const data = await res.json();
48
+ setRuns(data.runs || []);
49
+ } catch (error) {
50
+ console.error('Failed to fetch runs:', error);
51
+ toast.error(`Failed to load pipeline runs: ${error.message}`);
52
+ } finally {
53
+ setLoading(false);
54
+ }
55
+ };
56
+
57
+ const handleProjectUpdate = async (newProject) => {
58
+ try {
59
+ await fetchApi(`/api/pipelines/${pipeline.name}/project`, {
60
+ method: 'PUT',
61
+ headers: {
62
+ 'Content-Type': 'application/json',
63
+ },
64
+ body: JSON.stringify({ project_name: newProject })
65
+ });
66
+ setCurrentProject(newProject);
67
+ toast.success(`Pipeline assigned to project: ${newProject}`);
68
+ // Trigger parent component refresh to update the pipelines list
69
+ if (onProjectUpdate) {
70
+ onProjectUpdate();
71
+ }
72
+ } catch (error) {
73
+ console.error('Failed to update project:', error);
74
+ toast.error(`Failed to update project: ${error.message}`);
75
+ }
76
+ };
77
+
78
+ if (!pipeline) return null;
79
+
80
+ const stats = {
81
+ total: runs.length,
82
+ success: runs.filter(r => r.status === 'completed').length,
83
+ failed: runs.filter(r => r.status === 'failed').length,
84
+ avgDuration: runs.length > 0
85
+ ? runs.reduce((acc, r) => acc + (r.duration || 0), 0) / runs.length
86
+ : 0
87
+ };
88
+
89
+ const successRate = stats.total > 0 ? (stats.success / stats.total) * 100 : 0;
90
+
91
+ return (
92
+ <div className="h-full flex flex-col bg-white dark:bg-slate-900">
93
+ {/* Header */}
94
+ <div className="p-6 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/50">
95
+ <div className="flex items-start justify-between mb-4">
96
+ <div className="flex items-center gap-4">
97
+ <div className="p-3 bg-gradient-to-br from-primary-500 to-purple-500 rounded-xl text-white shadow-lg">
98
+ <Layers size={24} />
99
+ </div>
100
+ <div>
101
+ <h2 className="text-2xl font-bold text-slate-900 dark:text-white">
102
+ {pipeline.name}
103
+ </h2>
104
+ <div className="flex items-center gap-2 mt-1 text-sm text-slate-500">
105
+ <ProjectSelector
106
+ currentProject={currentProject}
107
+ onUpdate={handleProjectUpdate}
108
+ />
109
+ {pipeline.version && (
110
+ <>
111
+ <span>•</span>
112
+ <Badge variant="secondary" className="text-xs">v{pipeline.version}</Badge>
113
+ </>
114
+ )}
115
+ </div>
116
+ </div>
117
+ </div>
118
+ <div className="flex items-center gap-2">
119
+ <Button variant="ghost" size="sm" onClick={onClose}>
120
+ <X size={20} className="text-slate-400" />
121
+ </Button>
122
+ </div>
123
+ </div>
124
+
125
+ {/* Stats Grid */}
126
+ <div className="grid grid-cols-4 gap-4">
127
+ <StatCard
128
+ label="Total Runs"
129
+ value={stats.total}
130
+ icon={Activity}
131
+ color="blue"
132
+ />
133
+ <StatCard
134
+ label="Success Rate"
135
+ value={`${Math.round(successRate)}%`}
136
+ icon={CheckCircle}
137
+ color={successRate >= 80 ? 'emerald' : successRate >= 50 ? 'amber' : 'rose'}
138
+ />
139
+ <StatCard
140
+ label="Avg Duration"
141
+ value={`${stats.avgDuration.toFixed(1)}s`}
142
+ icon={Clock}
143
+ color="purple"
144
+ />
145
+ <StatCard
146
+ label="Failed"
147
+ value={stats.failed}
148
+ icon={XCircle}
149
+ color="rose"
150
+ />
151
+ </div>
152
+ </div>
153
+
154
+ {/* Content - Runs List */}
155
+ <div className="flex-1 overflow-hidden flex flex-col">
156
+ <div className="p-4 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between bg-white dark:bg-slate-900">
157
+ <h3 className="font-semibold text-slate-900 dark:text-white flex items-center gap-2">
158
+ <PlayCircle size={18} className="text-slate-400" />
159
+ Recent Executions
160
+ </h3>
161
+ <Link to={`/runs?pipeline=${encodeURIComponent(pipeline.name)}`}>
162
+ <Button variant="ghost" size="sm" className="text-primary-600">
163
+ View All Runs <ArrowRight size={16} className="ml-1" />
164
+ </Button>
165
+ </Link>
166
+ </div>
167
+
168
+ <div className="flex-1 overflow-y-auto p-4 space-y-3 bg-slate-50 dark:bg-slate-900/50">
169
+ {loading ? (
170
+ <div className="flex justify-center py-8">
171
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
172
+ </div>
173
+ ) : runs.length === 0 ? (
174
+ <div className="text-center py-12 text-slate-500">
175
+ No runs found for this pipeline
176
+ </div>
177
+ ) : (
178
+ runs.map(run => (
179
+ <Link key={run.run_id} to={`/runs/${run.run_id}`}>
180
+ <motion.div
181
+ initial={{ opacity: 0, y: 10 }}
182
+ animate={{ opacity: 1, y: 0 }}
183
+ className="bg-white dark:bg-slate-800 p-4 rounded-xl border border-slate-200 dark:border-slate-700 hover:shadow-md hover:border-primary-300 dark:hover:border-primary-700 transition-all group"
184
+ >
185
+ <div className="flex items-center justify-between">
186
+ <div className="flex items-center gap-4">
187
+ <StatusBadge status={run.status} />
188
+ <div>
189
+ <div className="font-medium text-slate-900 dark:text-white flex items-center gap-2">
190
+ {run.name || `Run ${run.run_id.slice(0, 8)}`}
191
+ <span className="text-xs font-mono text-slate-400">#{run.run_id.slice(0, 6)}</span>
192
+ </div>
193
+ <div className="flex items-center gap-3 text-xs text-slate-500 mt-1">
194
+ <span className="flex items-center gap-1">
195
+ <Calendar size={12} />
196
+ {format(new Date(run.created || run.start_time), 'MMM d, HH:mm')}
197
+ </span>
198
+ <span className="flex items-center gap-1">
199
+ <Clock size={12} />
200
+ {run.duration ? `${run.duration.toFixed(1)}s` : '-'}
201
+ </span>
202
+ </div>
203
+ </div>
204
+ </div>
205
+ <ArrowRight size={16} className="text-slate-300 group-hover:text-primary-500 transition-colors" />
206
+ </div>
207
+ </motion.div>
208
+ </Link>
209
+ ))
210
+ )}
211
+ </div>
212
+ </div>
213
+ </div>
214
+ );
215
+ }
216
+
217
+ function StatCard({ label, value, icon: Icon, color }) {
218
+ const colorClasses = {
219
+ blue: 'text-blue-600 bg-blue-50 dark:bg-blue-900/20',
220
+ emerald: 'text-emerald-600 bg-emerald-50 dark:bg-emerald-900/20',
221
+ purple: 'text-purple-600 bg-purple-50 dark:bg-purple-900/20',
222
+ rose: 'text-rose-600 bg-rose-50 dark:bg-rose-900/20',
223
+ amber: 'text-amber-600 bg-amber-50 dark:bg-amber-900/20',
224
+ };
225
+
226
+ return (
227
+ <div className="bg-white dark:bg-slate-800 p-3 rounded-xl border border-slate-200 dark:border-slate-700">
228
+ <div className="flex items-center gap-2 mb-1">
229
+ <div className={`p-1 rounded-lg ${colorClasses[color] || colorClasses.blue}`}>
230
+ <Icon size={14} />
231
+ </div>
232
+ <span className="text-xs text-slate-500 font-medium">{label}</span>
233
+ </div>
234
+ <div className="text-lg font-bold text-slate-900 dark:text-white pl-1">
235
+ {value}
236
+ </div>
237
+ </div>
238
+ );
239
+ }
@@ -276,16 +276,80 @@ function CustomStepNode({ data }) {
276
276
  );
277
277
  }
278
278
 
279
+
279
280
  function CustomArtifactNode({ data }) {
281
+ // Determine icon and styling based on artifact type (inferred from label)
282
+ const getArtifactStyle = (label) => {
283
+ const lowerLabel = label.toLowerCase();
284
+
285
+ if (lowerLabel.includes('model') || lowerLabel.includes('weights')) {
286
+ return {
287
+ icon: Box,
288
+ bgColor: 'bg-purple-100 dark:bg-purple-900/30',
289
+ borderColor: 'border-purple-400 dark:border-purple-600',
290
+ iconColor: 'text-purple-600 dark:text-purple-400',
291
+ textColor: 'text-purple-900 dark:text-purple-100'
292
+ };
293
+ }
294
+ if (lowerLabel.includes('feature') || lowerLabel.includes('train_set') || lowerLabel.includes('test_set')) {
295
+ return {
296
+ icon: Layers,
297
+ bgColor: 'bg-emerald-100 dark:bg-emerald-900/30',
298
+ borderColor: 'border-emerald-400 dark:border-emerald-600',
299
+ iconColor: 'text-emerald-600 dark:text-emerald-400',
300
+ textColor: 'text-emerald-900 dark:text-emerald-100'
301
+ };
302
+ }
303
+ if (lowerLabel.includes('data') || lowerLabel.includes('batch') || lowerLabel.includes('set')) {
304
+ return {
305
+ icon: Database,
306
+ bgColor: 'bg-blue-100 dark:bg-blue-900/30',
307
+ borderColor: 'border-blue-400 dark:border-blue-600',
308
+ iconColor: 'text-blue-600 dark:text-blue-400',
309
+ textColor: 'text-blue-900 dark:text-blue-100'
310
+ };
311
+ }
312
+ if (lowerLabel.includes('metrics') || lowerLabel.includes('report') || lowerLabel.includes('status')) {
313
+ return {
314
+ icon: BarChart2,
315
+ bgColor: 'bg-orange-100 dark:bg-orange-900/30',
316
+ borderColor: 'border-orange-400 dark:border-orange-600',
317
+ iconColor: 'text-orange-600 dark:text-orange-400',
318
+ textColor: 'text-orange-900 dark:text-orange-100'
319
+ };
320
+ }
321
+ if (lowerLabel.includes('image') || lowerLabel.includes('docker')) {
322
+ return {
323
+ icon: Box,
324
+ bgColor: 'bg-cyan-100 dark:bg-cyan-900/30',
325
+ borderColor: 'border-cyan-400 dark:border-cyan-600',
326
+ iconColor: 'text-cyan-600 dark:text-cyan-400',
327
+ textColor: 'text-cyan-900 dark:text-cyan-100'
328
+ };
329
+ }
330
+
331
+ // Default style
332
+ return {
333
+ icon: FileText,
334
+ bgColor: 'bg-slate-100 dark:bg-slate-800',
335
+ borderColor: 'border-slate-300 dark:border-slate-600',
336
+ iconColor: 'text-slate-500 dark:text-slate-400',
337
+ textColor: 'text-slate-700 dark:text-slate-300'
338
+ };
339
+ };
340
+
341
+ const style = getArtifactStyle(data.label);
342
+ const Icon = style.icon;
343
+
280
344
  return (
281
345
  <div
282
- className="px-3 py-2 rounded-full bg-slate-100 border border-slate-300 flex items-center justify-center gap-2 shadow-sm min-w-[120px]"
346
+ className={`px-3 py-2 rounded-lg ${style.bgColor} border-2 ${style.borderColor} flex items-center justify-center gap-2 shadow-md hover:shadow-lg transition-all min-w-[140px]`}
283
347
  style={{ height: artifactNodeHeight }}
284
348
  >
285
349
  <Handle type="target" position={Position.Top} className="!bg-slate-400 !w-2 !h-2" />
286
350
 
287
- <Database size={12} className="text-slate-500" />
288
- <span className="text-xs font-medium text-slate-700 truncate max-w-[140px]" title={data.label}>
351
+ <Icon size={14} className={style.iconColor} />
352
+ <span className={`text-xs font-semibold ${style.textColor} truncate max-w-[100px]`} title={data.label}>
289
353
  {data.label}
290
354
  </span>
291
355
 
@@ -0,0 +1,115 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { fetchApi } from '../utils/api';
3
+ import { Folder, ChevronDown, Check } from 'lucide-react';
4
+ import { motion, AnimatePresence } from 'framer-motion';
5
+
6
+ export function ProjectSelector({ currentProject, onUpdate, type = 'default' }) {
7
+ const [projects, setProjects] = useState([]);
8
+ const [isOpen, setIsOpen] = useState(false);
9
+ const [loading, setLoading] = useState(false);
10
+
11
+ useEffect(() => {
12
+ if (isOpen && projects.length === 0) {
13
+ fetchProjects();
14
+ }
15
+ }, [isOpen]);
16
+
17
+ const fetchProjects = async () => {
18
+ setLoading(true);
19
+ try {
20
+ const res = await fetchApi('/api/projects/');
21
+ const data = await res.json();
22
+ setProjects(data.projects || []);
23
+ } catch (error) {
24
+ console.error('Failed to fetch projects:', error);
25
+ } finally {
26
+ setLoading(false);
27
+ }
28
+ };
29
+
30
+ const handleSelect = async (projectName) => {
31
+ if (projectName === currentProject) {
32
+ setIsOpen(false);
33
+ return;
34
+ }
35
+
36
+ await onUpdate(projectName);
37
+ setIsOpen(false);
38
+ };
39
+
40
+ return (
41
+ <div className="relative">
42
+ <button
43
+ onClick={() => setIsOpen(!isOpen)}
44
+ className={`
45
+ flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-all
46
+ ${currentProject
47
+ ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/30'
48
+ : 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700'
49
+ }
50
+ `}
51
+ >
52
+ <Folder size={14} />
53
+ <span>{currentProject || 'Assign Project'}</span>
54
+ <ChevronDown size={12} className={`transition-transform ${isOpen ? 'rotate-180' : ''}`} />
55
+ </button>
56
+
57
+ <AnimatePresence>
58
+ {isOpen && (
59
+ <>
60
+ <div
61
+ className="fixed inset-0 z-40"
62
+ onClick={() => setIsOpen(false)}
63
+ />
64
+ <motion.div
65
+ initial={{ opacity: 0, y: 5, scale: 0.95 }}
66
+ animate={{ opacity: 1, y: 0, scale: 1 }}
67
+ exit={{ opacity: 0, y: 5, scale: 0.95 }}
68
+ transition={{ duration: 0.1 }}
69
+ className="absolute top-full left-0 mt-2 w-56 bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 z-50 overflow-hidden"
70
+ >
71
+ <div className="p-2 border-b border-slate-100 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
72
+ <span className="text-xs font-semibold text-slate-500 uppercase tracking-wider px-2">
73
+ Select Project
74
+ </span>
75
+ </div>
76
+
77
+ <div className="max-h-64 overflow-y-auto p-1">
78
+ {loading ? (
79
+ <div className="p-4 text-center text-slate-400 text-xs">Loading...</div>
80
+ ) : (
81
+ <>
82
+ {projects.map(project => (
83
+ <button
84
+ key={project.name}
85
+ onClick={() => handleSelect(project.name)}
86
+ className={`
87
+ w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors
88
+ ${currentProject === project.name
89
+ ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
90
+ : 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700'
91
+ }
92
+ `}
93
+ >
94
+ <div className="flex items-center gap-2">
95
+ <Folder size={14} />
96
+ <span className="truncate max-w-[140px]">{project.name}</span>
97
+ </div>
98
+ {currentProject === project.name && <Check size={14} />}
99
+ </button>
100
+ ))}
101
+ {projects.length === 0 && (
102
+ <div className="p-3 text-center text-slate-400 text-xs">
103
+ No projects found
104
+ </div>
105
+ )}
106
+ </>
107
+ )}
108
+ </div>
109
+ </motion.div>
110
+ </>
111
+ )}
112
+ </AnimatePresence>
113
+ </div>
114
+ );
115
+ }