@goodandready/dsh-clinebot 0.5.32 → 0.6.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.
package/lib/client.js CHANGED
@@ -51,7 +51,7 @@ const en = {
51
51
  'header.sub': 'Connect models under your active ClinePass subscription. OpenAI API compatible, automated rolling limit tracking, and instant auto-registration in DSH.',
52
52
  'badge.online': 'Host online ({latency} ms)',
53
53
  'badge.offline': 'Host unreachable',
54
- 'badge.key_ok': 'Key ✓ ({source})',
54
+ 'badge.key_ok': 'Key set ({source})',
55
55
  'badge.key_missing': 'Key missing',
56
56
  'badge.registered': 'DSH Registered ({count} models)',
57
57
  'badge.not_registered': 'Not registered',
@@ -65,8 +65,8 @@ const en = {
65
65
  'key.saving': 'Saving…',
66
66
  'key.login_fast': 'Open API Keys ↗',
67
67
  'key.verifying': 'Validating key with Cline…',
68
- 'key.verified': '✓ Valid key for {email} ({plan})',
69
- 'key.invalid': '✗ Key validation failed: {error}',
68
+ 'key.verified': 'Valid key for {email} ({plan})',
69
+ 'key.invalid': 'Key validation failed: {error}',
70
70
  'key.env_label': 'Credential environment name: ',
71
71
  'key.get_key': 'Get key in app.cline.bot console ↗',
72
72
  'key.saved_msg': 'Key saved to DSH Credentials. Validation: {status}',
@@ -93,6 +93,11 @@ const en = {
93
93
  'accounts.route_model_placeholder': 'Model ID (e.g. cline-pass/deepseek-v4-pro)',
94
94
  'accounts.route_account_placeholder': 'Target Account',
95
95
  'accounts.route_btn_add': 'Add Route',
96
+ 'accounts.actions': 'Account actions', 'accounts.configured': 'Configured', 'accounts.missing_key': 'Missing key', 'accounts.source': 'Source: {source}',
97
+ 'accounts.quota_5h': '5h {pct}%', 'accounts.rename': 'Rename', 'accounts.rename_save': 'Save name', 'accounts.rename_placeholder': 'Account label',
98
+ 'accounts.clear_key': 'Clear stored key', 'accounts.clear_confirm': 'Clear the stored key for {name}? The account row stays.',
99
+ 'accounts.remove_confirm': 'Remove {name} from the pool?', 'accounts.confirm': 'Confirm', 'accounts.cancel': 'Cancel', 'accounts.unpin': 'Unpin',
100
+ 'accounts.no_accounts': 'No accounts configured yet.', 'accounts.route_remove': 'Remove route',
96
101
  'quota.title': 'ClinePass Quota & Rate Limits',
97
102
  'quota.desc': 'Official rolling window request limits from ClinePass',
98
103
  'quota.account': 'Account: {email} · Plan: {plan}',
@@ -119,9 +124,9 @@ const en = {
119
124
  'models.caps_vision': 'Vision',
120
125
  'models.caps_general': 'General',
121
126
  'models.caps_coding': 'Coding',
122
- 'diag.smoke_result': '✅ Latency: {latency} ms | Model: {model}\nResponse: {preview}',
127
+ 'diag.smoke_result': 'Latency: {latency} ms | Model: {model}\nResponse: {preview}',
123
128
  'diag.empty': '(empty)',
124
- 'ui.error_title': '⚠️ ClineBot UI Error: ',
129
+ 'ui.error_title': 'ClineBot UI Error: ',
125
130
  'ui.retry': 'Retry',
126
131
  'update.check_failed': 'Failed to check for updates',
127
132
  'update.retry': 'Retry check',
@@ -242,13 +247,86 @@ const en = {
242
247
  'composer.completion_tok': 'Completion Tokens',
243
248
  'composer.reasoning_tok': 'Reasoning Tokens',
244
249
  'composer.est_cost': 'Estimated Cost',
250
+ 'composer.unpriced': 'unpriced', 'composer.cache_read': 'Cache Read', 'composer.cache_write': 'Cache Write',
251
+ 'composer.price_source': 'Price', 'composer.price_estimate': 'estimate', 'composer.price_override': 'override',
245
252
  'discovery.title': 'Discovered Local Keys',
246
253
  'discovery.import_btn': 'Import from {source}',
247
254
  'discovery.imported': 'Imported to accounts pool',
248
255
  'discovery.scanning': 'Scanning for local Cline keys…',
256
+ 'settings.unsaved': '{count} unsaved change(s)', 'settings.saving': 'Saving…', 'settings.saved': 'Settings saved', 'settings.save_failed': 'Could not save settings', 'settings.save': 'Save', 'settings.discard': 'Discard',
257
+ 'settings.save_invalid_base_url': 'Base URL must be https:// or a localhost address.', 'settings.save_invalid_number': 'Timeouts and payload limits must be positive numbers.',
258
+ 'settings.models_group': 'Models & context', 'settings.models_group_hint': 'Plan filter, visible models, context windows, output limits and reasoning defaults.',
259
+ 'settings.integrations': 'Integrations & display', 'settings.integrations_desc': 'Surfaces outside the chat request that reuse this provider.',
260
+ 'settings.web_search': 'Serve web search with ClineBot', 'settings.web_search_hint': 'Use the ClinePass endpoint for the DSH web_search tool.',
261
+ 'settings.sidebar_quota': 'Show quota meter in the sidebar', 'settings.sidebar_quota_hint': 'Display the compact plan and rolling-window meter above Settings.',
262
+ 'settings.composer_cost': 'Show session cost in the composer', 'settings.composer_cost_hint': 'Display the estimated token cost beside the composer.',
263
+ 'settings.proxy_mode': 'Transparent loopback proxy', 'settings.proxy_mode_hint': 'Legacy transport only: route requests through the local failover proxy.',
264
+ 'settings.advanced': 'Advanced', 'settings.advanced_desc': 'Connection, timeouts, storage paths and the temporary transport switch.',
265
+ 'settings.base_url': 'API base URL', 'settings.base_url_hint': 'ClinePass API root. Remote endpoints must use https://.',
266
+ 'settings.timeout': 'Connect timeout (ms)', 'settings.timeout_hint': 'Maximum wait for the upstream response headers.', 'settings.smoke_timeout': 'Smoke-test timeout (ms)', 'settings.smoke_timeout_hint': 'Maximum wait for the diagnostic chat completion.',
267
+ 'settings.stream_idle': 'Stream idle timeout (ms)', 'settings.stream_idle_hint': 'Maximum gap between SSE chunks before the stream is treated as dead.', 'settings.max_payload': 'Max multimodal payload (MB)', 'settings.max_payload_hint': 'Oldest images are evicted once the request exceeds this budget.',
268
+ 'settings.stats_path': 'Stats file path', 'settings.stats_path_hint': 'Persistent JSON telemetry path (supports ~/).', 'settings.models_cache_path': 'Models cache path', 'settings.models_cache_path_hint': 'Offline model catalogue cache (supports ~/).',
269
+ 'settings.transport': 'Transport', 'settings.transport_hint': 'Native ctx.llm adapter (recommended) or the legacy llm-pi-ai + loopback proxy.',
270
+ 'providerCard.title': 'ClineBot (ClinePass)',
271
+ 'providerCard.hint': 'Paste a ClinePass API key or manage the full account pool in Settings → ClineBot.',
272
+ 'providerCard.keyPresent': 'Key configured',
273
+ 'providerCard.keyMissing': 'Key missing',
274
+ 'providerCard.routeActive': 'Route active',
275
+ 'providerCard.placeholder': 'Paste API key',
276
+ 'providerCard.replace': 'Replace API key',
277
+ 'providerCard.save': 'Save key',
278
+ 'providerCard.saving': 'Saving…',
279
+ 'providerCard.saved': 'Key saved and validated',
280
+ 'providerCard.failed': 'Could not save the key',
281
+ 'providerCard.show': 'Show',
282
+ 'providerCard.hide': 'Hide',
283
+ 'providerCard.openSettings': 'Open Settings → ClineBot for accounts, models and quotas.',
284
+ 'dashboard.tab_quota': 'Quota', 'dashboard.tab_analytics': 'Analytics', 'dashboard.auto_account': 'Automatic (least used)',
285
+ 'analytics.title': 'Usage analytics', 'analytics.requests': 'Requests', 'analytics.tokens': 'Tokens', 'analytics.cost': 'Estimated cost', 'analytics.success': 'Success rate', 'analytics.ttft': 'Avg TTFT',
286
+ 'analytics.loading': 'Loading analytics…', 'analytics.no_data': 'No analytics yet. Send a few messages first.',
287
+ 'analytics.unpriced': '{count} request(s) have no price estimate and are excluded from cost totals.',
288
+ 'analytics.group_model': 'Models', 'analytics.group_day': 'Days', 'analytics.group_session': 'Sessions',
289
+ 'analytics.heatmap': 'Request heatmap', 'analytics.heatmap_hint': 'Requests per hour over the last 7 days.',
290
+ 'analytics.heatmap_less': 'Less', 'analytics.heatmap_more': 'More',
291
+ 'analytics.heatmap_empty': 'No hourly data yet. Send a message to start the heatmap.',
292
+ 'analytics.input': 'Input', 'analytics.output': 'Output', 'analytics.reasoning': 'Reasoning',
293
+ 'analytics.cache_read': 'Cache read', 'analytics.cache_write': 'Cache write',
294
+ 'analytics.avg_ttft': 'Avg TTFT', 'analytics.avg_latency': 'Avg latency', 'analytics.errors': 'Errors',
295
+ 'analytics.no_key': 'ClinePass key is not configured.',
296
+ 'analytics.no_key_hint': 'Open Settings → ClineBot and paste a key, or import a discovered local key.',
297
+ 'analytics.health_error': 'ClinePass endpoint is unreachable.',
298
+ 'analytics.not_registered': 'ClineBot provider is not registered with DSH.',
299
+ 'analytics.not_registered_hint': 'Open Settings → ClineBot and check Diagnostics.',
300
+ 'analytics.stale': 'Showing the last successful snapshot.',
301
+ 'analytics.range': 'Range', 'analytics.range_7': '7 days', 'analytics.range_30': '30 days', 'analytics.range_90': '90 days',
302
+ 'analytics.heatmap_all_models': 'All models', 'analytics.heatmap_peak': 'Peak',
303
+ 'analytics.heatmap_by_hour': 'By hour', 'analytics.heatmap_model': 'Heatmap model',
304
+ 'quota.no_windows': 'No quota windows reported.',
305
+ 'prices.title': 'Price overrides', 'prices.desc': 'USD per 1M tokens. Blank uses the local estimate.',
306
+ 'prices.show': 'Show prices', 'prices.hide': 'Hide prices', 'prices.model': 'Model',
307
+ 'prices.input': 'Input', 'prices.output': 'Output', 'prices.cache_read': 'Cache read', 'prices.cache_write': 'Cache write',
308
+ 'prices.auto': 'auto', 'prices.empty': 'No models available.',
309
+ 'prices.hint': 'These rates affect the composer and local analytics only; they are not an invoice.',
249
310
  }
250
311
 
251
- const zh = {
312
+ function makeT(dict, fallback) {
313
+ return function t(key, vars) {
314
+ if (key === 'quota.countdown' && vars?.time === 'resetting') {
315
+ return (dict && dict['quota.resetting']) || (fallback && fallback['quota.resetting']) || '正在重置'
316
+ }
317
+ let val = (dict && dict[key]) || (fallback && fallback[key]) || key
318
+ if (vars && typeof val === 'string') {
319
+ for (const k of Object.keys(vars)) {
320
+ val = val.replace(new RegExp('\\{' + k + '\\}', 'g'), String(vars[k]))
321
+ }
322
+ }
323
+ return val
324
+ }
325
+ }
326
+
327
+ // --- src/client/locales-zh.js ---
328
+ // Chinese locale dictionary.
329
+ const zh = {
252
330
  nav: 'ClineBot',
253
331
  title: 'ClineBot',
254
332
  subtitle: 'ClinePass 订阅服务,包含动态套餐模型。',
@@ -274,8 +352,8 @@ const en = {
274
352
  'key.saving': '正在保存…',
275
353
  'key.login_fast': '打开 API 密钥页 ↗',
276
354
  'key.verifying': '正在连接 Cline 验证密钥…',
277
- 'key.verified': '✓ 密钥有效:{email} ({plan})',
278
- 'key.invalid': '✗ 密钥验证未通过:{error}',
355
+ 'key.verified': '密钥有效:{email} ({plan})',
356
+ 'key.invalid': '密钥验证未通过:{error}',
279
357
  'key.env_label': '凭据环境变量名称:',
280
358
  'key.get_key': '前往 app.cline.bot 控制台获取密钥 ↗',
281
359
  'key.saved_msg': '密钥已成功保存至 DSH 凭据服务。验证状态:{status}',
@@ -302,6 +380,11 @@ const en = {
302
380
  'accounts.route_model_placeholder': '模型 ID (例如 cline-pass/deepseek-v4-pro)',
303
381
  'accounts.route_account_placeholder': '目标账号',
304
382
  'accounts.route_btn_add': '添加路由',
383
+ 'accounts.actions': '账户操作', 'accounts.configured': '已配置', 'accounts.missing_key': '缺少密钥', 'accounts.source': '来源:{source}',
384
+ 'accounts.quota_5h': '5小时 {pct}%', 'accounts.rename': '重命名', 'accounts.rename_save': '保存名称', 'accounts.rename_placeholder': '账户名称',
385
+ 'accounts.clear_key': '清除已存密钥', 'accounts.clear_confirm': '清除 {name} 的已存密钥?账户行会保留。',
386
+ 'accounts.remove_confirm': '从池中移除 {name}?', 'accounts.confirm': '确认', 'accounts.cancel': '取消', 'accounts.unpin': '取消固定',
387
+ 'accounts.no_accounts': '尚未配置账户。', 'accounts.route_remove': '移除路由',
305
388
  'quota.title': 'ClinePass 用量配额与限额监控',
306
389
  'quota.desc': '来自 ClinePass 官方的实时滚动窗口用量限额',
307
390
  'quota.account': '账号:{email} · 套餐:{plan}',
@@ -328,9 +411,9 @@ const en = {
328
411
  'models.caps_vision': '视觉',
329
412
  'models.caps_general': '通用',
330
413
  'models.caps_coding': '编程',
331
- 'diag.smoke_result': '✅ 延迟:{latency} ms | 模型:{model}\n响应:{preview}',
414
+ 'diag.smoke_result': '延迟:{latency} ms | 模型:{model}\n响应:{preview}',
332
415
  'diag.empty': '(空)',
333
- 'ui.error_title': '⚠️ ClineBot 界面错误:',
416
+ 'ui.error_title': 'ClineBot 界面错误:',
334
417
  'ui.retry': '重试',
335
418
  'update.check_failed': '检查更新失败',
336
419
  'update.retry': '重试检查',
@@ -451,25 +534,66 @@ const en = {
451
534
  'composer.completion_tok': '补全 Token',
452
535
  'composer.reasoning_tok': '思考推理 Token',
453
536
  'composer.est_cost': '预估花费',
537
+ 'composer.unpriced': '无价格', 'composer.cache_read': '缓存读取', 'composer.cache_write': '缓存写入',
538
+ 'composer.price_source': '价格来源', 'composer.price_estimate': '估算', 'composer.price_override': '覆盖',
454
539
  'discovery.title': '发现本地密钥',
455
540
  'discovery.import_btn': '从 {source} 导入',
456
541
  'discovery.imported': '已成功导入账户池',
457
542
  'discovery.scanning': '正在扫描本地 Cline 密钥…',
458
- }
459
-
460
- function makeT(dict, fallback) {
461
- return function t(key, vars) {
462
- if (key === 'quota.countdown' && vars?.time === 'resetting') {
463
- return (dict && dict['quota.resetting']) || (fallback && fallback['quota.resetting']) || '正在重置'
464
- }
465
- let val = (dict && dict[key]) || (fallback && fallback[key]) || key
466
- if (vars && typeof val === 'string') {
467
- for (const k of Object.keys(vars)) {
468
- val = val.replace(new RegExp('\\{' + k + '\\}', 'g'), String(vars[k]))
469
- }
470
- }
471
- return val
472
- }
543
+ 'settings.unsaved': '{count} 项未保存更改', 'settings.saving': '正在保存…', 'settings.saved': '设置已保存', 'settings.save_failed': '无法保存设置', 'settings.save': '保存', 'settings.discard': '放弃',
544
+ 'settings.save_invalid_base_url': 'Base URL 必须使用 https:// 或 localhost。', 'settings.save_invalid_number': '超时和负载限制必须是正数。',
545
+ 'settings.models_group': '模型与上下文', 'settings.models_group_hint': '套餐过滤、可见模型、上下文窗口、输出上限和推理默认值。',
546
+ 'settings.integrations': '集成与显示', 'settings.integrations_desc': '聊天请求之外复用此服务商的界面入口。',
547
+ 'settings.web_search': '使用 ClineBot 提供网页搜索', 'settings.web_search_hint': '为 DSH web_search 工具使用 ClinePass 端点。',
548
+ 'settings.sidebar_quota': '在侧边栏显示配额', 'settings.sidebar_quota_hint': '在设置上方显示紧凑的套餐与滚动窗口配额。',
549
+ 'settings.composer_cost': '在输入框显示会话成本', 'settings.composer_cost_hint': '在输入框旁显示预估 token 成本。',
550
+ 'settings.proxy_mode': '透明回环代理', 'settings.proxy_mode_hint': '仅旧传输:通过本地故障转移代理发送请求。',
551
+ 'settings.advanced': '高级', 'settings.advanced_desc': '连接、超时、存储路径和临时传输开关。',
552
+ 'settings.base_url': 'API 基础地址', 'settings.base_url_hint': 'ClinePass API 根地址。远程端点必须使用 https://。',
553
+ 'settings.timeout': '连接超时(毫秒)', 'settings.timeout_hint': '等待上游响应头的最大时间。', 'settings.smoke_timeout': '烟雾测试超时(毫秒)', 'settings.smoke_timeout_hint': '等待诊断聊天补全的最大时间。',
554
+ 'settings.stream_idle': '流空闲超时(毫秒)', 'settings.stream_idle_hint': 'SSE 数据块之间的最大间隔,超过后视为断流。', 'settings.max_payload': '多模态负载上限(MB)', 'settings.max_payload_hint': '请求超过预算时会淘汰最旧的图片。',
555
+ 'settings.stats_path': '统计文件路径', 'settings.stats_path_hint': '持久化 JSON 遥测路径(支持 ~/)。', 'settings.models_cache_path': '模型缓存路径', 'settings.models_cache_path_hint': '离线模型目录缓存(支持 ~/)。',
556
+ 'settings.transport': '传输方式', 'settings.transport_hint': '原生 ctx.llm 适配器(推荐)或旧版 llm-pi-ai + 回环代理。',
557
+ 'providerCard.title': 'ClineBot(ClinePass)',
558
+ 'providerCard.hint': '粘贴 ClinePass API 密钥,或前往“设置 → ClineBot”管理完整账户池。',
559
+ 'providerCard.keyPresent': '密钥已配置',
560
+ 'providerCard.keyMissing': '缺少密钥',
561
+ 'providerCard.routeActive': '路由已启用',
562
+ 'providerCard.placeholder': '粘贴 API 密钥',
563
+ 'providerCard.replace': '替换 API 密钥',
564
+ 'providerCard.save': '保存密钥',
565
+ 'providerCard.saving': '正在保存…',
566
+ 'providerCard.saved': '密钥已保存并验证',
567
+ 'providerCard.failed': '无法保存密钥',
568
+ 'providerCard.show': '显示',
569
+ 'providerCard.hide': '隐藏',
570
+ 'providerCard.openSettings': '前往“设置 → ClineBot”管理账户、模型和配额。',
571
+ 'dashboard.tab_quota': '配额', 'dashboard.tab_analytics': '分析', 'dashboard.auto_account': '自动(最少使用)',
572
+ 'analytics.title': '用量分析', 'analytics.requests': '请求数', 'analytics.tokens': 'Token', 'analytics.cost': '预估成本', 'analytics.success': '成功率', 'analytics.ttft': '平均首字延迟',
573
+ 'analytics.loading': '正在加载分析…', 'analytics.no_data': '暂无分析数据,请先发送几条消息。',
574
+ 'analytics.unpriced': '{count} 个请求没有价格估算,未计入成本合计。',
575
+ 'analytics.group_model': '模型', 'analytics.group_day': '日期', 'analytics.group_session': '会话',
576
+ 'analytics.heatmap': '请求热力图', 'analytics.heatmap_hint': '最近 7 天每小时的请求数。',
577
+ 'analytics.heatmap_less': '少', 'analytics.heatmap_more': '多',
578
+ 'analytics.heatmap_empty': '暂无按小时数据。发送一条消息后开始记录。',
579
+ 'analytics.input': '输入', 'analytics.output': '输出', 'analytics.reasoning': '推理',
580
+ 'analytics.cache_read': '缓存读取', 'analytics.cache_write': '缓存写入',
581
+ 'analytics.avg_ttft': '平均首字延迟', 'analytics.avg_latency': '平均延迟', 'analytics.errors': '错误',
582
+ 'analytics.no_key': '尚未配置 ClinePass 密钥。',
583
+ 'analytics.no_key_hint': '打开“设置 → ClineBot”粘贴密钥,或导入已发现的本地密钥。',
584
+ 'analytics.health_error': 'ClinePass 端点不可达。',
585
+ 'analytics.not_registered': 'ClineBot 提供商未在 DSH 中注册。',
586
+ 'analytics.not_registered_hint': '打开“设置 → ClineBot”检查诊断。',
587
+ 'analytics.stale': '正在显示上次成功获取的数据。',
588
+ 'analytics.range': '范围', 'analytics.range_7': '7 天', 'analytics.range_30': '30 天', 'analytics.range_90': '90 天',
589
+ 'analytics.heatmap_all_models': '所有模型', 'analytics.heatmap_peak': '峰值',
590
+ 'analytics.heatmap_by_hour': '按小时', 'analytics.heatmap_model': '热力图模型',
591
+ 'quota.no_windows': '未返回配额窗口。',
592
+ 'prices.title': '价格覆盖', 'prices.desc': '每 100 万 token 的美元价格。留空使用本地估算。',
593
+ 'prices.show': '显示价格', 'prices.hide': '隐藏价格', 'prices.model': '模型',
594
+ 'prices.input': '输入', 'prices.output': '输出', 'prices.cache_read': '缓存读取', 'prices.cache_write': '缓存写入',
595
+ 'prices.auto': '自动', 'prices.empty': '暂无模型。',
596
+ 'prices.hint': '这些价格只影响输入框成本和本地分析,不是账单。',
473
597
  }
474
598
 
475
599
  // --- src/client/primitives.js ---
@@ -517,6 +641,121 @@ function BooleanControl(props) {
517
641
  })
518
642
  }
519
643
 
644
+ // --- src/client/icons.js ---
645
+ /**
646
+ * Calm 16px stroke icons. They inherit `currentColor` and never carry emoji or
647
+ * hardcoded palette values, so they follow the host theme in both modes.
648
+ */
649
+ function CbIcon({ name, size = 16, className }) {
650
+ const common = {
651
+ width: size,
652
+ height: size,
653
+ viewBox: '0 0 16 16',
654
+ fill: 'none',
655
+ stroke: 'currentColor',
656
+ strokeWidth: 1.5,
657
+ strokeLinecap: 'round',
658
+ strokeLinejoin: 'round',
659
+ className: className || 'cb-icon',
660
+ 'aria-hidden': 'true',
661
+ focusable: 'false',
662
+ }
663
+ const children = []
664
+ switch (name) {
665
+ case 'search':
666
+ children.push(
667
+ React.createElement('circle', { key: 'c', cx: 7, cy: 7, r: 4.5 }),
668
+ React.createElement('path', { key: 'l', d: 'M10.5 10.5 14 14' })
669
+ )
670
+ break
671
+ case 'plus':
672
+ children.push(
673
+ React.createElement('path', { key: 'v', d: 'M8 3v10' }),
674
+ React.createElement('path', { key: 'h', d: 'M3 8h10' })
675
+ )
676
+ break
677
+ case 'close':
678
+ children.push(React.createElement('path', { key: 'x', d: 'm4 4 8 8M12 4l-8 8' }))
679
+ break
680
+ case 'check':
681
+ children.push(React.createElement('path', { key: 'p', d: 'm3.5 8.5 3 3 6-7' }))
682
+ break
683
+ case 'warning':
684
+ children.push(
685
+ React.createElement('path', { key: 't', d: 'M8 2 14 13H2Z' }),
686
+ React.createElement('path', { key: 'l', d: 'M8 6v3' }),
687
+ React.createElement('circle', { key: 'd', cx: 8, cy: 11.2, r: 0.6, fill: 'currentColor', stroke: 'none' })
688
+ )
689
+ break
690
+ case 'error':
691
+ children.push(
692
+ React.createElement('circle', { key: 'c', cx: 8, cy: 8, r: 6 }),
693
+ React.createElement('path', { key: 'l', d: 'M8 4.5v4' }),
694
+ React.createElement('circle', { key: 'd', cx: 8, cy: 11.4, r: 0.6, fill: 'currentColor', stroke: 'none' })
695
+ )
696
+ break
697
+ case 'download':
698
+ children.push(
699
+ React.createElement('path', { key: 'a', d: 'M8 2v7' }),
700
+ React.createElement('path', { key: 'b', d: 'm5 6.5 3 3 3-3' }),
701
+ React.createElement('path', { key: 'c', d: 'M3 12.5h10' })
702
+ )
703
+ break
704
+ case 'chevron':
705
+ children.push(React.createElement('path', { key: 'p', d: 'm6 4 4 4-4 4' }))
706
+ break
707
+ case 'kebab':
708
+ children.push(
709
+ React.createElement('circle', { key: 'a', cx: 8, cy: 3.5, r: 1, fill: 'currentColor', stroke: 'none' }),
710
+ React.createElement('circle', { key: 'b', cx: 8, cy: 8, r: 1, fill: 'currentColor', stroke: 'none' }),
711
+ React.createElement('circle', { key: 'c', cx: 8, cy: 12.5, r: 1, fill: 'currentColor', stroke: 'none' })
712
+ )
713
+ break
714
+ case 'key':
715
+ children.push(
716
+ React.createElement('circle', { key: 'c', cx: 5, cy: 8, r: 2.5 }),
717
+ React.createElement('path', { key: 'a', d: 'M7.5 8H14' }),
718
+ React.createElement('path', { key: 'b', d: 'M11.5 8v2' }),
719
+ React.createElement('path', { key: 'd', d: 'M13.5 8v1.5' })
720
+ )
721
+ break
722
+ case 'edit':
723
+ children.push(
724
+ React.createElement('path', { key: 'a', d: 'M3 13l.5-3L11 2.5 13.5 5 6 12.5Z' }),
725
+ React.createElement('path', { key: 'b', d: 'm9.5 4 2.5 2.5' })
726
+ )
727
+ break
728
+ case 'trash':
729
+ children.push(
730
+ React.createElement('path', { key: 'a', d: 'M3 5h10' }),
731
+ React.createElement('path', { key: 'b', d: 'M6 5V3.5h4V5' }),
732
+ React.createElement('path', { key: 'c', d: 'M4.5 5 5 13h6l.5-8' })
733
+ )
734
+ break
735
+ case 'route':
736
+ children.push(
737
+ React.createElement('path', { key: 'a', d: 'M3 5h6a2 2 0 0 1 0 4H5a2 2 0 0 0 0 4h8' }),
738
+ React.createElement('path', { key: 'b', d: 'm11 10 2 3-2 3' })
739
+ )
740
+ break
741
+ case 'refresh':
742
+ children.push(
743
+ React.createElement('path', { key: 'a', d: 'M13 8a5 5 0 1 1-1.5-3.5' }),
744
+ React.createElement('path', { key: 'b', d: 'M13 2.5V6h-3.5' })
745
+ )
746
+ break
747
+ case 'account':
748
+ children.push(
749
+ React.createElement('circle', { key: 'a', cx: 8, cy: 5.5, r: 2.5 }),
750
+ React.createElement('path', { key: 'b', d: 'M3 13c.6-2.5 2.6-4 5-4s4.4 1.5 5 4' })
751
+ )
752
+ break
753
+ default:
754
+ children.push(React.createElement('circle', { key: 'c', cx: 8, cy: 8, r: 6 }))
755
+ }
756
+ return React.createElement('svg', common, children)
757
+ }
758
+
520
759
  // --- src/client/styles.js ---
521
760
  function ensureCss() {
522
761
  if (typeof document === 'undefined') return
@@ -532,7 +771,7 @@ function ensureCss() {
532
771
 
533
772
  .cb-section-card{display:flex;flex-direction:column;gap:12px;padding:12px 14px;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-xl);background:var(--dsw-alias-settings-card-fill)}
534
773
  .cb-section-title{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px 12px;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:20px}
535
- .cb-section-desc{margin-top:-6px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
774
+ .cb-section-desc{margin-top:-6px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;overflow-wrap:anywhere}
536
775
 
537
776
  .cb-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
538
777
  .cb-grid-2{display:grid;grid-template-columns:repeat(auto-fit, minmax(280px, 1fr));gap:12px}
@@ -559,10 +798,13 @@ function ensureCss() {
559
798
  .cb-btn-danger:hover:not(:disabled){background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent)}
560
799
  .cb-btn-disabled{opacity:.5;cursor:not-allowed}
561
800
 
562
- .cb-progress-card, .cb-bar-container{display:flex;flex-direction:column;gap:8px;padding:12px 14px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-1)}
563
- .cb-progress-head, .cb-bar-head{display:flex;justify-content:space-between;align-items:baseline;gap:16px;width:100%;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:20px}
564
- .cb-progress-label{font-weight:500;white-space:nowrap}
565
- .cb-progress-meta, .cb-bar-meta{margin-left:auto;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;white-space:nowrap}
801
+ .cb-progress-card, .cb-bar-container{display:flex;flex-direction:column;gap:8px;min-width:0;padding:12px 14px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-1)}
802
+ .cb-progress-card-bad{border-color:var(--dsw-alias-state-error-primary)}
803
+ .cb-progress-head, .cb-bar-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:baseline;gap:4px 12px;width:100%;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:20px}
804
+ .cb-progress-label{flex:0 0 auto;font-weight:500;white-space:nowrap}
805
+ .cb-progress-meta, .cb-bar-meta{flex:1 1 140px;min-width:0;margin-left:auto;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;text-align:right;white-space:normal;overflow-wrap:anywhere}
806
+ .cb-progress-meta-bad{color:var(--dsw-alias-state-error-primary)}
807
+ .cb-progress-low{display:inline-flex;align-items:center;gap:4px;margin-left:6px;font-size:11px;color:var(--dsw-alias-state-error-primary)}
566
808
  .cb-progress-track, .cb-bar-track{width:100%;height:6px;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-bg-layer-2);overflow:hidden}
567
809
  .cb-progress-bar-fill, .cb-bar-fill{height:100%;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-brand-primary);transition:width .3s ease}
568
810
  .cb-progress-bar-warn{height:100%;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-state-warn-primary);transition:width .3s ease}
@@ -594,15 +836,19 @@ function ensureCss() {
594
836
  .cb-foot:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l3)}
595
837
  .cb-foot:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
596
838
  .cb-footTop{display:flex;align-items:center;gap:8px;min-width:0}
597
- .cb-footName{min-width:0;overflow:hidden;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:20px;white-space:nowrap;text-overflow:ellipsis}
839
+ .cb-footName{flex:1 1 auto;min-width:0;overflow:hidden;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:20px;white-space:nowrap;text-overflow:ellipsis}
598
840
  .cb-footRow{display:flex;flex-direction:column;gap:4px;min-width:0}
599
841
  .cb-footHead{display:flex;align-items:baseline;gap:8px;min-width:0}
600
- .cb-footLabel{flex:1;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:16px}
601
- .cb-footAmount{flex:none;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:16px;font-variant-numeric:tabular-nums;white-space:nowrap}
842
+ .cb-footLabel{flex:1;min-width:0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:16px;white-space:nowrap;text-overflow:ellipsis}
843
+ .cb-footAmount{flex:0 1 auto;max-width:68%;overflow:hidden;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:16px;font-variant-numeric:tabular-nums;white-space:nowrap;text-overflow:ellipsis}
844
+ .cb-footRowMissing .cb-footLabel{opacity:.65}
845
+ .cb-footAmountMissing{color:var(--dsw-alias-label-tertiary)}
846
+ .cb-footMessage{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}
602
847
  .cb-footBar{display:block;height:4px;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-bg-layer-2);overflow:hidden}
603
848
  .cb-footFill{display:block;height:100%;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-brand-primary);transition:width .3s ease}
604
849
  .cb-footFillWarn{background:var(--dsw-alias-state-error-primary)}
605
- .cb-footPct{flex:none;width:34px;color:var(--dsw-alias-label-secondary);text-align:right;font-size:12px;line-height:16px;font-variant-numeric:tabular-nums}
850
+ .cb-footPct{margin-left:auto;flex:none;width:34px;color:var(--dsw-alias-label-secondary);text-align:right;font-size:12px;font-weight:600;line-height:16px;font-variant-numeric:tabular-nums}
851
+ .cb-footPctWarn{color:var(--dsw-alias-state-error-primary)}
606
852
  .cb-railButton{box-sizing:border-box;flex:none;display:inline-flex;justify-content:center;align-items:center;width:36px;height:36px;margin:0 0 4px;padding:0;border:.5px solid transparent;border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}
607
853
  .cb-railButton:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
608
854
  .cb-railButton:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
@@ -611,11 +857,261 @@ function ensureCss() {
611
857
  /* The window chrome and its width belong to the shell's Modal, so the body only
612
858
  fills whatever it is given — a fixed width here overflows the panel. */
613
859
  .cb-dialog{display:flex;flex-direction:column;gap:12px;width:100%;min-width:0;max-height:min(72vh, 640px);overflow-y:auto;color:var(--dsw-alias-label-primary)}
860
+ .cb-panel{display:flex;flex-direction:column;gap:14px;width:100%;max-width:780px;margin:0 auto;padding:18px;height:100%;max-height:calc(100vh - 56px);min-height:0;overflow-y:auto;overscroll-behavior:contain;scrollbar-gutter:stable;color:var(--dsw-alias-label-primary)}
861
+ .cb-panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}
862
+ .cb-panel-identity{display:flex;flex-direction:column;gap:4px;min-width:0}
863
+ .cb-panel-title{color:var(--dsw-alias-label-primary);font-size:18px;font-weight:650;line-height:24px}
864
+ .cb-panel-sub{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
865
+ .cb-panel-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;flex:none}
866
+ .cb-panel-updated{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;font-variant-numeric:tabular-nums}
867
+ .cb-panel-close{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:18px;line-height:1;cursor:pointer}
868
+ .cb-panel-close:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
869
+ .cb-notice{display:flex;flex-direction:column;gap:4px;padding:10px 12px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-2)}
870
+ .cb-notice-title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:18px}
871
+ .cb-notice-hint{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
872
+ .cb-notice-error{border-color:var(--dsw-alias-state-error-primary)}
873
+ .cb-notice-error .cb-notice-title{color:var(--dsw-alias-state-error-primary)}
874
+ @media(max-width:720px){.cb-panel{padding:12px;max-height:none}}
614
875
  .cb-dialogMeta{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;font-variant-numeric:tabular-nums}
615
876
  .cb-dialogCard{display:flex;flex-direction:column;gap:12px;padding:12px 14px;border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-xl);background:var(--dsw-alias-settings-card-fill)}
616
877
  .cb-dialogFallback{position:fixed;inset:0;z-index:900;display:flex;align-items:center;justify-content:center;padding:24px;background:var(--dsw-alias-bg-mask-1)}
617
878
  .cb-dialogFallbackPanel{display:flex;flex-direction:column;gap:12px;max-height:calc(100vh - 96px);overflow-y:auto;padding:18px;border-radius:var(--dsw-radius-panel);background:var(--dsw-alias-bg-layer-2);box-shadow:var(--dsw-elevation-prominent)}
618
879
 
880
+ /* Provider card inside Settings -> Models. */
881
+ .cb-provider-card{display:flex;flex-direction:column;gap:10px;padding:12px 14px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-1)}
882
+ .cb-provider-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
883
+ .cb-provider-title{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:600;line-height:20px}
884
+ .cb-provider-hint{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
885
+
886
+ /* Grouped settings, disclosure rows and the staged-save bar. */
887
+ .cb-settings-group{display:flex;flex-direction:column;gap:10px;padding:4px 0}
888
+ .cb-settings-group-divided{border-top:.5px solid var(--dsw-alias-border-l2);padding-top:16px;margin-top:4px}
889
+ .cb-settings-group-head{display:flex;align-items:center;gap:8px;margin:0;padding:0}
890
+ .cb-settings-disclosure{width:100%;border:0;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;padding:2px 0}
891
+ .cb-settings-group-title{margin:0;color:var(--dsw-alias-label-primary);font-size:14px;line-height:20px;font-weight:600}
892
+ .cb-settings-group-action{margin-left:auto;color:var(--dsw-alias-label-secondary);font-size:12px}
893
+ .cb-settings-group-desc{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
894
+ .cb-settings-group-body{display:flex;flex-direction:column;gap:2px}
895
+ .cb-settings-spacer{flex:1 1 auto}
896
+ .cb-settings-chevron{display:inline-flex;justify-content:center;align-items:center;width:18px;height:18px;color:var(--dsw-alias-label-tertiary);font-size:18px;line-height:1;transition:transform .16s ease}
897
+ .cb-settings-chevron-open{transform:rotate(90deg)}
898
+ .cb-settings-row{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:12px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}
899
+ .cb-settings-row:last-child{border-bottom:0}
900
+ .cb-settings-row-text{display:flex;flex-direction:column;gap:4px;min-width:0}
901
+ .cb-settings-row-title{color:var(--dsw-alias-label-primary);font-size:13px;line-height:18px;font-weight:500}
902
+ .cb-settings-row-desc{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
903
+ .cb-settings-row-error{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px}
904
+ .cb-settings-row-control{display:flex;align-items:center;gap:8px;flex:none}
905
+ .cb-settings-input{width:220px;max-width:46vw}
906
+ .cb-savebar{position:sticky;bottom:0;z-index:30;margin:18px -4px -4px;padding:10px 4px 4px;background:linear-gradient(to top,var(--dsw-alias-bg-layer-1) 70%,transparent)}
907
+ .cb-savebar-inner{display:flex;align-items:center;gap:10px;padding:10px 12px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-2);box-shadow:var(--dsw-elevation-prominent)}
908
+ .cb-savebar-text{min-width:0;overflow:hidden;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;white-space:nowrap;text-overflow:ellipsis}
909
+
910
+ /* Calm icon and account-pool primitives. */
911
+ .cb-icon{flex:none;display:inline-block;vertical-align:-2px}
912
+ .cb-icon-chevron-open{transform:rotate(90deg)}
913
+ .cb-banner-icon{display:inline-flex;flex:none;align-items:center;justify-content:center;color:inherit}
914
+ .cb-inline-alert{display:flex;align-items:center;gap:6px}
915
+ .cb-mono{font-family:var(--ds-font-family-code,monospace);font-size:11px;color:var(--dsw-alias-label-secondary)}
916
+ .cb-muted{color:var(--dsw-alias-label-secondary);font-size:12px}
917
+ .cb-empty{padding:16px 0;color:var(--dsw-alias-label-secondary);font-size:12px;text-align:center}
918
+ .cb-section-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
919
+ .cb-accounts .cb-section-desc{margin-bottom:8px}
920
+ .cb-discovered{display:flex;flex-direction:column;gap:6px;margin:8px 0;padding:10px 12px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-1)}
921
+ .cb-discovered-head{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-primary);font-size:12px;font-weight:500}
922
+ .cb-discovered-row{display:flex;align-items:center;gap:8px;font-size:12px}
923
+ .cb-discovered-row .cb-mono{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
924
+ .cb-account-mode{display:flex;align-items:center;gap:8px;margin:8px 0 4px;font-size:12px}
925
+ .cb-seg{display:inline-flex;overflow:hidden;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md)}
926
+ .cb-seg-item{padding:4px 10px;border:0;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer}
927
+ .cb-seg-item+.cb-seg-item{border-left:.5px solid var(--dsw-alias-border-l2)}
928
+ .cb-seg-item-active{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary);font-weight:500}
929
+ .cb-account-add{display:flex;flex-direction:column;gap:8px;margin:8px 0 12px;padding:12px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-1)}
930
+ .cb-account-add-row{display:flex;gap:8px;flex-wrap:wrap}
931
+ .cb-account-add-row .cb-input{flex:1 1 180px}
932
+ .cb-account-add-actions{display:flex;justify-content:flex-end;gap:8px}
933
+ .cb-account-list{display:flex;flex-direction:column;margin-top:4px}
934
+ .cb-account-item{position:relative;border-bottom:.5px solid var(--dsw-alias-border-l2)}
935
+ .cb-account-item:last-child{border-bottom:0}
936
+ .cb-account-row{display:flex;align-items:center;gap:10px;padding:10px 0}
937
+ .cb-status-dot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-border-l3)}
938
+ .cb-status-dot-ok{background:var(--dsw-alias-state-success-primary)}
939
+ .cb-status-dot-warn{background:var(--dsw-alias-state-warn-primary)}
940
+ .cb-status-dot-bad{background:var(--dsw-alias-state-error-primary)}
941
+ .cb-status-dot-idle{background:var(--dsw-alias-border-l3)}
942
+ .cb-account-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
943
+ .cb-account-name{display:flex;align-items:baseline;gap:8px;min-width:0;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;line-height:18px}
944
+ .cb-account-env{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
945
+ .cb-account-meta{min-width:0;overflow:hidden;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:16px;white-space:nowrap;text-overflow:ellipsis}
946
+ .cb-account-badges{display:flex;flex:none;flex-wrap:wrap;justify-content:flex-end;gap:4px;max-width:240px}
947
+ .cb-account-quota{display:flex;flex:none;align-items:center;justify-content:flex-end;gap:6px;min-width:112px}
948
+ .cb-meter{display:block;width:64px;height:4px;overflow:hidden;border-radius:2px;background:var(--dsw-alias-bg-layer-2)}
949
+ .cb-meter-fill{display:block;height:100%;background:var(--dsw-alias-brand-primary);transition:width .3s ease}
950
+ .cb-meter-fill-warn{background:var(--dsw-alias-state-error-primary)}
951
+ .cb-icon-btn{display:inline-flex;flex:none;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:.5px solid transparent;border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}
952
+ .cb-icon-btn:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
953
+ .cb-icon-btn:disabled{opacity:.5;cursor:not-allowed}
954
+ .cb-account-menu{display:flex;flex-direction:column;min-width:180px;margin:0 0 8px 18px;padding:4px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-2);box-shadow:var(--dsw-elevation-prominent)}
955
+ .cb-menu-item{padding:6px 8px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;text-align:left;cursor:pointer}
956
+ .cb-menu-item:hover{background:var(--dsw-alias-interactive-bg-hover)}
957
+ .cb-menu-item-danger{color:var(--dsw-alias-state-error-primary)}
958
+ .cb-account-inline{display:flex;align-items:center;gap:8px;padding:0 0 10px 18px}
959
+ .cb-account-inline .cb-input{flex:1;min-width:0}
960
+ .cb-account-danger{padding:8px 10px;margin:0 0 8px 18px;border-radius:var(--dsw-radius-md);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 8%,transparent)}
961
+ .cb-account-danger-text{flex:1;min-width:0;color:var(--dsw-alias-state-error-primary);font-size:12px}
962
+ .cb-account-routes{display:flex;flex-direction:column;gap:8px;margin-top:14px;padding-top:12px;border-top:.5px solid var(--dsw-alias-border-l2)}
963
+ .cb-account-routes-head{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-primary);font-size:12px;font-weight:500}
964
+ .cb-account-route-add{display:flex;gap:8px;flex-wrap:wrap}
965
+ .cb-account-route-add .cb-input{flex:2 1 160px}
966
+ .cb-account-route-add .cb-select{flex:1 1 140px}
967
+ .cb-account-route-list{display:flex;flex-direction:column;gap:4px}
968
+ .cb-account-route-row{display:flex;align-items:center;gap:6px;padding:4px 8px;border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1);font-size:12px}
969
+ .cb-account-route-row .cb-mono{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
970
+ @media(max-width:720px){
971
+ .cb-account-row{flex-wrap:wrap}
972
+ .cb-account-badges{justify-content:flex-start;max-width:none}
973
+ .cb-account-quota{min-width:0}
974
+ }
975
+
976
+ /* Calm section toolbars, stats and banners. */
977
+ .cb-section-actions{display:flex;align-items:center;gap:6px;flex:none}
978
+ .cb-btn-link{display:inline-flex;align-items:center;gap:4px;text-decoration:none}
979
+ .cb-quota-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr));gap:10px;margin-top:4px}
980
+ @media(max-width:640px){.cb-quota-grid{grid-template-columns:1fr}}
981
+ .cb-stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:8px;margin-top:4px}
982
+ .cb-model-list{display:flex;flex-direction:column;gap:4px;margin-top:12px}
983
+ .cb-model-list-title{margin-bottom:2px;color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:500}
984
+ .cb-model-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:4px 8px;border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1);font-size:12px}
985
+ .cb-model-row .cb-mono{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
986
+ .cb-banner-body{flex:1;min-width:0}
987
+ .cb-banner-sub{margin-top:2px;font-size:11px;opacity:.9}
988
+ .cb-state-ok{color:var(--dsw-alias-state-success-primary);font-size:12px}
989
+ .cb-hint{margin-top:8px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
990
+ .cb-key-status{margin-top:8px;font-size:12px}
991
+ .cb-key-meta{display:flex;align-items:center;gap:6px;margin-top:12px;color:var(--dsw-alias-label-secondary);font-size:12px}
992
+ .cb-link{color:var(--dsw-alias-brand-primary);text-decoration:none}
993
+ .cb-link:hover{text-decoration:underline}
994
+ .cb-update-banner{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-2)}
995
+ .cb-update-info{display:flex;align-items:center;gap:8px;min-width:0;font-size:12px}
996
+ .cb-update-version{color:var(--dsw-alias-label-secondary);font-weight:500}
997
+ .cb-row-actions{display:flex;align-items:center;gap:8px;margin-top:4px}
998
+
999
+ /* Composer cost and analytics dialog. */
1000
+ .cb-composer-cost{position:relative;display:inline-flex;align-items:center}
1001
+ .cb-composer-cost-button{display:inline-flex;align-items:center;gap:4px;height:24px;padding:2px 8px;border:.5px solid var(--dsw-alias-border-l2);border-radius:999px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;cursor:pointer}
1002
+ .cb-composer-cost-button:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3)}
1003
+ .cb-composer-cost-value{color:var(--dsw-alias-label-primary);font-weight:600}
1004
+ .cb-composer-popover{position:absolute;bottom:100%;left:0;z-index:100;display:flex;flex-direction:column;gap:6px;min-width:200px;margin-bottom:6px;padding:10px 12px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-3);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-primary);font-size:12px}
1005
+ .cb-composer-title{padding-bottom:4px;border-bottom:.5px solid var(--dsw-alias-border-l2);font-weight:600}
1006
+ .cb-composer-row,.cb-composer-total{display:flex;align-items:center;justify-content:space-between;gap:12px}
1007
+ .cb-composer-total{padding-top:4px;border-top:.5px solid var(--dsw-alias-border-l2);font-weight:600}
1008
+ .cb-composer-value{font-variant-numeric:tabular-nums}
1009
+ .cb-tabs{display:flex;gap:4px;border-bottom:.5px solid var(--dsw-alias-border-l2)}
1010
+ .cb-tab{padding:6px 10px;border:0;border-bottom:1.5px solid transparent;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer}
1011
+ .cb-tab-active{border-bottom-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary);font-weight:500}
1012
+ .cb-dialogRow{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
1013
+ .cb-dialogBadges{display:flex;align-items:center;justify-content:flex-end;gap:4px;flex-wrap:wrap}
1014
+ .cb-analytics-controls{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap}
1015
+ .cb-account-tabs{display:flex;flex-wrap:wrap;gap:6px}
1016
+ .cb-account-tab{display:inline-flex;align-items:center;padding:2px 10px;border:.5px solid var(--dsw-alias-border-l2);border-radius:999px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:18px;cursor:pointer}
1017
+ .cb-account-tab:hover:not(.cb-account-tab-active){color:var(--dsw-alias-label-primary)}
1018
+ .cb-account-tab-active{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary)}
1019
+ .cb-price-section{display:flex;flex-direction:column;gap:8px;margin-top:14px;padding-top:12px;border-top:.5px solid var(--dsw-alias-border-l2)}
1020
+ .cb-price-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}
1021
+ .cb-price-copy{display:flex;flex-direction:column;gap:2px;min-width:0}
1022
+ .cb-price-table{display:flex;flex-direction:column;gap:4px;min-width:0;overflow-x:auto}
1023
+ .cb-price-head-row,.cb-price-row{display:grid;grid-template-columns:minmax(160px,1.6fr) repeat(4,minmax(72px,1fr));gap:6px;align-items:center;min-width:520px}
1024
+ .cb-price-head-row{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}
1025
+ .cb-price-row .cb-mono{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary);font-size:12px}
1026
+ .cb-price-input{width:100%;min-width:0;padding:4px 6px;font-size:12px}
1027
+ .cb-dialogIdentity{display:flex;flex-direction:column;gap:2px;min-width:0}
1028
+ .cb-dialogTiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:8px}
1029
+ .cb-dialogWindows{display:flex;flex-direction:column;gap:12px}
1030
+ .cb-analytics-list{display:flex;flex-direction:column;gap:2px;margin-top:8px}
1031
+ .cb-analytics-head,.cb-analytics-row{display:grid;grid-template-columns:minmax(120px,1fr) 70px 70px 80px 70px;gap:8px;align-items:center;padding:4px 0;font-size:11px}
1032
+ .cb-analytics-head{border-bottom:.5px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary)}
1033
+ .cb-analytics-row{border-bottom:.5px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary)}
1034
+ .cb-analytics-row .cb-mono{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1035
+ .cb-heat-wrap{display:flex;flex-direction:column;gap:8px;min-width:0}
1036
+ .cb-heat-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
1037
+ .cb-heat-toolbar .cb-hint{margin:0}
1038
+ .cb-heat-select{max-width:260px;padding:3px 8px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px}
1039
+ .cb-heat-scroll{overflow-x:auto;padding-bottom:4px}
1040
+ .cb-heat{display:grid;grid-template-columns:44px repeat(24,minmax(10px,1fr));gap:2px;align-items:center;min-width:620px}
1041
+ .cb-heat-hour{color:var(--dsw-alias-label-tertiary);font-size:9px;line-height:12px;text-align:center}
1042
+ .cb-heat-day{color:var(--dsw-alias-label-secondary);font-size:10px;line-height:14px;white-space:nowrap}
1043
+ .cb-heat-cell,.cb-heat-swatch{display:block;border-radius:3px;background:var(--dsw-alias-bg-layer-2)}
1044
+ .cb-heat-cell{height:14px}
1045
+ .cb-heat-swatch{width:12px;height:12px;border-radius:2px}
1046
+ .cb-heat-l0{background:var(--dsw-alias-bg-layer-2)}
1047
+ .cb-heat-l1{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,var(--dsw-alias-bg-layer-2))}
1048
+ .cb-heat-l2{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 36%,var(--dsw-alias-bg-layer-2))}
1049
+ .cb-heat-l3{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 58%,var(--dsw-alias-bg-layer-2))}
1050
+ .cb-heat-l4{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 80%,var(--dsw-alias-bg-layer-2))}
1051
+ .cb-heat-legend{display:flex;align-items:center;gap:4px;color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:14px}
1052
+ .cb-hour-strip-head{margin:10px 0 0;color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:14px}
1053
+ .cb-hour-strip{display:grid;grid-template-columns:44px repeat(24,minmax(10px,1fr));gap:2px;align-items:end;height:30px;min-width:620px}
1054
+ .cb-hour-col{display:flex;align-items:flex-end;height:100%}
1055
+ .cb-hour-bar{display:block;width:100%;min-height:2px;border-radius:2px 2px 0 0}
1056
+ .cb-hour-labels{display:grid;grid-template-columns:44px repeat(24,minmax(10px,1fr));gap:2px;color:var(--dsw-alias-label-tertiary);font-size:9px;line-height:12px;text-align:center;min-width:620px}
1057
+ @media(max-width:720px){
1058
+ .cb-analytics-head,.cb-analytics-row{grid-template-columns:1fr 56px 64px}
1059
+ .cb-analytics-head span:nth-child(4),.cb-analytics-head span:nth-child(5),.cb-analytics-row span:nth-child(4),.cb-analytics-row span:nth-child(5){display:none}
1060
+ }
1061
+
1062
+ /* Opencode-inspired visual language: neutral rgba surfaces, 7-10px radii and
1063
+ the same green/amber/red accents. Text still follows the harness theme. */
1064
+ .cb-page{gap:14px;padding:4px 2px 24px}
1065
+ .cb-page-title{font-size:18px;font-weight:650;line-height:1.4}
1066
+ .cb-panel{max-width:760px;padding:18px}
1067
+ .cb-section-card,.cb-dialogCard,.cb-provider-card,.cb-progress-card,.cb-stat-box,.cb-account-add,.cb-discovered,.cb-update-banner,.cb-notice,.cb-savebar-inner,.cb-account-menu{border:1px solid rgba(127,127,127,.25);border-radius:10px;background:rgba(127,127,127,.06)}
1068
+ .cb-section-card,.cb-dialogCard{padding:12px 14px;gap:10px}
1069
+ .cb-section-title,.cb-settings-group-title{font-size:13px;font-weight:650;line-height:1.5}
1070
+ .cb-section-desc,.cb-settings-group-desc,.cb-settings-row-desc,.cb-hint{opacity:.72;line-height:1.5}
1071
+ .cb-input,.cb-select{height:auto;min-height:32px;padding:6px 8px;border:1px solid rgba(127,127,127,.35);border-radius:7px;background:rgba(127,127,127,.08);font-size:13px;line-height:1.4}
1072
+ .cb-input:focus,.cb-select:focus{border-color:rgba(80,140,255,.6);outline:2px solid rgba(80,140,255,.25);outline-offset:1px}
1073
+ .cb-btn{height:auto;min-height:30px;padding:6px 11px;border:1px solid rgba(127,127,127,.35);border-radius:7px;background:rgba(127,127,127,.1);font-size:13px;line-height:1.4}
1074
+ .cb-btn:hover:not(:disabled){background:rgba(127,127,127,.18)}
1075
+ .cb-btn-primary{border-color:rgba(80,140,255,.6);background:rgba(80,140,255,.18);color:inherit}
1076
+ .cb-btn-primary:hover:not(:disabled){background:rgba(80,140,255,.26)}
1077
+ .cb-btn-danger{border-color:rgba(220,80,80,.5);color:#e07a7a}
1078
+ .cb-badge{padding:2px 7px;border:1px solid rgba(127,127,127,.3);border-radius:999px;background:rgba(127,127,127,.1);font-size:11px;line-height:1.4}
1079
+ .cb-badge-ok{color:#4fbf86;border-color:rgba(60,180,110,.5);background:rgba(60,180,110,.10)}
1080
+ .cb-badge-warn{color:#d9a24a;border-color:rgba(220,160,60,.5);background:rgba(220,160,60,.10)}
1081
+ .cb-badge-bad{color:#e07a7a;border-color:rgba(220,80,80,.5);background:rgba(220,80,80,.10)}
1082
+ .cb-stat-box{padding:8px 10px;border-radius:8px}
1083
+ .cb-stat-val{font-size:15px;font-weight:600;line-height:1.4}
1084
+ .cb-stat-lbl{font-size:11px;line-height:1.5}
1085
+ .cb-table{font-size:12px}
1086
+ .cb-table th{padding:5px 8px;border-bottom:1px solid rgba(127,127,127,.18);font-size:11px;font-weight:600;opacity:.8}
1087
+ .cb-table td{padding:5px 8px;border-bottom:1px solid rgba(127,127,127,.18);font-size:12px}
1088
+ .cb-progress-track,.cb-bar-track{height:8px;border-radius:999px;background:rgba(127,127,127,.2)}
1089
+ .cb-progress-bar-fill,.cb-bar-fill,.cb-footFill,.cb-meter-fill{background:#4fbf86}
1090
+ .cb-progress-bar-warn{background:#d9a24a}
1091
+ .cb-progress-bar-bad,.cb-footFillWarn,.cb-meter-fill-warn{background:#e07a7a}
1092
+ .cb-progress-meta-bad,.cb-progress-low,.cb-footPctWarn{color:#e07a7a}
1093
+ .cb-footBar,.cb-meter{background:rgba(127,127,127,.2)}
1094
+ .cb-account-tab,.cb-seg{border:1px solid rgba(127,127,127,.3);border-radius:999px;background:rgba(127,127,127,.1)}
1095
+ .cb-seg{border-radius:7px;overflow:hidden}
1096
+ .cb-seg-item{border:0;border-left:1px solid rgba(127,127,127,.2);border-radius:0;background:transparent}
1097
+ .cb-seg-item:first-child{border-left:0}
1098
+ .cb-seg-item-active,.cb-account-tab-active{border-color:rgba(80,140,255,.6);background:rgba(80,140,255,.18);color:inherit}
1099
+ .cb-tab{border:0;border-radius:0;background:transparent;border-bottom:1.5px solid transparent}
1100
+ .cb-tab-active{border-bottom-color:#4fbf86;background:transparent}
1101
+ .cb-heat-l0{background:rgba(127,127,127,.10)}
1102
+ .cb-heat-l1{background:rgba(79,191,134,.234)}
1103
+ .cb-heat-l2{background:rgba(79,191,134,.382)}
1104
+ .cb-heat-l3{background:rgba(79,191,134,.530)}
1105
+ .cb-heat-l4{background:rgba(79,191,134,.826)}
1106
+ .cb-heat-cell,.cb-heat-swatch{border-radius:3px}
1107
+ .cb-analytics-head,.cb-analytics-row{padding:5px 0;border-bottom:1px solid rgba(127,127,127,.18)}
1108
+ .cb-settings-row,.cb-price-section{border-color:rgba(127,127,127,.18)}
1109
+ .cb-alert-ok{background:rgba(60,180,110,.10);color:#4fbf86}
1110
+ .cb-alert-bad,.cb-alert-err{background:rgba(220,80,80,.10);color:#e07a7a}
1111
+ .cb-banner-warning{background:rgba(220,160,60,.10);border-color:rgba(220,160,60,.5);color:#d9a24a}
1112
+ .cb-banner-exhausted{background:rgba(220,80,80,.10);border-color:rgba(220,80,80,.5);color:#e07a7a}
1113
+ .cb-account-route-row,.cb-model-row,.cb-preview,.cb-account-add{background:rgba(127,127,127,.06)}
1114
+
619
1115
  `
620
1116
  document.head.appendChild(style)
621
1117
  }
@@ -643,6 +1139,257 @@ async function fetchWithTimeout(url, opts = {}, ms = 30000) {
643
1139
  }
644
1140
  }
645
1141
 
1142
+ // --- src/client/settings-draft.js ---
1143
+ /**
1144
+ * Staged configuration draft for the ClineBot settings page.
1145
+ *
1146
+ * Fields edited in the form are merged into `pending` and written with one
1147
+ * `PUT /config` when the user presses Save. Account operations remain
1148
+ * immediate and are handled by the page's existing handlers.
1149
+ */
1150
+ function useStagedConfig({ status, draft, setDraft, load, t, pendingRef }) {
1151
+ const [pending, setPending] = React.useState({})
1152
+ const [saving, setSaving] = React.useState(false)
1153
+ const [saveState, setSaveState] = React.useState('idle')
1154
+ const [saveError, setSaveError] = React.useState('')
1155
+ const savedTimer = React.useRef(null)
1156
+
1157
+ React.useEffect(() => () => {
1158
+ if (savedTimer.current) clearTimeout(savedTimer.current)
1159
+ }, [])
1160
+
1161
+ const validationError = React.useMemo(() => {
1162
+ if (Object.hasOwn(pending, 'baseUrl')) {
1163
+ const raw = String(pending.baseUrl || '').trim()
1164
+ try {
1165
+ const url = new URL(raw)
1166
+ const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1'
1167
+ if (url.protocol !== 'https:' && !local) return t('settings.save_invalid_base_url')
1168
+ } catch {
1169
+ return t('settings.save_invalid_base_url')
1170
+ }
1171
+ }
1172
+ for (const key of ['timeoutMs', 'smokeTimeoutMs', 'streamIdleTimeoutMs', 'maxPayloadMb']) {
1173
+ if (!Object.hasOwn(pending, key)) continue
1174
+ const value = Number(pending[key])
1175
+ if (!Number.isFinite(value) || value <= 0) return t('settings.save_invalid_number')
1176
+ }
1177
+ return ''
1178
+ }, [pending, t])
1179
+
1180
+ const stage = React.useCallback((patch) => {
1181
+ if (!patch || typeof patch !== 'object') return
1182
+ const next = { ...(pendingRef.current || {}), ...patch }
1183
+ pendingRef.current = next
1184
+ setPending(next)
1185
+ setDraft((current) => ({ ...(current || {}), ...patch }))
1186
+ setSaveState('unsaved')
1187
+ setSaveError('')
1188
+ }, [pendingRef, setDraft])
1189
+
1190
+ const discard = React.useCallback(() => {
1191
+ pendingRef.current = {}
1192
+ setPending({})
1193
+ setDraft(status?.config || {})
1194
+ setSaveState('idle')
1195
+ setSaveError('')
1196
+ }, [pendingRef, setDraft, status])
1197
+
1198
+ const save = React.useCallback(async () => {
1199
+ const patch = pendingRef.current || {}
1200
+ if (Object.keys(patch).length === 0 || saving) return
1201
+ if (validationError) {
1202
+ setSaveState('error')
1203
+ setSaveError(validationError)
1204
+ return
1205
+ }
1206
+ setSaving(true)
1207
+ setSaveState('saving')
1208
+ setSaveError('')
1209
+ try {
1210
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/config`, {
1211
+ method: 'PUT',
1212
+ headers: { 'Content-Type': 'application/json' },
1213
+ body: JSON.stringify({ config: patch }),
1214
+ })
1215
+ const data = await res.json().catch(() => ({}))
1216
+ if (!res.ok || data.ok === false) throw new Error(data.error || `HTTP ${res.status}`)
1217
+ pendingRef.current = {}
1218
+ setPending({})
1219
+ await load()
1220
+ setSaveState('saved')
1221
+ if (savedTimer.current) clearTimeout(savedTimer.current)
1222
+ savedTimer.current = setTimeout(() => {
1223
+ setSaveState((current) => (current === 'saved' ? 'idle' : current))
1224
+ }, 2500)
1225
+ } catch (error) {
1226
+ setSaveState('error')
1227
+ setSaveError(String(error?.message || error))
1228
+ } finally {
1229
+ setSaving(false)
1230
+ }
1231
+ }, [load, pendingRef, saving, validationError])
1232
+
1233
+ return {
1234
+ pending,
1235
+ pendingCount: Object.keys(pending).length,
1236
+ saving,
1237
+ saveState,
1238
+ saveError,
1239
+ validationError,
1240
+ invalid: Boolean(validationError),
1241
+ stage,
1242
+ discard,
1243
+ save,
1244
+ }
1245
+ }
1246
+
1247
+ // --- src/client/settings-models.js ---
1248
+ /**
1249
+ * Model-picker selection state and debounced persistence.
1250
+ *
1251
+ * Model toggles are immediate by design: the picker is a direct inventory
1252
+ * control, not part of the staged settings form.
1253
+ */
1254
+ function useModelSelection({ status, draft, setDraft, load, setErr }) {
1255
+ const debounceTimerRef = React.useRef(null)
1256
+
1257
+ React.useEffect(() => () => {
1258
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
1259
+ }, [])
1260
+
1261
+ function debounceSaveDisabledModels(nextDisabled, previousDisabled) {
1262
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
1263
+ debounceTimerRef.current = setTimeout(async () => {
1264
+ try {
1265
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/models/toggle`, {
1266
+ method: 'POST',
1267
+ headers: { 'Content-Type': 'application/json' },
1268
+ body: JSON.stringify({ disabledModels: nextDisabled }),
1269
+ })
1270
+ const data = await res.json().catch(() => ({}))
1271
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1272
+ await load()
1273
+ } catch (error) {
1274
+ console.warn('[dsh-clinebot] Failed saving disabled models:', error)
1275
+ setErr(String(error?.message || error))
1276
+ setDraft((previous) => {
1277
+ if (!previous) return previous
1278
+ const all = status?.availableModels || []
1279
+ const previousEnabled = all.map((model) => model.id).filter((id) => !previousDisabled.includes(id))
1280
+ return { ...previous, disabledModels: previousDisabled, enabledModels: previousEnabled }
1281
+ })
1282
+ }
1283
+ }, 400)
1284
+ }
1285
+
1286
+ function handleToggleModel(id) {
1287
+ const current = draft?.disabledModels || []
1288
+ const next = current.includes(id)
1289
+ ? current.filter((entry) => entry !== id)
1290
+ : [...current, id]
1291
+ const all = status?.availableModels || []
1292
+ const enabled = all.map((model) => model.id).filter((modelId) => !next.includes(modelId))
1293
+ setDraft({ ...(draft || {}), disabledModels: next, enabledModels: enabled })
1294
+ debounceSaveDisabledModels(next, current)
1295
+ }
1296
+
1297
+ function handleSetModelsFilter(type) {
1298
+ const all = status?.availableModels || []
1299
+ let allowed = new Set()
1300
+ if (type === 'all') allowed = new Set(all.map((model) => model.id))
1301
+ else if (type === 'vision') allowed = new Set(all.filter((model) => model.input?.includes('image') || model.input?.includes('vision')).map((model) => model.id))
1302
+ else if (type === 'coding') allowed = new Set(all.filter((model) => model.category === 'coding').map((model) => model.id))
1303
+ else if (type === 'recommended') allowed = new Set(all.filter((model) => model.recommended).map((model) => model.id))
1304
+
1305
+ const current = draft?.disabledModels || []
1306
+ const nextDisabled = all.map((model) => model.id).filter((id) => !allowed.has(id))
1307
+ const nextEnabled = Array.from(allowed)
1308
+ setDraft({ ...(draft || {}), disabledModels: nextDisabled, enabledModels: nextEnabled })
1309
+ debounceSaveDisabledModels(nextDisabled, current)
1310
+ }
1311
+
1312
+ return { handleToggleModel, handleSetModelsFilter }
1313
+ }
1314
+
1315
+ // --- src/client/settings-accounts.js ---
1316
+ /**
1317
+ * Immediate account-pool operations. These are intentionally outside the
1318
+ * staged settings form: adding, pinning, renaming, clearing and deleting an
1319
+ * account commit on their own.
1320
+ */
1321
+ function createAccountActions({ performAction, load, t, setMsg }) {
1322
+ async function handlePinAccount(accountEnv) {
1323
+ await performAction('pin-account', async () => {
1324
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/active`, {
1325
+ method: 'POST',
1326
+ headers: { 'Content-Type': 'application/json' },
1327
+ body: JSON.stringify({ account: accountEnv }),
1328
+ })
1329
+ const data = await res.json().catch(() => ({}))
1330
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1331
+ await load()
1332
+ })
1333
+ }
1334
+
1335
+ async function handleAddAccount({ label, apiKeyEnv, apiKey, id }) {
1336
+ await performAction('add-account', async () => {
1337
+ const endpoint = id ? `${ROUTE_PREFIX}/keys/discovered/import` : `${ROUTE_PREFIX}/accounts`
1338
+ const payload = id ? { id, label, apiKeyEnv } : { label, apiKeyEnv, apiKey }
1339
+ const res = await fetchWithTimeout(endpoint, {
1340
+ method: 'POST',
1341
+ headers: { 'Content-Type': 'application/json' },
1342
+ body: JSON.stringify(payload),
1343
+ })
1344
+ const data = await res.json().catch(() => ({}))
1345
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1346
+ setMsg(t('key.saved_msg', { status: data.validated ? 'OK' : (data.validationError || 'Notice') }))
1347
+ await load()
1348
+ })
1349
+ }
1350
+
1351
+ async function handleDeleteAccount(accountEnv, deleteSecret = true) {
1352
+ await performAction('delete-account', async () => {
1353
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/delete`, {
1354
+ method: 'POST',
1355
+ headers: { 'Content-Type': 'application/json' },
1356
+ body: JSON.stringify({ apiKeyEnv: accountEnv, deleteSecret }),
1357
+ })
1358
+ const data = await res.json().catch(() => ({}))
1359
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1360
+ await load()
1361
+ })
1362
+ }
1363
+
1364
+ async function handleRenameAccount(accountEnv, label) {
1365
+ await performAction('rename-account', async () => {
1366
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/rename`, {
1367
+ method: 'POST',
1368
+ headers: { 'Content-Type': 'application/json' },
1369
+ body: JSON.stringify({ apiKeyEnv: accountEnv, label }),
1370
+ })
1371
+ const data = await res.json().catch(() => ({}))
1372
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1373
+ await load()
1374
+ })
1375
+ }
1376
+
1377
+ async function handleClearKey(accountEnv) {
1378
+ await performAction('clear-key', async () => {
1379
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/key/clear`, {
1380
+ method: 'POST',
1381
+ headers: { 'Content-Type': 'application/json' },
1382
+ body: JSON.stringify({ apiKeyEnv: accountEnv }),
1383
+ })
1384
+ const data = await res.json().catch(() => ({}))
1385
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1386
+ await load()
1387
+ })
1388
+ }
1389
+
1390
+ return { handlePinAccount, handleAddAccount, handleDeleteAccount, handleRenameAccount, handleClearKey }
1391
+ }
1392
+
646
1393
  // --- src/client/error-boundary.js ---
647
1394
  function createErrorBoundary() {
648
1395
  try {
@@ -663,7 +1410,7 @@ function createErrorBoundary() {
663
1410
  render() {
664
1411
  if (this.state.hasError) {
665
1412
  const t = this.props && typeof this.props.t === 'function' ? this.props.t : null
666
- const errorTitle = t ? t('ui.error_title') : '⚠️ ClineBot UI Error: '
1413
+ const errorTitle = t ? t('ui.error_title') : 'ClineBot UI Error: '
667
1414
  const retryLabel = t ? t('ui.retry') : 'Retry'
668
1415
  return React.createElement(
669
1416
  'div',
@@ -736,128 +1483,343 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
736
1483
 
737
1484
  return React.createElement(
738
1485
  'div',
739
- {
740
- className: 'cb-progress-card cb-bar-container',
741
- style: {
742
- display: 'flex',
743
- flexDirection: 'column',
744
- gap: '8px',
745
- padding: '12px 14px',
746
- borderRadius: '8px',
747
- border: isBad ? '1px solid var(--dsw-alias-state-error-primary)' : '1px solid var(--dsw-alias-border-l2)',
748
- background: 'var(--dsw-alias-bg-layer-2)',
749
- },
750
- },
1486
+ { className: isBad ? 'cb-progress-card cb-progress-card-bad' : 'cb-progress-card' },
751
1487
  React.createElement(
752
1488
  'div',
753
- {
754
- className: 'cb-progress-head cb-bar-head',
755
- style: {
756
- display: 'flex',
757
- justifyContent: 'space-between',
758
- alignItems: 'baseline',
759
- gap: '16px',
760
- width: '100%',
761
- },
762
- },
1489
+ { className: 'cb-progress-head' },
763
1490
  React.createElement(
764
1491
  'span',
765
- { className: 'cb-progress-label', style: { fontWeight: 500, whiteSpace: 'nowrap' } },
1492
+ { className: 'cb-progress-label' },
766
1493
  label,
767
- isBad ? React.createElement('span', { style: { marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-state-error-primary)' } }, '⚠️ <10%') : null
1494
+ isBad
1495
+ ? React.createElement(
1496
+ 'span',
1497
+ { className: 'cb-progress-low' },
1498
+ React.createElement(CbIcon, { name: 'warning', size: 12 }),
1499
+ '<10%'
1500
+ )
1501
+ : null
768
1502
  ),
769
1503
  React.createElement(
770
1504
  'span',
771
- {
772
- className: 'cb-progress-meta cb-bar-meta',
773
- style: {
774
- fontSize: '12px',
775
- color: isBad ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-label-secondary)',
776
- whiteSpace: 'nowrap',
777
- marginLeft: 'auto',
778
- },
779
- },
1505
+ { className: isBad ? 'cb-progress-meta cb-progress-meta-bad' : 'cb-progress-meta' },
780
1506
  t('quota.used', { pct }),
781
- resetCountdown ? ` · ${resetCountdown === 'resetting' ? (t('quota.resetting') || '正在重置') : t('quota.countdown', { time: resetCountdown })}` : (resetClock ? ` · ${t('quota.reset_at', { time: resetClock })}` : '')
1507
+ resetCountdown
1508
+ ? ` · ${resetCountdown === 'resetting' ? (t('quota.resetting') || '正在重置') : t('quota.countdown', { time: resetCountdown })}`
1509
+ : (resetClock ? ` · ${t('quota.reset_at', { time: resetClock })}` : '')
782
1510
  )
783
1511
  ),
784
1512
  React.createElement(
785
1513
  'div',
786
- {
787
- className: 'cb-progress-track cb-bar-track',
788
- style: {
789
- width: '100%',
790
- height: '8px',
791
- borderRadius: '999px',
792
- background: 'var(--dsw-alias-bg-layer-1)',
793
- overflow: 'hidden',
794
- border: '1px solid var(--dsw-alias-border-l2)',
795
- },
796
- },
797
- React.createElement('div', {
798
- className: barClass,
799
- style: { width: `${pct}%`, height: '100%', borderRadius: '999px' },
800
- })
1514
+ { className: 'cb-progress-track' },
1515
+ React.createElement('div', { className: barClass, style: { width: `${pct}%` } })
801
1516
  )
802
1517
  )
803
1518
  }
804
1519
 
805
- // --- src/client/components/update-banner.js ---
806
- function UpdateBanner({ updateState, handleTriggerUpdate, handleCheckUpdate, t }) {
807
- const status = updateState.status || (
808
- updateState.checking
809
- ? 'checking'
810
- : (updateState.latestCheckFailed
811
- ? 'error'
812
- : (updateState.updateAvailable
813
- ? 'available'
814
- : (updateState.lastCheckedAt ? 'current' : 'idle')))
1520
+ // --- src/client/components/settings-layout.js ---
1521
+ /**
1522
+ * Shared settings layout primitives: a titled group, a foldable group, a
1523
+ * title/description/control row, and the floating staged-save bar.
1524
+ */
1525
+ function SettingsGroup({ title, description, divided = false, action, children }) {
1526
+ return React.createElement(
1527
+ 'section',
1528
+ { className: divided ? 'cb-settings-group cb-settings-group-divided' : 'cb-settings-group', 'aria-label': title },
1529
+ React.createElement(
1530
+ 'div',
1531
+ { className: 'cb-settings-group-head' },
1532
+ React.createElement('h3', { className: 'cb-settings-group-title' }, title),
1533
+ action ? React.createElement('span', { className: 'cb-settings-group-action' }, action) : null
1534
+ ),
1535
+ description ? React.createElement('div', { className: 'cb-settings-group-desc' }, description) : null,
1536
+ React.createElement('div', { className: 'cb-settings-group-body' }, children)
815
1537
  )
1538
+ }
816
1539
 
817
- const isChecking = status === 'checking' || updateState.checking
818
- const isAvailable = status === 'available' || updateState.updateAvailable
819
- const isCurrent = status === 'current'
820
- const isError = status === 'error' || updateState.latestCheckFailed
821
-
1540
+ function CollapsibleGroup({ title, description, defaultOpen = false, divided = false, badge, children }) {
1541
+ const [open, setOpen] = React.useState(Boolean(defaultOpen))
822
1542
  return React.createElement(
823
- React.Fragment,
824
- null,
1543
+ 'section',
1544
+ { className: divided ? 'cb-settings-group cb-settings-group-divided' : 'cb-settings-group', 'aria-label': title },
825
1545
  React.createElement(
826
- 'div',
1546
+ 'button',
827
1547
  {
828
- className: 'cb-row',
829
- style: {
830
- padding: '10px 14px',
831
- borderRadius: '8px',
832
- border: '1px solid var(--dsw-alias-border-l2)',
833
- background: 'var(--dsw-alias-bg-layer-2)',
834
- justifyContent: 'space-between',
835
- }
1548
+ type: 'button',
1549
+ className: 'cb-settings-group-head cb-settings-disclosure',
1550
+ 'aria-expanded': open,
1551
+ onClick: () => setOpen((value) => !value),
836
1552
  },
1553
+ React.createElement('span', { className: 'cb-settings-group-title' }, title),
1554
+ badge !== undefined && badge !== '' ? React.createElement('span', { className: 'cb-badge' }, badge) : null,
1555
+ React.createElement('span', { className: 'cb-settings-spacer' }),
1556
+ React.createElement('span', { className: open ? 'cb-settings-chevron cb-settings-chevron-open' : 'cb-settings-chevron', 'aria-hidden': 'true' }, '›')
1557
+ ),
1558
+ open && description ? React.createElement('div', { className: 'cb-settings-group-desc' }, description) : null,
1559
+ open ? React.createElement('div', { className: 'cb-settings-group-body' }, children) : null
1560
+ )
1561
+ }
1562
+
1563
+ function SettingRow({ title, description, control, error, htmlFor }) {
1564
+ return React.createElement(
1565
+ 'div',
1566
+ { className: 'cb-settings-row' },
1567
+ React.createElement(
1568
+ 'div',
1569
+ { className: 'cb-settings-row-text' },
1570
+ htmlFor
1571
+ ? React.createElement('label', { className: 'cb-settings-row-title', htmlFor }, title)
1572
+ : React.createElement('span', { className: 'cb-settings-row-title' }, title),
1573
+ error
1574
+ ? React.createElement('div', { className: 'cb-settings-row-error' }, error)
1575
+ : (description ? React.createElement('div', { className: 'cb-settings-row-desc' }, description) : null)
1576
+ ),
1577
+ control ? React.createElement('div', { className: 'cb-settings-row-control' }, control) : null
1578
+ )
1579
+ }
1580
+
1581
+ function SaveBar({ pendingCount, saving, saveState, saveError, invalid, validationError, onSave, onDiscard, t }) {
1582
+ const visible = pendingCount > 0 || saving || saveState === 'saved' || saveState === 'error'
1583
+ if (!visible) return null
1584
+ const message = saveError
1585
+ || validationError
1586
+ || (saving
1587
+ ? t('settings.saving')
1588
+ : (saveState === 'saved' ? t('settings.saved') : t('settings.unsaved', { count: pendingCount })))
1589
+ return React.createElement(
1590
+ 'div',
1591
+ { className: 'cb-savebar', role: 'status' },
1592
+ React.createElement(
1593
+ 'div',
1594
+ { className: 'cb-savebar-inner' },
1595
+ React.createElement('span', { className: 'cb-savebar-text' }, message),
1596
+ React.createElement('span', { className: 'cb-settings-spacer' }),
837
1597
  React.createElement(
838
- 'div',
839
- { style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' } },
840
- updateState.currentVersion
841
- ? React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)', fontWeight: 500 } },
842
- `v${updateState.currentVersion}`
843
- )
1598
+ 'button',
1599
+ {
1600
+ type: 'button',
1601
+ className: 'cb-btn',
1602
+ disabled: saving || pendingCount === 0,
1603
+ onClick: onDiscard,
1604
+ },
1605
+ t('settings.discard')
1606
+ ),
1607
+ React.createElement(
1608
+ 'button',
1609
+ {
1610
+ type: 'button',
1611
+ className: 'cb-btn cb-btn-primary',
1612
+ disabled: saving || pendingCount === 0 || invalid,
1613
+ onClick: onSave,
1614
+ },
1615
+ saving ? t('settings.saving') : t('settings.save')
1616
+ )
1617
+ )
1618
+ )
1619
+ }
1620
+
1621
+ // --- src/client/components/integrations-section.js ---
1622
+ /** Integrations & display settings: surfaces outside the chat request itself. */
1623
+ function IntegrationToggle({ id, label, hint, checked, onChange }) {
1624
+ return React.createElement(SettingRow, {
1625
+ title: label,
1626
+ description: hint,
1627
+ htmlFor: id,
1628
+ control: React.createElement(BooleanControl, {
1629
+ checked: !!checked,
1630
+ label,
1631
+ onChange: (next) => onChange(next),
1632
+ }),
1633
+ })
1634
+ }
1635
+
1636
+ function IntegrationsSection({ draft, onStage, transport, t }) {
1637
+ const current = draft || {}
1638
+ return React.createElement(
1639
+ SettingsGroup,
1640
+ {
1641
+ title: t('settings.integrations'),
1642
+ description: t('settings.integrations_desc'),
1643
+ divided: true,
1644
+ },
1645
+ React.createElement(IntegrationToggle, {
1646
+ id: 'cb-web-search',
1647
+ label: t('settings.web_search'),
1648
+ hint: t('settings.web_search_hint'),
1649
+ checked: current.webSearch !== false,
1650
+ onChange: (value) => onStage({ webSearch: value }),
1651
+ }),
1652
+ React.createElement(IntegrationToggle, {
1653
+ id: 'cb-sidebar-quota',
1654
+ label: t('settings.sidebar_quota'),
1655
+ hint: t('settings.sidebar_quota_hint'),
1656
+ checked: current.showSidebarQuota !== false,
1657
+ onChange: (value) => onStage({ showSidebarQuota: value }),
1658
+ }),
1659
+ React.createElement(IntegrationToggle, {
1660
+ id: 'cb-composer-cost',
1661
+ label: t('settings.composer_cost'),
1662
+ hint: t('settings.composer_cost_hint'),
1663
+ checked: current.showComposerCost !== false,
1664
+ onChange: (value) => onStage({ showComposerCost: value }),
1665
+ }),
1666
+ transport === 'pi-ai'
1667
+ ? React.createElement(IntegrationToggle, {
1668
+ id: 'cb-proxy-mode',
1669
+ label: t('settings.proxy_mode'),
1670
+ hint: t('settings.proxy_mode_hint'),
1671
+ checked: current.proxyMode !== false,
1672
+ onChange: (value) => onStage({ proxyMode: value }),
1673
+ })
1674
+ : null
1675
+ )
1676
+ }
1677
+
1678
+ // --- src/client/components/advanced-section.js ---
1679
+ /** Advanced connection, timeout and storage settings. */
1680
+ function AdvancedField({ id, label, hint, value, type, onChange }) {
1681
+ return React.createElement(SettingRow, {
1682
+ title: label,
1683
+ description: hint,
1684
+ htmlFor: id,
1685
+ control: React.createElement('input', {
1686
+ id,
1687
+ className: 'cb-input cb-settings-input',
1688
+ type: type || 'text',
1689
+ value: value === undefined || value === null ? '' : String(value),
1690
+ onChange: (event) => onChange(event.target.value),
1691
+ }),
1692
+ })
1693
+ }
1694
+
1695
+ function AdvancedSection({ draft, onStage, transport, t }) {
1696
+ const current = draft || {}
1697
+ const numeric = (key, minimum) => (raw) => {
1698
+ if (raw === '') return
1699
+ const value = Number(raw)
1700
+ if (Number.isFinite(value) && value > minimum) onStage({ [key]: value })
1701
+ }
1702
+ return React.createElement(
1703
+ CollapsibleGroup,
1704
+ {
1705
+ title: t('settings.advanced'),
1706
+ description: t('settings.advanced_desc'),
1707
+ divided: true,
1708
+ defaultOpen: false,
1709
+ },
1710
+ React.createElement(AdvancedField, {
1711
+ id: 'cb-base-url',
1712
+ label: t('settings.base_url'),
1713
+ hint: t('settings.base_url_hint'),
1714
+ value: current.baseUrl,
1715
+ onChange: (value) => onStage({ baseUrl: value }),
1716
+ }),
1717
+ React.createElement(AdvancedField, {
1718
+ id: 'cb-timeout',
1719
+ label: t('settings.timeout'),
1720
+ hint: t('settings.timeout_hint'),
1721
+ value: current.timeoutMs,
1722
+ type: 'number',
1723
+ onChange: numeric('timeoutMs', 0),
1724
+ }),
1725
+ React.createElement(AdvancedField, {
1726
+ id: 'cb-smoke-timeout',
1727
+ label: t('settings.smoke_timeout'),
1728
+ hint: t('settings.smoke_timeout_hint'),
1729
+ value: current.smokeTimeoutMs,
1730
+ type: 'number',
1731
+ onChange: numeric('smokeTimeoutMs', 0),
1732
+ }),
1733
+ React.createElement(AdvancedField, {
1734
+ id: 'cb-stream-idle',
1735
+ label: t('settings.stream_idle'),
1736
+ hint: t('settings.stream_idle_hint'),
1737
+ value: current.streamIdleTimeoutMs,
1738
+ type: 'number',
1739
+ onChange: numeric('streamIdleTimeoutMs', 0),
1740
+ }),
1741
+ React.createElement(AdvancedField, {
1742
+ id: 'cb-max-payload',
1743
+ label: t('settings.max_payload'),
1744
+ hint: t('settings.max_payload_hint'),
1745
+ value: current.maxPayloadMb,
1746
+ type: 'number',
1747
+ onChange: numeric('maxPayloadMb', 0),
1748
+ }),
1749
+ React.createElement(AdvancedField, {
1750
+ id: 'cb-stats-path',
1751
+ label: t('settings.stats_path'),
1752
+ hint: t('settings.stats_path_hint'),
1753
+ value: current.statsPath,
1754
+ onChange: (value) => onStage({ statsPath: value }),
1755
+ }),
1756
+ React.createElement(AdvancedField, {
1757
+ id: 'cb-models-cache-path',
1758
+ label: t('settings.models_cache_path'),
1759
+ hint: t('settings.models_cache_path_hint'),
1760
+ value: current.modelsCachePath,
1761
+ onChange: (value) => onStage({ modelsCachePath: value }),
1762
+ }),
1763
+ React.createElement(SettingRow, {
1764
+ title: t('settings.transport'),
1765
+ description: t('settings.transport_hint'),
1766
+ htmlFor: 'cb-transport',
1767
+ control: React.createElement(
1768
+ 'select',
1769
+ {
1770
+ id: 'cb-transport',
1771
+ className: 'cb-select',
1772
+ value: current.transport === 'pi-ai' ? 'pi-ai' : 'adapter',
1773
+ onChange: (event) => onStage({ transport: event.target.value }),
1774
+ },
1775
+ React.createElement('option', { value: 'adapter' }, 'Native adapter'),
1776
+ React.createElement('option', { value: 'pi-ai' }, 'Legacy llm-pi-ai + proxy')
1777
+ ),
1778
+ })
1779
+ )
1780
+ }
1781
+
1782
+ // --- src/client/components/update-banner.js ---
1783
+ function UpdateBanner({ updateState, handleTriggerUpdate, handleCheckUpdate, t }) {
1784
+ const status = updateState.status || (
1785
+ updateState.checking
1786
+ ? 'checking'
1787
+ : (updateState.latestCheckFailed
1788
+ ? 'error'
1789
+ : (updateState.updateAvailable
1790
+ ? 'available'
1791
+ : (updateState.lastCheckedAt ? 'current' : 'idle')))
1792
+ )
1793
+
1794
+ const isChecking = status === 'checking' || updateState.checking
1795
+ const isAvailable = status === 'available' || updateState.updateAvailable
1796
+ const isCurrent = status === 'current'
1797
+ const isError = status === 'error' || updateState.latestCheckFailed
1798
+
1799
+ return React.createElement(
1800
+ React.Fragment,
1801
+ null,
1802
+ React.createElement(
1803
+ 'div',
1804
+ { className: 'cb-update-banner' },
1805
+ React.createElement(
1806
+ 'div',
1807
+ { className: 'cb-update-info' },
1808
+ updateState.currentVersion
1809
+ ? React.createElement('span', { className: 'cb-update-version' }, `v${updateState.currentVersion}`)
844
1810
  : null,
845
1811
  isChecking
846
- ? React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: '12px' } },
847
- t('update.checking')
848
- )
1812
+ ? React.createElement('span', { className: 'cb-muted' }, t('update.checking'))
849
1813
  : isAvailable
850
- ? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
1814
+ ? React.createElement(
1815
+ 'span',
1816
+ { className: 'cb-badge cb-badge-warn' },
851
1817
  t('update.available', { latestVersion: updateState.latestVersion, currentVersion: updateState.currentVersion })
852
1818
  )
853
1819
  : isCurrent
854
- ? React.createElement('span', { className: 'cb-badge cb-badge-ok' },
855
- '✓ ' + t('update.up_to_date')
856
- )
1820
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, t('update.up_to_date'))
857
1821
  : isError
858
- ? React.createElement('span', { className: 'cb-badge cb-badge-bad' },
859
- t('update.check_failed')
860
- )
1822
+ ? React.createElement('span', { className: 'cb-badge cb-badge-bad' }, t('update.check_failed'))
861
1823
  : null
862
1824
  ),
863
1825
  isAvailable
@@ -868,7 +1830,6 @@ function UpdateBanner({ updateState, handleTriggerUpdate, handleCheckUpdate, t }
868
1830
  className: 'cb-btn cb-btn-primary',
869
1831
  disabled: updateState.updating,
870
1832
  onClick: handleTriggerUpdate,
871
- style: { padding: '5px 12px', fontSize: '12px' }
872
1833
  },
873
1834
  updateState.updating ? t('update.updating') : t('update.btn')
874
1835
  )
@@ -880,13 +1841,12 @@ function UpdateBanner({ updateState, handleTriggerUpdate, handleCheckUpdate, t }
880
1841
  className: 'cb-btn',
881
1842
  disabled: isChecking,
882
1843
  onClick: handleCheckUpdate,
883
- style: { padding: '5px 12px', fontSize: '12px' }
884
1844
  },
885
1845
  t('update.retry')
886
1846
  )
887
1847
  : null
888
1848
  ),
889
- updateState.notice ? React.createElement('div', { className: 'cb-alert-ok' }, '✓ ' + updateState.notice) : null,
1849
+ updateState.notice ? React.createElement('div', { className: 'cb-alert-ok' }, updateState.notice) : null,
890
1850
  updateState.error ? React.createElement('div', { className: 'cb-alert-err' }, updateState.error) : null
891
1851
  )
892
1852
  }
@@ -894,7 +1854,7 @@ function UpdateBanner({ updateState, handleTriggerUpdate, handleCheckUpdate, t }
894
1854
  // --- src/client/components/key-section.js ---
895
1855
  function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowKey, busy, draft, handleSaveKey, handleFastLogin, verifyStatus, t }) {
896
1856
  return React.createElement(
897
- 'div',
1857
+ 'section',
898
1858
  { className: 'cb-section-card' },
899
1859
  React.createElement('div', { className: 'cb-section-title' }, t('key.title')),
900
1860
  React.createElement('div', { className: 'cb-section-desc' }, t('key.desc')),
@@ -906,15 +1866,11 @@ function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowK
906
1866
  type: showKey ? 'text' : 'password',
907
1867
  placeholder: keyPresent ? t('key.placeholder_has') : t('key.placeholder_empty'),
908
1868
  value: apiKeyInput,
909
- onChange: (e) => setApiKeyInput(e.target.value),
1869
+ onChange: (event) => setApiKeyInput(event.target.value),
910
1870
  }),
911
1871
  React.createElement(
912
1872
  'button',
913
- {
914
- type: 'button',
915
- className: 'cb-btn',
916
- onClick: () => setShowKey((v) => !v),
917
- },
1873
+ { type: 'button', className: 'cb-btn', onClick: () => setShowKey((value) => !value) },
918
1874
  showKey ? t('key.hide') : t('key.show')
919
1875
  ),
920
1876
  React.createElement(
@@ -929,37 +1885,32 @@ function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowK
929
1885
  ),
930
1886
  React.createElement(
931
1887
  'button',
932
- {
933
- type: 'button',
934
- className: 'cb-btn',
935
- disabled: !!busy,
936
- onClick: handleFastLogin,
937
- },
1888
+ { type: 'button', className: 'cb-btn', disabled: !!busy, onClick: handleFastLogin },
938
1889
  t('key.login_fast')
939
1890
  )
940
1891
  ),
941
1892
  verifyStatus && verifyStatus.state === 'verifying'
942
- ? React.createElement('div', { style: { marginTop: '8px', fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } }, t('key.verifying'))
1893
+ ? React.createElement('div', { className: 'cb-hint' }, t('key.verifying'))
943
1894
  : null,
944
1895
  verifyStatus && verifyStatus.state === 'valid'
945
- ? React.createElement('div', { className: 'cb-alert-ok', style: { marginTop: '8px', fontSize: '13px' } }, t('key.verified', { email: verifyStatus.email, plan: verifyStatus.plan }))
1896
+ ? React.createElement('div', { className: 'cb-alert-ok cb-key-status' }, t('key.verified', { email: verifyStatus.email, plan: verifyStatus.plan }))
946
1897
  : null,
947
1898
  verifyStatus && verifyStatus.state === 'invalid'
948
- ? React.createElement('div', { className: 'cb-alert-bad', style: { marginTop: '8px', fontSize: '13px' } }, t('key.invalid', { error: verifyStatus.error }))
1899
+ ? React.createElement('div', { className: 'cb-alert-bad cb-key-status' }, t('key.invalid', { error: verifyStatus.error }))
949
1900
  : null,
950
1901
  React.createElement(
951
1902
  'div',
952
- { className: 'cb-row', style: { fontSize: '13px', marginTop: '12px', color: 'var(--dsw-alias-label-secondary)' } },
1903
+ { className: 'cb-key-meta' },
953
1904
  React.createElement('span', null, t('key.env_label')),
954
- React.createElement('code', null, draft.apiKeyEnv || 'CLINEBOT_API_KEY'),
955
- React.createElement('span', null, ' · '),
1905
+ React.createElement('code', { className: 'cb-mono' }, draft.apiKeyEnv || 'CLINEBOT_API_KEY'),
1906
+ React.createElement('span', { className: 'cb-muted' }, '·'),
956
1907
  React.createElement(
957
1908
  'a',
958
1909
  {
959
1910
  href: 'https://app.cline.bot/settings/api-keys',
960
1911
  target: '_blank',
961
1912
  rel: 'noreferrer',
962
- style: { color: 'var(--dsw-alias-brand-primary)' },
1913
+ className: 'cb-link',
963
1914
  },
964
1915
  t('key.get_key')
965
1916
  )
@@ -968,188 +1919,201 @@ function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowK
968
1919
  }
969
1920
 
970
1921
  // --- src/client/components/accounts-section.js ---
1922
+ /**
1923
+ * Accounts pool: one calm row per account, with live quota, status dot and a
1924
+ * compact action menu. Account mutations commit immediately; the rest of the
1925
+ * settings page uses the staged save bar.
1926
+ */
971
1927
  function AccountsSection({
972
1928
  status,
973
1929
  busy,
974
1930
  handlePinAccount,
975
1931
  handleAddAccount,
976
1932
  handleDeleteAccount,
1933
+ handleRenameAccount,
1934
+ handleClearKey,
977
1935
  accountMode,
978
1936
  handleUpdateAccountMode,
979
1937
  modelRoutes = {},
980
1938
  onUpdateModelRoutes,
981
1939
  t,
982
1940
  }) {
983
- if (!status.accounts || !status.accounts.length) return null
984
-
985
1941
  const [showAdd, setShowAdd] = React.useState(false)
986
1942
  const [newLabel, setNewLabel] = React.useState('')
987
1943
  const [newEnv, setNewEnv] = React.useState('')
988
1944
  const [newKey, setNewKey] = React.useState('')
989
1945
  const [showKey, setShowKey] = React.useState(false)
990
- const [deleteSecret, setDeleteSecret] = React.useState(true)
1946
+ const [discoveredKeys, setDiscoveredKeys] = React.useState([])
1947
+ const [usageByAccount, setUsageByAccount] = React.useState({})
1948
+ const [menuFor, setMenuFor] = React.useState('')
1949
+ const [renameFor, setRenameFor] = React.useState('')
1950
+ const [renameValue, setRenameValue] = React.useState('')
1951
+ const [confirmAction, setConfirmAction] = React.useState(null)
991
1952
  const [routeModel, setRouteModel] = React.useState('')
992
1953
  const [routeAccount, setRouteAccount] = React.useState('')
993
- const [discoveredKeys, setDiscoveredKeys] = React.useState([])
994
1954
 
995
1955
  React.useEffect(() => {
996
1956
  let active = true
997
1957
  fetchWithTimeout(`${ROUTE_PREFIX}/keys/discovered`, { cache: 'no-store' })
998
- .then((r) => r.json())
1958
+ .then((response) => response.json())
999
1959
  .then((data) => {
1000
- if (active && data?.ok && Array.isArray(data.discovered)) {
1001
- setDiscoveredKeys(data.discovered)
1002
- }
1960
+ if (active && data?.ok && Array.isArray(data.discovered)) setDiscoveredKeys(data.discovered)
1003
1961
  })
1004
1962
  .catch(() => {})
1005
1963
  return () => { active = false }
1006
1964
  }, [])
1007
1965
 
1008
- async function handleImportDiscovered(item) {
1009
- let max = 1
1010
- for (const a of (status.accounts || [])) {
1011
- const m = String(a.apiKeyEnv || '').match(/^CLINEBOT_API_KEY_(\d+)$/)
1012
- if (m) {
1013
- const n = parseInt(m[1], 10)
1014
- if (n >= max) max = n + 1
1015
- }
1016
- }
1017
- const targetEnv = `CLINEBOT_API_KEY_${max}`
1018
- if (typeof handleAddAccount === 'function') {
1019
- await handleAddAccount({
1020
- label: item.source || targetEnv,
1021
- apiKeyEnv: targetEnv,
1022
- id: item.id,
1966
+ const accountSignature = (status?.accounts || []).map((account) => account.apiKeyEnv || account.id).join('|')
1967
+ React.useEffect(() => {
1968
+ let active = true
1969
+ fetchWithTimeout(`${ROUTE_PREFIX}/accounts/usage`, { cache: 'no-store' })
1970
+ .then((response) => response.json())
1971
+ .then((data) => {
1972
+ if (!active || !data?.ok || !Array.isArray(data.accounts)) return
1973
+ const next = {}
1974
+ for (const entry of data.accounts) next[entry.apiKeyEnv] = entry
1975
+ setUsageByAccount(next)
1023
1976
  })
1024
- setDiscoveredKeys((prev) => prev.filter((k) => k.id !== item.id))
1025
- }
1026
- }
1977
+ .catch(() => {})
1978
+ return () => { active = false }
1979
+ }, [accountSignature, status?.activeAccount, busy])
1027
1980
 
1028
- function handleOpenAdd() {
1981
+ function nextEnvName() {
1029
1982
  let max = 1
1030
- for (const a of (status.accounts || [])) {
1031
- const m = String(a.apiKeyEnv || '').match(/^CLINEBOT_API_KEY_(\d+)$/)
1032
- if (m) {
1033
- const n = parseInt(m[1], 10)
1034
- if (n >= max) max = n + 1
1035
- }
1983
+ for (const account of status.accounts || []) {
1984
+ const match = String(account.apiKeyEnv || '').match(/^CLINEBOT_API_KEY_(\d+)$/)
1985
+ if (match) max = Math.max(max, Number(match[1]) + 1)
1036
1986
  }
1037
- setNewEnv(`CLINEBOT_API_KEY_${max}`)
1987
+ return `CLINEBOT_API_KEY_${max}`
1988
+ }
1989
+
1990
+ function openAdd() {
1991
+ setNewEnv(nextEnvName())
1038
1992
  setShowAdd(true)
1039
1993
  }
1040
1994
 
1041
- async function onSubmitAdd(e) {
1042
- if (e && e.preventDefault) e.preventDefault()
1043
- if (!newKey.trim()) return
1044
- if (typeof handleAddAccount === 'function') {
1045
- await handleAddAccount({
1046
- label: newLabel.trim() || newEnv.trim(),
1047
- apiKeyEnv: newEnv.trim(),
1048
- apiKey: newKey.trim(),
1049
- })
1050
- }
1995
+ async function submitAdd(event) {
1996
+ if (event?.preventDefault) event.preventDefault()
1997
+ if (!newKey.trim() || typeof handleAddAccount !== 'function') return
1998
+ await handleAddAccount({
1999
+ label: newLabel.trim() || newEnv.trim(),
2000
+ apiKeyEnv: newEnv.trim(),
2001
+ apiKey: newKey.trim(),
2002
+ })
1051
2003
  setNewLabel('')
1052
2004
  setNewEnv('')
1053
2005
  setNewKey('')
1054
2006
  setShowAdd(false)
1055
2007
  }
1056
2008
 
1057
- async function onDeleteAccount(envName) {
1058
- if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
1059
- if (!window.confirm(t('accounts.delete_confirm', { label: envName }))) return
2009
+ async function importDiscovered(item) {
2010
+ if (typeof handleAddAccount !== 'function') return
2011
+ await handleAddAccount({
2012
+ label: item.source || nextEnvName(),
2013
+ apiKeyEnv: nextEnvName(),
2014
+ id: item.id,
2015
+ })
2016
+ setDiscoveredKeys((previous) => previous.filter((entry) => entry.id !== item.id))
2017
+ }
2018
+
2019
+ function openRename(account) {
2020
+ setMenuFor('')
2021
+ setRenameFor(account.apiKeyEnv)
2022
+ setRenameValue(account.label || '')
2023
+ }
2024
+
2025
+ async function submitRename(account) {
2026
+ if (typeof handleRenameAccount === 'function') {
2027
+ await handleRenameAccount(account.apiKeyEnv, renameValue.trim() || account.apiKeyEnv)
2028
+ }
2029
+ setRenameFor('')
2030
+ setRenameValue('')
2031
+ }
2032
+
2033
+ async function runConfirm() {
2034
+ if (!confirmAction) return
2035
+ if (confirmAction.type === 'delete' && typeof handleDeleteAccount === 'function') {
2036
+ await handleDeleteAccount(confirmAction.env, true)
1060
2037
  }
1061
- if (typeof handleDeleteAccount === 'function') {
1062
- await handleDeleteAccount(envName, deleteSecret)
2038
+ if (confirmAction.type === 'clear' && typeof handleClearKey === 'function') {
2039
+ await handleClearKey(confirmAction.env)
1063
2040
  }
2041
+ setConfirmAction(null)
2042
+ }
2043
+
2044
+ function dotClass(account, live) {
2045
+ if (!account.present) return 'cb-status-dot cb-status-dot-warn'
2046
+ if (live?.error) return 'cb-status-dot cb-status-dot-bad'
2047
+ const percent = live?.usage?.windows?.fiveHour?.percentUsed
2048
+ if (typeof percent === 'number' && percent >= 95) return 'cb-status-dot cb-status-dot-bad'
2049
+ if (account.isPinned || status.activeAccount === account.apiKeyEnv) return 'cb-status-dot cb-status-dot-ok'
2050
+ return 'cb-status-dot cb-status-dot-idle'
1064
2051
  }
1065
2052
 
2053
+ const accounts = status.accounts || []
2054
+
1066
2055
  return React.createElement(
1067
- 'div',
1068
- { className: 'cb-section-card' },
2056
+ 'section',
2057
+ { className: 'cb-section-card cb-accounts' },
1069
2058
  React.createElement(
1070
2059
  'div',
1071
- { className: 'cb-section-title', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
1072
- React.createElement('span', null, t('accounts.title')),
2060
+ { className: 'cb-section-head' },
2061
+ React.createElement('div', { className: 'cb-section-title' }, t('accounts.title')),
1073
2062
  !showAdd
1074
2063
  ? React.createElement(
1075
2064
  'button',
1076
- {
1077
- type: 'button',
1078
- className: 'cb-btn',
1079
- style: { fontSize: '11px', padding: '4px 10px' },
1080
- onClick: handleOpenAdd,
1081
- disabled: !!busy,
1082
- },
1083
- t('accounts.add_btn')
2065
+ { type: 'button', className: 'cb-btn cb-btn-primary', disabled: !!busy, onClick: openAdd },
2066
+ React.createElement(CbIcon, { name: 'plus', size: 13 }),
2067
+ ` ${t('accounts.add_btn')}`
1084
2068
  )
1085
2069
  : null
1086
2070
  ),
1087
2071
  React.createElement('div', { className: 'cb-section-desc' }, t('accounts.desc')),
2072
+
1088
2073
  discoveredKeys.length > 0
1089
2074
  ? React.createElement(
1090
2075
  'div',
1091
- { className: 'cb-alert-ok', style: { display: 'flex', flexDirection: 'column', gap: '6px', margin: '6px 0' } },
1092
- React.createElement('div', { style: { fontWeight: 600 } }, `🔍 ${t('discovery.title')}`),
1093
- discoveredKeys.map((item, idx) =>
2076
+ { className: 'cb-discovered' },
2077
+ React.createElement(
2078
+ 'div',
2079
+ { className: 'cb-discovered-head' },
2080
+ React.createElement(CbIcon, { name: 'key', size: 14 }),
2081
+ ` ${t('discovery.title')}`
2082
+ ),
2083
+ discoveredKeys.map((item) => React.createElement(
2084
+ 'div',
2085
+ { className: 'cb-discovered-row', key: item.id },
2086
+ React.createElement('span', { className: 'cb-mono' }, item.maskedKey || item.source),
2087
+ React.createElement('span', { className: 'cb-muted' }, item.source || ''),
1094
2088
  React.createElement(
1095
- 'div',
1096
- { key: idx, style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
1097
- React.createElement('span', null, `${item.maskedKey} (${item.source})`),
1098
- React.createElement(
1099
- 'button',
1100
- {
1101
- type: 'button',
1102
- className: 'cb-btn',
1103
- style: { fontSize: '11px', padding: '2px 8px' },
1104
- disabled: !!busy,
1105
- onClick: () => handleImportDiscovered(item),
1106
- },
1107
- t('discovery.import_btn', { source: item.source })
1108
- )
2089
+ 'button',
2090
+ {
2091
+ type: 'button',
2092
+ className: 'cb-btn',
2093
+ disabled: !!busy,
2094
+ onClick: () => importDiscovered(item),
2095
+ },
2096
+ React.createElement(CbIcon, { name: 'download', size: 12 }),
2097
+ ` ${t('discovery.import_btn', { source: item.source || '' })}`
1109
2098
  )
1110
- )
2099
+ ))
1111
2100
  )
1112
2101
  : null,
2102
+
1113
2103
  React.createElement(
1114
2104
  'div',
1115
- {
1116
- style: {
1117
- display: 'flex',
1118
- alignItems: 'center',
1119
- gap: '8px',
1120
- margin: '8px 0 12px 0',
1121
- fontSize: '12px',
1122
- color: 'var(--dsw-alias-label-secondary)',
1123
- },
1124
- },
1125
- React.createElement('span', { style: { fontWeight: 500 } }, t('accounts.mode_label') + ':'),
2105
+ { className: 'cb-account-mode' },
2106
+ React.createElement('span', { className: 'cb-muted' }, t('accounts.mode_label')),
1126
2107
  React.createElement(
1127
2108
  'div',
1128
- {
1129
- style: {
1130
- display: 'inline-flex',
1131
- borderRadius: '6px',
1132
- border: '1px solid var(--dsw-alias-border-l2, color-mix(in srgb, currentColor 12%, transparent))',
1133
- overflow: 'hidden',
1134
- },
1135
- },
2109
+ { className: 'cb-seg' },
1136
2110
  React.createElement(
1137
2111
  'button',
1138
2112
  {
1139
2113
  type: 'button',
1140
- className: 'cb-btn',
1141
- style: {
1142
- borderRadius: 0,
1143
- fontSize: '11px',
1144
- padding: '3px 9px',
1145
- border: 'none',
1146
- background: (accountMode || 'least_used') === 'least_used'
1147
- ? 'var(--dsw-alias-interactive-bg-active, color-mix(in srgb, currentColor 14%, transparent))'
1148
- : 'transparent',
1149
- fontWeight: (accountMode || 'least_used') === 'least_used' ? 600 : 400,
1150
- },
1151
- onClick: () => typeof handleUpdateAccountMode === 'function' && handleUpdateAccountMode('least_used'),
2114
+ className: (accountMode || 'least_used') === 'least_used' ? 'cb-seg-item cb-seg-item-active' : 'cb-seg-item',
1152
2115
  disabled: !!busy,
2116
+ onClick: () => handleUpdateAccountMode && handleUpdateAccountMode('least_used'),
1153
2117
  },
1154
2118
  t('accounts.mode_least_used')
1155
2119
  ),
@@ -1157,20 +2121,9 @@ function AccountsSection({
1157
2121
  'button',
1158
2122
  {
1159
2123
  type: 'button',
1160
- className: 'cb-btn',
1161
- style: {
1162
- borderRadius: 0,
1163
- fontSize: '11px',
1164
- padding: '3px 9px',
1165
- border: 'none',
1166
- borderLeft: '1px solid var(--dsw-alias-border-l2, color-mix(in srgb, currentColor 12%, transparent))',
1167
- background: accountMode === 'round_robin'
1168
- ? 'var(--dsw-alias-interactive-bg-active, color-mix(in srgb, currentColor 14%, transparent))'
1169
- : 'transparent',
1170
- fontWeight: accountMode === 'round_robin' ? 600 : 400,
1171
- },
1172
- onClick: () => typeof handleUpdateAccountMode === 'function' && handleUpdateAccountMode('round_robin'),
2124
+ className: accountMode === 'round_robin' ? 'cb-seg-item cb-seg-item-active' : 'cb-seg-item',
1173
2125
  disabled: !!busy,
2126
+ onClick: () => handleUpdateAccountMode && handleUpdateAccountMode('round_robin'),
1174
2127
  },
1175
2128
  t('accounts.mode_round_robin')
1176
2129
  )
@@ -1180,72 +2133,47 @@ function AccountsSection({
1180
2133
  showAdd
1181
2134
  ? React.createElement(
1182
2135
  'form',
1183
- {
1184
- onSubmit: onSubmitAdd,
1185
- style: {
1186
- background: 'var(--dsw-alias-interactive-bg-hover, color-mix(in srgb, currentColor 4%, transparent))',
1187
- border: '1px solid var(--dsw-alias-border-l2, color-mix(in srgb, currentColor 12%, transparent))',
1188
- borderRadius: '8px',
1189
- padding: '12px',
1190
- marginBottom: '14px',
1191
- display: 'flex',
1192
- flexDirection: 'column',
1193
- gap: '8px',
1194
- },
1195
- },
2136
+ { className: 'cb-account-add', onSubmit: submitAdd },
1196
2137
  React.createElement(
1197
2138
  'div',
1198
- { style: { display: 'flex', gap: '8px', flexWrap: 'wrap' } },
2139
+ { className: 'cb-account-add-row' },
1199
2140
  React.createElement('input', {
1200
2141
  type: 'text',
1201
2142
  className: 'cb-input',
1202
- style: { flex: '1 1 180px' },
1203
2143
  placeholder: t('accounts.label_placeholder'),
1204
2144
  value: newLabel,
1205
- onChange: (e) => setNewLabel(e.target.value),
2145
+ onChange: (event) => setNewLabel(event.target.value),
1206
2146
  }),
1207
2147
  React.createElement('input', {
1208
2148
  type: 'text',
1209
2149
  className: 'cb-input',
1210
- style: { flex: '1 1 180px' },
1211
2150
  placeholder: t('accounts.env_placeholder'),
1212
2151
  value: newEnv,
1213
- onChange: (e) => setNewEnv(e.target.value),
2152
+ onChange: (event) => setNewEnv(event.target.value),
1214
2153
  })
1215
2154
  ),
1216
2155
  React.createElement(
1217
2156
  'div',
1218
- { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
2157
+ { className: 'cb-account-add-row' },
1219
2158
  React.createElement('input', {
1220
2159
  type: showKey ? 'text' : 'password',
1221
2160
  className: 'cb-input',
1222
- style: { flex: '1' },
1223
2161
  placeholder: t('key.placeholder_empty'),
1224
2162
  value: newKey,
1225
- onChange: (e) => setNewKey(e.target.value),
2163
+ onChange: (event) => setNewKey(event.target.value),
1226
2164
  }),
1227
2165
  React.createElement(
1228
2166
  'button',
1229
- {
1230
- type: 'button',
1231
- className: 'cb-btn',
1232
- style: { padding: '4px 8px', fontSize: '11px' },
1233
- onClick: () => setShowKey(!showKey),
1234
- },
2167
+ { type: 'button', className: 'cb-btn', onClick: () => setShowKey((value) => !value) },
1235
2168
  showKey ? t('key.hide') : t('key.show')
1236
2169
  )
1237
2170
  ),
1238
2171
  React.createElement(
1239
2172
  'div',
1240
- { style: { display: 'flex', gap: '8px', justifyContent: 'flex-end', marginTop: '4px' } },
2173
+ { className: 'cb-account-add-actions' },
1241
2174
  React.createElement(
1242
2175
  'button',
1243
- {
1244
- type: 'button',
1245
- className: 'cb-btn',
1246
- style: { padding: '4px 10px', fontSize: '12px' },
1247
- onClick: () => setShowAdd(false),
1248
- },
2176
+ { type: 'button', className: 'cb-btn', onClick: () => setShowAdd(false) },
1249
2177
  t('accounts.cancel_btn')
1250
2178
  ),
1251
2179
  React.createElement(
@@ -1253,7 +2181,6 @@ function AccountsSection({
1253
2181
  {
1254
2182
  type: 'submit',
1255
2183
  className: 'cb-btn cb-btn-primary',
1256
- style: { padding: '4px 12px', fontSize: '12px' },
1257
2184
  disabled: !newKey.trim() || !!busy,
1258
2185
  },
1259
2186
  busy === 'add-account' ? t('key.saving') : t('accounts.save_btn')
@@ -1262,147 +2189,186 @@ function AccountsSection({
1262
2189
  )
1263
2190
  : null,
1264
2191
 
1265
- React.createElement(
1266
- 'table',
1267
- { className: 'cb-table' },
1268
- React.createElement(
1269
- 'thead',
1270
- null,
1271
- React.createElement(
1272
- 'tr',
1273
- null,
1274
- React.createElement('th', null, 'Account'),
1275
- React.createElement('th', null, 'Credential Ref'),
1276
- React.createElement('th', null, 'Status'),
1277
- React.createElement('th', { style: { textAlign: 'right' } }, 'Action')
1278
- )
1279
- ),
1280
- React.createElement(
1281
- 'tbody',
1282
- null,
1283
- status.accounts.map((acc) => {
1284
- const envName = typeof acc.apiKeyEnv === 'string' ? acc.apiKeyEnv : ''
1285
- const label = typeof acc.label === 'string' ? acc.label : ''
1286
- const isActive = status.activeAccount === envName || (!status.activeAccount && acc.id === 'default')
1287
- const isPrimary = acc.id === 'default' || envName === status.config?.apiKeyEnv
1288
- return React.createElement(
1289
- 'tr',
1290
- { key: acc.id || envName },
1291
- React.createElement('td', null, React.createElement('strong', null, label)),
1292
- React.createElement('td', null, React.createElement('code', null, envName)),
1293
- React.createElement(
1294
- 'td',
1295
- null,
1296
- acc.present
1297
- ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, `Configured (${acc.source})`)
1298
- : React.createElement('span', { className: 'cb-badge cb-badge-warn' }, 'Missing Key'),
1299
- isActive
1300
- ? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: '6px' } }, t('accounts.active_badge'))
1301
- : null,
1302
- acc.isPinned && !isActive
1303
- ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: '6px', opacity: 0.85 } }, t('accounts.pinned_badge'))
1304
- : null,
1305
- typeof acc.percentUsed === 'number'
1306
- ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: '6px' } }, t('accounts.quota_used', { pct: acc.percentUsed }))
1307
- : null
1308
- ),
1309
- React.createElement(
1310
- 'td',
1311
- { style: { textAlign: 'right' } },
1312
- !isActive && acc.present
2192
+ accounts.length === 0
2193
+ ? React.createElement('div', { className: 'cb-empty' }, t('accounts.no_accounts'))
2194
+ : React.createElement(
2195
+ 'div',
2196
+ { className: 'cb-account-list' },
2197
+ accounts.map((account) => {
2198
+ const envName = account.apiKeyEnv || account.id || ''
2199
+ const live = usageByAccount[envName]
2200
+ const isActive = status.activeAccount === envName || (!status.activeAccount && account.id === 'default')
2201
+ const isPrimary = account.id === 'default' || envName === status.config?.apiKeyEnv
2202
+ const five = live?.usage?.windows?.fiveHour
2203
+ const percent = typeof five?.percentUsed === 'number'
2204
+ ? five.percentUsed
2205
+ : (typeof account.percentUsed === 'number' ? account.percentUsed : null)
2206
+ const plan = live?.usage?.plan || ''
2207
+ const email = live?.usage?.user?.email || ''
2208
+ return React.createElement(
2209
+ 'div',
2210
+ { className: 'cb-account-item', key: envName || account.id },
2211
+ React.createElement(
2212
+ 'div',
2213
+ { className: 'cb-account-row' },
2214
+ React.createElement('span', { className: dotClass(account, live), 'aria-hidden': 'true' }),
2215
+ React.createElement(
2216
+ 'div',
2217
+ { className: 'cb-account-main' },
2218
+ React.createElement(
2219
+ 'div',
2220
+ { className: 'cb-account-name' },
2221
+ account.label || envName,
2222
+ React.createElement('span', { className: 'cb-account-env cb-mono' }, envName)
2223
+ ),
2224
+ React.createElement(
2225
+ 'div',
2226
+ { className: 'cb-account-meta' },
2227
+ [plan, email].filter(Boolean).join(' · ') || t('accounts.source', { source: account.source || '—' })
2228
+ )
2229
+ ),
2230
+ React.createElement(
2231
+ 'div',
2232
+ { className: 'cb-account-badges' },
2233
+ isActive ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, t('accounts.active_badge')) : null,
2234
+ account.isPinned ? React.createElement('span', { className: 'cb-badge' }, t('accounts.pinned_badge')) : null,
2235
+ account.present
2236
+ ? React.createElement('span', { className: 'cb-badge' }, t('accounts.configured'))
2237
+ : React.createElement('span', { className: 'cb-badge cb-badge-warn' }, t('accounts.missing_key'))
2238
+ ),
2239
+ React.createElement(
2240
+ 'div',
2241
+ { className: 'cb-account-quota' },
2242
+ percent === null
2243
+ ? null
2244
+ : React.createElement(
2245
+ React.Fragment,
2246
+ null,
2247
+ React.createElement('span', { className: 'cb-muted' }, t('accounts.quota_5h', { pct: Math.round(percent) })),
2248
+ React.createElement(
2249
+ 'span',
2250
+ { className: 'cb-meter' },
2251
+ React.createElement('span', {
2252
+ className: percent >= 95 ? 'cb-meter-fill cb-meter-fill-warn' : 'cb-meter-fill',
2253
+ style: { width: `${Math.max(0, Math.min(100, percent))}%` },
2254
+ })
2255
+ )
2256
+ )
2257
+ ),
2258
+ React.createElement(
2259
+ 'button',
2260
+ {
2261
+ type: 'button',
2262
+ className: 'cb-icon-btn',
2263
+ 'aria-label': t('accounts.actions'),
2264
+ 'aria-expanded': menuFor === envName,
2265
+ disabled: !!busy,
2266
+ onClick: () => setMenuFor((current) => (current === envName ? '' : envName)),
2267
+ },
2268
+ React.createElement(CbIcon, { name: 'kebab', size: 16 })
2269
+ )
2270
+ ),
2271
+
2272
+ menuFor === envName
1313
2273
  ? React.createElement(
1314
- 'button',
1315
- {
1316
- type: 'button',
1317
- className: 'cb-btn',
1318
- style: { padding: '4px 8px', fontSize: '11px' },
1319
- disabled: !!busy,
1320
- onClick: () => handlePinAccount(envName),
1321
- },
1322
- t('accounts.pin_btn')
2274
+ 'div',
2275
+ { className: 'cb-account-menu' },
2276
+ isActive
2277
+ ? React.createElement('button', { type: 'button', className: 'cb-menu-item', onClick: () => { setMenuFor(''); handlePinAccount('') } }, t('accounts.unpin'))
2278
+ : account.present
2279
+ ? React.createElement('button', { type: 'button', className: 'cb-menu-item', onClick: () => { setMenuFor(''); handlePinAccount(envName) } }, t('accounts.pin_btn'))
2280
+ : null,
2281
+ !isPrimary
2282
+ ? React.createElement('button', { type: 'button', className: 'cb-menu-item', onClick: () => openRename(account) }, t('accounts.rename'))
2283
+ : null,
2284
+ account.present
2285
+ ? React.createElement('button', { type: 'button', className: 'cb-menu-item', onClick: () => { setMenuFor(''); setConfirmAction({ type: 'clear', env: envName }) } }, t('accounts.clear_key'))
2286
+ : null,
2287
+ !isPrimary
2288
+ ? React.createElement('button', { type: 'button', className: 'cb-menu-item cb-menu-item-danger', onClick: () => { setMenuFor(''); setConfirmAction({ type: 'delete', env: envName }) } }, t('accounts.delete_btn'))
2289
+ : null
1323
2290
  )
1324
2291
  : null,
1325
- !isPrimary
2292
+
2293
+ renameFor === envName
1326
2294
  ? React.createElement(
1327
- 'button',
1328
- {
1329
- type: 'button',
1330
- className: 'cb-btn cb-btn-danger',
1331
- style: { padding: '4px 8px', fontSize: '11px', marginLeft: '6px' },
1332
- disabled: !!busy,
1333
- onClick: () => onDeleteAccount(envName),
1334
- },
1335
- t('accounts.delete_btn')
2295
+ 'div',
2296
+ { className: 'cb-account-inline' },
2297
+ React.createElement('input', {
2298
+ type: 'text',
2299
+ className: 'cb-input',
2300
+ value: renameValue,
2301
+ onChange: (event) => setRenameValue(event.target.value),
2302
+ placeholder: t('accounts.rename_placeholder'),
2303
+ }),
2304
+ React.createElement('button', { type: 'button', className: 'cb-btn cb-btn-primary', disabled: !!busy, onClick: () => submitRename(account) }, t('accounts.rename_save')),
2305
+ React.createElement('button', { type: 'button', className: 'cb-btn', onClick: () => setRenameFor('') }, t('accounts.cancel'))
2306
+ )
2307
+ : null,
2308
+
2309
+ confirmAction && confirmAction.env === envName
2310
+ ? React.createElement(
2311
+ 'div',
2312
+ { className: 'cb-account-inline cb-account-danger' },
2313
+ React.createElement(
2314
+ 'span',
2315
+ { className: 'cb-account-danger-text' },
2316
+ confirmAction.type === 'delete'
2317
+ ? t('accounts.remove_confirm', { name: account.label || envName })
2318
+ : t('accounts.clear_confirm', { name: account.label || envName })
2319
+ ),
2320
+ React.createElement('button', { type: 'button', className: 'cb-btn cb-btn-danger', disabled: !!busy, onClick: runConfirm }, t('accounts.confirm')),
2321
+ React.createElement('button', { type: 'button', className: 'cb-btn', onClick: () => setConfirmAction(null) }, t('accounts.cancel'))
1336
2322
  )
1337
2323
  : null
1338
2324
  )
1339
- )
1340
- })
1341
- )
1342
- ),
1343
- status.lastRotation
1344
- ? React.createElement(
1345
- 'div',
1346
- { className: 'cb-rotation-info', style: { marginTop: '10px', fontSize: '12px', opacity: 0.85 } },
1347
- t('accounts.last_failover', {
1348
- from: status.lastRotation.from,
1349
- to: status.lastRotation.to,
1350
- reason: status.lastRotation.reason,
1351
2325
  })
1352
- )
1353
- : null,
2326
+ ),
2327
+
1354
2328
  React.createElement(
1355
2329
  'div',
1356
- { style: { marginTop: '14px', paddingTop: '12px', borderTop: '1px solid var(--dsw-alias-border-l2)' } },
2330
+ { className: 'cb-account-routes' },
1357
2331
  React.createElement(
1358
2332
  'div',
1359
- { style: { fontSize: '12px', fontWeight: 600, color: 'var(--dsw-alias-label-primary)', marginBottom: '4px' } },
1360
- t('accounts.routes_title')
2333
+ { className: 'cb-account-routes-head' },
2334
+ React.createElement(CbIcon, { name: 'route', size: 14 }),
2335
+ ` ${t('accounts.routes_title')}`
1361
2336
  ),
2337
+ React.createElement('div', { className: 'cb-section-desc' }, t('accounts.routes_desc')),
1362
2338
  React.createElement(
1363
2339
  'div',
1364
- { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', marginBottom: '8px' } },
1365
- t('accounts.routes_desc')
1366
- ),
1367
- React.createElement(
1368
- 'div',
1369
- { style: { display: 'flex', gap: '6px', flexWrap: 'wrap', alignItems: 'center' } },
2340
+ { className: 'cb-account-route-add' },
1370
2341
  React.createElement('input', {
1371
2342
  type: 'text',
1372
2343
  className: 'cb-input',
1373
- style: { flex: '2 1 140px', height: '26px', fontSize: '11px', padding: '0 6px' },
1374
2344
  placeholder: t('accounts.route_model_placeholder'),
1375
2345
  value: routeModel,
1376
- onChange: (e) => setRouteModel(e.target.value),
2346
+ onChange: (event) => setRouteModel(event.target.value),
1377
2347
  }),
1378
2348
  React.createElement(
1379
2349
  'select',
1380
2350
  {
1381
2351
  className: 'cb-select',
1382
- style: { flex: '1 1 120px', height: '26px', fontSize: '11px', padding: '0 4px' },
1383
2352
  value: routeAccount,
1384
- onChange: (e) => setRouteAccount(e.target.value),
2353
+ onChange: (event) => setRouteAccount(event.target.value),
1385
2354
  },
1386
2355
  React.createElement('option', { value: '' }, t('accounts.route_account_placeholder')),
1387
- (status.accounts || []).map((acc) => {
1388
- const keyName = acc.apiKeyEnv || acc.id
1389
- return React.createElement('option', { key: keyName, value: keyName }, `${acc.label || keyName} (${keyName})`)
2356
+ accounts.map((account) => {
2357
+ const key = account.apiKeyEnv || account.id
2358
+ return React.createElement('option', { key, value: key }, `${account.label || key} (${key})`)
1390
2359
  })
1391
2360
  ),
1392
2361
  React.createElement(
1393
2362
  'button',
1394
2363
  {
1395
2364
  type: 'button',
1396
- className: 'cb-btn cb-btn-primary',
1397
- style: { padding: '3px 8px', fontSize: '11px' },
2365
+ className: 'cb-btn',
1398
2366
  disabled: !routeModel.trim() || !routeAccount.trim() || !!busy,
1399
2367
  onClick: () => {
1400
- if (typeof onUpdateModelRoutes === 'function') {
1401
- const next = { ...(modelRoutes || {}), [routeModel.trim()]: routeAccount.trim() }
1402
- onUpdateModelRoutes(next)
1403
- setRouteModel('')
1404
- setRouteAccount('')
1405
- }
2368
+ if (typeof onUpdateModelRoutes !== 'function') return
2369
+ onUpdateModelRoutes({ ...(modelRoutes || {}), [routeModel.trim()]: routeAccount.trim() })
2370
+ setRouteModel('')
2371
+ setRouteAccount('')
1406
2372
  },
1407
2373
  },
1408
2374
  t('accounts.route_btn_add')
@@ -1411,48 +2377,30 @@ function AccountsSection({
1411
2377
  Object.keys(modelRoutes || {}).length > 0
1412
2378
  ? React.createElement(
1413
2379
  'div',
1414
- { style: { display: 'flex', flexDirection: 'column', gap: '4px', marginTop: '8px' } },
1415
- Object.entries(modelRoutes || {}).map(([mId, accKey]) =>
2380
+ { className: 'cb-account-route-list' },
2381
+ Object.entries(modelRoutes || {}).map(([modelId, accountKey]) => React.createElement(
2382
+ 'div',
2383
+ { className: 'cb-account-route-row', key: modelId },
2384
+ React.createElement('span', { className: 'cb-mono' }, modelId),
2385
+ React.createElement(CbIcon, { name: 'chevron', size: 12 }),
2386
+ React.createElement('strong', null, accountKey),
1416
2387
  React.createElement(
1417
- 'div',
2388
+ 'button',
1418
2389
  {
1419
- key: mId,
1420
- style: {
1421
- display: 'flex',
1422
- justifyContent: 'space-between',
1423
- alignItems: 'center',
1424
- padding: '3px 8px',
1425
- fontSize: '11px',
1426
- background: 'var(--dsw-alias-bg-layer-2)',
1427
- borderRadius: '4px',
2390
+ type: 'button',
2391
+ className: 'cb-icon-btn',
2392
+ 'aria-label': t('accounts.route_remove'),
2393
+ disabled: !!busy,
2394
+ onClick: () => {
2395
+ if (typeof onUpdateModelRoutes !== 'function') return
2396
+ const next = { ...(modelRoutes || {}) }
2397
+ delete next[modelId]
2398
+ onUpdateModelRoutes(next)
1428
2399
  },
1429
2400
  },
1430
- React.createElement(
1431
- 'span',
1432
- null,
1433
- React.createElement('code', null, mId),
1434
- ' ➔ ',
1435
- React.createElement('strong', null, accKey)
1436
- ),
1437
- React.createElement(
1438
- 'button',
1439
- {
1440
- type: 'button',
1441
- className: 'cb-btn cb-btn-danger',
1442
- style: { padding: '1px 6px', fontSize: '10px' },
1443
- disabled: !!busy,
1444
- onClick: () => {
1445
- if (typeof onUpdateModelRoutes === 'function') {
1446
- const next = { ...(modelRoutes || {}) }
1447
- delete next[mId]
1448
- onUpdateModelRoutes(next)
1449
- }
1450
- },
1451
- },
1452
- '✕'
1453
- )
2401
+ React.createElement(CbIcon, { name: 'close', size: 13 })
1454
2402
  )
1455
- )
2403
+ ))
1456
2404
  )
1457
2405
  : null
1458
2406
  )
@@ -1474,15 +2422,26 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, sho
1474
2422
  ? React.createElement(
1475
2423
  'div',
1476
2424
  { className: status.quotaWarning.level === 'exhausted' ? 'cb-banner-exhausted' : 'cb-banner-warning' },
1477
- React.createElement('span', { style: { fontSize: '16px' } }, status.quotaWarning.level === 'exhausted' ? '🚨' : '⚠️'),
2425
+ React.createElement(
2426
+ 'span',
2427
+ { className: 'cb-banner-icon' },
2428
+ React.createElement(CbIcon, {
2429
+ name: status.quotaWarning.level === 'exhausted' ? 'error' : 'warning',
2430
+ size: 16,
2431
+ })
2432
+ ),
1478
2433
  React.createElement(
1479
2434
  'div',
1480
- { style: { flex: 1 } },
1481
- React.createElement('div', null, (status.quotaWarning.code && t(status.quotaWarning.code, status.quotaWarning.params)) || status.quotaWarning.message),
2435
+ { className: 'cb-banner-body' },
2436
+ React.createElement(
2437
+ 'div',
2438
+ null,
2439
+ (status.quotaWarning.code && t(status.quotaWarning.code, status.quotaWarning.params)) || status.quotaWarning.message
2440
+ ),
1482
2441
  status.quotaWarning.resetsAt
1483
2442
  ? React.createElement(
1484
2443
  'div',
1485
- { style: { fontSize: '11px', opacity: 0.9, marginTop: '2px' } },
2444
+ { className: 'cb-banner-sub' },
1486
2445
  t('quota.reset_at', { time: new Date(status.quotaWarning.resetsAt).toLocaleTimeString() })
1487
2446
  )
1488
2447
  : null
@@ -1492,26 +2451,25 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, sho
1492
2451
  ? React.createElement(
1493
2452
  'div',
1494
2453
  { className: 'cb-banner-warning' },
1495
- React.createElement('span', { style: { fontSize: '16px' } }, '⚠️'),
1496
- React.createElement('div', { style: { flex: 1 } }, t('quota.warning_low'))
2454
+ React.createElement(
2455
+ 'span',
2456
+ { className: 'cb-banner-icon' },
2457
+ React.createElement(CbIcon, { name: 'warning', size: 16 })
2458
+ ),
2459
+ React.createElement('div', { className: 'cb-banner-body' }, t('quota.warning_low'))
1497
2460
  )
1498
2461
  : null),
1499
2462
  keyPresent
1500
2463
  ? React.createElement(
1501
- 'div',
2464
+ 'section',
1502
2465
  { className: 'cb-section-card' },
1503
2466
  React.createElement(
1504
2467
  'div',
1505
- { className: 'cb-section-title' },
1506
- t('quota.title'),
2468
+ { className: 'cb-section-head' },
2469
+ React.createElement('div', { className: 'cb-section-title' }, t('quota.title')),
1507
2470
  React.createElement(
1508
2471
  'button',
1509
- {
1510
- type: 'button',
1511
- className: 'cb-btn',
1512
- disabled: !!busy,
1513
- onClick: handleRefreshQuota,
1514
- },
2472
+ { type: 'button', className: 'cb-btn', disabled: !!busy, onClick: handleRefreshQuota },
1515
2473
  busy === 'refresh-quota' ? t('quota.refreshing') : t('quota.refresh')
1516
2474
  )
1517
2475
  ),
@@ -1524,7 +2482,7 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, sho
1524
2482
  ),
1525
2483
  React.createElement(
1526
2484
  'div',
1527
- { className: 'cb-grid-2', style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '10px' } },
2485
+ { className: 'cb-quota-grid' },
1528
2486
  usage?.windows?.fiveHour
1529
2487
  ? React.createElement(ProgressBar, {
1530
2488
  label: t('quota.window_5h'),
@@ -1551,21 +2509,15 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, sho
1551
2509
  })
1552
2510
  : null
1553
2511
  ),
1554
- React.createElement(
1555
- 'div',
1556
- { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '24px', marginTop: '4px', paddingTop: '16px', borderTop: '.5px solid var(--dsw-alias-border-l2)' } },
1557
- React.createElement(
1558
- 'div',
1559
- { style: { display: 'flex', flexDirection: 'column', minWidth: 0 } },
1560
- React.createElement('span', { style: { fontSize: '14px', lineHeight: '20px', color: 'var(--dsw-alias-label-primary)' } }, t('sidebar.show_quota')),
1561
- React.createElement('span', { style: { marginTop: '4px', fontSize: '12px', lineHeight: '18px', color: 'var(--dsw-alias-label-secondary)' } }, t('sidebar.show_quota_hint'))
1562
- ),
1563
- React.createElement(BooleanControl, {
2512
+ React.createElement(SettingRow, {
2513
+ title: t('sidebar.show_quota'),
2514
+ description: t('sidebar.show_quota_hint'),
2515
+ control: React.createElement(BooleanControl, {
1564
2516
  checked: showSidebarQuota !== false,
1565
2517
  label: t('sidebar.show_quota'),
1566
2518
  onChange: (next) => onToggleSidebarQuota && onToggleSidebarQuota(next),
1567
- })
1568
- )
2519
+ }),
2520
+ })
1569
2521
  )
1570
2522
  : null
1571
2523
  )
@@ -1632,12 +2584,17 @@ function CustomModelsSub({ customModels = [], onConfigUpdate, busy, t }) {
1632
2584
  React.createElement(
1633
2585
  'span',
1634
2586
  { style: { fontWeight: 500, fontSize: '13px', color: 'var(--dsw-alias-label-primary)' } },
1635
- `➕ ${t('models.custom_add_title')} (${customModels.length})`
2587
+ React.createElement(CbIcon, { name: 'plus', size: 13 }),
2588
+ ` ${t('models.custom_add_title')} (${customModels.length})`
1636
2589
  ),
1637
2590
  React.createElement(
1638
2591
  'span',
1639
2592
  { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } },
1640
- isExpanded ? '▲' : '▼'
2593
+ React.createElement(CbIcon, {
2594
+ name: 'chevron',
2595
+ size: 13,
2596
+ className: isExpanded ? 'cb-icon cb-icon-chevron-open' : 'cb-icon',
2597
+ })
1641
2598
  )
1642
2599
  ),
1643
2600
  isExpanded
@@ -1897,7 +2854,12 @@ function VisibleModelsSub({
1897
2854
  style: { padding: '4px 10px', fontSize: '12px' },
1898
2855
  onClick: () => setOpen(!open),
1899
2856
  },
1900
- open ? '▲ ' + t('models.models_selected', { selected: count, total: modelsList.length }) : '▼ ' + t('models.models_selected', { selected: count, total: modelsList.length })
2857
+ React.createElement(CbIcon, {
2858
+ name: 'chevron',
2859
+ size: 12,
2860
+ className: open ? 'cb-icon cb-icon-chevron-open' : 'cb-icon',
2861
+ }),
2862
+ ` ${t('models.models_selected', { selected: count, total: modelsList.length })}`
1901
2863
  )
1902
2864
  )
1903
2865
  ),
@@ -1984,7 +2946,7 @@ function VisibleModelsSub({
1984
2946
  ? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { fontSize: '10px', padding: '1px 6px' } }, t('models.in_plan'))
1985
2947
  : React.createElement('span', { className: 'cb-badge', style: { fontSize: '10px', padding: '1px 6px', color: 'var(--dsw-alias-label-secondary)' } }, t('models.out_of_plan')),
1986
2948
  !m.isContextOverridden
1987
- ? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { fontSize: '10px', padding: '1px 5px' } }, '原厂')
2949
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { fontSize: '10px', padding: '1px 5px' } }, t('models.ctx_tag_original'))
1988
2950
  : null
1989
2951
  )
1990
2952
  )
@@ -1995,6 +2957,23 @@ function VisibleModelsSub({
1995
2957
  )
1996
2958
  }
1997
2959
 
2960
+ // --- src/client/components/provider-card.js ---
2961
+ /**
2962
+ * Provider card inside DSH Settings -> Models.
2963
+ *
2964
+ * The host owns the row chrome: provider name, status dot and the Edit action.
2965
+ * Accounts, the API key, models and quotas live on the full Settings -> ClineBot
2966
+ * page, so this slot intentionally renders nothing instead of duplicating that
2967
+ * form inside the Models page.
2968
+ */
2969
+ function ProviderCard(props) {
2970
+ const row = props?.provider || {}
2971
+ const providerId = typeof row === 'string' ? row : (row.provider || props?.providerId || '')
2972
+ const settingsNs = row && typeof row === 'object' ? (row.settingsNs || '') : ''
2973
+ if (providerId !== 'clinebot' && settingsNs !== NS) return null
2974
+ return null
2975
+ }
2976
+
1998
2977
  // --- src/client/components/models-section.js ---
1999
2978
  function ModelsSection({
2000
2979
  modelsList,
@@ -2061,9 +3040,7 @@ function ModelsSection({
2061
3040
  return React.createElement(
2062
3041
  'div',
2063
3042
  { className: 'cb-section-card' },
2064
- defaultModelWarning
2065
- ? React.createElement('div', { className: 'cb-alert-bad', style: { marginBottom: '12px' } }, `⚠️ ${defaultModelWarning}`)
2066
- : null,
3043
+ defaultModelWarning ? React.createElement('div', { className: 'cb-alert-bad cb-inline-alert', style: { marginBottom: '12px' } }, React.createElement(CbIcon, { name: 'warning', size: 14 }), ` ${defaultModelWarning}`) : null,
2067
3044
  React.createElement(VisibleModelsSub, {
2068
3045
  modelsList,
2069
3046
  visibleModels,
@@ -2424,7 +3401,7 @@ function ModelsSection({
2424
3401
  setEditingModelId(m.id)
2425
3402
  },
2426
3403
  },
2427
- '✎'
3404
+ React.createElement(CbIcon, { name: 'edit', size: 13 })
2428
3405
  )
2429
3406
  )
2430
3407
  ),
@@ -2503,6 +3480,7 @@ function StatsSection({ status, onResetStats, busy, t }) {
2503
3480
  const requests = typeof totals.requests === 'number' ? totals.requests : (typeof s.requests === 'number' ? s.requests : 0)
2504
3481
  const totalTokens = typeof totals.totalTokens === 'number' ? totals.totalTokens : (typeof s.totalTokens === 'number' ? s.totalTokens : 0)
2505
3482
  const errors429 = typeof totals.rateLimited429 === 'number' ? totals.rateLimited429 : (typeof s.errors429 === 'number' ? s.errors429 : (typeof totals.failed === 'number' ? totals.failed : 0))
3483
+ const reasoning = Number(totals.reasoningTokens || sess.reasoningTokensEst || 0)
2506
3484
 
2507
3485
  const topModels = Object.entries(s.byModel || {})
2508
3486
  .sort((a, b) => {
@@ -2513,24 +3491,24 @@ function StatsSection({ status, onResetStats, busy, t }) {
2513
3491
  .slice(0, 5)
2514
3492
 
2515
3493
  return React.createElement(
2516
- 'div',
3494
+ 'section',
2517
3495
  { className: 'cb-section-card' },
2518
3496
  React.createElement(
2519
3497
  'div',
2520
- { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' } },
3498
+ { className: 'cb-section-head' },
2521
3499
  React.createElement('div', { className: 'cb-section-title' }, t('stats.title')),
2522
3500
  React.createElement(
2523
3501
  'div',
2524
- { style: { display: 'flex', gap: '6px', alignItems: 'center' } },
3502
+ { className: 'cb-section-actions' },
2525
3503
  React.createElement(
2526
3504
  'a',
2527
3505
  {
2528
3506
  href: '/dsh-clinebot/stats/export',
2529
3507
  download: 'clinebot-stats.csv',
2530
- className: 'cb-btn',
2531
- style: { padding: '2px 8px', fontSize: '11px', textDecoration: 'none', display: 'inline-flex', alignItems: 'center' },
3508
+ className: 'cb-btn cb-btn-link',
2532
3509
  },
2533
- `📥 ${t('stats.export_csv')}`
3510
+ React.createElement(CbIcon, { name: 'download', size: 13 }),
3511
+ ` ${t('stats.export_csv')}`
2534
3512
  ),
2535
3513
  confirmReset
2536
3514
  ? React.createElement(
@@ -2541,7 +3519,6 @@ function StatsSection({ status, onResetStats, busy, t }) {
2541
3519
  {
2542
3520
  type: 'button',
2543
3521
  className: 'cb-btn cb-btn-warn',
2544
- style: { padding: '2px 8px', fontSize: '11px', marginRight: '6px' },
2545
3522
  disabled: !!busy,
2546
3523
  onClick: () => {
2547
3524
  setConfirmReset(false)
@@ -2552,12 +3529,7 @@ function StatsSection({ status, onResetStats, busy, t }) {
2552
3529
  ),
2553
3530
  React.createElement(
2554
3531
  'button',
2555
- {
2556
- type: 'button',
2557
- className: 'cb-btn',
2558
- style: { padding: '2px 8px', fontSize: '11px' },
2559
- onClick: () => setConfirmReset(false),
2560
- },
3532
+ { type: 'button', className: 'cb-btn', onClick: () => setConfirmReset(false) },
2561
3533
  t('stats.cancel_reset')
2562
3534
  )
2563
3535
  )
@@ -2566,7 +3538,6 @@ function StatsSection({ status, onResetStats, busy, t }) {
2566
3538
  {
2567
3539
  type: 'button',
2568
3540
  className: 'cb-btn',
2569
- style: { padding: '2px 8px', fontSize: '11px' },
2570
3541
  disabled: !!busy,
2571
3542
  onClick: () => setConfirmReset(true),
2572
3543
  },
@@ -2577,7 +3548,7 @@ function StatsSection({ status, onResetStats, busy, t }) {
2577
3548
  React.createElement('div', { className: 'cb-section-desc' }, t('stats.desc')),
2578
3549
  React.createElement(
2579
3550
  'div',
2580
- { className: 'cb-grid-2', style: { marginTop: '12px' } },
3551
+ { className: 'cb-stats-grid' },
2581
3552
  React.createElement(
2582
3553
  'div',
2583
3554
  { className: 'cb-stat-box' },
@@ -2587,16 +3558,12 @@ function StatsSection({ status, onResetStats, busy, t }) {
2587
3558
  React.createElement(
2588
3559
  'div',
2589
3560
  { className: 'cb-stat-box' },
2590
- React.createElement(
2591
- 'div',
2592
- { className: 'cb-stat-val' },
2593
- `${Number(totalTokens).toLocaleString()}`
2594
- ),
3561
+ React.createElement('div', { className: 'cb-stat-val' }, Number(totalTokens).toLocaleString()),
2595
3562
  React.createElement(
2596
3563
  'div',
2597
3564
  { className: 'cb-stat-lbl' },
2598
- totals.reasoningTokens || sess.reasoningTokensEst
2599
- ? `${t('stats.tokens')} (${Number(totals.reasoningTokens || sess.reasoningTokensEst).toLocaleString()} reasoning)`
3565
+ reasoning > 0
3566
+ ? `${t('stats.tokens')} (${reasoning.toLocaleString()} reasoning)`
2600
3567
  : t('stats.tokens')
2601
3568
  )
2602
3569
  ),
@@ -2616,30 +3583,18 @@ function StatsSection({ status, onResetStats, busy, t }) {
2616
3583
  topModels.length > 0
2617
3584
  ? React.createElement(
2618
3585
  'div',
2619
- { style: { marginTop: '14px' } },
2620
- React.createElement('div', { style: { fontSize: '12px', fontWeight: 500, marginBottom: '6px', color: 'var(--dsw-alias-label-secondary)' } }, t('stats.top_models')),
2621
- React.createElement(
3586
+ { className: 'cb-model-list' },
3587
+ React.createElement('div', { className: 'cb-model-list-title' }, t('stats.top_models')),
3588
+ topModels.map(([modelId, data]) => React.createElement(
2622
3589
  'div',
2623
- { style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
2624
- topModels.map(([modId, data]) =>
2625
- React.createElement(
2626
- 'div',
2627
- {
2628
- key: modId,
2629
- style: {
2630
- display: 'flex',
2631
- justifyContent: 'space-between',
2632
- fontSize: '11px',
2633
- padding: '4px 8px',
2634
- background: 'var(--dsw-alias-bg-layer-2)',
2635
- borderRadius: '4px',
2636
- },
2637
- },
2638
- React.createElement('span', { style: { fontFamily: 'monospace' } }, modId),
2639
- React.createElement('span', null, `${data.requests || 0} reqs · ${Number(data.totalTokens ?? data.tokens ?? 0).toLocaleString()} tok${data.reasoningTokens ? ` (${Number(data.reasoningTokens).toLocaleString()} thk)` : ''}`)
2640
- )
3590
+ { className: 'cb-model-row', key: modelId },
3591
+ React.createElement('span', { className: 'cb-mono' }, modelId),
3592
+ React.createElement(
3593
+ 'span',
3594
+ { className: 'cb-muted' },
3595
+ `${data.requests || 0} reqs · ${Number(data.totalTokens ?? data.tokens ?? 0).toLocaleString()} tok${data.reasoningTokens ? ` (${Number(data.reasoningTokens).toLocaleString()} reasoning)` : ''}`
2641
3596
  )
2642
- )
3597
+ ))
2643
3598
  )
2644
3599
  : null
2645
3600
  )
@@ -2659,49 +3614,19 @@ function DiagSection({
2659
3614
  t,
2660
3615
  }) {
2661
3616
  return React.createElement(
2662
- 'div',
3617
+ 'section',
2663
3618
  { className: 'cb-section-card' },
2664
3619
  React.createElement('div', { className: 'cb-section-title' }, t('diag.title')),
2665
3620
  React.createElement('div', { className: 'cb-section-desc' }, t('diag.desc')),
2666
- React.createElement(
2667
- 'div',
2668
- {
2669
- style: {
2670
- display: 'flex',
2671
- alignItems: 'center',
2672
- justifyContent: 'space-between',
2673
- padding: '10px 14px',
2674
- background: 'var(--dsw-alias-bg-layer-2)',
2675
- borderRadius: '8px',
2676
- border: '1px solid var(--dsw-alias-border-l2)',
2677
- },
2678
- },
2679
- React.createElement(
2680
- 'div',
2681
- { style: { display: 'flex', flexDirection: 'column', gap: '3px' } },
2682
- React.createElement(
2683
- 'span',
2684
- { style: { fontSize: '13px', fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } },
2685
- t('diag.proxy_title')
2686
- ),
2687
- React.createElement(
2688
- 'span',
2689
- { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } },
2690
- t('diag.proxy_desc')
2691
- )
2692
- ),
2693
- React.createElement(
2694
- 'div',
2695
- { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '24px' } },
3621
+ React.createElement(SettingRow, {
3622
+ title: t('diag.proxy_title'),
3623
+ description: t('diag.proxy_desc'),
3624
+ control: React.createElement(
3625
+ React.Fragment,
3626
+ null,
2696
3627
  React.createElement(
2697
3628
  'span',
2698
- {
2699
- style: {
2700
- fontSize: '14px',
2701
- lineHeight: '20px',
2702
- color: proxyMode ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-label-secondary)',
2703
- },
2704
- },
3629
+ { className: proxyMode ? 'cb-state-ok' : 'cb-muted' },
2705
3630
  proxyMode ? t('diag.proxy_enabled') : t('diag.proxy_disabled')
2706
3631
  ),
2707
3632
  React.createElement(BooleanControl, {
@@ -2710,11 +3635,11 @@ function DiagSection({
2710
3635
  label: t('diag.proxy_title'),
2711
3636
  onChange: (next) => onToggleProxy && onToggleProxy(next),
2712
3637
  })
2713
- )
2714
- ),
3638
+ ),
3639
+ }),
2715
3640
  React.createElement(
2716
3641
  'div',
2717
- { className: 'cb-row' },
3642
+ { className: 'cb-row cb-row-actions' },
2718
3643
  React.createElement(
2719
3644
  'button',
2720
3645
  {
@@ -2737,12 +3662,12 @@ function DiagSection({
2737
3662
  busy === 'unregister' ? t('diag.unregistering') : t('diag.unregister_btn')
2738
3663
  )
2739
3664
  : null,
3665
+ React.createElement('span', { className: 'cb-settings-spacer' }),
2740
3666
  React.createElement(
2741
3667
  'button',
2742
3668
  {
2743
3669
  type: 'button',
2744
3670
  className: 'cb-btn cb-btn-primary',
2745
- style: { marginLeft: 'auto' },
2746
3671
  disabled: !!busy,
2747
3672
  onClick: handleRegister,
2748
3673
  },
@@ -2802,7 +3727,7 @@ function MiniRing({ percent, size = 18, warn = false }) {
2802
3727
  )
2803
3728
  }
2804
3729
 
2805
- function SidebarQuota({ ctx, wide, t: propT }) {
3730
+ function SidebarQuota({ ctx, wide, t: propT, open: openPanel }) {
2806
3731
  const t = propT || makeT(en, en)
2807
3732
  const [data, setData] = React.useState(null)
2808
3733
  const [open, setOpen] = React.useState(false)
@@ -2839,18 +3764,48 @@ function SidebarQuota({ ctx, wide, t: propT }) {
2839
3764
 
2840
3765
  if (!show) return null
2841
3766
 
2842
- const fiveHour = data?.windows?.fiveHour
2843
- const weekly = data?.windows?.weekly
2844
- const activeWindow = fiveHour || weekly
2845
- const pct = Math.round(activeWindow?.percentUsed || 0)
2846
- const isWarn = pct >= 85
2847
- const planName = data?.plan || t('title')
3767
+ const windowEntries = [
3768
+ { key: 'fiveHour', label: t('quota.window_5h'), value: data?.windows?.fiveHour },
3769
+ { key: 'weekly', label: t('quota.window_weekly'), value: data?.windows?.weekly },
3770
+ { key: 'monthly', label: t('quota.window_monthly'), value: data?.windows?.monthly },
3771
+ ]
3772
+
3773
+ const windowPct = (window) => {
3774
+ const value = Number(window.value?.percentUsed)
3775
+ return Number.isFinite(value) ? Math.max(0, Math.min(100, Math.round(value))) : null
3776
+ }
3777
+
3778
+ const measured = windowEntries
3779
+ .map((window) => ({ ...window, pct: windowPct(window) }))
3780
+ .filter((window) => window.pct !== null)
3781
+
3782
+ const pct = measured.length ? Math.max(...measured.map((window) => window.pct)) : 0
3783
+ const isWarn = measured.some((window) => window.pct >= 85)
3784
+ const planName = String(data?.plan || t('title')).replace(/\s*\(\$[^)]*\)\s*$/, '')
3785
+ const accountLabel = data?.account ? ` · ${data.account}` : ''
3786
+ const tooltip = `${planName}${accountLabel} · ${windowEntries.map((window) => {
3787
+ const value = windowPct(window)
3788
+ return `${window.label} ${value === null ? '—' : `${value}% ${t('dashboard.used')}`}`
3789
+ }).join(' · ')}`
2848
3790
 
2849
3791
  // The window is local to the meter: nothing is selected or navigated, so the
2850
3792
  // conversation stays where it is and closing simply drops the window.
2851
- const handleOpen = () => setOpen(true)
3793
+ const handleOpen = () => {
3794
+ if (typeof openPanel === 'function') {
3795
+ try {
3796
+ if (openPanel() !== false) return
3797
+ } catch {
3798
+ /* fall through to the local window */
3799
+ }
3800
+ }
3801
+ setOpen(true)
3802
+ }
3803
+
3804
+ // Keep all three rows visible even when the endpoint omits a window for the
3805
+ // current plan; the missing row shows an explicit dash instead of silently
3806
+ // relabelling another window.
3807
+ const rows = windowEntries
2852
3808
 
2853
- const tooltip = `${t('sidebar.quota_title')}: ${pct}% ${t('dashboard.used')} (${planName})`
2854
3809
  const dialog = React.createElement(DashboardDialog, { t, open, onClose: () => setOpen(false) })
2855
3810
 
2856
3811
  if (!wide) {
@@ -2891,68 +3846,403 @@ function SidebarQuota({ ctx, wide, t: propT }) {
2891
3846
  { className: 'cb-footTop' },
2892
3847
  React.createElement(MiniRing, { percent: pct, warn: isWarn, size: 16 }),
2893
3848
  React.createElement('span', { className: 'cb-footName' }, planName),
2894
- React.createElement('span', { className: 'cb-footPct', style: { marginLeft: 'auto', fontWeight: 600, color: isWarn ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-label-secondary)' } }, `${pct}%`)
3849
+ React.createElement('span', { className: isWarn ? 'cb-footPct cb-footPctWarn' : 'cb-footPct' }, `${pct}%`)
2895
3850
  ),
3851
+ rows.map((window) => {
3852
+ const value = windowPct(window)
3853
+ const hasData = value !== null
3854
+ const warn = hasData && value >= 85
3855
+ const reset = hasData && typeof formatResetCountdown === 'function'
3856
+ ? formatResetCountdown(window.value?.resetsAt)
3857
+ : ''
3858
+ const resetText = reset
3859
+ ? ` · ${reset === 'resetting' ? t('quota.resetting') : reset}`
3860
+ : ''
3861
+ return React.createElement(
3862
+ 'div',
3863
+ { className: hasData ? 'cb-footRow' : 'cb-footRow cb-footRowMissing', key: window.key },
3864
+ React.createElement(
3865
+ 'div',
3866
+ { className: 'cb-footHead' },
3867
+ React.createElement('span', { className: 'cb-footLabel' }, window.label),
3868
+ React.createElement(
3869
+ 'span',
3870
+ { className: hasData ? 'cb-footAmount' : 'cb-footAmount cb-footAmountMissing' },
3871
+ hasData ? `${100 - value}% ${t('dashboard.remaining')}${resetText}` : '—'
3872
+ )
3873
+ ),
3874
+ React.createElement(
3875
+ 'span',
3876
+ { className: 'cb-footBar' },
3877
+ React.createElement('span', {
3878
+ className: warn ? 'cb-footFill cb-footFillWarn' : 'cb-footFill',
3879
+ style: { width: hasData ? `${Math.min(100, Math.max(2, value))}%` : '0%' },
3880
+ })
3881
+ )
3882
+ )
3883
+ }),
3884
+ rows.some((window) => windowPct(window) !== null)
3885
+ ? null
3886
+ : React.createElement('span', { className: 'cb-footMessage' }, data?.error || t('quota.no_windows'))
3887
+ ),
3888
+ dialog
3889
+ )
3890
+ }
3891
+
3892
+ // --- src/client/components/price-section.js ---
3893
+ /**
3894
+ * Per-model price overrides. A blank field falls back to the built-in local
3895
+ * estimate; the values are USD per 1M tokens.
3896
+ */
3897
+ function PriceSection({ modelsList = [], priceOverrides = {}, onConfigUpdate, t }) {
3898
+ const [expanded, setExpanded] = React.useState(false)
3899
+ const models = Array.isArray(modelsList) ? modelsList : []
3900
+
3901
+ const update = (modelId, field, raw) => {
3902
+ const next = { ...(priceOverrides || {}) }
3903
+ const current = { ...(next[modelId] || {}) }
3904
+ const value = String(raw ?? '').trim()
3905
+ const numeric = Number(value)
3906
+ if (value === '' || !Number.isFinite(numeric) || numeric < 0) delete current[field]
3907
+ else current[field] = numeric
3908
+ if (Object.keys(current).length === 0) delete next[modelId]
3909
+ else next[modelId] = current
3910
+ onConfigUpdate({ priceOverrides: next })
3911
+ }
3912
+
3913
+ return React.createElement(
3914
+ 'div',
3915
+ { className: 'cb-price-section' },
3916
+ React.createElement(
3917
+ 'div',
3918
+ { className: 'cb-price-head' },
2896
3919
  React.createElement(
2897
3920
  'div',
2898
- { className: 'cb-footRow' },
2899
- React.createElement(
3921
+ { className: 'cb-price-copy' },
3922
+ React.createElement('div', { className: 'cb-section-title' }, t('prices.title')),
3923
+ React.createElement('div', { className: 'cb-section-desc' }, t('prices.desc'))
3924
+ ),
3925
+ React.createElement(
3926
+ 'button',
3927
+ { type: 'button', className: 'cb-btn', onClick: () => setExpanded((value) => !value) },
3928
+ expanded ? t('prices.hide') : t('prices.show')
3929
+ )
3930
+ ),
3931
+ expanded
3932
+ ? React.createElement(
2900
3933
  'div',
2901
- { className: 'cb-footHead' },
2902
- React.createElement('span', { className: 'cb-footLabel' }, t('dashboard.rolling_window')),
2903
- React.createElement('span', { className: 'cb-footAmount' }, `${100 - pct}% ${t('dashboard.remaining')}`)
2904
- ),
2905
- React.createElement(
3934
+ { className: 'cb-price-table' },
3935
+ React.createElement(
3936
+ 'div',
3937
+ { className: 'cb-price-head-row' },
3938
+ React.createElement('span', null, t('prices.model')),
3939
+ React.createElement('span', null, t('prices.input')),
3940
+ React.createElement('span', null, t('prices.output')),
3941
+ React.createElement('span', null, t('prices.cache_read')),
3942
+ React.createElement('span', null, t('prices.cache_write'))
3943
+ ),
3944
+ models.length === 0
3945
+ ? React.createElement('div', { className: 'cb-hint' }, t('prices.empty'))
3946
+ : models.map((model) => {
3947
+ const override = priceOverrides?.[model.id] || {}
3948
+ return React.createElement(
3949
+ 'div',
3950
+ { className: 'cb-price-row', key: model.id },
3951
+ React.createElement('span', { className: 'cb-mono', title: model.id }, model.name || model.id),
3952
+ ['input', 'output', 'cacheRead', 'cacheWrite'].map((field) => React.createElement('input', {
3953
+ key: field,
3954
+ className: 'cb-input cb-price-input',
3955
+ type: 'number',
3956
+ min: '0',
3957
+ step: '0.001',
3958
+ value: override[field] === undefined ? '' : String(override[field]),
3959
+ placeholder: t('prices.auto'),
3960
+ onChange: (event) => update(model.id, field, event.target.value),
3961
+ }))
3962
+ )
3963
+ }),
3964
+ React.createElement('div', { className: 'cb-hint' }, t('prices.hint'))
3965
+ )
3966
+ : null
3967
+ )
3968
+ }
3969
+
3970
+ // --- src/client/components/analytics-heatmap.js ---
3971
+ /**
3972
+ * Weekday × hour request heatmap, matching the reference providers: seven rows
3973
+ * (Mon–Sun), 24 columns, a peak-hour line and a compact by-hour strip. The
3974
+ * model selector re-fetches the same range for one model.
3975
+ */
3976
+ function heatWeekdayLabels() {
3977
+ return Array.from({ length: 7 }, (_, index) => {
3978
+ const date = new Date(2024, 0, 1 + index)
3979
+ return date.toLocaleDateString(undefined, { weekday: 'short' })
3980
+ })
3981
+ }
3982
+
3983
+ function heatHourLabel(hour) {
3984
+ return hour % 3 === 0 ? String(hour).padStart(2, '0') : ''
3985
+ }
3986
+
3987
+ function HeatmapView({ heatmap, model, onModelChange, t }) {
3988
+ if (!heatmap || !Array.isArray(heatmap.grid)) {
3989
+ return React.createElement('div', { className: 'cb-hint' }, t('analytics.heatmap_empty'))
3990
+ }
3991
+
3992
+ const grid = heatmap.grid
3993
+ const hours = Array.isArray(heatmap.hours) ? heatmap.hours : Array(24).fill(0)
3994
+ const total = Number(heatmap.total) || 0
3995
+ const models = Array.isArray(heatmap.models) ? heatmap.models : []
3996
+ const max = Math.max(1, ...grid.flat())
3997
+ const maxHours = Math.max(1, ...hours)
3998
+ const peakHour = hours.reduce((best, value, hour) => (value > (hours[best] || 0) ? hour : best), 0)
3999
+ const peak = hours[peakHour] || 0
4000
+ const level = (value) => {
4001
+ const requests = Number(value) || 0
4002
+ if (requests <= 0) return 0
4003
+ return Math.min(4, Math.max(1, Math.ceil((requests / max) * 4)))
4004
+ }
4005
+ const weekdays = heatWeekdayLabels()
4006
+
4007
+ return React.createElement(
4008
+ 'div',
4009
+ { className: 'cb-heat-wrap' },
4010
+ React.createElement(
4011
+ 'div',
4012
+ { className: 'cb-heat-toolbar' },
4013
+ React.createElement('span', { className: 'cb-badge' }, `${total} ${t('analytics.requests')}`),
4014
+ models.length > 1
4015
+ ? React.createElement(
4016
+ 'select',
4017
+ {
4018
+ className: 'cb-heat-select',
4019
+ value: model || '',
4020
+ title: t('analytics.heatmap_model'),
4021
+ onChange: (event) => onModelChange && onModelChange(event.target.value),
4022
+ },
4023
+ React.createElement('option', { value: '' }, `${t('analytics.heatmap_all_models')} (${total})`),
4024
+ models.map((entry) => React.createElement(
4025
+ 'option',
4026
+ { key: entry.model, value: entry.model },
4027
+ `${entry.model} (${entry.total})`
4028
+ ))
4029
+ )
4030
+ : null,
4031
+ peak > 0
4032
+ ? React.createElement('span', { className: 'cb-hint' }, `${t('analytics.heatmap_peak')}: ${String(peakHour).padStart(2, '0')}:00 · ${peak}`)
4033
+ : null
4034
+ ),
4035
+ React.createElement(
4036
+ 'div',
4037
+ { className: 'cb-heat-scroll' },
4038
+ React.createElement(
4039
+ 'div',
4040
+ { className: 'cb-heat' },
4041
+ React.createElement('span', { className: 'cb-heat-corner' }),
4042
+ hours.map((_, hour) => React.createElement(
4043
+ 'span',
4044
+ { className: 'cb-heat-hour', key: `hour-${hour}` },
4045
+ heatHourLabel(hour)
4046
+ )),
4047
+ weekdays.map((label, day) => React.createElement(
4048
+ React.Fragment,
4049
+ { key: `day-${day}` },
4050
+ React.createElement('span', { className: 'cb-heat-day' }, label),
4051
+ (grid[day] || []).map((value, hour) => React.createElement('span', {
4052
+ key: `cell-${day}-${hour}`,
4053
+ className: `cb-heat-cell cb-heat-l${level(value)}`,
4054
+ title: `${label} ${String(hour).padStart(2, '0')}:00 — ${value} ${t('analytics.requests')}`,
4055
+ }))
4056
+ ))
4057
+ ),
4058
+ React.createElement('p', { className: 'cb-hour-strip-head' }, t('analytics.heatmap_by_hour')),
4059
+ React.createElement(
4060
+ 'div',
4061
+ { className: 'cb-hour-strip' },
4062
+ React.createElement('span'),
4063
+ hours.map((value, hour) => React.createElement(
2906
4064
  'span',
2907
- { className: 'cb-footBar' },
4065
+ { className: 'cb-hour-col', key: `strip-${hour}`, title: `${String(hour).padStart(2, '0')}:00 — ${value}` },
2908
4066
  React.createElement('span', {
2909
- className: isWarn ? 'cb-footFill cb-footFillWarn' : 'cb-footFill',
2910
- style: { width: `${Math.min(100, Math.max(2, pct))}%` },
4067
+ className: `cb-hour-bar cb-heat-l${level(value)}`,
4068
+ style: { height: `${Math.max(2, Math.round(28 * (Number(value) || 0) / maxHours))}px` },
2911
4069
  })
4070
+ ))
4071
+ ),
4072
+ React.createElement(
4073
+ 'div',
4074
+ { className: 'cb-hour-labels' },
4075
+ React.createElement('span'),
4076
+ hours.map((_, hour) => React.createElement('span', { key: `label-${hour}` }, heatHourLabel(hour)))
4077
+ )
4078
+ ),
4079
+ React.createElement(
4080
+ 'div',
4081
+ { className: 'cb-heat-legend' },
4082
+ React.createElement('span', null, t('analytics.heatmap_less')),
4083
+ [0, 1, 2, 3, 4].map((bucket) => React.createElement('span', {
4084
+ key: bucket,
4085
+ className: `cb-heat-swatch cb-heat-l${bucket}`,
4086
+ })),
4087
+ React.createElement('span', null, t('analytics.heatmap_more'))
4088
+ ),
4089
+ total === 0
4090
+ ? React.createElement('div', { className: 'cb-hint' }, t('analytics.heatmap_empty'))
4091
+ : null
4092
+ )
4093
+ }
4094
+
4095
+ // --- src/client/components/quota-panel-view.js ---
4096
+ /**
4097
+ * The account/quota cards shared by the center-column panel and the modal
4098
+ * fallback. Account tabs and the selected account's report are passed in by
4099
+ * DashboardDialog; this component only renders the selected snapshot.
4100
+ */
4101
+ function QuotaPanelView({
4102
+ accountTabs,
4103
+ selectedUsage,
4104
+ accountName,
4105
+ accountEmail,
4106
+ accountPlan,
4107
+ accountBadges = [],
4108
+ activeAcc,
4109
+ routingMode,
4110
+ enabledCount,
4111
+ t,
4112
+ }) {
4113
+ return React.createElement(
4114
+ React.Fragment,
4115
+ null,
4116
+ accountTabs,
4117
+ selectedUsage?.ok === false
4118
+ ? React.createElement('div', { className: 'cb-alert-bad' }, selectedUsage.error || t('analytics.no_data'))
4119
+ : null,
4120
+ React.createElement(
4121
+ 'div',
4122
+ { className: 'cb-dialogCard' },
4123
+ React.createElement(
4124
+ 'div',
4125
+ { className: 'cb-dialogRow' },
4126
+ React.createElement(
4127
+ 'div',
4128
+ { className: 'cb-dialogIdentity' },
4129
+ React.createElement('div', { className: 'cb-row-title' }, accountName),
4130
+ React.createElement('div', { className: 'cb-row-desc' }, [accountEmail, accountPlan].filter(Boolean).join(' · '))
4131
+ ),
4132
+ React.createElement(
4133
+ 'div',
4134
+ { className: 'cb-dialogBadges' },
4135
+ accountBadges.map((badge, index) => React.createElement('span', { className: badge.className, key: `account-badge-${index}` }, badge.label)),
4136
+ React.createElement('span', { className: 'cb-badge cb-badge-ok' }, t('badge.registered', { count: enabledCount }))
4137
+ )
4138
+ ),
4139
+ React.createElement(
4140
+ 'div',
4141
+ { className: 'cb-dialogTiles' },
4142
+ React.createElement(
4143
+ 'div',
4144
+ { className: 'cb-stat-box' },
4145
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('dashboard.account_pool')),
4146
+ React.createElement('div', { className: 'cb-stat-val' }, activeAcc)
4147
+ ),
4148
+ React.createElement(
4149
+ 'div',
4150
+ { className: 'cb-stat-box' },
4151
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('accounts.mode_label')),
4152
+ React.createElement('div', { className: 'cb-stat-val' }, routingMode)
2912
4153
  )
2913
4154
  )
2914
4155
  ),
2915
- dialog
4156
+ React.createElement(
4157
+ 'div',
4158
+ { className: 'cb-dialogCard' },
4159
+ React.createElement('div', { className: 'cb-section-title' }, t('quota.title')),
4160
+ React.createElement(
4161
+ 'div',
4162
+ { className: 'cb-dialogWindows' },
4163
+ selectedUsage?.windows?.fiveHour
4164
+ ? React.createElement(ProgressBar, {
4165
+ label: t('quota.window_5h'),
4166
+ percentUsed: selectedUsage.windows.fiveHour.percentUsed || 0,
4167
+ remainingPercent: selectedUsage.windows.fiveHour.remainingPercent || 100,
4168
+ resetsAt: selectedUsage.windows.fiveHour.resetsAt,
4169
+ t,
4170
+ })
4171
+ : null,
4172
+ selectedUsage?.windows?.weekly
4173
+ ? React.createElement(ProgressBar, {
4174
+ label: t('quota.window_weekly'),
4175
+ percentUsed: selectedUsage.windows.weekly.percentUsed || 0,
4176
+ remainingPercent: selectedUsage.windows.weekly.remainingPercent || 100,
4177
+ resetsAt: selectedUsage.windows.weekly.resetsAt,
4178
+ t,
4179
+ })
4180
+ : null,
4181
+ selectedUsage?.windows?.monthly
4182
+ ? React.createElement(ProgressBar, {
4183
+ label: t('quota.window_monthly'),
4184
+ percentUsed: selectedUsage.windows.monthly.percentUsed || 0,
4185
+ remainingPercent: selectedUsage.windows.monthly.remainingPercent || 100,
4186
+ resetsAt: selectedUsage.windows.monthly.resetsAt,
4187
+ t,
4188
+ })
4189
+ : null,
4190
+ !selectedUsage?.windows || (!selectedUsage.windows.fiveHour && !selectedUsage.windows.weekly && !selectedUsage.windows.monthly)
4191
+ ? React.createElement('div', { className: 'cb-hint' }, t('quota.no_windows'))
4192
+ : null
4193
+ )
4194
+ )
2916
4195
  )
2917
4196
  }
2918
4197
 
2919
4198
  // --- src/client/components/dashboard-dialog.js ---
2920
4199
  /**
2921
- * The quota & status window, opened from the sidebar meter.
2922
- *
2923
- * This used to be a `main`-slot panel: clicking the meter called
2924
- * `layout.selectPanel('clinebot-dashboard')`, which swapped the conversation out
2925
- * of the centre column for a full-bleed dashboard. A status read-out has no
2926
- * business replacing the conversation, so it is a window now — the shell's own
2927
- * `Modal`, which brings the backdrop, the close affordance, Escape and focus
2928
- * handling with it.
2929
- *
2930
- * `open` is owned by the caller (the meter), and the data is fetched on open
2931
- * rather than on mount, so a closed window costs nothing.
4200
+ * Quota and analytics surfaces. The sidebar card opens this as the layout's
4201
+ * center-column panel (the Command Code / OpenCode shape); when the layout
4202
+ * service is unavailable the same content falls back to the shell's Modal.
2932
4203
  */
2933
4204
  function DashboardDialog(props) {
2934
4205
  const t = props?.t || makeT(en, en)
2935
- const open = props?.open !== false
4206
+ const panel = props?.panel === true
4207
+ const open = panel || props?.open !== false
2936
4208
  const onClose = typeof props?.onClose === 'function' ? props.onClose : () => {}
2937
4209
 
2938
4210
  const [data, setData] = React.useState(null)
2939
4211
  const [status, setStatus] = React.useState(null)
4212
+ const [analytics, setAnalytics] = React.useState(null)
4213
+ const [tab, setTab] = React.useState('quota')
4214
+ const [groupBy, setGroupBy] = React.useState('model')
2940
4215
  const [loading, setLoading] = React.useState(false)
4216
+ const [analyticsLoading, setAnalyticsLoading] = React.useState(false)
2941
4217
  const [updatedAt, setUpdatedAt] = React.useState('')
2942
4218
  const [err, setErr] = React.useState('')
4219
+ const [accountsUsage, setAccountsUsage] = React.useState(null)
4220
+ const [selectedAccount, setSelectedAccount] = React.useState('')
4221
+ const [analyticsDays, setAnalyticsDays] = React.useState(7)
4222
+ const [heatmapModel, setHeatmapModel] = React.useState('')
2943
4223
 
2944
4224
  const loadData = React.useCallback(async () => {
2945
4225
  setLoading(true)
2946
4226
  setErr('')
2947
4227
  try {
2948
- const [uRes, sRes] = await Promise.all([
4228
+ const [uRes, sRes, aRes] = await Promise.all([
2949
4229
  fetchWithTimeout(`${ROUTE_PREFIX}/usage`, { cache: 'no-store' }),
2950
4230
  fetchWithTimeout(`${ROUTE_PREFIX}/status`, { cache: 'no-store' }),
4231
+ fetchWithTimeout(`${ROUTE_PREFIX}/accounts/usage`, { cache: 'no-store' }),
2951
4232
  ])
2952
4233
  const uJson = await uRes.json().catch(() => ({}))
2953
4234
  const sJson = await sRes.json().catch(() => ({}))
4235
+ const aJson = await aRes.json().catch(() => ({}))
2954
4236
  if (uJson?.ok) setData(uJson)
2955
4237
  if (sJson?.ok) setStatus(sJson)
4238
+ if (aJson?.ok) {
4239
+ setAccountsUsage(aJson)
4240
+ setSelectedAccount((current) => {
4241
+ const accounts = Array.isArray(aJson.accounts) ? aJson.accounts : []
4242
+ if (accounts.some((entry) => entry.apiKeyEnv === current)) return current
4243
+ return aJson.activeAccount || accounts[0]?.apiKeyEnv || ''
4244
+ })
4245
+ }
2956
4246
  setUpdatedAt(new Date().toLocaleTimeString())
2957
4247
  } catch (e) {
2958
4248
  setErr(String(e?.message || e))
@@ -2961,105 +4251,375 @@ function DashboardDialog(props) {
2961
4251
  }
2962
4252
  }, [])
2963
4253
 
4254
+ const loadAnalytics = React.useCallback(async (group = groupBy, days = analyticsDays, model = heatmapModel) => {
4255
+ setAnalyticsLoading(true)
4256
+ setErr('')
4257
+ try {
4258
+ const from = Date.now() - days * 86_400_000
4259
+ const query = [
4260
+ `groupBy=${encodeURIComponent(group)}`,
4261
+ 'limit=20',
4262
+ `from=${from}`,
4263
+ `heatmapDays=${days}`,
4264
+ ...(model ? [`heatmapModel=${encodeURIComponent(model)}`] : []),
4265
+ ].join('&')
4266
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/analytics?${query}`, { cache: 'no-store' })
4267
+ const json = await res.json().catch(() => ({}))
4268
+ if (!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`)
4269
+ setAnalytics(json)
4270
+ } catch (e) {
4271
+ setErr(String(e?.message || e))
4272
+ } finally {
4273
+ setAnalyticsLoading(false)
4274
+ }
4275
+ }, [groupBy, analyticsDays, heatmapModel])
4276
+
2964
4277
  React.useEffect(() => {
2965
4278
  if (open) loadData()
2966
4279
  }, [open, loadData])
2967
4280
 
4281
+ React.useEffect(() => {
4282
+ if (open && (panel || tab === 'analytics')) loadAnalytics(groupBy, analyticsDays, heatmapModel)
4283
+ }, [open, panel, tab, groupBy, analyticsDays, heatmapModel, loadAnalytics])
4284
+
4285
+ React.useEffect(() => {
4286
+ if (!open) return undefined
4287
+ let timer = null
4288
+ const tick = () => {
4289
+ loadData()
4290
+ if (panel || tab === 'analytics') loadAnalytics(groupBy, analyticsDays, heatmapModel)
4291
+ timer = setTimeout(tick, 120000)
4292
+ }
4293
+ timer = setTimeout(tick, 120000)
4294
+ return () => { if (timer) clearTimeout(timer) }
4295
+ }, [open, panel, tab, groupBy, analyticsDays, heatmapModel, loadData, loadAnalytics])
4296
+
2968
4297
  if (!open) return null
2969
4298
 
4299
+ function fmtTokens(value) {
4300
+ const n = Number(value) || 0
4301
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
4302
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
4303
+ return String(n)
4304
+ }
4305
+
4306
+ function fmtCost(value) {
4307
+ const n = Number(value) || 0
4308
+ if (n === 0) return '$0'
4309
+ if (n < 0.0001) return '< $0.0001'
4310
+ return `$${n.toFixed(4)}`
4311
+ }
4312
+
4313
+ function fmtMs(value) {
4314
+ const n = Number(value) || 0
4315
+ return n > 0 ? `${Math.round(n)} ms` : '—'
4316
+ }
4317
+
2970
4318
  const plan = data?.plan || status?.config?.defaultModel || 'ClinePass'
2971
4319
  const email = data?.user?.email || ''
2972
- const activeAcc = status?.config?.activeAccount || 'Auto (Least-used)'
4320
+ const activeAcc = status?.config?.activeAccount || t('dashboard.auto_account')
2973
4321
  const enabledCount = status?.config?.enabledModels?.length || 0
2974
4322
  const routingMode = status?.config?.accountMode === 'round_robin' ? t('accounts.mode_round_robin') : t('accounts.mode_least_used')
4323
+ const accountEntries = Array.isArray(accountsUsage?.accounts) ? accountsUsage.accounts : []
4324
+ const selectedEntry = accountEntries.find((entry) => entry.apiKeyEnv === selectedAccount)
4325
+ || accountEntries.find((entry) => entry.active)
4326
+ || accountEntries[0]
4327
+ const selectedUsage = selectedEntry?.usage || data
4328
+ const accountPlan = selectedUsage?.plan || plan
4329
+ const accountEmail = selectedUsage?.user?.email || email
4330
+ const accountName = selectedEntry?.label || selectedEntry?.apiKeyEnv || accountPlan
4331
+ const accountBadges = []
4332
+ if (selectedEntry) {
4333
+ accountBadges.push(selectedEntry.configured === false
4334
+ ? { className: 'cb-badge cb-badge-warn', label: t('accounts.missing_key') }
4335
+ : { className: 'cb-badge cb-badge-ok', label: t('accounts.configured') })
4336
+ if (selectedEntry.active) accountBadges.push({ className: 'cb-badge cb-badge-ok', label: t('accounts.active_badge') })
4337
+ if (selectedEntry.isPinned) accountBadges.push({ className: 'cb-badge', label: t('accounts.pinned_badge') })
4338
+ }
4339
+ const accountTabs = accountEntries.length > 1
4340
+ ? React.createElement(
4341
+ 'div',
4342
+ { className: 'cb-account-tabs' },
4343
+ accountEntries.map((entry) => React.createElement(
4344
+ 'button',
4345
+ {
4346
+ type: 'button',
4347
+ key: entry.apiKeyEnv || entry.id,
4348
+ className: entry.apiKeyEnv === selectedEntry?.apiKeyEnv ? 'cb-account-tab cb-account-tab-active' : 'cb-account-tab',
4349
+ onClick: () => setSelectedAccount(entry.apiKeyEnv),
4350
+ },
4351
+ entry.label || entry.apiKeyEnv || entry.id
4352
+ ))
4353
+ )
4354
+ : null
4355
+
4356
+ const noticeItems = []
4357
+ if (status?.key && status.key.present === false) {
4358
+ noticeItems.push(React.createElement(
4359
+ 'div',
4360
+ { className: 'cb-notice', key: 'no-key' },
4361
+ React.createElement('div', { className: 'cb-notice-title' }, t('analytics.no_key')),
4362
+ React.createElement('div', { className: 'cb-notice-hint' }, t('analytics.no_key_hint'))
4363
+ ))
4364
+ }
4365
+ if (status?.health && status.health.ok === false) {
4366
+ noticeItems.push(React.createElement(
4367
+ 'div',
4368
+ { className: 'cb-notice cb-notice-error', key: 'health' },
4369
+ React.createElement('div', { className: 'cb-notice-title' }, t('analytics.health_error')),
4370
+ React.createElement('div', { className: 'cb-notice-hint' }, status.health.error || '')
4371
+ ))
4372
+ }
4373
+ if (status && status.isRegistered === false) {
4374
+ noticeItems.push(React.createElement(
4375
+ 'div',
4376
+ { className: 'cb-notice', key: 'not-registered' },
4377
+ React.createElement('div', { className: 'cb-notice-title' }, t('analytics.not_registered')),
4378
+ React.createElement('div', { className: 'cb-notice-hint' }, t('analytics.not_registered_hint'))
4379
+ ))
4380
+ }
4381
+ const notices = noticeItems.length
4382
+ ? React.createElement(React.Fragment, null, ...noticeItems)
4383
+ : null
4384
+ const errorNotice = err
4385
+ ? React.createElement('div', { className: data ? 'cb-hint' : 'cb-alert-bad' }, data ? `${t('analytics.stale')} ${err}` : err)
4386
+ : null
4387
+ const totals = analytics?.totals
4388
+ const secondaryTiles = totals
4389
+ ? [
4390
+ [t('analytics.input'), fmtTokens(totals.promptTokens)],
4391
+ [t('analytics.output'), fmtTokens(totals.completionTokens)],
4392
+ [t('analytics.reasoning'), fmtTokens(totals.reasoningTokens)],
4393
+ [t('analytics.cache_read'), fmtTokens(totals.cacheReadTokens)],
4394
+ [t('analytics.cache_write'), fmtTokens(totals.cacheWriteTokens)],
4395
+ [t('analytics.avg_ttft'), fmtMs(totals.avgTtftMs)],
4396
+ [t('analytics.avg_latency'), fmtMs(totals.avgLatencyMs)],
4397
+ [t('analytics.errors'), String(Math.round(Number(totals.failed) || 0))],
4398
+ ]
4399
+ : []
4400
+
4401
+ const tabs = React.createElement(
4402
+ 'div',
4403
+ { className: 'cb-tabs' },
4404
+ React.createElement(
4405
+ 'button',
4406
+ { type: 'button', className: tab === 'quota' ? 'cb-tab cb-tab-active' : 'cb-tab', onClick: () => setTab('quota') },
4407
+ t('dashboard.tab_quota')
4408
+ ),
4409
+ React.createElement(
4410
+ 'button',
4411
+ { type: 'button', className: tab === 'analytics' ? 'cb-tab cb-tab-active' : 'cb-tab', onClick: () => setTab('analytics') },
4412
+ t('dashboard.tab_analytics')
4413
+ )
4414
+ )
4415
+
4416
+ const quotaBody = React.createElement(QuotaPanelView, {
4417
+ accountTabs,
4418
+ selectedUsage,
4419
+ accountName,
4420
+ accountEmail,
4421
+ accountPlan,
4422
+ accountBadges,
4423
+ activeAcc,
4424
+ routingMode,
4425
+ enabledCount,
4426
+ t,
4427
+ })
4428
+
4429
+ const analyticsBody = React.createElement(
4430
+ 'div',
4431
+ { className: 'cb-dialogCard' },
4432
+ React.createElement(
4433
+ 'div',
4434
+ { className: 'cb-dialogRow' },
4435
+ React.createElement('div', { className: 'cb-section-title' }, t('analytics.title')),
4436
+ React.createElement(
4437
+ 'div',
4438
+ { className: 'cb-analytics-controls' },
4439
+ React.createElement(
4440
+ 'select',
4441
+ {
4442
+ className: 'cb-heat-select',
4443
+ value: String(analyticsDays),
4444
+ title: t('analytics.range'),
4445
+ onChange: (event) => setAnalyticsDays(Number(event.target.value)),
4446
+ },
4447
+ React.createElement('option', { value: '7' }, t('analytics.range_7')),
4448
+ React.createElement('option', { value: '30' }, t('analytics.range_30')),
4449
+ React.createElement('option', { value: '90' }, t('analytics.range_90'))
4450
+ ),
4451
+ React.createElement(
4452
+ 'div',
4453
+ { className: 'cb-seg' },
4454
+ ['model', 'day', 'session'].map((group) => React.createElement(
4455
+ 'button',
4456
+ {
4457
+ key: group,
4458
+ type: 'button',
4459
+ className: groupBy === group ? 'cb-seg-item cb-seg-item-active' : 'cb-seg-item',
4460
+ onClick: () => setGroupBy(group),
4461
+ },
4462
+ t(`analytics.group_${group}`)
4463
+ ))
4464
+ )
4465
+ )
4466
+ ),
4467
+ analytics?.totals
4468
+ ? React.createElement(
4469
+ 'div',
4470
+ { className: 'cb-dialogTiles' },
4471
+ React.createElement(
4472
+ 'div',
4473
+ { className: 'cb-stat-box' },
4474
+ React.createElement('div', { className: 'cb-stat-val' }, String(analytics.totals.requests || 0)),
4475
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('analytics.requests'))
4476
+ ),
4477
+ React.createElement(
4478
+ 'div',
4479
+ { className: 'cb-stat-box' },
4480
+ React.createElement('div', { className: 'cb-stat-val' }, fmtTokens(analytics.totals.totalTokens)),
4481
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('analytics.tokens'))
4482
+ ),
4483
+ React.createElement(
4484
+ 'div',
4485
+ { className: 'cb-stat-box' },
4486
+ React.createElement('div', { className: 'cb-stat-val' }, `≈ ${fmtCost(analytics.totals.costUsd)}`),
4487
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('analytics.cost'))
4488
+ ),
4489
+ React.createElement(
4490
+ 'div',
4491
+ { className: 'cb-stat-box' },
4492
+ React.createElement('div', { className: 'cb-stat-val' }, `${Math.round(analytics.totals.successRate || 0)}%`),
4493
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('analytics.success'))
4494
+ )
4495
+ )
4496
+ : null,
4497
+ secondaryTiles.length
4498
+ ? React.createElement(
4499
+ 'div',
4500
+ { className: 'cb-dialogTiles' },
4501
+ secondaryTiles.map(([label, value]) => React.createElement(
4502
+ 'div',
4503
+ { className: 'cb-stat-box', key: label },
4504
+ React.createElement('div', { className: 'cb-stat-val' }, value),
4505
+ React.createElement('div', { className: 'cb-stat-lbl' }, label)
4506
+ ))
4507
+ )
4508
+ : null,
4509
+ analyticsLoading
4510
+ ? React.createElement('div', { className: 'cb-hint' }, t('analytics.loading'))
4511
+ : analytics?.groups?.length
4512
+ ? React.createElement(
4513
+ 'div',
4514
+ { className: 'cb-analytics-list' },
4515
+ React.createElement(
4516
+ 'div',
4517
+ { className: 'cb-analytics-head' },
4518
+ React.createElement('span', null, analytics.groupBy || groupBy),
4519
+ React.createElement('span', null, t('analytics.requests')),
4520
+ React.createElement('span', null, t('analytics.tokens')),
4521
+ React.createElement('span', null, t('analytics.cost')),
4522
+ React.createElement('span', null, t('analytics.ttft'))
4523
+ ),
4524
+ analytics.groups.map((group) => React.createElement(
4525
+ 'div',
4526
+ { className: 'cb-analytics-row', key: group.key },
4527
+ React.createElement('span', { className: 'cb-mono' }, group.label || group.key),
4528
+ React.createElement('span', null, String(group.requests || 0)),
4529
+ React.createElement('span', null, fmtTokens(group.totalTokens)),
4530
+ React.createElement('span', null, `≈ ${fmtCost(group.costUsd)}`),
4531
+ React.createElement('span', null, group.avgTtftMs ? `${Math.round(group.avgTtftMs)} ms` : '—')
4532
+ ))
4533
+ )
4534
+ : React.createElement('div', { className: 'cb-empty' }, t('analytics.no_data')),
4535
+ analytics?.unpriced > 0
4536
+ ? React.createElement('div', { className: 'cb-hint' }, t('analytics.unpriced', { count: analytics.unpriced }))
4537
+ : null
4538
+ )
2975
4539
 
2976
- const body = React.createElement(
4540
+ const heatmapCard = React.createElement(
2977
4541
  'div',
2978
- { className: 'cb-dialog' },
2979
- React.createElement(
2980
- 'div',
2981
- { className: 'cb-dialogMeta' },
2982
- updatedAt ? React.createElement('span', null, `${t('dashboard.updated_at')} ${updatedAt}`) : null,
2983
- React.createElement('span', { style: { flex: 1 } }),
2984
- React.createElement(
2985
- 'button',
2986
- { type: 'button', className: 'cb-btn', disabled: loading, onClick: loadData },
2987
- loading ? t('dashboard.refreshing') : t('dashboard.refresh')
2988
- )
2989
- ),
2990
-
2991
- err ? React.createElement('div', { className: 'cb-alert-bad' }, err) : null,
4542
+ { className: 'cb-dialogCard' },
4543
+ React.createElement('div', { className: 'cb-section-title' }, t('analytics.heatmap')),
4544
+ React.createElement('div', { className: 'cb-section-desc' }, t('analytics.heatmap_hint')),
4545
+ React.createElement(HeatmapView, {
4546
+ heatmap: analytics?.heatmap,
4547
+ model: heatmapModel,
4548
+ onModelChange: setHeatmapModel,
4549
+ t,
4550
+ })
4551
+ )
2992
4552
 
2993
- // Account and plan
4553
+ const panelBody = React.createElement(
4554
+ 'div',
4555
+ { className: 'cb-panel' },
2994
4556
  React.createElement(
2995
4557
  'div',
2996
- { className: 'cb-dialogCard' },
4558
+ { className: 'cb-panel-head' },
2997
4559
  React.createElement(
2998
4560
  'div',
2999
- { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '12px', flexWrap: 'wrap' } },
3000
- React.createElement(
3001
- 'div',
3002
- { style: { display: 'flex', flexDirection: 'column', minWidth: 0 } },
3003
- React.createElement('div', { className: 'cb-row-title' }, plan),
3004
- email ? React.createElement('div', { className: 'cb-row-desc' }, email) : null
3005
- ),
3006
- React.createElement('span', { className: 'cb-badge cb-badge-ok' }, t('badge.registered', { count: enabledCount }))
4561
+ { className: 'cb-panel-identity' },
4562
+ React.createElement('div', { className: 'cb-panel-title' }, t('dashboard.title')),
4563
+ React.createElement('div', { className: 'cb-panel-sub' }, t('dashboard.subtitle'))
3007
4564
  ),
3008
4565
  React.createElement(
3009
4566
  'div',
3010
- { className: 'cb-grid-2', style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '10px' } },
4567
+ { className: 'cb-panel-actions' },
4568
+ updatedAt ? React.createElement('span', { className: 'cb-panel-updated' }, `${t('dashboard.updated_at')} ${updatedAt}`) : null,
3011
4569
  React.createElement(
3012
- 'div',
3013
- { className: 'cb-stat-box' },
3014
- React.createElement('div', { className: 'cb-stat-lbl' }, t('dashboard.account_pool')),
3015
- React.createElement('div', { className: 'cb-stat-val', style: { fontSize: '14px', wordBreak: 'break-all' } }, activeAcc)
4570
+ 'button',
4571
+ {
4572
+ type: 'button',
4573
+ className: 'cb-btn',
4574
+ disabled: loading || analyticsLoading,
4575
+ onClick: () => { loadData(); loadAnalytics(groupBy) },
4576
+ },
4577
+ (loading || analyticsLoading) ? t('dashboard.refreshing') : t('dashboard.refresh')
3016
4578
  ),
3017
4579
  React.createElement(
3018
- 'div',
3019
- { className: 'cb-stat-box' },
3020
- React.createElement('div', { className: 'cb-stat-lbl' }, t('accounts.mode_label')),
3021
- React.createElement('div', { className: 'cb-stat-val', style: { fontSize: '14px' } }, routingMode)
4580
+ 'button',
4581
+ {
4582
+ type: 'button',
4583
+ className: 'cb-panel-close',
4584
+ 'aria-label': t('dashboard.close'),
4585
+ title: t('dashboard.close'),
4586
+ onClick: onClose,
4587
+ },
4588
+ '×'
3022
4589
  )
3023
4590
  )
3024
4591
  ),
4592
+ notices,
4593
+ errorNotice,
4594
+ quotaBody,
4595
+ analyticsBody,
4596
+ heatmapCard
4597
+ )
3025
4598
 
3026
- // Quota windows
4599
+ const body = React.createElement(
4600
+ 'div',
4601
+ { className: 'cb-dialog' },
3027
4602
  React.createElement(
3028
4603
  'div',
3029
- { className: 'cb-dialogCard' },
3030
- React.createElement('div', { className: 'cb-section-title' }, t('quota.title')),
4604
+ { className: 'cb-dialogMeta' },
4605
+ updatedAt ? React.createElement('span', null, `${t('dashboard.updated_at')} ${updatedAt}`) : null,
4606
+ React.createElement('span', { className: 'cb-settings-spacer' }),
3031
4607
  React.createElement(
3032
- 'div',
3033
- { style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
3034
- data?.windows?.fiveHour
3035
- ? React.createElement(ProgressBar, {
3036
- label: t('quota.window_5h'),
3037
- percentUsed: data.windows.fiveHour.percentUsed || 0,
3038
- remainingPercent: data.windows.fiveHour.remainingPercent || 100,
3039
- resetsAt: data.windows.fiveHour.resetsAt,
3040
- t,
3041
- })
3042
- : null,
3043
- React.createElement(ProgressBar, {
3044
- label: t('quota.window_weekly'),
3045
- percentUsed: data?.windows?.weekly?.percentUsed || 0,
3046
- remainingPercent: data?.windows?.weekly?.remainingPercent || 100,
3047
- resetsAt: data?.windows?.weekly?.resetsAt,
3048
- t,
3049
- }),
3050
- data?.windows?.monthly
3051
- ? React.createElement(ProgressBar, {
3052
- label: t('quota.window_monthly'),
3053
- percentUsed: data.windows.monthly.percentUsed || 0,
3054
- remainingPercent: data.windows.monthly.remainingPercent || 100,
3055
- resetsAt: data.windows.monthly.resetsAt,
3056
- t,
3057
- })
3058
- : null
4608
+ 'button',
4609
+ { type: 'button', className: 'cb-btn', disabled: loading || analyticsLoading, onClick: () => (tab === 'quota' ? loadData() : loadAnalytics(groupBy)) },
4610
+ (loading || analyticsLoading) ? t('dashboard.refreshing') : t('dashboard.refresh')
3059
4611
  )
3060
- )
4612
+ ),
4613
+ tabs,
4614
+ notices,
4615
+ errorNotice,
4616
+ tab === 'quota'
4617
+ ? quotaBody
4618
+ : React.createElement(React.Fragment, null, analyticsBody, heatmapCard)
3061
4619
  )
3062
4620
 
4621
+ if (panel) return panelBody
4622
+
3063
4623
  if (Modal) {
3064
4624
  return React.createElement(Modal, {
3065
4625
  open: true,
@@ -3071,18 +4631,17 @@ function DashboardDialog(props) {
3071
4631
  })
3072
4632
  }
3073
4633
 
3074
- // Older shells without the primitive still get a window, just a plainer one.
3075
4634
  return React.createElement(
3076
4635
  'div',
3077
4636
  { className: 'cb-dialogFallback', role: 'dialog', 'aria-label': t('dashboard.title'), onClick: onClose },
3078
4637
  React.createElement(
3079
4638
  'div',
3080
- { className: 'cb-dialogFallbackPanel', onClick: (e) => e.stopPropagation() },
4639
+ { className: 'cb-dialogFallbackPanel', onClick: (event) => event.stopPropagation() },
3081
4640
  React.createElement(
3082
4641
  'div',
3083
4642
  { className: 'cb-dialogMeta' },
3084
4643
  React.createElement('span', { className: 'cb-row-title' }, t('dashboard.title')),
3085
- React.createElement('span', { style: { flex: 1 } }),
4644
+ React.createElement('span', { className: 'cb-settings-spacer' }),
3086
4645
  React.createElement(
3087
4646
  'button',
3088
4647
  { type: 'button', className: 'cb-btn', 'aria-label': t('dashboard.close'), onClick: onClose },
@@ -3102,43 +4661,84 @@ function formatTokens(count) {
3102
4661
  return String(n)
3103
4662
  }
3104
4663
 
3105
- function calculateCost(tokens, modelId = '') {
3106
- const isPremium = String(modelId).includes('claude') || String(modelId).includes('pro')
3107
- // Rates per 1M tokens
3108
- const promptRate = isPremium ? 3.0 : 0.15
3109
- const completionRate = isPremium ? 15.0 : 0.60
3110
- const reasoningRate = isPremium ? 15.0 : 0.60
4664
+ function findPrice(modelId, prices) {
4665
+ if (!modelId || !Array.isArray(prices)) return null
4666
+ const exact = prices.find((entry) => entry && entry.id === modelId)
4667
+ if (exact) return exact
4668
+ return prices.find((entry) => entry && entry.id && (modelId === entry.id || modelId.endsWith(`/${entry.id}`) || modelId.includes(entry.id))) || null
4669
+ }
3111
4670
 
4671
+ function calculateCost(tokens, modelId, prices) {
3112
4672
  const pTok = Number(tokens?.promptTokens || tokens?.inputTokens || 0)
3113
4673
  const cTok = Number(tokens?.completionTokens || tokens?.outputTokens || 0)
3114
4674
  const rTok = Number(tokens?.reasoningTokens || 0)
3115
-
3116
- const cost = (pTok * promptRate + cTok * completionRate + rTok * reasoningRate) / 1000000
3117
- return { cost, pTok, cTok, rTok, total: pTok + cTok + rTok }
4675
+ const cacheRead = Number(tokens?.cacheReadTokens || tokens?.cacheRead || 0)
4676
+ const cacheWrite = Number(tokens?.cacheWriteTokens || tokens?.cacheWrite || 0)
4677
+ const total = pTok + cTok + rTok
4678
+ const price = findPrice(modelId, prices)
4679
+ if (!price) return { cost: 0, priced: false, source: null, pTok, cTok, rTok, cacheRead, cacheWrite, total }
4680
+ const uncachedInput = Math.max(0, pTok - cacheRead - cacheWrite)
4681
+ const cost = (
4682
+ uncachedInput * Number(price.input || 0) +
4683
+ cTok * Number(price.output || 0) +
4684
+ cacheRead * Number(price.cacheRead || 0) +
4685
+ cacheWrite * Number(price.cacheWrite || 0)
4686
+ ) / 1000000
4687
+ return { cost, priced: true, source: price.source || 'estimate', pTok, cTok, rTok, cacheRead, cacheWrite, total }
3118
4688
  }
3119
4689
 
4690
+ const COST_ANCHOR = '[data-slot="conversation.composer.dock"]'
4691
+ const STATS_ROOT = '[data-composer-stats]'
4692
+ const STATS_USAGE = '[data-composer-stat="usage"]'
4693
+ const STATS_ANY = '[data-composer-stat]'
4694
+ const DIALOG_TRIGGER = 'button[aria-haspopup="dialog"]'
4695
+ const MISSES_BEFORE_WARNING = 3
4696
+
4697
+ /**
4698
+ * Inject the session cost into the native token-usage pill instead of drawing a
4699
+ * second pill. The composer's `conversation.composer.dock` entry is still the
4700
+ * only way to read the session projection; the rendered node is a hidden anchor
4701
+ * the injection locates its own composer from.
4702
+ */
3120
4703
  function ComposerCostDock(props) {
4704
+ if (typeof props?.useProjection !== 'function') return null
4705
+ return React.createElement(ComposerCostEntry, props)
4706
+ }
4707
+
4708
+ function ComposerCostEntry(props) {
3121
4709
  const t = props?.t || makeT(en, en)
4710
+ const configSnapshot = useConfigFormsSnapshot(props?.ctx)
3122
4711
  const [stats, setStats] = React.useState(null)
3123
- const [showPopover, setShowPopover] = React.useState(false)
4712
+ const [prices, setPrices] = React.useState([])
4713
+ const markerRef = React.useRef(null)
4714
+
4715
+ const projectedTokens = props.useProjection('tokenUsage')
4716
+ const selection = props.useProjection('modelSelection')
4717
+ const priceOverridesKey = JSON.stringify(configSnapshot?.value?.priceOverrides || {})
3124
4718
 
3125
- // Use projection hook if passed by DSH composer dock, or poll session stats
3126
- const projectedTokens = typeof props?.useProjection === 'function' ? props.useProjection('tokenUsage') : null
3127
- const projectedModel = typeof props?.useProjection === 'function' ? props.useProjection('modelSelection') : null
4719
+ React.useEffect(() => {
4720
+ let active = true
4721
+ fetchWithTimeout(`${ROUTE_PREFIX}/prices`, { cache: 'no-store' })
4722
+ .then((response) => response.json())
4723
+ .then((data) => {
4724
+ if (active && data?.ok && Array.isArray(data.models)) setPrices(data.models)
4725
+ })
4726
+ .catch(() => {})
4727
+ return () => { active = false }
4728
+ }, [priceOverridesKey])
3128
4729
 
3129
4730
  React.useEffect(() => {
3130
- if (projectedTokens) return
4731
+ if (projectedTokens) return undefined
3131
4732
  let active = true
3132
4733
  const loadStats = async () => {
3133
4734
  try {
3134
4735
  const res = await fetchWithTimeout(`${ROUTE_PREFIX}/stats`, { cache: 'no-store' })
3135
4736
  if (!res.ok) return
3136
4737
  const data = await res.json().catch(() => null)
3137
- if (active && data?.stats?.summary) {
3138
- setStats(data.stats.summary)
3139
- }
4738
+ const totals = data?.stats?.totals || data?.stats?.summary || null
4739
+ if (active && totals) setStats(totals)
3140
4740
  } catch {
3141
- // quiet
4741
+ /* quiet */
3142
4742
  }
3143
4743
  }
3144
4744
  loadStats()
@@ -3146,111 +4746,122 @@ function ComposerCostDock(props) {
3146
4746
  return () => { active = false; clearTimeout(timer) }
3147
4747
  }, [projectedTokens])
3148
4748
 
3149
- const tokens = projectedTokens || {
4749
+ const selected = selection?.lastUsed
4750
+ ?? selection?.next
4751
+ ?? (selection?.provider || selection?.model ? selection : null)
4752
+ const provider = String(selected?.provider || '')
4753
+ const modelId = String(selected?.model || selected?.id || '')
4754
+ const isClineBot = (provider === '' || provider === 'clinebot' || provider === 'llm-clinebot')
4755
+ && (provider === 'clinebot' || provider === 'llm-clinebot' || modelId.startsWith('cline-pass/'))
4756
+
4757
+ const rawTokens = projectedTokens || {
3150
4758
  promptTokens: stats?.promptTokens || 0,
3151
4759
  completionTokens: stats?.completionTokens || 0,
3152
4760
  reasoningTokens: stats?.reasoningTokens || 0,
4761
+ cacheReadTokens: stats?.cacheReadTokens || 0,
4762
+ cacheWriteTokens: stats?.cacheWriteTokens || 0,
3153
4763
  }
3154
-
3155
- const modelId = projectedModel?.id || ''
3156
- const { cost, pTok, cTok, rTok, total } = calculateCost(tokens, modelId)
3157
-
3158
- if (total === 0 && !stats) {
3159
- return null
4764
+ const cacheRead = Number(rawTokens.cacheReadTokens || 0)
4765
+ const cacheWrite = Number(rawTokens.cacheWriteTokens || 0)
4766
+ const uncachedInput = Number(rawTokens.uncachedInputTokens)
4767
+ const promptTokens = Number.isFinite(uncachedInput)
4768
+ ? uncachedInput + cacheRead + cacheWrite
4769
+ : Number(rawTokens.promptTokens || rawTokens.inputTokens || 0)
4770
+ const tokens = {
4771
+ promptTokens,
4772
+ completionTokens: Number(rawTokens.completionTokens || rawTokens.outputTokens || 0),
4773
+ reasoningTokens: Number(rawTokens.reasoningTokens || 0),
4774
+ cacheReadTokens: cacheRead,
4775
+ cacheWriteTokens: cacheWrite,
3160
4776
  }
4777
+ const { cost, priced, source, pTok, cTok, rTok, total } = calculateCost(tokens, modelId, prices)
4778
+
4779
+ const costValue = priced ? (cost < 0.0001 && cost > 0 ? '< $0.0001' : `$${cost.toFixed(4)}`) : t('composer.unpriced')
4780
+ const costDisplay = priced && source === 'estimate' ? `≈ ${costValue}` : costValue
4781
+ const title = [
4782
+ `${t('composer.cost_title')} ${costDisplay}`,
4783
+ `${t('composer.prompt_tok')} ${formatTokens(pTok)}`,
4784
+ `${t('composer.completion_tok')} ${formatTokens(cTok)}`,
4785
+ ...(rTok > 0 ? [`${t('composer.reasoning_tok')} ${formatTokens(rTok)}`] : []),
4786
+ ...(cacheRead > 0 ? [`${t('composer.cache_read')} ${formatTokens(cacheRead)}`] : []),
4787
+ ...(cacheWrite > 0 ? [`${t('composer.cache_write')} ${formatTokens(cacheWrite)}`] : []),
4788
+ ...(priced ? [`${t('composer.price_source')} ${source === 'override' ? t('composer.price_override') : t('composer.price_estimate')}`] : []),
4789
+ ].join(' · ')
4790
+ const inject = isClineBot && priced && total > 0
3161
4791
 
3162
- const costDisplay = cost < 0.001 && cost > 0 ? '< $0.001' : `$${cost.toFixed(3)}`
4792
+ React.useEffect(() => {
4793
+ if (!inject || typeof document === 'undefined') return undefined
4794
+ const marker = markerRef.current
4795
+ if (!marker) return undefined
4796
+ const scope = marker.closest(COST_ANCHOR)
4797
+ if (!scope) return undefined
4798
+
4799
+ let host = null
4800
+ let root = null
4801
+ let valueNode = null
4802
+ let misses = 0
4803
+
4804
+ const apply = () => {
4805
+ if (!root || root.parentNode === null) {
4806
+ const usageAnchor = scope.querySelector(STATS_USAGE) || scope.querySelector(STATS_ANY)
4807
+ const statsRoot = usageAnchor || scope.querySelector(STATS_ROOT) || scope
4808
+ const triggers = statsRoot.querySelectorAll(DIALOG_TRIGGER)
4809
+ const button = triggers[triggers.length - 1] || usageAnchor
4810
+ if (!button) {
4811
+ misses += 1
4812
+ if (misses === MISSES_BEFORE_WARNING) {
4813
+ console.warn('[dsh-clinebot] no native token-usage pill found for the session cost')
4814
+ }
4815
+ return
4816
+ }
4817
+ misses = 0
4818
+ host = button
4819
+ root = document.createElement('span')
4820
+ root.setAttribute('data-clinebot-session-cost', '')
4821
+ root.style.display = 'inline-flex'
4822
+ root.style.alignItems = 'center'
4823
+ const separator = document.createElement('span')
4824
+ separator.textContent = '·'
4825
+ separator.setAttribute('aria-hidden', 'true')
4826
+ separator.style.color = 'var(--dsw-alias-separator-primary)'
4827
+ separator.style.margin = '0 6px 0 0'
4828
+ valueNode = document.createElement('span')
4829
+ valueNode.style.fontWeight = '500'
4830
+ root.appendChild(separator)
4831
+ root.appendChild(valueNode)
4832
+ button.appendChild(root)
4833
+ }
4834
+ if (root.parentNode !== host) host.appendChild(root)
4835
+ if (valueNode.textContent !== costDisplay) valueNode.textContent = costDisplay
4836
+ if (root.title !== title) root.title = title
4837
+ }
3163
4838
 
3164
- return React.createElement(
3165
- 'div',
3166
- {
3167
- className: 'cb-composer-cost-dock',
3168
- 'data-composer-session-cost': 'true',
3169
- style: { position: 'relative', display: 'inline-flex', alignItems: 'center' },
3170
- },
3171
- React.createElement(
3172
- 'button',
3173
- {
3174
- type: 'button',
3175
- className: 'cb-btn',
3176
- style: {
3177
- padding: '2px 8px',
3178
- fontSize: '11px',
3179
- height: '24px',
3180
- borderRadius: '999px',
3181
- display: 'inline-flex',
3182
- alignItems: 'center',
3183
- gap: '4px',
3184
- color: 'var(--dsw-alias-label-secondary)',
3185
- background: 'var(--dsw-alias-bg-layer-2)',
3186
- border: '1px solid var(--dsw-alias-border-l2)',
3187
- cursor: 'pointer',
3188
- },
3189
- title: `${t('composer.cost_title')}: ${costDisplay} (${formatTokens(total)} tokens)`,
3190
- onClick: () => setShowPopover(!showPopover),
3191
- },
3192
- React.createElement('span', { style: { fontSize: '12px' } }, '🪙'),
3193
- React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, `≈ ${costDisplay}`),
3194
- React.createElement('span', { style: { opacity: 0.7 } }, `· ${formatTokens(total)} tok`)
3195
- ),
3196
- showPopover
3197
- ? React.createElement(
3198
- 'div',
3199
- {
3200
- className: 'cb-popover',
3201
- style: {
3202
- position: 'absolute',
3203
- bottom: '100%',
3204
- left: 0,
3205
- marginBottom: '6px',
3206
- minWidth: '200px',
3207
- padding: '10px 12px',
3208
- background: 'var(--dsw-alias-bg-layer-3)',
3209
- border: '1px solid var(--dsw-alias-border-l2)',
3210
- borderRadius: '8px',
3211
- boxShadow: 'var(--dsw-elevation-prominent)',
3212
- zIndex: 100,
3213
- fontSize: '12px',
3214
- color: 'var(--dsw-alias-label-primary)',
3215
- display: 'flex',
3216
- flexDirection: 'column',
3217
- gap: '6px',
3218
- },
3219
- },
3220
- React.createElement(
3221
- 'div',
3222
- { style: { fontWeight: 600, borderBottom: '1px solid var(--dsw-alias-border-l2)', paddingBottom: '4px' } },
3223
- t('composer.cost_breakdown')
3224
- ),
3225
- React.createElement(
3226
- 'div',
3227
- { style: { display: 'flex', justifyContent: 'space-between' } },
3228
- React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('composer.prompt_tok')),
3229
- React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, formatTokens(pTok))
3230
- ),
3231
- React.createElement(
3232
- 'div',
3233
- { style: { display: 'flex', justifyContent: 'space-between' } },
3234
- React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('composer.completion_tok')),
3235
- React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, formatTokens(cTok))
3236
- ),
3237
- rTok > 0
3238
- ? React.createElement(
3239
- 'div',
3240
- { style: { display: 'flex', justifyContent: 'space-between' } },
3241
- React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('composer.reasoning_tok')),
3242
- React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, formatTokens(rTok))
3243
- )
3244
- : null,
3245
- React.createElement(
3246
- 'div',
3247
- { style: { display: 'flex', justifyContent: 'space-between', borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: '4px', fontWeight: 600 } },
3248
- React.createElement('span', null, t('composer.est_cost')),
3249
- React.createElement('span', { style: { color: 'var(--dsw-alias-brand-primary)' } }, `≈ ${costDisplay}`)
3250
- )
3251
- )
4839
+ apply()
4840
+ const observer = typeof window !== 'undefined' && typeof window.MutationObserver === 'function'
4841
+ ? new window.MutationObserver(apply)
3252
4842
  : null
3253
- )
4843
+ observer?.observe(document.body, { childList: true })
4844
+ let timer = null
4845
+ const tick = () => {
4846
+ apply()
4847
+ timer = setTimeout(tick, 2000)
4848
+ }
4849
+ timer = setTimeout(tick, 2000)
4850
+ return () => {
4851
+ observer?.disconnect()
4852
+ if (timer) clearTimeout(timer)
4853
+ if (root?.parentNode) root.parentNode.removeChild(root)
4854
+ }
4855
+ }, [inject, costDisplay, title])
4856
+
4857
+ if (configSnapshot?.value?.showComposerCost === false) return null
4858
+ if (total === 0 && !stats) return null
4859
+ if (!inject) return null
4860
+ return React.createElement('span', {
4861
+ ref: markerRef,
4862
+ style: { display: 'none' },
4863
+ 'data-clinebot-cost-anchor': '',
4864
+ })
3254
4865
  }
3255
4866
 
3256
4867
  // --- src/client/settings-page.js ---
@@ -3275,6 +4886,7 @@ function SettingsPage(props) {
3275
4886
 
3276
4887
  React.useEffect(() => { ensureCss() }, [])
3277
4888
 
4889
+ const pendingRef = React.useRef({})
3278
4890
  const load = React.useCallback(async () => {
3279
4891
  setErr('')
3280
4892
  const [res, statsRes] = await Promise.all([
@@ -3286,11 +4898,15 @@ function SettingsPage(props) {
3286
4898
  const rawStats = statsRes && statsRes.ok ? await statsRes.json().catch(() => null) : null
3287
4899
  const statsObj = (rawStats && typeof rawStats === 'object' && rawStats.stats) ? rawStats.stats : (rawStats || data.statsSummary || {})
3288
4900
  setStatus({ ...data, statsSummary: statsObj })
3289
- setDraft(data.config || {})
4901
+ // Preserve unsaved staged fields across an account/model refresh.
4902
+ setDraft({ ...(data.config || {}), ...(pendingRef.current || {}) })
3290
4903
  }, [])
3291
4904
 
3292
4905
  React.useEffect(() => { load().catch((e) => setErr(String(e.message || e))) }, [load])
3293
4906
 
4907
+ const staged = useStagedConfig({ status, draft, setDraft, load, t, pendingRef })
4908
+ const modelSelection = useModelSelection({ status, draft, setDraft, load, setErr })
4909
+
3294
4910
  const checkUpdate = React.useCallback(async () => {
3295
4911
  setUpdateState((s) => ({ ...s, checking: true, status: 'checking', error: '' }))
3296
4912
  try {
@@ -3378,6 +4994,14 @@ function SettingsPage(props) {
3378
4994
  }
3379
4995
  }
3380
4996
 
4997
+ const {
4998
+ handlePinAccount,
4999
+ handleAddAccount,
5000
+ handleDeleteAccount,
5001
+ handleRenameAccount,
5002
+ handleClearKey,
5003
+ } = createAccountActions({ performAction, load, t, setMsg })
5004
+
3381
5005
  async function handleSaveKey() {
3382
5006
  const keyVal = String(apiKeyInput || '').trim()
3383
5007
  if (!keyVal) { setErr(t('key.empty_err')); return }
@@ -3498,45 +5122,6 @@ function SettingsPage(props) {
3498
5122
  window.open('https://app.cline.bot/settings/api-keys', '_blank')
3499
5123
  }
3500
5124
 
3501
- async function handlePinAccount(accountEnv) {
3502
- await performAction('pin-account', async () => {
3503
- const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/active`, {
3504
- method: 'POST', headers: { 'Content-Type': 'application/json' },
3505
- body: JSON.stringify({ account: accountEnv }),
3506
- })
3507
- const data = await res.json().catch(() => ({}))
3508
- if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
3509
- await load()
3510
- })
3511
- }
3512
-
3513
- async function handleAddAccount({ label, apiKeyEnv, apiKey, id }) {
3514
- await performAction('add-account', async () => {
3515
- const endpoint = id ? `${ROUTE_PREFIX}/keys/discovered/import` : `${ROUTE_PREFIX}/accounts`
3516
- const payload = id ? { id, label, apiKeyEnv } : { label, apiKeyEnv, apiKey }
3517
- const res = await fetchWithTimeout(endpoint, {
3518
- method: 'POST', headers: { 'Content-Type': 'application/json' },
3519
- body: JSON.stringify(payload),
3520
- })
3521
- const data = await res.json().catch(() => ({}))
3522
- if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
3523
- setMsg(t('key.saved_msg', { status: data.validated ? 'OK' : (data.validationError || 'Notice') }))
3524
- await load()
3525
- })
3526
- }
3527
-
3528
- async function handleDeleteAccount(accountEnv, deleteSecret = true) {
3529
- await performAction('delete-account', async () => {
3530
- const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/delete`, {
3531
- method: 'POST', headers: { 'Content-Type': 'application/json' },
3532
- body: JSON.stringify({ apiKeyEnv: accountEnv, deleteSecret }),
3533
- })
3534
- const data = await res.json().catch(() => ({}))
3535
- if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
3536
- await load()
3537
- })
3538
- }
3539
-
3540
5125
  async function handleConfigPatch(patch) {
3541
5126
  setDraft((d) => ({ ...(d || {}), ...(patch || {}) }))
3542
5127
  await performAction('patch-config', async () => {
@@ -3559,60 +5144,6 @@ function SettingsPage(props) {
3559
5144
  })
3560
5145
  }
3561
5146
 
3562
- const debounceTimerRef = typeof React.useRef === 'function' ? React.useRef(null) : { current: null }
3563
-
3564
- React.useEffect(() => () => { if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current) }, [])
3565
-
3566
- function debounceSaveDisabledModels(nextDisabled, previousDisabled) {
3567
- if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
3568
- debounceTimerRef.current = setTimeout(async () => {
3569
- try {
3570
- const res = await fetchWithTimeout(`${ROUTE_PREFIX}/models/toggle`, {
3571
- method: 'POST',
3572
- headers: { 'Content-Type': 'application/json' },
3573
- body: JSON.stringify({ disabledModels: nextDisabled }),
3574
- })
3575
- const data = await res.json().catch(() => ({}))
3576
- if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
3577
- await load()
3578
- } catch (err) {
3579
- console.warn('[dsh-clinebot] Failed saving disabled models:', err)
3580
- setErr(String(err?.message || err))
3581
- setDraft((prev) => {
3582
- if (!prev) return prev
3583
- const all = status?.availableModels || []
3584
- const prevEnabled = all.map((m) => m.id).filter((mId) => !previousDisabled.includes(mId))
3585
- return { ...prev, disabledModels: previousDisabled, enabledModels: prevEnabled }
3586
- })
3587
- }
3588
- }, 400)
3589
- }
3590
-
3591
- function handleToggleModel(id) {
3592
- const curr = draft?.disabledModels || []
3593
- const isCurrentlyDisabled = curr.includes(id)
3594
- const next = isCurrentlyDisabled ? curr.filter((x) => x !== id) : [...curr, id]
3595
- const all = status?.availableModels || []
3596
- const enabledIds = all.map((m) => m.id).filter((mId) => !next.includes(mId))
3597
- setDraft({ ...draft, disabledModels: next, enabledModels: enabledIds })
3598
- debounceSaveDisabledModels(next, curr)
3599
- }
3600
-
3601
- function handleSetModelsFilter(type) {
3602
- const all = status?.availableModels || []
3603
- let allowed = new Set()
3604
- if (type === 'all') allowed = new Set(all.map((m) => m.id))
3605
- else if (type === 'vision') allowed = new Set(all.filter((m) => m.input?.includes('image') || m.input?.includes('vision')).map((m) => m.id))
3606
- else if (type === 'coding') allowed = new Set(all.filter((m) => m.category === 'coding').map((m) => m.id))
3607
- else if (type === 'recommended') allowed = new Set(all.filter((m) => m.recommended).map((m) => m.id))
3608
-
3609
- const curr = draft?.disabledModels || []
3610
- const nextDisabled = all.map((m) => m.id).filter((id) => !allowed.has(id))
3611
- const nextEnabled = Array.from(allowed)
3612
- setDraft({ ...draft, disabledModels: nextDisabled, enabledModels: nextEnabled })
3613
- debounceSaveDisabledModels(nextDisabled, curr)
3614
- }
3615
-
3616
5147
  if (!status || !draft) {
3617
5148
  if (err) {
3618
5149
  return React.createElement(
@@ -3693,7 +5224,13 @@ function SettingsPage(props) {
3693
5224
 
3694
5225
  // Accounts Pool Card
3695
5226
  React.createElement(AccountsSection, {
3696
- status, busy, handlePinAccount, handleAddAccount, handleDeleteAccount,
5227
+ status,
5228
+ busy,
5229
+ handlePinAccount,
5230
+ handleAddAccount,
5231
+ handleDeleteAccount,
5232
+ handleRenameAccount,
5233
+ handleClearKey,
3697
5234
  accountMode: draft?.accountMode || status?.config?.accountMode || 'least_used',
3698
5235
  handleUpdateAccountMode: (mode) => handleConfigPatch({ accountMode: mode }),
3699
5236
  modelRoutes: draft?.modelRoutes || status?.config?.modelRoutes || {},
@@ -3702,39 +5239,72 @@ function SettingsPage(props) {
3702
5239
  }),
3703
5240
 
3704
5241
  // Quota Warning & Dashboard Card
3705
- React.createElement(QuotaSection, { keyPresent, status, usage, busy, handleRefreshQuota, showSidebarQuota: draft?.showSidebarQuota !== false, onToggleSidebarQuota: (v) => handleConfigPatch({ showSidebarQuota: v }), t }),
3706
-
3707
- // Card 3: Model Picker Management
3708
- React.createElement(ModelsSection, {
3709
- modelsList,
3710
- disabledSet,
3711
- enabledCount,
5242
+ React.createElement(QuotaSection, {
3712
5243
  keyPresent,
5244
+ status,
5245
+ usage,
3713
5246
  busy,
3714
- handleSyncPlanModels,
3715
- handleUpdateModelContext,
3716
- handleSetModelsFilter,
3717
- handleToggleModel,
3718
- planSynced: !!(status?.config?.planSynced || (status?.config?.dynamicModels && status.config.dynamicModels.length > 0)),
3719
- planSyncedAt: status?.config?.planSyncedAt || 0,
3720
- defaultModelWarning: status?.config?.defaultModelWarning || '',
3721
- customModels: draft?.customModels || [],
3722
- modelReasoningDefaults: draft?.modelReasoningDefaults || {},
3723
- visibleModels: draft?.visibleModels || [],
3724
- filterModelsByPlan: draft?.filterModelsByPlan !== false,
3725
- onConfigUpdate: handleConfigPatch,
3726
- onUpdateReasoningEffort: (modelId, effort) => {
3727
- const next = { ...(draft?.modelReasoningDefaults || {}) }
3728
- if (!effort) delete next[modelId]; else next[modelId] = effort
3729
- handleConfigPatch({ modelReasoningDefaults: next })
3730
- },
5247
+ handleRefreshQuota,
5248
+ showSidebarQuota: draft?.showSidebarQuota !== false,
5249
+ onToggleSidebarQuota: (value) => staged.stage({ showSidebarQuota: value }),
3731
5250
  t,
3732
5251
  }),
3733
5252
 
3734
- // Card 4: Session Metrics & Usage Tracking
5253
+ // Models & context: a foldable group keeps the long model table out of the
5254
+ // initial page while preserving the picker controls.
5255
+ React.createElement(
5256
+ CollapsibleGroup,
5257
+ {
5258
+ title: t('settings.models_group'),
5259
+ description: t('settings.models_group_hint'),
5260
+ badge: `${enabledCount}/${modelsList.length}`,
5261
+ divided: true,
5262
+ defaultOpen: false,
5263
+ },
5264
+ React.createElement(ModelsSection, {
5265
+ modelsList,
5266
+ disabledSet,
5267
+ enabledCount,
5268
+ keyPresent,
5269
+ busy,
5270
+ handleSyncPlanModels,
5271
+ handleUpdateModelContext,
5272
+ handleSetModelsFilter: modelSelection.handleSetModelsFilter,
5273
+ handleToggleModel: modelSelection.handleToggleModel,
5274
+ planSynced: !!(status?.config?.planSynced || (status?.config?.dynamicModels && status.config.dynamicModels.length > 0)),
5275
+ planSyncedAt: status?.config?.planSyncedAt || 0,
5276
+ defaultModelWarning: status?.config?.defaultModelWarning || '',
5277
+ customModels: draft?.customModels || [],
5278
+ modelReasoningDefaults: draft?.modelReasoningDefaults || {},
5279
+ visibleModels: draft?.visibleModels || [],
5280
+ filterModelsByPlan: draft?.filterModelsByPlan !== false,
5281
+ onConfigUpdate: staged.stage,
5282
+ onUpdateReasoningEffort: (modelId, effort) => {
5283
+ const next = { ...(draft?.modelReasoningDefaults || {}) }
5284
+ if (!effort) delete next[modelId]; else next[modelId] = effort
5285
+ staged.stage({ modelReasoningDefaults: next })
5286
+ },
5287
+ t,
5288
+ }),
5289
+ React.createElement(PriceSection, {
5290
+ modelsList,
5291
+ priceOverrides: draft?.priceOverrides || {},
5292
+ onConfigUpdate: staged.stage,
5293
+ t,
5294
+ })
5295
+ ),
5296
+
5297
+ // Session Metrics & Usage Tracking
3735
5298
  React.createElement(StatsSection, { status, onResetStats: handleResetStats, busy, t }),
3736
5299
 
3737
- // Card 5: Diagnostics & Sync with DSH
5300
+ React.createElement(IntegrationsSection, {
5301
+ draft,
5302
+ onStage: staged.stage,
5303
+ transport: draft?.transport || status?.config?.transport || 'adapter',
5304
+ t,
5305
+ }),
5306
+
5307
+ // Diagnostics & Sync with DSH
3738
5308
  React.createElement(DiagSection, {
3739
5309
  keyPresent,
3740
5310
  isRegistered,
@@ -3744,7 +5314,26 @@ function SettingsPage(props) {
3744
5314
  handleUnregister,
3745
5315
  handleRegister,
3746
5316
  proxyMode: draft?.proxyMode !== false,
3747
- onToggleProxy: (val) => handleConfigPatch({ proxyMode: val }),
5317
+ onToggleProxy: (value) => staged.stage({ proxyMode: value }),
5318
+ t,
5319
+ }),
5320
+
5321
+ React.createElement(AdvancedSection, {
5322
+ draft,
5323
+ onStage: staged.stage,
5324
+ transport: draft?.transport || status?.config?.transport || 'adapter',
5325
+ t,
5326
+ }),
5327
+
5328
+ React.createElement(SaveBar, {
5329
+ pendingCount: staged.pendingCount,
5330
+ saving: staged.saving,
5331
+ saveState: staged.saveState,
5332
+ saveError: staged.saveError,
5333
+ invalid: staged.invalid,
5334
+ validationError: staged.validationError,
5335
+ onSave: staged.save,
5336
+ onDiscard: staged.discard,
3748
5337
  t,
3749
5338
  })
3750
5339
  )
@@ -3820,6 +5409,24 @@ function apply(ctx) {
3820
5409
  }
3821
5410
  }
3822
5411
 
5412
+ function selectLayoutPanel(id) {
5413
+ try {
5414
+ const layout = typeof ctx.get === 'function' ? ctx.get('layout') : ctx.layout
5415
+ if (!layout || typeof layout.selectPanel !== 'function') return false
5416
+ layout.selectPanel(id)
5417
+ return true
5418
+ } catch (err) {
5419
+ console.warn('[dsh-clinebot] layout.selectPanel failed:', err)
5420
+ return false
5421
+ }
5422
+ }
5423
+
5424
+ const openPanel = () => selectLayoutPanel('clinebot-panel')
5425
+ const closePanel = () => {
5426
+ if (selectLayoutPanel(null)) return
5427
+ selectLayoutPanel('conversation')
5428
+ }
5429
+
3823
5430
  // The plugin's configuration is a section of DSH Settings (`settings.section`),
3824
5431
  // a peer of General/Models/Plugins — not a row in the Plugins list and not a tab
3825
5432
  // inside the Plugins settings section. The shell owns the modal, the section nav
@@ -3856,24 +5463,57 @@ function apply(ctx) {
3856
5463
  id: 'clinebot-quota',
3857
5464
  order: 20,
3858
5465
  locale: NS,
3859
- inject: () => ({ ctx }),
5466
+ inject: () => ({ ctx, open: openPanel }),
3860
5467
  },
3861
5468
  (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(SidebarQuota, { ...props, ctx: (props && props.ctx) || ctx }))
3862
5469
  )
3863
5470
  )
3864
5471
 
3865
- // The quota & status read-out is a window opened from the sidebar meter, not a
3866
- // `main`-slot panel: occupying the centre column meant clicking the meter threw
3867
- // the conversation out of the way to show a status read-out.
3868
-
3869
- // `settings.models.provider-card` is deliberately not registered. The seat is
3870
- // KEYED and dispatched with `entryKey = <provider settingsNs>` on every provider
3871
- // row, unconditionally: the models page renders it before the row's own
3872
- // `open ? editor : null`, and its owner props carry no open state. Anything
3873
- // registered there therefore shows on the collapsed row and reads as an editor
3874
- // that opened by itself. The row's 编辑 affordance already opens a provider
3875
- // editor with the same API-key field, and this plugin's configuration lives in
3876
- // its own Settings section, so the seat is left free.
5472
+ // The quota & usage dashboard opens in the layout's center column, matching
5473
+ // the Command Code / OpenCode provider surfaces. The sidebar card selects it
5474
+ // through `layout.selectPanel`; the panel's Close button selects the
5475
+ // conversation again. When no layout service is available the card falls back
5476
+ // to its local Modal.
5477
+ registerSlotWhenReady('main', () =>
5478
+ ctx.slots.register(
5479
+ {
5480
+ name: 'main',
5481
+ key: 'clinebot-panel',
5482
+ locale: NS,
5483
+ inject: () => ({ ctx, open: openPanel, close: closePanel }),
5484
+ },
5485
+ (props) => React.createElement(
5486
+ ErrorBoundary,
5487
+ { t: props && props.t },
5488
+ React.createElement(DashboardDialog, {
5489
+ ...props,
5490
+ panel: true,
5491
+ ctx: (props && props.ctx) || ctx,
5492
+ onClose: (props && props.close) || closePanel,
5493
+ })
5494
+ )
5495
+ )
5496
+ )
5497
+
5498
+ // Models-page provider card. The seat is keyed by the provider's settings
5499
+ // namespace, which the native adapter registration declares as `dsh-clinebot`
5500
+ // (`settingsNs`). The component returns null for every other provider row, so
5501
+ // it cannot leak onto a sibling's card.
5502
+ registerSlotWhenReady('settings.models.provider-card', () =>
5503
+ ctx.slots.register(
5504
+ {
5505
+ name: 'settings.models.provider-card',
5506
+ key: NS,
5507
+ locale: NS,
5508
+ inject: () => ({ ctx }),
5509
+ },
5510
+ (props) => React.createElement(
5511
+ ErrorBoundary,
5512
+ { t: props && props.t },
5513
+ React.createElement(ProviderCard, { ...props, ctx: (props && props.ctx) || ctx })
5514
+ )
5515
+ )
5516
+ )
3877
5517
 
3878
5518
  // Composer Session Cost Dock (conversation.composer.dock slot)
3879
5519
  registerSlotWhenReady('conversation.composer.dock', () =>