@brainpilot/web 0.0.9 → 0.0.11

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.
@@ -1,35 +1,45 @@
1
1
  import { FormEvent, useEffect, useState } from "react";
2
- import { Check, Eye, EyeOff, Loader2, Plug, Plus, Settings, SlidersHorizontal, Trash2, UserRound, X } from "lucide-react";
2
+ import { Check, Database, Eye, EyeOff, Loader2, Plug, Plus, Settings, SlidersHorizontal, Trash2, UserRound, X } from "lucide-react";
3
3
  import type { LucideIcon } from "lucide-react";
4
4
  import type { McpServerEntry, ProviderProfile, ProviderApi } from "../../contracts/backend";
5
5
  import { useAuth } from "../../contexts/AuthContext";
6
6
  import { usePreferences } from "../../contexts/PreferencesContext";
7
7
  import { useT } from "../../i18n/useT";
8
8
  import { api } from "../../utils/api";
9
+ import { runtimeConfig } from "../../config";
10
+ import { EXAMPLE_MODEL } from "@brainpilot/protocol";
9
11
  import { CustomSelect } from "../primitives/CustomSelect";
10
12
  import { IconButton } from "../primitives/IconButton";
13
+ import { KnowledgeBasePanel } from "./KnowledgeBasePanel";
11
14
 
12
- type SettingsTab = "account" | "providers" | "mcp" | "preferences";
15
+ type SettingsTab = "account" | "providers" | "mcp" | "knowledgeBase" | "preferences";
13
16
 
14
17
  type SettingsDialogProps = {
15
18
  isOpen: boolean;
16
19
  onClose: () => void;
17
20
  };
18
21
 
19
- const tabs: Array<{ id: SettingsTab; labelKey: string; icon: LucideIcon }> = [
22
+ const ALL_TABS: Array<{ id: SettingsTab; labelKey: string; icon: LucideIcon }> = [
20
23
  { id: "account", labelKey: "settings.tab.account", icon: UserRound },
21
24
  { id: "providers", labelKey: "settings.tab.providers", icon: SlidersHorizontal },
22
25
  { id: "mcp", labelKey: "settings.tab.mcp", icon: Plug },
26
+ { id: "knowledgeBase", labelKey: "settings.tab.knowledgeBase", icon: Database },
23
27
  { id: "preferences", labelKey: "settings.tab.preferences", icon: Settings },
24
28
  ];
25
29
 
30
+ // Local single-user mode has no host-managed identity — the account tab would
31
+ // only show placeholder "local / local / 1970" values, so drop it entirely.
32
+ const tabs = runtimeConfig.localMode
33
+ ? ALL_TABS.filter((tab) => tab.id !== "account")
34
+ : ALL_TABS;
35
+
26
36
  const DEFAULT_PROVIDER_FORM = {
27
37
  name: "",
28
38
  baseUrl: "https://api.anthropic.com",
29
39
  api: "anthropic-messages" as ProviderApi,
30
40
  apiKey: "",
31
41
  apiKeyMasked: "",
32
- models: ["claude-opus-4-6"],
42
+ models: [EXAMPLE_MODEL],
33
43
  iconColor: "#111111",
34
44
  notes: "",
35
45
  };
@@ -56,7 +66,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
56
66
  const { user } = useAuth();
57
67
  const preferences = usePreferences();
58
68
  const t = useT();
59
- const [activeTab, setActiveTab] = useState<SettingsTab>("account");
69
+ const [activeTab, setActiveTab] = useState<SettingsTab>(tabs[0].id);
60
70
  const [providers, setProviders] = useState<ProviderProfile[]>([]);
61
71
  const [mcpServers, setMcpServers] = useState<McpServerEntry[]>([]);
62
72
  const [providerForm, setProviderForm] = useState(DEFAULT_PROVIDER_FORM);
@@ -364,7 +374,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
364
374
  </div>
365
375
  </dl>
366
376
  <p className="settings-note">{t("settings.account.managedByHost")}</p>
367
- {version ? <span className="settings-version">{version}</span> : null}
368
377
  </section>
369
378
  ) : null}
370
379
 
@@ -440,6 +449,19 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
440
449
  </article>
441
450
  );
442
451
 
452
+ if (providers.length === 0) {
453
+ return (
454
+ <div className="settings-empty">
455
+ <SlidersHorizontal size={22} />
456
+ <strong>{t("settings.providers.empty")}</strong>
457
+ <p>{t("settings.providers.emptyHint")}</p>
458
+ <button className="settings-button" onClick={openProviderForm} type="button">
459
+ {t("settings.providers.add")}
460
+ </button>
461
+ </div>
462
+ );
463
+ }
464
+
443
465
  return (
444
466
  <>
445
467
  {sharedProviders.length > 0 ? (
@@ -475,72 +497,113 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
475
497
  {t("settings.mcp.addServer")}
476
498
  </button>
477
499
  </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>
500
+ {mcpServers.length === 0 ? (
501
+ <div className="settings-empty">
502
+ <Plug size={22} />
503
+ <strong>{t("settings.mcp.empty")}</strong>
504
+ <p>{t("settings.mcp.emptyHint")}</p>
505
+ <button className="settings-button" onClick={openMcpForm} type="button">
506
+ {t("settings.mcp.addServer")}
507
+ </button>
508
+ </div>
509
+ ) : (
510
+ <div className="settings-list">
511
+ {mcpServers.map((server) => (
512
+ <article className="settings-list-item" key={server.name}>
513
+ <div>
514
+ <strong>
515
+ {server.name}
516
+ <span className={`mcp-transport-chip mcp-transport-chip--${server.type}`}>{server.type}</span>
517
+ </strong>
518
+ <span>{server.type === "stdio" ? [server.command, ...(server.args || [])].filter(Boolean).join(" ") : server.url}</span>
519
+ </div>
520
+ <div className="settings-list-item__actions mcp-actions">
521
+ <button onClick={() => editMcpServer(server)} type="button">{t("settings.mcp.edit")}</button>
522
+ <button onClick={() => void removeMcpServer(server.name)} type="button">{t("settings.mcp.remove")}</button>
523
+ </div>
524
+ </article>
525
+ ))}
526
+ </div>
527
+ )}
493
528
  </section>
494
529
  ) : null}
495
530
 
531
+ {activeTab === "knowledgeBase" ? <KnowledgeBasePanel /> : null}
532
+
496
533
  {activeTab === "preferences" ? (
497
534
  <section className="settings-section">
498
535
  <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
- />
536
+
537
+ <div className="settings-group">
538
+ <h4 className="settings-group__title">{t("settings.prefs.groupAppearance")}</h4>
539
+ <div className="settings-field settings-field--split">
540
+ <div className="settings-field__label">
541
+ <span>{t("settings.prefs.theme")}</span>
542
+ <small>{t("settings.prefs.themeDesc")}</small>
543
+ </div>
544
+ <CustomSelect
545
+ ariaLabel={t("settings.prefs.theme")}
546
+ onChange={(value) => preferences.setTheme(value as typeof preferences.theme)}
547
+ options={[
548
+ { label: t("settings.prefs.themeLight"), value: "light" },
549
+ { label: t("settings.prefs.themeDark"), value: "dark" },
550
+ { label: t("settings.prefs.themeSystem"), value: "system" },
551
+ ]}
552
+ value={preferences.theme}
553
+ />
554
+ </div>
555
+ <div className="settings-field settings-field--split">
556
+ <div className="settings-field__label">
557
+ <span>{t("settings.prefs.language")}</span>
558
+ <small>{t("settings.prefs.languageDesc")}</small>
559
+ </div>
560
+ <CustomSelect
561
+ ariaLabel={t("settings.prefs.language")}
562
+ onChange={(value) => preferences.setLanguage(value as typeof preferences.language)}
563
+ options={[
564
+ { label: "简体中文", value: "zh-CN" },
565
+ { label: "English", value: "en-US" },
566
+ ]}
567
+ value={preferences.language}
568
+ />
569
+ </div>
511
570
  </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
- />
571
+
572
+ <div className="settings-group">
573
+ <h4 className="settings-group__title">{t("settings.prefs.groupBehavior")}</h4>
574
+ <label className="settings-toggle-row">
575
+ <span className="settings-toggle-row__text">
576
+ <span>{t("settings.prefs.confirmDangerous")}</span>
577
+ <small>{t("settings.prefs.confirmDangerousDesc")}</small>
578
+ </span>
579
+ <input
580
+ checked={preferences.security.confirmDangerousActions}
581
+ onChange={(event) => preferences.setSecurity({ ...preferences.security, confirmDangerousActions: event.target.checked })}
582
+ type="checkbox"
583
+ />
584
+ </label>
585
+ <label className="settings-toggle-row">
586
+ <span className="settings-toggle-row__text">
587
+ <span>{t("settings.prefs.notifyDone")}</span>
588
+ <small>{t("settings.prefs.notifyDoneDesc")}</small>
589
+ </span>
590
+ <input
591
+ checked={preferences.notifications.agentDone}
592
+ onChange={(event) => preferences.setNotifications({ ...preferences.notifications, agentDone: event.target.checked })}
593
+ type="checkbox"
594
+ />
595
+ </label>
523
596
  </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
597
  </section>
541
598
  ) : null}
542
599
  </div>
543
600
  </div>
601
+
602
+ {version ? (
603
+ <footer className="settings-modal__footer">
604
+ <span className="settings-version">{version}</span>
605
+ </footer>
606
+ ) : null}
544
607
  </section>
545
608
 
546
609
  {isProviderFormOpen ? (
@@ -611,7 +674,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
611
674
  {providerForm.models.map((model, index) => (
612
675
  <label className="provider-model-row" key={`${index}-${providerForm.models.length}`}>
613
676
  <input
614
- placeholder="claude-sonnet-4-6"
677
+ placeholder={EXAMPLE_MODEL}
615
678
  value={model}
616
679
  onChange={(event) => updateProviderModel(index, event.target.value)}
617
680
  />
@@ -626,6 +689,9 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
626
689
  </label>
627
690
  ))}
628
691
  </div>
692
+ <p className="provider-form__models-hint">
693
+ {t("settings.providerForm.modelsHint")}
694
+ </p>
629
695
  </div>
630
696
 
631
697
  <div className="provider-form__appearance">
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
+ import { Radio, Server } from "lucide-react";
2
3
  import { useSandbox } from "../../contexts/SandboxContext";
3
4
  import { useSessions } from "../../contexts/SessionContext";
4
5
  import { useT } from "../../i18n/useT";
@@ -24,6 +25,13 @@ function formatBytes(bytes?: number) {
24
25
  return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[index]}`;
25
26
  }
26
27
 
28
+ /** Clamp a percentage into [0, 100] and pick a severity band for the meter fill. */
29
+ function meterLevel(percent?: number): { pct: number; level: "ok" | "warning" | "critical" } {
30
+ const pct = Math.max(0, Math.min(100, Math.round(percent ?? 0)));
31
+ const level = pct >= 90 ? "critical" : pct >= 75 ? "warning" : "ok";
32
+ return { pct, level };
33
+ }
34
+
27
35
  function getConnectionState(status: string, isConnected: boolean): SandboxConnectionState {
28
36
  if (status === "running" && isConnected) {
29
37
  return "connected";
@@ -64,6 +72,7 @@ function getStatusKey(status: string, isConnected: boolean) {
64
72
  export function SandboxStatus() {
65
73
  const [isOpen, setIsOpen] = useState(false);
66
74
  const [logs, setLogs] = useState("");
75
+ const [showLogs, setShowLogs] = useState(false);
67
76
  const [health, setHealth] = useState<Record<string, unknown> | null>(null);
68
77
  const [detailsLoading, setDetailsLoading] = useState(false);
69
78
  const rootRef = useRef<HTMLDivElement | null>(null);
@@ -157,98 +166,119 @@ export function SandboxStatus() {
157
166
  id="sandbox-status-popover"
158
167
  role="dialog"
159
168
  >
160
- <div className="sandbox-status__header">
161
- <span className="sandbox-status__eyebrow">Sandbox</span>
162
- <strong>{t(connectionLabelKey[connection])}</strong>
169
+ <div className={`sandbox-status__hero sandbox-status__hero--${connection}`}>
170
+ <span className="sandbox-status__hero-dot" aria-hidden="true" />
171
+ <div className="sandbox-status__hero-text">
172
+ <strong>{t(connectionLabelKey[connection])}</strong>
173
+ <span className={isLoading ? "is-loading" : ""}>
174
+ {(() => {
175
+ const notice = getStatusKey(effectiveStatus, isConnected);
176
+ return t(notice.key, notice.vars);
177
+ })()}
178
+ </span>
179
+ </div>
163
180
  </div>
164
-
165
- <p className={`sandbox-status__notice ${isLoading ? "is-loading" : ""}`}>
166
- {(() => {
167
- const notice = getStatusKey(effectiveStatus, isConnected);
168
- return t(notice.key, notice.vars);
169
- })()}
170
- </p>
171
181
  {error ? <p className="sandbox-status__empty">{error}</p> : null}
172
182
 
173
183
  {hasSandbox ? (
174
184
  <>
175
- <dl className="sandbox-status__grid">
176
- <div>
177
- <dt>{t("sandbox.label.name")}</dt>
178
- <dd>{currentSandbox.name}</dd>
179
- </div>
180
- <div>
181
- <dt>{t("sandbox.label.id")}</dt>
182
- <dd>{currentSandbox.id}</dd>
183
- </div>
184
- <div>
185
- <dt>{t("sandbox.label.container")}</dt>
186
- <dd>{currentSandbox.containerName || "-"}</dd>
187
- </div>
188
- <div>
189
- <dt>{t("sandbox.label.hostApi")}</dt>
190
- <dd>{currentSandbox.hostApiUrl || "-"}</dd>
191
- </div>
192
- <div>
193
- <dt>{t("sandbox.label.port")}</dt>
194
- <dd>{currentSandbox.port ?? "-"}</dd>
195
- </div>
196
- <div>
197
- <dt>{t("sandbox.label.created")}</dt>
198
- <dd>{new Date(currentSandbox.createdAt).toLocaleString()}</dd>
199
- </div>
200
- </dl>
201
-
202
- <div className="sandbox-status__metrics">
203
- <div>
204
- <span>{t("sandbox.label.memory")}</span>
205
- <strong>
206
- {formatBytes(stats?.memory.usedBytes)} / {formatBytes(stats?.memory.limitBytes)}
207
- </strong>
208
- <small>{stats?.memory.percent ?? 0}%</small>
209
- </div>
210
- <div>
211
- <span>{t("sandbox.label.cpu")}</span>
212
- <strong>{t("sandbox.cpuUsed", { percent: stats?.cpu.usedPercent ?? 0 })}</strong>
213
- <small>
214
- {t("sandbox.cpuQuota", { quota: stats?.cpu.quotaPercent ?? 0, cpus: stats?.cpu.onlineCpus ?? 0 })}
215
- </small>
216
- </div>
217
- <div>
218
- <span>{t("sandbox.label.pids")}</span>
219
- <strong>
220
- {stats?.pids.current ?? 0} / {stats?.pids.limit ?? t("sandbox.unlimited")}
221
- </strong>
222
- </div>
223
- <div>
224
- <span>{t("sandbox.label.disk")}</span>
225
- <strong>
226
- {formatBytes(stats?.disk.workspaceUsedBytes)} / {formatBytes(stats?.disk.quotaBytes)}
227
- </strong>
228
- <small>{stats?.disk.percentOfQuota ?? 0}%</small>
229
- </div>
185
+ <div className="sandbox-status__meters">
186
+ {(() => {
187
+ const rows: Array<{ label: string; percent?: number; detail: string }> = [
188
+ {
189
+ label: t("sandbox.label.memory"),
190
+ percent: stats?.memory.percent,
191
+ detail: `${formatBytes(stats?.memory.usedBytes)} / ${formatBytes(stats?.memory.limitBytes)}`,
192
+ },
193
+ {
194
+ label: t("sandbox.label.disk"),
195
+ percent: stats?.disk.percentOfQuota,
196
+ detail: `${formatBytes(stats?.disk.workspaceUsedBytes)} / ${formatBytes(stats?.disk.quotaBytes)}`,
197
+ },
198
+ {
199
+ label: t("sandbox.label.cpu"),
200
+ percent: stats?.cpu.usedPercent,
201
+ detail: t("sandbox.cpuQuota", { quota: stats?.cpu.quotaPercent ?? 0, cpus: stats?.cpu.onlineCpus ?? 0 }),
202
+ },
203
+ ];
204
+ return rows.map((row) => {
205
+ const { pct, level } = meterLevel(row.percent);
206
+ return (
207
+ <div className="sandbox-meter" key={row.label}>
208
+ <div className="sandbox-meter__head">
209
+ <span>{row.label}</span>
210
+ <strong>{pct}%</strong>
211
+ </div>
212
+ <div className="sandbox-meter__track" aria-hidden="true">
213
+ <span className={`sandbox-meter__fill sandbox-meter__fill--${level}`} style={{ width: `${pct}%` }} />
214
+ </div>
215
+ <small>{row.detail}</small>
216
+ </div>
217
+ );
218
+ });
219
+ })()}
230
220
  </div>
231
221
 
232
- <div className="sandbox-status__health">
233
- <div>
234
- <span>{t("sandbox.label.health")}</span>
235
- <strong>{String(health?.status ?? (detailsLoading ? t("sandbox.health.checking") : t("sandbox.health.unknown")))}</strong>
236
- </div>
237
- <div>
238
- <span>{t("sandbox.label.runtime")}</span>
239
- <strong>{health?.agent_runtime === false ? t("sandbox.health.offline") : health?.agent_runtime ? t("sandbox.health.online") : "-"}</strong>
240
- </div>
241
- <div>
242
- <span>{t("sandbox.label.sse")}</span>
243
- <strong>{isConnected ? t("sandbox.health.connected") : t("sandbox.health.offline")}</strong>
244
- </div>
245
- <div>
246
- <span>{t("sandbox.label.checked")}</span>
247
- <strong>{health?.checked_at ? new Date(String(health.checked_at)).toLocaleTimeString() : "-"}</strong>
248
- </div>
222
+ <div className="sandbox-status__chips">
223
+ {(() => {
224
+ const runtimeOnline = health?.agent_runtime !== false && !!health?.agent_runtime;
225
+ const runtimeKnown = health?.agent_runtime !== undefined;
226
+ return (
227
+ <span className={`sandbox-chip sandbox-chip--${runtimeKnown ? (runtimeOnline ? "ok" : "off") : "unknown"}`}>
228
+ <Server size={13} />
229
+ {t("sandbox.label.runtime")}
230
+ <i className="sandbox-chip__dot" aria-hidden="true" />
231
+ </span>
232
+ );
233
+ })()}
234
+ <span className={`sandbox-chip sandbox-chip--${isConnected ? "ok" : "off"}`}>
235
+ <Radio size={13} />
236
+ {t("sandbox.label.sse")}
237
+ <i className="sandbox-chip__dot" aria-hidden="true" />
238
+ </span>
249
239
  </div>
250
240
 
251
- <pre className="sandbox-status__logs">{detailsLoading ? t("sandbox.logs.loading") : logs || t("sandbox.logs.empty")}</pre>
241
+ <details className="sandbox-status__details">
242
+ <summary>{t("sandbox.details.more")}</summary>
243
+ <dl className="sandbox-status__grid">
244
+ <div>
245
+ <dt>{t("sandbox.label.name")}</dt>
246
+ <dd>{currentSandbox.name}</dd>
247
+ </div>
248
+ <div>
249
+ <dt>{t("sandbox.label.id")}</dt>
250
+ <dd>{currentSandbox.id}</dd>
251
+ </div>
252
+ <div>
253
+ <dt>{t("sandbox.label.container")}</dt>
254
+ <dd>{currentSandbox.containerName || "-"}</dd>
255
+ </div>
256
+ <div>
257
+ <dt>{t("sandbox.label.hostApi")}</dt>
258
+ <dd>{currentSandbox.hostApiUrl || "-"}</dd>
259
+ </div>
260
+ <div>
261
+ <dt>{t("sandbox.label.port")}</dt>
262
+ <dd>{currentSandbox.port ?? "-"}</dd>
263
+ </div>
264
+ <div>
265
+ <dt>{t("sandbox.label.pids")}</dt>
266
+ <dd>{stats?.pids.current ?? 0} / {stats?.pids.limit ?? t("sandbox.unlimited")}</dd>
267
+ </div>
268
+ <div>
269
+ <dt>{t("sandbox.label.created")}</dt>
270
+ <dd>{new Date(currentSandbox.createdAt).toLocaleString()}</dd>
271
+ </div>
272
+ <div>
273
+ <dt>{t("sandbox.label.checked")}</dt>
274
+ <dd>{health?.checked_at ? new Date(String(health.checked_at)).toLocaleTimeString() : "-"}</dd>
275
+ </div>
276
+ </dl>
277
+ </details>
278
+
279
+ {showLogs ? (
280
+ <pre className="sandbox-status__logs">{detailsLoading ? t("sandbox.logs.loading") : logs || t("sandbox.logs.empty")}</pre>
281
+ ) : null}
252
282
  </>
253
283
  ) : (
254
284
  <p className="sandbox-status__empty">{t("sandbox.empty")}</p>
@@ -263,7 +293,21 @@ export function SandboxStatus() {
263
293
  <button disabled={isLoading} onClick={() => void refresh()} type="button">
264
294
  {t("sandbox.action.refresh")}
265
295
  </button>
266
- <button disabled={detailsLoading} onClick={() => void loadDetails()} type="button">
296
+ <button
297
+ aria-pressed={showLogs}
298
+ className={showLogs ? "is-active" : ""}
299
+ disabled={detailsLoading}
300
+ onClick={() => {
301
+ setShowLogs((current) => {
302
+ const next = !current;
303
+ if (next) {
304
+ void loadDetails();
305
+ }
306
+ return next;
307
+ });
308
+ }}
309
+ type="button"
310
+ >
267
311
  {t("sandbox.action.logs")}
268
312
  </button>
269
313
  <button