@aion0/forge 0.5.21 → 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.
Files changed (39) hide show
  1. package/.forge/agent-context.json +1 -1
  2. package/.forge/mcp.json +1 -1
  3. package/RELEASE_NOTES.md +31 -9
  4. package/app/api/plugins/route.ts +75 -0
  5. package/components/Dashboard.tsx +1 -0
  6. package/components/PipelineEditor.tsx +135 -9
  7. package/components/PluginsPanel.tsx +472 -0
  8. package/components/ProjectDetail.tsx +36 -98
  9. package/components/SessionView.tsx +4 -4
  10. package/components/SettingsModal.tsx +160 -66
  11. package/components/SkillsPanel.tsx +14 -5
  12. package/components/TerminalLauncher.tsx +398 -0
  13. package/components/WebTerminal.tsx +84 -84
  14. package/components/WorkspaceView.tsx +256 -76
  15. package/lib/agents/index.ts +7 -4
  16. package/lib/builtin-plugins/docker.yaml +70 -0
  17. package/lib/builtin-plugins/http.yaml +66 -0
  18. package/lib/builtin-plugins/jenkins.yaml +92 -0
  19. package/lib/builtin-plugins/llm-vision.yaml +85 -0
  20. package/lib/builtin-plugins/playwright.yaml +111 -0
  21. package/lib/builtin-plugins/shell-command.yaml +60 -0
  22. package/lib/builtin-plugins/slack.yaml +48 -0
  23. package/lib/builtin-plugins/webhook.yaml +56 -0
  24. package/lib/forge-mcp-server.ts +116 -2
  25. package/lib/pipeline.ts +62 -5
  26. package/lib/plugins/executor.ts +347 -0
  27. package/lib/plugins/registry.ts +228 -0
  28. package/lib/plugins/types.ts +103 -0
  29. package/lib/project-sessions.ts +7 -2
  30. package/lib/session-utils.ts +7 -3
  31. package/lib/terminal-standalone.ts +6 -34
  32. package/lib/workspace/agent-worker.ts +1 -1
  33. package/lib/workspace/orchestrator.ts +414 -136
  34. package/lib/workspace/presets.ts +5 -3
  35. package/lib/workspace/session-monitor.ts +14 -10
  36. package/lib/workspace/types.ts +3 -1
  37. package/lib/workspace-standalone.ts +38 -21
  38. package/package.json +1 -1
  39. 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 [launchDialog, setLaunchDialog] = useState<{ agentId: string; agentName: string; env?: Record<string, string>; model?: string } | null>(null);
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
- // Fetch sessions when dialog opens (only for claude-code agents)
1530
- useEffect(() => {
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
- // Build profile env for the event
1551
- const profileEnv = env ? { ...env } : undefined;
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
- setSessions([]);
1573
- setShowSessions(false);
1574
- setLaunchDialog({ agentId: a.id, agentName: a.name, env: info.env, model: info.model });
1575
- } else {
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
- // Fallback: open directly
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
- {/* Launch dialog New / Resume / Sessions */}
1630
- {launchDialog && (
1631
- <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.75)' }}
1632
- onClick={e => { if (e.target === e.currentTarget) setLaunchDialog(null); }}>
1633
- <div className="w-80 rounded-lg border border-[#30363d] p-4 shadow-xl" style={{ background: '#0d1117' }}>
1634
- <div className="text-sm font-bold text-white mb-3">⌨️ {launchDialog.agentName}</div>
1635
- <div className="space-y-2">
1636
- <button onClick={() => openWithAgent(launchDialog.agentId, false, undefined, launchDialog.env, launchDialog.model)}
1637
- className="w-full text-left px-3 py-2 rounded border border-[#30363d] hover:border-[#58a6ff] hover:bg-[#161b22] transition-colors">
1638
- <div className="text-xs text-white font-semibold">New Session</div>
1639
- <div className="text-[9px] text-gray-500">Start fresh</div>
1640
- </button>
1641
-
1642
- {sessions.length > 0 && (
1643
- <button onClick={async () => {
1644
- // Use fixedSession if available, otherwise latest session
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
  );