@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,918 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
2
+ import {
3
+ Eye,
4
+ EyeOff,
5
+ ExternalLink,
6
+ KeyRound,
7
+ MessageSquare,
8
+ Pencil,
9
+ Save,
10
+ Settings,
11
+ Trash2,
12
+ X,
13
+ Zap,
14
+ ChevronDown,
15
+ ChevronRight,
16
+ } from "lucide-react";
17
+ import { api } from "@/lib/api";
18
+ import type { EnvVarInfo } from "@/lib/api";
19
+ import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
20
+ import { Toast } from "@nastechai/ui/ui/components/toast";
21
+ import { useConfirmDelete } from "@nastechai/ui/hooks/use-confirm-delete";
22
+ import { useToast } from "@nastechai/ui/hooks/use-toast";
23
+ import { OAuthProvidersCard } from "@/components/OAuthProvidersCard";
24
+ import { Button } from "@nastechai/ui/ui/components/button";
25
+ import { ListItem } from "@nastechai/ui/ui/components/list-item";
26
+ import { Spinner } from "@nastechai/ui/ui/components/spinner";
27
+ import {
28
+ Card,
29
+ CardContent,
30
+ CardDescription,
31
+ CardHeader,
32
+ CardTitle,
33
+ } from "@nastechai/ui/ui/components/card";
34
+ import { Badge } from "@nastechai/ui/ui/components/badge";
35
+ import { Input } from "@nastechai/ui/ui/components/input";
36
+ import { Label } from "@nastechai/ui/ui/components/label";
37
+ import { useI18n } from "@/i18n";
38
+ import { usePageHeader } from "@/contexts/usePageHeader";
39
+ import { PluginSlot } from "@/plugins";
40
+
41
+ /* ------------------------------------------------------------------ */
42
+ /* Provider grouping */
43
+ /* ------------------------------------------------------------------ */
44
+
45
+ /** Map env-var key prefixes to a human-friendly provider name + ordering. */
46
+ const PROVIDER_GROUPS: { prefix: string; name: string; priority: number }[] = [
47
+ // NasTech Portal first
48
+ { prefix: "NASTECH_", name: "NasTech Portal", priority: 0 },
49
+ // Then alphabetical by display name
50
+ { prefix: "ANTHROPIC_", name: "Anthropic", priority: 1 },
51
+ { prefix: "DASHSCOPE_", name: "DashScope (Qwen)", priority: 2 },
52
+ { prefix: "NASTECH_QWEN_", name: "DashScope (Qwen)", priority: 2 },
53
+ { prefix: "DEEPSEEK_", name: "DeepSeek", priority: 3 },
54
+ { prefix: "GOOGLE_", name: "Gemini", priority: 4 },
55
+ { prefix: "GEMINI_", name: "Gemini", priority: 4 },
56
+ { prefix: "GLM_", name: "GLM / Z.AI", priority: 5 },
57
+ { prefix: "ZAI_", name: "GLM / Z.AI", priority: 5 },
58
+ { prefix: "Z_AI_", name: "GLM / Z.AI", priority: 5 },
59
+ { prefix: "HF_", name: "Hugging Face", priority: 6 },
60
+ { prefix: "KIMI_", name: "Kimi / Moonshot", priority: 7 },
61
+ { prefix: "MINIMAX_CN_", name: "MiniMax (China)", priority: 9 },
62
+ { prefix: "MINIMAX_", name: "MiniMax", priority: 8 },
63
+ { prefix: "OPENCODE_GO_", name: "OpenCode Go", priority: 10 },
64
+ { prefix: "OPENCODE_ZEN_", name: "OpenCode Zen", priority: 11 },
65
+ { prefix: "OPENROUTER_", name: "OpenRouter", priority: 12 },
66
+ { prefix: "XIAOMI_", name: "Xiaomi MiMo", priority: 13 },
67
+ ];
68
+
69
+ function getProviderGroup(key: string): string {
70
+ for (const g of PROVIDER_GROUPS) {
71
+ if (key.startsWith(g.prefix)) return g.name;
72
+ }
73
+ return "Other";
74
+ }
75
+
76
+ function getProviderPriority(groupName: string): number {
77
+ const entry = PROVIDER_GROUPS.find((g) => g.name === groupName);
78
+ return entry?.priority ?? 99;
79
+ }
80
+
81
+ interface ProviderGroup {
82
+ name: string;
83
+ priority: number;
84
+ entries: [string, EnvVarInfo][];
85
+ hasAnySet: boolean;
86
+ }
87
+
88
+ const CATEGORY_META_ICONS: Record<string, typeof KeyRound> = {
89
+ provider: Zap,
90
+ tool: KeyRound,
91
+ messaging: MessageSquare,
92
+ setting: Settings,
93
+ };
94
+
95
+ /* ------------------------------------------------------------------ */
96
+ /* EnvVarRow — single key edit row */
97
+ /* ------------------------------------------------------------------ */
98
+
99
+ function EnvVarRow({
100
+ varKey,
101
+ info,
102
+ edits,
103
+ setEdits,
104
+ revealed,
105
+ saving,
106
+ onSave,
107
+ onClear,
108
+ onReveal,
109
+ onCancelEdit,
110
+ clearDialogOpen = false,
111
+ compact = false,
112
+ }: {
113
+ varKey: string;
114
+ info: EnvVarInfo;
115
+ edits: Record<string, string>;
116
+ setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
117
+ revealed: Record<string, string>;
118
+ saving: string | null;
119
+ onSave: (key: string) => void;
120
+ onClear: (key: string) => void;
121
+ onReveal: (key: string) => void;
122
+ onCancelEdit: (key: string) => void;
123
+ clearDialogOpen?: boolean;
124
+ compact?: boolean;
125
+ }) {
126
+ const { t } = useI18n();
127
+ const isEditing = edits[varKey] !== undefined;
128
+ const isRevealed = !!revealed[varKey];
129
+ const displayValue = isRevealed
130
+ ? revealed[varKey]
131
+ : (info.redacted_value ?? "---");
132
+
133
+ // Compact inline row for unset, non-editing keys (used inside provider groups)
134
+ if (compact && !info.is_set && !isEditing) {
135
+ return (
136
+ <div className="flex items-center justify-between gap-3 py-1.5 min-w-0 overflow-hidden text-text-secondary hover:text-foreground transition-colors">
137
+ <div className="flex items-center gap-2 min-w-0">
138
+ <span className="font-mono-ui text-xs">
139
+ {varKey}
140
+ </span>
141
+ <span className="text-xs text-text-tertiary truncate hidden sm:block">
142
+ {info.description}
143
+ </span>
144
+ </div>
145
+ <div className="flex items-center gap-2 shrink-0">
146
+ {info.url && (
147
+ <a
148
+ href={info.url}
149
+ target="_blank"
150
+ rel="noreferrer"
151
+ className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
152
+ >
153
+ {t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
154
+ </a>
155
+ )}
156
+ <Button
157
+ size="sm"
158
+ outlined
159
+ prefix={<Pencil />}
160
+ onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}
161
+ >
162
+ {t.common.set}
163
+ </Button>
164
+ </div>
165
+ </div>
166
+ );
167
+ }
168
+
169
+ // Non-compact unset row
170
+ if (!info.is_set && !isEditing) {
171
+ return (
172
+ <div className="flex items-center justify-between gap-3 border border-border/50 px-4 py-2.5 min-w-0 overflow-hidden text-text-secondary hover:text-foreground transition-colors">
173
+ <div className="flex items-center gap-3 min-w-0">
174
+ <Label className="font-mono-ui text-xs">
175
+ {varKey}
176
+ </Label>
177
+ <span className="text-xs text-text-tertiary truncate hidden sm:block">
178
+ {info.description}
179
+ </span>
180
+ </div>
181
+ <div className="flex items-center gap-2 shrink-0">
182
+ {info.url && (
183
+ <a
184
+ href={info.url}
185
+ target="_blank"
186
+ rel="noreferrer"
187
+ className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
188
+ >
189
+ {t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
190
+ </a>
191
+ )}
192
+ <Button
193
+ size="sm"
194
+ outlined
195
+ prefix={<Pencil />}
196
+ onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}
197
+ >
198
+ {t.common.set}
199
+ </Button>
200
+ </div>
201
+ </div>
202
+ );
203
+ }
204
+
205
+ // Full expanded row for set keys or keys being edited
206
+ return (
207
+ <div className="grid gap-2 border border-border p-4 min-w-0 overflow-hidden">
208
+ <div className="flex items-center justify-between gap-2 flex-wrap">
209
+ <div className="flex items-center gap-2">
210
+ <Label className="font-mono-ui text-xs">{varKey}</Label>
211
+ <Badge tone={info.is_set ? "success" : "outline"}>
212
+ {info.is_set ? t.common.set : t.env.notSet}
213
+ </Badge>
214
+ </div>
215
+ {info.url && (
216
+ <a
217
+ href={info.url}
218
+ target="_blank"
219
+ rel="noreferrer"
220
+ className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
221
+ >
222
+ {t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
223
+ </a>
224
+ )}
225
+ </div>
226
+
227
+ <p className="text-xs text-muted-foreground">{info.description}</p>
228
+
229
+ {info.tools.length > 0 && (
230
+ <div className="flex flex-wrap gap-1">
231
+ {info.tools.map((tool) => (
232
+ <Badge
233
+ key={tool}
234
+ tone="secondary"
235
+ className="text-xs py-0 px-1.5"
236
+ >
237
+ {tool}
238
+ </Badge>
239
+ ))}
240
+ </div>
241
+ )}
242
+
243
+ {!isEditing && (
244
+ <div className="flex items-center gap-2">
245
+ <div
246
+ className={`flex-1 border border-border px-3 py-2 font-mono-ui text-xs ${
247
+ isRevealed
248
+ ? "bg-background text-foreground select-all"
249
+ : "bg-muted/30 text-muted-foreground"
250
+ }`}
251
+ >
252
+ {info.is_set ? displayValue : "---"}
253
+ </div>
254
+
255
+ {info.is_set && (
256
+ <Button
257
+ ghost
258
+ size="icon"
259
+ onClick={() => onReveal(varKey)}
260
+ title={isRevealed ? t.env.hideValue : t.env.showValue}
261
+ aria-label={isRevealed ? `Hide ${varKey}` : `Reveal ${varKey}`}
262
+ >
263
+ {isRevealed ? <EyeOff /> : <Eye />}
264
+ </Button>
265
+ )}
266
+
267
+ <Button
268
+ size="sm"
269
+ outlined
270
+ prefix={<Pencil />}
271
+ onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}
272
+ >
273
+ {info.is_set ? t.common.replace : t.common.set}
274
+ </Button>
275
+
276
+ {info.is_set && (
277
+ <Button
278
+ size="sm"
279
+ outlined
280
+ destructive
281
+ prefix={<Trash2 />}
282
+ onClick={() => onClear(varKey)}
283
+ disabled={saving === varKey || clearDialogOpen}
284
+ >
285
+ {saving === varKey ? "..." : t.common.clear}
286
+ </Button>
287
+ )}
288
+ </div>
289
+ )}
290
+
291
+ {isEditing && (
292
+ <div className="flex items-center gap-2">
293
+ <Input
294
+ autoFocus
295
+ type="text"
296
+ value={edits[varKey]}
297
+ onChange={(e) =>
298
+ setEdits((prev) => ({ ...prev, [varKey]: e.target.value }))
299
+ }
300
+ placeholder={
301
+ info.is_set
302
+ ? t.env.replaceCurrentValue.replace(
303
+ "{preview}",
304
+ info.redacted_value ?? "---",
305
+ )
306
+ : t.env.enterValue
307
+ }
308
+ className="flex-1 font-mono-ui text-xs"
309
+ />
310
+ <Button
311
+ size="sm"
312
+ onClick={() => onSave(varKey)}
313
+ prefix={<Save />}
314
+ disabled={saving === varKey || !edits[varKey]}
315
+ >
316
+ {saving === varKey ? "..." : t.common.save}
317
+ </Button>
318
+ <Button
319
+ size="sm"
320
+ outlined
321
+ prefix={<X />}
322
+ onClick={() => onCancelEdit(varKey)}
323
+ >
324
+ {t.common.cancel}
325
+ </Button>
326
+ </div>
327
+ )}
328
+ </div>
329
+ );
330
+ }
331
+
332
+ /* ------------------------------------------------------------------ */
333
+ /* ProviderGroupCard — groups API key + base URL per provider */
334
+ /* ------------------------------------------------------------------ */
335
+
336
+ function ProviderGroupCard({
337
+ group,
338
+ edits,
339
+ setEdits,
340
+ revealed,
341
+ saving,
342
+ onSave,
343
+ onClear,
344
+ onReveal,
345
+ onCancelEdit,
346
+ clearDialogOpen = false,
347
+ }: {
348
+ group: ProviderGroup;
349
+ edits: Record<string, string>;
350
+ setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
351
+ revealed: Record<string, string>;
352
+ saving: string | null;
353
+ onSave: (key: string) => void;
354
+ onClear: (key: string) => void;
355
+ onReveal: (key: string) => void;
356
+ onCancelEdit: (key: string) => void;
357
+ clearDialogOpen?: boolean;
358
+ }) {
359
+ const [expanded, setExpanded] = useState(false);
360
+ const { t } = useI18n();
361
+
362
+ // Separate API keys from base URLs and other settings
363
+ const apiKeys = group.entries.filter(
364
+ ([k]) => k.endsWith("_API_KEY") || k.endsWith("_TOKEN"),
365
+ );
366
+ const baseUrls = group.entries.filter(([k]) => k.endsWith("_BASE_URL"));
367
+ const other = group.entries.filter(
368
+ ([k]) =>
369
+ !k.endsWith("_API_KEY") &&
370
+ !k.endsWith("_TOKEN") &&
371
+ !k.endsWith("_BASE_URL"),
372
+ );
373
+ const hasAnyConfigured = group.entries.some(([, info]) => info.is_set);
374
+ const configuredCount = group.entries.filter(
375
+ ([, info]) => info.is_set,
376
+ ).length;
377
+
378
+ // Get a representative URL for "Get key" link
379
+ const keyUrl = apiKeys.find(([, info]) => info.url)?.[1]?.url ?? null;
380
+
381
+ return (
382
+ <div className="border border-border">
383
+ {/* Header — always visible */}
384
+ <ListItem
385
+ onClick={() => setExpanded(!expanded)}
386
+ aria-expanded={expanded}
387
+ className="justify-between gap-3 px-4 py-3 hover:bg-primary/5"
388
+ >
389
+ <div className="flex items-center gap-3 min-w-0">
390
+ {expanded ? (
391
+ <ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
392
+ ) : (
393
+ <ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
394
+ )}
395
+ <span className="font-semibold text-sm tracking-wide">
396
+ {group.name === "Other" ? t.common.other : group.name}
397
+ </span>
398
+ {hasAnyConfigured && (
399
+ <Badge tone="success" className="text-xs">
400
+ {configuredCount} {t.common.set.toLowerCase()}
401
+ </Badge>
402
+ )}
403
+ </div>
404
+ <div className="flex items-center gap-2 shrink-0">
405
+ {keyUrl && (
406
+ <a
407
+ href={keyUrl}
408
+ target="_blank"
409
+ rel="noreferrer"
410
+ className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
411
+ onClick={(e) => e.stopPropagation()}
412
+ >
413
+ {t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
414
+ </a>
415
+ )}
416
+ <span className="text-xs text-text-tertiary">
417
+ {t.env.keysCount
418
+ .replace("{count}", String(group.entries.length))
419
+ .replace("{s}", group.entries.length !== 1 ? "s" : "")}
420
+ </span>
421
+ </div>
422
+ </ListItem>
423
+
424
+ {expanded && (
425
+ <div className="border-t border-border px-4 py-3 grid gap-2">
426
+ {apiKeys.map(([key, info]) => (
427
+ <EnvVarRow
428
+ key={key}
429
+ varKey={key}
430
+ info={info}
431
+ compact
432
+ edits={edits}
433
+ setEdits={setEdits}
434
+ revealed={revealed}
435
+ saving={saving}
436
+ onSave={onSave}
437
+ onClear={onClear}
438
+ onReveal={onReveal}
439
+ onCancelEdit={onCancelEdit}
440
+ clearDialogOpen={clearDialogOpen}
441
+ />
442
+ ))}
443
+
444
+ {baseUrls.map(([key, info]) => (
445
+ <EnvVarRow
446
+ key={key}
447
+ varKey={key}
448
+ info={info}
449
+ compact
450
+ edits={edits}
451
+ setEdits={setEdits}
452
+ revealed={revealed}
453
+ saving={saving}
454
+ onSave={onSave}
455
+ onClear={onClear}
456
+ onReveal={onReveal}
457
+ onCancelEdit={onCancelEdit}
458
+ clearDialogOpen={clearDialogOpen}
459
+ />
460
+ ))}
461
+
462
+ {other.map(([key, info]) => (
463
+ <EnvVarRow
464
+ key={key}
465
+ varKey={key}
466
+ info={info}
467
+ compact
468
+ edits={edits}
469
+ setEdits={setEdits}
470
+ revealed={revealed}
471
+ saving={saving}
472
+ onSave={onSave}
473
+ onClear={onClear}
474
+ onReveal={onReveal}
475
+ onCancelEdit={onCancelEdit}
476
+ clearDialogOpen={clearDialogOpen}
477
+ />
478
+ ))}
479
+ </div>
480
+ )}
481
+ </div>
482
+ );
483
+ }
484
+
485
+ /* ------------------------------------------------------------------ */
486
+ /* Main page */
487
+ /* ------------------------------------------------------------------ */
488
+
489
+ export default function EnvPage() {
490
+ const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null);
491
+ const [edits, setEdits] = useState<Record<string, string>>({});
492
+ const [revealed, setRevealed] = useState<Record<string, string>>({});
493
+ const [saving, setSaving] = useState<string | null>(null);
494
+ const [showAdvanced, setShowAdvanced] = useState(true); // Show all providers by default
495
+ const { toast, showToast } = useToast();
496
+ const { t } = useI18n();
497
+ const { setAfterTitle } = usePageHeader();
498
+
499
+ useEffect(() => {
500
+ api
501
+ .getEnvVars()
502
+ .then(setVars)
503
+ .catch(() => {});
504
+ }, []);
505
+
506
+ // Scroll-to sub-nav in the page header
507
+ const sections = useMemo(() => {
508
+ const items: { id: string; label: string }[] = [
509
+ { id: "section-oauth", label: "OAuth" },
510
+ { id: "section-providers", label: "Providers" },
511
+ ];
512
+ if (vars) {
513
+ const categories = ["tool", "messaging", "setting"];
514
+ const CATEGORY_LABELS: Record<string, string> = {
515
+ tool: "Tools",
516
+ messaging: "Messaging",
517
+ setting: "Settings",
518
+ };
519
+ for (const cat of categories) {
520
+ const hasEntries = Object.values(vars).some(
521
+ (info) => info.category === cat,
522
+ );
523
+ if (hasEntries) {
524
+ items.push({ id: `section-${cat}`, label: CATEGORY_LABELS[cat] ?? cat });
525
+ }
526
+ }
527
+ }
528
+ return items;
529
+ }, [vars]);
530
+
531
+ useLayoutEffect(() => {
532
+ if (!vars) {
533
+ setAfterTitle(null);
534
+ return;
535
+ }
536
+ const scrollTo = (id: string) => {
537
+ document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
538
+ };
539
+ setAfterTitle(
540
+ <nav
541
+ className="flex shrink-0 flex-nowrap items-center gap-1"
542
+ aria-label="Jump to section"
543
+ >
544
+ {sections.map((s) => (
545
+ <button
546
+ key={s.id}
547
+ type="button"
548
+ onClick={() => scrollTo(s.id)}
549
+ className="shrink-0 cursor-pointer px-2 py-0.5 font-mondwest text-display text-xs tracking-wider text-text-secondary hover:text-foreground border border-border/50 hover:border-foreground/30 transition-colors"
550
+ >
551
+ {s.label}
552
+ </button>
553
+ ))}
554
+ </nav>,
555
+ );
556
+ return () => {
557
+ setAfterTitle(null);
558
+ };
559
+ }, [vars, sections, setAfterTitle]);
560
+
561
+ const handleSave = async (key: string) => {
562
+ const value = edits[key];
563
+ if (!value) return;
564
+ setSaving(key);
565
+ try {
566
+ await api.setEnvVar(key, value);
567
+ setVars((prev) =>
568
+ prev
569
+ ? {
570
+ ...prev,
571
+ [key]: {
572
+ ...prev[key],
573
+ is_set: true,
574
+ redacted_value: value.slice(0, 4) + "..." + value.slice(-4),
575
+ },
576
+ }
577
+ : prev,
578
+ );
579
+ setEdits((prev) => {
580
+ const n = { ...prev };
581
+ delete n[key];
582
+ return n;
583
+ });
584
+ setRevealed((prev) => {
585
+ const n = { ...prev };
586
+ delete n[key];
587
+ return n;
588
+ });
589
+ showToast(`${key} ${t.common.save.toLowerCase()}d`, "success");
590
+ } catch (e) {
591
+ showToast(`${t.config.failedToSave} ${key}: ${e}`, "error");
592
+ } finally {
593
+ setSaving(null);
594
+ }
595
+ };
596
+
597
+ const keyClear = useConfirmDelete({
598
+ onDelete: useCallback(
599
+ async (key: string) => {
600
+ setSaving(key);
601
+ try {
602
+ await api.deleteEnvVar(key);
603
+ setVars((prev) =>
604
+ prev
605
+ ? {
606
+ ...prev,
607
+ [key]: { ...prev[key], is_set: false, redacted_value: null },
608
+ }
609
+ : prev,
610
+ );
611
+ setEdits((prev) => {
612
+ const n = { ...prev };
613
+ delete n[key];
614
+ return n;
615
+ });
616
+ setRevealed((prev) => {
617
+ const n = { ...prev };
618
+ delete n[key];
619
+ return n;
620
+ });
621
+ showToast(`${key} ${t.common.removed}`, "success");
622
+ } catch (e) {
623
+ showToast(`${t.common.failedToRemove} ${key}: ${e}`, "error");
624
+ throw e;
625
+ } finally {
626
+ setSaving(null);
627
+ }
628
+ },
629
+ [showToast, t.common.removed, t.common.failedToRemove],
630
+ ),
631
+ });
632
+
633
+ const handleReveal = async (key: string) => {
634
+ if (revealed[key]) {
635
+ setRevealed((prev) => {
636
+ const n = { ...prev };
637
+ delete n[key];
638
+ return n;
639
+ });
640
+ return;
641
+ }
642
+ try {
643
+ const resp = await api.revealEnvVar(key);
644
+ setRevealed((prev) => ({ ...prev, [key]: resp.value }));
645
+ } catch {
646
+ showToast(`${t.common.failedToReveal} ${key}`, "error");
647
+ }
648
+ };
649
+
650
+ const cancelEdit = (key: string) => {
651
+ setEdits((prev) => {
652
+ const n = { ...prev };
653
+ delete n[key];
654
+ return n;
655
+ });
656
+ };
657
+
658
+ /* ---- Build provider groups ---- */
659
+ const { providerGroups, nonProviderGrouped } = useMemo(() => {
660
+ if (!vars) return { providerGroups: [], nonProviderGrouped: [] };
661
+
662
+ const providerEntries = Object.entries(vars).filter(
663
+ ([, info]) =>
664
+ info.category === "provider" && (showAdvanced || !info.advanced),
665
+ );
666
+
667
+ // Group by provider
668
+ const groupMap = new Map<string, [string, EnvVarInfo][]>();
669
+ for (const entry of providerEntries) {
670
+ const groupName = getProviderGroup(entry[0]);
671
+ if (!groupMap.has(groupName)) groupMap.set(groupName, []);
672
+ groupMap.get(groupName)!.push(entry);
673
+ }
674
+
675
+ const groups: ProviderGroup[] = Array.from(groupMap.entries())
676
+ .map(([name, entries]) => ({
677
+ name,
678
+ priority: getProviderPriority(name),
679
+ entries,
680
+ hasAnySet: entries.some(([, info]) => info.is_set),
681
+ }))
682
+ .sort((a, b) => a.priority - b.priority);
683
+
684
+ // Non-provider categories — use translated labels
685
+ const CATEGORY_META_LABELS: Record<string, string> = {
686
+ tool: t.app.nav.keys,
687
+ messaging: t.common.messaging,
688
+ setting: t.app.nav.config,
689
+ };
690
+ const otherCategories = ["tool", "messaging", "setting"];
691
+ const nonProvider = otherCategories.map((cat) => {
692
+ const entries = Object.entries(vars).filter(
693
+ ([, info]) => info.category === cat && (showAdvanced || !info.advanced),
694
+ );
695
+ const setEntries = entries.filter(([, info]) => info.is_set);
696
+ const unsetEntries = entries.filter(([, info]) => !info.is_set);
697
+ return {
698
+ label: CATEGORY_META_LABELS[cat] ?? cat,
699
+ icon: CATEGORY_META_ICONS[cat] ?? KeyRound,
700
+ category: cat,
701
+ setEntries,
702
+ unsetEntries,
703
+ totalEntries: entries.length,
704
+ };
705
+ });
706
+
707
+ return { providerGroups: groups, nonProviderGrouped: nonProvider };
708
+ }, [vars, showAdvanced, t]);
709
+
710
+ if (!vars) {
711
+ return (
712
+ <div className="flex items-center justify-center py-24">
713
+ <Spinner className="text-2xl text-primary" />
714
+ </div>
715
+ );
716
+ }
717
+
718
+ const totalProviders = providerGroups.length;
719
+ const configuredProviders = providerGroups.filter((g) => g.hasAnySet).length;
720
+
721
+ const pendingClearKey = keyClear.pendingId;
722
+ const pendingKeyDescription =
723
+ pendingClearKey && vars ? vars[pendingClearKey]?.description : undefined;
724
+
725
+ return (
726
+ <div className="flex flex-col gap-6">
727
+ <PluginSlot name="env:top" />
728
+ <Toast toast={toast} />
729
+
730
+ <DeleteConfirmDialog
731
+ open={keyClear.isOpen}
732
+ onCancel={keyClear.cancel}
733
+ onConfirm={keyClear.confirm}
734
+ title={t.env.confirmClearTitle}
735
+ description={
736
+ pendingClearKey
737
+ ? `${pendingClearKey}${pendingKeyDescription ? ` — ${pendingKeyDescription}` : ""}. ${t.env.confirmClearMessage}`
738
+ : t.env.confirmClearMessage
739
+ }
740
+ loading={keyClear.isDeleting}
741
+ />
742
+
743
+ <div className="flex items-center justify-between">
744
+ <div className="flex flex-col gap-1">
745
+ <p className="text-sm text-muted-foreground">
746
+ {t.env.description} <code>~/.nastech/.env</code>
747
+ </p>
748
+ <p className="text-xs text-text-tertiary">
749
+ {t.env.changesNote}
750
+ </p>
751
+ </div>
752
+ <Button
753
+ size="sm"
754
+ outlined
755
+ onClick={() => setShowAdvanced(!showAdvanced)}
756
+ >
757
+ {showAdvanced ? t.env.hideAdvanced : t.env.showAdvanced}
758
+ </Button>
759
+ </div>
760
+
761
+ <div id="section-oauth">
762
+ <OAuthProvidersCard
763
+ onError={(msg) => showToast(msg, "error")}
764
+ onSuccess={(msg) => showToast(msg, "success")}
765
+ />
766
+ </div>
767
+
768
+ <Card id="section-providers">
769
+ <CardHeader className="border-b border-border bg-card">
770
+ <div className="flex items-center gap-2">
771
+ <Zap className="h-5 w-5 text-muted-foreground" />
772
+ <CardTitle className="text-base">{t.env.llmProviders}</CardTitle>
773
+ </div>
774
+ <CardDescription>
775
+ {t.env.providersConfigured
776
+ .replace("{configured}", String(configuredProviders))
777
+ .replace("{total}", String(totalProviders))}
778
+ </CardDescription>
779
+ </CardHeader>
780
+
781
+ <CardContent className="grid gap-0 p-0">
782
+ {providerGroups.map((group) => (
783
+ <ProviderGroupCard
784
+ key={group.name}
785
+ group={group}
786
+ edits={edits}
787
+ setEdits={setEdits}
788
+ revealed={revealed}
789
+ saving={saving}
790
+ onSave={handleSave}
791
+ onClear={keyClear.requestDelete}
792
+ onReveal={handleReveal}
793
+ onCancelEdit={cancelEdit}
794
+ clearDialogOpen={keyClear.isOpen}
795
+ />
796
+ ))}
797
+ </CardContent>
798
+ </Card>
799
+
800
+ {nonProviderGrouped.map((section) => {
801
+ if (section.totalEntries === 0) return null;
802
+
803
+ return (
804
+ <EnvCategoryCard
805
+ key={section.category}
806
+ section={section}
807
+ edits={edits}
808
+ setEdits={setEdits}
809
+ revealed={revealed}
810
+ saving={saving}
811
+ onSave={handleSave}
812
+ onClear={keyClear.requestDelete}
813
+ onReveal={handleReveal}
814
+ onCancelEdit={cancelEdit}
815
+ clearDialogOpen={keyClear.isOpen}
816
+ />
817
+ );
818
+ })}
819
+ <PluginSlot name="env:bottom" />
820
+ </div>
821
+ );
822
+ }
823
+
824
+ /* ------------------------------------------------------------------ */
825
+ /* EnvCategoryCard — keys / messaging / settings sections */
826
+ /* ------------------------------------------------------------------ */
827
+
828
+ function EnvCategoryCard({
829
+ section,
830
+ edits,
831
+ setEdits,
832
+ revealed,
833
+ saving,
834
+ onSave,
835
+ onClear,
836
+ onReveal,
837
+ onCancelEdit,
838
+ clearDialogOpen = false,
839
+ }: {
840
+ section: {
841
+ category: string;
842
+ icon: React.ComponentType<{ className?: string }>;
843
+ label: string;
844
+ setEntries: [string, EnvVarInfo][];
845
+ totalEntries: number;
846
+ unsetEntries: [string, EnvVarInfo][];
847
+ };
848
+ edits: Record<string, string>;
849
+ setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
850
+ revealed: Record<string, string>;
851
+ saving: string | null;
852
+ onSave: (key: string) => void;
853
+ onClear: (key: string) => void;
854
+ onReveal: (key: string) => void;
855
+ onCancelEdit: (key: string) => void;
856
+ clearDialogOpen?: boolean;
857
+ }) {
858
+ const noneConfigured = section.setEntries.length === 0;
859
+ const [showAll, setShowAll] = useState(noneConfigured);
860
+ const { t } = useI18n();
861
+ const Icon = section.icon;
862
+ const hasContent = section.setEntries.length > 0 || showAll;
863
+ const rowProps = {
864
+ edits,
865
+ setEdits,
866
+ revealed,
867
+ saving,
868
+ onSave,
869
+ onClear,
870
+ onReveal,
871
+ onCancelEdit,
872
+ clearDialogOpen,
873
+ };
874
+
875
+ return (
876
+ <Card id={`section-${section.category}`}>
877
+ <CardHeader
878
+ className={`bg-card${hasContent ? " border-b border-border" : ""}`}
879
+ >
880
+ <div className="flex items-center justify-between gap-3">
881
+ <div className="flex min-w-0 items-center gap-2">
882
+ <Icon className="h-5 w-5 shrink-0 text-muted-foreground" />
883
+ <CardTitle className="text-base">{section.label}</CardTitle>
884
+ </div>
885
+
886
+ {section.unsetEntries.length > 0 && (
887
+ <button
888
+ type="button"
889
+ onClick={() => setShowAll((open) => !open)}
890
+ aria-expanded={showAll}
891
+ className="shrink-0 cursor-pointer border-0 bg-transparent p-0 font-mondwest text-xs tracking-[0.08em] text-text-secondary transition-colors hover:text-foreground"
892
+ >
893
+ {showAll ? t.env.showLess : t.env.showMore}
894
+ </button>
895
+ )}
896
+ </div>
897
+
898
+ <CardDescription>
899
+ {section.setEntries.length} {t.common.of} {section.totalEntries}{" "}
900
+ {t.common.configured}
901
+ </CardDescription>
902
+ </CardHeader>
903
+
904
+ {hasContent && (
905
+ <CardContent className="grid gap-3 overflow-hidden pt-4">
906
+ {section.setEntries.map(([key, info]) => (
907
+ <EnvVarRow key={key} varKey={key} info={info} {...rowProps} />
908
+ ))}
909
+
910
+ {showAll &&
911
+ section.unsetEntries.map(([key, info]) => (
912
+ <EnvVarRow key={key} varKey={key} info={info} {...rowProps} />
913
+ ))}
914
+ </CardContent>
915
+ )}
916
+ </Card>
917
+ );
918
+ }