groove-dev 0.27.142 → 0.27.144
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.
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +1086 -6532
- package/node_modules/@groove-dev/daemon/src/gateways/manager.js +35 -1
- package/node_modules/@groove-dev/daemon/src/index.js +3 -0
- package/node_modules/@groove-dev/daemon/src/journalist.js +23 -13
- package/node_modules/@groove-dev/daemon/src/mlx-server.js +365 -0
- package/node_modules/@groove-dev/daemon/src/model-lab.js +308 -12
- package/node_modules/@groove-dev/daemon/src/pm.js +1 -1
- package/node_modules/@groove-dev/daemon/src/process.js +2 -2
- package/node_modules/@groove-dev/daemon/src/providers/local.js +36 -8
- package/node_modules/@groove-dev/daemon/src/registry.js +21 -5
- package/node_modules/@groove-dev/daemon/src/routes/agents.js +889 -0
- package/node_modules/@groove-dev/daemon/src/routes/coordination.js +318 -0
- package/node_modules/@groove-dev/daemon/src/routes/files.js +751 -0
- package/node_modules/@groove-dev/daemon/src/routes/integrations.js +485 -0
- package/node_modules/@groove-dev/daemon/src/routes/network.js +1784 -0
- package/node_modules/@groove-dev/daemon/src/routes/providers.js +755 -0
- package/node_modules/@groove-dev/daemon/src/routes/schedules.js +110 -0
- package/node_modules/@groove-dev/daemon/src/routes/teams.js +650 -0
- package/node_modules/@groove-dev/daemon/src/scheduler.js +456 -24
- package/node_modules/@groove-dev/daemon/src/teams.js +1 -1
- package/node_modules/@groove-dev/daemon/src/validate.js +38 -1
- package/node_modules/@groove-dev/daemon/templates/mlx-setup.json +12 -0
- package/node_modules/@groove-dev/daemon/templates/tgi-setup.json +1 -1
- package/node_modules/@groove-dev/daemon/templates/vllm-setup.json +1 -1
- package/node_modules/@groove-dev/daemon/test/introducer.test.js +3 -3
- package/node_modules/@groove-dev/daemon/test/journalist.test.js +7 -10
- package/node_modules/@groove-dev/daemon/test/registry.test.js +38 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BcoF6_eF.js +1012 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-Dd7qhiEd.css +1 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/{packages/gui/src/app.jsx → node_modules/@groove-dev/gui/src/App.jsx} +0 -2
- package/node_modules/@groove-dev/gui/src/app.css +35 -0
- package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +1 -128
- package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +144 -31
- package/node_modules/@groove-dev/gui/src/components/agents/agent-node.jsx +8 -13
- package/node_modules/@groove-dev/gui/src/components/agents/code-review.jsx +159 -122
- package/node_modules/@groove-dev/gui/src/components/agents/diff-viewer.jsx +23 -23
- package/node_modules/@groove-dev/gui/src/components/agents/journalist-panel.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +2 -135
- package/node_modules/@groove-dev/gui/src/components/automations/automation-card.jsx +274 -0
- package/node_modules/@groove-dev/gui/src/components/automations/automation-wizard.jsx +1136 -0
- package/node_modules/@groove-dev/gui/src/components/dashboard/activity-feed.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/dashboard/cache-ring.jsx +5 -5
- package/node_modules/@groove-dev/gui/src/components/dashboard/context-gauges.jsx +6 -8
- package/node_modules/@groove-dev/gui/src/components/dashboard/fleet-panel.jsx +8 -14
- package/node_modules/@groove-dev/gui/src/components/dashboard/intel-panel.jsx +238 -656
- package/node_modules/@groove-dev/gui/src/components/dashboard/kpi-card.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/dashboard/routing-chart.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/dashboard/team-burn-panel.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/dashboard/token-chart.jsx +4 -4
- package/node_modules/@groove-dev/gui/src/components/editor/selection-menu.jsx +2 -0
- package/node_modules/@groove-dev/gui/src/components/lab/lab-assistant.jsx +316 -82
- package/node_modules/@groove-dev/gui/src/components/lab/metrics-panel.jsx +187 -32
- package/node_modules/@groove-dev/gui/src/components/lab/parameter-panel.jsx +195 -14
- package/node_modules/@groove-dev/gui/src/components/lab/runtime-config.jsx +286 -102
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -4
- package/node_modules/@groove-dev/gui/src/components/layout/terminal-panel.jsx +4 -2
- package/node_modules/@groove-dev/gui/src/components/layout/welcome-splash.jsx +137 -108
- package/node_modules/@groove-dev/gui/src/components/network/network-health.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/components/network/performance-dashboard.jsx +4 -4
- package/node_modules/@groove-dev/gui/src/components/settings/ssh-wizard.jsx +81 -99
- package/node_modules/@groove-dev/gui/src/components/ui/sheet.jsx +5 -2
- package/node_modules/@groove-dev/gui/src/lib/cron.js +64 -0
- package/node_modules/@groove-dev/gui/src/lib/status.js +24 -24
- package/node_modules/@groove-dev/gui/src/lib/theme-hex.js +1 -0
- package/node_modules/@groove-dev/gui/src/stores/groove.js +34 -3144
- package/node_modules/@groove-dev/gui/src/stores/helpers.js +10 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +452 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/automations-slice.js +96 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/chat-slice.js +227 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/editor-slice.js +285 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/marketplace-slice.js +461 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/network-slice.js +361 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/preview-slice.js +109 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/providers-slice.js +897 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/teams-slice.js +413 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/ui-slice.js +98 -0
- package/node_modules/@groove-dev/gui/src/views/agents.jsx +5 -5
- package/node_modules/@groove-dev/gui/src/views/dashboard.jsx +12 -13
- package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +191 -3
- package/node_modules/@groove-dev/gui/src/views/model-lab.jsx +17 -6
- package/node_modules/@groove-dev/gui/src/views/models.jsx +410 -509
- package/node_modules/@groove-dev/gui/src/views/network.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +81 -94
- package/node_modules/@groove-dev/gui/src/views/teams.jsx +40 -483
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +1086 -6532
- package/packages/daemon/src/gateways/manager.js +35 -1
- package/packages/daemon/src/index.js +3 -0
- package/packages/daemon/src/journalist.js +23 -13
- package/packages/daemon/src/mlx-server.js +365 -0
- package/packages/daemon/src/model-lab.js +308 -12
- package/packages/daemon/src/pm.js +1 -1
- package/packages/daemon/src/process.js +2 -2
- package/packages/daemon/src/providers/local.js +36 -8
- package/packages/daemon/src/registry.js +21 -5
- package/packages/daemon/src/routes/agents.js +889 -0
- package/packages/daemon/src/routes/coordination.js +318 -0
- package/packages/daemon/src/routes/files.js +751 -0
- package/packages/daemon/src/routes/integrations.js +485 -0
- package/packages/daemon/src/routes/network.js +1784 -0
- package/packages/daemon/src/routes/providers.js +755 -0
- package/packages/daemon/src/routes/schedules.js +110 -0
- package/packages/daemon/src/routes/teams.js +650 -0
- package/packages/daemon/src/scheduler.js +456 -24
- package/packages/daemon/src/teams.js +1 -1
- package/packages/daemon/src/validate.js +38 -1
- package/packages/daemon/templates/mlx-setup.json +12 -0
- package/packages/daemon/templates/tgi-setup.json +1 -1
- package/packages/daemon/templates/vllm-setup.json +1 -1
- package/packages/gui/dist/assets/index-BcoF6_eF.js +1012 -0
- package/packages/gui/dist/assets/index-Dd7qhiEd.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/{node_modules/@groove-dev/gui/src/app.jsx → packages/gui/src/App.jsx} +0 -2
- package/packages/gui/src/app.css +35 -0
- package/packages/gui/src/components/agents/agent-config.jsx +1 -128
- package/packages/gui/src/components/agents/agent-feed.jsx +144 -31
- package/packages/gui/src/components/agents/agent-node.jsx +8 -13
- package/packages/gui/src/components/agents/code-review.jsx +159 -122
- package/packages/gui/src/components/agents/diff-viewer.jsx +23 -23
- package/packages/gui/src/components/agents/journalist-panel.jsx +1 -1
- package/packages/gui/src/components/agents/spawn-wizard.jsx +2 -135
- package/packages/gui/src/components/automations/automation-card.jsx +274 -0
- package/packages/gui/src/components/automations/automation-wizard.jsx +1136 -0
- package/packages/gui/src/components/dashboard/activity-feed.jsx +3 -3
- package/packages/gui/src/components/dashboard/cache-ring.jsx +5 -5
- package/packages/gui/src/components/dashboard/context-gauges.jsx +6 -8
- package/packages/gui/src/components/dashboard/fleet-panel.jsx +8 -14
- package/packages/gui/src/components/dashboard/intel-panel.jsx +238 -656
- package/packages/gui/src/components/dashboard/kpi-card.jsx +3 -3
- package/packages/gui/src/components/dashboard/routing-chart.jsx +3 -3
- package/packages/gui/src/components/dashboard/team-burn-panel.jsx +1 -1
- package/packages/gui/src/components/dashboard/token-chart.jsx +4 -4
- package/packages/gui/src/components/editor/selection-menu.jsx +2 -0
- package/packages/gui/src/components/lab/lab-assistant.jsx +316 -82
- package/packages/gui/src/components/lab/metrics-panel.jsx +187 -32
- package/packages/gui/src/components/lab/parameter-panel.jsx +195 -14
- package/packages/gui/src/components/lab/runtime-config.jsx +286 -102
- package/packages/gui/src/components/layout/activity-bar.jsx +2 -4
- package/packages/gui/src/components/layout/terminal-panel.jsx +4 -2
- package/packages/gui/src/components/layout/welcome-splash.jsx +137 -108
- package/packages/gui/src/components/network/network-health.jsx +2 -2
- package/packages/gui/src/components/network/performance-dashboard.jsx +4 -4
- package/packages/gui/src/components/settings/ssh-wizard.jsx +81 -99
- package/packages/gui/src/components/ui/sheet.jsx +5 -2
- package/packages/gui/src/lib/cron.js +64 -0
- package/packages/gui/src/lib/status.js +24 -24
- package/packages/gui/src/lib/theme-hex.js +1 -0
- package/packages/gui/src/stores/groove.js +34 -3144
- package/packages/gui/src/stores/helpers.js +10 -0
- package/packages/gui/src/stores/slices/agents-slice.js +452 -0
- package/packages/gui/src/stores/slices/automations-slice.js +96 -0
- package/packages/gui/src/stores/slices/chat-slice.js +227 -0
- package/packages/gui/src/stores/slices/editor-slice.js +285 -0
- package/packages/gui/src/stores/slices/marketplace-slice.js +461 -0
- package/packages/gui/src/stores/slices/network-slice.js +361 -0
- package/packages/gui/src/stores/slices/preview-slice.js +109 -0
- package/packages/gui/src/stores/slices/providers-slice.js +897 -0
- package/packages/gui/src/stores/slices/teams-slice.js +413 -0
- package/packages/gui/src/stores/slices/ui-slice.js +98 -0
- package/packages/gui/src/views/agents.jsx +5 -5
- package/packages/gui/src/views/dashboard.jsx +12 -13
- package/packages/gui/src/views/marketplace.jsx +191 -3
- package/packages/gui/src/views/model-lab.jsx +17 -6
- package/packages/gui/src/views/models.jsx +410 -509
- package/packages/gui/src/views/network.jsx +3 -3
- package/packages/gui/src/views/settings.jsx +81 -94
- package/packages/gui/src/views/teams.jsx +40 -483
- package/SECURITY_SWEEP.md +0 -228
- package/TRAINING_DATA_v4.md +0 -6
- package/node_modules/@groove-dev/gui/dist/assets/index-Bjd91ufV.js +0 -984
- package/node_modules/@groove-dev/gui/dist/assets/index-BqdwIFn4.css +0 -1
- package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +0 -322
- package/node_modules/@groove-dev/gui/src/views/preview.jsx +0 -6
- package/node_modules/@groove-dev/gui/src/views/subscription-panel.jsx +0 -327
- package/packages/gui/dist/assets/index-Bjd91ufV.js +0 -984
- package/packages/gui/dist/assets/index-BqdwIFn4.css +0 -1
- package/packages/gui/src/components/agents/agent-chat.jsx +0 -322
- package/packages/gui/src/views/preview.jsx +0 -6
- package/packages/gui/src/views/subscription-panel.jsx +0 -327
- package/test.py +0 -571
|
@@ -0,0 +1,1136 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
|
+
import { useGrooveStore } from '../../stores/groove';
|
|
4
|
+
import { Dialog, DialogContent } from '../ui/dialog';
|
|
5
|
+
import { Button } from '../ui/button';
|
|
6
|
+
import { Badge } from '../ui/badge';
|
|
7
|
+
import { Input, Textarea } from '../ui/input';
|
|
8
|
+
import { Select, SelectTrigger, SelectContent, SelectItem } from '../ui/select';
|
|
9
|
+
import { Collapsible } from '../ui/collapsible';
|
|
10
|
+
import { CRON_PRESETS, validateCron } from '../../lib/cron';
|
|
11
|
+
import { api } from '../../lib/api';
|
|
12
|
+
import { cn } from '../../lib/cn';
|
|
13
|
+
import {
|
|
14
|
+
User, Code, Briefcase, Settings, Plus, X, Clock,
|
|
15
|
+
FileText, Folder, ChevronRight, ChevronLeft, Package,
|
|
16
|
+
FolderOpen, FolderClosed, ArrowUp, HardDrive,
|
|
17
|
+
Loader2, File, Save, MessageSquare, ChevronDown, AlertTriangle,
|
|
18
|
+
} from 'lucide-react';
|
|
19
|
+
|
|
20
|
+
const TEAM_TYPES = [
|
|
21
|
+
{ id: 'solo', label: 'Solo Agent', icon: User, description: 'Single agent for focused tasks' },
|
|
22
|
+
{ id: 'dev', label: 'Dev Team', icon: Code, description: 'Frontend + Backend + QC' },
|
|
23
|
+
{ id: 'business', label: 'Business Team', icon: Briefcase, description: 'CMO + CFO + Analyst' },
|
|
24
|
+
{ id: 'custom', label: 'Custom', icon: Settings, description: 'Build your own team' },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const DEV_ROLES = [
|
|
28
|
+
{ role: 'frontend', phase: 1 },
|
|
29
|
+
{ role: 'backend', phase: 1 },
|
|
30
|
+
{ role: 'fullstack', phase: 2 },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const BUSINESS_ROLES = [
|
|
34
|
+
{ role: 'CMO', phase: 1 },
|
|
35
|
+
{ role: 'CFO', phase: 1 },
|
|
36
|
+
{ role: 'analyst', phase: 2 },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const ALL_ROLES = [
|
|
40
|
+
'chat', 'planner', 'backend', 'frontend', 'fullstack',
|
|
41
|
+
'testing', 'devops', 'docs', 'security', 'database',
|
|
42
|
+
'cmo', 'cfo', 'ea', 'support', 'analyst',
|
|
43
|
+
'creative', 'slides', 'ambassador',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const ROLE_LABELS = {
|
|
47
|
+
chat: 'Chat', planner: 'Planner', backend: 'Backend', frontend: 'Frontend',
|
|
48
|
+
fullstack: 'Fullstack', testing: 'Testing', devops: 'DevOps', docs: 'Docs',
|
|
49
|
+
security: 'Security', database: 'Database', cmo: 'CMO', cfo: 'CFO',
|
|
50
|
+
ea: 'EA', support: 'Support', analyst: 'Analyst', creative: 'Writer',
|
|
51
|
+
slides: 'Slides', ambassador: 'Ambassador',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function roleLabel(r) { return ROLE_LABELS[r] || r; }
|
|
55
|
+
|
|
56
|
+
const MEMORY_PATTERN = /\[read\]\s*#[\w/.-]+/g;
|
|
57
|
+
|
|
58
|
+
function detectMemoryRefs(text) {
|
|
59
|
+
if (!text) return [];
|
|
60
|
+
return [...text.matchAll(MEMORY_PATTERN)].map((m) => m[0]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function MemoryBadges({ text }) {
|
|
64
|
+
const refs = detectMemoryRefs(text);
|
|
65
|
+
if (refs.length === 0) return null;
|
|
66
|
+
return (
|
|
67
|
+
<div className="flex items-center gap-1.5 flex-wrap px-1">
|
|
68
|
+
{refs.map((ref, i) => (
|
|
69
|
+
<span key={i} className="text-2xs font-mono text-teal-400">{ref}</span>
|
|
70
|
+
))}
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const SCHEDULE_UNITS = [
|
|
76
|
+
{ value: 'min', label: 'minutes' },
|
|
77
|
+
{ value: 'hour', label: 'hours' },
|
|
78
|
+
{ value: 'day', label: 'days' },
|
|
79
|
+
{ value: 'week', label: 'weeks' },
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const INITIAL_FORM = {
|
|
83
|
+
name: '',
|
|
84
|
+
description: '',
|
|
85
|
+
teamType: null,
|
|
86
|
+
soloRole: 'planner',
|
|
87
|
+
customRoles: [{ role: 'fullstack', phase: 1 }],
|
|
88
|
+
provider: '',
|
|
89
|
+
model: '',
|
|
90
|
+
runtimeId: '',
|
|
91
|
+
instructionMode: 'write',
|
|
92
|
+
instructions: '',
|
|
93
|
+
filePath: '',
|
|
94
|
+
cronPreset: null,
|
|
95
|
+
scheduleMode: 'preset',
|
|
96
|
+
scheduleCount: 1,
|
|
97
|
+
scheduleUnit: 'hour',
|
|
98
|
+
enabledOnCreate: true,
|
|
99
|
+
gatewayIds: [],
|
|
100
|
+
notifyOn: 'complete',
|
|
101
|
+
integrationIds: [],
|
|
102
|
+
outputFilePath: '',
|
|
103
|
+
outputCustom: '',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
function simpleToCron(count, unit) {
|
|
107
|
+
const n = parseInt(count, 10);
|
|
108
|
+
if (!n || n < 1) return null;
|
|
109
|
+
switch (unit) {
|
|
110
|
+
case 'min': return `*/${n} * * * *`;
|
|
111
|
+
case 'hour': return n === 1 ? '0 * * * *' : `0 */${n} * * *`;
|
|
112
|
+
case 'day': return n === 1 ? '0 9 * * *' : `0 9 */${n} * *`;
|
|
113
|
+
case 'week': return '0 9 * * 1';
|
|
114
|
+
default: return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function simpleToCronLabel(count, unit) {
|
|
119
|
+
const n = parseInt(count, 10);
|
|
120
|
+
if (!n || n < 1) return '';
|
|
121
|
+
const labels = { min: 'minute', hour: 'hour', day: 'day', week: 'week' };
|
|
122
|
+
return `Every ${n === 1 ? '' : n + ' '}${labels[unit]}${n !== 1 ? 's' : ''}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function cronToSimple(cron) {
|
|
126
|
+
if (!cron) return null;
|
|
127
|
+
const p = cron.trim().split(/\s+/);
|
|
128
|
+
if (p.length !== 5) return null;
|
|
129
|
+
const [min, hour, dom, mon, dow] = p;
|
|
130
|
+
if (min.startsWith('*/') && hour === '*' && dom === '*' && mon === '*' && dow === '*')
|
|
131
|
+
return { count: parseInt(min.slice(2)), unit: 'min' };
|
|
132
|
+
if (min === '0' && hour === '*' && dom === '*' && mon === '*' && dow === '*')
|
|
133
|
+
return { count: 1, unit: 'hour' };
|
|
134
|
+
if (min === '0' && hour.startsWith('*/') && dom === '*' && mon === '*' && dow === '*')
|
|
135
|
+
return { count: parseInt(hour.slice(2)), unit: 'hour' };
|
|
136
|
+
if (min === '0' && /^\d+$/.test(hour) && dom === '*' && mon === '*' && dow === '*')
|
|
137
|
+
return { count: 1, unit: 'day' };
|
|
138
|
+
if (min === '0' && /^\d+$/.test(hour) && dom.startsWith('*/') && mon === '*' && dow === '*')
|
|
139
|
+
return { count: parseInt(dom.slice(2)), unit: 'day' };
|
|
140
|
+
if (min === '0' && /^\d+$/.test(hour) && dom === '*' && mon === '*' && /^\d$/.test(dow))
|
|
141
|
+
return { count: 1, unit: 'week' };
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function rolesMatch(a, b) {
|
|
146
|
+
return a.length === b.length &&
|
|
147
|
+
a.every((r, i) => b[i]?.role === r.role && (b[i]?.phase || 1) === r.phase);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function automationToForm(a) {
|
|
151
|
+
let teamType = 'custom';
|
|
152
|
+
let soloRole = 'planner';
|
|
153
|
+
let customRoles = [{ role: 'fullstack', phase: 1 }];
|
|
154
|
+
|
|
155
|
+
if (a.agentConfig && !a.teamConfig) {
|
|
156
|
+
teamType = 'solo';
|
|
157
|
+
soloRole = a.agentConfig.role || 'planner';
|
|
158
|
+
} else if (a.teamConfig) {
|
|
159
|
+
if (rolesMatch(DEV_ROLES, a.teamConfig)) teamType = 'dev';
|
|
160
|
+
else if (rolesMatch(BUSINESS_ROLES, a.teamConfig)) teamType = 'business';
|
|
161
|
+
else customRoles = a.teamConfig.map((r) => ({ role: r.role, phase: r.phase || 1 }));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const cronPreset = CRON_PRESETS.find((p) => p.cron === a.cron)?.cron || null;
|
|
165
|
+
let scheduleMode = 'preset';
|
|
166
|
+
let scheduleCount = 1;
|
|
167
|
+
let scheduleUnit = 'hour';
|
|
168
|
+
|
|
169
|
+
if (!cronPreset) {
|
|
170
|
+
const simple = cronToSimple(a.cron);
|
|
171
|
+
if (simple) {
|
|
172
|
+
scheduleMode = 'simple';
|
|
173
|
+
scheduleCount = simple.count;
|
|
174
|
+
scheduleUnit = simple.unit;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let providerVal = a.agentConfig?.provider || (a.teamConfig?.[0]?.provider) || '';
|
|
179
|
+
let modelVal = a.agentConfig?.model || (a.teamConfig?.[0]?.model) || '';
|
|
180
|
+
let runtimeIdVal = '';
|
|
181
|
+
if (providerVal === 'local' && modelVal?.startsWith('runtime:')) {
|
|
182
|
+
const parts = modelVal.split(':');
|
|
183
|
+
runtimeIdVal = parts[1];
|
|
184
|
+
modelVal = parts.slice(2).join(':');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
name: a.name || '',
|
|
189
|
+
description: a.description || '',
|
|
190
|
+
teamType,
|
|
191
|
+
soloRole,
|
|
192
|
+
customRoles,
|
|
193
|
+
provider: providerVal,
|
|
194
|
+
model: modelVal === 'auto' ? '' : modelVal,
|
|
195
|
+
runtimeId: runtimeIdVal,
|
|
196
|
+
instructionMode: a.instructionSource?.type === 'file' ? 'file' : 'write',
|
|
197
|
+
instructions: a.instructionSource?.type === 'inline' ? (a.instructionSource.content || '') : '',
|
|
198
|
+
filePath: a.instructionSource?.type === 'file' ? (a.instructionSource.filePath || '') : '',
|
|
199
|
+
cronPreset,
|
|
200
|
+
scheduleMode,
|
|
201
|
+
scheduleCount,
|
|
202
|
+
scheduleUnit,
|
|
203
|
+
enabledOnCreate: a.enabled !== false,
|
|
204
|
+
gatewayIds: a.outputConfig?.gatewayIds || [],
|
|
205
|
+
notifyOn: a.outputConfig?.notifyOn || 'complete',
|
|
206
|
+
integrationIds: a.integrationIds || [],
|
|
207
|
+
outputFilePath: a.outputConfig?.filePath || '',
|
|
208
|
+
outputCustom: a.outputConfig?.customInstructions || '',
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function AutomationWizard() {
|
|
213
|
+
const open = useGrooveStore((s) => s.automationWizardOpen);
|
|
214
|
+
const close = useGrooveStore((s) => s.closeAutomationWizard);
|
|
215
|
+
const createAutomation = useGrooveStore((s) => s.createAutomation);
|
|
216
|
+
const updateAutomation = useGrooveStore((s) => s.updateAutomation);
|
|
217
|
+
const editingId = useGrooveStore((s) => s.editingAutomationId);
|
|
218
|
+
const automations = useGrooveStore((s) => s.automations);
|
|
219
|
+
const availableGateways = useGrooveStore((s) => s.availableGateways);
|
|
220
|
+
const availableIntegrations = useGrooveStore((s) => s.availableIntegrations);
|
|
221
|
+
const fetchGateways = useGrooveStore((s) => s.fetchGateways);
|
|
222
|
+
const fetchInstalledIntegrations = useGrooveStore((s) => s.fetchInstalledIntegrations);
|
|
223
|
+
|
|
224
|
+
const isEditing = !!editingId;
|
|
225
|
+
const [step, setStep] = useState(1);
|
|
226
|
+
const [form, setForm] = useState(INITIAL_FORM);
|
|
227
|
+
const [providers, setProviders] = useState([]);
|
|
228
|
+
const [localModels, setLocalModels] = useState([]);
|
|
229
|
+
const labRuntimes = useGrooveStore((s) => s.labRuntimes);
|
|
230
|
+
const fetchLabRuntimes = useGrooveStore((s) => s.fetchLabRuntimes);
|
|
231
|
+
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
if (open) {
|
|
234
|
+
setStep(1);
|
|
235
|
+
if (editingId) {
|
|
236
|
+
const existing = automations.find((a) => a.id === editingId);
|
|
237
|
+
setForm(existing ? automationToForm(existing) : INITIAL_FORM);
|
|
238
|
+
} else {
|
|
239
|
+
setForm(INITIAL_FORM);
|
|
240
|
+
}
|
|
241
|
+
fetchGateways();
|
|
242
|
+
fetchInstalledIntegrations();
|
|
243
|
+
fetchLabRuntimes();
|
|
244
|
+
api.get('/providers').then((data) => {
|
|
245
|
+
const list = Array.isArray(data) ? data : data.providers || [];
|
|
246
|
+
setProviders(list);
|
|
247
|
+
const local = list.find((p) => p.id === 'local');
|
|
248
|
+
if (local?.models?.length) setLocalModels(local.models);
|
|
249
|
+
}).catch(() => setProviders([]));
|
|
250
|
+
api.get('/providers/ollama/models').then((data) => {
|
|
251
|
+
const installed = data.installed || [];
|
|
252
|
+
const catalog = data.catalog || [];
|
|
253
|
+
if (installed.length || catalog.length) {
|
|
254
|
+
setLocalModels((prev) => {
|
|
255
|
+
const ids = new Set(prev.map((m) => m.id));
|
|
256
|
+
const merged = [...prev];
|
|
257
|
+
for (const m of installed) {
|
|
258
|
+
if (!ids.has(m.id)) { merged.push(m); ids.add(m.id); }
|
|
259
|
+
}
|
|
260
|
+
for (const m of catalog) {
|
|
261
|
+
if (!ids.has(m.id)) { merged.push({ id: m.id, name: m.name || m.id }); }
|
|
262
|
+
}
|
|
263
|
+
return merged;
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}).catch(() => {});
|
|
267
|
+
}
|
|
268
|
+
}, [open]);
|
|
269
|
+
|
|
270
|
+
function update(patch) {
|
|
271
|
+
setForm((prev) => ({ ...prev, ...patch }));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function getCronValue() {
|
|
275
|
+
if (form.cronPreset) return form.cronPreset;
|
|
276
|
+
return simpleToCron(form.scheduleCount, form.scheduleUnit);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function buildTeamConfig() {
|
|
280
|
+
let p = form.provider || undefined;
|
|
281
|
+
let m = form.model || undefined;
|
|
282
|
+
if (p === 'local' && form.runtimeId && m) {
|
|
283
|
+
m = `runtime:${form.runtimeId}:${m}`;
|
|
284
|
+
}
|
|
285
|
+
switch (form.teamType) {
|
|
286
|
+
case 'solo': return { agentConfig: { role: form.soloRole, provider: p, model: m } };
|
|
287
|
+
case 'dev': return { teamConfig: DEV_ROLES.map((r) => ({ ...r, provider: p, model: m })) };
|
|
288
|
+
case 'business': return { teamConfig: BUSINESS_ROLES.map((r) => ({ ...r, provider: p, model: m })) };
|
|
289
|
+
case 'custom': return { teamConfig: form.customRoles.filter((r) => r.role).map((r) => ({ ...r, provider: p, model: m })) };
|
|
290
|
+
default: return {};
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function canProceed() {
|
|
295
|
+
switch (step) {
|
|
296
|
+
case 1: return form.name.trim() && form.teamType;
|
|
297
|
+
case 2: return form.instructionMode === 'write' ? form.instructions.trim() : form.filePath.trim();
|
|
298
|
+
case 3: {
|
|
299
|
+
if (form.cronPreset) return true;
|
|
300
|
+
const cron = simpleToCron(form.scheduleCount, form.scheduleUnit);
|
|
301
|
+
return cron && validateCron(cron).valid;
|
|
302
|
+
}
|
|
303
|
+
case 4: return true;
|
|
304
|
+
default: return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function handleSubmit() {
|
|
309
|
+
const cronVal = getCronValue();
|
|
310
|
+
const config = {
|
|
311
|
+
name: form.name.trim(),
|
|
312
|
+
description: form.description.trim() || undefined,
|
|
313
|
+
cron: cronVal,
|
|
314
|
+
...buildTeamConfig(),
|
|
315
|
+
instructionSource: form.instructionMode === 'write'
|
|
316
|
+
? { type: 'inline', content: form.instructions }
|
|
317
|
+
: { type: 'file', filePath: form.filePath },
|
|
318
|
+
outputConfig: {
|
|
319
|
+
gatewayIds: form.gatewayIds,
|
|
320
|
+
notifyOn: form.notifyOn,
|
|
321
|
+
filePath: form.outputFilePath || undefined,
|
|
322
|
+
customInstructions: form.outputCustom || undefined,
|
|
323
|
+
},
|
|
324
|
+
integrationIds: form.integrationIds.length > 0 ? form.integrationIds : undefined,
|
|
325
|
+
enabled: form.enabledOnCreate,
|
|
326
|
+
};
|
|
327
|
+
if (isEditing) {
|
|
328
|
+
updateAutomation(editingId, config);
|
|
329
|
+
close();
|
|
330
|
+
} else {
|
|
331
|
+
createAutomation(config);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return (
|
|
336
|
+
<Dialog open={open} onOpenChange={(v) => !v && close()}>
|
|
337
|
+
<DialogContent title={isEditing ? 'Edit Automation' : 'New Automation'} description={isEditing ? 'Update this automation' : 'Create a scheduled automation'} className="max-w-xl">
|
|
338
|
+
{/* Step indicator */}
|
|
339
|
+
<div className="px-5 py-3 border-b border-border-subtle flex items-center justify-center gap-2">
|
|
340
|
+
{[1, 2, 3, 4].map((s) => (
|
|
341
|
+
<div key={s} className="flex items-center gap-2">
|
|
342
|
+
<div className={cn(
|
|
343
|
+
'w-2 h-2 rounded-full transition-colors',
|
|
344
|
+
s === step ? 'bg-accent' : s < step ? 'bg-accent/40' : 'bg-surface-5',
|
|
345
|
+
)} />
|
|
346
|
+
{s < 4 && <div className={cn('w-6 h-px', s < step ? 'bg-accent/40' : 'bg-surface-5')} />}
|
|
347
|
+
</div>
|
|
348
|
+
))}
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
{/* Step content */}
|
|
352
|
+
<div className="px-5 py-4 min-h-[300px]">
|
|
353
|
+
{step === 1 && <Step1 form={form} update={update} providers={providers} labRuntimes={labRuntimes || []} localModels={localModels} />}
|
|
354
|
+
{step === 2 && <Step2 form={form} update={update} />}
|
|
355
|
+
{step === 3 && <Step3 form={form} update={update} />}
|
|
356
|
+
{step === 4 && (
|
|
357
|
+
<Step4
|
|
358
|
+
form={form}
|
|
359
|
+
update={update}
|
|
360
|
+
gateways={availableGateways}
|
|
361
|
+
integrations={availableIntegrations}
|
|
362
|
+
/>
|
|
363
|
+
)}
|
|
364
|
+
</div>
|
|
365
|
+
|
|
366
|
+
{/* Footer */}
|
|
367
|
+
<div className="px-5 py-3 border-t border-border-subtle flex items-center justify-between">
|
|
368
|
+
<div>
|
|
369
|
+
{step > 1 && (
|
|
370
|
+
<Button variant="ghost" size="sm" onClick={() => setStep(step - 1)} className="gap-1">
|
|
371
|
+
<ChevronLeft size={12} /> Back
|
|
372
|
+
</Button>
|
|
373
|
+
)}
|
|
374
|
+
</div>
|
|
375
|
+
<div className="flex items-center gap-2">
|
|
376
|
+
<Button variant="ghost" size="sm" onClick={close}>Cancel</Button>
|
|
377
|
+
{step < 4 ? (
|
|
378
|
+
<Button variant="primary" size="sm" disabled={!canProceed()} onClick={() => setStep(step + 1)} className="gap-1">
|
|
379
|
+
Next <ChevronRight size={12} />
|
|
380
|
+
</Button>
|
|
381
|
+
) : (
|
|
382
|
+
<Button variant="primary" size="sm" disabled={!canProceed()} onClick={handleSubmit}>
|
|
383
|
+
{isEditing ? 'Save' : 'Create'}
|
|
384
|
+
</Button>
|
|
385
|
+
)}
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
</DialogContent>
|
|
389
|
+
</Dialog>
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ── Step 1: Name + Team Type ────────────────────────────────────
|
|
394
|
+
|
|
395
|
+
const RUNTIME_TYPE_LABELS = {
|
|
396
|
+
ollama: 'Ollama', vllm: 'vLLM', 'llama-cpp': 'llama.cpp',
|
|
397
|
+
mlx: 'MLX', tgi: 'TGI', 'openai-compatible': 'OpenAI Compatible',
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
function Step1({ form, update, providers, labRuntimes, localModels }) {
|
|
401
|
+
const isLocal = form.provider === 'local';
|
|
402
|
+
const selectedProvider = providers.find((p) => p.id === form.provider);
|
|
403
|
+
const availableModels = isLocal ? (localModels || []) : (selectedProvider?.models || []);
|
|
404
|
+
const selectedRuntime = isLocal ? labRuntimes.find((r) => r.id === form.runtimeId) : null;
|
|
405
|
+
|
|
406
|
+
return (
|
|
407
|
+
<div className="space-y-4">
|
|
408
|
+
<Input
|
|
409
|
+
label="Name"
|
|
410
|
+
placeholder="Morning briefing"
|
|
411
|
+
value={form.name}
|
|
412
|
+
onChange={(e) => update({ name: e.target.value })}
|
|
413
|
+
/>
|
|
414
|
+
<Input
|
|
415
|
+
label="Description (optional)"
|
|
416
|
+
placeholder="Check email, calendar, and write a daily summary"
|
|
417
|
+
value={form.description}
|
|
418
|
+
onChange={(e) => update({ description: e.target.value })}
|
|
419
|
+
/>
|
|
420
|
+
<div className="space-y-2">
|
|
421
|
+
<label className="text-xs font-medium text-text-2 font-sans">Team Type</label>
|
|
422
|
+
<div className="grid grid-cols-2 gap-2">
|
|
423
|
+
{TEAM_TYPES.map((t) => (
|
|
424
|
+
<button
|
|
425
|
+
key={t.id}
|
|
426
|
+
onClick={() => update({ teamType: t.id })}
|
|
427
|
+
className={cn(
|
|
428
|
+
'flex items-center gap-3 p-3 rounded-md border text-left transition-colors cursor-pointer',
|
|
429
|
+
form.teamType === t.id
|
|
430
|
+
? 'border-accent bg-accent/5'
|
|
431
|
+
: 'border-border-subtle bg-surface-0 hover:border-border hover:bg-surface-2',
|
|
432
|
+
)}
|
|
433
|
+
>
|
|
434
|
+
<t.icon size={16} className={cn(form.teamType === t.id ? 'text-accent' : 'text-text-3')} />
|
|
435
|
+
<div className="min-w-0">
|
|
436
|
+
<div className="text-xs font-semibold text-text-0 font-sans">{t.label}</div>
|
|
437
|
+
<div className="text-2xs text-text-3 font-sans">{t.description}</div>
|
|
438
|
+
</div>
|
|
439
|
+
</button>
|
|
440
|
+
))}
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
|
|
444
|
+
{form.teamType === 'solo' && (
|
|
445
|
+
<Select value={form.soloRole} onValueChange={(v) => update({ soloRole: v })}>
|
|
446
|
+
<SelectTrigger placeholder="Select role" />
|
|
447
|
+
<SelectContent>
|
|
448
|
+
{ALL_ROLES.map((r) => (
|
|
449
|
+
<SelectItem key={r} value={r}>{roleLabel(r)}</SelectItem>
|
|
450
|
+
))}
|
|
451
|
+
</SelectContent>
|
|
452
|
+
</Select>
|
|
453
|
+
)}
|
|
454
|
+
|
|
455
|
+
{form.teamType === 'dev' && (
|
|
456
|
+
<div className="space-y-1.5">
|
|
457
|
+
<label className="text-xs font-medium text-text-2 font-sans">Roles</label>
|
|
458
|
+
{DEV_ROLES.map((r, i) => (
|
|
459
|
+
<div key={i} className="flex items-center gap-2 px-3 py-1.5 rounded-md bg-surface-0 border border-border-subtle">
|
|
460
|
+
<Badge variant="default" className="text-2xs">{roleLabel(r.role)}</Badge>
|
|
461
|
+
<div className="flex-1" />
|
|
462
|
+
<span className="text-2xs font-mono text-text-4">Phase {r.phase}</span>
|
|
463
|
+
</div>
|
|
464
|
+
))}
|
|
465
|
+
</div>
|
|
466
|
+
)}
|
|
467
|
+
|
|
468
|
+
{form.teamType === 'business' && (
|
|
469
|
+
<div className="space-y-1.5">
|
|
470
|
+
<label className="text-xs font-medium text-text-2 font-sans">Roles</label>
|
|
471
|
+
{BUSINESS_ROLES.map((r, i) => (
|
|
472
|
+
<div key={i} className="flex items-center gap-2 px-3 py-1.5 rounded-md bg-surface-0 border border-border-subtle">
|
|
473
|
+
<Badge variant="default" className="text-2xs">{roleLabel(r.role)}</Badge>
|
|
474
|
+
<div className="flex-1" />
|
|
475
|
+
<span className="text-2xs font-mono text-text-4">Phase {r.phase}</span>
|
|
476
|
+
</div>
|
|
477
|
+
))}
|
|
478
|
+
</div>
|
|
479
|
+
)}
|
|
480
|
+
|
|
481
|
+
{form.teamType === 'custom' && (
|
|
482
|
+
<div className="space-y-2">
|
|
483
|
+
<label className="text-xs font-medium text-text-2 font-sans">Roles</label>
|
|
484
|
+
{form.customRoles.map((r, i) => (
|
|
485
|
+
<div key={i} className="flex items-center gap-2">
|
|
486
|
+
<Select value={r.role} onValueChange={(v) => updateCustomRole(form, update, i, { role: v })}>
|
|
487
|
+
<SelectTrigger placeholder="Role" className="flex-1" />
|
|
488
|
+
<SelectContent>
|
|
489
|
+
{ALL_ROLES.map((role) => (
|
|
490
|
+
<SelectItem key={role} value={role}>{roleLabel(role)}</SelectItem>
|
|
491
|
+
))}
|
|
492
|
+
</SelectContent>
|
|
493
|
+
</Select>
|
|
494
|
+
<button
|
|
495
|
+
onClick={() => updateCustomRole(form, update, i, { phase: r.phase === 1 ? 2 : 1 })}
|
|
496
|
+
className={cn(
|
|
497
|
+
'h-8 px-2.5 rounded-md text-2xs font-mono border transition-colors cursor-pointer',
|
|
498
|
+
r.phase === 1
|
|
499
|
+
? 'border-accent/30 text-accent bg-accent/5'
|
|
500
|
+
: 'border-border text-text-3 bg-surface-0',
|
|
501
|
+
)}
|
|
502
|
+
>
|
|
503
|
+
P{r.phase}
|
|
504
|
+
</button>
|
|
505
|
+
<button
|
|
506
|
+
onClick={() => {
|
|
507
|
+
const updated = form.customRoles.filter((_, idx) => idx !== i);
|
|
508
|
+
update({ customRoles: updated.length > 0 ? updated : [{ role: 'fullstack', phase: 1 }] });
|
|
509
|
+
}}
|
|
510
|
+
className="p-1.5 text-text-4 hover:text-danger rounded transition-colors cursor-pointer"
|
|
511
|
+
>
|
|
512
|
+
<X size={12} />
|
|
513
|
+
</button>
|
|
514
|
+
</div>
|
|
515
|
+
))}
|
|
516
|
+
<Button variant="ghost" size="sm" onClick={() => update({ customRoles: [...form.customRoles, { role: 'fullstack', phase: 1 }] })} className="gap-1 text-2xs">
|
|
517
|
+
<Plus size={10} /> Add Role
|
|
518
|
+
</Button>
|
|
519
|
+
</div>
|
|
520
|
+
)}
|
|
521
|
+
|
|
522
|
+
{form.teamType && (
|
|
523
|
+
<div className="space-y-2">
|
|
524
|
+
<label className="text-xs font-medium text-text-2 font-sans">Provider & Model</label>
|
|
525
|
+
<div className={cn('grid gap-3', isLocal ? 'grid-cols-3' : 'grid-cols-2')}>
|
|
526
|
+
{/* Provider */}
|
|
527
|
+
<div className="relative">
|
|
528
|
+
<select
|
|
529
|
+
value={form.provider}
|
|
530
|
+
onChange={(e) => update({ provider: e.target.value, model: '', runtimeId: '' })}
|
|
531
|
+
className="w-full h-8 px-3 pr-8 text-sm rounded-md bg-surface-1 border border-border text-text-0 font-sans appearance-none cursor-pointer focus:outline-none focus:ring-1 focus:ring-accent"
|
|
532
|
+
>
|
|
533
|
+
<option value="">Auto</option>
|
|
534
|
+
{providers.filter((p) => p.authType !== 'local').map((p) => (
|
|
535
|
+
<option key={p.id} value={p.id} disabled={p.authType === 'api-key' ? !(p.installed && p.hasKey) : !p.installed}>
|
|
536
|
+
{p.name}{!p.installed ? ' (Not installed)' : (p.authType === 'api-key' && !p.hasKey) ? ' (No API key)' : ''}
|
|
537
|
+
</option>
|
|
538
|
+
))}
|
|
539
|
+
<option value="local">Local Model</option>
|
|
540
|
+
</select>
|
|
541
|
+
<ChevronDown size={14} className="absolute right-2 top-1/2 -translate-y-1/2 text-text-3 pointer-events-none" />
|
|
542
|
+
</div>
|
|
543
|
+
|
|
544
|
+
{/* Model */}
|
|
545
|
+
<div className="relative">
|
|
546
|
+
<select
|
|
547
|
+
value={form.model}
|
|
548
|
+
onChange={(e) => update({ model: e.target.value })}
|
|
549
|
+
disabled={!form.provider}
|
|
550
|
+
className="w-full h-8 px-3 pr-8 text-sm rounded-md bg-surface-1 border border-border text-text-0 font-sans appearance-none cursor-pointer focus:outline-none focus:ring-1 focus:ring-accent disabled:opacity-40"
|
|
551
|
+
>
|
|
552
|
+
<option value="">Auto</option>
|
|
553
|
+
{availableModels.map((m) => (
|
|
554
|
+
<option key={m.id} value={m.id}>{m.name || m.id}</option>
|
|
555
|
+
))}
|
|
556
|
+
</select>
|
|
557
|
+
<ChevronDown size={14} className="absolute right-2 top-1/2 -translate-y-1/2 text-text-3 pointer-events-none" />
|
|
558
|
+
</div>
|
|
559
|
+
|
|
560
|
+
{/* Runtime (local only) */}
|
|
561
|
+
{isLocal && (
|
|
562
|
+
<div className="relative">
|
|
563
|
+
<select
|
|
564
|
+
value={form.runtimeId}
|
|
565
|
+
onChange={(e) => update({ runtimeId: e.target.value })}
|
|
566
|
+
className="w-full h-8 px-3 pr-8 text-sm rounded-md bg-surface-1 border border-border text-text-0 font-sans appearance-none cursor-pointer focus:outline-none focus:ring-1 focus:ring-accent"
|
|
567
|
+
>
|
|
568
|
+
<option value="">Select runtime</option>
|
|
569
|
+
{labRuntimes.map((rt) => (
|
|
570
|
+
<option key={rt.id} value={rt.id}>
|
|
571
|
+
{rt.name || RUNTIME_TYPE_LABELS[rt.type] || rt.type}
|
|
572
|
+
{rt.status === 'connected' ? '' : ' (offline)'}
|
|
573
|
+
</option>
|
|
574
|
+
))}
|
|
575
|
+
</select>
|
|
576
|
+
<ChevronDown size={14} className="absolute right-2 top-1/2 -translate-y-1/2 text-text-3 pointer-events-none" />
|
|
577
|
+
</div>
|
|
578
|
+
)}
|
|
579
|
+
</div>
|
|
580
|
+
|
|
581
|
+
{isLocal && (
|
|
582
|
+
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-warning/8 border border-warning/20">
|
|
583
|
+
<AlertTriangle size={13} className="text-warning flex-shrink-0 mt-0.5" />
|
|
584
|
+
<span className="text-2xs font-sans text-text-2">
|
|
585
|
+
Make sure your runtime{selectedRuntime ? ` (${selectedRuntime.name || RUNTIME_TYPE_LABELS[selectedRuntime.type] || selectedRuntime.type})` : ''} is running when this automation fires.
|
|
586
|
+
</span>
|
|
587
|
+
</div>
|
|
588
|
+
)}
|
|
589
|
+
|
|
590
|
+
{isLocal && labRuntimes.length === 0 && (
|
|
591
|
+
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-surface-4 border border-border-subtle">
|
|
592
|
+
<span className="text-2xs font-sans text-text-3">
|
|
593
|
+
No runtimes configured. Set one up in the Model Lab tab first.
|
|
594
|
+
</span>
|
|
595
|
+
</div>
|
|
596
|
+
)}
|
|
597
|
+
</div>
|
|
598
|
+
)}
|
|
599
|
+
</div>
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function updateCustomRole(form, update, idx, patch) {
|
|
604
|
+
update({
|
|
605
|
+
customRoles: form.customRoles.map((r, i) => i === idx ? { ...r, ...patch } : r),
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ── Step 2: Instructions ────────────────────────────────────────
|
|
610
|
+
|
|
611
|
+
function Step2({ form, update }) {
|
|
612
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
613
|
+
|
|
614
|
+
return (
|
|
615
|
+
<div className="space-y-4">
|
|
616
|
+
<div className="flex gap-1">
|
|
617
|
+
<button
|
|
618
|
+
onClick={() => update({ instructionMode: 'write' })}
|
|
619
|
+
className={cn(
|
|
620
|
+
'px-3 py-1.5 text-xs font-sans rounded-md transition-colors cursor-pointer',
|
|
621
|
+
form.instructionMode === 'write'
|
|
622
|
+
? 'bg-accent/10 text-accent border border-accent/30'
|
|
623
|
+
: 'text-text-3 border border-border-subtle hover:text-text-1',
|
|
624
|
+
)}
|
|
625
|
+
>
|
|
626
|
+
Write instructions
|
|
627
|
+
</button>
|
|
628
|
+
<button
|
|
629
|
+
onClick={() => update({ instructionMode: 'file' })}
|
|
630
|
+
className={cn(
|
|
631
|
+
'px-3 py-1.5 text-xs font-sans rounded-md transition-colors cursor-pointer',
|
|
632
|
+
form.instructionMode === 'file'
|
|
633
|
+
? 'bg-accent/10 text-accent border border-accent/30'
|
|
634
|
+
: 'text-text-3 border border-border-subtle hover:text-text-1',
|
|
635
|
+
)}
|
|
636
|
+
>
|
|
637
|
+
Reference a document
|
|
638
|
+
</button>
|
|
639
|
+
</div>
|
|
640
|
+
|
|
641
|
+
{form.instructionMode === 'write' ? (
|
|
642
|
+
<div className="space-y-2">
|
|
643
|
+
<Textarea
|
|
644
|
+
mono
|
|
645
|
+
value={form.instructions}
|
|
646
|
+
onChange={(e) => update({ instructions: e.target.value })}
|
|
647
|
+
placeholder={'Describe what this team should do...\n\nExample: Check my Gmail inbox for important emails.\n[read] #daily-briefing-template\nSummarize action items.'}
|
|
648
|
+
className="min-h-[120px]"
|
|
649
|
+
rows={8}
|
|
650
|
+
/>
|
|
651
|
+
<MemoryBadges text={form.instructions} />
|
|
652
|
+
<p className="text-2xs text-text-4 font-sans">Use <span className="font-mono text-teal-400">[read] #tag</span> to include a memory</p>
|
|
653
|
+
</div>
|
|
654
|
+
) : (
|
|
655
|
+
<div className="space-y-3">
|
|
656
|
+
<label className="text-xs font-medium text-text-2 font-sans">Instruction file</label>
|
|
657
|
+
<div className="flex items-center gap-2">
|
|
658
|
+
<div className="flex-1 flex items-center gap-2.5 px-3 py-2.5 rounded-md bg-surface-0 border border-border-subtle min-h-[40px]">
|
|
659
|
+
{form.filePath ? (
|
|
660
|
+
<>
|
|
661
|
+
<File size={13} className="text-accent flex-shrink-0" />
|
|
662
|
+
<span className="text-xs font-mono text-text-1 truncate flex-1">{form.filePath}</span>
|
|
663
|
+
<button
|
|
664
|
+
onClick={() => update({ filePath: '' })}
|
|
665
|
+
className="p-0.5 text-text-4 hover:text-text-2 cursor-pointer"
|
|
666
|
+
>
|
|
667
|
+
<X size={11} />
|
|
668
|
+
</button>
|
|
669
|
+
</>
|
|
670
|
+
) : (
|
|
671
|
+
<span className="text-xs font-sans text-text-4">No file selected</span>
|
|
672
|
+
)}
|
|
673
|
+
</div>
|
|
674
|
+
<Button variant="ghost" size="sm" onClick={() => setPickerOpen(true)} className="gap-1.5 flex-shrink-0">
|
|
675
|
+
<Folder size={12} /> Browse
|
|
676
|
+
</Button>
|
|
677
|
+
</div>
|
|
678
|
+
<Input
|
|
679
|
+
mono
|
|
680
|
+
value={form.filePath}
|
|
681
|
+
onChange={(e) => update({ filePath: e.target.value })}
|
|
682
|
+
placeholder="/path/to/instructions.md"
|
|
683
|
+
/>
|
|
684
|
+
<p className="text-2xs text-text-4 font-sans">Select a markdown, text, or any file with instructions for the team</p>
|
|
685
|
+
<FilePicker
|
|
686
|
+
open={pickerOpen}
|
|
687
|
+
onOpenChange={setPickerOpen}
|
|
688
|
+
onSelect={(path) => update({ filePath: path })}
|
|
689
|
+
/>
|
|
690
|
+
</div>
|
|
691
|
+
)}
|
|
692
|
+
</div>
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// ── Step 3: Schedule ────────────────────────────────────────────
|
|
697
|
+
|
|
698
|
+
function Step3({ form, update }) {
|
|
699
|
+
const isPreset = form.scheduleMode === 'preset' && form.cronPreset;
|
|
700
|
+
const isSimple = form.scheduleMode === 'simple';
|
|
701
|
+
const cronVal = isPreset
|
|
702
|
+
? form.cronPreset
|
|
703
|
+
: simpleToCron(form.scheduleCount, form.scheduleUnit);
|
|
704
|
+
const label = isSimple ? simpleToCronLabel(form.scheduleCount, form.scheduleUnit) : null;
|
|
705
|
+
|
|
706
|
+
return (
|
|
707
|
+
<div className="space-y-5">
|
|
708
|
+
{/* Mode toggle */}
|
|
709
|
+
<div className="flex gap-1">
|
|
710
|
+
<button
|
|
711
|
+
onClick={() => update({ scheduleMode: 'preset', cronPreset: form.cronPreset })}
|
|
712
|
+
className={cn(
|
|
713
|
+
'px-3 py-1.5 text-xs font-sans rounded-md transition-colors cursor-pointer',
|
|
714
|
+
form.scheduleMode === 'preset'
|
|
715
|
+
? 'bg-accent/10 text-accent border border-accent/30'
|
|
716
|
+
: 'text-text-3 border border-border-subtle hover:text-text-1',
|
|
717
|
+
)}
|
|
718
|
+
>
|
|
719
|
+
Preset
|
|
720
|
+
</button>
|
|
721
|
+
<button
|
|
722
|
+
onClick={() => update({ scheduleMode: 'simple', cronPreset: null })}
|
|
723
|
+
className={cn(
|
|
724
|
+
'px-3 py-1.5 text-xs font-sans rounded-md transition-colors cursor-pointer',
|
|
725
|
+
form.scheduleMode === 'simple'
|
|
726
|
+
? 'bg-accent/10 text-accent border border-accent/30'
|
|
727
|
+
: 'text-text-3 border border-border-subtle hover:text-text-1',
|
|
728
|
+
)}
|
|
729
|
+
>
|
|
730
|
+
Custom
|
|
731
|
+
</button>
|
|
732
|
+
</div>
|
|
733
|
+
|
|
734
|
+
{form.scheduleMode === 'preset' ? (
|
|
735
|
+
<div className="grid grid-cols-2 gap-2">
|
|
736
|
+
{CRON_PRESETS.map((preset) => (
|
|
737
|
+
<button
|
|
738
|
+
key={preset.cron}
|
|
739
|
+
onClick={() => update({ cronPreset: preset.cron })}
|
|
740
|
+
className={cn(
|
|
741
|
+
'p-3 rounded-md border text-left transition-colors cursor-pointer',
|
|
742
|
+
form.cronPreset === preset.cron
|
|
743
|
+
? 'border-accent bg-accent/5'
|
|
744
|
+
: 'border-border-subtle bg-surface-0 hover:border-border hover:bg-surface-2',
|
|
745
|
+
)}
|
|
746
|
+
>
|
|
747
|
+
<div className="text-xs font-semibold text-text-0 font-sans">{preset.label}</div>
|
|
748
|
+
<div className="text-2xs text-text-3 font-sans mt-0.5">{preset.description}</div>
|
|
749
|
+
</button>
|
|
750
|
+
))}
|
|
751
|
+
</div>
|
|
752
|
+
) : (
|
|
753
|
+
<div className="space-y-4">
|
|
754
|
+
<div className="flex items-center gap-3 p-4 rounded-lg bg-surface-0 border border-border-subtle">
|
|
755
|
+
<span className="text-xs font-sans text-text-2">Every</span>
|
|
756
|
+
<input
|
|
757
|
+
type="text"
|
|
758
|
+
inputMode="numeric"
|
|
759
|
+
value={form.scheduleCount}
|
|
760
|
+
onChange={(e) => {
|
|
761
|
+
const raw = e.target.value.replace(/\D/g, '');
|
|
762
|
+
update({ scheduleCount: raw === '' ? '' : parseInt(raw, 10) });
|
|
763
|
+
}}
|
|
764
|
+
onBlur={() => {
|
|
765
|
+
if (!form.scheduleCount || form.scheduleCount < 1) update({ scheduleCount: 1 });
|
|
766
|
+
}}
|
|
767
|
+
className="w-16 h-8 px-2 text-center text-sm font-mono rounded-md bg-surface-2 border border-border text-text-0 focus:border-accent focus:outline-none"
|
|
768
|
+
/>
|
|
769
|
+
<div className="flex gap-1">
|
|
770
|
+
{SCHEDULE_UNITS.map((u) => (
|
|
771
|
+
<button
|
|
772
|
+
key={u.value}
|
|
773
|
+
onClick={() => update({ scheduleUnit: u.value })}
|
|
774
|
+
className={cn(
|
|
775
|
+
'px-3 py-1.5 text-xs font-sans rounded-md border transition-colors cursor-pointer',
|
|
776
|
+
form.scheduleUnit === u.value
|
|
777
|
+
? 'border-accent bg-accent/10 text-accent'
|
|
778
|
+
: 'border-border-subtle text-text-3 hover:text-text-1 hover:border-border',
|
|
779
|
+
)}
|
|
780
|
+
>
|
|
781
|
+
{u.label}
|
|
782
|
+
</button>
|
|
783
|
+
))}
|
|
784
|
+
</div>
|
|
785
|
+
</div>
|
|
786
|
+
|
|
787
|
+
{label && cronVal && (
|
|
788
|
+
<div className="flex items-center gap-1.5 px-1">
|
|
789
|
+
<Clock size={11} className="text-accent" />
|
|
790
|
+
<span className="text-xs text-accent font-sans">{label}</span>
|
|
791
|
+
<span className="text-2xs text-text-4 font-mono ml-auto">{cronVal}</span>
|
|
792
|
+
</div>
|
|
793
|
+
)}
|
|
794
|
+
</div>
|
|
795
|
+
)}
|
|
796
|
+
|
|
797
|
+
{/* Enabled toggle */}
|
|
798
|
+
<div className="flex items-center gap-3 pt-2">
|
|
799
|
+
<button
|
|
800
|
+
onClick={() => update({ enabledOnCreate: !form.enabledOnCreate })}
|
|
801
|
+
className={cn(
|
|
802
|
+
'w-8 h-4.5 rounded-full relative transition-colors cursor-pointer',
|
|
803
|
+
form.enabledOnCreate ? 'bg-accent' : 'bg-surface-5',
|
|
804
|
+
)}
|
|
805
|
+
>
|
|
806
|
+
<div className={cn(
|
|
807
|
+
'absolute top-0.5 w-3.5 h-3.5 rounded-full bg-white transition-transform',
|
|
808
|
+
form.enabledOnCreate ? 'translate-x-4' : 'translate-x-0.5',
|
|
809
|
+
)} />
|
|
810
|
+
</button>
|
|
811
|
+
<span className="text-xs text-text-1 font-sans">Enabled on creation</span>
|
|
812
|
+
</div>
|
|
813
|
+
</div>
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// ── Step 4: Output ──────────────────────────────────────────────
|
|
818
|
+
|
|
819
|
+
function Step4({ form, update, gateways, integrations }) {
|
|
820
|
+
const [outputPickerOpen, setOutputPickerOpen] = useState(false);
|
|
821
|
+
|
|
822
|
+
function toggleGateway(id) {
|
|
823
|
+
const ids = form.gatewayIds.includes(id)
|
|
824
|
+
? form.gatewayIds.filter((g) => g !== id)
|
|
825
|
+
: [...form.gatewayIds, id];
|
|
826
|
+
update({ gatewayIds: ids });
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function toggleIntegration(id) {
|
|
830
|
+
const ids = form.integrationIds.includes(id)
|
|
831
|
+
? form.integrationIds.filter((i) => i !== id)
|
|
832
|
+
: [...form.integrationIds, id];
|
|
833
|
+
update({ integrationIds: ids });
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
return (
|
|
837
|
+
<div className="space-y-5">
|
|
838
|
+
{/* Gateways */}
|
|
839
|
+
{gateways.length > 0 && (
|
|
840
|
+
<Collapsible title="Chat Gateways" icon={MessageSquare} defaultOpen={form.gatewayIds.length > 0} badge={gateways.length}>
|
|
841
|
+
<div className="space-y-1.5">
|
|
842
|
+
{gateways.map((gw) => (
|
|
843
|
+
<label
|
|
844
|
+
key={gw.id}
|
|
845
|
+
className="flex items-center gap-3 px-3 py-2 rounded-md bg-surface-0 border border-border-subtle cursor-pointer hover:bg-surface-2 transition-colors"
|
|
846
|
+
>
|
|
847
|
+
<input
|
|
848
|
+
type="checkbox"
|
|
849
|
+
checked={form.gatewayIds.includes(gw.id)}
|
|
850
|
+
onChange={() => toggleGateway(gw.id)}
|
|
851
|
+
className="rounded accent-accent"
|
|
852
|
+
/>
|
|
853
|
+
<span className="text-xs font-sans text-text-0">{gw.name || gw.platform || gw.id}</span>
|
|
854
|
+
<Badge variant={gw.connected ? 'success' : 'default'} className="text-2xs ml-auto">
|
|
855
|
+
{gw.connected ? 'Connected' : 'Offline'}
|
|
856
|
+
</Badge>
|
|
857
|
+
</label>
|
|
858
|
+
))}
|
|
859
|
+
</div>
|
|
860
|
+
</Collapsible>
|
|
861
|
+
)}
|
|
862
|
+
|
|
863
|
+
{/* Save to file */}
|
|
864
|
+
<div className="space-y-2">
|
|
865
|
+
<label className="text-xs font-medium text-text-2 font-sans flex items-center gap-1.5">
|
|
866
|
+
<Save size={11} /> Save results to file
|
|
867
|
+
</label>
|
|
868
|
+
<div className="flex items-center gap-2">
|
|
869
|
+
<div className="flex-1 flex items-center gap-2.5 px-3 py-2 rounded-md bg-surface-0 border border-border-subtle min-h-[36px]">
|
|
870
|
+
{form.outputFilePath ? (
|
|
871
|
+
<>
|
|
872
|
+
<File size={12} className="text-accent flex-shrink-0" />
|
|
873
|
+
<span className="text-xs font-mono text-text-1 truncate flex-1">{form.outputFilePath}</span>
|
|
874
|
+
<button
|
|
875
|
+
onClick={() => update({ outputFilePath: '' })}
|
|
876
|
+
className="p-0.5 text-text-4 hover:text-text-2 cursor-pointer"
|
|
877
|
+
>
|
|
878
|
+
<X size={11} />
|
|
879
|
+
</button>
|
|
880
|
+
</>
|
|
881
|
+
) : (
|
|
882
|
+
<span className="text-xs font-sans text-text-4">Optional — choose a file to save output</span>
|
|
883
|
+
)}
|
|
884
|
+
</div>
|
|
885
|
+
<Button variant="ghost" size="sm" onClick={() => setOutputPickerOpen(true)} className="gap-1 flex-shrink-0 text-2xs">
|
|
886
|
+
<Folder size={11} /> Browse
|
|
887
|
+
</Button>
|
|
888
|
+
</div>
|
|
889
|
+
<Input
|
|
890
|
+
mono
|
|
891
|
+
value={form.outputFilePath}
|
|
892
|
+
onChange={(e) => update({ outputFilePath: e.target.value })}
|
|
893
|
+
placeholder="/path/to/output.md"
|
|
894
|
+
/>
|
|
895
|
+
<FilePicker
|
|
896
|
+
open={outputPickerOpen}
|
|
897
|
+
onOpenChange={setOutputPickerOpen}
|
|
898
|
+
onSelect={(path) => update({ outputFilePath: path })}
|
|
899
|
+
allowCreate
|
|
900
|
+
/>
|
|
901
|
+
</div>
|
|
902
|
+
|
|
903
|
+
{/* Custom output instructions */}
|
|
904
|
+
<div className="space-y-2">
|
|
905
|
+
<label className="text-xs font-medium text-text-2 font-sans">Custom output instructions</label>
|
|
906
|
+
<Textarea
|
|
907
|
+
mono
|
|
908
|
+
value={form.outputCustom}
|
|
909
|
+
onChange={(e) => update({ outputCustom: e.target.value })}
|
|
910
|
+
placeholder={'Optional — tell the agent how to deliver results.\n\nExamples:\n• Email me a summary at ryan@example.com\n• Use the Twilio API to send me a text\n• [read] #output-template\n• Create a PR with the changes'}
|
|
911
|
+
className="min-h-[80px]"
|
|
912
|
+
rows={4}
|
|
913
|
+
/>
|
|
914
|
+
<MemoryBadges text={form.outputCustom} />
|
|
915
|
+
<p className="text-2xs text-text-4 font-sans">
|
|
916
|
+
Free-form instructions — works with any API or service. Use <span className="font-mono text-teal-400">[read] #tag</span> to include a memory.
|
|
917
|
+
</p>
|
|
918
|
+
</div>
|
|
919
|
+
|
|
920
|
+
{/* Notification timing */}
|
|
921
|
+
<div className="space-y-2">
|
|
922
|
+
<label className="text-xs font-medium text-text-2 font-sans">Notify when</label>
|
|
923
|
+
<div className="flex gap-2">
|
|
924
|
+
{[
|
|
925
|
+
{ value: 'always', label: 'Every run' },
|
|
926
|
+
{ value: 'error', label: 'Only on errors' },
|
|
927
|
+
{ value: 'complete', label: 'On completion' },
|
|
928
|
+
].map((opt) => (
|
|
929
|
+
<button
|
|
930
|
+
key={opt.value}
|
|
931
|
+
onClick={() => update({ notifyOn: opt.value })}
|
|
932
|
+
className={cn(
|
|
933
|
+
'px-3 py-1.5 text-xs font-sans rounded-md border transition-colors cursor-pointer',
|
|
934
|
+
form.notifyOn === opt.value
|
|
935
|
+
? 'border-accent bg-accent/5 text-accent'
|
|
936
|
+
: 'border-border-subtle text-text-3 hover:text-text-1',
|
|
937
|
+
)}
|
|
938
|
+
>
|
|
939
|
+
{opt.label}
|
|
940
|
+
</button>
|
|
941
|
+
))}
|
|
942
|
+
</div>
|
|
943
|
+
</div>
|
|
944
|
+
|
|
945
|
+
{/* Integrations */}
|
|
946
|
+
{integrations.length > 0 && (
|
|
947
|
+
<Collapsible title="Integrations" icon={Package} badge={integrations.length}>
|
|
948
|
+
<div className="space-y-1.5">
|
|
949
|
+
{integrations.map((intg) => (
|
|
950
|
+
<label
|
|
951
|
+
key={intg.id}
|
|
952
|
+
className="flex items-center gap-3 px-3 py-2 rounded-md bg-surface-0 border border-border-subtle cursor-pointer hover:bg-surface-2 transition-colors"
|
|
953
|
+
>
|
|
954
|
+
<input
|
|
955
|
+
type="checkbox"
|
|
956
|
+
checked={form.integrationIds.includes(intg.id)}
|
|
957
|
+
onChange={() => toggleIntegration(intg.id)}
|
|
958
|
+
className="rounded accent-accent"
|
|
959
|
+
/>
|
|
960
|
+
<span className="text-xs font-sans text-text-0">{intg.name || intg.id}</span>
|
|
961
|
+
</label>
|
|
962
|
+
))}
|
|
963
|
+
</div>
|
|
964
|
+
</Collapsible>
|
|
965
|
+
)}
|
|
966
|
+
</div>
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// ── File Picker ─────────────────────────────────────────────────
|
|
971
|
+
|
|
972
|
+
function FilePicker({ open, onOpenChange, onSelect, allowCreate }) {
|
|
973
|
+
const [root, setRoot] = useState('');
|
|
974
|
+
const [currentPath, setCurrentPath] = useState('');
|
|
975
|
+
const [entries, setEntries] = useState([]);
|
|
976
|
+
const [loading, setLoading] = useState(false);
|
|
977
|
+
const [error, setError] = useState(null);
|
|
978
|
+
const [newFileName, setNewFileName] = useState('');
|
|
979
|
+
|
|
980
|
+
useEffect(() => {
|
|
981
|
+
if (open) {
|
|
982
|
+
setNewFileName('');
|
|
983
|
+
setError(null);
|
|
984
|
+
api.get('/files/root').then((data) => {
|
|
985
|
+
setRoot(data.root || '');
|
|
986
|
+
loadTree('');
|
|
987
|
+
}).catch(() => setError('Could not load project files'));
|
|
988
|
+
}
|
|
989
|
+
}, [open]);
|
|
990
|
+
|
|
991
|
+
async function loadTree(relPath) {
|
|
992
|
+
setLoading(true);
|
|
993
|
+
setError(null);
|
|
994
|
+
try {
|
|
995
|
+
const data = await api.get(`/files/tree?path=${encodeURIComponent(relPath)}`);
|
|
996
|
+
setCurrentPath(relPath);
|
|
997
|
+
setEntries(data.entries || []);
|
|
998
|
+
} catch (err) {
|
|
999
|
+
setError(err.message);
|
|
1000
|
+
setEntries([]);
|
|
1001
|
+
}
|
|
1002
|
+
setLoading(false);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function selectFile(entry) {
|
|
1006
|
+
const abs = root + '/' + entry.path;
|
|
1007
|
+
onSelect(abs);
|
|
1008
|
+
onOpenChange(false);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function selectNew() {
|
|
1012
|
+
if (!newFileName.trim()) return;
|
|
1013
|
+
const rel = currentPath ? currentPath + '/' + newFileName.trim() : newFileName.trim();
|
|
1014
|
+
const abs = root + '/' + rel;
|
|
1015
|
+
onSelect(abs);
|
|
1016
|
+
onOpenChange(false);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const breadcrumbs = currentPath ? currentPath.split('/') : [];
|
|
1020
|
+
|
|
1021
|
+
return (
|
|
1022
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
1023
|
+
<DialogContent title="Select File" description="Browse project files" className="max-w-[520px]">
|
|
1024
|
+
<div className="px-5 py-4 space-y-3">
|
|
1025
|
+
{/* Breadcrumb navigation */}
|
|
1026
|
+
<div className="flex items-center gap-1 min-w-0 overflow-x-auto py-1 scrollbar-none">
|
|
1027
|
+
<button
|
|
1028
|
+
onClick={() => loadTree('')}
|
|
1029
|
+
className="flex-shrink-0 p-1 rounded hover:bg-surface-5 cursor-pointer text-text-3 hover:text-text-0 transition-colors"
|
|
1030
|
+
>
|
|
1031
|
+
<HardDrive size={13} />
|
|
1032
|
+
</button>
|
|
1033
|
+
{breadcrumbs.map((part, i) => {
|
|
1034
|
+
const pathTo = breadcrumbs.slice(0, i + 1).join('/');
|
|
1035
|
+
return (
|
|
1036
|
+
<div key={i} className="flex items-center gap-0.5 flex-shrink-0">
|
|
1037
|
+
<ChevronRight size={11} className="text-text-4" />
|
|
1038
|
+
<button
|
|
1039
|
+
onClick={() => loadTree(pathTo)}
|
|
1040
|
+
className={cn(
|
|
1041
|
+
'px-1.5 py-0.5 rounded text-xs font-mono cursor-pointer transition-colors',
|
|
1042
|
+
i === breadcrumbs.length - 1
|
|
1043
|
+
? 'text-text-0 bg-surface-4 font-medium'
|
|
1044
|
+
: 'text-text-3 hover:text-text-0 hover:bg-surface-5',
|
|
1045
|
+
)}
|
|
1046
|
+
>
|
|
1047
|
+
{part}
|
|
1048
|
+
</button>
|
|
1049
|
+
</div>
|
|
1050
|
+
);
|
|
1051
|
+
})}
|
|
1052
|
+
</div>
|
|
1053
|
+
|
|
1054
|
+
{/* Up button */}
|
|
1055
|
+
{currentPath && (
|
|
1056
|
+
<button
|
|
1057
|
+
onClick={() => {
|
|
1058
|
+
const parent = breadcrumbs.slice(0, -1).join('/');
|
|
1059
|
+
loadTree(parent);
|
|
1060
|
+
}}
|
|
1061
|
+
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs text-text-3 hover:text-text-0 hover:bg-surface-4 transition-colors cursor-pointer"
|
|
1062
|
+
>
|
|
1063
|
+
<ArrowUp size={12} /> Up
|
|
1064
|
+
</button>
|
|
1065
|
+
)}
|
|
1066
|
+
|
|
1067
|
+
{/* File listing */}
|
|
1068
|
+
<div className="bg-surface-0 rounded-lg border border-border-subtle overflow-hidden">
|
|
1069
|
+
<div className="max-h-[280px] overflow-y-auto">
|
|
1070
|
+
{loading && (
|
|
1071
|
+
<div className="flex items-center justify-center py-8">
|
|
1072
|
+
<Loader2 size={18} className="text-text-3 animate-spin" />
|
|
1073
|
+
</div>
|
|
1074
|
+
)}
|
|
1075
|
+
{error && (
|
|
1076
|
+
<div className="px-4 py-6 text-center">
|
|
1077
|
+
<p className="text-xs text-danger font-sans">{error}</p>
|
|
1078
|
+
</div>
|
|
1079
|
+
)}
|
|
1080
|
+
{!loading && !error && entries.length === 0 && (
|
|
1081
|
+
<div className="px-4 py-6 text-center">
|
|
1082
|
+
<p className="text-xs text-text-3 font-sans">Empty directory</p>
|
|
1083
|
+
</div>
|
|
1084
|
+
)}
|
|
1085
|
+
{!loading && !error && entries.map((entry) => (
|
|
1086
|
+
<button
|
|
1087
|
+
key={entry.path}
|
|
1088
|
+
onClick={() => entry.type === 'dir' ? loadTree(entry.path) : selectFile(entry)}
|
|
1089
|
+
className={cn(
|
|
1090
|
+
'w-full flex items-center gap-2.5 px-3.5 py-2 text-left cursor-pointer',
|
|
1091
|
+
'hover:bg-surface-4 transition-colors border-b border-border-subtle last:border-0',
|
|
1092
|
+
)}
|
|
1093
|
+
>
|
|
1094
|
+
{entry.type === 'dir'
|
|
1095
|
+
? <FolderClosed size={14} className="text-warning flex-shrink-0" />
|
|
1096
|
+
: <File size={14} className="text-text-3 flex-shrink-0" />
|
|
1097
|
+
}
|
|
1098
|
+
<span className="text-xs text-text-0 font-sans truncate flex-1">{entry.name}</span>
|
|
1099
|
+
{entry.type === 'dir' && entry.hasChildren && (
|
|
1100
|
+
<ChevronRight size={11} className="text-text-4 flex-shrink-0" />
|
|
1101
|
+
)}
|
|
1102
|
+
{entry.type === 'file' && entry.size != null && (
|
|
1103
|
+
<span className="text-2xs font-mono text-text-4 flex-shrink-0">
|
|
1104
|
+
{entry.size > 1024 ? `${(entry.size / 1024).toFixed(1)}K` : `${entry.size}B`}
|
|
1105
|
+
</span>
|
|
1106
|
+
)}
|
|
1107
|
+
</button>
|
|
1108
|
+
))}
|
|
1109
|
+
</div>
|
|
1110
|
+
</div>
|
|
1111
|
+
|
|
1112
|
+
{/* Create new file */}
|
|
1113
|
+
{allowCreate && (
|
|
1114
|
+
<div className="flex items-center gap-2">
|
|
1115
|
+
<Input
|
|
1116
|
+
mono
|
|
1117
|
+
value={newFileName}
|
|
1118
|
+
onChange={(e) => setNewFileName(e.target.value)}
|
|
1119
|
+
placeholder="new-file.md"
|
|
1120
|
+
className="flex-1"
|
|
1121
|
+
/>
|
|
1122
|
+
<Button variant="ghost" size="sm" onClick={selectNew} disabled={!newFileName.trim()} className="gap-1 text-2xs flex-shrink-0">
|
|
1123
|
+
<Plus size={10} /> Create
|
|
1124
|
+
</Button>
|
|
1125
|
+
</div>
|
|
1126
|
+
)}
|
|
1127
|
+
|
|
1128
|
+
{/* Actions */}
|
|
1129
|
+
<div className="flex justify-end">
|
|
1130
|
+
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>Cancel</Button>
|
|
1131
|
+
</div>
|
|
1132
|
+
</div>
|
|
1133
|
+
</DialogContent>
|
|
1134
|
+
</Dialog>
|
|
1135
|
+
);
|
|
1136
|
+
}
|