@aion0/forge 0.10.35 → 0.10.37
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/README.md +9 -0
- package/RELEASE_NOTES.md +4 -8
- package/app/api/connectors/import-config-template/route.ts +358 -0
- package/app/api/onboarding/detect-cli/route.ts +46 -0
- package/app/api/onboarding/route.ts +422 -0
- package/components/ConnectorsPanel.tsx +326 -0
- package/components/Dashboard.tsx +29 -1
- package/components/OnboardingWizard.tsx +924 -0
- package/components/SettingsModal.tsx +42 -0
- package/components/WebTerminal.tsx +16 -1
- package/lib/chat/agent-loop.ts +87 -30
- package/lib/chat/llm/openai.ts +5 -1
- package/lib/chat/session-store.ts +22 -2
- package/lib/chat/tool-dispatcher.ts +195 -1
- package/lib/help-docs/17-connectors.md +51 -0
- package/lib/settings.ts +16 -0
- package/package.json +1 -1
- package/templates/connector-config-template.json +131 -0
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Onboarding wizard — first-run setup banner + right-side drawer.
|
|
5
|
+
*
|
|
6
|
+
* - Banner: thin bar at top of Dashboard when `settings.onboardingCompleted` is
|
|
7
|
+
* false. Clicking it opens the drawer. Drawer is a slide-in panel ~520px
|
|
8
|
+
* wide; the rest of the UI stays visible behind it (per user request).
|
|
9
|
+
* - 6 sections: Identity / API Profile / Connectors / Pipelines / Projects /
|
|
10
|
+
* Memory. Apply hits POST /api/onboarding (action:apply) once with the
|
|
11
|
+
* full payload — backend phases each step atomically and reports back.
|
|
12
|
+
*
|
|
13
|
+
* Re-runnable from Settings → "Re-run Onboarding" (POST action:reset).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useEffect, useRef, useState } from 'react';
|
|
17
|
+
|
|
18
|
+
// ─── Types echoing the API surface ───────────────────────────
|
|
19
|
+
|
|
20
|
+
interface PromptDef {
|
|
21
|
+
label: string;
|
|
22
|
+
hint?: string;
|
|
23
|
+
url?: string;
|
|
24
|
+
url_label?: string;
|
|
25
|
+
secret?: boolean;
|
|
26
|
+
required?: boolean;
|
|
27
|
+
/** Override the auto-detected group (default = first target's connector). */
|
|
28
|
+
group?: string;
|
|
29
|
+
/** Pre-filled value shown in the input on first render. */
|
|
30
|
+
default?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface OnboardingState {
|
|
34
|
+
ok: boolean;
|
|
35
|
+
onboardingCompleted: boolean;
|
|
36
|
+
current: {
|
|
37
|
+
displayName: string;
|
|
38
|
+
displayEmail: string;
|
|
39
|
+
apiProfileIds: string[];
|
|
40
|
+
apiProfile: {
|
|
41
|
+
id: string; name: string;
|
|
42
|
+
provider: 'anthropic' | 'openai-compatible';
|
|
43
|
+
model: string; baseUrl: string;
|
|
44
|
+
apiKeySet: boolean;
|
|
45
|
+
} | null;
|
|
46
|
+
chatAgent: string;
|
|
47
|
+
defaultAgent: string;
|
|
48
|
+
agents: Array<{ id: string; name: string; tool: string; path: string; enabled: boolean }>;
|
|
49
|
+
projectRoots: string[];
|
|
50
|
+
};
|
|
51
|
+
detected_cli: Array<{ name: string; path: string; version: string }>;
|
|
52
|
+
template_prompts: Record<string, PromptDef>;
|
|
53
|
+
/** Per ${key} prompt: true = at least one target field already has a real value;
|
|
54
|
+
* leave blank to keep it. Backend's applyConnectors preserves existing on empty. */
|
|
55
|
+
prompt_values_set: Record<string, boolean>;
|
|
56
|
+
/** Per ${key}: which connector.field references it (for UI hint). */
|
|
57
|
+
prompt_targets: Record<string, Array<{ connector: string; field: string }>>;
|
|
58
|
+
suggested_pipelines: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface ApplyResultPhase {
|
|
62
|
+
phase: string;
|
|
63
|
+
ok: boolean;
|
|
64
|
+
error?: string;
|
|
65
|
+
detail?: any;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Banner ──────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
export function OnboardingBanner({ onOpen }: { onOpen: () => void }) {
|
|
71
|
+
return (
|
|
72
|
+
<div className="bg-[var(--accent)]/10 border-b border-[var(--accent)]/40 px-3 py-1.5 flex items-center justify-between text-[11px] text-[var(--text-primary)]">
|
|
73
|
+
<span>
|
|
74
|
+
<span className="font-medium">Welcome to Forge.</span>{' '}
|
|
75
|
+
Finish setup — identity, API key, connectors, pipelines (1 click).
|
|
76
|
+
</span>
|
|
77
|
+
<div className="flex items-center gap-2">
|
|
78
|
+
<button
|
|
79
|
+
onClick={onOpen}
|
|
80
|
+
className="text-[11px] px-2.5 py-0.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
|
|
81
|
+
>
|
|
82
|
+
Open setup →
|
|
83
|
+
</button>
|
|
84
|
+
<button
|
|
85
|
+
onClick={async () => {
|
|
86
|
+
await fetch('/api/onboarding', {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'Content-Type': 'application/json' },
|
|
89
|
+
body: JSON.stringify({ action: 'apply', payload: {} }),
|
|
90
|
+
});
|
|
91
|
+
window.location.reload();
|
|
92
|
+
}}
|
|
93
|
+
className="text-[10px] px-2 py-0.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
94
|
+
title="Mark setup as completed without changes"
|
|
95
|
+
>
|
|
96
|
+
Skip
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Drawer ──────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
const inputCls = 'w-full text-[11px] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]';
|
|
106
|
+
|
|
107
|
+
const PROVIDER_DEFAULTS: Record<string, { baseUrl: string; model: string }> = {
|
|
108
|
+
anthropic: { baseUrl: 'https://api.anthropic.com', model: 'claude-sonnet-4-6' },
|
|
109
|
+
deepseek: { baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat' },
|
|
110
|
+
openai: { baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
|
|
111
|
+
qwen: { baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-max' },
|
|
112
|
+
// LiteLLM proxy — baseUrl points to your LiteLLM instance (default
|
|
113
|
+
// local). Lets one token-cap profile front many backend models.
|
|
114
|
+
litellm: { baseUrl: 'http://127.0.0.1:4000/v1', model: 'gpt-4o-mini' },
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void; onComplete: () => void }) {
|
|
118
|
+
const [state, setState] = useState<OnboardingState | null>(null);
|
|
119
|
+
|
|
120
|
+
// Section 1: Identity
|
|
121
|
+
const [displayName, setDisplayName] = useState('');
|
|
122
|
+
const [displayEmail, setDisplayEmail] = useState('');
|
|
123
|
+
|
|
124
|
+
// Section 2: API Profile
|
|
125
|
+
const [apiProfileId, setApiProfileId] = useState('deepseek');
|
|
126
|
+
const [apiProvider, setApiProvider] = useState<'anthropic' | 'openai-compatible'>('openai-compatible');
|
|
127
|
+
const [apiBaseUrl, setApiBaseUrl] = useState(PROVIDER_DEFAULTS.deepseek.baseUrl);
|
|
128
|
+
const [apiModel, setApiModel] = useState(PROVIDER_DEFAULTS.deepseek.model);
|
|
129
|
+
const [apiKey, setApiKey] = useState('');
|
|
130
|
+
const [apiKeyExisting, setApiKeyExisting] = useState(false); // true → "leave blank to keep current"
|
|
131
|
+
const [apiSetAsDefault, setApiSetAsDefault] = useState(true);
|
|
132
|
+
|
|
133
|
+
// Section 2.5: CLI Agent
|
|
134
|
+
const [cliAgentId, setCliAgentId] = useState(''); // '' = don't configure
|
|
135
|
+
const [cliAgentTool, setCliAgentTool] = useState<'claude' | 'codex' | 'aider' | 'opencode'>('claude');
|
|
136
|
+
const [cliAgentPath, setCliAgentPath] = useState('');
|
|
137
|
+
const [cliSetAsDefault, setCliSetAsDefault] = useState(true);
|
|
138
|
+
|
|
139
|
+
// Section 3: Connector template values (one per ${key})
|
|
140
|
+
const [connectorValues, setConnectorValues] = useState<Record<string, string>>({});
|
|
141
|
+
|
|
142
|
+
// Section 4: Pipelines
|
|
143
|
+
const [selectedPipelines, setSelectedPipelines] = useState<Set<string>>(new Set());
|
|
144
|
+
|
|
145
|
+
// Section 5: Projects
|
|
146
|
+
const [projectInput, setProjectInput] = useState('');
|
|
147
|
+
// Folder picker — uses Forge's existing /api/fs/browse endpoint.
|
|
148
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
149
|
+
const [pickerPath, setPickerPath] = useState('');
|
|
150
|
+
const [pickerEntries, setPickerEntries] = useState<Array<{ name: string; path: string }>>([]);
|
|
151
|
+
const [pickerParent, setPickerParent] = useState<string | null>(null);
|
|
152
|
+
|
|
153
|
+
async function openPicker(path?: string) {
|
|
154
|
+
setPickerOpen(true);
|
|
155
|
+
try {
|
|
156
|
+
const url = path ? `/api/fs/browse?path=${encodeURIComponent(path)}` : '/api/fs/browse';
|
|
157
|
+
const r = await fetch(url);
|
|
158
|
+
const j = await r.json();
|
|
159
|
+
if (j.ok) {
|
|
160
|
+
setPickerPath(j.path);
|
|
161
|
+
setPickerEntries(j.entries || []);
|
|
162
|
+
setPickerParent(j.parent || null);
|
|
163
|
+
}
|
|
164
|
+
} catch { /* ignore */ }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function addPickedPath(path: string) {
|
|
168
|
+
const lines = projectInput.split('\n').map(s => s.trim()).filter(Boolean);
|
|
169
|
+
if (!lines.includes(path)) lines.push(path);
|
|
170
|
+
setProjectInput(lines.join('\n'));
|
|
171
|
+
setPickerOpen(false);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Apply state
|
|
175
|
+
const [applying, setApplying] = useState(false);
|
|
176
|
+
const [result, setResult] = useState<ApplyResultPhase[] | null>(null);
|
|
177
|
+
|
|
178
|
+
// Post-apply health-check step. Once apply succeeds we slide the
|
|
179
|
+
// drawer into a "checks" view that runs glab-sync / login-status /
|
|
180
|
+
// backend monitor probes — so users see what's actually live before
|
|
181
|
+
// we dismiss the wizard.
|
|
182
|
+
type CheckState = { status: 'pending' | 'running' | 'ok' | 'fail'; message?: string; detail?: any };
|
|
183
|
+
const [phase, setPhase] = useState<'form' | 'checks'>('form');
|
|
184
|
+
const [checks, setChecks] = useState<Record<string, CheckState>>({
|
|
185
|
+
glabCli: { status: 'pending' },
|
|
186
|
+
loginStatus: { status: 'pending' },
|
|
187
|
+
services: { status: 'pending' },
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
async function runChecks() {
|
|
191
|
+
// glab sync — only meaningful if gitlab connector enabled w/ a token.
|
|
192
|
+
setChecks(c => ({ ...c, glabCli: { status: 'running' } }));
|
|
193
|
+
fetch('/api/connectors/gitlab/sync-cli', { method: 'POST' })
|
|
194
|
+
.then(r => r.json().then(j => ({ ok: r.ok && j.ok !== false, j })))
|
|
195
|
+
.then(({ ok, j }) => setChecks(c => ({
|
|
196
|
+
...c,
|
|
197
|
+
glabCli: {
|
|
198
|
+
status: ok ? 'ok' : 'fail',
|
|
199
|
+
message: ok ? 'glab CLI synced with GitLab token' : (j.error || j.stderr || 'sync failed'),
|
|
200
|
+
detail: j,
|
|
201
|
+
},
|
|
202
|
+
})))
|
|
203
|
+
.catch(e => setChecks(c => ({ ...c, glabCli: { status: 'fail', message: e.message } })));
|
|
204
|
+
|
|
205
|
+
// Login status — POST triggers fresh check across all sources.
|
|
206
|
+
setChecks(c => ({ ...c, loginStatus: { status: 'running' } }));
|
|
207
|
+
fetch('/api/login-status', { method: 'POST' })
|
|
208
|
+
.then(r => r.json())
|
|
209
|
+
.then(j => {
|
|
210
|
+
const rows = j.rows || j.results || [];
|
|
211
|
+
const okCount = rows.filter((r: any) => r.result?.ok).length;
|
|
212
|
+
const failCount = rows.length - okCount;
|
|
213
|
+
setChecks(c => ({
|
|
214
|
+
...c,
|
|
215
|
+
loginStatus: {
|
|
216
|
+
status: rows.length === 0 ? 'ok' : (failCount === 0 ? 'ok' : 'fail'),
|
|
217
|
+
message: rows.length === 0
|
|
218
|
+
? 'no sources configured yet'
|
|
219
|
+
: `${okCount}/${rows.length} sources ok` + (failCount ? `, ${failCount} need attention` : ''),
|
|
220
|
+
detail: rows,
|
|
221
|
+
},
|
|
222
|
+
}));
|
|
223
|
+
})
|
|
224
|
+
.catch(e => setChecks(c => ({ ...c, loginStatus: { status: 'fail', message: e.message } })));
|
|
225
|
+
|
|
226
|
+
// Backend processes via /api/monitor.
|
|
227
|
+
setChecks(c => ({ ...c, services: { status: 'running' } }));
|
|
228
|
+
fetch('/api/monitor')
|
|
229
|
+
.then(r => r.json())
|
|
230
|
+
.then(j => {
|
|
231
|
+
const procs = j.processes || j;
|
|
232
|
+
const expected = ['nextjs', 'terminal', 'workspace', 'chat'];
|
|
233
|
+
const down = expected.filter(k => !procs[k]?.running);
|
|
234
|
+
setChecks(c => ({
|
|
235
|
+
...c,
|
|
236
|
+
services: {
|
|
237
|
+
status: down.length === 0 ? 'ok' : 'fail',
|
|
238
|
+
message: down.length === 0 ? 'core services running' : `down: ${down.join(', ')}`,
|
|
239
|
+
detail: procs,
|
|
240
|
+
},
|
|
241
|
+
}));
|
|
242
|
+
})
|
|
243
|
+
.catch(e => setChecks(c => ({ ...c, services: { status: 'fail', message: e.message } })));
|
|
244
|
+
}
|
|
245
|
+
// Dirty tracking — flip true on any input touch. Guards close.
|
|
246
|
+
// Suppress the first effect run (initial state hydration from API).
|
|
247
|
+
const [dirty, setDirty] = useState(false);
|
|
248
|
+
const initializedRef = useRef(false);
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
if (!initializedRef.current) { initializedRef.current = true; return; }
|
|
251
|
+
setDirty(true);
|
|
252
|
+
}, [displayName, displayEmail, apiKey, apiBaseUrl, apiModel, apiProfileId, apiProvider,
|
|
253
|
+
connectorValues, selectedPipelines, projectInput, cliAgentId, cliAgentPath]);
|
|
254
|
+
// Inline marketplace sync (so user doesn't have to leave the wizard
|
|
255
|
+
// to populate the pipeline list when it's empty / stale).
|
|
256
|
+
const [syncingMarket, setSyncingMarket] = useState(false);
|
|
257
|
+
const [syncMsg, setSyncMsg] = useState<string>('');
|
|
258
|
+
|
|
259
|
+
async function syncMarketplaceAndReload() {
|
|
260
|
+
if (syncingMarket) return;
|
|
261
|
+
setSyncingMarket(true); setSyncMsg('');
|
|
262
|
+
try {
|
|
263
|
+
const r = await fetch('/api/workflows/marketplace', {
|
|
264
|
+
method: 'POST',
|
|
265
|
+
headers: { 'Content-Type': 'application/json' },
|
|
266
|
+
body: JSON.stringify({ action: 'sync' }),
|
|
267
|
+
});
|
|
268
|
+
const j = await r.json();
|
|
269
|
+
if (!r.ok || j.ok === false) {
|
|
270
|
+
setSyncMsg(`sync failed: ${j.error || r.status}`);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
// Pull fresh onboarding state (which re-computes suggested_pipelines).
|
|
274
|
+
const s = await fetch('/api/onboarding').then(x => x.json()) as OnboardingState;
|
|
275
|
+
setState(s);
|
|
276
|
+
// Auto-select fortinet-* (existing user selections preserved).
|
|
277
|
+
setSelectedPipelines(prev => {
|
|
278
|
+
const next = new Set(prev);
|
|
279
|
+
for (const n of s.suggested_pipelines) next.add(n);
|
|
280
|
+
return next;
|
|
281
|
+
});
|
|
282
|
+
setSyncMsg(`synced — ${s.suggested_pipelines.length} fortinet-* pipeline(s) suggested`);
|
|
283
|
+
} catch (e) {
|
|
284
|
+
setSyncMsg(`sync failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
285
|
+
} finally { setSyncingMarket(false); }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ─── Load state ─────────────────────────────────────────
|
|
289
|
+
// detect-cli is a separate (slower) endpoint — fire it in parallel
|
|
290
|
+
// so the main state arrives instantly, CLI list fills in shortly
|
|
291
|
+
// after. Avoids blocking the wizard mount on `which claude` etc.
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
Promise.all([
|
|
294
|
+
fetch('/api/onboarding').then(r => r.json()),
|
|
295
|
+
fetch('/api/onboarding/detect-cli').then(r => r.json()).catch(() => ({ detected: [] })),
|
|
296
|
+
]).then(([s, d]: [OnboardingState, { detected: typeof s.detected_cli }]) => {
|
|
297
|
+
// Merge probe results into state
|
|
298
|
+
s.detected_cli = d.detected || [];
|
|
299
|
+
setState(s);
|
|
300
|
+
setDisplayName(s.current.displayName);
|
|
301
|
+
setDisplayEmail(s.current.displayEmail);
|
|
302
|
+
setSelectedPipelines(new Set(s.suggested_pipelines));
|
|
303
|
+
|
|
304
|
+
// Pre-fill any prompt that declares a `default` (e.g. jenkins
|
|
305
|
+
// instance name = "default-jenkins"). User-set values from a prior
|
|
306
|
+
// run would shadow these via the "currently set" path on apply.
|
|
307
|
+
const presets: Record<string, string> = {};
|
|
308
|
+
for (const [key, def] of Object.entries(s.template_prompts || {})) {
|
|
309
|
+
if (def?.default && !s.prompt_values_set?.[key]) {
|
|
310
|
+
presets[key] = def.default;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (Object.keys(presets).length > 0) setConnectorValues(presets);
|
|
314
|
+
|
|
315
|
+
// Pre-fill API profile from existing default (settings.chatAgent)
|
|
316
|
+
if (s.current.apiProfile) {
|
|
317
|
+
const a = s.current.apiProfile;
|
|
318
|
+
setApiProfileId(a.id);
|
|
319
|
+
setApiProvider(a.provider);
|
|
320
|
+
setApiBaseUrl(a.baseUrl);
|
|
321
|
+
setApiModel(a.model);
|
|
322
|
+
setApiKeyExisting(a.apiKeySet);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Pre-fill CLI agent: pick existing default, else first detected
|
|
326
|
+
if (s.current.defaultAgent && s.current.agents.find(a => a.id === s.current.defaultAgent)) {
|
|
327
|
+
const a = s.current.agents.find(x => x.id === s.current.defaultAgent)!;
|
|
328
|
+
setCliAgentId(a.id);
|
|
329
|
+
if (['claude', 'codex', 'aider', 'opencode'].includes(a.tool)) {
|
|
330
|
+
setCliAgentTool(a.tool as any);
|
|
331
|
+
}
|
|
332
|
+
setCliAgentPath(a.path || '');
|
|
333
|
+
} else if (s.detected_cli.length > 0) {
|
|
334
|
+
const first = s.detected_cli[0];
|
|
335
|
+
setCliAgentId(first.name);
|
|
336
|
+
setCliAgentTool(first.name as any);
|
|
337
|
+
setCliAgentPath(first.path);
|
|
338
|
+
}
|
|
339
|
+
}).catch(() => setState({
|
|
340
|
+
ok: false, onboardingCompleted: false,
|
|
341
|
+
current: { displayName: '', displayEmail: '', apiProfileIds: [], apiProfile: null, chatAgent: '', defaultAgent: '', agents: [], projectRoots: [] },
|
|
342
|
+
detected_cli: [], template_prompts: {}, prompt_values_set: {}, prompt_targets: {}, suggested_pipelines: [],
|
|
343
|
+
}));
|
|
344
|
+
}, []);
|
|
345
|
+
|
|
346
|
+
// Quick provider preset switch
|
|
347
|
+
function setProviderPreset(key: keyof typeof PROVIDER_DEFAULTS) {
|
|
348
|
+
setApiProfileId(key);
|
|
349
|
+
const p = PROVIDER_DEFAULTS[key];
|
|
350
|
+
setApiBaseUrl(p.baseUrl);
|
|
351
|
+
setApiModel(p.model);
|
|
352
|
+
setApiProvider(key === 'anthropic' ? 'anthropic' : 'openai-compatible');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function apply() {
|
|
356
|
+
if (applying) return;
|
|
357
|
+
setApplying(true); setResult(null);
|
|
358
|
+
try {
|
|
359
|
+
const payload: any = {
|
|
360
|
+
identity: { displayName, displayEmail },
|
|
361
|
+
connectorValues,
|
|
362
|
+
pipelines: Array.from(selectedPipelines),
|
|
363
|
+
projectRoots: projectInput.split(/[\n,]/).map(s => s.trim()).filter(Boolean),
|
|
364
|
+
};
|
|
365
|
+
// Send apiProfile if a key was typed OR if we're re-confirming an
|
|
366
|
+
// existing profile (apiKeyExisting=true and user changed any field).
|
|
367
|
+
// Empty apiKey + existing → backend preserves the on-disk key.
|
|
368
|
+
if (apiKey.trim() || apiKeyExisting) {
|
|
369
|
+
payload.apiProfile = {
|
|
370
|
+
id: apiProfileId,
|
|
371
|
+
name: apiProfileId,
|
|
372
|
+
provider: apiProvider,
|
|
373
|
+
model: apiModel,
|
|
374
|
+
baseUrl: apiBaseUrl,
|
|
375
|
+
setAsDefault: apiSetAsDefault,
|
|
376
|
+
...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
if (cliAgentId) {
|
|
380
|
+
payload.cliAgent = {
|
|
381
|
+
id: cliAgentId,
|
|
382
|
+
tool: cliAgentTool,
|
|
383
|
+
...(cliAgentPath ? { path: cliAgentPath } : {}),
|
|
384
|
+
setAsDefault: cliSetAsDefault,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
const r = await fetch('/api/onboarding', {
|
|
388
|
+
method: 'POST',
|
|
389
|
+
headers: { 'Content-Type': 'application/json' },
|
|
390
|
+
body: JSON.stringify({ action: 'apply', payload }),
|
|
391
|
+
});
|
|
392
|
+
const j = await r.json();
|
|
393
|
+
setResult(j.phases || []);
|
|
394
|
+
if (j.ok) {
|
|
395
|
+
setDirty(false); // applied state is canonical now
|
|
396
|
+
setPhase('checks');
|
|
397
|
+
runChecks();
|
|
398
|
+
}
|
|
399
|
+
} catch (e) {
|
|
400
|
+
setResult([{ phase: 'request', ok: false, error: e instanceof Error ? e.message : String(e) }]);
|
|
401
|
+
} finally { setApplying(false); }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (!state) {
|
|
405
|
+
return (
|
|
406
|
+
<DrawerShell onClose={onClose}>
|
|
407
|
+
<div className="p-4 text-[11px] text-[var(--text-secondary)]">Loading…</div>
|
|
408
|
+
</DrawerShell>
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const prompts = state.template_prompts || {};
|
|
413
|
+
// Group prompts by the prompt's own `group` field (if set) — else
|
|
414
|
+
// fall back to the first connector target. Within a group, preserve
|
|
415
|
+
// template insertion order (gives template authors control over UI
|
|
416
|
+
// order; e.g. put 'gitlab token name' above 'gitlab PAT' just by
|
|
417
|
+
// listing it first in _prompts).
|
|
418
|
+
const promptGroups = (() => {
|
|
419
|
+
const byConnector = new Map<string, string[]>();
|
|
420
|
+
for (const key of Object.keys(prompts)) {
|
|
421
|
+
const explicit = prompts[key]?.group;
|
|
422
|
+
const targets = state.prompt_targets?.[key] || [];
|
|
423
|
+
const conn = explicit || targets[0]?.connector || '_other';
|
|
424
|
+
if (!byConnector.has(conn)) byConnector.set(conn, []);
|
|
425
|
+
byConnector.get(conn)!.push(key);
|
|
426
|
+
}
|
|
427
|
+
return Array.from(byConnector.entries());
|
|
428
|
+
})();
|
|
429
|
+
|
|
430
|
+
const guardedClose = () => {
|
|
431
|
+
if (!dirty || confirm('Close without saving? Your unsaved inputs will be lost.')) onClose();
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
if (phase === 'checks') {
|
|
435
|
+
return (
|
|
436
|
+
<DrawerShell onClose={onClose}>
|
|
437
|
+
<div className="overflow-y-auto flex-1 p-4 space-y-4">
|
|
438
|
+
<h2 className="text-sm font-medium text-[var(--text-primary)]">Setup applied — verifying</h2>
|
|
439
|
+
<p className="text-[11px] text-[var(--text-secondary)]">
|
|
440
|
+
Running post-setup checks. You can finish anytime; failures here usually
|
|
441
|
+
just mean the corresponding token/connector wasn't configured.
|
|
442
|
+
</p>
|
|
443
|
+
|
|
444
|
+
<CheckRow
|
|
445
|
+
title="GitLab → glab CLI sync"
|
|
446
|
+
hint="Writes the GitLab PAT into ~/.config/glab-cli so shell `glab` matches Forge."
|
|
447
|
+
state={checks.glabCli}
|
|
448
|
+
/>
|
|
449
|
+
<CheckRow
|
|
450
|
+
title="Connector login status"
|
|
451
|
+
hint="Probes each configured connector source (gitlab, mantis, etc.) for live auth."
|
|
452
|
+
state={checks.loginStatus}
|
|
453
|
+
renderDetail={d => Array.isArray(d) ? (
|
|
454
|
+
<ul className="space-y-0.5">
|
|
455
|
+
{d.map((row: any, i: number) => {
|
|
456
|
+
const ok = !!row.result?.ok;
|
|
457
|
+
const ref = row.source?.refresh;
|
|
458
|
+
return (
|
|
459
|
+
<li key={i} className="font-mono flex items-center gap-1 flex-wrap">
|
|
460
|
+
<span className={ok ? 'text-emerald-500' : 'text-red-400'}>
|
|
461
|
+
{ok ? '✓' : '✗'}
|
|
462
|
+
</span>
|
|
463
|
+
<span>{row.source?.label || row.source?.id || 'source'}</span>
|
|
464
|
+
{row.result?.message && (
|
|
465
|
+
<span className="text-[var(--text-secondary)]">— {row.result.message}</span>
|
|
466
|
+
)}
|
|
467
|
+
{/* Render a direct fix link only when the row failed */}
|
|
468
|
+
{!ok && ref?.kind === 'open-url' && ref.url && (
|
|
469
|
+
<a
|
|
470
|
+
href={ref.url}
|
|
471
|
+
target="_blank"
|
|
472
|
+
rel="noopener noreferrer"
|
|
473
|
+
className="text-[var(--accent)] hover:underline ml-auto"
|
|
474
|
+
title={ref.description || ref.url}
|
|
475
|
+
>
|
|
476
|
+
↗ login
|
|
477
|
+
</a>
|
|
478
|
+
)}
|
|
479
|
+
{!ok && ref?.kind === 'show-command' && (
|
|
480
|
+
<code
|
|
481
|
+
className="text-[var(--accent)] cursor-pointer ml-auto"
|
|
482
|
+
title={`Click to copy — ${ref.description}`}
|
|
483
|
+
onClick={() => navigator.clipboard?.writeText(ref.command)}
|
|
484
|
+
>
|
|
485
|
+
⧉ {ref.command}
|
|
486
|
+
</code>
|
|
487
|
+
)}
|
|
488
|
+
{!ok && ref?.kind === 'open-settings' && (
|
|
489
|
+
<span className="text-[var(--text-secondary)] ml-auto">
|
|
490
|
+
(configure in Settings → {ref.section})
|
|
491
|
+
</span>
|
|
492
|
+
)}
|
|
493
|
+
</li>
|
|
494
|
+
);
|
|
495
|
+
})}
|
|
496
|
+
</ul>
|
|
497
|
+
) : null}
|
|
498
|
+
/>
|
|
499
|
+
<CheckRow
|
|
500
|
+
title="Backend services"
|
|
501
|
+
hint="Web / terminal / workspace / chat process counts (per ps aux)."
|
|
502
|
+
state={checks.services}
|
|
503
|
+
renderDetail={d => d && typeof d === 'object' ? (
|
|
504
|
+
<ul className="space-y-0.5">
|
|
505
|
+
{Object.entries(d).map(([k, v]: [string, any]) => (
|
|
506
|
+
<li key={k} className="font-mono">
|
|
507
|
+
<span className={v?.running ? 'text-emerald-500' : 'text-red-400'}>
|
|
508
|
+
{v?.running ? '✓' : '✗'}
|
|
509
|
+
</span>{' '}
|
|
510
|
+
{k}{v?.pid ? ` (pid ${v.pid})` : ''}{v?.port ? `:${v.port}` : ''}
|
|
511
|
+
</li>
|
|
512
|
+
))}
|
|
513
|
+
</ul>
|
|
514
|
+
) : null}
|
|
515
|
+
/>
|
|
516
|
+
</div>
|
|
517
|
+
|
|
518
|
+
<div className="border-t border-[var(--border)] p-3 flex items-center gap-2 shrink-0">
|
|
519
|
+
<button
|
|
520
|
+
onClick={runChecks}
|
|
521
|
+
className="text-[10px] px-2.5 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:border-[var(--text-primary)]"
|
|
522
|
+
>
|
|
523
|
+
↻ Re-run checks
|
|
524
|
+
</button>
|
|
525
|
+
<div className="flex-1" />
|
|
526
|
+
<button
|
|
527
|
+
onClick={onComplete}
|
|
528
|
+
className="text-[11px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90"
|
|
529
|
+
>
|
|
530
|
+
Finish
|
|
531
|
+
</button>
|
|
532
|
+
</div>
|
|
533
|
+
</DrawerShell>
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return (
|
|
538
|
+
<DrawerShell onClose={guardedClose}>
|
|
539
|
+
<div className="overflow-y-auto flex-1 p-4 space-y-5">
|
|
540
|
+
<h2 className="text-sm font-medium text-[var(--text-primary)]">Forge Setup</h2>
|
|
541
|
+
<p className="text-[11px] text-[var(--text-secondary)] -mt-3">
|
|
542
|
+
Fill in once. Re-runnable from Settings → "Re-run Onboarding".
|
|
543
|
+
</p>
|
|
544
|
+
|
|
545
|
+
{/* ── 1. Identity ──────────────────────────────────────── */}
|
|
546
|
+
<Section title="1. Identity" hint="Used as your name in pipeline/connector contexts; referenceable later as {user.name} / {user.email}.">
|
|
547
|
+
<Field label="Display name">
|
|
548
|
+
<input className={inputCls} value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder="Zhen Liu" />
|
|
549
|
+
</Field>
|
|
550
|
+
<Field label="Email">
|
|
551
|
+
<input className={inputCls} value={displayEmail} onChange={e => setDisplayEmail(e.target.value)} placeholder="zliu@fortinet.com" />
|
|
552
|
+
</Field>
|
|
553
|
+
</Section>
|
|
554
|
+
|
|
555
|
+
{/* ── 2. API Profile ───────────────────────────────────── */}
|
|
556
|
+
<Section title="2. Chat API key" hint="The LLM Forge's chat agent talks to. DeepSeek / Anthropic / OpenAI / Qwen / LiteLLM-compatible. Key encrypted at rest.">
|
|
557
|
+
<div className="flex gap-1 mb-1 flex-wrap">
|
|
558
|
+
{(['deepseek', 'anthropic', 'openai', 'qwen', 'litellm'] as const).map(p => (
|
|
559
|
+
<button
|
|
560
|
+
key={p}
|
|
561
|
+
onClick={() => setProviderPreset(p)}
|
|
562
|
+
className={`text-[10px] px-2 py-0.5 rounded ${apiProfileId === p ? 'bg-[var(--accent)] text-white' : 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'}`}
|
|
563
|
+
>
|
|
564
|
+
{p}
|
|
565
|
+
</button>
|
|
566
|
+
))}
|
|
567
|
+
</div>
|
|
568
|
+
<Field label="Base URL">
|
|
569
|
+
<input className={inputCls + ' font-mono'} value={apiBaseUrl} onChange={e => setApiBaseUrl(e.target.value)} />
|
|
570
|
+
</Field>
|
|
571
|
+
<Field label="Model">
|
|
572
|
+
<input className={inputCls + ' font-mono'} value={apiModel} onChange={e => setApiModel(e.target.value)} />
|
|
573
|
+
</Field>
|
|
574
|
+
<Field label={apiKeyExisting ? `API Key (current profile "${apiProfileId}" already has a key — leave blank to keep, or paste new to overwrite)` : 'API Key (required to make chat work)'}>
|
|
575
|
+
<input
|
|
576
|
+
type="password"
|
|
577
|
+
className={inputCls + ' font-mono'}
|
|
578
|
+
value={apiKey}
|
|
579
|
+
onChange={e => setApiKey(e.target.value)}
|
|
580
|
+
placeholder={apiKeyExisting ? '•••••••• (keep existing)' : 'sk-...'}
|
|
581
|
+
/>
|
|
582
|
+
</Field>
|
|
583
|
+
<label className="flex items-center gap-1.5 text-[10px] text-[var(--text-secondary)]">
|
|
584
|
+
<input type="checkbox" checked={apiSetAsDefault} onChange={e => setApiSetAsDefault(e.target.checked)} />
|
|
585
|
+
Use this profile as the default chat agent
|
|
586
|
+
</label>
|
|
587
|
+
</Section>
|
|
588
|
+
|
|
589
|
+
{/* ── 2.5. CLI Agent ──────────────────────────────────── */}
|
|
590
|
+
<Section title="3. CLI Agent" hint="The CLI tool Forge launches for terminal / task sessions (claude-code / codex / aider). Detected on PATH below.">
|
|
591
|
+
{state.detected_cli.length === 0 && state.current.agents.length === 0 && (
|
|
592
|
+
<p className="text-[10px] text-amber-500">
|
|
593
|
+
No CLI agents detected on PATH. Install one (e.g. <code>npm i -g @anthropic-ai/claude-code</code>) and re-run onboarding.
|
|
594
|
+
</p>
|
|
595
|
+
)}
|
|
596
|
+
{state.detected_cli.map(c => (
|
|
597
|
+
<label key={c.name} className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]">
|
|
598
|
+
<input
|
|
599
|
+
type="radio"
|
|
600
|
+
name="cli-agent"
|
|
601
|
+
checked={cliAgentId === c.name}
|
|
602
|
+
onChange={() => {
|
|
603
|
+
setCliAgentId(c.name);
|
|
604
|
+
setCliAgentTool(c.name as any);
|
|
605
|
+
setCliAgentPath(c.path);
|
|
606
|
+
}}
|
|
607
|
+
/>
|
|
608
|
+
<span className="font-mono">{c.name}</span>
|
|
609
|
+
{c.version && <span className="text-[9px] text-[var(--text-secondary)]">v{c.version}</span>}
|
|
610
|
+
<span className="text-[9px] text-[var(--text-secondary)] font-mono truncate">{c.path}</span>
|
|
611
|
+
</label>
|
|
612
|
+
))}
|
|
613
|
+
{state.current.agents.filter(a => !state.detected_cli.find(c => c.name === a.tool)).map(a => (
|
|
614
|
+
<label key={a.id} className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]">
|
|
615
|
+
<input
|
|
616
|
+
type="radio"
|
|
617
|
+
name="cli-agent"
|
|
618
|
+
checked={cliAgentId === a.id}
|
|
619
|
+
onChange={() => {
|
|
620
|
+
setCliAgentId(a.id);
|
|
621
|
+
if (['claude', 'codex', 'aider', 'opencode'].includes(a.tool)) {
|
|
622
|
+
setCliAgentTool(a.tool as any);
|
|
623
|
+
}
|
|
624
|
+
setCliAgentPath(a.path);
|
|
625
|
+
}}
|
|
626
|
+
/>
|
|
627
|
+
<span className="font-mono">{a.id}</span>
|
|
628
|
+
<span className="text-[9px] text-[var(--text-secondary)]">{a.tool}</span>
|
|
629
|
+
<span className="text-[9px] text-[var(--text-secondary)] font-mono truncate">{a.path}</span>
|
|
630
|
+
</label>
|
|
631
|
+
))}
|
|
632
|
+
{(state.detected_cli.length > 0 || state.current.agents.length > 0) && (
|
|
633
|
+
<label className="flex items-center gap-1.5 text-[10px] text-[var(--text-secondary)] mt-1">
|
|
634
|
+
<input type="checkbox" checked={cliSetAsDefault} onChange={e => setCliSetAsDefault(e.target.checked)} />
|
|
635
|
+
Use this CLI agent as the default for terminal sessions
|
|
636
|
+
</label>
|
|
637
|
+
)}
|
|
638
|
+
</Section>
|
|
639
|
+
|
|
640
|
+
{/* ── 3. Connectors ────────────────────────────────────── */}
|
|
641
|
+
<Section title="3. Connector tokens" hint="Forge ships a Fortinet template. Shared keys (e.g. GitLab PAT for both gitlab + jenkins) are asked once. Tokens encrypted at rest.">
|
|
642
|
+
{promptGroups.length === 0 && (
|
|
643
|
+
<p className="text-[10px] text-amber-500">
|
|
644
|
+
No template loaded. Make sure the marketplace has been synced (Settings → Connectors → Sync).
|
|
645
|
+
</p>
|
|
646
|
+
)}
|
|
647
|
+
{promptGroups.map(([connector, keys]) => (
|
|
648
|
+
<div key={connector} className="border-l-2 border-[var(--accent)]/40 pl-2.5 space-y-2.5">
|
|
649
|
+
<div className="text-[10px] font-mono uppercase tracking-wider text-[var(--accent)]">
|
|
650
|
+
{connector}
|
|
651
|
+
</div>
|
|
652
|
+
{keys.map(key => {
|
|
653
|
+
const p = prompts[key];
|
|
654
|
+
const v = connectorValues[key] ?? '';
|
|
655
|
+
const isSet = !!state.prompt_values_set?.[key];
|
|
656
|
+
const targets = state.prompt_targets?.[key] || [];
|
|
657
|
+
const sharedWith = targets.filter(t => t.connector !== connector);
|
|
658
|
+
return (
|
|
659
|
+
<div key={key} className="space-y-1">
|
|
660
|
+
<label className="text-[10px] font-medium text-[var(--text-primary)] flex items-center gap-1.5 flex-wrap">
|
|
661
|
+
{p.label}
|
|
662
|
+
{p.required && !isSet && <span className="text-red-500">*</span>}
|
|
663
|
+
{!p.required && !isSet && <span className="text-[9px] text-[var(--text-secondary)]">(optional)</span>}
|
|
664
|
+
{isSet && <span className="text-[9px] text-emerald-500">● currently set</span>}
|
|
665
|
+
{p.url && (
|
|
666
|
+
<a href={p.url} target="_blank" rel="noopener noreferrer" className="ml-auto text-[10px] text-[var(--accent)] hover:underline" title={p.url}>
|
|
667
|
+
↗ {p.url_label || 'Get token'}
|
|
668
|
+
</a>
|
|
669
|
+
)}
|
|
670
|
+
</label>
|
|
671
|
+
{p.hint && <p className="text-[9px] text-[var(--text-secondary)] leading-snug">{p.hint}</p>}
|
|
672
|
+
{sharedWith.length > 0 && (
|
|
673
|
+
<p className="text-[9px] text-[var(--text-secondary)] font-mono">
|
|
674
|
+
also used by: {sharedWith.map(t => `${t.connector}.${t.field}`).join(', ')}
|
|
675
|
+
</p>
|
|
676
|
+
)}
|
|
677
|
+
<input
|
|
678
|
+
type={p.secret ? 'password' : 'text'}
|
|
679
|
+
className={inputCls + ' font-mono'}
|
|
680
|
+
value={v}
|
|
681
|
+
onChange={e => setConnectorValues({ ...connectorValues, [key]: e.target.value })}
|
|
682
|
+
placeholder={isSet ? '•••••••• (leave blank to keep current)' : (p.required ? 'required' : 'leave blank')}
|
|
683
|
+
/>
|
|
684
|
+
</div>
|
|
685
|
+
);
|
|
686
|
+
})}
|
|
687
|
+
</div>
|
|
688
|
+
))}
|
|
689
|
+
</Section>
|
|
690
|
+
|
|
691
|
+
{/* ── 4. Pipelines ─────────────────────────────────────── */}
|
|
692
|
+
<Section title="4. Pipelines" hint="Installs from the workflow marketplace. fortinet-* default-selected.">
|
|
693
|
+
<div className="flex items-center gap-2 mb-1">
|
|
694
|
+
<button
|
|
695
|
+
type="button"
|
|
696
|
+
onClick={syncMarketplaceAndReload}
|
|
697
|
+
disabled={syncingMarket}
|
|
698
|
+
className="text-[10px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white disabled:opacity-40"
|
|
699
|
+
>
|
|
700
|
+
{syncingMarket ? 'Syncing…' : '↻ Sync marketplace'}
|
|
701
|
+
</button>
|
|
702
|
+
{syncMsg && <span className="text-[10px] text-[var(--text-secondary)]">{syncMsg}</span>}
|
|
703
|
+
</div>
|
|
704
|
+
{state.suggested_pipelines.length === 0 && (
|
|
705
|
+
<p className="text-[10px] text-amber-500">
|
|
706
|
+
No pipelines found yet. Click <strong>Sync marketplace</strong> above to pull the latest registry.
|
|
707
|
+
</p>
|
|
708
|
+
)}
|
|
709
|
+
{state.suggested_pipelines.map(name => (
|
|
710
|
+
<label key={name} className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]">
|
|
711
|
+
<input
|
|
712
|
+
type="checkbox"
|
|
713
|
+
checked={selectedPipelines.has(name)}
|
|
714
|
+
onChange={e => {
|
|
715
|
+
const next = new Set(selectedPipelines);
|
|
716
|
+
if (e.target.checked) next.add(name); else next.delete(name);
|
|
717
|
+
setSelectedPipelines(next);
|
|
718
|
+
}}
|
|
719
|
+
/>
|
|
720
|
+
<span className="font-mono">{name}</span>
|
|
721
|
+
</label>
|
|
722
|
+
))}
|
|
723
|
+
</Section>
|
|
724
|
+
|
|
725
|
+
{/* ── 5. Projects ──────────────────────────────────────── */}
|
|
726
|
+
<Section title="5. Project roots (optional)" hint="Directories Forge can run agents in. Pick from filesystem or type/paste paths (one per line).">
|
|
727
|
+
{state.current.projectRoots.length > 0 && (
|
|
728
|
+
<p className="text-[10px] text-[var(--text-secondary)]">
|
|
729
|
+
Existing: <span className="font-mono">{state.current.projectRoots.join(', ')}</span> (new entries appended)
|
|
730
|
+
</p>
|
|
731
|
+
)}
|
|
732
|
+
<div className="flex items-center gap-2">
|
|
733
|
+
<button
|
|
734
|
+
type="button"
|
|
735
|
+
onClick={() => openPicker()}
|
|
736
|
+
className="text-[10px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white"
|
|
737
|
+
>
|
|
738
|
+
📁 Pick folder…
|
|
739
|
+
</button>
|
|
740
|
+
<span className="text-[9px] text-[var(--text-secondary)]">or type below</span>
|
|
741
|
+
</div>
|
|
742
|
+
<textarea
|
|
743
|
+
rows={2}
|
|
744
|
+
className={inputCls + ' font-mono'}
|
|
745
|
+
value={projectInput}
|
|
746
|
+
onChange={e => setProjectInput(e.target.value)}
|
|
747
|
+
placeholder="/Users/you/IdeaProjects"
|
|
748
|
+
/>
|
|
749
|
+
|
|
750
|
+
{pickerOpen && (
|
|
751
|
+
<div className="border border-[var(--border)] rounded p-2 bg-[var(--bg-tertiary)] space-y-1">
|
|
752
|
+
<div className="flex items-center gap-1 text-[10px] font-mono text-[var(--text-secondary)] truncate">
|
|
753
|
+
{pickerParent && (
|
|
754
|
+
<button onClick={() => openPicker(pickerParent!)} className="text-[var(--accent)] hover:underline">..</button>
|
|
755
|
+
)}
|
|
756
|
+
<span className="truncate">{pickerPath || '(loading)'}</span>
|
|
757
|
+
<button
|
|
758
|
+
onClick={() => setPickerOpen(false)}
|
|
759
|
+
className="ml-auto text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
760
|
+
>✕</button>
|
|
761
|
+
</div>
|
|
762
|
+
<div className="max-h-40 overflow-y-auto space-y-0.5">
|
|
763
|
+
{pickerEntries.length === 0 && (
|
|
764
|
+
<p className="text-[10px] text-[var(--text-secondary)] italic">no subdirectories</p>
|
|
765
|
+
)}
|
|
766
|
+
{pickerEntries.map(e => (
|
|
767
|
+
<div key={e.path} className="flex items-center gap-1 text-[10px] font-mono">
|
|
768
|
+
<button
|
|
769
|
+
onClick={() => openPicker(e.path)}
|
|
770
|
+
className="text-[var(--text-primary)] hover:text-[var(--accent)] flex-1 text-left truncate"
|
|
771
|
+
title="Drill into this folder"
|
|
772
|
+
>
|
|
773
|
+
📁 {e.name}
|
|
774
|
+
</button>
|
|
775
|
+
<button
|
|
776
|
+
onClick={() => addPickedPath(e.path)}
|
|
777
|
+
className="text-[9px] text-[var(--accent)] hover:underline px-1"
|
|
778
|
+
title="Add this folder to project roots"
|
|
779
|
+
>+ pick</button>
|
|
780
|
+
</div>
|
|
781
|
+
))}
|
|
782
|
+
</div>
|
|
783
|
+
{pickerPath && (
|
|
784
|
+
<button
|
|
785
|
+
onClick={() => addPickedPath(pickerPath)}
|
|
786
|
+
className="text-[10px] px-2 py-0.5 bg-[var(--accent)] text-white rounded w-full"
|
|
787
|
+
>
|
|
788
|
+
Use current folder: <span className="font-mono">{pickerPath}</span>
|
|
789
|
+
</button>
|
|
790
|
+
)}
|
|
791
|
+
</div>
|
|
792
|
+
)}
|
|
793
|
+
</Section>
|
|
794
|
+
|
|
795
|
+
{/* ── 6. Memory (info only) ────────────────────────────── */}
|
|
796
|
+
<Section title="6. Memory">
|
|
797
|
+
<p className="text-[10px] text-[var(--text-secondary)]">
|
|
798
|
+
Defaults to local SQLite — no setup needed. Want team-shared memory?
|
|
799
|
+
Configure Temper later in Settings → Memory.
|
|
800
|
+
</p>
|
|
801
|
+
</Section>
|
|
802
|
+
</div>
|
|
803
|
+
|
|
804
|
+
{/* Footer */}
|
|
805
|
+
<div className="border-t border-[var(--border)] p-3 flex items-center gap-2 shrink-0">
|
|
806
|
+
{result && (
|
|
807
|
+
<div className="flex-1 text-[10px]">
|
|
808
|
+
{result.map((p, i) => (
|
|
809
|
+
<span key={i} className={`mr-2 ${p.ok ? 'text-emerald-500' : 'text-red-400'}`}>
|
|
810
|
+
{p.ok ? '✓' : '✗'} {p.phase}
|
|
811
|
+
</span>
|
|
812
|
+
))}
|
|
813
|
+
</div>
|
|
814
|
+
)}
|
|
815
|
+
<button
|
|
816
|
+
onClick={() => { if (!dirty || confirm('Close without saving? Your unsaved inputs will be lost.')) onClose(); }}
|
|
817
|
+
className="text-[10px] px-2.5 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:border-[var(--text-primary)]"
|
|
818
|
+
>
|
|
819
|
+
Close
|
|
820
|
+
</button>
|
|
821
|
+
<button
|
|
822
|
+
onClick={async () => {
|
|
823
|
+
if (dirty && !confirm('Skip setup? Your unsaved inputs will be discarded. (Banner will not appear again.)')) return;
|
|
824
|
+
// Mark onboarding completed without applying anything — empty payload.
|
|
825
|
+
await fetch('/api/onboarding', {
|
|
826
|
+
method: 'POST',
|
|
827
|
+
headers: { 'Content-Type': 'application/json' },
|
|
828
|
+
body: JSON.stringify({ action: 'apply', payload: {} }),
|
|
829
|
+
});
|
|
830
|
+
onComplete();
|
|
831
|
+
}}
|
|
832
|
+
className="text-[10px] px-2.5 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
833
|
+
title="Mark setup as done without applying anything"
|
|
834
|
+
>
|
|
835
|
+
Skip
|
|
836
|
+
</button>
|
|
837
|
+
<button
|
|
838
|
+
onClick={apply}
|
|
839
|
+
disabled={applying}
|
|
840
|
+
className="text-[11px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-40"
|
|
841
|
+
>
|
|
842
|
+
{applying ? 'Applying…' : 'Apply & Finish'}
|
|
843
|
+
</button>
|
|
844
|
+
</div>
|
|
845
|
+
</DrawerShell>
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// ─── Drawer shell ────────────────────────────────────────────
|
|
850
|
+
|
|
851
|
+
function DrawerShell({ onClose, children }: { onClose: () => void; children: React.ReactNode }) {
|
|
852
|
+
return (
|
|
853
|
+
<>
|
|
854
|
+
{/* Backdrop — semi-opaque, click-to-close */}
|
|
855
|
+
<div className="fixed inset-0 bg-black/30 z-40" onClick={onClose} />
|
|
856
|
+
{/* Drawer */}
|
|
857
|
+
<div className="fixed right-0 top-0 bottom-0 w-[520px] max-w-full bg-[var(--bg-primary)] border-l border-[var(--border)] shadow-xl z-50 flex flex-col">
|
|
858
|
+
{children}
|
|
859
|
+
</div>
|
|
860
|
+
</>
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function Section({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
|
|
865
|
+
return (
|
|
866
|
+
<div className="space-y-1.5 pb-3 border-b border-[var(--border)]/40">
|
|
867
|
+
<h3 className="text-[12px] font-medium text-[var(--text-primary)]">{title}</h3>
|
|
868
|
+
{hint && <p className="text-[10px] text-[var(--text-secondary)] leading-snug">{hint}</p>}
|
|
869
|
+
{children}
|
|
870
|
+
</div>
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function CheckRow({
|
|
875
|
+
title, hint, state, renderDetail,
|
|
876
|
+
}: {
|
|
877
|
+
title: string;
|
|
878
|
+
hint?: string;
|
|
879
|
+
state: { status: 'pending' | 'running' | 'ok' | 'fail'; message?: string; detail?: any };
|
|
880
|
+
renderDetail?: (d: any) => React.ReactNode;
|
|
881
|
+
}) {
|
|
882
|
+
const [open, setOpen] = useState(false);
|
|
883
|
+
const icon = state.status === 'ok' ? '✓'
|
|
884
|
+
: state.status === 'fail' ? '✗'
|
|
885
|
+
: state.status === 'running' ? '⟳'
|
|
886
|
+
: '○';
|
|
887
|
+
const color = state.status === 'ok' ? 'text-emerald-500'
|
|
888
|
+
: state.status === 'fail' ? 'text-red-400'
|
|
889
|
+
: state.status === 'running' ? 'text-amber-500'
|
|
890
|
+
: 'text-[var(--text-secondary)]';
|
|
891
|
+
return (
|
|
892
|
+
<div className="border border-[var(--border)] rounded p-2.5 space-y-1">
|
|
893
|
+
<div className="flex items-center gap-2 text-[12px]">
|
|
894
|
+
<span className={`${color} font-mono w-3 inline-block`}>{icon}</span>
|
|
895
|
+
<span className="text-[var(--text-primary)] font-medium">{title}</span>
|
|
896
|
+
{state.message && (
|
|
897
|
+
<span className={`text-[10px] ${state.status === 'fail' ? 'text-red-400' : 'text-[var(--text-secondary)]'} truncate`}>
|
|
898
|
+
— {state.message}
|
|
899
|
+
</span>
|
|
900
|
+
)}
|
|
901
|
+
{state.detail && renderDetail && (
|
|
902
|
+
<button onClick={() => setOpen(o => !o)} className="ml-auto text-[10px] text-[var(--accent)] hover:underline">
|
|
903
|
+
{open ? 'hide' : 'details'}
|
|
904
|
+
</button>
|
|
905
|
+
)}
|
|
906
|
+
</div>
|
|
907
|
+
{hint && <p className="text-[10px] text-[var(--text-secondary)] leading-snug ml-5">{hint}</p>}
|
|
908
|
+
{open && state.detail && renderDetail && (
|
|
909
|
+
<div className="text-[10px] text-[var(--text-secondary)] ml-5 pt-1 border-t border-[var(--border)]/40">
|
|
910
|
+
{renderDetail(state.detail)}
|
|
911
|
+
</div>
|
|
912
|
+
)}
|
|
913
|
+
</div>
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
918
|
+
return (
|
|
919
|
+
<div className="space-y-0.5">
|
|
920
|
+
<label className="text-[10px] text-[var(--text-secondary)]">{label}</label>
|
|
921
|
+
{children}
|
|
922
|
+
</div>
|
|
923
|
+
);
|
|
924
|
+
}
|