openclacky 1.2.5 → 1.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/README.md +34 -0
  4. data/README_CN.md +34 -0
  5. data/lib/clacky/agent/cost_tracker.rb +24 -10
  6. data/lib/clacky/agent/llm_caller.rb +25 -3
  7. data/lib/clacky/agent/message_compressor.rb +2 -1
  8. data/lib/clacky/agent/message_compressor_helper.rb +6 -2
  9. data/lib/clacky/agent/session_serializer.rb +23 -4
  10. data/lib/clacky/agent/tool_executor.rb +14 -0
  11. data/lib/clacky/agent/tool_registry.rb +0 -7
  12. data/lib/clacky/agent.rb +43 -10
  13. data/lib/clacky/agent_config.rb +54 -6
  14. data/lib/clacky/billing/billing_store.rb +62 -4
  15. data/lib/clacky/brand_config.rb +5 -0
  16. data/lib/clacky/cli.rb +76 -24
  17. data/lib/clacky/client.rb +59 -4
  18. data/lib/clacky/default_parsers/wps_parser.rb +82 -0
  19. data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
  20. data/lib/clacky/json_ui_controller.rb +5 -2
  21. data/lib/clacky/message_format/anthropic.rb +13 -3
  22. data/lib/clacky/message_format/bedrock.rb +2 -2
  23. data/lib/clacky/plain_ui_controller.rb +1 -1
  24. data/lib/clacky/platform_http_client.rb +28 -1
  25. data/lib/clacky/providers.rb +11 -29
  26. data/lib/clacky/server/channel/channel_manager.rb +148 -12
  27. data/lib/clacky/server/channel/channel_ui_controller.rb +4 -2
  28. data/lib/clacky/server/http_server.rb +133 -13
  29. data/lib/clacky/server/session_registry.rb +30 -4
  30. data/lib/clacky/server/web_ui_controller.rb +6 -3
  31. data/lib/clacky/tools/browser.rb +4 -13
  32. data/lib/clacky/tools/terminal.rb +23 -27
  33. data/lib/clacky/ui2/ui_controller.rb +1 -1
  34. data/lib/clacky/ui_interface.rb +1 -1
  35. data/lib/clacky/utils/file_processor.rb +3 -0
  36. data/lib/clacky/utils/parser_manager.rb +3 -0
  37. data/lib/clacky/version.rb +1 -1
  38. data/lib/clacky/web/app.css +659 -75
  39. data/lib/clacky/web/app.js +0 -1
  40. data/lib/clacky/web/billing.js +371 -99
  41. data/lib/clacky/web/i18n.js +48 -2
  42. data/lib/clacky/web/index.html +34 -1
  43. data/lib/clacky/web/sessions.js +213 -82
  44. data/lib/clacky/web/settings.js +59 -17
  45. data/lib/clacky/web/workspace.js +204 -0
  46. data/lib/clacky/web/ws-dispatcher.js +19 -3
  47. data/lib/clacky.rb +9 -3
  48. metadata +4 -5
  49. data/lib/clacky/tools/list_tasks.rb +0 -54
  50. data/lib/clacky/tools/redo_task.rb +0 -41
  51. data/lib/clacky/tools/undo_task.rb +0 -35
@@ -81,6 +81,14 @@ const I18n = (() => {
81
81
  "sessions.actions.downloadHint": "for debugging",
82
82
  "sib.dir.tooltip": "Click to change directory",
83
83
  "sib.dir.changePrompt": "Change working directory:",
84
+ "workspace.title": "Workspace",
85
+ "workspace.expand": "Open workspace",
86
+ "workspace.collapse": "Collapse workspace",
87
+ "workspace.refresh": "Refresh",
88
+ "workspace.empty": "This folder is empty",
89
+ "workspace.loading": "Loading…",
90
+ "workspace.error": "Failed to load files",
91
+ "workspace.downloadFailed": "Download failed",
84
92
  "sib.model.tooltip": "Click to switch model",
85
93
  "sib.model.tooltip.busy": "Model switching is disabled while the agent is responding",
86
94
  "sib.signal.tooltip": "Recent LLM latency",
@@ -507,6 +515,7 @@ const I18n = (() => {
507
515
  "settings.models.btn.saved": "Saved ✓",
508
516
  "settings.models.btn.testing": "Testing…",
509
517
  "settings.models.btn.test": "Test",
518
+ "settings.models.link.topUp": "Top up / Manage account",
510
519
  "settings.models.btn.edit": "Edit",
511
520
  "settings.models.btn.delete": "Delete",
512
521
  "settings.models.btn.cancel": "Cancel",
@@ -664,12 +673,26 @@ const I18n = (() => {
664
673
  "billing.byModel": "By Model",
665
674
  "billing.model": "Model",
666
675
  "billing.cost": "Cost",
667
- "billing.dailyUsage": "Daily Usage",
676
+ "billing.dailyUsage": "Usage Details",
668
677
  "billing.period.day": "Today",
669
678
  "billing.period.week": "This Week",
670
679
  "billing.period.month": "This Month",
671
680
  "billing.period.year": "This Year",
672
681
  "billing.period.all": "All Time",
682
+ "billing.clearData": "Clear Data",
683
+ "billing.clearToday": "Clear Today",
684
+ "billing.clearAll": "Clear All",
685
+ "billing.allModels": "All Models",
686
+ "billing.cacheHit": "Cache Hit",
687
+ "billing.inputCacheHit": "Input (Cache Hit)",
688
+ "billing.inputCacheMiss": "Input (Cache Miss)",
689
+ "billing.output": "Output",
690
+ "billing.tokenUsage": "Token Usage",
691
+ "billing.costTrend": "Cost Trend",
692
+ "billing.noData": "No data available",
693
+
694
+ "error.insufficient_credit": "Insufficient LLM credit. Please top up your account to continue.",
695
+ "error.insufficient_credit.action": "Top up",
673
696
  },
674
697
 
675
698
  zh: {
@@ -739,6 +762,14 @@ const I18n = (() => {
739
762
  "sessions.actions.downloadHint": "用于调试",
740
763
  "sib.dir.tooltip": "点击切换工作目录",
741
764
  "sib.dir.changePrompt": "切换工作目录:",
765
+ "workspace.title": "工作区",
766
+ "workspace.expand": "打开工作区",
767
+ "workspace.collapse": "收起工作区",
768
+ "workspace.refresh": "刷新",
769
+ "workspace.empty": "此文件夹为空",
770
+ "workspace.loading": "加载中…",
771
+ "workspace.error": "加载文件失败",
772
+ "workspace.downloadFailed": "下载失败",
742
773
  "sib.model.tooltip": "点击切换模型",
743
774
  "sib.model.tooltip.busy": "Agent 回复中,暂时无法切换模型",
744
775
  "sib.signal.tooltip": "最近一次 LLM 响应延迟",
@@ -1164,6 +1195,7 @@ const I18n = (() => {
1164
1195
  "settings.models.btn.saved": "已保存 ✓",
1165
1196
  "settings.models.btn.testing": "测试中…",
1166
1197
  "settings.models.btn.test": "测试",
1198
+ "settings.models.link.topUp": "充值 / 账户管理",
1167
1199
  "settings.models.btn.edit": "编辑",
1168
1200
  "settings.models.btn.delete": "删除",
1169
1201
  "settings.models.btn.cancel": "取消",
@@ -1321,12 +1353,26 @@ const I18n = (() => {
1321
1353
  "billing.byModel": "按模型",
1322
1354
  "billing.model": "模型",
1323
1355
  "billing.cost": "费用",
1324
- "billing.dailyUsage": "每日用量",
1356
+ "billing.dailyUsage": "使用详情",
1325
1357
  "billing.period.day": "今日",
1326
1358
  "billing.period.week": "本周",
1327
1359
  "billing.period.month": "本月",
1328
1360
  "billing.period.year": "今年",
1329
1361
  "billing.period.all": "全部",
1362
+ "billing.clearData": "清除数据",
1363
+ "billing.clearToday": "清除今日",
1364
+ "billing.clearAll": "全部清除",
1365
+ "billing.allModels": "所有模型",
1366
+ "billing.cacheHit": "缓存命中",
1367
+ "billing.inputCacheHit": "输入(命中缓存)",
1368
+ "billing.inputCacheMiss": "输入(未命中缓存)",
1369
+ "billing.output": "输出",
1370
+ "billing.tokenUsage": "Token 用量",
1371
+ "billing.costTrend": "费用趋势",
1372
+ "billing.noData": "暂无数据",
1373
+
1374
+ "error.insufficient_credit": "LLM(大模型)余额不足,请充值后继续使用。",
1375
+ "error.insufficient_credit.action": "去充值",
1330
1376
  }
1331
1377
  };
1332
1378
 
@@ -313,6 +313,8 @@
313
313
  <!-- Chat panel -->
314
314
  <div id="chat-panel" style="display:none">
315
315
 
316
+ <!-- Chat column (messages + info bar + input) -->
317
+ <div id="chat-main">
316
318
  <div id="messages"></div>
317
319
  <!-- New message notification banner -->
318
320
  <div id="new-message-banner" class="new-message-banner" style="display:none">
@@ -334,6 +336,7 @@
334
336
  <span id="sib-model-wrap">
335
337
  <span id="sib-model" class="sib-model-clickable" title="Click to switch model"></span>
336
338
  <div id="sib-model-dropdown" class="sib-model-dropdown" style="display:none"></div>
339
+ <div id="sib-submodel-panel" class="sib-submodel-panel" style="display:none"></div>
337
340
  </span>
338
341
  <span class="sib-sep sib-sep-after-model">│</span>
339
342
  <span id="sib-reasoning-wrap">
@@ -381,7 +384,7 @@
381
384
  <div id="image-preview-strip" style="display:none"></div>
382
385
  <div id="input-bar">
383
386
  <!-- Hidden file picker -->
384
- <input type="file" id="image-file-input" accept="image/png,image/jpeg,image/gif,image/webp,application/pdf,application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/x-tar,application/x-compressed-tar,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/msword,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.ms-powerpoint,text/csv,application/csv,text/markdown,text/plain,.csv,.md,.markdown,.txt,.log,.tar,.gz,.tgz,.tar.gz" multiple style="display:none">
387
+ <input type="file" id="image-file-input" accept="image/png,image/jpeg,image/gif,image/webp,*/*" multiple style="display:none">
385
388
  <button id="btn-attach" title="Attach file (image, pdf, docx, md, tar.gz…) — drag & drop / Ctrl+V also work">
386
389
  <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
387
390
  <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66L9.41 17.41a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
@@ -397,6 +400,35 @@
397
400
  <button id="btn-interrupt" style="display:none" title="Stop"></button>
398
401
  </div>
399
402
  </div>
403
+ </div><!-- /#chat-main -->
404
+
405
+ <!-- ── WORKSPACE PANEL (right) ──────────────────────────────────── -->
406
+ <aside id="workspace-panel" class="collapsed">
407
+ <div id="workspace-header">
408
+ <span id="workspace-title" data-i18n="workspace.title">Workspace</span>
409
+ <div class="workspace-header-actions">
410
+ <button id="btn-workspace-refresh" class="workspace-icon-btn" data-i18n-title="workspace.refresh" title="Refresh">
411
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
412
+ <path d="M23 4v6h-6"/><path d="M1 20v-6h6"/>
413
+ <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
414
+ </svg>
415
+ </button>
416
+ <button id="btn-workspace-close" class="workspace-icon-btn" data-i18n-title="workspace.collapse" title="Collapse">
417
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
418
+ <path d="M9 18l6-6-6-6"/>
419
+ </svg>
420
+ </button>
421
+ </div>
422
+ </div>
423
+ <div id="workspace-tree" role="tree"></div>
424
+ </aside>
425
+
426
+ <!-- Collapsed-state opener tab -->
427
+ <button id="btn-workspace-open" data-i18n-title="workspace.expand" title="Open workspace" style="display:none">
428
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
429
+ <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
430
+ </svg>
431
+ </button>
400
432
  </div>
401
433
 
402
434
  <!-- ── CREATOR PANEL ─────────────────────────────────────────────── -->
@@ -1250,6 +1282,7 @@
1250
1282
  <script src="/ws.js"></script>
1251
1283
  <script src="/ws-dispatcher.js"></script>
1252
1284
  <script src="/sessions.js"></script>
1285
+ <script src="/workspace.js"></script>
1253
1286
  <script src="/datepicker.js"></script>
1254
1287
  <script src="/tasks.js"></script>
1255
1288
  <script src="/skills.js"></script>
@@ -492,57 +492,12 @@ const Sessions = (() => {
492
492
  const MAX_IMAGE_LONG_EDGE = 1920; // px — scale down if larger
493
493
  const MAX_FILE_BYTES = 32 * 1024 * 1024; // 32 MB
494
494
  const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
495
- const ACCEPTED_DOC_TYPES = [
496
- "application/pdf",
497
- "application/zip",
498
- "application/x-zip-compressed",
499
- "application/gzip",
500
- "application/x-gzip",
501
- "application/x-tar",
502
- "application/x-compressed-tar",
503
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
504
- "application/msword", // .doc
505
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
506
- "application/vnd.ms-excel", // .xls
507
- "application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx
508
- "application/vnd.ms-powerpoint", // .ppt
509
- "text/csv", // .csv
510
- "application/csv", // .csv (some browsers)
511
- "text/markdown", // .md
512
- "text/x-markdown", // .md (some browsers)
513
- "text/plain", // .md / .txt (many browsers report this)
514
- ];
515
-
516
- // Extension-based fallback for files whose MIME type is missing or unreliable.
517
- // Browsers frequently report "" or "application/octet-stream" for .md / .tar.gz.
518
- const ACCEPTED_DOC_EXTENSIONS = [
519
- ".pdf", ".zip",
520
- ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
521
- ".csv",
522
- ".md", ".markdown", ".txt", ".log",
523
- ".tar", ".gz", ".tgz", ".tar.gz", ".rar", ".7z"
524
- ];
525
-
526
- function _hasAcceptedDocExt(filename) {
527
- const lower = (filename || "").toLowerCase();
528
- return ACCEPTED_DOC_EXTENSIONS.some(ext => lower.endsWith(ext));
529
- }
530
-
531
- function _isAcceptedDoc(file) {
532
- if (!file) return false;
533
- if (file.type && ACCEPTED_DOC_TYPES.includes(file.type)) return true;
534
- return _hasAcceptedDocExt(file.name);
535
- }
536
495
 
537
496
  function _isAcceptedImage(file) {
538
497
  if (!file) return false;
539
498
  return ACCEPTED_IMAGE_TYPES.includes(file.type);
540
499
  }
541
500
 
542
- function _isAcceptedFile(file) {
543
- return _isAcceptedImage(file) || _isAcceptedDoc(file);
544
- }
545
-
546
501
  function _docTypeIcon(mimeType, filename) {
547
502
  const lower = (filename || "").toLowerCase();
548
503
  if (mimeType === "application/pdf" || lower.endsWith(".pdf")) return "📄";
@@ -552,11 +507,11 @@ const Sessions = (() => {
552
507
  lower.endsWith(".tar") || lower.endsWith(".gz") || lower.endsWith(".tgz") || lower.endsWith(".tar.gz") ||
553
508
  lower.endsWith(".rar") || lower.endsWith(".7z")) return "🗜️";
554
509
  if ((mimeType && mimeType.includes("wordprocessingml")) || mimeType === "application/msword" ||
555
- lower.endsWith(".doc") || lower.endsWith(".docx")) return "📝";
510
+ lower.endsWith(".doc") || lower.endsWith(".docx") || lower.endsWith(".wps")) return "📝";
556
511
  if ((mimeType && mimeType.includes("spreadsheetml")) || mimeType === "application/vnd.ms-excel" ||
557
- lower.endsWith(".xls") || lower.endsWith(".xlsx")) return "📊";
512
+ lower.endsWith(".xls") || lower.endsWith(".xlsx") || lower.endsWith(".et")) return "📊";
558
513
  if ((mimeType && mimeType.includes("presentationml")) || mimeType === "application/vnd.ms-powerpoint" ||
559
- lower.endsWith(".ppt") || lower.endsWith(".pptx")) return "📋";
514
+ lower.endsWith(".ppt") || lower.endsWith(".pptx") || lower.endsWith(".dps")) return "📋";
560
515
  if (mimeType === "text/csv" || mimeType === "application/csv" || lower.endsWith(".csv")) return "📊";
561
516
  if (mimeType === "text/markdown" || mimeType === "text/x-markdown" ||
562
517
  lower.endsWith(".md") || lower.endsWith(".markdown")) return "📝";
@@ -649,16 +604,10 @@ const Sessions = (() => {
649
604
  }
650
605
 
651
606
  function _addAttachmentFile(file) {
652
- // Route by content category. Images must match known image MIME types
653
- // (MIME is reliable for images). Documents fall back to extension-based
654
- // detection because browsers frequently report "" or "application/octet-stream"
655
- // for .md / .tar.gz files.
656
607
  if (_isAcceptedImage(file)) {
657
608
  _addImageFile(file);
658
- } else if (_isAcceptedDoc(file)) {
659
- _addGenericFile(file);
660
609
  } else {
661
- alert(`Unsupported file: ${file.name}\nSupported: images (PNG/JPG/GIF/WEBP), PDF, Office (DOC/XLS/PPT), ZIP, TAR, TAR.GZ, MD, TXT, CSV`);
610
+ _addGenericFile(file);
662
611
  }
663
612
  }
664
613
 
@@ -848,27 +797,20 @@ const Sessions = (() => {
848
797
  inputArea.addEventListener("drop", (e) => {
849
798
  e.preventDefault();
850
799
  inputArea.classList.remove("drag-over");
851
- const files = Array.from(e.dataTransfer.files).filter(_isAcceptedFile);
800
+ const files = Array.from(e.dataTransfer.files);
852
801
  if (files.length === 0) return;
853
802
  files.forEach(_addAttachmentFile);
854
803
  });
855
804
 
856
- // Paste handler — images and accepted docs from the clipboard.
857
805
  document.getElementById("user-input").addEventListener("paste", (e) => {
858
806
  const items = Array.from(e.clipboardData?.items || []);
859
- // Any file-kind item that's an image, or a document whose type/name
860
- // passes our doc filter. Must check name via getAsFile() for .md/.tar.gz
861
- // (browsers often leave item.type empty for these).
862
- const attachItems = items.filter(it => {
863
- if (it.kind !== "file") return false;
864
- if (ACCEPTED_IMAGE_TYPES.includes(it.type)) return true;
865
- if (ACCEPTED_DOC_TYPES.includes(it.type)) return true;
866
- const f = it.getAsFile && it.getAsFile();
867
- return f ? _hasAcceptedDocExt(f.name) : false;
868
- });
807
+ const attachItems = items.filter(it => it.kind === "file");
869
808
  if (attachItems.length === 0) return;
870
809
  e.preventDefault();
871
- attachItems.forEach(it => _addAttachmentFile(it.getAsFile()));
810
+ attachItems.forEach(it => {
811
+ const f = it.getAsFile && it.getAsFile();
812
+ if (f) _addAttachmentFile(f);
813
+ });
872
814
  });
873
815
  }
874
816
 
@@ -1454,8 +1396,15 @@ const Sessions = (() => {
1454
1396
  // showProgress() with the authoritative started_at, which is the
1455
1397
  // single source of truth for first-visit sessions (no cached state).
1456
1398
  } else if (session.status === "error" && session.error) {
1457
- // Show the stored error message at the end of history
1458
- Sessions.appendMsg("error", session.error);
1399
+ if (window.renderErrorEvent) {
1400
+ window.renderErrorEvent({
1401
+ code: session.error_code,
1402
+ message: session.error,
1403
+ top_up_url: session.top_up_url,
1404
+ });
1405
+ } else {
1406
+ Sessions.appendMsg("error", session.error);
1407
+ }
1459
1408
  }
1460
1409
  }
1461
1410
  }
@@ -2393,6 +2342,7 @@ const Sessions = (() => {
2393
2342
  /** Update the session info bar below the chat header with current session metadata. */
2394
2343
  updateInfoBar(s) {
2395
2344
  this._lastSession = s;
2345
+ if (window.Workspace) Workspace.onSession(s);
2396
2346
  if (!s) {
2397
2347
  // Hide all spans when no session
2398
2348
  ["sib-id", "sib-status", "sib-dir", "sib-mode", "sib-model", "sib-reasoning", "sib-tasks", "sib-cost"].forEach(id => {
@@ -2451,15 +2401,21 @@ const Sessions = (() => {
2451
2401
  const sibModelWrap = $("sib-model-wrap");
2452
2402
  const sibModel = $("sib-model");
2453
2403
  if (sibModel) {
2454
- sibModel.textContent = s.model || "";
2455
- // Store current session ID on the model element for later use
2404
+ const subModel = s.sub_model;
2405
+ const cardModel = s.card_model;
2406
+ const display = subModel
2407
+ ? `${subModel}`
2408
+ : (s.model || "");
2409
+ sibModel.textContent = display;
2456
2410
  sibModel.dataset.sessionId = s.id;
2457
2411
  if (s.model_id) {
2458
2412
  sibModel.dataset.modelId = s.model_id;
2459
2413
  } else {
2460
2414
  delete sibModel.dataset.modelId;
2461
2415
  }
2462
- // Disable model switching while the agent is responding
2416
+ if (cardModel) sibModel.dataset.cardModel = cardModel; else delete sibModel.dataset.cardModel;
2417
+ if (subModel) sibModel.dataset.subModel = subModel; else delete sibModel.dataset.subModel;
2418
+ sibModel.dataset.subModelOptions = JSON.stringify(s.sub_model_options || []);
2463
2419
  const busy = s.status === "running";
2464
2420
  sibModel.classList.toggle("sib-model-disabled", busy);
2465
2421
  sibModel.title = busy
@@ -3401,8 +3357,16 @@ const Sessions = (() => {
3401
3357
  if (_isOpen) {
3402
3358
  dropdown.style.display = "none";
3403
3359
  _isOpen = false;
3360
+ _closeSubmodelPanel();
3404
3361
  } else {
3405
- await _populateModelDropdown(modelEl.dataset.sessionId, modelEl.dataset.modelId || null);
3362
+ let subOptions = [];
3363
+ try { subOptions = JSON.parse(modelEl.dataset.subModelOptions || "[]"); } catch (_) {}
3364
+ const subInfo = {
3365
+ options: Array.isArray(subOptions) ? subOptions : [],
3366
+ current: modelEl.dataset.subModel || null,
3367
+ cardModel: modelEl.dataset.cardModel || null
3368
+ };
3369
+ await _populateModelDropdown(modelEl.dataset.sessionId, modelEl.dataset.modelId || null, subInfo);
3406
3370
 
3407
3371
  // Calculate position relative to the model element (fixed positioning)
3408
3372
  const rect = modelEl.getBoundingClientRect();
@@ -3417,15 +3381,17 @@ const Sessions = (() => {
3417
3381
  }
3418
3382
 
3419
3383
  // Close dropdown when clicking outside
3420
- if (_isOpen && !e.target.closest(".sib-model-dropdown")) {
3384
+ if (_isOpen && !e.target.closest(".sib-model-dropdown") && !e.target.closest(".sib-submodel-panel")) {
3421
3385
  const dropdown = $("sib-model-dropdown");
3422
3386
  if (dropdown) dropdown.style.display = "none";
3423
3387
  _isOpen = false;
3388
+ _closeSubmodelPanel();
3424
3389
  }
3425
3390
  });
3426
3391
 
3427
3392
  // Populate dropdown with available models
3428
- async function _populateModelDropdown(sessionId, currentModelId) {
3393
+ async function _populateModelDropdown(sessionId, currentModelId, subInfo) {
3394
+ subInfo = subInfo || { options: [], current: null, cardModel: null };
3429
3395
  const dropdown = $("sib-model-dropdown");
3430
3396
  if (!dropdown) return;
3431
3397
 
@@ -3489,6 +3455,13 @@ const Sessions = (() => {
3489
3455
  nameLine.textContent = m.model;
3490
3456
  left.appendChild(nameLine);
3491
3457
 
3458
+ if (m.id === currentModelId && subInfo.current && subInfo.current !== subInfo.cardModel) {
3459
+ const overrideLine = document.createElement("span");
3460
+ overrideLine.className = "sib-model-name-override";
3461
+ overrideLine.textContent = `→ ${subInfo.current}`;
3462
+ left.appendChild(overrideLine);
3463
+ }
3464
+
3492
3465
  if (_nameCounts[m.model] > 1) {
3493
3466
  left.classList.add("has-sub");
3494
3467
  const host = (() => {
@@ -3516,17 +3489,36 @@ const Sessions = (() => {
3516
3489
  right.appendChild(badge);
3517
3490
  }
3518
3491
 
3519
- // Latency cell — populated from _benchCache on open, updated live
3520
- // when a benchmark run completes. Empty slot keeps row heights stable
3521
- // so the list doesn't visually jump mid-benchmark.
3522
3492
  const lat = document.createElement("span");
3523
3493
  lat.className = "sib-model-latency";
3524
3494
  _fillLatencyCell(lat, _benchCache[m.id]);
3525
3495
  right.appendChild(lat);
3526
3496
 
3497
+ const hasSubModels =
3498
+ m.id === currentModelId &&
3499
+ subInfo.options &&
3500
+ subInfo.options.length > 1;
3501
+
3502
+ if (hasSubModels) {
3503
+ const toggleBtn = document.createElement("button");
3504
+ toggleBtn.type = "button";
3505
+ toggleBtn.className = "sib-submodel-toggle";
3506
+ toggleBtn.title = "Switch sub-model";
3507
+ toggleBtn.setAttribute("aria-expanded", "false");
3508
+ toggleBtn.innerHTML =
3509
+ '<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">' +
3510
+ '<path d="M6 3.5L10.5 8 6 12.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>' +
3511
+ '</svg>';
3512
+ right.appendChild(toggleBtn);
3513
+
3514
+ toggleBtn.addEventListener("click", (ev) => {
3515
+ ev.stopPropagation();
3516
+ _toggleSubmodelPanel(opt, toggleBtn, sessionId, subInfo);
3517
+ });
3518
+ }
3519
+
3527
3520
  opt.appendChild(right);
3528
3521
 
3529
- // Switch by id (stable across reorders/edits). Keep model name for UI update.
3530
3522
  opt.addEventListener("click", () => _switchModel(sessionId, m.id, m.model));
3531
3523
  dropdown.appendChild(opt);
3532
3524
  });
@@ -3637,14 +3629,15 @@ const Sessions = (() => {
3637
3629
  }
3638
3630
 
3639
3631
  // Switch session model via API
3640
- // modelId stable runtime id (required by backend)
3641
- // modelName display name, used for optimistic UI update
3632
+ // Switch the session's current card. modelId is the stable runtime id,
3633
+ // modelName is for optimistic display.
3642
3634
  async function _switchModel(sessionId, modelId, modelName) {
3643
3635
  const dropdown = $("sib-model-dropdown");
3644
3636
  if (dropdown) {
3645
3637
  dropdown.style.display = "none";
3646
3638
  _isOpen = false;
3647
3639
  }
3640
+ _closeSubmodelPanel();
3648
3641
 
3649
3642
  try {
3650
3643
  const res = await fetch(`/api/sessions/${sessionId}/model`, {
@@ -3669,6 +3662,144 @@ const Sessions = (() => {
3669
3662
  alert("Failed to switch model: " + e.message);
3670
3663
  }
3671
3664
  }
3665
+
3666
+ // Pin (or clear) the session's sub-model. Pass modelName=null to clear.
3667
+ // displayName is what we optimistically show in the status bar.
3668
+ async function _switchSubModel(sessionId, modelName, displayName) {
3669
+ const dropdown = $("sib-model-dropdown");
3670
+ if (dropdown) {
3671
+ dropdown.style.display = "none";
3672
+ _isOpen = false;
3673
+ }
3674
+ _closeSubmodelPanel();
3675
+
3676
+ try {
3677
+ const res = await fetch(`/api/sessions/${sessionId}/submodel`, {
3678
+ method: "PATCH",
3679
+ headers: { "Content-Type": "application/json" },
3680
+ body: JSON.stringify({ model_name: modelName })
3681
+ });
3682
+ const data = await res.json();
3683
+ if (!res.ok) throw new Error(data.error || "Unknown error");
3684
+
3685
+ const sibModel = $("sib-model");
3686
+ if (sibModel) {
3687
+ sibModel.textContent = displayName || "";
3688
+ sibModel.dataset.subModel = modelName || "";
3689
+ }
3690
+ } catch (e) {
3691
+ console.error("Failed to switch sub-model:", e);
3692
+ alert("Failed to switch sub-model: " + e.message);
3693
+ }
3694
+ }
3695
+
3696
+ let _activeSubmodelAnchor = null;
3697
+
3698
+ function _closeSubmodelPanel() {
3699
+ const panel = $("sib-submodel-panel");
3700
+ if (panel) panel.style.display = "none";
3701
+ if (_activeSubmodelAnchor) {
3702
+ const btn = _activeSubmodelAnchor.querySelector(".sib-submodel-toggle");
3703
+ if (btn) btn.setAttribute("aria-expanded", "false");
3704
+ _activeSubmodelAnchor.classList.remove("submodel-open");
3705
+ _activeSubmodelAnchor = null;
3706
+ }
3707
+ }
3708
+
3709
+ function _toggleSubmodelPanel(anchorRow, btn, sessionId, subInfo) {
3710
+ const panel = $("sib-submodel-panel");
3711
+ const dropdown = $("sib-model-dropdown");
3712
+ if (!panel || !dropdown) return;
3713
+
3714
+ if (panel.parentElement !== document.body) {
3715
+ document.body.appendChild(panel);
3716
+ }
3717
+
3718
+ const isOpen = panel.style.display !== "none" && _activeSubmodelAnchor === anchorRow;
3719
+ if (isOpen) {
3720
+ _closeSubmodelPanel();
3721
+ return;
3722
+ }
3723
+
3724
+ _renderSubmodelPanel(panel, sessionId, subInfo);
3725
+
3726
+ // Reset any prior position so measurements are accurate.
3727
+ panel.style.left = "0px";
3728
+ panel.style.top = "0px";
3729
+ panel.style.display = "block";
3730
+ panel.style.visibility = "hidden";
3731
+
3732
+ const dropRect = dropdown.getBoundingClientRect();
3733
+ const btnRect = btn.getBoundingClientRect();
3734
+ const panelRect = panel.getBoundingClientRect();
3735
+ const gap = 6;
3736
+ const margin = 8;
3737
+ const vw = window.innerWidth;
3738
+ const vh = window.innerHeight;
3739
+
3740
+ // Prefer right of dropdown; flip to left if we'd overflow viewport.
3741
+ let left = dropRect.right + gap;
3742
+ if (left + panelRect.width > vw - margin) {
3743
+ left = dropRect.left - panelRect.width - gap;
3744
+ }
3745
+ // If still off-screen on the left, clamp inside viewport.
3746
+ if (left < margin) left = margin;
3747
+
3748
+ // Vertically align to the chevron button, but clamp inside viewport.
3749
+ let top = btnRect.top - 6;
3750
+ if (top + panelRect.height > vh - margin) {
3751
+ top = vh - margin - panelRect.height;
3752
+ }
3753
+ if (top < margin) top = margin;
3754
+
3755
+ panel.style.left = `${left}px`;
3756
+ panel.style.top = `${top}px`;
3757
+ panel.style.visibility = "";
3758
+
3759
+ _activeSubmodelAnchor = anchorRow;
3760
+ anchorRow.classList.add("submodel-open");
3761
+ btn.setAttribute("aria-expanded", "true");
3762
+ }
3763
+
3764
+ function _renderSubmodelPanel(panel, sessionId, subInfo) {
3765
+ panel.innerHTML = "";
3766
+
3767
+ const header = document.createElement("div");
3768
+ header.className = "sib-submodel-panel-header";
3769
+ header.textContent = "Sub-model";
3770
+ panel.appendChild(header);
3771
+
3772
+ const cardDefault = subInfo.cardModel;
3773
+ subInfo.options.forEach(name => {
3774
+ const row = document.createElement("div");
3775
+ row.className = "sib-submodel-row";
3776
+ row.dataset.subModel = name;
3777
+
3778
+ const isActive = subInfo.current
3779
+ ? name === subInfo.current
3780
+ : name === cardDefault;
3781
+ if (isActive) row.classList.add("current");
3782
+
3783
+ const nameEl = document.createElement("span");
3784
+ nameEl.className = "sib-submodel-row-name";
3785
+ nameEl.textContent = name;
3786
+ row.appendChild(nameEl);
3787
+
3788
+ if (name === cardDefault) {
3789
+ const tag = document.createElement("span");
3790
+ tag.className = "sib-submodel-default-tag";
3791
+ tag.textContent = "default";
3792
+ row.appendChild(tag);
3793
+ }
3794
+
3795
+ row.addEventListener("click", (ev) => {
3796
+ ev.stopPropagation();
3797
+ const passName = (name === cardDefault) ? null : name;
3798
+ _switchSubModel(sessionId, passName, name);
3799
+ });
3800
+ panel.appendChild(row);
3801
+ });
3802
+ }
3672
3803
  })();
3673
3804
 
3674
3805
  // ── Session Info Bar Working Directory Switcher ───────────────────────────