@aion0/forge 0.5.20 → 0.5.22
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/.forge/agent-context.json +1 -1
- package/RELEASE_NOTES.md +32 -6
- package/app/api/code/route.ts +10 -4
- package/app/api/plugins/route.ts +75 -0
- package/components/Dashboard.tsx +1 -0
- package/components/PipelineEditor.tsx +135 -9
- package/components/PluginsPanel.tsx +472 -0
- package/components/ProjectDetail.tsx +36 -98
- package/components/SessionView.tsx +4 -4
- package/components/SettingsModal.tsx +160 -66
- package/components/SkillsPanel.tsx +14 -5
- package/components/TerminalLauncher.tsx +398 -0
- package/components/WebTerminal.tsx +84 -84
- package/components/WorkspaceView.tsx +371 -87
- package/lib/agents/index.ts +7 -4
- package/lib/builtin-plugins/docker.yaml +70 -0
- package/lib/builtin-plugins/http.yaml +66 -0
- package/lib/builtin-plugins/jenkins.yaml +92 -0
- package/lib/builtin-plugins/llm-vision.yaml +85 -0
- package/lib/builtin-plugins/playwright.yaml +111 -0
- package/lib/builtin-plugins/shell-command.yaml +60 -0
- package/lib/builtin-plugins/slack.yaml +48 -0
- package/lib/builtin-plugins/webhook.yaml +56 -0
- package/lib/forge-mcp-server.ts +116 -2
- package/lib/pipeline.ts +62 -5
- package/lib/plugins/executor.ts +347 -0
- package/lib/plugins/registry.ts +228 -0
- package/lib/plugins/types.ts +103 -0
- package/lib/project-sessions.ts +7 -2
- package/lib/session-utils.ts +7 -3
- package/lib/terminal-standalone.ts +6 -34
- package/lib/workspace/agent-worker.ts +1 -1
- package/lib/workspace/orchestrator.ts +414 -136
- package/lib/workspace/presets.ts +5 -3
- package/lib/workspace/session-monitor.ts +14 -10
- package/lib/workspace/types.ts +3 -1
- package/lib/workspace-standalone.ts +38 -21
- package/next-env.d.ts +1 -1
- package/package.json +1 -1
- package/qa/.forge/agent-context.json +1 -1
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
interface PluginSource {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
icon: string;
|
|
9
|
+
version: string;
|
|
10
|
+
author: string;
|
|
11
|
+
description: string;
|
|
12
|
+
source: 'builtin' | 'local' | 'registry';
|
|
13
|
+
installed: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface PluginInstance {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
source: string; // plugin definition ID
|
|
20
|
+
icon: string;
|
|
21
|
+
config: Record<string, any>;
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface PluginDetail {
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
icon: string;
|
|
29
|
+
version: string;
|
|
30
|
+
author?: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
config: Record<string, { type: string; label?: string; description?: string; required?: boolean; default?: any; options?: string[] }>;
|
|
33
|
+
params: Record<string, { type: string; label?: string; description?: string; required?: boolean; default?: any }>;
|
|
34
|
+
actions: Record<string, { run: string; method?: string; url?: string; command?: string }>;
|
|
35
|
+
defaultAction?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default function PluginsPanel() {
|
|
39
|
+
const [plugins, setPlugins] = useState<PluginSource[]>([]);
|
|
40
|
+
const [instances, setInstances] = useState<PluginInstance[]>([]);
|
|
41
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
42
|
+
const [selectedInstance, setSelectedInstance] = useState<string | null>(null);
|
|
43
|
+
const [detail, setDetail] = useState<PluginDetail | null>(null);
|
|
44
|
+
const [installedConfig, setInstalledConfig] = useState<Record<string, any> | null>(null);
|
|
45
|
+
const [configValues, setConfigValues] = useState<Record<string, any>>({});
|
|
46
|
+
const [configSaved, setConfigSaved] = useState(false);
|
|
47
|
+
const [filter, setFilter] = useState<'all' | 'installed'>('all');
|
|
48
|
+
const [loading, setLoading] = useState(true);
|
|
49
|
+
const [testResult, setTestResult] = useState<{ ok: boolean; output: any; error?: string; duration?: number } | null>(null);
|
|
50
|
+
const [testAction, setTestAction] = useState('');
|
|
51
|
+
const [testParams, setTestParams] = useState('{}');
|
|
52
|
+
const [testing, setTesting] = useState(false);
|
|
53
|
+
// New instance form
|
|
54
|
+
const [showNewInstance, setShowNewInstance] = useState(false);
|
|
55
|
+
const [newInstanceName, setNewInstanceName] = useState('');
|
|
56
|
+
|
|
57
|
+
const fetchPlugins = useCallback(async () => {
|
|
58
|
+
setLoading(true);
|
|
59
|
+
try {
|
|
60
|
+
const [allRes, instRes] = await Promise.all([
|
|
61
|
+
fetch('/api/plugins'),
|
|
62
|
+
fetch('/api/plugins?installed=true'),
|
|
63
|
+
]);
|
|
64
|
+
const allData = await allRes.json();
|
|
65
|
+
const instData = await instRes.json();
|
|
66
|
+
setPlugins(allData.plugins || []);
|
|
67
|
+
// Build instances list from installed plugins
|
|
68
|
+
const inst: PluginInstance[] = (instData.plugins || []).map((p: any) => ({
|
|
69
|
+
id: p.id,
|
|
70
|
+
name: p.instanceName || p.definition?.name || p.id,
|
|
71
|
+
source: p.source || p.id,
|
|
72
|
+
icon: p.definition?.icon || '🔌',
|
|
73
|
+
config: p.config || {},
|
|
74
|
+
enabled: p.enabled !== false,
|
|
75
|
+
}));
|
|
76
|
+
setInstances(inst);
|
|
77
|
+
} catch {} finally { setLoading(false); }
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
useEffect(() => { fetchPlugins(); }, [fetchPlugins]);
|
|
81
|
+
|
|
82
|
+
const selectPlugin = useCallback(async (id: string, instanceId?: string) => {
|
|
83
|
+
setSelectedId(id);
|
|
84
|
+
setSelectedInstance(instanceId || null);
|
|
85
|
+
setTestResult(null);
|
|
86
|
+
setShowNewInstance(false);
|
|
87
|
+
try {
|
|
88
|
+
const lookupId = instanceId || id;
|
|
89
|
+
const res = await fetch(`/api/plugins?id=${lookupId}`);
|
|
90
|
+
const data = await res.json();
|
|
91
|
+
setDetail(data.plugin || null);
|
|
92
|
+
setInstalledConfig(data.config ?? null);
|
|
93
|
+
setConfigValues(data.config || {});
|
|
94
|
+
if (data.plugin?.defaultAction) setTestAction(data.plugin.defaultAction);
|
|
95
|
+
else if (data.plugin?.actions) setTestAction(Object.keys(data.plugin.actions)[0] || '');
|
|
96
|
+
} catch {}
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
const handleInstall = async () => {
|
|
100
|
+
if (!selectedId) return;
|
|
101
|
+
await fetch('/api/plugins', {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { 'Content-Type': 'application/json' },
|
|
104
|
+
body: JSON.stringify({ action: 'install', id: selectedId, config: {} }),
|
|
105
|
+
});
|
|
106
|
+
await fetchPlugins();
|
|
107
|
+
await selectPlugin(selectedId);
|
|
108
|
+
// Auto-open new instance form after install
|
|
109
|
+
setShowNewInstance(true);
|
|
110
|
+
setNewInstanceName('');
|
|
111
|
+
setConfigValues({});
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const handleUninstall = async () => {
|
|
115
|
+
const id = selectedInstance || selectedId;
|
|
116
|
+
if (!id) return;
|
|
117
|
+
await fetch('/api/plugins', {
|
|
118
|
+
method: 'POST',
|
|
119
|
+
headers: { 'Content-Type': 'application/json' },
|
|
120
|
+
body: JSON.stringify({ action: 'uninstall', id }),
|
|
121
|
+
});
|
|
122
|
+
setInstalledConfig(null);
|
|
123
|
+
setSelectedInstance(null);
|
|
124
|
+
await fetchPlugins();
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const handleSaveConfig = async () => {
|
|
128
|
+
const id = selectedInstance || selectedId;
|
|
129
|
+
if (!id || !detail) return;
|
|
130
|
+
// Merge schema defaults with user-entered values
|
|
131
|
+
const finalConfig: Record<string, any> = {};
|
|
132
|
+
for (const [key, schema] of Object.entries(detail.config)) {
|
|
133
|
+
finalConfig[key] = configValues[key] ?? (schema as any).default ?? '';
|
|
134
|
+
}
|
|
135
|
+
await fetch('/api/plugins', {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: { 'Content-Type': 'application/json' },
|
|
138
|
+
body: JSON.stringify({ action: 'update_config', id, config: finalConfig }),
|
|
139
|
+
});
|
|
140
|
+
setConfigSaved(true);
|
|
141
|
+
setTimeout(() => setConfigSaved(false), 2000);
|
|
142
|
+
await selectPlugin(selectedId!, selectedInstance || undefined);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const handleCreateInstance = async () => {
|
|
146
|
+
if (!selectedId || !newInstanceName.trim() || !detail) return;
|
|
147
|
+
// Merge schema defaults with user-entered values
|
|
148
|
+
const finalConfig: Record<string, any> = {};
|
|
149
|
+
for (const [key, schema] of Object.entries(detail.config)) {
|
|
150
|
+
finalConfig[key] = configValues[key] ?? (schema as any).default ?? '';
|
|
151
|
+
}
|
|
152
|
+
const res = await fetch('/api/plugins', {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: { 'Content-Type': 'application/json' },
|
|
155
|
+
body: JSON.stringify({ action: 'create_instance', source: selectedId, name: newInstanceName.trim(), config: finalConfig }),
|
|
156
|
+
});
|
|
157
|
+
const data = await res.json();
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
alert(data.error || 'Failed to create instance');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
setShowNewInstance(false);
|
|
163
|
+
setNewInstanceName('');
|
|
164
|
+
await fetchPlugins();
|
|
165
|
+
// Auto-select the new instance
|
|
166
|
+
if (data.instanceId) {
|
|
167
|
+
await selectPlugin(selectedId, data.instanceId);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const handleTest = async () => {
|
|
172
|
+
const id = selectedInstance || selectedId;
|
|
173
|
+
if (!id || !testAction) return;
|
|
174
|
+
setTesting(true);
|
|
175
|
+
setTestResult(null);
|
|
176
|
+
try {
|
|
177
|
+
let params = {};
|
|
178
|
+
try { params = JSON.parse(testParams); } catch {}
|
|
179
|
+
const res = await fetch('/api/plugins', {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
headers: { 'Content-Type': 'application/json' },
|
|
182
|
+
body: JSON.stringify({ action: 'test', id, actionName: testAction, params }),
|
|
183
|
+
});
|
|
184
|
+
setTestResult(await res.json());
|
|
185
|
+
} catch (err: any) {
|
|
186
|
+
setTestResult({ ok: false, output: {}, error: err.message });
|
|
187
|
+
} finally { setTesting(false); }
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const filtered = filter === 'installed' ? plugins.filter(p => p.installed || instances.some(i => i.source === p.id)) : plugins;
|
|
191
|
+
const pluginInstances = (id: string) => instances.filter(i => i.source === id && i.id !== id);
|
|
192
|
+
|
|
193
|
+
if (loading) return <div className="p-4 text-xs text-[var(--text-secondary)]">Loading plugins...</div>;
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<div className="flex-1 flex flex-col min-h-0">
|
|
197
|
+
{/* Header */}
|
|
198
|
+
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--border)] shrink-0">
|
|
199
|
+
<div className="flex items-center gap-2">
|
|
200
|
+
<span className="text-xs font-semibold text-[var(--text-primary)]">Plugins</span>
|
|
201
|
+
<span className="text-[10px] text-[var(--text-secondary)]">{instances.length} instances from {plugins.filter(p => p.installed).length} plugins</span>
|
|
202
|
+
</div>
|
|
203
|
+
<div className="flex items-center bg-[var(--bg-tertiary)] rounded p-0.5">
|
|
204
|
+
{(['all', 'installed'] as const).map(f => (
|
|
205
|
+
<button key={f} onClick={() => setFilter(f)}
|
|
206
|
+
className={`text-[10px] px-2 py-0.5 rounded transition-colors ${filter === f ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)]'}`}
|
|
207
|
+
>{f === 'all' ? 'All' : 'Installed'}</button>
|
|
208
|
+
))}
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<div className="flex-1 flex min-h-0">
|
|
213
|
+
{/* Plugin list with instances */}
|
|
214
|
+
<div className="w-56 overflow-y-auto shrink-0 border-r border-[var(--border)]">
|
|
215
|
+
{filtered.length === 0 && (
|
|
216
|
+
<div className="p-4 text-xs text-[var(--text-secondary)] text-center">No plugins found</div>
|
|
217
|
+
)}
|
|
218
|
+
{filtered.map(p => {
|
|
219
|
+
const pInstances = pluginInstances(p.id);
|
|
220
|
+
const isSelected = selectedId === p.id && !selectedInstance;
|
|
221
|
+
return (
|
|
222
|
+
<div key={p.id}>
|
|
223
|
+
<div
|
|
224
|
+
onClick={() => selectPlugin(p.id)}
|
|
225
|
+
className={`px-3 py-2 cursor-pointer border-b border-[var(--border)]/50 transition-colors ${
|
|
226
|
+
isSelected ? 'bg-[var(--bg-secondary)]' : 'hover:bg-[var(--bg-tertiary)]'
|
|
227
|
+
}`}
|
|
228
|
+
>
|
|
229
|
+
<div className="flex items-center gap-2">
|
|
230
|
+
<span className="text-sm">{p.icon}</span>
|
|
231
|
+
<span className="text-[11px] font-semibold text-[var(--text-primary)] truncate flex-1">{p.name}</span>
|
|
232
|
+
{p.installed && <span className="w-1.5 h-1.5 rounded-full bg-green-400 shrink-0" />}
|
|
233
|
+
</div>
|
|
234
|
+
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5 line-clamp-1">{p.description}</div>
|
|
235
|
+
<div className="flex items-center gap-2 mt-1">
|
|
236
|
+
<span className="text-[9px] text-[var(--text-secondary)]">v{p.version}</span>
|
|
237
|
+
{pInstances.length > 0 && <span className="text-[9px] text-[var(--accent)]">{pInstances.length} instance{pInstances.length > 1 ? 's' : ''}</span>}
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
{/* Instances */}
|
|
241
|
+
{pInstances.map(inst => (
|
|
242
|
+
<div key={inst.id}
|
|
243
|
+
onClick={() => selectPlugin(p.id, inst.id)}
|
|
244
|
+
className={`pl-8 pr-3 py-1.5 cursor-pointer border-b border-[var(--border)]/30 transition-colors ${
|
|
245
|
+
selectedInstance === inst.id ? 'bg-[var(--bg-secondary)]' : 'hover:bg-[var(--bg-tertiary)]'
|
|
246
|
+
}`}
|
|
247
|
+
>
|
|
248
|
+
<div className="flex items-center gap-1.5">
|
|
249
|
+
<span className="text-[9px]">{p.icon}</span>
|
|
250
|
+
<span className="text-[10px] text-[var(--text-primary)] truncate">{inst.name}</span>
|
|
251
|
+
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${inst.enabled ? 'bg-green-400' : 'bg-gray-500'}`} />
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
))}
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
})}
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
{/* Detail panel */}
|
|
261
|
+
<div className="flex-1 overflow-y-auto p-4">
|
|
262
|
+
{!selectedId || !detail ? (
|
|
263
|
+
<div className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)] h-full">
|
|
264
|
+
Select a plugin to view details
|
|
265
|
+
</div>
|
|
266
|
+
) : (
|
|
267
|
+
<div className="space-y-4 max-w-xl">
|
|
268
|
+
{/* Header */}
|
|
269
|
+
<div className="flex items-center gap-3">
|
|
270
|
+
<span className="text-2xl">{detail.icon}</span>
|
|
271
|
+
<div>
|
|
272
|
+
<h2 className="text-sm font-semibold text-[var(--text-primary)]">
|
|
273
|
+
{selectedInstance ? instances.find(i => i.id === selectedInstance)?.name || selectedInstance : detail.name}
|
|
274
|
+
</h2>
|
|
275
|
+
<p className="text-[10px] text-[var(--text-secondary)]">
|
|
276
|
+
{selectedInstance ? `Instance of ${detail.name}` : `v${detail.version} by ${detail.author || 'unknown'}`}
|
|
277
|
+
</p>
|
|
278
|
+
</div>
|
|
279
|
+
<div className="flex-1" />
|
|
280
|
+
<div className="flex items-center gap-1.5">
|
|
281
|
+
{/* Create instance button (only for installed base plugins) */}
|
|
282
|
+
{installedConfig !== null && !selectedInstance && (
|
|
283
|
+
<button onClick={() => { setShowNewInstance(true); setNewInstanceName(''); setConfigValues({}); }}
|
|
284
|
+
className="text-[10px] px-3 py-1 rounded bg-[var(--accent)]/20 text-[var(--accent)] hover:bg-[var(--accent)]/30 transition-colors"
|
|
285
|
+
>+ Instance</button>
|
|
286
|
+
)}
|
|
287
|
+
{installedConfig !== null || selectedInstance ? (
|
|
288
|
+
<button onClick={handleUninstall}
|
|
289
|
+
className="text-[10px] px-3 py-1 rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
|
|
290
|
+
>{selectedInstance ? 'Delete' : 'Uninstall'}</button>
|
|
291
|
+
) : (
|
|
292
|
+
<button onClick={handleInstall}
|
|
293
|
+
className="text-[10px] px-3 py-1 rounded bg-[var(--accent)]/20 text-[var(--accent)] hover:bg-[var(--accent)]/30 transition-colors"
|
|
294
|
+
>Install</button>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
{/* New instance form */}
|
|
300
|
+
{showNewInstance && (
|
|
301
|
+
<div className="rounded border border-[var(--accent)]/30 bg-[var(--accent)]/5 p-3 space-y-2">
|
|
302
|
+
<h3 className="text-[11px] font-semibold text-[var(--accent)]">New Instance</h3>
|
|
303
|
+
<div>
|
|
304
|
+
<label className="text-[10px] text-[var(--text-secondary)] block mb-0.5">Instance Name</label>
|
|
305
|
+
<input value={newInstanceName} onChange={e => setNewInstanceName(e.target.value)}
|
|
306
|
+
placeholder={`e.g., ${detail.name} Production`}
|
|
307
|
+
className="w-full bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[11px] text-[var(--text-primary)]"
|
|
308
|
+
/>
|
|
309
|
+
</div>
|
|
310
|
+
{/* Config fields for new instance */}
|
|
311
|
+
{Object.entries(detail.config).map(([key, schema]) => (
|
|
312
|
+
<div key={key}>
|
|
313
|
+
<label className="text-[10px] text-[var(--text-secondary)] block mb-0.5">
|
|
314
|
+
{schema.label || key} {schema.required && <span className="text-red-400">*</span>}
|
|
315
|
+
{schema.description && <span className="text-[8px] text-[var(--text-secondary)]/60 ml-1">{schema.description}</span>}
|
|
316
|
+
</label>
|
|
317
|
+
{schema.type === 'select' ? (
|
|
318
|
+
<select
|
|
319
|
+
value={configValues[key] || schema.default || ''}
|
|
320
|
+
onChange={e => setConfigValues({ ...configValues, [key]: e.target.value })}
|
|
321
|
+
className="w-full bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[11px] text-[var(--text-primary)]"
|
|
322
|
+
>
|
|
323
|
+
<option value="">Select...</option>
|
|
324
|
+
{(schema.options || []).map((o: string) => <option key={o} value={o}>{o}</option>)}
|
|
325
|
+
</select>
|
|
326
|
+
) : schema.type === 'boolean' ? (
|
|
327
|
+
<input type="checkbox"
|
|
328
|
+
checked={configValues[key] === true || configValues[key] === 'true'}
|
|
329
|
+
onChange={e => setConfigValues({ ...configValues, [key]: e.target.checked })}
|
|
330
|
+
className="accent-[var(--accent)]"
|
|
331
|
+
/>
|
|
332
|
+
) : (
|
|
333
|
+
<input
|
|
334
|
+
type={schema.type === 'secret' ? 'password' : schema.type === 'number' ? 'number' : 'text'}
|
|
335
|
+
value={configValues[key] ?? schema.default ?? ''}
|
|
336
|
+
onChange={e => setConfigValues({ ...configValues, [key]: e.target.value })}
|
|
337
|
+
placeholder={schema.description || ''}
|
|
338
|
+
className="w-full bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[11px] text-[var(--text-primary)]"
|
|
339
|
+
/>
|
|
340
|
+
)}
|
|
341
|
+
</div>
|
|
342
|
+
))}
|
|
343
|
+
<div className="flex gap-2">
|
|
344
|
+
<button onClick={handleCreateInstance} disabled={!newInstanceName.trim()}
|
|
345
|
+
className="text-[10px] px-3 py-1 rounded bg-[var(--accent)] text-white hover:opacity-90 disabled:opacity-40"
|
|
346
|
+
>Create</button>
|
|
347
|
+
<button onClick={() => setShowNewInstance(false)}
|
|
348
|
+
className="text-[10px] px-3 py-1 rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
349
|
+
>Cancel</button>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
)}
|
|
353
|
+
|
|
354
|
+
{detail.description && !showNewInstance && (
|
|
355
|
+
<p className="text-[11px] text-[var(--text-secondary)]">{detail.description}</p>
|
|
356
|
+
)}
|
|
357
|
+
|
|
358
|
+
{/* Actions */}
|
|
359
|
+
{!showNewInstance && (
|
|
360
|
+
<div>
|
|
361
|
+
<h3 className="text-[11px] font-semibold text-[var(--text-primary)] mb-1.5">Actions</h3>
|
|
362
|
+
<div className="grid gap-1.5">
|
|
363
|
+
{Object.entries(detail.actions).map(([name, action]) => (
|
|
364
|
+
<div key={name} className="flex items-center gap-2 px-2.5 py-1.5 rounded bg-[var(--bg-tertiary)]">
|
|
365
|
+
<span className="text-[10px] font-mono font-semibold text-[var(--accent)]">{name}</span>
|
|
366
|
+
<span className="text-[9px] px-1.5 py-0.5 rounded bg-[var(--bg-secondary)] text-[var(--text-secondary)]">{action.run}</span>
|
|
367
|
+
{action.url && <span className="text-[9px] text-[var(--text-secondary)] truncate">{action.method || 'GET'} {action.url}</span>}
|
|
368
|
+
{action.command && <span className="text-[9px] text-[var(--text-secondary)] truncate font-mono">{action.command.slice(0, 60)}</span>}
|
|
369
|
+
{detail.defaultAction === name && <span className="text-[8px] px-1 py-0.5 rounded bg-[var(--accent)]/10 text-[var(--accent)]">default</span>}
|
|
370
|
+
</div>
|
|
371
|
+
))}
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
)}
|
|
375
|
+
|
|
376
|
+
{/* Hint: create instance if base plugin has no instances */}
|
|
377
|
+
{!showNewInstance && !selectedInstance && installedConfig !== null && pluginInstances(selectedId!).length === 0 && Object.keys(detail.config).length > 0 && (
|
|
378
|
+
<div className="rounded border border-dashed border-[var(--accent)]/30 bg-[var(--accent)]/5 p-3 text-center">
|
|
379
|
+
<p className="text-[11px] text-[var(--text-secondary)] mb-2">Create an instance to configure and use this plugin</p>
|
|
380
|
+
<button onClick={() => { setShowNewInstance(true); setNewInstanceName(''); setConfigValues({}); }}
|
|
381
|
+
className="text-[10px] px-3 py-1 rounded bg-[var(--accent)] text-white hover:opacity-90"
|
|
382
|
+
>+ Create First Instance</button>
|
|
383
|
+
</div>
|
|
384
|
+
)}
|
|
385
|
+
|
|
386
|
+
{/* Config (for installed plugins and instances) */}
|
|
387
|
+
{!showNewInstance && Object.keys(detail.config).length > 0 && installedConfig !== null && (
|
|
388
|
+
<div>
|
|
389
|
+
<h3 className="text-[11px] font-semibold text-[var(--text-primary)] mb-1.5">Configuration</h3>
|
|
390
|
+
<div className="space-y-2">
|
|
391
|
+
{Object.entries(detail.config).map(([key, schema]) => (
|
|
392
|
+
<div key={key}>
|
|
393
|
+
<label className="text-[10px] text-[var(--text-secondary)] block mb-0.5">
|
|
394
|
+
{schema.label || key} {schema.required && <span className="text-red-400">*</span>}
|
|
395
|
+
</label>
|
|
396
|
+
{schema.type === 'select' ? (
|
|
397
|
+
<select
|
|
398
|
+
value={configValues[key] || schema.default || ''}
|
|
399
|
+
onChange={e => setConfigValues({ ...configValues, [key]: e.target.value })}
|
|
400
|
+
className="w-full bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[11px] text-[var(--text-primary)]"
|
|
401
|
+
>
|
|
402
|
+
<option value="">Select...</option>
|
|
403
|
+
{(schema.options || []).map(o => <option key={o} value={o}>{o}</option>)}
|
|
404
|
+
</select>
|
|
405
|
+
) : schema.type === 'boolean' ? (
|
|
406
|
+
<input type="checkbox"
|
|
407
|
+
checked={configValues[key] === true || configValues[key] === 'true'}
|
|
408
|
+
onChange={e => setConfigValues({ ...configValues, [key]: e.target.checked })}
|
|
409
|
+
className="accent-[var(--accent)]"
|
|
410
|
+
/>
|
|
411
|
+
) : (
|
|
412
|
+
<input
|
|
413
|
+
type={schema.type === 'secret' ? 'password' : schema.type === 'number' ? 'number' : 'text'}
|
|
414
|
+
value={configValues[key] ?? schema.default ?? ''}
|
|
415
|
+
onChange={e => setConfigValues({ ...configValues, [key]: e.target.value })}
|
|
416
|
+
placeholder={schema.description || ''}
|
|
417
|
+
className="w-full bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[11px] text-[var(--text-primary)]"
|
|
418
|
+
/>
|
|
419
|
+
)}
|
|
420
|
+
</div>
|
|
421
|
+
))}
|
|
422
|
+
<button onClick={handleSaveConfig}
|
|
423
|
+
className="text-[10px] px-3 py-1 rounded bg-[var(--accent)]/20 text-[var(--accent)] hover:bg-[var(--accent)]/30 transition-colors"
|
|
424
|
+
>{configSaved ? 'Saved!' : 'Save Config'}</button>
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
427
|
+
)}
|
|
428
|
+
|
|
429
|
+
{/* Run Action (only for instances) */}
|
|
430
|
+
{!showNewInstance && selectedInstance && (
|
|
431
|
+
<div>
|
|
432
|
+
<h3 className="text-[11px] font-semibold text-[var(--text-primary)] mb-1.5">Run Action</h3>
|
|
433
|
+
<div className="space-y-2">
|
|
434
|
+
<div className="flex items-center gap-2">
|
|
435
|
+
<select value={testAction} onChange={e => setTestAction(e.target.value)}
|
|
436
|
+
className="bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[11px] text-[var(--text-primary)]"
|
|
437
|
+
>
|
|
438
|
+
{Object.keys(detail.actions).map(a => <option key={a} value={a}>{a}</option>)}
|
|
439
|
+
</select>
|
|
440
|
+
<button onClick={handleTest} disabled={testing}
|
|
441
|
+
className="text-[10px] px-3 py-1 rounded bg-[var(--accent)] text-white hover:opacity-90 transition-opacity disabled:opacity-50"
|
|
442
|
+
>{testing ? 'Running...' : 'Run'}</button>
|
|
443
|
+
</div>
|
|
444
|
+
<textarea
|
|
445
|
+
value={testParams}
|
|
446
|
+
onChange={e => setTestParams(e.target.value)}
|
|
447
|
+
placeholder='{"key": "value"}'
|
|
448
|
+
rows={3}
|
|
449
|
+
className="w-full bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1.5 text-[10px] font-mono text-[var(--text-primary)] resize-y"
|
|
450
|
+
/>
|
|
451
|
+
{testResult && (
|
|
452
|
+
<div className={`rounded p-2.5 text-[10px] font-mono ${testResult.ok ? 'bg-green-500/10 border border-green-500/20' : 'bg-red-500/10 border border-red-500/20'}`}>
|
|
453
|
+
<div className="flex items-center gap-2 mb-1">
|
|
454
|
+
<span className={testResult.ok ? 'text-green-400' : 'text-red-400'}>{testResult.ok ? 'OK' : 'FAILED'}</span>
|
|
455
|
+
{testResult.duration && <span className="text-[var(--text-secondary)]">{testResult.duration}ms</span>}
|
|
456
|
+
</div>
|
|
457
|
+
{testResult.error && <div className="text-red-400 mb-1">{testResult.error}</div>}
|
|
458
|
+
<pre className="text-[var(--text-secondary)] whitespace-pre-wrap break-all max-h-40 overflow-y-auto">
|
|
459
|
+
{JSON.stringify(testResult.output, null, 2)}
|
|
460
|
+
</pre>
|
|
461
|
+
</div>
|
|
462
|
+
)}
|
|
463
|
+
</div>
|
|
464
|
+
</div>
|
|
465
|
+
)}
|
|
466
|
+
</div>
|
|
467
|
+
)}
|
|
468
|
+
</div>
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
);
|
|
472
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { useState, useEffect, useCallback, useRef, memo, lazy, Suspense } from 'react';
|
|
4
4
|
import { useSidebarResize } from '@/hooks/useSidebarResize';
|
|
5
5
|
|
|
6
|
+
import { TerminalSessionPickerLazy, fetchProjectSessions } from './TerminalLauncher';
|
|
6
7
|
const InlinePipelineView = lazy(() => import('./InlinePipelineView'));
|
|
7
8
|
const WorkspaceViewLazy = lazy(() => import('./WorkspaceView'));
|
|
8
9
|
const SessionViewLazy = lazy(() => import('./SessionView'));
|
|
@@ -1508,9 +1509,7 @@ const FileTreeNode = memo(function FileTreeNode({ node, depth, selected, onSelec
|
|
|
1508
1509
|
function AgentTerminalButton({ projectPath, projectName }: { projectPath: string; projectName: string }) {
|
|
1509
1510
|
const [agents, setAgents] = useState<{ id: string; name: string; detected?: boolean; isProfile?: boolean; base?: string; backendType?: string; env?: Record<string, string>; model?: string }[]>([]);
|
|
1510
1511
|
const [showMenu, setShowMenu] = useState(false);
|
|
1511
|
-
const [
|
|
1512
|
-
const [sessions, setSessions] = useState<{ id: string; modified: string; size: number }[]>([]);
|
|
1513
|
-
const [showSessions, setShowSessions] = useState(false);
|
|
1512
|
+
const [pickerInfo, setPickerInfo] = useState<{ agentId: string; agentName: string; env?: Record<string, string>; model?: string; supportsSession: boolean; currentSessionId: string | null } | null>(null);
|
|
1514
1513
|
const ref = useRef<HTMLDivElement>(null);
|
|
1515
1514
|
|
|
1516
1515
|
useEffect(() => {
|
|
@@ -1526,69 +1525,40 @@ function AgentTerminalButton({ projectPath, projectName }: { projectPath: string
|
|
|
1526
1525
|
return () => document.removeEventListener('mousedown', h);
|
|
1527
1526
|
}, [showMenu]);
|
|
1528
1527
|
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
if (!launchDialog) return;
|
|
1532
|
-
const pName = projectPath.replace(/\/+$/, '').split('/').pop() || '';
|
|
1533
|
-
fetch(`/api/claude-sessions/${encodeURIComponent(pName)}`)
|
|
1534
|
-
.then(r => r.json())
|
|
1535
|
-
.then(data => {
|
|
1536
|
-
if (Array.isArray(data) && data.length > 0) {
|
|
1537
|
-
setSessions(data.map((s: any) => ({
|
|
1538
|
-
id: s.sessionId || s.id || '',
|
|
1539
|
-
modified: s.modified || '',
|
|
1540
|
-
size: s.fileSize || s.size || 0,
|
|
1541
|
-
})));
|
|
1542
|
-
}
|
|
1543
|
-
})
|
|
1544
|
-
.catch(() => {});
|
|
1545
|
-
}, [launchDialog, projectPath]);
|
|
1546
|
-
|
|
1547
|
-
const openWithAgent = (agentId: string, resumeMode?: boolean, sessionId?: string, env?: Record<string, string>, model?: string) => {
|
|
1548
|
-
setLaunchDialog(null);
|
|
1528
|
+
const openTerminal = (agentId: string, resumeMode?: boolean, sessionId?: string, env?: Record<string, string>, model?: string) => {
|
|
1529
|
+
setPickerInfo(null);
|
|
1549
1530
|
setShowMenu(false);
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
if (model && profileEnv) profileEnv.CLAUDE_MODEL = model;
|
|
1553
|
-
else if (model) {
|
|
1554
|
-
const pe: Record<string, string> = { CLAUDE_MODEL: model };
|
|
1555
|
-
window.dispatchEvent(new CustomEvent('forge:open-terminal', {
|
|
1556
|
-
detail: { projectPath, projectName, agentId, resumeMode, sessionId, profileEnv: pe },
|
|
1557
|
-
}));
|
|
1558
|
-
return;
|
|
1559
|
-
}
|
|
1531
|
+
const profileEnv: Record<string, string> = { ...(env || {}) };
|
|
1532
|
+
if (model) profileEnv.CLAUDE_MODEL = model;
|
|
1560
1533
|
window.dispatchEvent(new CustomEvent('forge:open-terminal', {
|
|
1561
|
-
detail: { projectPath, projectName, agentId, resumeMode, sessionId, profileEnv },
|
|
1534
|
+
detail: { projectPath, projectName, agentId, resumeMode, sessionId, profileEnv: Object.keys(profileEnv).length > 0 ? profileEnv : undefined },
|
|
1562
1535
|
}));
|
|
1563
1536
|
};
|
|
1564
1537
|
|
|
1565
1538
|
const handleAgentClick = async (a: typeof agents[0]) => {
|
|
1566
1539
|
setShowMenu(false);
|
|
1567
|
-
// Resolve launch info from server (reads cliType + profile)
|
|
1568
1540
|
try {
|
|
1569
1541
|
const res = await fetch(`/api/agents?resolve=${encodeURIComponent(a.id)}`);
|
|
1570
1542
|
const info = await res.json();
|
|
1543
|
+
// Resolve current session (fixedSession for this project)
|
|
1544
|
+
let currentSessionId: string | null = null;
|
|
1571
1545
|
if (info.supportsSession) {
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
openWithAgent(a.id, false, undefined, info.env, info.model);
|
|
1546
|
+
try {
|
|
1547
|
+
const { resolveFixedSession } = await import('@/lib/session-utils');
|
|
1548
|
+
currentSessionId = await resolveFixedSession(projectPath) || null;
|
|
1549
|
+
} catch {}
|
|
1577
1550
|
}
|
|
1551
|
+
setPickerInfo({
|
|
1552
|
+
agentId: a.id, agentName: a.name,
|
|
1553
|
+
env: info.env, model: info.model,
|
|
1554
|
+
supportsSession: info.supportsSession ?? true,
|
|
1555
|
+
currentSessionId,
|
|
1556
|
+
});
|
|
1578
1557
|
} catch {
|
|
1579
|
-
|
|
1580
|
-
openWithAgent(a.id);
|
|
1558
|
+
openTerminal(a.id);
|
|
1581
1559
|
}
|
|
1582
1560
|
};
|
|
1583
1561
|
|
|
1584
|
-
const formatTime = (iso: string) => {
|
|
1585
|
-
const diff = Date.now() - new Date(iso).getTime();
|
|
1586
|
-
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
|
1587
|
-
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
|
1588
|
-
return new Date(iso).toLocaleDateString();
|
|
1589
|
-
};
|
|
1590
|
-
const formatSize = (b: number) => b < 1024 ? `${b}B` : b < 1048576 ? `${(b/1024).toFixed(0)}KB` : `${(b/1048576).toFixed(1)}MB`;
|
|
1591
|
-
|
|
1592
1562
|
const allAgents = agents.filter(a => a.detected !== false || a.isProfile);
|
|
1593
1563
|
|
|
1594
1564
|
return (
|
|
@@ -1626,54 +1596,22 @@ function AgentTerminalButton({ projectPath, projectName }: { projectPath: string
|
|
|
1626
1596
|
)}
|
|
1627
1597
|
</div>
|
|
1628
1598
|
|
|
1629
|
-
{/*
|
|
1630
|
-
{
|
|
1631
|
-
<
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
const { resolveFixedSession } = await import('@/lib/session-utils');
|
|
1646
|
-
const fixedId = await resolveFixedSession(projectPath);
|
|
1647
|
-
openWithAgent(launchDialog.agentId, true, fixedId || sessions[0].id, launchDialog.env, launchDialog.model);
|
|
1648
|
-
}}
|
|
1649
|
-
className="w-full text-left px-3 py-2 rounded border border-[#30363d] hover:border-[#3fb950] hover:bg-[#161b22] transition-colors">
|
|
1650
|
-
<div className="text-xs text-white font-semibold">Resume Latest</div>
|
|
1651
|
-
<div className="text-[9px] text-gray-500">{sessions[0].id.slice(0, 8)} · {formatTime(sessions[0].modified)} · {formatSize(sessions[0].size)}</div>
|
|
1652
|
-
</button>
|
|
1653
|
-
)}
|
|
1654
|
-
|
|
1655
|
-
{sessions.length > 1 && (
|
|
1656
|
-
<button onClick={() => setShowSessions(!showSessions)}
|
|
1657
|
-
className="w-full text-[9px] text-gray-500 hover:text-white py-1">
|
|
1658
|
-
{showSessions ? '▼' : '▶'} More sessions ({sessions.length - 1})
|
|
1659
|
-
</button>
|
|
1660
|
-
)}
|
|
1661
|
-
|
|
1662
|
-
{showSessions && sessions.slice(1).map(s => (
|
|
1663
|
-
<button key={s.id} onClick={() => openWithAgent(launchDialog.agentId, true, s.id, launchDialog.env, launchDialog.model)}
|
|
1664
|
-
className="w-full text-left px-3 py-1.5 rounded border border-[#21262d] hover:border-[#30363d] hover:bg-[#161b22] transition-colors">
|
|
1665
|
-
<div className="flex items-center gap-2">
|
|
1666
|
-
<span className="text-[9px] text-gray-400 font-mono">{s.id.slice(0, 8)}</span>
|
|
1667
|
-
<span className="text-[8px] text-gray-600">{formatTime(s.modified)}</span>
|
|
1668
|
-
<span className="text-[8px] text-gray-600">{formatSize(s.size)}</span>
|
|
1669
|
-
</div>
|
|
1670
|
-
</button>
|
|
1671
|
-
))}
|
|
1672
|
-
</div>
|
|
1673
|
-
<button onClick={() => setLaunchDialog(null)}
|
|
1674
|
-
className="w-full mt-3 text-[9px] text-gray-500 hover:text-white">Cancel</button>
|
|
1675
|
-
</div>
|
|
1676
|
-
</div>
|
|
1599
|
+
{/* Unified Terminal Session Picker */}
|
|
1600
|
+
{pickerInfo && (
|
|
1601
|
+
<TerminalSessionPickerLazy
|
|
1602
|
+
agentLabel={pickerInfo.agentName}
|
|
1603
|
+
currentSessionId={pickerInfo.currentSessionId}
|
|
1604
|
+
supportsSession={pickerInfo.supportsSession}
|
|
1605
|
+
fetchSessions={() => fetchProjectSessions(projectName)}
|
|
1606
|
+
onSelect={(sel) => {
|
|
1607
|
+
if (sel.mode === 'new') {
|
|
1608
|
+
openTerminal(pickerInfo.agentId, false, undefined, pickerInfo.env, pickerInfo.model);
|
|
1609
|
+
} else {
|
|
1610
|
+
openTerminal(pickerInfo.agentId, true, sel.sessionId, pickerInfo.env, pickerInfo.model);
|
|
1611
|
+
}
|
|
1612
|
+
}}
|
|
1613
|
+
onCancel={() => setPickerInfo(null)}
|
|
1614
|
+
/>
|
|
1677
1615
|
)}
|
|
1678
1616
|
</>
|
|
1679
1617
|
);
|