@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
|
@@ -83,6 +83,35 @@ interface TestResult {
|
|
|
83
83
|
|
|
84
84
|
const SECRET_MASK = '••••••••';
|
|
85
85
|
|
|
86
|
+
// ─── Config template import ───────────────────────────────
|
|
87
|
+
|
|
88
|
+
interface TemplatePrompt {
|
|
89
|
+
key: string;
|
|
90
|
+
label: string;
|
|
91
|
+
hint?: string;
|
|
92
|
+
url?: string;
|
|
93
|
+
url_label?: string;
|
|
94
|
+
secret?: boolean;
|
|
95
|
+
required?: boolean;
|
|
96
|
+
targets: Array<{ connector: string; field_path: string }>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface TemplateModalState {
|
|
100
|
+
template: any;
|
|
101
|
+
pending: TemplatePrompt[];
|
|
102
|
+
static_apply_targets: string[];
|
|
103
|
+
missing_manifests: string[];
|
|
104
|
+
values: Record<string, string>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface TemplateApplyResult {
|
|
108
|
+
applied: string[];
|
|
109
|
+
enabled_changed: string[];
|
|
110
|
+
skipped_missing_manifest: string[];
|
|
111
|
+
fields_preserved: Array<{ connector: string; field: string; reason: string }>;
|
|
112
|
+
fields_left_empty: Array<{ connector: string; field: string }>;
|
|
113
|
+
}
|
|
114
|
+
|
|
86
115
|
export default function ConnectorsPanel() {
|
|
87
116
|
const [state, setState] = useState<MarketState | null>(null);
|
|
88
117
|
const [busyId, setBusyId] = useState('');
|
|
@@ -102,6 +131,16 @@ export default function ConnectorsPanel() {
|
|
|
102
131
|
const [dragOver, setDragOver] = useState(false);
|
|
103
132
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
104
133
|
|
|
134
|
+
// ─── Config template import ───────────────────────────────
|
|
135
|
+
// The "Import Template" button accepts a JSON file that pre-fills shared
|
|
136
|
+
// defaults (base URLs, etc.) and prompts the user once for each ${key}
|
|
137
|
+
// placeholder. Same key shared by multiple connectors → asked once.
|
|
138
|
+
const tplFileInputRef = useRef<HTMLInputElement>(null);
|
|
139
|
+
const [tplAnalyzing, setTplAnalyzing] = useState(false);
|
|
140
|
+
const [tplApplying, setTplApplying] = useState(false);
|
|
141
|
+
const [tplModal, setTplModal] = useState<TemplateModalState | null>(null);
|
|
142
|
+
const [tplResult, setTplResult] = useState<TemplateApplyResult | null>(null);
|
|
143
|
+
|
|
105
144
|
const [testing, setTesting] = useState(false);
|
|
106
145
|
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
|
107
146
|
|
|
@@ -153,6 +192,12 @@ export default function ConnectorsPanel() {
|
|
|
153
192
|
}).catch(() => {});
|
|
154
193
|
}, []);
|
|
155
194
|
|
|
195
|
+
// Forge ships a bundled connector template (templates/connector-config-template.json),
|
|
196
|
+
// so the Import button is always one-click. Users can override by
|
|
197
|
+
// dropping their own at <dataDir>/config-template.json — the GET
|
|
198
|
+
// endpoint picks the override automatically. No mount-time probe
|
|
199
|
+
// needed; we just always show the one-click button.
|
|
200
|
+
|
|
156
201
|
// ─── Actions ──────────────────────────────────────────────
|
|
157
202
|
async function sync() {
|
|
158
203
|
setSyncing(true); setError('');
|
|
@@ -229,6 +274,92 @@ export default function ConnectorsPanel() {
|
|
|
229
274
|
if (f) uploadFile(f);
|
|
230
275
|
}
|
|
231
276
|
|
|
277
|
+
// ─── Config template: one-click import (bundled or local override) ──
|
|
278
|
+
// Always hits GET — the server picks the user override at
|
|
279
|
+
// <dataDir>/config-template.json if present, else the bundled
|
|
280
|
+
// templates/connector-config-template.json that ships with Forge.
|
|
281
|
+
async function onClickImport() {
|
|
282
|
+
if (tplAnalyzing || tplApplying) return;
|
|
283
|
+
setTplAnalyzing(true); setError(''); setTplResult(null);
|
|
284
|
+
try {
|
|
285
|
+
const r = await fetch('/api/connectors/import-config-template');
|
|
286
|
+
const j = await r.json();
|
|
287
|
+
if (!r.ok || j.ok === false) {
|
|
288
|
+
setError(j.error || 'template load failed');
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (!j.pending?.length) {
|
|
292
|
+
await applyTemplate(j.template, {});
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
setTplModal({
|
|
296
|
+
template: j.template,
|
|
297
|
+
pending: j.pending,
|
|
298
|
+
static_apply_targets: j.static_apply_targets || [],
|
|
299
|
+
missing_manifests: j.missing_manifests || [],
|
|
300
|
+
values: {},
|
|
301
|
+
});
|
|
302
|
+
} catch (e) {
|
|
303
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
304
|
+
} finally { setTplAnalyzing(false); }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ─── Config template: analyze (file pick) ─────────────────
|
|
308
|
+
async function onPickTemplateFile(ev: React.ChangeEvent<HTMLInputElement>) {
|
|
309
|
+
const f = ev.target.files?.[0];
|
|
310
|
+
ev.target.value = '';
|
|
311
|
+
if (!f) return;
|
|
312
|
+
setTplAnalyzing(true); setError(''); setTplResult(null);
|
|
313
|
+
try {
|
|
314
|
+
const fd = new FormData();
|
|
315
|
+
fd.append('file', f);
|
|
316
|
+
const r = await fetch('/api/connectors/import-config-template', {
|
|
317
|
+
method: 'POST', body: fd,
|
|
318
|
+
});
|
|
319
|
+
const j = await r.json();
|
|
320
|
+
if (!r.ok || j.ok === false) {
|
|
321
|
+
setError(j.error || 'template analyze failed');
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
// If nothing to ask, apply directly (only static defaults).
|
|
325
|
+
if (!j.pending?.length) {
|
|
326
|
+
await applyTemplate(j.template, {});
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
setTplModal({
|
|
330
|
+
template: j.template,
|
|
331
|
+
pending: j.pending,
|
|
332
|
+
static_apply_targets: j.static_apply_targets || [],
|
|
333
|
+
missing_manifests: j.missing_manifests || [],
|
|
334
|
+
values: {},
|
|
335
|
+
});
|
|
336
|
+
} catch (e) {
|
|
337
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
338
|
+
} finally { setTplAnalyzing(false); }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function applyTemplate(template: any, values: Record<string, string>) {
|
|
342
|
+
setTplApplying(true); setError('');
|
|
343
|
+
try {
|
|
344
|
+
const r = await fetch('/api/connectors/import-config-template', {
|
|
345
|
+
method: 'POST',
|
|
346
|
+
headers: { 'Content-Type': 'application/json' },
|
|
347
|
+
body: JSON.stringify({ template, values }),
|
|
348
|
+
});
|
|
349
|
+
const j = await r.json();
|
|
350
|
+
if (!r.ok || j.ok === false) {
|
|
351
|
+
setError(j.error || 'template apply failed');
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
setTplModal(null);
|
|
355
|
+
setTplResult(j as TemplateApplyResult);
|
|
356
|
+
await refresh();
|
|
357
|
+
if (selectedId) await loadDetail(selectedId);
|
|
358
|
+
} catch (e) {
|
|
359
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
360
|
+
} finally { setTplApplying(false); }
|
|
361
|
+
}
|
|
362
|
+
|
|
232
363
|
async function runTest() {
|
|
233
364
|
if (!selectedId) return;
|
|
234
365
|
setTesting(true); setTestResult(null);
|
|
@@ -334,6 +465,31 @@ export default function ConnectorsPanel() {
|
|
|
334
465
|
onChange={onPickFile}
|
|
335
466
|
className="hidden"
|
|
336
467
|
/>
|
|
468
|
+
<input
|
|
469
|
+
ref={tplFileInputRef}
|
|
470
|
+
type="file"
|
|
471
|
+
accept=".json,application/json"
|
|
472
|
+
onChange={onPickTemplateFile}
|
|
473
|
+
className="hidden"
|
|
474
|
+
/>
|
|
475
|
+
<button
|
|
476
|
+
type="button"
|
|
477
|
+
onClick={onClickImport}
|
|
478
|
+
disabled={tplAnalyzing || tplApplying}
|
|
479
|
+
title="One-click import — uses the bundled connector template (or your <dataDir>/config-template.json override). Asks once for each missing token; shared keys (e.g. gitlab_pat used by gitlab+jenkins) are asked once."
|
|
480
|
+
className="text-[10px] px-2.5 py-0.5 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors disabled:opacity-40"
|
|
481
|
+
>
|
|
482
|
+
{tplAnalyzing ? 'Analyzing…' : tplApplying ? 'Applying…' : '↥ Import Template'}
|
|
483
|
+
</button>
|
|
484
|
+
<button
|
|
485
|
+
type="button"
|
|
486
|
+
onClick={() => tplFileInputRef.current?.click()}
|
|
487
|
+
disabled={tplAnalyzing || tplApplying}
|
|
488
|
+
title="Override: pick a different template file"
|
|
489
|
+
className="text-[10px] px-1.5 py-0.5 text-[var(--text-secondary)] hover:text-[var(--accent)] disabled:opacity-40"
|
|
490
|
+
>
|
|
491
|
+
(or file…)
|
|
492
|
+
</button>
|
|
337
493
|
<button
|
|
338
494
|
type="button"
|
|
339
495
|
onClick={() => fileInputRef.current?.click()}
|
|
@@ -639,6 +795,176 @@ export default function ConnectorsPanel() {
|
|
|
639
795
|
})()}
|
|
640
796
|
</div>
|
|
641
797
|
</div>
|
|
798
|
+
|
|
799
|
+
{/* ─── Template Import: prompt modal ─────────────────── */}
|
|
800
|
+
{tplModal && (
|
|
801
|
+
<TemplateImportModal
|
|
802
|
+
state={tplModal}
|
|
803
|
+
onChange={(values) => setTplModal({ ...tplModal, values })}
|
|
804
|
+
onCancel={() => setTplModal(null)}
|
|
805
|
+
onSubmit={(values) => applyTemplate(tplModal.template, values)}
|
|
806
|
+
submitting={tplApplying}
|
|
807
|
+
/>
|
|
808
|
+
)}
|
|
809
|
+
|
|
810
|
+
{/* ─── Template Import: result toast ─────────────────── */}
|
|
811
|
+
{tplResult && (
|
|
812
|
+
<TemplateImportResultModal
|
|
813
|
+
result={tplResult}
|
|
814
|
+
onClose={() => setTplResult(null)}
|
|
815
|
+
/>
|
|
816
|
+
)}
|
|
817
|
+
</div>
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// ─── Template Import: prompt modal ──────────────────────────
|
|
822
|
+
|
|
823
|
+
function TemplateImportModal({
|
|
824
|
+
state, onChange, onCancel, onSubmit, submitting,
|
|
825
|
+
}: {
|
|
826
|
+
state: TemplateModalState;
|
|
827
|
+
onChange: (values: Record<string, string>) => void;
|
|
828
|
+
onCancel: () => void;
|
|
829
|
+
onSubmit: (values: Record<string, string>) => void;
|
|
830
|
+
submitting: boolean;
|
|
831
|
+
}) {
|
|
832
|
+
const required = state.pending.filter(p => p.required);
|
|
833
|
+
const missingRequired = required.filter(p => !(state.values[p.key] || '').trim());
|
|
834
|
+
|
|
835
|
+
return (
|
|
836
|
+
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-6">
|
|
837
|
+
<div className="bg-[var(--bg-primary)] border border-[var(--border)] rounded-md shadow-xl max-w-2xl w-full max-h-[85vh] flex flex-col">
|
|
838
|
+
<div className="px-4 py-3 border-b border-[var(--border)] shrink-0">
|
|
839
|
+
<h3 className="text-sm font-medium text-[var(--text-primary)]">Import Connector Config Template</h3>
|
|
840
|
+
<p className="text-[11px] text-[var(--text-secondary)] mt-1">
|
|
841
|
+
Static defaults will be applied to <strong>{state.static_apply_targets.length}</strong> connector{state.static_apply_targets.length === 1 ? '' : 's'} ({state.static_apply_targets.join(', ')}).
|
|
842
|
+
{state.missing_manifests.length > 0 && (
|
|
843
|
+
<> Skipped (not installed): <span className="text-amber-500">{state.missing_manifests.join(', ')}</span>.</>
|
|
844
|
+
)}
|
|
845
|
+
<br />
|
|
846
|
+
Fill in the fields below — values shared across connectors are asked once. Existing non-empty fields are preserved.
|
|
847
|
+
</p>
|
|
848
|
+
</div>
|
|
849
|
+
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
|
850
|
+
{state.pending.map(p => {
|
|
851
|
+
const v = state.values[p.key] ?? '';
|
|
852
|
+
return (
|
|
853
|
+
<div key={p.key} className="space-y-1">
|
|
854
|
+
<label className="text-[11px] font-medium text-[var(--text-primary)] flex items-center gap-1.5 flex-wrap">
|
|
855
|
+
{p.label}
|
|
856
|
+
{p.required ? <span className="text-red-500">*</span> : <span className="text-[9px] text-[var(--text-secondary)]">(optional)</span>}
|
|
857
|
+
{p.url && (
|
|
858
|
+
<a
|
|
859
|
+
href={p.url}
|
|
860
|
+
target="_blank"
|
|
861
|
+
rel="noopener noreferrer"
|
|
862
|
+
className="ml-auto text-[10px] text-[var(--accent)] hover:underline"
|
|
863
|
+
title={p.url}
|
|
864
|
+
>
|
|
865
|
+
↗ {p.url_label || 'Get token'}
|
|
866
|
+
</a>
|
|
867
|
+
)}
|
|
868
|
+
</label>
|
|
869
|
+
{p.hint && (
|
|
870
|
+
<p className="text-[10px] text-[var(--text-secondary)] leading-snug">{p.hint}</p>
|
|
871
|
+
)}
|
|
872
|
+
<input
|
|
873
|
+
type={p.secret ? 'password' : 'text'}
|
|
874
|
+
value={v}
|
|
875
|
+
onChange={(e) => onChange({ ...state.values, [p.key]: e.target.value })}
|
|
876
|
+
placeholder={p.required ? 'required' : 'leave blank to skip'}
|
|
877
|
+
className="w-full text-[11px] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
|
|
878
|
+
/>
|
|
879
|
+
<p className="text-[9px] text-[var(--text-secondary)]">
|
|
880
|
+
→ {p.targets.map(t => `${t.connector}${t.field_path ? '.' + t.field_path : ''}`).join(', ')}
|
|
881
|
+
</p>
|
|
882
|
+
</div>
|
|
883
|
+
);
|
|
884
|
+
})}
|
|
885
|
+
</div>
|
|
886
|
+
<div className="px-4 py-2 border-t border-[var(--border)] shrink-0 flex items-center justify-end gap-2">
|
|
887
|
+
{missingRequired.length > 0 && (
|
|
888
|
+
<span className="text-[10px] text-amber-500 mr-auto">
|
|
889
|
+
{missingRequired.length} required field{missingRequired.length === 1 ? '' : 's'} empty
|
|
890
|
+
</span>
|
|
891
|
+
)}
|
|
892
|
+
<button
|
|
893
|
+
type="button"
|
|
894
|
+
onClick={onCancel}
|
|
895
|
+
disabled={submitting}
|
|
896
|
+
className="text-[10px] px-2.5 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:border-[var(--text-primary)] disabled:opacity-40"
|
|
897
|
+
>
|
|
898
|
+
Cancel
|
|
899
|
+
</button>
|
|
900
|
+
<button
|
|
901
|
+
type="button"
|
|
902
|
+
onClick={() => onSubmit(state.values)}
|
|
903
|
+
disabled={submitting || missingRequired.length > 0}
|
|
904
|
+
className="text-[10px] px-2.5 py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white disabled:opacity-40"
|
|
905
|
+
>
|
|
906
|
+
{submitting ? 'Applying…' : 'Apply'}
|
|
907
|
+
</button>
|
|
908
|
+
</div>
|
|
909
|
+
</div>
|
|
910
|
+
</div>
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function TemplateImportResultModal({
|
|
915
|
+
result, onClose,
|
|
916
|
+
}: { result: TemplateApplyResult; onClose: () => void; }) {
|
|
917
|
+
return (
|
|
918
|
+
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-6">
|
|
919
|
+
<div className="bg-[var(--bg-primary)] border border-[var(--border)] rounded-md shadow-xl max-w-xl w-full max-h-[80vh] flex flex-col">
|
|
920
|
+
<div className="px-4 py-3 border-b border-[var(--border)] shrink-0 flex items-center">
|
|
921
|
+
<h3 className="text-sm font-medium text-[var(--text-primary)]">Import Complete</h3>
|
|
922
|
+
</div>
|
|
923
|
+
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3 text-[11px] text-[var(--text-primary)]">
|
|
924
|
+
<div>
|
|
925
|
+
<div className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)] mb-1">Applied ({result.applied.length})</div>
|
|
926
|
+
<div className="text-emerald-500">{result.applied.join(', ') || '—'}</div>
|
|
927
|
+
</div>
|
|
928
|
+
{result.fields_preserved.length > 0 && (
|
|
929
|
+
<div>
|
|
930
|
+
<div className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)] mb-1">Preserved (existing values not overwritten)</div>
|
|
931
|
+
<ul className="text-[var(--text-secondary)] font-mono text-[10px] space-y-0.5">
|
|
932
|
+
{result.fields_preserved.map((f, i) => (
|
|
933
|
+
<li key={i}>{f.connector}.{f.field}</li>
|
|
934
|
+
))}
|
|
935
|
+
</ul>
|
|
936
|
+
</div>
|
|
937
|
+
)}
|
|
938
|
+
{result.fields_left_empty.length > 0 && (
|
|
939
|
+
<div>
|
|
940
|
+
<div className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)] mb-1">Left empty (optional fields skipped)</div>
|
|
941
|
+
<ul className="text-amber-500 font-mono text-[10px] space-y-0.5">
|
|
942
|
+
{result.fields_left_empty.map((f, i) => (
|
|
943
|
+
<li key={i}>{f.connector}.{f.field}</li>
|
|
944
|
+
))}
|
|
945
|
+
</ul>
|
|
946
|
+
</div>
|
|
947
|
+
)}
|
|
948
|
+
{result.skipped_missing_manifest.length > 0 && (
|
|
949
|
+
<div>
|
|
950
|
+
<div className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)] mb-1">Skipped (manifest not installed)</div>
|
|
951
|
+
<div className="text-amber-500">{result.skipped_missing_manifest.join(', ')}</div>
|
|
952
|
+
<p className="text-[10px] text-[var(--text-secondary)] mt-1">
|
|
953
|
+
Sync the marketplace first (Refresh button), then re-import the template.
|
|
954
|
+
</p>
|
|
955
|
+
</div>
|
|
956
|
+
)}
|
|
957
|
+
</div>
|
|
958
|
+
<div className="px-4 py-2 border-t border-[var(--border)] shrink-0 flex justify-end">
|
|
959
|
+
<button
|
|
960
|
+
type="button"
|
|
961
|
+
onClick={onClose}
|
|
962
|
+
className="text-[10px] px-2.5 py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white"
|
|
963
|
+
>
|
|
964
|
+
OK
|
|
965
|
+
</button>
|
|
966
|
+
</div>
|
|
967
|
+
</div>
|
|
642
968
|
</div>
|
|
643
969
|
);
|
|
644
970
|
}
|
package/components/Dashboard.tsx
CHANGED
|
@@ -28,6 +28,7 @@ const LoginStatusPanel = lazy(() => import('./LoginStatusPanel'));
|
|
|
28
28
|
const ActivityPanel = lazy(() => import('./ActivityPanel'));
|
|
29
29
|
const WorkspaceView = lazy(() => import('./WorkspaceView'));
|
|
30
30
|
// WorkspaceTree moved into ProjectDetail — no longer needed at Dashboard level
|
|
31
|
+
import { OnboardingBanner, OnboardingDrawer } from './OnboardingWizard';
|
|
31
32
|
|
|
32
33
|
interface UsageSummary {
|
|
33
34
|
provider: string;
|
|
@@ -133,6 +134,17 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
133
134
|
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
|
134
135
|
const [showNewTask, setShowNewTask] = useState(false);
|
|
135
136
|
const [showSettings, setShowSettings] = useState(false);
|
|
137
|
+
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
|
138
|
+
const [showOnboarding, setShowOnboarding] = useState(false);
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
fetch('/api/onboarding').then(r => r.json()).then(j => {
|
|
141
|
+
const need = !!(j?.ok && !j.onboardingCompleted);
|
|
142
|
+
setNeedsOnboarding(need);
|
|
143
|
+
// Auto-open the drawer when setup is needed — saves the user a click.
|
|
144
|
+
// They can still click "Skip" inside the drawer to dismiss.
|
|
145
|
+
if (need) setShowOnboarding(true);
|
|
146
|
+
}).catch(() => {});
|
|
147
|
+
}, []);
|
|
136
148
|
const [showMonitor, setShowMonitor] = useState(false);
|
|
137
149
|
const [showLoginStatus, setShowLoginStatus] = useState(false);
|
|
138
150
|
const [loginBadge, setLoginBadge] = useState<{ broken: number; total: number } | null>(null);
|
|
@@ -294,7 +306,22 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
294
306
|
const queued = tasks.filter(t => t.status === 'queued');
|
|
295
307
|
|
|
296
308
|
return (
|
|
297
|
-
<div className="h-screen flex">
|
|
309
|
+
<div className="h-screen flex flex-col">
|
|
310
|
+
{needsOnboarding && (
|
|
311
|
+
<OnboardingBanner onOpen={() => setShowOnboarding(true)} />
|
|
312
|
+
)}
|
|
313
|
+
{showOnboarding && (
|
|
314
|
+
<OnboardingDrawer
|
|
315
|
+
onClose={() => setShowOnboarding(false)}
|
|
316
|
+
onComplete={() => {
|
|
317
|
+
setShowOnboarding(false);
|
|
318
|
+
setNeedsOnboarding(false);
|
|
319
|
+
// Reload settings so chat picks up new API profile etc.
|
|
320
|
+
fetchData();
|
|
321
|
+
}}
|
|
322
|
+
/>
|
|
323
|
+
)}
|
|
324
|
+
<div className="flex-1 flex min-h-0">
|
|
298
325
|
{/* Browser — left side */}
|
|
299
326
|
{browserMode === 'left' && (
|
|
300
327
|
<>
|
|
@@ -951,6 +978,7 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
951
978
|
</Suspense>
|
|
952
979
|
)}
|
|
953
980
|
|
|
981
|
+
</div>{/* /flex-1 inner container */}
|
|
954
982
|
{showMonitor && <Suspense fallback={null}><MonitorPanel onClose={() => setShowMonitor(false)} /></Suspense>}
|
|
955
983
|
{showLoginStatus && <Suspense fallback={null}><LoginStatusPanel onClose={() => { setShowLoginStatus(false); /* refresh badge after panel actions */ fetch('/api/login-status').then(r => r.json()).then(j => { const rows = j.rows || []; const broken = rows.filter((r: any) => r.result && !r.result.ok).length; setLoginBadge({ broken, total: rows.length }); }).catch(() => {}); }} /></Suspense>}
|
|
956
984
|
|