@steerable/agent-shell-web 0.7.2 → 0.8.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.
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Failed-turn presentation: what to say and what to offer, per failure kind.
3
+ *
4
+ * The backend classifies every provider failure (`LLMError.kind`) and records
5
+ * it with the turn. Matching on that field — never on the message text — keeps
6
+ * the copy stable while error strings change, and keeps untranslated exception
7
+ * names out of the headline. The message itself belongs in the details
8
+ * disclosure, which is for whoever reads it: the user reporting a problem and
9
+ * the person on support.
10
+ */
11
+
12
+ import { t } from '@/i18n';
13
+
14
+ export type FailureKind =
15
+ | 'transport'
16
+ | 'rate_limit'
17
+ | 'server'
18
+ | 'auth'
19
+ | 'invalid_request'
20
+ | 'context_overflow'
21
+ | 'unknown';
22
+
23
+ /** The provider classifications the backend can send. */
24
+ const KINDS: readonly FailureKind[] = [
25
+ 'transport',
26
+ 'rate_limit',
27
+ 'server',
28
+ 'auth',
29
+ 'invalid_request',
30
+ 'context_overflow',
31
+ 'unknown',
32
+ ];
33
+
34
+ /** One action a failed turn can offer. */
35
+ export type FailureAction =
36
+ | { kind: 'retry' }
37
+ | { kind: 'network-diagnostics' }
38
+ | { kind: 'open-model-settings' }
39
+ | { kind: 'new-chat' };
40
+
41
+ /** Structured failure detail persisted with the turn (`messageMetadata.failure`). */
42
+ export interface TurnFailureDetail {
43
+ kind: FailureKind;
44
+ /** Proxy the failing request used, credentials already stripped. */
45
+ proxy?: string;
46
+ /** Provider attempts spent on the request, including the last. */
47
+ attempts?: number;
48
+ statusCode?: number;
49
+ }
50
+
51
+ /** Everything the card renders, derived from one failure. */
52
+ export interface FailurePresentation {
53
+ title: string;
54
+ body: string;
55
+ /** The primary action: the most likely thing to fix this failure. */
56
+ primary: FailureAction;
57
+ secondary: FailureAction[];
58
+ /** True when the details disclosure carries a classified cause. */
59
+ classified: boolean;
60
+ }
61
+
62
+ /**
63
+ * Narrows an unknown metadata value to a failure kind.
64
+ *
65
+ * Returns undefined for anything unrecognized — an older sidecar that sent
66
+ * nothing, a value from a newer one, a corrupted record. The caller then shows
67
+ * the generic card with the raw message, which is still actionable when the
68
+ * only remedy is reporting it.
69
+ */
70
+ export function readFailureKind(value: unknown): FailureKind | undefined {
71
+ return typeof value === 'string' && (KINDS as readonly string[]).includes(value)
72
+ ? (value as FailureKind)
73
+ : undefined;
74
+ }
75
+
76
+ /**
77
+ * Copy and actions for one failure.
78
+ *
79
+ * The primary action is the one most likely to make the next attempt work:
80
+ * a rejected key needs settings, an over-long conversation needs a fresh one,
81
+ * and everything else is worth retrying once the cause clears. Actions whose
82
+ * handler is unavailable at render time (`available`) are dropped by the card,
83
+ * so this function can describe the ideal set without knowing the shell.
84
+ */
85
+ export function failurePresentation(
86
+ detail: TurnFailureDetail | undefined,
87
+ options: { completedSteps?: number } = {},
88
+ ): FailurePresentation {
89
+ const steps = options.completedSteps ?? 0;
90
+ const attempts = detail?.attempts;
91
+ const retried =
92
+ typeof attempts === 'number' && attempts > 1
93
+ ? t('Retried {count} times. ', { count: attempts - 1 })
94
+ : '';
95
+ switch (detail?.kind) {
96
+ case 'transport': {
97
+ const next =
98
+ steps > 0
99
+ ? t(
100
+ "When your network is back, select Continue. The {count} completed steps won't run again.",
101
+ { count: steps },
102
+ )
103
+ : t('When your network is back, select Continue.');
104
+ return {
105
+ title: t("Can't reach the model service right now"),
106
+ body: t('The connection kept dropping. ') + retried + next,
107
+ primary: { kind: 'retry' },
108
+ secondary: [{ kind: 'network-diagnostics' }],
109
+ classified: true,
110
+ };
111
+ }
112
+ case 'auth':
113
+ return {
114
+ title: t('The model service rejected the API key'),
115
+ body: t('Check the API key in settings, then select Continue.'),
116
+ primary: { kind: 'open-model-settings' },
117
+ secondary: [{ kind: 'retry' }],
118
+ classified: true,
119
+ };
120
+ case 'rate_limit':
121
+ return {
122
+ title: t('The model service is limiting requests'),
123
+ body: t('Select Continue in a moment.'),
124
+ primary: { kind: 'retry' },
125
+ secondary: [],
126
+ classified: true,
127
+ };
128
+ case 'server':
129
+ return {
130
+ title: t('The model service had an error'),
131
+ body: t('Select Continue in a moment.'),
132
+ primary: { kind: 'retry' },
133
+ secondary: [],
134
+ classified: true,
135
+ };
136
+ case 'context_overflow':
137
+ return {
138
+ title: t('This conversation is too long for the model'),
139
+ body: t('Start a new chat, or select Regenerate to try a different approach.'),
140
+ primary: { kind: 'new-chat' },
141
+ secondary: [{ kind: 'retry' }],
142
+ classified: true,
143
+ };
144
+ case 'invalid_request':
145
+ return {
146
+ title: t('The model service rejected this request'),
147
+ body: t('Select Continue to try again, or copy the diagnostic info to report it.'),
148
+ primary: { kind: 'retry' },
149
+ secondary: [],
150
+ classified: true,
151
+ };
152
+ default:
153
+ return {
154
+ title: t('Reply failed'),
155
+ body: t('Select Continue to try again, or copy the diagnostic info to report it.'),
156
+ primary: { kind: 'retry' },
157
+ secondary: [],
158
+ classified: false,
159
+ };
160
+ }
161
+ }
162
+
163
+ /** Reads the persisted failure block, dropping fields of the wrong type. */
164
+ export function readFailureDetail(value: unknown): TurnFailureDetail | undefined {
165
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
166
+ const record = value as Record<string, unknown>;
167
+ const kind = readFailureKind(record.errorKind);
168
+ if (!kind) return undefined;
169
+ const proxy =
170
+ typeof record.proxy === 'string' && record.proxy ? record.proxy : undefined;
171
+ const attempts =
172
+ typeof record.attempts === 'number' && Number.isFinite(record.attempts)
173
+ ? record.attempts
174
+ : undefined;
175
+ const statusCode =
176
+ typeof record.statusCode === 'number' && Number.isFinite(record.statusCode)
177
+ ? record.statusCode
178
+ : undefined;
179
+ return {
180
+ kind,
181
+ ...(proxy ? { proxy } : {}),
182
+ ...(attempts !== undefined ? { attempts } : {}),
183
+ ...(statusCode !== undefined ? { statusCode } : {}),
184
+ };
185
+ }
@@ -1,6 +1,11 @@
1
- import { useState } from 'react';
1
+ import { useEffect, useState } from 'react';
2
2
  import { LuCircleCheck, LuCircleX, LuLoader, LuPlay } from 'react-icons/lu';
3
- import { diagnoseLlmConnection, type DiagnoseResult } from '@/lib/local-api';
3
+ import {
4
+ diagnoseLlmConnection,
5
+ getReliabilitySummary,
6
+ type DiagnoseResult,
7
+ type ReliabilitySummary,
8
+ } from '@/lib/local-api';
4
9
  import { t } from '@/i18n';
5
10
 
6
11
  /**
@@ -8,6 +13,110 @@ import { t } from '@/i18n';
8
13
  * (DNS → TCP → TLS → HTTP /models → chat completion),并报告
9
14
  * 宿主机的 ambient 代理配置。
10
15
  */
16
+ /**
17
+ * 「最近 7 天」:失败与重试的统计。
18
+ *
19
+ * 与下面的连接检查分开:检查回答「现在能不能连上」,统计回答「这一周
20
+ * 到底发生了什么」。没有回复时显示空状态,而不是一排 0——0 会被读成
21
+ * 「一切正常」,实际是「还没数据」。
22
+ */
23
+ function ReliabilityPanel() {
24
+ const [summary, setSummary] = useState<ReliabilitySummary | null>(null);
25
+ const [error, setError] = useState<string | null>(null);
26
+
27
+ useEffect(() => {
28
+ let cancelled = false;
29
+ void getReliabilitySummary(7)
30
+ .then((value) => {
31
+ if (!cancelled) setSummary(value);
32
+ })
33
+ .catch((err: unknown) => {
34
+ if (!cancelled) setError(err instanceof Error ? err.message : String(err));
35
+ });
36
+ return () => {
37
+ cancelled = true;
38
+ };
39
+ }, []);
40
+
41
+ if (error) {
42
+ return (
43
+ <p className="mt-2 text-xs text-agent-muted-foreground" data-testid="reliability-error">
44
+ {t('Could not load the statistics: {error}', { error })}
45
+ </p>
46
+ );
47
+ }
48
+ if (!summary) {
49
+ return (
50
+ <p className="mt-2 text-xs text-agent-muted-foreground" data-testid="reliability-loading">
51
+ {t('Loading…')}
52
+ </p>
53
+ );
54
+ }
55
+ if (summary.total === 0) {
56
+ return (
57
+ <p className="mt-2 text-xs text-agent-muted-foreground" data-testid="reliability-empty">
58
+ {t('No replies in the last 7 days')}
59
+ </p>
60
+ );
61
+ }
62
+
63
+ return (
64
+ <div className="mt-2 space-y-2" data-testid="reliability-summary">
65
+ <div className="grid grid-cols-3 gap-2">
66
+ <Stat value={summary.total} label={t('Replies')} />
67
+ <Stat value={summary.failed} label={t('Failed')} />
68
+ <Stat value={summary.recoveredByRetry} label={t('Recovered by retry')} />
69
+ </div>
70
+ {summary.failuresByKind.length > 0 ? (
71
+ <ul className="space-y-1 rounded-agent-sm bg-agent-muted/40 px-2.5 py-1.5 text-xs">
72
+ {summary.failuresByKind.map((row) => (
73
+ <li key={row.kind} className="flex items-center justify-between gap-2">
74
+ <span className="text-agent-muted-foreground">{kindLabel(row.kind)}</span>
75
+ <span className="font-mono text-agent-foreground">{row.count}</span>
76
+ </li>
77
+ ))}
78
+ </ul>
79
+ ) : null}
80
+ {summary.usedDirect > 0 ? (
81
+ <p className="text-xs text-agent-muted-foreground">
82
+ {t('{count} replies went direct instead of through the system proxy.', {
83
+ count: summary.usedDirect,
84
+ })}
85
+ </p>
86
+ ) : null}
87
+ </div>
88
+ );
89
+ }
90
+
91
+ function Stat({ value, label }: { value: number; label: string }) {
92
+ return (
93
+ <div className="rounded-agent-sm bg-agent-muted/40 px-2.5 py-1.5">
94
+ <div className="font-mono text-sm text-agent-foreground">{value}</div>
95
+ <div className="text-[11px] text-agent-muted-foreground">{label}</div>
96
+ </div>
97
+ );
98
+ }
99
+
100
+ /** Provider failure kinds, in the words a user reads. */
101
+ function kindLabel(kind: string): string {
102
+ switch (kind) {
103
+ case 'transport':
104
+ return t('Network');
105
+ case 'auth':
106
+ return t('Credentials');
107
+ case 'rate_limit':
108
+ return t('Rate limit');
109
+ case 'server':
110
+ return t('Service error');
111
+ case 'context_overflow':
112
+ return t('Conversation too long');
113
+ case 'invalid_request':
114
+ return t('Rejected request');
115
+ default:
116
+ return t('Other');
117
+ }
118
+ }
119
+
11
120
  export function DiagnoseSettingsPanel() {
12
121
  const [running, setRunning] = useState(false);
13
122
  const [result, setResult] = useState<DiagnoseResult | null>(null);
@@ -36,6 +145,8 @@ export function DiagnoseSettingsPanel() {
36
145
  {t('The diagnosis runs in the host process and also reports system and environment proxy settings.')}
37
146
  </p>
38
147
 
148
+ <ReliabilityPanel />
149
+
39
150
  <button
40
151
  type="button"
41
152
  onClick={() => void run()}
@@ -52,6 +52,11 @@ import {
52
52
 
53
53
  export interface LlmSettingsPanelHandle {
54
54
  save: () => Promise<void>;
55
+ /**
56
+ * 把光标放到 API Key 输入框。返回 false 表示字段当前不存在(面板还在
57
+ * 加载,或该服务商没有这个字段),调用方据此决定是否稍后再试。
58
+ */
59
+ focusApiKey: () => boolean;
55
60
  }
56
61
 
57
62
  export interface LlmSaveUi {
@@ -389,8 +394,16 @@ export const LlmSettingsPanel = forwardRef<LlmSettingsPanelHandle, LlmSettingsPa
389
394
 
390
395
  const handleSaveRef = useRef(handleSave);
391
396
  handleSaveRef.current = handleSave;
397
+ const apiKeyRef = useRef<HTMLInputElement | null>(null);
398
+
392
399
  useImperativeHandle(ref, () => ({
393
400
  save: () => handleSaveRef.current(),
401
+ focusApiKey: () => {
402
+ const field = apiKeyRef.current;
403
+ if (!field) return false;
404
+ field.focus();
405
+ return document.activeElement === field;
406
+ },
394
407
  }), []);
395
408
 
396
409
  useEffect(() => {
@@ -482,7 +495,12 @@ export const LlmSettingsPanel = forwardRef<LlmSettingsPanelHandle, LlmSettingsPa
482
495
  </label>
483
496
  <div className="flex items-center gap-2">
484
497
  <input
498
+ ref={apiKeyRef}
485
499
  type="password"
500
+ // The failure card's 「打开模型设置」 targets this field:
501
+ // an authentication failure is fixed here, so the panel
502
+ // focuses it instead of leaving the reader to hunt.
503
+ data-settings-field="llm-api-key"
486
504
  value={settings.apiKey || ''}
487
505
  onChange={(e) =>
488
506
  setSettings((prev) => ({ ...prev, apiKey: e.target.value }))
@@ -365,7 +365,7 @@ function AgentLayoutContent() {
365
365
  return {
366
366
  kind,
367
367
  contentId,
368
- title: request.title || contentTitle(contentId, slot.title),
368
+ title: request.title || contentTitle(contentId, t(slot.title)),
369
369
  };
370
370
  }, [packChatSlots]);
371
371
 
@@ -192,7 +192,7 @@ function unopenedPanels(
192
192
  const Icon = slot.Icon;
193
193
  entries.push({
194
194
  id: slot.slotId,
195
- title: slot.title,
195
+ title: t(slot.title),
196
196
  testId: `header-slot-${slot.slotId}`,
197
197
  icon: <Icon className="h-3.5 w-3.5 shrink-0" />,
198
198
  });
@@ -143,7 +143,7 @@ export function normalizeRightPanelEntry(
143
143
  *
144
144
  * 兼容这些历史格式:
145
145
  * - v2:{"version":2,"sessions":{"<chatId>":{"tabs":[...],"activeTabId":"..."}}};
146
- * - 旧多标签:{"<chatId>": { "tabs": ["ppt", "terminal"], "active": "ppt" }};
146
+ * - 旧多标签:{"<chatId>": { "tabs": ["<slotId>", "terminal"], "active": "<slotId>" }};
147
147
  * - 单值映射:{"<chatId>": "terminal" | "<slotId>"} → 一个标签;
148
148
  * - 整个值就是单个字符串 → 迁到当前会话;
149
149
  * - 更老:只记录终端是否打开过的布尔 key。
@@ -915,6 +915,24 @@ export async function installCommandLineTool(name: string) {
915
915
  });
916
916
  }
917
917
 
918
+ /** Reliability numbers for the settings page's network section (R4). */
919
+ export interface ReliabilitySummary {
920
+ sinceDays: number;
921
+ total: number;
922
+ failed: number;
923
+ cancelled: number;
924
+ recoveredByRetry: number;
925
+ usedDirect: number;
926
+ failuresByKind: Array<{ kind: string; count: number }>;
927
+ }
928
+
929
+ export async function getReliabilitySummary(days = 7) {
930
+ return bridge().localBackend.request<ReliabilitySummary>({
931
+ method: 'GET',
932
+ path: `/api/v2/usage/reliability?days=${encodeURIComponent(days)}`,
933
+ });
934
+ }
935
+
918
936
  export async function diagnoseLlmConnection(input: {
919
937
  baseUrl?: string;
920
938
  apiKey?: string;
@@ -10,6 +10,8 @@ import {
10
10
  } from "@/lib/chat-transport";
11
11
  import { ChatHeader } from "@/components/ChatHeader";
12
12
  import { EmptyChatHero } from "@/components/chat/EmptyChatHero";
13
+ import type { RetryState } from "@/components/chat/RetryStatus";
14
+ import type { FailureAction } from "@/components/chat/turn-failure";
13
15
  import { LocalChatPanel } from "@/components/chat/LocalChatPanel";
14
16
  import { ModelPicker } from "@/components/chat/ModelPicker";
15
17
  import {
@@ -540,6 +542,11 @@ function AgentChatView({
540
542
  // before this signal, the UI looked frozen whenever the model paused to
541
543
  // wait for tool results.
542
544
  const [currentRound, setCurrentRound] = useState(1);
545
+ // Live "reconnecting" state for the in-flight turn. Set by the backend's
546
+ // `turn_retry` notice, cleared when the round ends or the stream settles —
547
+ // a retry that succeeded leaves no trace in the UI or in history.
548
+ const [retryState, setRetryState] = useState<RetryState | null>(null);
549
+ const navigate = useNavigate();
543
550
  // Holds the latest in-flight actions for the `message_id` reconciliation
544
551
  // — `useState`'s setter sees the latest value via the functional update
545
552
  // form, but we keep this ref so the reconciliation event handler can read
@@ -624,6 +631,21 @@ function AgentChatView({
624
631
  // 这不是新一轮,跳过计数。
625
632
  if (ev.payload?.status === "cancelled") return;
626
633
  setCurrentRound((r) => r + 1);
634
+ // A new round means the previous request finished (or was replaced), so
635
+ // any "reconnecting" line belongs to the round that is now over.
636
+ setRetryState(null);
637
+ return;
638
+ }
639
+ if (ev.event === "turn_retry") {
640
+ // The backend announces each retry; the first one starts the clock the
641
+ // status line counts from. Later ones keep it so the count is the whole
642
+ // wait, not just the latest attempt.
643
+ const delayMs =
644
+ typeof ev.payload?.delayMs === "number" ? ev.payload.delayMs : undefined;
645
+ setRetryState((prev) => ({
646
+ startedAt: prev?.startedAt ?? Date.now(),
647
+ ...(delayMs !== undefined ? { delayMs } : {}),
648
+ }));
627
649
  return;
628
650
  }
629
651
  if (ev.event === "orchestration_child") {
@@ -942,6 +964,7 @@ function AgentChatView({
942
964
  pendingTurnFilesRef.current = [];
943
965
  setCurrentTurnFiles([]);
944
966
  setCurrentRound(1);
967
+ setRetryState(null);
945
968
  const started = Date.now();
946
969
  turnStartedAtRef.current = started;
947
970
  setCurrentTurnStartedAtMs(started);
@@ -1059,6 +1082,7 @@ function AgentChatView({
1059
1082
  pendingTurnFilesRef.current = [];
1060
1083
  setCurrentTurnFiles([]);
1061
1084
  setCurrentRound(1);
1085
+ setRetryState(null);
1062
1086
  const started = Date.now();
1063
1087
  turnStartedAtRef.current = started;
1064
1088
  setCurrentTurnStartedAtMs(started);
@@ -1081,6 +1105,63 @@ function AgentChatView({
1081
1105
  setInterrupted(false);
1082
1106
  }, []);
1083
1107
 
1108
+ // 「新建对话」失败时的就地提示;点一次清一次。
1109
+ const [newChatError, setNewChatError] = useState<string | null>(null);
1110
+
1111
+ /**
1112
+ * 失败卡片的按钮。和「继续上次回复」走同一条 resume 通道:sidecar 回放
1113
+ * durable record,已完成的工具调用不会再执行一遍——这正是失败后点
1114
+ * 「继续」想要的语义。本轮正在跑时按钮禁用(同一会话不能同时两轮)。
1115
+ */
1116
+ const failureAction = useCallback(
1117
+ (action: FailureAction) => {
1118
+ switch (action.kind) {
1119
+ case "retry":
1120
+ return {
1121
+ onClick: () => void handleContinueInterrupted(),
1122
+ disabled: isStreaming,
1123
+ hint: isStreaming ? t("This chat is running. Continue when it finishes.") : undefined,
1124
+ };
1125
+ case "network-diagnostics":
1126
+ // 诊断区挂在设置页「通用」下(settingsChrome('diagnose') 控制显隐)。
1127
+ return settingsChrome("diagnose")
1128
+ ? { onClick: () => navigate("/settings") }
1129
+ : null;
1130
+ case "open-model-settings":
1131
+ return settingsChrome("llm") && hasHostBridge()
1132
+ ? { onClick: () => setLlmSettingsOpen(true) }
1133
+ : null;
1134
+ case "new-chat":
1135
+ // 同项目的空会话:对话过长时的新出路,而不是把用户丢回落地页再
1136
+ // 重选项目、重填输入。失败时提示并留在原地,不吞掉这次点击。
1137
+ return {
1138
+ onClick: () => {
1139
+ void ctx
1140
+ .createChat({
1141
+ ...(chat?.projectId ? { projectId: chat.projectId } : {}),
1142
+ ...(chat?.agentId ? { agentId: chat.agentId } : {}),
1143
+ })
1144
+ .then((id) => {
1145
+ if (id) navigate(`/agent/${id}`);
1146
+ else setNewChatError(t("Failed to create the chat. Please try again."));
1147
+ })
1148
+ .catch((err: unknown) =>
1149
+ setNewChatError(err instanceof Error ? err.message : String(err)),
1150
+ );
1151
+ },
1152
+ };
1153
+ }
1154
+ },
1155
+ [
1156
+ chat?.agentId,
1157
+ chat?.projectId,
1158
+ ctx,
1159
+ handleContinueInterrupted,
1160
+ isStreaming,
1161
+ navigate,
1162
+ ],
1163
+ );
1164
+
1084
1165
  // 轮中插队 + 失败兜底(W6-2):streaming 期间 ⌘/Ctrl+Enter 优先注入运行中的
1085
1166
  // CoreLoop 回合;注入不了时 hook 按回合实况降级——仍在跑则排入待发队列
1086
1167
  // ('queued',ChatInput 提示"已改为排队"),恰好已结束则作为新回合直发
@@ -1322,6 +1403,8 @@ function AgentChatView({
1322
1403
  currentTurnChildren={effectiveCurrentTurnChildren}
1323
1404
  orchestrationChildrenByMessageId={orchestrationChildrenByMessageId}
1324
1405
  currentRound={currentRound}
1406
+ retryState={retryState}
1407
+ failureAction={failureAction}
1325
1408
  interrupted={interrupted}
1326
1409
  onContinueInterrupted={handleContinueInterrupted}
1327
1410
  onDismissInterrupted={handleDismissInterrupted}
@@ -1369,6 +1452,23 @@ function AgentChatView({
1369
1452
  }
1370
1453
  inputBanner={
1371
1454
  <>
1455
+ {newChatError ? (
1456
+ <div
1457
+ role="status"
1458
+ data-testid="new-chat-error"
1459
+ className="mb-1 flex items-center justify-between gap-2 rounded-agent-md border border-agent-destructive/20 bg-agent-destructive/10 px-2.5 py-1.5 text-xs text-agent-destructive"
1460
+ >
1461
+ <span>{newChatError}</span>
1462
+ <button
1463
+ type="button"
1464
+ onClick={() => setNewChatError(null)}
1465
+ className="rounded px-1 hover:bg-agent-destructive/10"
1466
+ aria-label={t("Dismiss")}
1467
+ >
1468
+ ✕
1469
+ </button>
1470
+ </div>
1471
+ ) : null}
1372
1472
  <GoalLoopStatusBar
1373
1473
  chatId={chatId}
1374
1474
  goal={goal}
@@ -220,7 +220,7 @@ export function SettingsPage() {
220
220
  for (const panel of packPanels) {
221
221
  list.push({
222
222
  id: panel.panelId,
223
- label: panel.title,
223
+ label: t(panel.title),
224
224
  Icon: panel.Icon ?? LuSettings,
225
225
  });
226
226
  }
@@ -503,7 +503,7 @@ export function SettingsPage() {
503
503
  {panel.Icon ? (
504
504
  <panel.Icon className="h-3.5 w-3.5 text-agent-muted-foreground" />
505
505
  ) : null}
506
- {panel.title}
506
+ {t(panel.title)}
507
507
  </h2>
508
508
  <panel.Component />
509
509
  </section>