@brainpilot/web 0.0.4 → 0.0.5

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 (97) hide show
  1. package/dist/assets/index-C-8G4D4j.js +448 -0
  2. package/dist/assets/index-C501m5OS.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/index.html +13 -0
  5. package/package.json +9 -3
  6. package/src/App.tsx +10 -0
  7. package/src/__tests__/api.test.ts +103 -0
  8. package/src/__tests__/messageGroups.test.ts +80 -0
  9. package/src/__tests__/newUiComponents.test.tsx +101 -0
  10. package/src/__tests__/newUiEvents.test.ts +236 -0
  11. package/src/components/chat/AskUserCard.tsx +123 -0
  12. package/src/components/chat/AutoRetryIndicator.tsx +71 -0
  13. package/src/components/chat/ComposerInput.tsx +73 -0
  14. package/src/components/chat/ComposerSendButton.tsx +26 -0
  15. package/src/components/chat/MarkdownMessage.tsx +24 -0
  16. package/src/components/chat/MessageStream.tsx +464 -0
  17. package/src/components/chat/PromptComposer.tsx +398 -0
  18. package/src/components/chat/SystemMessageBubble.tsx +46 -0
  19. package/src/components/demo/DemoFileTree.tsx +146 -0
  20. package/src/components/demo/DemoView.tsx +668 -0
  21. package/src/components/demo/TraceNodeModal.tsx +76 -0
  22. package/src/components/demo/demoBundle.ts +218 -0
  23. package/src/components/demo/demoCache.ts +42 -0
  24. package/src/components/files/FilePreviewView.tsx +153 -0
  25. package/src/components/files/FileSidebar.tsx +664 -0
  26. package/src/components/files/filePreview.ts +113 -0
  27. package/src/components/primitives/CustomSelect.tsx +200 -0
  28. package/src/components/primitives/IconButton.tsx +27 -0
  29. package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
  30. package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
  31. package/src/components/quota/QuotaFileManager.tsx +197 -0
  32. package/src/components/search/SearchDialog.tsx +101 -0
  33. package/src/components/session/AgentNetwork.tsx +1240 -0
  34. package/src/components/session/AgentTraceViews.tsx +381 -0
  35. package/src/components/session/AnalyticsTab.tsx +386 -0
  36. package/src/components/session/GlobalOverview.tsx +108 -0
  37. package/src/components/session/NodeTooltip.tsx +127 -0
  38. package/src/components/session/TimelineTab.tsx +320 -0
  39. package/src/components/session/TraceGraphView.tsx +301 -0
  40. package/src/components/session/TraceNodeDetail.tsx +142 -0
  41. package/src/components/session/agentAnalytics.ts +397 -0
  42. package/src/components/session/agentNetworkShared.ts +329 -0
  43. package/src/components/session/traceLayout.ts +150 -0
  44. package/src/components/settings/SettingsDialog.tsx +719 -0
  45. package/src/components/shell/DesktopShell.tsx +236 -0
  46. package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
  47. package/src/components/shell/SandboxStatus.tsx +287 -0
  48. package/src/components/shell/TerminalDrawer.tsx +387 -0
  49. package/src/components/sidebar/Sidebar.tsx +187 -0
  50. package/src/config.ts +10 -0
  51. package/src/contexts/AppProviders.tsx +20 -0
  52. package/src/contexts/AuthContext.tsx +61 -0
  53. package/src/contexts/PreferencesContext.tsx +125 -0
  54. package/src/contexts/SSEContext.tsx +175 -0
  55. package/src/contexts/SandboxContext.tsx +310 -0
  56. package/src/contexts/SessionContext.tsx +608 -0
  57. package/src/contexts/draftStore.ts +103 -0
  58. package/src/contexts/messageFilters.ts +29 -0
  59. package/src/contexts/messageGroups.ts +77 -0
  60. package/src/contexts/messageReducer.ts +401 -0
  61. package/src/contexts/newUiEvents.ts +190 -0
  62. package/src/contracts/backend.ts +846 -0
  63. package/src/contracts/demoBundle.ts +83 -0
  64. package/src/i18n/messages/analytics.ts +96 -0
  65. package/src/i18n/messages/chat.ts +108 -0
  66. package/src/i18n/messages/contexts.ts +40 -0
  67. package/src/i18n/messages/demo.ts +80 -0
  68. package/src/i18n/messages/files.ts +82 -0
  69. package/src/i18n/messages/network.ts +186 -0
  70. package/src/i18n/messages/profile.ts +40 -0
  71. package/src/i18n/messages/quota.ts +36 -0
  72. package/src/i18n/messages/sandbox.ts +116 -0
  73. package/src/i18n/messages/search.ts +16 -0
  74. package/src/i18n/messages/settings.ts +184 -0
  75. package/src/i18n/messages/shell.ts +38 -0
  76. package/src/i18n/messages/sidebar.ts +52 -0
  77. package/src/i18n/messages/terminal.ts +22 -0
  78. package/src/i18n/messages/trace.ts +84 -0
  79. package/src/i18n/messages.ts +32 -0
  80. package/src/i18n/translate.ts +46 -0
  81. package/src/i18n/types.ts +15 -0
  82. package/src/i18n/useT.ts +15 -0
  83. package/src/main.tsx +13 -0
  84. package/src/mocks/backend.ts +722 -0
  85. package/src/styles/global.css +7429 -0
  86. package/src/styles/tokens.css +161 -0
  87. package/src/utils/api.ts +627 -0
  88. package/src/utils/download.ts +18 -0
  89. package/src/utils/format.ts +7 -0
  90. package/src/utils/zip.ts +119 -0
  91. package/src/vite-env.d.ts +1 -0
  92. package/tsconfig.app.json +22 -0
  93. package/tsconfig.json +7 -0
  94. package/tsconfig.node.json +13 -0
  95. package/vite.config.ts +13 -0
  96. package/dist/assets/index-Cd0Mi_WU.css +0 -1
  97. package/dist/assets/index-FGg-DeYR.js +0 -448
@@ -0,0 +1,719 @@
1
+ import { FormEvent, useEffect, useState } from "react";
2
+ import { Check, Eye, EyeOff, Loader2, Plug, Plus, Settings, SlidersHorizontal, Trash2, UserRound, X } from "lucide-react";
3
+ import type { LucideIcon } from "lucide-react";
4
+ import type { McpServerEntry, ProviderProfile } from "../../contracts/backend";
5
+ import { useAuth } from "../../contexts/AuthContext";
6
+ import { usePreferences } from "../../contexts/PreferencesContext";
7
+ import { useT } from "../../i18n/useT";
8
+ import { api } from "../../utils/api";
9
+ import { CustomSelect } from "../primitives/CustomSelect";
10
+ import { IconButton } from "../primitives/IconButton";
11
+
12
+ type SettingsTab = "account" | "providers" | "mcp" | "preferences";
13
+
14
+ type SettingsDialogProps = {
15
+ isOpen: boolean;
16
+ onClose: () => void;
17
+ };
18
+
19
+ const tabs: Array<{ id: SettingsTab; labelKey: string; icon: LucideIcon }> = [
20
+ { id: "account", labelKey: "settings.tab.account", icon: UserRound },
21
+ { id: "providers", labelKey: "settings.tab.providers", icon: SlidersHorizontal },
22
+ { id: "mcp", labelKey: "settings.tab.mcp", icon: Plug },
23
+ { id: "preferences", labelKey: "settings.tab.preferences", icon: Settings },
24
+ ];
25
+
26
+ const DEFAULT_PROVIDER_FORM = {
27
+ name: "",
28
+ baseUrl: "https://api.anthropic.com",
29
+ apiKey: "",
30
+ apiKeyMasked: "",
31
+ models: ["claude-opus-4-6"],
32
+ iconColor: "#111111",
33
+ notes: "",
34
+ };
35
+
36
+ const DEFAULT_MCP_FORM = {
37
+ name: "",
38
+ type: "stdio" as McpServerEntry["type"],
39
+ command: "",
40
+ args: "",
41
+ url: "",
42
+ };
43
+
44
+ const colorOptions = ["#111111", "#16a34a", "#2563eb", "#7c3aed", "#f59e0b", "#d92d20", "#64748b"];
45
+ const PROVIDER_UPDATED_EVENT = "provider-profiles-updated";
46
+
47
+ function splitList(value: string) {
48
+ return value
49
+ .split(",")
50
+ .map((item) => item.trim())
51
+ .filter(Boolean);
52
+ }
53
+
54
+ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
55
+ const { user } = useAuth();
56
+ const preferences = usePreferences();
57
+ const t = useT();
58
+ const [activeTab, setActiveTab] = useState<SettingsTab>("account");
59
+ const [providers, setProviders] = useState<ProviderProfile[]>([]);
60
+ const [mcpServers, setMcpServers] = useState<McpServerEntry[]>([]);
61
+ const [providerForm, setProviderForm] = useState(DEFAULT_PROVIDER_FORM);
62
+ const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
63
+ const [isProviderFormOpen, setIsProviderFormOpen] = useState(false);
64
+ const [showProviderKey, setShowProviderKey] = useState(false);
65
+ const [mcpForm, setMcpForm] = useState(DEFAULT_MCP_FORM);
66
+ const [editingMcpName, setEditingMcpName] = useState<string | null>(null);
67
+ const [isMcpFormOpen, setIsMcpFormOpen] = useState(false);
68
+ const [isLoading, setIsLoading] = useState(false);
69
+ const [status, setStatus] = useState<string | null>(null);
70
+ const [error, setError] = useState<string | null>(null);
71
+ const [version, setVersion] = useState<string | null>(null);
72
+ const [testingProviderId, setTestingProviderId] = useState<string | null>(null);
73
+
74
+ const mergeHealth = (profiles: ProviderProfile[], healthProfiles: ProviderProfile[]): ProviderProfile[] => {
75
+ const healthById = new Map(healthProfiles.map((p) => [p.id, p]));
76
+ return profiles.map((p) => {
77
+ const h = healthById.get(p.id);
78
+ if (!h) return p;
79
+ return { ...p, healthStatus: h.healthStatus, healthCheckedAt: h.healthCheckedAt, modelHealth: h.modelHealth };
80
+ });
81
+ };
82
+
83
+ const loadSettings = async () => {
84
+ setIsLoading(true);
85
+ setError(null);
86
+ try {
87
+ const [nextProviders, nextMcpServers, healthProfiles] = await Promise.all([
88
+ api.providers.list(),
89
+ api.mcpServers.list(),
90
+ api.providers.health().catch(() => [] as ProviderProfile[]),
91
+ ]);
92
+ setProviders(mergeHealth(nextProviders, healthProfiles));
93
+ setMcpServers(nextMcpServers);
94
+ } catch (err) {
95
+ setError(err instanceof Error ? err.message : t("settings.loadFailed"));
96
+ } finally {
97
+ setIsLoading(false);
98
+ }
99
+ };
100
+
101
+ const refreshHealth = async () => {
102
+ try {
103
+ const healthProfiles = await api.providers.health();
104
+ setProviders((current) => mergeHealth(current, healthProfiles));
105
+ } catch {
106
+ // ignore silent refresh errors
107
+ }
108
+ };
109
+
110
+ const refreshSettingsSilent = async () => {
111
+ try {
112
+ const [nextProviders, nextMcpServers, healthProfiles] = await Promise.all([
113
+ api.providers.list(),
114
+ api.mcpServers.list(),
115
+ api.providers.health().catch(() => [] as ProviderProfile[]),
116
+ ]);
117
+ setProviders(mergeHealth(nextProviders, healthProfiles));
118
+ setMcpServers(nextMcpServers);
119
+ } catch {
120
+ // ignore silent refresh errors
121
+ }
122
+ };
123
+
124
+ const testProvider = async (providerId: string) => {
125
+ setTestingProviderId(providerId);
126
+ try {
127
+ const result = await api.providers.test(providerId);
128
+ setProviders((current) =>
129
+ current.map((p) => (p.id === providerId ? { ...p, healthStatus: result.healthStatus, healthCheckedAt: result.healthCheckedAt, modelHealth: result.modelHealth } : p)),
130
+ );
131
+ setStatus(t("settings.providers.tested", { name: result.name, status: result.healthStatus }));
132
+ } catch (err) {
133
+ setError(err instanceof Error ? err.message : t("settings.providers.testFailed"));
134
+ } finally {
135
+ setTestingProviderId(null);
136
+ }
137
+ };
138
+
139
+ useEffect(() => {
140
+ if (isOpen) {
141
+ void loadSettings();
142
+ void api.getVersion().then((v) => setVersion(v.version)).catch(() => setVersion(null));
143
+ }
144
+ }, [isOpen]);
145
+
146
+ useEffect(() => {
147
+ if (!isOpen || activeTab !== "providers") return;
148
+ const id = window.setInterval(() => void refreshSettingsSilent(), 30000);
149
+ return () => window.clearInterval(id);
150
+ }, [isOpen, activeTab]);
151
+
152
+ if (!isOpen) {
153
+ return null;
154
+ }
155
+
156
+ const emitProviderUpdated = () => {
157
+ window.dispatchEvent(new Event(PROVIDER_UPDATED_EVENT));
158
+ };
159
+
160
+ const openProviderForm = () => {
161
+ setEditingProviderId(null);
162
+ setProviderForm(DEFAULT_PROVIDER_FORM);
163
+ setShowProviderKey(false);
164
+ setIsProviderFormOpen(true);
165
+ };
166
+
167
+ const closeProviderForm = () => {
168
+ setEditingProviderId(null);
169
+ setProviderForm(DEFAULT_PROVIDER_FORM);
170
+ setShowProviderKey(false);
171
+ setIsProviderFormOpen(false);
172
+ };
173
+
174
+ const saveProvider = async (event: FormEvent) => {
175
+ event.preventDefault();
176
+ setError(null);
177
+ const models = providerForm.models.map((model) => model.trim()).filter(Boolean);
178
+ if (!models.length) {
179
+ setError(t("settings.providers.modelRequired"));
180
+ return;
181
+ }
182
+ try {
183
+ const provider = editingProviderId
184
+ ? await api.providers.update(editingProviderId, {
185
+ name: providerForm.name,
186
+ baseUrl: providerForm.baseUrl,
187
+ ...(providerForm.apiKey ? { apiKey: providerForm.apiKey } : {}),
188
+ models,
189
+ iconColor: providerForm.iconColor,
190
+ notes: providerForm.notes,
191
+ })
192
+ : await api.providers.create({
193
+ name: providerForm.name,
194
+ baseUrl: providerForm.baseUrl,
195
+ apiKey: providerForm.apiKey,
196
+ models,
197
+ iconColor: providerForm.iconColor,
198
+ notes: providerForm.notes,
199
+ });
200
+ setProviders((current) => [provider, ...current.filter((item) => item.id !== provider.id)]);
201
+ setProviderForm(DEFAULT_PROVIDER_FORM);
202
+ setEditingProviderId(null);
203
+ setShowProviderKey(false);
204
+ setIsProviderFormOpen(false);
205
+ setStatus(editingProviderId ? t("settings.providers.updated") : t("settings.providers.added"));
206
+ emitProviderUpdated();
207
+ } catch (err) {
208
+ setError(err instanceof Error ? err.message : t("settings.providers.saveFailed"));
209
+ }
210
+ };
211
+
212
+ const editProvider = (provider: ProviderProfile) => {
213
+ setEditingProviderId(provider.id);
214
+ setProviderForm({
215
+ name: provider.name,
216
+ baseUrl: provider.baseUrl,
217
+ apiKey: "",
218
+ apiKeyMasked: provider.apiKeyMasked || "",
219
+ models: provider.models.length ? provider.models : [""],
220
+ iconColor: provider.iconColor || "#111111",
221
+ notes: provider.notes,
222
+ });
223
+ setShowProviderKey(false);
224
+ setIsProviderFormOpen(true);
225
+ };
226
+
227
+ const activateProvider = async (providerId: string) => {
228
+ const active = await api.providers.setActive(providerId);
229
+ setProviders((current) => current.map((provider) => ({ ...provider, isActive: provider.id === active.id })));
230
+ setStatus(t("settings.providers.activeSwitched", { name: active.name }));
231
+ emitProviderUpdated();
232
+ };
233
+
234
+ const removeProvider = async (providerId: string) => {
235
+ await api.providers.remove(providerId);
236
+ setProviders((current) => current.filter((provider) => provider.id !== providerId));
237
+ emitProviderUpdated();
238
+ };
239
+
240
+ const updateProviderModel = (index: number, value: string) => {
241
+ setProviderForm((current) => ({
242
+ ...current,
243
+ models: current.models.map((model, modelIndex) => (modelIndex === index ? value : model)),
244
+ }));
245
+ };
246
+
247
+ const addProviderModel = () => {
248
+ setProviderForm((current) => ({ ...current, models: [...current.models, ""] }));
249
+ };
250
+
251
+ const removeProviderModel = (index: number) => {
252
+ setProviderForm((current) => ({
253
+ ...current,
254
+ models: current.models.filter((_, modelIndex) => modelIndex !== index),
255
+ }));
256
+ };
257
+
258
+ const openMcpForm = () => {
259
+ setEditingMcpName(null);
260
+ setMcpForm(DEFAULT_MCP_FORM);
261
+ setIsMcpFormOpen(true);
262
+ };
263
+
264
+ const closeMcpForm = () => {
265
+ setEditingMcpName(null);
266
+ setMcpForm(DEFAULT_MCP_FORM);
267
+ setIsMcpFormOpen(false);
268
+ };
269
+
270
+ const saveMcpServer = async (event: FormEvent) => {
271
+ event.preventDefault();
272
+ setError(null);
273
+ try {
274
+ const config: Omit<McpServerEntry, "name"> =
275
+ mcpForm.type === "stdio"
276
+ ? { type: "stdio", command: mcpForm.command, args: splitList(mcpForm.args) }
277
+ : { type: mcpForm.type, url: mcpForm.url };
278
+ const server = editingMcpName
279
+ ? await api.mcpServers.update(editingMcpName, config)
280
+ : await api.mcpServers.add(mcpForm.name, config);
281
+ setMcpServers((current) => [server, ...current.filter((item) => item.name !== server.name)]);
282
+ setMcpForm(DEFAULT_MCP_FORM);
283
+ setEditingMcpName(null);
284
+ setIsMcpFormOpen(false);
285
+ setStatus(editingMcpName ? t("settings.mcp.updated") : t("settings.mcp.added"));
286
+ } catch (err) {
287
+ setError(err instanceof Error ? err.message : t("settings.mcp.saveFailed"));
288
+ }
289
+ };
290
+
291
+ const editMcpServer = (server: McpServerEntry) => {
292
+ setEditingMcpName(server.name);
293
+ setMcpForm({
294
+ name: server.name,
295
+ type: server.type,
296
+ command: server.command || "",
297
+ args: (server.args || []).join(", "),
298
+ url: server.url || "",
299
+ });
300
+ setIsMcpFormOpen(true);
301
+ };
302
+
303
+ const removeMcpServer = async (name: string) => {
304
+ await api.mcpServers.remove(name);
305
+ setMcpServers((current) => current.filter((server) => server.name !== name));
306
+ };
307
+
308
+ return (
309
+ <div className="settings-modal" role="dialog" aria-label={t("settings.aria.dialog")}>
310
+ <div className="settings-modal__backdrop" onClick={onClose} />
311
+ <section className="settings-modal__panel">
312
+ <header className="settings-modal__header">
313
+ <div>
314
+ <span className="settings-modal__eyebrow">{t("settings.eyebrow.workspace")}</span>
315
+ <h2>{t("settings.title")}</h2>
316
+ </div>
317
+ <IconButton label={t("settings.aria.close")} onClick={onClose}>
318
+ <X size={16} />
319
+ </IconButton>
320
+ </header>
321
+
322
+ <div className="settings-modal__body">
323
+ <nav className="settings-tabs" aria-label={t("settings.aria.sections")}>
324
+ {tabs.map((tab) => {
325
+ const Icon = tab.icon;
326
+ return (
327
+ <button
328
+ className={activeTab === tab.id ? "is-active" : ""}
329
+ key={tab.id}
330
+ onClick={() => setActiveTab(tab.id)}
331
+ type="button"
332
+ >
333
+ <Icon size={15} />
334
+ <span>{t(tab.labelKey)}</span>
335
+ </button>
336
+ );
337
+ })}
338
+ </nav>
339
+
340
+ <div className="settings-content">
341
+ {isLoading ? <p className="settings-note">{t("settings.loading")}</p> : null}
342
+ {error ? <p className="settings-note settings-note--error">{error}</p> : null}
343
+ {status ? <p className="settings-note">{status}</p> : null}
344
+
345
+ {activeTab === "account" ? (
346
+ <section className="settings-section">
347
+ <h3>{t("settings.account.title")}</h3>
348
+ <dl className="settings-kv">
349
+ <div>
350
+ <dt>{t("settings.account.username")}</dt>
351
+ <dd>{user?.username || "-"}</dd>
352
+ </div>
353
+ <div>
354
+ <dt>{t("settings.account.userId")}</dt>
355
+ <dd>{user?.id || "-"}</dd>
356
+ </div>
357
+ <div>
358
+ <dt>{t("settings.account.created")}</dt>
359
+ <dd>{user?.createdAt ? new Date(user.createdAt).toLocaleString() : "-"}</dd>
360
+ </div>
361
+ </dl>
362
+ <p className="settings-note">{t("settings.account.managedByHost")}</p>
363
+ {version ? <span className="settings-version">{version}</span> : null}
364
+ </section>
365
+ ) : null}
366
+
367
+ {activeTab === "providers" ? (
368
+ <section className="settings-section">
369
+ <div className="settings-section__header">
370
+ <div>
371
+ <h3>{t("settings.providers.title")}</h3>
372
+ <p>{t("settings.providers.desc")}</p>
373
+ </div>
374
+ <div className="provider-header-actions">
375
+ {providers.find((provider) => provider.isActive) ? (
376
+ <span className="provider-active-pill">
377
+ <Check size={13} />
378
+ {providers.find((provider) => provider.isActive)?.name}
379
+ </span>
380
+ ) : null}
381
+ <button className="settings-button" onClick={openProviderForm} type="button">
382
+ {t("settings.providers.add")}
383
+ </button>
384
+ </div>
385
+ </div>
386
+
387
+ {(() => {
388
+ const sharedProviders = providers.filter((p) => p.id.startsWith("shared_"));
389
+ const privateProviders = providers.filter((p) => !p.id.startsWith("shared_"));
390
+
391
+ const renderProviderCard = (provider: ProviderProfile) => (
392
+ <article className="settings-list-item" key={provider.id}>
393
+ <div>
394
+ <strong>
395
+ <span className="provider-color-dot" style={{ backgroundColor: provider.iconColor }} />
396
+ {provider.name}
397
+ <span
398
+ className={`provider-health-dot provider-health-dot--${provider.healthStatus}`}
399
+ title={t("settings.providers.statusTitle", { status: provider.healthStatus })}
400
+ />
401
+ </strong>
402
+ <span>{provider.baseUrl}</span>
403
+ <small>{provider.models.join(", ") || t("settings.providers.noModelList")} · {provider.apiKeyMasked}</small>
404
+ <div className="provider-model-health-row">
405
+ {provider.modelHealth.map((mh) => (
406
+ <span
407
+ key={mh.model}
408
+ className={`provider-model-pill provider-model-pill--${mh.status}`}
409
+ title={mh.error || mh.status}
410
+ >
411
+ <span className={`model-status-dot model-status-dot--${mh.status}`} />
412
+ {mh.model}
413
+ {mh.latencyMs !== undefined ? ` (${mh.latencyMs}ms)` : null}
414
+ </span>
415
+ ))}
416
+ </div>
417
+ </div>
418
+ <div className="settings-list-item__actions provider-actions">
419
+ <button onClick={() => editProvider(provider)} type="button">
420
+ {t("settings.providers.edit")}
421
+ </button>
422
+ <button
423
+ disabled={testingProviderId === provider.id}
424
+ onClick={() => void testProvider(provider.id)}
425
+ type="button"
426
+ >
427
+ {testingProviderId === provider.id ? <Loader2 size={14} className="spin" /> : t("settings.providers.test")}
428
+ </button>
429
+ <button disabled={provider.isActive} onClick={() => void activateProvider(provider.id)} type="button">
430
+ {provider.isActive ? <Check size={14} /> : t("settings.providers.use")}
431
+ </button>
432
+ <button disabled={provider.isActive} onClick={() => void removeProvider(provider.id)} type="button">
433
+ {t("settings.providers.remove")}
434
+ </button>
435
+ </div>
436
+ </article>
437
+ );
438
+
439
+ return (
440
+ <>
441
+ {sharedProviders.length > 0 ? (
442
+ <div className="provider-group">
443
+ <h4 className="provider-group-title">{t("settings.providers.shared")}</h4>
444
+ <div className="settings-list">
445
+ {sharedProviders.map(renderProviderCard)}
446
+ </div>
447
+ </div>
448
+ ) : null}
449
+ {privateProviders.length > 0 ? (
450
+ <div className="provider-group">
451
+ <h4 className="provider-group-title">{t("settings.providers.private")}</h4>
452
+ <div className="settings-list">
453
+ {privateProviders.map(renderProviderCard)}
454
+ </div>
455
+ </div>
456
+ ) : null}
457
+ </>
458
+ );
459
+ })()}
460
+ </section>
461
+ ) : null}
462
+
463
+ {activeTab === "mcp" ? (
464
+ <section className="settings-section">
465
+ <div className="settings-section__header">
466
+ <div>
467
+ <h3>{t("settings.mcp.title")}</h3>
468
+ <p>{t("settings.mcp.desc")}</p>
469
+ </div>
470
+ <button className="settings-button" onClick={openMcpForm} type="button">
471
+ {t("settings.mcp.addServer")}
472
+ </button>
473
+ </div>
474
+ <div className="settings-list">
475
+ {mcpServers.map((server) => (
476
+ <article className="settings-list-item" key={server.name}>
477
+ <div>
478
+ <strong>{server.name}</strong>
479
+ <span>{server.type === "stdio" ? [server.command, ...(server.args || [])].filter(Boolean).join(" ") : server.url}</span>
480
+ <small>{server.type}</small>
481
+ </div>
482
+ <div className="settings-list-item__actions mcp-actions">
483
+ <button onClick={() => editMcpServer(server)} type="button">{t("settings.mcp.edit")}</button>
484
+ <button onClick={() => void removeMcpServer(server.name)} type="button">{t("settings.mcp.remove")}</button>
485
+ </div>
486
+ </article>
487
+ ))}
488
+ </div>
489
+ </section>
490
+ ) : null}
491
+
492
+ {activeTab === "preferences" ? (
493
+ <section className="settings-section">
494
+ <h3>{t("settings.prefs.title")}</h3>
495
+ <div className="settings-field">
496
+ <span>{t("settings.prefs.theme")}</span>
497
+ <CustomSelect
498
+ ariaLabel={t("settings.prefs.theme")}
499
+ onChange={(value) => preferences.setTheme(value as typeof preferences.theme)}
500
+ options={[
501
+ { label: t("settings.prefs.themeLight"), value: "light" },
502
+ { label: t("settings.prefs.themeDark"), value: "dark" },
503
+ { label: t("settings.prefs.themeSystem"), value: "system" },
504
+ ]}
505
+ value={preferences.theme}
506
+ />
507
+ </div>
508
+ <div className="settings-field">
509
+ <span>{t("settings.prefs.language")}</span>
510
+ <CustomSelect
511
+ ariaLabel={t("settings.prefs.language")}
512
+ onChange={(value) => preferences.setLanguage(value as typeof preferences.language)}
513
+ options={[
514
+ { label: "简体中文", value: "zh-CN" },
515
+ { label: "English", value: "en-US" },
516
+ ]}
517
+ value={preferences.language}
518
+ />
519
+ </div>
520
+ <label className="settings-check">
521
+ <input
522
+ checked={preferences.security.confirmDangerousActions}
523
+ onChange={(event) => preferences.setSecurity({ ...preferences.security, confirmDangerousActions: event.target.checked })}
524
+ type="checkbox"
525
+ />
526
+ <span>{t("settings.prefs.confirmDangerous")}</span>
527
+ </label>
528
+ <label className="settings-check">
529
+ <input
530
+ checked={preferences.notifications.agentDone}
531
+ onChange={(event) => preferences.setNotifications({ ...preferences.notifications, agentDone: event.target.checked })}
532
+ type="checkbox"
533
+ />
534
+ <span>{t("settings.prefs.notifyDone")}</span>
535
+ </label>
536
+ </section>
537
+ ) : null}
538
+ </div>
539
+ </div>
540
+ </section>
541
+
542
+ {isProviderFormOpen ? (
543
+ <div className="provider-form-modal" role="dialog" aria-label={editingProviderId ? t("settings.providerForm.editAria") : t("settings.providerForm.addAria")}>
544
+ <div className="provider-form-modal__backdrop" onClick={closeProviderForm} />
545
+ <form className="provider-form provider-form--modal" onSubmit={saveProvider}>
546
+ <header className="provider-form-modal__header">
547
+ <div>
548
+ <span className="settings-modal__eyebrow">{t("settings.providerForm.eyebrow")}</span>
549
+ <h3>{editingProviderId ? t("settings.providerForm.editTitle") : t("settings.providerForm.addTitle")}</h3>
550
+ </div>
551
+ <IconButton label={t("settings.providerForm.close")} onClick={closeProviderForm}>
552
+ <X size={15} />
553
+ </IconButton>
554
+ </header>
555
+
556
+ <div className="provider-form__grid">
557
+ <label>
558
+ <span>{t("settings.providerForm.name")}</span>
559
+ <input placeholder="Anthropic" required value={providerForm.name} onChange={(event) => setProviderForm({ ...providerForm, name: event.target.value })} />
560
+ </label>
561
+ <label>
562
+ <span>{t("settings.providerForm.baseUrl")}</span>
563
+ <input placeholder="https://api.anthropic.com" required value={providerForm.baseUrl} onChange={(event) => setProviderForm({ ...providerForm, baseUrl: event.target.value })} />
564
+ </label>
565
+ <label className="provider-form__key">
566
+ <span>{t("settings.providerForm.apiKey")} {editingProviderId ? t("settings.providerForm.apiKeyKeep") : ""}</span>
567
+ <input
568
+ placeholder={editingProviderId ? (providerForm.apiKeyMasked || "****") : ""}
569
+ required={!editingProviderId}
570
+ type={showProviderKey ? "text" : "password"}
571
+ value={providerForm.apiKey}
572
+ onChange={(event) => setProviderForm({ ...providerForm, apiKey: event.target.value })}
573
+ />
574
+ <button aria-label={showProviderKey ? t("settings.providerForm.hideKey") : t("settings.providerForm.showKey")} onClick={() => setShowProviderKey((current) => !current)} type="button">
575
+ {showProviderKey ? <EyeOff size={15} /> : <Eye size={15} />}
576
+ </button>
577
+ </label>
578
+ <label>
579
+ <span>{t("settings.providerForm.notes")}</span>
580
+ <input placeholder={t("settings.providerForm.notesPlaceholder")} value={providerForm.notes} onChange={(event) => setProviderForm({ ...providerForm, notes: event.target.value })} />
581
+ </label>
582
+ </div>
583
+
584
+ <div className="provider-form__models">
585
+ <div className="provider-form__models-header">
586
+ <span>{t("settings.providerForm.models")}</span>
587
+ <button onClick={addProviderModel} type="button">
588
+ <Plus size={13} />
589
+ {t("settings.providerForm.addModel")}
590
+ </button>
591
+ </div>
592
+ <div className="provider-model-list">
593
+ {providerForm.models.map((model, index) => (
594
+ <label className="provider-model-row" key={`${index}-${providerForm.models.length}`}>
595
+ <input
596
+ placeholder="claude-sonnet-4-6"
597
+ value={model}
598
+ onChange={(event) => updateProviderModel(index, event.target.value)}
599
+ />
600
+ <button
601
+ aria-label={t("settings.providerForm.removeModel")}
602
+ disabled={providerForm.models.length <= 1}
603
+ onClick={() => removeProviderModel(index)}
604
+ type="button"
605
+ >
606
+ <Trash2 size={14} />
607
+ </button>
608
+ </label>
609
+ ))}
610
+ </div>
611
+ </div>
612
+
613
+ <div className="provider-form__appearance">
614
+ <div>
615
+ <span>{t("settings.providerForm.color")}</span>
616
+ <div className="provider-color-row">
617
+ {colorOptions.map((color) => (
618
+ <button
619
+ aria-label={t("settings.providerForm.useColor", { color })}
620
+ className={providerForm.iconColor === color ? "is-selected" : ""}
621
+ key={color}
622
+ onClick={() => setProviderForm({ ...providerForm, iconColor: color })}
623
+ style={{ backgroundColor: color }}
624
+ type="button"
625
+ />
626
+ ))}
627
+ </div>
628
+ </div>
629
+ </div>
630
+
631
+ <div className="settings-actions provider-form-modal__actions">
632
+ <button className="settings-button settings-button--ghost" onClick={closeProviderForm} type="button">{t("settings.providerForm.cancel")}</button>
633
+ <button className="settings-button" type="submit">{editingProviderId ? t("settings.providerForm.save") : t("settings.providerForm.addTitle")}</button>
634
+ </div>
635
+ </form>
636
+ </div>
637
+ ) : null}
638
+
639
+ {isMcpFormOpen ? (
640
+ <div className="provider-form-modal" role="dialog" aria-label={editingMcpName ? t("settings.mcpForm.editAria") : t("settings.mcpForm.addAria")}>
641
+ <div className="provider-form-modal__backdrop" onClick={closeMcpForm} />
642
+ <form className="provider-form provider-form--modal mcp-form--modal" onSubmit={saveMcpServer}>
643
+ <header className="provider-form-modal__header">
644
+ <div>
645
+ <span className="settings-modal__eyebrow">{t("settings.mcpForm.eyebrow")}</span>
646
+ <h3>{editingMcpName ? t("settings.mcpForm.editTitle") : t("settings.mcpForm.addTitle")}</h3>
647
+ </div>
648
+ <IconButton label={t("settings.mcpForm.close")} onClick={closeMcpForm}>
649
+ <X size={15} />
650
+ </IconButton>
651
+ </header>
652
+
653
+ <div className="provider-form__grid">
654
+ <label>
655
+ <span>{t("settings.mcpForm.name")}</span>
656
+ <input
657
+ disabled={!!editingMcpName}
658
+ placeholder="filesystem"
659
+ required
660
+ value={mcpForm.name}
661
+ onChange={(event) => setMcpForm({ ...mcpForm, name: event.target.value })}
662
+ />
663
+ </label>
664
+ <div className="provider-form__field">
665
+ <span>{t("settings.mcpForm.transport")}</span>
666
+ <CustomSelect
667
+ ariaLabel={t("settings.mcpForm.transportAria")}
668
+ onChange={(value) => setMcpForm({ ...mcpForm, type: value as McpServerEntry["type"] })}
669
+ options={[
670
+ { label: "stdio", value: "stdio" },
671
+ { label: "http", value: "http" },
672
+ { label: "sse", value: "sse" },
673
+ ]}
674
+ value={mcpForm.type}
675
+ />
676
+ </div>
677
+ {mcpForm.type === "stdio" ? (
678
+ <>
679
+ <label>
680
+ <span>{t("settings.mcpForm.command")}</span>
681
+ <input
682
+ placeholder="npx"
683
+ required
684
+ value={mcpForm.command}
685
+ onChange={(event) => setMcpForm({ ...mcpForm, command: event.target.value })}
686
+ />
687
+ </label>
688
+ <label>
689
+ <span>{t("settings.mcpForm.arguments")}</span>
690
+ <input
691
+ placeholder="-y, @modelcontextprotocol/server-filesystem, /workspace"
692
+ value={mcpForm.args}
693
+ onChange={(event) => setMcpForm({ ...mcpForm, args: event.target.value })}
694
+ />
695
+ </label>
696
+ </>
697
+ ) : (
698
+ <label className="mcp-form__wide">
699
+ <span>{t("settings.mcpForm.url")}</span>
700
+ <input
701
+ placeholder="https://example.com/mcp"
702
+ required
703
+ value={mcpForm.url}
704
+ onChange={(event) => setMcpForm({ ...mcpForm, url: event.target.value })}
705
+ />
706
+ </label>
707
+ )}
708
+ </div>
709
+
710
+ <div className="settings-actions provider-form-modal__actions">
711
+ <button className="settings-button settings-button--ghost" onClick={closeMcpForm} type="button">{t("settings.mcpForm.cancel")}</button>
712
+ <button className="settings-button" type="submit">{editingMcpName ? t("settings.mcpForm.save") : t("settings.mcpForm.addTitle")}</button>
713
+ </div>
714
+ </form>
715
+ </div>
716
+ ) : null}
717
+ </div>
718
+ );
719
+ }