@nastechai/agent 0.16.0 → 0.17.0

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 (98) hide show
  1. package/eslint.config.js +23 -0
  2. package/index.html +24 -0
  3. package/package.json +54 -26
  4. package/package.json.bak +89 -0
  5. package/package.json.pub +88 -0
  6. package/src/App.tsx +1173 -0
  7. package/src/components/AuthWidget.tsx +150 -0
  8. package/src/components/AutoField.tsx +206 -0
  9. package/src/components/Backdrop.tsx +93 -0
  10. package/src/components/ChatSidebar.tsx +394 -0
  11. package/src/components/DeleteConfirmDialog.tsx +40 -0
  12. package/src/components/LanguageSwitcher.tsx +186 -0
  13. package/src/components/Markdown.tsx +383 -0
  14. package/src/components/ModelInfoCard.tsx +112 -0
  15. package/src/components/ModelPickerDialog.tsx +470 -0
  16. package/src/components/OAuthLoginModal.tsx +374 -0
  17. package/src/components/OAuthProvidersCard.tsx +287 -0
  18. package/src/components/PlatformsCard.tsx +97 -0
  19. package/src/components/ScheduleBuilder.tsx +273 -0
  20. package/src/components/SidebarFooter.tsx +42 -0
  21. package/src/components/SidebarStatusStrip.tsx +72 -0
  22. package/src/components/SlashPopover.tsx +171 -0
  23. package/src/components/ThemeSwitcher.tsx +243 -0
  24. package/src/components/ToolCall.tsx +228 -0
  25. package/src/components/ToolsetConfigDrawer.tsx +448 -0
  26. package/src/contexts/PageHeaderProvider.tsx +139 -0
  27. package/src/contexts/SystemActions.tsx +120 -0
  28. package/src/contexts/page-header-context.ts +12 -0
  29. package/src/contexts/system-actions-context.ts +18 -0
  30. package/src/contexts/usePageHeader.ts +10 -0
  31. package/src/contexts/useSystemActions.ts +15 -0
  32. package/src/hooks/useModalBehavior.ts +44 -0
  33. package/src/hooks/useSidebarStatus.ts +27 -0
  34. package/src/i18n/af.ts +702 -0
  35. package/src/i18n/context.tsx +123 -0
  36. package/src/i18n/de.ts +701 -0
  37. package/src/i18n/en.ts +708 -0
  38. package/src/i18n/es.ts +701 -0
  39. package/src/i18n/fr.ts +701 -0
  40. package/src/i18n/ga.ts +702 -0
  41. package/src/i18n/hu.ts +702 -0
  42. package/src/i18n/index.ts +2 -0
  43. package/src/i18n/it.ts +701 -0
  44. package/src/i18n/ja.ts +702 -0
  45. package/src/i18n/ko.ts +702 -0
  46. package/src/i18n/pt.ts +702 -0
  47. package/src/i18n/ru.ts +702 -0
  48. package/src/i18n/tr.ts +702 -0
  49. package/src/i18n/types.ts +710 -0
  50. package/src/i18n/uk.ts +702 -0
  51. package/src/i18n/zh-hant.ts +702 -0
  52. package/src/i18n/zh.ts +698 -0
  53. package/src/index.css +274 -0
  54. package/src/lib/api.ts +1585 -0
  55. package/src/lib/dashboard-flags.ts +15 -0
  56. package/src/lib/format.ts +9 -0
  57. package/src/lib/fuzzy.ts +192 -0
  58. package/src/lib/gatewayClient.ts +253 -0
  59. package/src/lib/nested.ts +23 -0
  60. package/src/lib/resolve-page-title.ts +41 -0
  61. package/src/lib/schedule.ts +382 -0
  62. package/src/lib/slashExec.ts +163 -0
  63. package/src/lib/utils.ts +35 -0
  64. package/src/main.tsx +25 -0
  65. package/src/pages/AnalyticsPage.tsx +601 -0
  66. package/src/pages/ChannelsPage.tsx +772 -0
  67. package/src/pages/ChatPage.tsx +889 -0
  68. package/src/pages/ConfigPage.tsx +660 -0
  69. package/src/pages/CronPage.tsx +524 -0
  70. package/src/pages/DocsPage.tsx +69 -0
  71. package/src/pages/EnvPage.tsx +918 -0
  72. package/src/pages/LogsPage.tsx +246 -0
  73. package/src/pages/McpPage.tsx +757 -0
  74. package/src/pages/ModelsPage.tsx +994 -0
  75. package/src/pages/PairingPage.tsx +276 -0
  76. package/src/pages/PluginsPage.tsx +580 -0
  77. package/src/pages/ProfilesPage.tsx +559 -0
  78. package/src/pages/SessionsPage.tsx +936 -0
  79. package/src/pages/SkillsPage.tsx +557 -0
  80. package/src/pages/SystemPage.tsx +1259 -0
  81. package/src/pages/WebhooksPage.tsx +483 -0
  82. package/src/plugins/PluginPage.tsx +64 -0
  83. package/src/plugins/index.ts +6 -0
  84. package/src/plugins/registry.ts +151 -0
  85. package/src/plugins/sdk.d.ts +160 -0
  86. package/src/plugins/slots.ts +199 -0
  87. package/src/plugins/types.ts +37 -0
  88. package/src/plugins/usePlugins.ts +133 -0
  89. package/src/themes/context.tsx +443 -0
  90. package/src/themes/fonts.ts +160 -0
  91. package/src/themes/index.ts +3 -0
  92. package/src/themes/presets.ts +477 -0
  93. package/src/themes/types.ts +187 -0
  94. package/tsconfig.app.json +34 -0
  95. package/tsconfig.json +7 -0
  96. package/tsconfig.node.json +26 -0
  97. package/vite.config.ts +124 -0
  98. package/vite.config.ts.timestamp-1780999102396-af6b77b30ebd8.mjs +105 -0
@@ -0,0 +1,660 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState, useMemo } from "react";
2
+ import {
3
+ Code,
4
+ Download,
5
+ FormInput,
6
+ RotateCcw,
7
+ Search,
8
+ Upload,
9
+ X,
10
+ Settings2,
11
+ FileText,
12
+ Settings,
13
+ Bot,
14
+ Monitor,
15
+ Palette,
16
+ Users,
17
+ Brain,
18
+ Package,
19
+ Lock,
20
+ Globe,
21
+ Mic,
22
+ Volume2,
23
+ Ear,
24
+ ClipboardList,
25
+ MessageCircle,
26
+ Wrench,
27
+ FileQuestion,
28
+ Filter,
29
+ Cloud,
30
+ Sparkles,
31
+ LayoutDashboard,
32
+ BookOpen,
33
+ Route,
34
+ History,
35
+ Shield,
36
+ FileOutput,
37
+ RefreshCw,
38
+ } from "lucide-react";
39
+ import { api } from "@/lib/api";
40
+ import { getNestedValue, setNestedValue } from "@/lib/nested";
41
+ import { useToast } from "@nastechai/ui/hooks/use-toast";
42
+ import { Toast } from "@nastechai/ui/ui/components/toast";
43
+ import { AutoField } from "@/components/AutoField";
44
+ import { Button } from "@nastechai/ui/ui/components/button";
45
+ import { ListItem } from "@nastechai/ui/ui/components/list-item";
46
+ import { Spinner } from "@nastechai/ui/ui/components/spinner";
47
+ import { Card, CardContent, CardHeader, CardTitle } from "@nastechai/ui/ui/components/card";
48
+ import { ConfirmDialog } from "@nastechai/ui/ui/components/confirm-dialog";
49
+ import { Input } from "@nastechai/ui/ui/components/input";
50
+ import { Badge } from "@nastechai/ui/ui/components/badge";
51
+ import { useI18n } from "@/i18n";
52
+ import { usePageHeader } from "@/contexts/usePageHeader";
53
+ import { PluginSlot } from "@/plugins";
54
+
55
+ /* ------------------------------------------------------------------ */
56
+ /* Helpers */
57
+ /* ------------------------------------------------------------------ */
58
+
59
+ const CATEGORY_ICONS: Record<
60
+ string,
61
+ React.ComponentType<{ className?: string }>
62
+ > = {
63
+ general: Settings,
64
+ agent: Bot,
65
+ terminal: Monitor,
66
+ display: Palette,
67
+ delegation: Users,
68
+ memory: Brain,
69
+ compression: Package,
70
+ security: Lock,
71
+ browser: Globe,
72
+ voice: Mic,
73
+ tts: Volume2,
74
+ stt: Ear,
75
+ logging: ClipboardList,
76
+ discord: MessageCircle,
77
+ auxiliary: Wrench,
78
+ bedrock: Cloud,
79
+ curator: Sparkles,
80
+ kanban: LayoutDashboard,
81
+ model_catalog: BookOpen,
82
+ openrouter: Route,
83
+ sessions: History,
84
+ tool_loop_guardrails: Shield,
85
+ tool_output: FileOutput,
86
+ updates: RefreshCw,
87
+ };
88
+
89
+ function CategoryIcon({
90
+ category,
91
+ className,
92
+ }: {
93
+ category: string;
94
+ className?: string;
95
+ }) {
96
+ const Icon = CATEGORY_ICONS[category] ?? FileQuestion;
97
+ return <Icon className={className ?? "h-4 w-4"} />;
98
+ }
99
+
100
+ /* ------------------------------------------------------------------ */
101
+ /* Component */
102
+ /* ------------------------------------------------------------------ */
103
+
104
+ export default function ConfigPage() {
105
+ const [config, setConfig] = useState<Record<string, unknown> | null>(null);
106
+ const [schema, setSchema] = useState<Record<
107
+ string,
108
+ Record<string, unknown>
109
+ > | null>(null);
110
+ const [categoryOrder, setCategoryOrder] = useState<string[]>([]);
111
+ const [defaults, setDefaults] = useState<Record<string, unknown> | null>(
112
+ null,
113
+ );
114
+ const [saving, setSaving] = useState(false);
115
+ const [searchQuery, setSearchQuery] = useState("");
116
+ const [yamlMode, setYamlMode] = useState(false);
117
+ const [yamlText, setYamlText] = useState("");
118
+ const [yamlLoading, setYamlLoading] = useState(false);
119
+ const [yamlSaving, setYamlSaving] = useState(false);
120
+ const [configPath, setConfigPath] = useState<string | null>(null);
121
+ const [activeCategory, setActiveCategory] = useState<string>("");
122
+ const [confirmReset, setConfirmReset] = useState(false);
123
+ const { toast, showToast } = useToast();
124
+ const fileInputRef = useRef<HTMLInputElement>(null);
125
+ const { t } = useI18n();
126
+ const { setEnd } = usePageHeader();
127
+
128
+ useLayoutEffect(() => {
129
+ if (!config || !schema) {
130
+ setEnd(null);
131
+ return;
132
+ }
133
+ setEnd(
134
+ <div className="relative w-full min-w-0 sm:max-w-xs">
135
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
136
+ <Input
137
+ className="h-8 pl-8 pr-7 text-xs"
138
+ placeholder={t.common.search}
139
+ value={searchQuery}
140
+ onChange={(e) => setSearchQuery(e.target.value)}
141
+ />
142
+ {searchQuery && (
143
+ <Button
144
+ ghost
145
+ size="xs"
146
+ className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
147
+ onClick={() => setSearchQuery("")}
148
+ aria-label={t.common.clear}
149
+ >
150
+ <X />
151
+ </Button>
152
+ )}
153
+ </div>,
154
+ );
155
+ return () => setEnd(null);
156
+ }, [config, schema, searchQuery, setEnd, t.common.clear, t.common.search]);
157
+
158
+ function prettyCategoryName(cat: string): string {
159
+ const key = cat as keyof typeof t.config.categories;
160
+ if (t.config.categories[key]) return t.config.categories[key];
161
+ return cat.charAt(0).toUpperCase() + cat.slice(1);
162
+ }
163
+
164
+ useEffect(() => {
165
+ api
166
+ .getConfig()
167
+ .then(setConfig)
168
+ .catch(() => {});
169
+ api
170
+ .getSchema()
171
+ .then((resp) => {
172
+ setSchema(resp.fields as Record<string, Record<string, unknown>>);
173
+ setCategoryOrder(resp.category_order ?? []);
174
+ })
175
+ .catch(() => {});
176
+ api
177
+ .getDefaults()
178
+ .then(setDefaults)
179
+ .catch(() => {});
180
+ api
181
+ .getStatus()
182
+ .then((resp) => setConfigPath(resp.config_path))
183
+ .catch(() => {});
184
+ }, []);
185
+
186
+ // Set active category when categories load
187
+ useEffect(() => {
188
+ if (categoryOrder.length > 0 && !activeCategory) {
189
+ setActiveCategory(categoryOrder[0]);
190
+ }
191
+ }, [categoryOrder, activeCategory]);
192
+
193
+ // Load YAML when switching to YAML mode
194
+ useEffect(() => {
195
+ if (yamlMode) {
196
+ setYamlLoading(true);
197
+ api
198
+ .getConfigRaw()
199
+ .then((resp) => setYamlText(resp.yaml))
200
+ .catch(() => showToast(t.config.failedToLoadRaw, "error"))
201
+ .finally(() => setYamlLoading(false));
202
+ }
203
+ }, [yamlMode]);
204
+
205
+ /* ---- Categories ---- */
206
+ const categories = useMemo(() => {
207
+ if (!schema) return [];
208
+ const allCats = [
209
+ ...new Set(
210
+ Object.values(schema).map((s) => String(s.category ?? "general")),
211
+ ),
212
+ ];
213
+ const ordered = categoryOrder.filter((c) => allCats.includes(c));
214
+ const extra = allCats.filter((c) => !categoryOrder.includes(c)).sort();
215
+ return [...ordered, ...extra];
216
+ }, [schema, categoryOrder]);
217
+
218
+ /* ---- Category field counts ---- */
219
+ const categoryCounts = useMemo(() => {
220
+ if (!schema) return {};
221
+ const counts: Record<string, number> = {};
222
+ for (const s of Object.values(schema)) {
223
+ const cat = String(s.category ?? "general");
224
+ counts[cat] = (counts[cat] || 0) + 1;
225
+ }
226
+ return counts;
227
+ }, [schema]);
228
+
229
+ /* ---- Search ---- */
230
+ const isSearching = searchQuery.trim().length > 0;
231
+ const lowerSearch = searchQuery.toLowerCase();
232
+
233
+ const searchMatchedFields = useMemo(() => {
234
+ if (!isSearching || !schema) return [];
235
+ return Object.entries(schema).filter(([key, s]) => {
236
+ const label = key.split(".").pop() ?? key;
237
+ const humanLabel = label.replace(/_/g, " ");
238
+ return (
239
+ key.toLowerCase().includes(lowerSearch) ||
240
+ humanLabel.toLowerCase().includes(lowerSearch) ||
241
+ String(s.category ?? "")
242
+ .toLowerCase()
243
+ .includes(lowerSearch) ||
244
+ String(s.description ?? "")
245
+ .toLowerCase()
246
+ .includes(lowerSearch)
247
+ );
248
+ });
249
+ }, [isSearching, lowerSearch, schema]);
250
+
251
+ /* ---- Active tab fields ---- */
252
+ const activeFields = useMemo(() => {
253
+ if (!schema || isSearching) return [];
254
+ return Object.entries(schema).filter(
255
+ ([, s]) => String(s.category ?? "general") === activeCategory,
256
+ );
257
+ }, [schema, activeCategory, isSearching]);
258
+
259
+ /* ---- Handlers ---- */
260
+ const handleSave = async () => {
261
+ if (!config) return;
262
+ setSaving(true);
263
+ try {
264
+ await api.saveConfig(config);
265
+ showToast(t.config.configSaved, "success");
266
+ } catch (e) {
267
+ showToast(`${t.config.failedToSave}: ${e}`, "error");
268
+ } finally {
269
+ setSaving(false);
270
+ }
271
+ };
272
+
273
+ const handleYamlSave = async () => {
274
+ setYamlSaving(true);
275
+ try {
276
+ await api.saveConfigRaw(yamlText);
277
+ showToast(t.config.yamlConfigSaved, "success");
278
+ api
279
+ .getConfig()
280
+ .then(setConfig)
281
+ .catch(() => {});
282
+ } catch (e) {
283
+ showToast(`${t.config.failedToSaveYaml}: ${e}`, "error");
284
+ } finally {
285
+ setYamlSaving(false);
286
+ }
287
+ };
288
+
289
+ const handleReset = () => {
290
+ if (!defaults || !config) return;
291
+ // Scope the reset to what the user is currently looking at:
292
+ // - search mode → the matched fields
293
+ // - form mode → the active category's fields
294
+ // Resetting the whole config here was a footgun (issue reported by @ykmfb001):
295
+ // the button sits next to the category tabs and users reasonably assumed
296
+ // "reset this tab", not "wipe my entire config.yaml".
297
+ const scopedFields = isSearching ? searchMatchedFields : activeFields;
298
+ if (scopedFields.length === 0) return;
299
+ setConfirmReset(true);
300
+ };
301
+
302
+ const executeReset = () => {
303
+ if (!defaults || !config) return;
304
+ setConfirmReset(false);
305
+ const scopedFields = isSearching ? searchMatchedFields : activeFields;
306
+ if (scopedFields.length === 0) return;
307
+ const scopeLabel = isSearching
308
+ ? t.config.searchResults
309
+ : prettyCategoryName(activeCategory);
310
+ let next: Record<string, unknown> = config;
311
+ for (const [key] of scopedFields) {
312
+ next = setNestedValue(next, key, getNestedValue(defaults, key));
313
+ }
314
+ setConfig(next);
315
+ showToast(
316
+ t.config.resetScopeToast.replace("{scope}", scopeLabel),
317
+ "success",
318
+ );
319
+ };
320
+
321
+ const handleExport = () => {
322
+ if (!config) return;
323
+ const blob = new Blob([JSON.stringify(config, null, 2)], {
324
+ type: "application/json",
325
+ });
326
+ const url = URL.createObjectURL(blob);
327
+ const a = document.createElement("a");
328
+ a.href = url;
329
+ a.download = "nastech-config.json";
330
+ a.click();
331
+ URL.revokeObjectURL(url);
332
+ };
333
+
334
+ const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
335
+ const file = e.target.files?.[0];
336
+ if (!file) return;
337
+ const reader = new FileReader();
338
+ reader.onload = () => {
339
+ try {
340
+ const imported = JSON.parse(reader.result as string);
341
+ setConfig(imported);
342
+ showToast(t.config.configImported, "success");
343
+ } catch {
344
+ showToast(t.config.invalidJson, "error");
345
+ }
346
+ };
347
+ reader.readAsText(file);
348
+ };
349
+
350
+ /* ---- Loading ---- */
351
+ if (!config || !schema) {
352
+ return (
353
+ <div className="flex items-center justify-center py-24">
354
+ <Spinner className="text-2xl text-primary" />
355
+ </div>
356
+ );
357
+ }
358
+
359
+ /* ---- Render field list (shared between search & normal) ---- */
360
+ const renderFields = (
361
+ fields: [string, Record<string, unknown>][],
362
+ showCategory = false,
363
+ ) => {
364
+ let lastSection = "";
365
+ let lastCat = "";
366
+ return fields.map(([key, s]) => {
367
+ const parts = key.split(".");
368
+ const section = parts.length > 1 ? parts[0] : "";
369
+ const cat = String(s.category ?? "general");
370
+ const showCatBadge = showCategory && cat !== lastCat;
371
+ const showSection =
372
+ !showCategory &&
373
+ section &&
374
+ section !== lastSection &&
375
+ section !== activeCategory;
376
+ lastSection = section;
377
+ lastCat = cat;
378
+
379
+ return (
380
+ <div key={key}>
381
+ {showCatBadge && (
382
+ <div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
383
+ <CategoryIcon
384
+ category={cat}
385
+ className="h-4 w-4 text-muted-foreground"
386
+ />
387
+ <span className="font-mondwest text-display text-xs font-semibold tracking-wider text-muted-foreground">
388
+ {prettyCategoryName(cat)}
389
+ </span>
390
+ <div className="flex-1 border-t border-border" />
391
+ </div>
392
+ )}
393
+ {showSection && (
394
+ <div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
395
+ <span className="font-mondwest text-display text-xs font-semibold tracking-wider text-muted-foreground">
396
+ {section.replace(/_/g, " ")}
397
+ </span>
398
+ <div className="flex-1 border-t border-border" />
399
+ </div>
400
+ )}
401
+ <div className="py-1">
402
+ <AutoField
403
+ schemaKey={key}
404
+ schema={s}
405
+ value={getNestedValue(config, key)}
406
+ onChange={(v) => setConfig(setNestedValue(config, key, v))}
407
+ />
408
+ </div>
409
+ </div>
410
+ );
411
+ });
412
+ };
413
+
414
+ return (
415
+ <div className="flex flex-col gap-4">
416
+ <PluginSlot name="config:top" />
417
+ <Toast toast={toast} />
418
+
419
+ <div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
420
+ <div className="flex min-w-0 items-center gap-2 sm:flex-1">
421
+ <Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
422
+ <code className="min-w-0 flex-1 break-words text-xs text-muted-foreground bg-muted/50 px-2 py-0.5">
423
+ {configPath ?? t.config.configPath}
424
+ </code>
425
+ </div>
426
+ <div className="flex flex-wrap items-center gap-1.5 sm:shrink-0">
427
+ <Button
428
+ ghost
429
+ size="icon"
430
+ onClick={handleExport}
431
+ title={t.config.exportConfig}
432
+ aria-label={t.config.exportConfig}
433
+ >
434
+ <Download />
435
+ </Button>
436
+ <Button
437
+ ghost
438
+ size="icon"
439
+ onClick={() => fileInputRef.current?.click()}
440
+ title={t.config.importConfig}
441
+ aria-label={t.config.importConfig}
442
+ >
443
+ <Upload />
444
+ </Button>
445
+ <input
446
+ ref={fileInputRef}
447
+ type="file"
448
+ accept=".json"
449
+ className="hidden"
450
+ onChange={handleImport}
451
+ />
452
+ {!yamlMode &&
453
+ (() => {
454
+ const resetScopeLabel = isSearching
455
+ ? t.config.searchResults
456
+ : prettyCategoryName(activeCategory);
457
+ const resetTitle = t.config.resetScopeTooltip.replace(
458
+ "{scope}",
459
+ resetScopeLabel,
460
+ );
461
+ return (
462
+ <Button
463
+ ghost
464
+ size="icon"
465
+ onClick={handleReset}
466
+ title={resetTitle}
467
+ aria-label={resetTitle}
468
+ >
469
+ <RotateCcw />
470
+ </Button>
471
+ );
472
+ })()}
473
+
474
+ <div className="w-px h-5 bg-border mx-1" />
475
+
476
+ <Button
477
+ size="sm"
478
+ outlined={!yamlMode}
479
+ onClick={() => setYamlMode(!yamlMode)}
480
+ prefix={yamlMode ? <FormInput /> : <Code />}
481
+ >
482
+ {yamlMode ? t.common.form : "YAML"}
483
+ </Button>
484
+
485
+ {yamlMode ? (
486
+ <Button
487
+ size="sm"
488
+ className="uppercase"
489
+ onClick={handleYamlSave}
490
+ disabled={yamlSaving}
491
+ >
492
+ {yamlSaving ? t.common.saving : t.common.save}
493
+ </Button>
494
+ ) : (
495
+ <Button
496
+ size="sm"
497
+ className="uppercase"
498
+ onClick={handleSave}
499
+ disabled={saving}
500
+ >
501
+ {saving ? t.common.saving : t.common.save}
502
+ </Button>
503
+ )}
504
+ </div>
505
+ </div>
506
+
507
+ {yamlMode ? (
508
+ <Card>
509
+ <CardHeader className="py-3 px-4">
510
+ <CardTitle className="text-sm flex items-center gap-2">
511
+ <FileText className="h-4 w-4" />
512
+ {t.config.rawYaml}
513
+ </CardTitle>
514
+ </CardHeader>
515
+ <CardContent className="p-0">
516
+ {yamlLoading ? (
517
+ <div className="flex items-center justify-center py-12">
518
+ <Spinner className="text-xl text-primary" />
519
+ </div>
520
+ ) : (
521
+ <textarea
522
+ className="flex min-h-[600px] w-full bg-transparent px-4 py-3 text-sm font-mono leading-relaxed placeholder:text-muted-foreground focus-visible:outline-none border-t border-border"
523
+ value={yamlText}
524
+ onChange={(e) => setYamlText(e.target.value)}
525
+ spellCheck={false}
526
+ />
527
+ )}
528
+ </CardContent>
529
+ </Card>
530
+ ) : (
531
+ <div className="flex flex-col sm:flex-row gap-4">
532
+ <aside aria-label={t.config.filters} className="sm:w-56 sm:shrink-0">
533
+ <div className="sm:sticky sm:top-4">
534
+ <div className="flex flex-col border border-border bg-muted/20">
535
+ <div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
536
+ <Filter className="h-3 w-3 text-text-tertiary" />
537
+ <span className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
538
+ {t.config.filters}
539
+ </span>
540
+ </div>
541
+
542
+ <div className="hidden sm:block px-3 pt-2 pb-1 font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary">
543
+ {t.config.sections}
544
+ </div>
545
+
546
+ <div className="flex sm:flex-col gap-1 sm:gap-px p-2 sm:pt-1 overflow-x-auto sm:overflow-x-visible scrollbar-none sm:max-h-[calc(100vh-260px)] sm:overflow-y-auto">
547
+ {categories.map((cat) => {
548
+ const isActive = !isSearching && activeCategory === cat;
549
+
550
+ return (
551
+ <ListItem
552
+ key={cat}
553
+ active={isActive}
554
+ onClick={() => {
555
+ setSearchQuery("");
556
+ setActiveCategory(cat);
557
+ }}
558
+ className="rounded-none whitespace-nowrap px-2 py-1 text-xs"
559
+ >
560
+ <CategoryIcon
561
+ category={cat}
562
+ className="h-3.5 w-3.5 shrink-0"
563
+ />
564
+ <span className="flex-1 truncate">
565
+ {prettyCategoryName(cat)}
566
+ </span>
567
+ <span
568
+ className={`text-xs tabular-nums ${
569
+ isActive
570
+ ? "text-text-secondary"
571
+ : "text-text-tertiary"
572
+ }`}
573
+ >
574
+ {categoryCounts[cat] || 0}
575
+ </span>
576
+ </ListItem>
577
+ );
578
+ })}
579
+ </div>
580
+ </div>
581
+ </div>
582
+ </aside>
583
+
584
+ <div className="flex-1 min-w-0">
585
+ {isSearching ? (
586
+ <Card>
587
+ <CardHeader className="py-3 px-4">
588
+ <div className="flex items-center justify-between">
589
+ <CardTitle className="text-sm flex items-center gap-2">
590
+ <Search className="h-4 w-4" />
591
+ {t.config.searchResults}
592
+ </CardTitle>
593
+ <Badge tone="secondary" className="text-xs">
594
+ {searchMatchedFields.length}{" "}
595
+ {t.config.fields.replace(
596
+ "{s}",
597
+ searchMatchedFields.length !== 1 ? "s" : "",
598
+ )}
599
+ </Badge>
600
+ </div>
601
+ </CardHeader>
602
+ <CardContent className="grid gap-2 px-4 pb-4">
603
+ {searchMatchedFields.length === 0 ? (
604
+ <p className="text-sm text-muted-foreground text-center py-8">
605
+ {t.config.noFieldsMatch.replace("{query}", searchQuery)}
606
+ </p>
607
+ ) : (
608
+ renderFields(searchMatchedFields, true)
609
+ )}
610
+ </CardContent>
611
+ </Card>
612
+ ) : (
613
+ /* Active category */
614
+ <Card>
615
+ <CardHeader className="py-3 px-4">
616
+ <div className="flex items-center justify-between">
617
+ <CardTitle className="text-sm flex items-center gap-2">
618
+ <CategoryIcon
619
+ category={activeCategory}
620
+ className="h-4 w-4"
621
+ />
622
+ {prettyCategoryName(activeCategory)}
623
+ </CardTitle>
624
+ <Badge tone="secondary" className="text-xs">
625
+ {activeFields.length}{" "}
626
+ {t.config.fields.replace(
627
+ "{s}",
628
+ activeFields.length !== 1 ? "s" : "",
629
+ )}
630
+ </Badge>
631
+ </div>
632
+ </CardHeader>
633
+ <CardContent className="grid gap-2 px-4 pb-4">
634
+ {renderFields(activeFields)}
635
+ </CardContent>
636
+ </Card>
637
+ )}
638
+ </div>
639
+ </div>
640
+ )}
641
+ <PluginSlot name="config:bottom" />
642
+ <ConfirmDialog
643
+ open={confirmReset}
644
+ onCancel={() => setConfirmReset(false)}
645
+ onConfirm={executeReset}
646
+ title={t.config.confirmResetScope.replace(
647
+ "{scope}",
648
+ isSearching
649
+ ? t.config.searchResults
650
+ : prettyCategoryName(activeCategory),
651
+ )}
652
+ description={`This will reset ${
653
+ (isSearching ? searchMatchedFields : activeFields).length
654
+ } field(s) to their default values.`}
655
+ destructive
656
+ confirmLabel={t.config.resetDefaults}
657
+ />
658
+ </div>
659
+ );
660
+ }