openclacky 1.5.19 → 1.5.21

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +95 -0
  3. data/README.md +19 -1
  4. data/README_CN.md +1 -1
  5. data/README_JA.md +1 -1
  6. data/lib/clacky/agent/chunk_index.rb +8 -1
  7. data/lib/clacky/agent/feedback.rb +30 -0
  8. data/lib/clacky/agent/history_navigation.rb +17 -3
  9. data/lib/clacky/agent/hook_manager.rb +6 -1
  10. data/lib/clacky/agent/llm_caller.rb +17 -3
  11. data/lib/clacky/agent/message_compressor_helper.rb +36 -2
  12. data/lib/clacky/agent/session_serializer.rb +45 -4
  13. data/lib/clacky/agent.rb +135 -55
  14. data/lib/clacky/agent_config.rb +82 -4
  15. data/lib/clacky/billing/platform_billing.rb +10 -5
  16. data/lib/clacky/brand_config.rb +1 -1
  17. data/lib/clacky/cli.rb +5 -4
  18. data/lib/clacky/client.rb +12 -5
  19. data/lib/clacky/deepseek_web_tools.rb +156 -0
  20. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +8 -0
  21. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +97 -20
  22. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -0
  23. data/lib/clacky/default_skills/onboard/SKILL.md +1 -1
  24. data/lib/clacky/extension/loader.rb +5 -3
  25. data/lib/clacky/locales/en.rb +5 -5
  26. data/lib/clacky/locales/zh.rb +5 -5
  27. data/lib/clacky/message_format/anthropic.rb +20 -0
  28. data/lib/clacky/message_format/open_ai.rb +31 -14
  29. data/lib/clacky/message_history.rb +45 -4
  30. data/lib/clacky/platform_http_client.rb +6 -2
  31. data/lib/clacky/providers.rb +38 -138
  32. data/lib/clacky/search_config.rb +47 -13
  33. data/lib/clacky/server/browser_manager.rb +35 -5
  34. data/lib/clacky/server/channel/channel_manager.rb +6 -6
  35. data/lib/clacky/server/discover.rb +5 -4
  36. data/lib/clacky/server/http_server.rb +285 -53
  37. data/lib/clacky/server/model_prices.rb +10 -5
  38. data/lib/clacky/server/pid_files.rb +25 -0
  39. data/lib/clacky/server/server_master.rb +9 -3
  40. data/lib/clacky/server/session_registry.rb +37 -7
  41. data/lib/clacky/server/web_ui_controller.rb +72 -0
  42. data/lib/clacky/session_manager.rb +28 -6
  43. data/lib/clacky/skill.rb +1 -1
  44. data/lib/clacky/tools/ask_user.rb +4 -1
  45. data/lib/clacky/tools/base.rb +3 -2
  46. data/lib/clacky/tools/browser.rb +18 -2
  47. data/lib/clacky/tools/file_reader.rb +1 -1
  48. data/lib/clacky/tools/terminal/session_manager.rb +19 -1
  49. data/lib/clacky/tools/terminal.rb +65 -23
  50. data/lib/clacky/tools/trash_manager.rb +5 -3
  51. data/lib/clacky/tools/web_fetch.rb +81 -22
  52. data/lib/clacky/tools/web_search.rb +45 -4
  53. data/lib/clacky/ui2/ui_controller.rb +1 -1
  54. data/lib/clacky/ui_interface.rb +4 -3
  55. data/lib/clacky/utils/file_processor.rb +51 -0
  56. data/lib/clacky/utils/model_pricing.rb +242 -39
  57. data/lib/clacky/version.rb +1 -1
  58. data/lib/clacky/web/app.css +1158 -249
  59. data/lib/clacky/web/app.js +82 -13
  60. data/lib/clacky/web/components/chat-navigator.js +8 -1
  61. data/lib/clacky/web/components/code-editor.js +6 -2
  62. data/lib/clacky/web/components/mentions.js +2 -13
  63. data/lib/clacky/web/components/model-picker.js +92 -97
  64. data/lib/clacky/web/components/onboard.js +13 -10
  65. data/lib/clacky/web/components/sidebar.js +6 -2
  66. data/lib/clacky/web/features/archive/store.js +196 -0
  67. data/lib/clacky/web/features/archive/view.js +409 -0
  68. data/lib/clacky/web/features/backup/view.js +17 -7
  69. data/lib/clacky/web/features/billing/view.js +84 -10
  70. data/lib/clacky/web/features/channels/view.js +75 -29
  71. data/lib/clacky/web/features/new-session/store.js +3 -3
  72. data/lib/clacky/web/features/new-session/view.js +1 -0
  73. data/lib/clacky/web/features/trash/store.js +14 -11
  74. data/lib/clacky/web/features/trash/view.js +66 -13
  75. data/lib/clacky/web/features/workspace/view.js +421 -16
  76. data/lib/clacky/web/i18n.js +242 -70
  77. data/lib/clacky/web/index.html +270 -117
  78. data/lib/clacky/web/projects.js +56 -1
  79. data/lib/clacky/web/sessions.js +609 -267
  80. data/lib/clacky/web/settings.js +186 -32
  81. data/lib/clacky/web/ws-dispatcher.js +33 -21
  82. data/lib/clacky.rb +4 -2
  83. metadata +6 -1
@@ -238,6 +238,7 @@ const BillingView = (() => {
238
238
  _bindChartTooltip();
239
239
  _bindHeatmapTooltip();
240
240
  _bindTrendTooltip();
241
+ _scrollHeatmapToLatest();
241
242
  }
242
243
 
243
244
  // ── Scorecard ───────────────────────────────────────────────────────────────
@@ -350,6 +351,14 @@ const BillingView = (() => {
350
351
  // so following the pointer covers the data no matter which side it flips to.
351
352
  // There it docks just outside `chart` instead, keeping the plot fully visible.
352
353
  const MOBILE_TOOLTIP_MAX_WIDTH = 768;
354
+ const MOBILE_CHART_MEDIA_QUERY = `(max-width: ${MOBILE_TOOLTIP_MAX_WIDTH}px)`;
355
+
356
+ function _isMobileChartLayout() {
357
+ if (typeof window.matchMedia === "function") {
358
+ return window.matchMedia(MOBILE_CHART_MEDIA_QUERY).matches;
359
+ }
360
+ return window.innerWidth <= MOBILE_TOOLTIP_MAX_WIDTH;
361
+ }
353
362
 
354
363
  function _placeTooltip(tooltip, e, chart) {
355
364
  tooltip.style.display = "block";
@@ -431,8 +440,7 @@ const BillingView = (() => {
431
440
  const tooltip = document.getElementById("billing-tooltip");
432
441
  if (!grid || !tooltip) return;
433
442
 
434
- grid.addEventListener("mousemove", (e) => {
435
- const cell = e.target.closest(".billing-heat-cell");
443
+ const showTooltip = (cell, e) => {
436
444
  if (!cell || cell.classList.contains("is-empty") || !cell.dataset.date) {
437
445
  tooltip.style.display = "none";
438
446
  return;
@@ -453,6 +461,21 @@ const BillingView = (() => {
453
461
  </div>
454
462
  `;
455
463
  _placeTooltip(tooltip, e, grid);
464
+ };
465
+
466
+ grid.addEventListener("mousemove", (e) => {
467
+ showTooltip(e.target.closest(".billing-heat-cell"), e);
468
+ });
469
+
470
+ grid.addEventListener("click", (e) => {
471
+ if (!_isMobileChartLayout()) return;
472
+
473
+ grid.querySelectorAll(".billing-heat-cell.is-active").forEach(cell => cell.classList.remove("is-active"));
474
+ const cell = _nearestHeatmapCell(grid, e.clientX, e.clientY);
475
+ if (cell && !cell.classList.contains("is-empty") && cell.dataset.date) {
476
+ cell.classList.add("is-active");
477
+ }
478
+ showTooltip(cell, e);
456
479
  });
457
480
 
458
481
  grid.addEventListener("mouseleave", () => {
@@ -461,9 +484,49 @@ const BillingView = (() => {
461
484
  });
462
485
  }
463
486
 
464
- // The dots are r="3" in viewBox units, which the responsive SVG scales down to
465
- // under 5px on a phone — far too small to hit. Snap to the nearest point on the
466
- // x axis instead so the whole column width is clickable.
487
+ // Mobile heatmap cells are intentionally compact, so include the surrounding
488
+ // gap in their logical hit area by snapping taps to the nearest week and day.
489
+ function _nearestHeatmapCell(grid, clientX, clientY) {
490
+ const nearestByRect = (elements, coordinate, startKey, endKey) => {
491
+ let nearest = null;
492
+ let nearestDistance = Infinity;
493
+ elements.forEach(element => {
494
+ const rect = element.getBoundingClientRect();
495
+ const distance = coordinate < rect[startKey]
496
+ ? rect[startKey] - coordinate
497
+ : coordinate > rect[endKey] ? coordinate - rect[endKey] : 0;
498
+ if (distance < nearestDistance) {
499
+ nearest = element;
500
+ nearestDistance = distance;
501
+ }
502
+ });
503
+ return nearest;
504
+ };
505
+
506
+ const week = nearestByRect(
507
+ Array.from(grid.querySelectorAll(".billing-heat-week")),
508
+ clientX,
509
+ "left",
510
+ "right"
511
+ );
512
+ if (!week) return null;
513
+
514
+ return nearestByRect(
515
+ Array.from(week.querySelectorAll(".billing-heat-cell")),
516
+ clientY,
517
+ "top",
518
+ "bottom"
519
+ );
520
+ }
521
+
522
+ function _scrollHeatmapToLatest() {
523
+ if (!_isMobileChartLayout()) return;
524
+ const scroller = document.querySelector(".billing-heatmap-year .billing-heat-year-wrap");
525
+ if (scroller) scroller.scrollLeft = scroller.scrollWidth;
526
+ }
527
+
528
+ // Even with larger mobile dots, a single point is too small to target reliably.
529
+ // Snap to the nearest point on the x axis so the whole column is clickable.
467
530
  function _nearestTrendDot(svg, clientX, clientY) {
468
531
  const count = parseInt(svg.dataset.count, 10);
469
532
  const xStep = parseFloat(svg.dataset.xStep);
@@ -487,7 +550,7 @@ const BillingView = (() => {
487
550
  const crosshairX = svg.querySelector(".billing-trend-crosshair-x");
488
551
  const crosshairY = svg.querySelector(".billing-trend-crosshair-y");
489
552
 
490
- svg.addEventListener("mousemove", (e) => {
553
+ const showTooltip = (e) => {
491
554
  const dot = _nearestTrendDot(svg, e.clientX, e.clientY);
492
555
  if (!dot) {
493
556
  tooltip.style.display = "none";
@@ -515,6 +578,11 @@ const BillingView = (() => {
515
578
  </div>
516
579
  `;
517
580
  _placeTooltip(tooltip, e, svg);
581
+ };
582
+
583
+ svg.addEventListener("mousemove", showTooltip);
584
+ svg.addEventListener("pointerdown", (e) => {
585
+ if (e.pointerType !== "mouse") showTooltip(e);
518
586
  });
519
587
 
520
588
  svg.addEventListener("mouseleave", () => {
@@ -788,9 +856,14 @@ const BillingView = (() => {
788
856
  const maxCost = Math.max(...costs, 0.0001);
789
857
  const minCost = Math.min(...costs);
790
858
 
791
- const pad = { top: 12, right: 16, bottom: 20, left: 48 };
792
- const w = 400;
793
- const h = 100;
859
+ const mobileLayout = _isMobileChartLayout();
860
+ const contentWidth = document.getElementById("billing-content")?.clientWidth || window.innerWidth;
861
+ const pad = mobileLayout
862
+ ? { top: 14, right: 12, bottom: 26, left: 58 }
863
+ : { top: 12, right: 16, bottom: 20, left: 48 };
864
+ const w = mobileLayout ? Math.max(240, contentWidth - 30) : 400;
865
+ const h = mobileLayout ? 160 : 100;
866
+ const dotRadius = mobileLayout ? 4 : 3;
794
867
  const plotW = w - pad.left - pad.right;
795
868
  const plotH = h - pad.top - pad.bottom;
796
869
 
@@ -851,7 +924,7 @@ const BillingView = (() => {
851
924
  <line class="billing-trend-crosshair billing-trend-crosshair-y" x1="${pad.left}" y1="0" x2="${w - pad.right}" y2="0" />
852
925
  <path d="${linePath}" fill="none" class="billing-trend-line" />
853
926
  ${costs.map((c, i) => `
854
- <circle cx="${pts[i].x.toFixed(2)}" cy="${pts[i].y.toFixed(2)}" r="3" class="billing-trend-dot" data-index="${i}" data-date="${days[i].date}" data-cost="${currencySymbol}${_formatCost(c)}" />
927
+ <circle cx="${pts[i].x.toFixed(2)}" cy="${pts[i].y.toFixed(2)}" r="${dotRadius}" class="billing-trend-dot" data-index="${i}" data-date="${days[i].date}" data-cost="${currencySymbol}${_formatCost(c)}" />
855
928
  `).join("")}
856
929
  </svg>
857
930
  </div>
@@ -1041,6 +1114,7 @@ const BillingView = (() => {
1041
1114
  Billing.on("billing:actionError", _onActionError);
1042
1115
  document.addEventListener("currencychange", () => Billing.refreshView());
1043
1116
  document.getElementById("header-print-receipt")?.addEventListener("click", _openScorecardShare);
1117
+ window.matchMedia?.(MOBILE_CHART_MEDIA_QUERY).addEventListener?.("change", _render);
1044
1118
  }
1045
1119
 
1046
1120
  return { init: _subscribe };
@@ -328,6 +328,8 @@ const ChannelsView = (() => {
328
328
 
329
329
  // ── QQ one-click bind (official QR page) ─────────────────────────────────
330
330
 
331
+ const REFRESH_SVG = `<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><polyline points="21 3 21 9 15 9"/></svg>`;
332
+
331
333
  let _qrPollTimer = null;
332
334
  let _qrAbort = false;
333
335
 
@@ -336,13 +338,17 @@ const ChannelsView = (() => {
336
338
  _qrAbort = true;
337
339
  }
338
340
 
339
- async function _openQrBind() {
341
+ async function _startQrTask() {
340
342
  _stopQrPolling();
343
+ const task = await Channels.qrStart();
341
344
  _qrAbort = false;
345
+ return task;
346
+ }
342
347
 
348
+ async function _openQrBind() {
343
349
  let task;
344
350
  try {
345
- task = await Channels.qrStart();
351
+ task = await _startQrTask();
346
352
  } catch (e) {
347
353
  Modal.toast("Error: " + e.message, "error");
348
354
  return;
@@ -356,26 +362,40 @@ const ChannelsView = (() => {
356
362
  overlay.className = "modal-overlay";
357
363
  overlay.innerHTML = `
358
364
  <div class="modal-box qq-qr-modal" role="dialog" aria-modal="true" aria-labelledby="qq-qr-title">
359
- <button type="button" class="modal-close-btn" id="qq-qr-close" aria-label="Close"></button>
365
+ <button type="button" class="modal-close-btn" id="qq-qr-close" aria-label="${_esc(I18n.t("modal.close"))}"></button>
360
366
  <h3 id="qq-qr-title">${_esc(I18n.t("channels.qq.qr.title"))}</h3>
361
- <p class="qq-qr-hint">${_esc(I18n.t("channels.qq.qr.hint"))}</p>
362
- <div class="qq-qr-status" id="qq-qr-status">${_esc(I18n.t("channels.qq.qr.waiting"))}</div>
363
- <div class="qq-qr-actions">
364
- <a class="qq-qr-open-btn" id="qq-qr-open" href="${_esc(task.connectUrl)}" target="_blank" rel="noopener">
365
- ${_esc(I18n.t("channels.qq.qr.open"))}
366
- </a>
367
- <button type="button" class="qq-qr-copy-btn" id="qq-qr-copy">${_esc(I18n.t("channels.qq.qr.copy"))}</button>
367
+ <div class="qq-qr-pending" id="qq-qr-pending">
368
+ <p class="qq-qr-hint">${_esc(I18n.t("channels.qq.qr.hint"))}</p>
369
+ <div class="qq-qr-frame-wrap" id="qq-qr-frame-wrap">
370
+ <iframe class="qq-qr-frame" id="qq-qr-frame" src="${_esc(task.connectUrl)}" title="${_esc(I18n.t("channels.qq.qr.title"))}" referrerpolicy="no-referrer" scrolling="no"></iframe>
371
+ <button type="button" class="qq-qr-refresh" id="qq-qr-refresh" hidden>
372
+ <span class="qq-qr-refresh-icon">${REFRESH_SVG}</span>
373
+ <span id="qq-qr-refresh-text"></span>
374
+ </button>
375
+ </div>
376
+ <div class="qq-qr-status" id="qq-qr-status">${_esc(I18n.t("channels.qq.qr.waiting"))}</div>
377
+ <div class="qq-qr-actions">
378
+ <a class="qq-qr-open-btn" id="qq-qr-open" href="${_esc(task.connectUrl)}" target="_blank" rel="noopener">
379
+ ${_esc(I18n.t("channels.qq.qr.open"))}
380
+ </a>
381
+ <button type="button" class="qq-qr-copy-btn" id="qq-qr-copy">${_esc(I18n.t("channels.qq.qr.copy"))}</button>
382
+ </div>
383
+ </div>
384
+ <div class="qq-qr-success" id="qq-qr-success" hidden>
385
+ <div class="qq-qr-success-title">${_esc(I18n.t("channels.qq.qr.success"))}</div>
386
+ <p class="qq-qr-hint">${_esc(I18n.t("channels.qq.qr.successDesc"))}</p>
387
+ <button type="button" class="qq-qr-open-btn" id="qq-qr-done">${_esc(I18n.t("channels.qq.qr.done"))}</button>
368
388
  </div>
369
389
  </div>
370
390
  `;
371
391
  document.body.appendChild(overlay);
372
392
 
393
+ let bound = false;
373
394
  const closeModal = () => { _stopQrPolling(); overlay.remove(); };
374
- overlay.addEventListener("click", (e) => { if (e.target === overlay) closeModal(); });
395
+ // While waiting, a stray backdrop click would drop the bind before the scan lands.
396
+ overlay.addEventListener("click", (e) => { if (e.target === overlay && bound) closeModal(); });
375
397
  $("qq-qr-close").addEventListener("click", closeModal);
376
- $("qq-qr-open").addEventListener("click", () => {
377
- $("qq-qr-status").textContent = I18n.t("channels.qq.qr.scanPrompt");
378
- });
398
+ $("qq-qr-done").addEventListener("click", closeModal);
379
399
  $("qq-qr-copy").addEventListener("click", async () => {
380
400
  try {
381
401
  await navigator.clipboard.writeText(task.connectUrl);
@@ -386,13 +406,44 @@ const ChannelsView = (() => {
386
406
  } catch (_) { /* ignore */ }
387
407
  });
388
408
 
389
- // Tencent blocks auto-popup in some contexts, so open directly as well.
390
- try { window.open(task.connectUrl, "_blank", "noopener"); } catch (_) { /* ignore */ }
409
+ const showExpired = (key) => {
410
+ _stopQrPolling();
411
+ $("qq-qr-frame-wrap").classList.add("expired");
412
+ $("qq-qr-refresh-text").textContent = I18n.t(key);
413
+ $("qq-qr-refresh").hidden = false;
414
+ $("qq-qr-status").textContent = "";
415
+ };
391
416
 
392
- _pollQr(task.taskId, closeModal);
417
+ $("qq-qr-refresh").addEventListener("click", async () => {
418
+ const btn = $("qq-qr-refresh");
419
+ btn.disabled = true;
420
+ try {
421
+ task = await _startQrTask();
422
+ } catch (e) {
423
+ Modal.toast("Error: " + e.message, "error");
424
+ btn.disabled = false;
425
+ return;
426
+ }
427
+ $("qq-qr-frame").src = task.connectUrl;
428
+ $("qq-qr-open").href = task.connectUrl;
429
+ $("qq-qr-frame-wrap").classList.remove("expired");
430
+ btn.hidden = true;
431
+ btn.disabled = false;
432
+ $("qq-qr-status").textContent = I18n.t("channels.qq.qr.waiting");
433
+ _pollQr(task.taskId, { onSuccess, onExpired: showExpired });
434
+ });
435
+
436
+ const onSuccess = () => {
437
+ bound = true;
438
+ $("qq-qr-pending").hidden = true;
439
+ $("qq-qr-success").hidden = false;
440
+ $("qq-qr-done").focus();
441
+ };
442
+
443
+ _pollQr(task.taskId, { onSuccess, onExpired: showExpired });
393
444
  }
394
445
 
395
- async function _pollQr(taskId, closeModal) {
446
+ function _pollQr(taskId, { onSuccess, onExpired }) {
396
447
  const POLL_MS = 2000;
397
448
  const tick = async () => {
398
449
  if (_qrAbort) return;
@@ -400,27 +451,22 @@ const ChannelsView = (() => {
400
451
  try {
401
452
  const status = await Channels.qrPoll(taskId);
402
453
  if (_qrAbort) return;
403
- const statusEl = $("qq-qr-status");
404
454
  if (status === "completed") {
405
- if (statusEl) statusEl.textContent = I18n.t("channels.qq.qr.success");
455
+ _stopQrPolling();
456
+ onSuccess();
406
457
  await Channels.load({ silent: true });
407
- setTimeout(closeModal, 1200);
408
458
  return;
409
459
  }
410
460
  if (status === "expired") {
411
- if (statusEl) statusEl.textContent = I18n.t("channels.qq.qr.expired");
412
- _stopQrPolling();
461
+ onExpired("channels.qq.qr.expired");
413
462
  return;
414
463
  }
415
464
  } catch (e) {
416
465
  if (_qrAbort) return;
417
- // 404/422 means the session was pruned (~600 s TTL): tell the user to
418
- // restart instead of spinning on 等待确认 forever.
466
+ // 404/422 means the session was pruned (~600 s TTL): offer a refresh
467
+ // instead of spinning on the stale code forever.
419
468
  if (e.status === 404 || e.status === 422) {
420
- const statusEl = $("qq-qr-status");
421
- if (statusEl) statusEl.textContent = I18n.t("channels.qq.qr.sessionExpired");
422
- Modal.toast(I18n.t("channels.qq.qr.sessionExpired"), "warning");
423
- _stopQrPolling();
469
+ onExpired("channels.qq.qr.sessionExpired");
424
470
  keepGoing = false;
425
471
  }
426
472
  // Other errors (network blips) keep polling.
@@ -152,11 +152,11 @@ const NewSessionStore = (() => {
152
152
  _emit("newSession:creating", { creating: true });
153
153
 
154
154
  try {
155
- // useDefaults=true: quick-create from sidebar — use the default agent and
156
- // skip advanced options (model / dir) so the session always starts clean.
155
+ // useDefaults=true: quick-create from sidebar — ignore the /#new draft
156
+ // (agent / name / model / dir / project) so the session always starts clean.
157
157
  const agentId = useDefaults ? "general" : (_state.selectedAgentId || "general");
158
158
  const adv = _state.advanced;
159
- const name = adv.name.trim() || _autoName(existingSessions);
159
+ const name = (!useDefaults && adv.name.trim()) || _autoName(existingSessions);
160
160
 
161
161
  const payload = { name, agent_profile: agentId, source: "manual" };
162
162
  if (!useDefaults) {
@@ -471,6 +471,7 @@ const NewSessionView = (() => {
471
471
  item.className = "img-preview-item";
472
472
  item.title = img.name;
473
473
  const thumbnail = document.createElement("img");
474
+ thumbnail.className = "img-preview-thumb";
474
475
  thumbnail.src = img.dataUrl;
475
476
  thumbnail.alt = img.name;
476
477
  const removeBtn = document.createElement("button");
@@ -13,7 +13,7 @@
13
13
  // ───────────────────────────────────────────────────────────────────────────
14
14
 
15
15
  const TrashStore = (() => {
16
- const SOURCES = Object.freeze({ ALL: "all", CRON: "cron", MANUAL: "manual" });
16
+ const SOURCES = Object.freeze({ ALL: "all", CRON: "cron", MANUAL: "manual", CHANNEL: "channel" });
17
17
 
18
18
  let _files = [];
19
19
  let _totals = { count: 0, size: 0 };
@@ -51,9 +51,9 @@ const TrashStore = (() => {
51
51
  get sessions() {
52
52
  let list = _sessions;
53
53
  if (_filterSource !== SOURCES.ALL) {
54
- list = _filterSource === SOURCES.CRON
55
- ? list.filter(s => s.source === SOURCES.CRON)
56
- : list.filter(s => s.source !== SOURCES.CRON);
54
+ // Same predicate the archive type filter uses: sessions with no source
55
+ // count as regular ones.
56
+ list = list.filter(s => (s.source || SOURCES.MANUAL) === _filterSource);
57
57
  }
58
58
  if (_filterProjectId !== null) {
59
59
  list = list.filter(s => String(s.project_id ?? "") === _filterProjectId);
@@ -200,14 +200,14 @@ const TrashStore = (() => {
200
200
  }
201
201
  },
202
202
 
203
- countMatching(items, daysOld) {
204
- if (!Array.isArray(items)) return 0;
205
- if (!daysOld || daysOld <= 0) return items.length;
203
+ matching(items, daysOld) {
204
+ if (!Array.isArray(items)) return [];
205
+ if (!daysOld || daysOld <= 0) return items;
206
206
  const cutoff = Date.now() - daysOld * 86400000;
207
207
  return items.filter(it => {
208
208
  const t = Date.parse(it.deleted_at || "");
209
209
  return !isNaN(t) && t < cutoff;
210
- }).length;
210
+ });
211
211
  },
212
212
 
213
213
  async emptyFilesBulk(daysOld) {
@@ -223,10 +223,13 @@ const TrashStore = (() => {
223
223
  }
224
224
  },
225
225
 
226
- async emptySessionsBulk(daysOld) {
227
- const url = "/api/trash/sessions?" + new URLSearchParams({ days_old: String(daysOld) }).toString();
226
+ async deleteSessions(sessions) {
228
227
  try {
229
- const res = await fetch(url, { method: "DELETE" });
228
+ const res = await fetch("/api/trash/sessions/delete", {
229
+ method: "POST",
230
+ headers: { "Content-Type": "application/json" },
231
+ body: JSON.stringify({ session_ids: sessions.map(s => s.session_id) })
232
+ });
230
233
  const data = await res.json();
231
234
  if (!res.ok || !data.ok) return { ok: false, error: data.error || res.statusText };
232
235
  await Trash.loadSessions();
@@ -49,6 +49,46 @@ const TrashView = (() => {
49
49
  return d.toLocaleDateString();
50
50
  }
51
51
 
52
+ // ── Source filter select helpers ─────────────────────────────────────
53
+
54
+ // "All types / Regular / Scheduled / Channel" — the archive page filters
55
+ // archived sessions by the same four sources, so this page mirrors it.
56
+ function _buildSourceOptions() {
57
+ const wrap = $("trash-filter-source");
58
+ if (!wrap) return;
59
+ const dropdown = wrap.querySelector(".custom-select-dropdown");
60
+ if (!dropdown || dropdown.dataset.built === "1") return;
61
+
62
+ const opts = [
63
+ { value: "", label: _t("trash.filterAllTypes") },
64
+ { value: Trash.SOURCES.MANUAL, label: _t("trash.filterManual") },
65
+ { value: Trash.SOURCES.CRON, label: _t("trash.filterCron") },
66
+ { value: Trash.SOURCES.CHANNEL, label: _t("trash.filterChannel") },
67
+ ];
68
+ dropdown.innerHTML = "";
69
+ opts.forEach(o => {
70
+ const el = document.createElement("div");
71
+ el.className = "custom-select-option";
72
+ el.dataset.value = o.value;
73
+ el.dataset.label = o.label;
74
+ el.textContent = o.label;
75
+ dropdown.appendChild(el);
76
+ });
77
+ dropdown.dataset.built = "1";
78
+ }
79
+
80
+ // The empty option is "every source", so the trigger keeps the placeholder
81
+ // styling the archive filters show until a real filter is picked.
82
+ function _syncSourceSelect() {
83
+ const wrap = $("trash-filter-source");
84
+ if (!wrap) return;
85
+ const dropdown = wrap.querySelector(".custom-select-dropdown");
86
+ const ctl = dropdown && dropdown.__customSelect;
87
+ if (!ctl) return;
88
+ const current = Trash.state.filterSource;
89
+ ctl.setValue(current === Trash.SOURCES.ALL ? "" : current);
90
+ }
91
+
52
92
  // ── Project filter select helpers ────────────────────────────────────
53
93
 
54
94
  function _refreshProjectOptions() {
@@ -386,18 +426,18 @@ const TrashView = (() => {
386
426
  async function _emptyBulk(daysOld, confirmKey) {
387
427
  const isSession = _activeTab === "session-trash";
388
428
  const items = isSession ? Trash.state.sessions : Trash.state.files;
389
- const matchCount = Trash.countMatching(items, daysOld);
429
+ const matches = Trash.matching(items, daysOld);
390
430
 
391
- if (matchCount === 0) {
431
+ if (matches.length === 0) {
392
432
  Modal.toast(_t(daysOld > 0 ? "trash.nothingOld" : "trash.empty"), "info");
393
433
  return;
394
434
  }
395
435
 
396
- const confirmed = await Modal.confirm(I18n.t(confirmKey, { count: matchCount }));
436
+ const confirmed = await Modal.confirm(I18n.t(confirmKey, { count: matches.length }));
397
437
  if (!confirmed) return;
398
438
 
399
439
  if (isSession) {
400
- const res = await Trash.emptySessionsBulk(daysOld);
440
+ const res = await Trash.deleteSessions(matches);
401
441
  if (!res.ok) { Modal.toast(I18n.t("trash.cleanFail", { msg: res.error }), "error"); return; }
402
442
  Modal.toast(I18n.t("trash.sessionsCleaned", { count: res.deleted_count }), "success");
403
443
  } else {
@@ -459,14 +499,21 @@ const TrashView = (() => {
459
499
  () => _emptyBulk(0, _activeTab === "session-trash"
460
500
  ? "trash.confirmEmptySessionAll" : "trash.confirmEmptyAll"));
461
501
 
462
- // Filter chips
463
- document.querySelectorAll(".trash-filter-chip").forEach(chip => {
464
- chip.addEventListener("click", () => {
465
- const val = chip.dataset.filter;
466
- Trash.setFilterSource(val);
467
- document.querySelectorAll(".trash-filter-chip").forEach(c => c.classList.toggle("active", c.dataset.filter === val));
468
- });
469
- });
502
+ // Filter selects — source (all / regular / scheduled) and project
503
+ _buildSourceOptions();
504
+
505
+ const sourceWrap = $("trash-filter-source");
506
+ if (sourceWrap && window.CustomSelect) {
507
+ const dropdown = sourceWrap.querySelector(".custom-select-dropdown");
508
+ if (!dropdown.__customSelect) {
509
+ window.CustomSelect.init({
510
+ trigger: sourceWrap.querySelector(".custom-select-trigger"),
511
+ dropdown: dropdown,
512
+ onSelect: (value) => Trash.setFilterSource(value || Trash.SOURCES.ALL)
513
+ });
514
+ }
515
+ _syncSourceSelect();
516
+ }
470
517
 
471
518
  // Project filter select
472
519
  const projWrap = $("trash-filter-project");
@@ -501,7 +548,13 @@ const TrashView = (() => {
501
548
  onPanelShow() {
502
549
  _wire();
503
550
  _activeTab = null;
504
- _switchTab("file-trash");
551
+ // Sessions are what people come here for; the file trash is the side trip.
552
+ _switchTab("session-trash");
553
+ },
554
+
555
+ // Sidebar deletes land in the session trash while this page may be open behind them.
556
+ refreshIfShown() {
557
+ if (Router.current === "trash" && _activeTab === "session-trash") Trash.loadSessions();
505
558
  }
506
559
  };
507
560