@brainpilot/web 0.0.4 → 0.0.6

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