openclacky 1.5.20 → 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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -0
  3. data/README.md +18 -0
  4. data/lib/clacky/agent/chunk_index.rb +8 -1
  5. data/lib/clacky/agent/feedback.rb +30 -0
  6. data/lib/clacky/agent/history_navigation.rb +17 -3
  7. data/lib/clacky/agent/hook_manager.rb +6 -1
  8. data/lib/clacky/agent/llm_caller.rb +1 -0
  9. data/lib/clacky/agent/message_compressor_helper.rb +28 -2
  10. data/lib/clacky/agent/session_serializer.rb +20 -2
  11. data/lib/clacky/agent.rb +56 -43
  12. data/lib/clacky/agent_config.rb +82 -4
  13. data/lib/clacky/billing/platform_billing.rb +5 -1
  14. data/lib/clacky/brand_config.rb +1 -1
  15. data/lib/clacky/cli.rb +3 -2
  16. data/lib/clacky/client.rb +5 -5
  17. data/lib/clacky/deepseek_web_tools.rb +156 -0
  18. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +8 -0
  19. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +21 -1
  20. data/lib/clacky/extension/loader.rb +5 -3
  21. data/lib/clacky/locales/en.rb +5 -5
  22. data/lib/clacky/locales/zh.rb +5 -5
  23. data/lib/clacky/message_format/anthropic.rb +20 -0
  24. data/lib/clacky/message_format/open_ai.rb +31 -14
  25. data/lib/clacky/message_history.rb +21 -3
  26. data/lib/clacky/platform_http_client.rb +6 -2
  27. data/lib/clacky/providers.rb +23 -14
  28. data/lib/clacky/search_config.rb +47 -13
  29. data/lib/clacky/server/browser_manager.rb +35 -5
  30. data/lib/clacky/server/channel/channel_manager.rb +6 -6
  31. data/lib/clacky/server/discover.rb +5 -4
  32. data/lib/clacky/server/http_server.rb +194 -47
  33. data/lib/clacky/server/model_prices.rb +5 -3
  34. data/lib/clacky/server/pid_files.rb +25 -0
  35. data/lib/clacky/server/server_master.rb +9 -3
  36. data/lib/clacky/server/session_registry.rb +19 -7
  37. data/lib/clacky/server/web_ui_controller.rb +61 -38
  38. data/lib/clacky/session_manager.rb +7 -1
  39. data/lib/clacky/skill.rb +1 -1
  40. data/lib/clacky/tools/ask_user.rb +4 -1
  41. data/lib/clacky/tools/base.rb +3 -2
  42. data/lib/clacky/tools/browser.rb +18 -2
  43. data/lib/clacky/tools/file_reader.rb +1 -1
  44. data/lib/clacky/tools/terminal/session_manager.rb +19 -1
  45. data/lib/clacky/tools/terminal.rb +65 -23
  46. data/lib/clacky/tools/trash_manager.rb +5 -3
  47. data/lib/clacky/tools/web_fetch.rb +81 -22
  48. data/lib/clacky/tools/web_search.rb +45 -4
  49. data/lib/clacky/ui2/ui_controller.rb +1 -1
  50. data/lib/clacky/ui_interface.rb +4 -3
  51. data/lib/clacky/utils/file_processor.rb +51 -0
  52. data/lib/clacky/utils/model_pricing.rb +132 -13
  53. data/lib/clacky/version.rb +1 -1
  54. data/lib/clacky/web/app.css +366 -105
  55. data/lib/clacky/web/components/chat-navigator.js +8 -1
  56. data/lib/clacky/web/components/mentions.js +2 -13
  57. data/lib/clacky/web/components/model-picker.js +3 -1
  58. data/lib/clacky/web/components/onboard.js +13 -10
  59. data/lib/clacky/web/features/archive/store.js +30 -16
  60. data/lib/clacky/web/features/archive/view.js +20 -10
  61. data/lib/clacky/web/features/backup/view.js +5 -5
  62. data/lib/clacky/web/features/new-session/store.js +3 -3
  63. data/lib/clacky/web/features/trash/store.js +10 -7
  64. data/lib/clacky/web/features/trash/view.js +9 -4
  65. data/lib/clacky/web/i18n.js +106 -30
  66. data/lib/clacky/web/index.html +90 -60
  67. data/lib/clacky/web/projects.js +56 -1
  68. data/lib/clacky/web/sessions.js +244 -131
  69. data/lib/clacky/web/settings.js +143 -8
  70. data/lib/clacky/web/ws-dispatcher.js +9 -13
  71. data/lib/clacky.rb +1 -0
  72. metadata +4 -1
@@ -306,18 +306,24 @@
306
306
  function syncMessages() {
307
307
  if (!messages || sessionId !== Sessions.activeId) return;
308
308
  const byId = new Map(items.map((item, index) => [item.id, index]));
309
+ const byTask = new Map();
309
310
  const liveByTime = new Map();
310
311
  items.forEach((item, index) => {
312
+ if (item.taskId) byTask.set(String(item.taskId), index);
311
313
  if (!item.archived && item.created_at) liveByTime.set(String(item.created_at), index);
312
314
  });
313
315
  loaded = [];
314
316
  messages.querySelectorAll(".msg-user-wrap").forEach(el => {
315
317
  const bubble = el.querySelector(".msg-user");
316
318
  let index = byId.get(bubble?.dataset.roundId);
319
+ // Compression re-keys rounds without re-rendering bubbles; task ids survive it.
320
+ if (index === undefined) index = byTask.get(bubble?.dataset.taskId);
317
321
  // Only optimistic live bubbles need a timestamp fallback. Archived
318
322
  // targets always use source locators, never synthetic timestamps.
319
323
  if (index === undefined && bubble?.dataset.editable !== "false") index = liveByTime.get(bubble?.dataset.createdAt);
320
- if (index !== undefined) loaded.push({ index, el });
324
+ if (index === undefined) return;
325
+ if (items[index].taskId) bubble.dataset.taskId = String(items[index].taskId);
326
+ loaded.push({ index, el });
321
327
  });
322
328
  _updateActive();
323
329
  }
@@ -379,6 +385,7 @@
379
385
  const identity = source.identities?.[offset] || null;
380
386
  return { id: JSON.stringify([source.key, offset, source.version, identity]),
381
387
  bookmarkKey: _bookmarkKey(source, offset, identity),
388
+ taskId: source.task_ids?.[offset] || identity?.[1] || null,
382
389
  archived: !source.identities, created_at: identity?.[0] };
383
390
  }));
384
391
  const validBookmarks = new Set(items.map(item => item.bookmarkKey));
@@ -67,7 +67,7 @@ const MentionAC = (() => {
67
67
  async function _fetchSessions(before) {
68
68
  // Cron/scheduled-task sessions are noise here — exclude them from references.
69
69
  const params = new URLSearchParams({ limit: "20", exclude_type: "cron" });
70
- if (before) params.set("before", before);
70
+ if (before) { params.set("before", before.before); params.set("before_id", before.before_id); }
71
71
  const url = `/api/sessions?${params}`;
72
72
  const res = await fetch(url);
73
73
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -303,18 +303,7 @@ const MentionAC = (() => {
303
303
  const sessions = data.sessions || [];
304
304
  _chatsHasMore = !!data.has_more;
305
305
  _items = _items.concat(sessions.map((s) => ({ kind: "session", id: s.id, name: s.name || s.id })));
306
- if (sessions.length) {
307
- // Cursor must come from a non-pinned session: pinned sessions sit at
308
- // the top regardless of age, so an old pinned row would otherwise push
309
- // the cursor too far back and leave the next page empty.
310
- const oldest = sessions.reduce((min, s) => {
311
- if (s.pinned) return min;
312
- const t = s.updated_at || s.created_at;
313
- if (!t) return min;
314
- return (!min || t < min) ? t : min;
315
- }, null);
316
- if (oldest) _chatsCursor = oldest;
317
- }
306
+ if (data.next_cursor) _chatsCursor = data.next_cursor;
318
307
  _renderChatItems(listWrap, reset);
319
308
  } catch (err) {
320
309
  if (_mode !== "chats" || req !== _chatsReq) return;
@@ -182,7 +182,9 @@ const ModelPicker = (() => {
182
182
  ratio.textContent = price.label;
183
183
  el.appendChild(ratio);
184
184
  const vars = { in: price.in, out: price.out };
185
- let tipKey = "sib.price.tip";
185
+ // Bedrock GPT carries a 20% markup over list, so the standard-price claim
186
+ // would be wrong for these rows.
187
+ let tipKey = name.startsWith("abs-gpt-") ? "sib.price.tip.abs" : "sib.price.tip";
186
188
  if (price.discount) {
187
189
  const badge = document.createElement("span");
188
190
  badge.className = "sib-model-discount";
@@ -414,8 +414,18 @@ const Onboard = (() => {
414
414
  el.className = "setup-test-result" + (msg ? " result-fail" : "");
415
415
  }
416
416
 
417
+ // Platform returns raw RFC 8628 codes (access_denied / expired_token); never
418
+ // surface those to users — map the status to a translated sentence instead.
419
+ function _deviceErrorText(status) {
420
+ switch (status) {
421
+ case "denied": return I18n.t("onboard.device.error.denied");
422
+ case "expired":
423
+ case "consumed": return I18n.t("onboard.device.error.expired");
424
+ default: return I18n.t("onboard.device.error.failed");
425
+ }
426
+ }
427
+
417
428
  async function _startDeviceLogin() {
418
- const zh = _selectedLang === "zh";
419
429
  _setDeviceError("");
420
430
 
421
431
  const w = window.open("about:blank", "_blank");
@@ -430,13 +440,11 @@ const Onboard = (() => {
430
440
 
431
441
  if (!data || !data.ok) {
432
442
  if (w && !w.closed) w.close();
433
- _setDeviceError((data && data.error) || (zh ? "无法发起登录,请稍后重试。" : "Could not start login. Please try again."));
443
+ _setDeviceError(I18n.t("onboard.device.error.startFailed"));
434
444
  return;
435
445
  }
436
446
 
437
447
  const url = data.verification_uri_complete || data.verification_uri;
438
- const codeEl = $("setup-device-usercode");
439
- if (codeEl) codeEl.textContent = data.user_code || "—";
440
448
  const link = $("setup-device-link");
441
449
  if (link && url) link.href = url;
442
450
 
@@ -452,8 +460,6 @@ const Onboard = (() => {
452
460
  }
453
461
 
454
462
  async function _pollDevice(deviceCode, intervalMs) {
455
- const zh = _selectedLang === "zh";
456
-
457
463
  while (_devicePolling) {
458
464
  await new Promise(r => setTimeout(r, intervalMs));
459
465
  if (!_devicePolling) return;
@@ -480,10 +486,7 @@ const Onboard = (() => {
480
486
  // denied / expired / consumed / error
481
487
  _devicePolling = false;
482
488
  _showDevicePending(false);
483
- const msg = data.status === "denied"
484
- ? (zh ? "授权已被拒绝。" : "Authorization was denied.")
485
- : (zh ? "授权已过期,请重新登录。" : "Authorization expired. Please try again.");
486
- _setDeviceError(data.error || msg);
489
+ _setDeviceError(_deviceErrorText(data.status));
487
490
  return;
488
491
  }
489
492
  }
@@ -14,6 +14,7 @@ const ArchiveStore = (() => {
14
14
 
15
15
  let _items = [];
16
16
  let _hasMore = false;
17
+ let _cursor = null; // server-issued sort key of the last loaded row
17
18
  let _loading = false;
18
19
  let _archivedTotal = 0; // whole archive, not just the loaded page
19
20
 
@@ -72,12 +73,9 @@ const ArchiveStore = (() => {
72
73
 
73
74
  try {
74
75
  const params = new URLSearchParams({ archived: "1", limit: String(PAGE_SIZE) });
75
- if (!opts.reset && _items.length) {
76
- // Cursor field must match the registry's sort key (updated_at, with
77
- // created_at as the legacy fallback) — archived_at is unrelated.
78
- const oldest = _items[_items.length - 1];
79
- const cursor = oldest.updated_at || oldest.created_at;
80
- if (cursor) params.set("before", cursor);
76
+ if (!opts.reset && _cursor) {
77
+ params.set("before", _cursor.before);
78
+ params.set("before_id", _cursor.before_id);
81
79
  }
82
80
 
83
81
  const res = await fetch(`/api/sessions?${params.toString()}`);
@@ -87,6 +85,7 @@ const ArchiveStore = (() => {
87
85
  const rows = data.sessions || [];
88
86
  _items = opts.reset === false ? _items.concat(rows) : rows;
89
87
  _hasMore = !!data.has_more;
88
+ _cursor = data.next_cursor || (opts.reset === false ? _cursor : null);
90
89
  if (typeof data.archived_count === "number") _archivedTotal = data.archived_count;
91
90
  _emit("archive:changed");
92
91
  } catch (e) {
@@ -110,6 +109,7 @@ const ArchiveStore = (() => {
110
109
  Sessions.renderList();
111
110
  }
112
111
  if (typeof Projects !== "undefined") Projects.renderSection();
112
+ Archive.refreshIfShown();
113
113
  return { ok: true };
114
114
  } catch (e) {
115
115
  return { ok: false, error: e.message };
@@ -155,21 +155,35 @@ const ArchiveStore = (() => {
155
155
  },
156
156
 
157
157
  /**
158
- * Delete every archived chat (soft delete, same as remove).
159
- * Filter- and search-agnostic on purpose: the button reads "delete all",
160
- * and the confirmation is worded with `state.archivedTotal` so the number
161
- * the user agrees to is the number this call takes out.
158
+ * Delete archived chats (soft delete, same as remove).
159
+ * `sessions` is the visible selection when search or filters narrow the
160
+ * page; null means the whole archive, which is paged, so only the server
161
+ * can enumerate it. Either way the caller words its confirmation with the
162
+ * number this call takes out.
162
163
  */
163
- async emptyAll() {
164
+ async deleteMany(sessions) {
165
+ const ids = sessions ? sessions.map(s => s.id || s.session_id) : null;
166
+ const opts = { method: "POST" };
167
+ if (ids) {
168
+ opts.headers = { "Content-Type": "application/json" };
169
+ opts.body = JSON.stringify({ session_ids: ids });
170
+ }
164
171
  try {
165
- const res = await fetch("/api/archive/sessions", { method: "DELETE" });
172
+ const res = await fetch("/api/archive/sessions/delete", opts);
166
173
  const data = await res.json();
167
174
  if (!res.ok || !data.ok) return { ok: false, error: data.error || res.statusText };
168
- _items = [];
169
- _hasMore = false;
170
- _archivedTotal = 0;
175
+ const deleted = data.deleted_count || 0;
176
+ if (ids) {
177
+ _items = _items.filter(s => !ids.includes(s.id || s.session_id));
178
+ _archivedTotal = Math.max(0, _archivedTotal - deleted);
179
+ } else {
180
+ _items = [];
181
+ _hasMore = false;
182
+ _cursor = null;
183
+ _archivedTotal = 0;
184
+ }
171
185
  _emit("archive:changed");
172
- return { ok: true, deleted_count: data.deleted_count || 0 };
186
+ return { ok: true, deleted_count: deleted };
173
187
  } catch (e) {
174
188
  return { ok: false, error: e.message };
175
189
  }
@@ -86,6 +86,10 @@ const ArchiveView = (() => {
86
86
  .map(k => byId.get(k));
87
87
  }
88
88
 
89
+ function _isNarrowed() {
90
+ return _query.trim() !== "" || _filterProject !== null || _filterType !== "";
91
+ }
92
+
89
93
  // ── Rendering ────────────────────────────────────────────────────────
90
94
 
91
95
  function _renderSkeleton() {
@@ -158,15 +162,15 @@ const ArchiveView = (() => {
158
162
  _refreshProjectOptions();
159
163
 
160
164
  const items = _visibleItems();
165
+ const narrowed = _isNarrowed();
161
166
  if (summary) {
162
167
  // With a filter or a search on, the number describes what the toolbar is
163
168
  // showing. Without one it describes the whole archive, which can be
164
169
  // bigger than the loaded page.
165
- const narrowed = _query.trim() !== "" || _filterProject !== null || _filterType !== "";
166
170
  const n = narrowed ? items.length : (Archive.state.archivedTotal || items.length);
167
171
  summary.textContent = n ? I18n.t("archive.summary", { n }) : "";
168
172
  }
169
- if (btnAll) btnAll.disabled = Archive.state.archivedTotal === 0;
173
+ if (btnAll) btnAll.disabled = (narrowed ? items.length : Archive.state.archivedTotal) === 0;
170
174
  if (btnMore) {
171
175
  btnMore.style.display = Archive.state.hasMore ? "" : "none";
172
176
  _setMoreLoading(false);
@@ -290,17 +294,18 @@ const ArchiveView = (() => {
290
294
  }
291
295
 
292
296
  async function _deleteAll() {
293
- // The whole archive, filters included: the confirmation has to say how many
294
- // chats actually leave, which is exactly what `Archive.state.count` (one
295
- // page of rows) cannot tell us.
296
- const total = Archive.state.archivedTotal;
297
- if (total === 0) {
298
- Modal.toast(_t("archive.empty"), "info");
297
+ // What the toolbar shows is what goes: the visible rows when search or a
298
+ // filter is on, otherwise the whole archive, whose size only the server
299
+ // knows (`archivedTotal`) because the list is paged.
300
+ const selection = _isNarrowed() ? _visibleItems() : null;
301
+ const count = selection ? selection.length : Archive.state.archivedTotal;
302
+ if (count === 0) {
303
+ Modal.toast(_t(selection ? "archive.noMatch" : "archive.empty"), "info");
299
304
  return;
300
305
  }
301
- const confirmed = await Modal.confirm(I18n.t("archive.confirmDeleteAll", { count: total }));
306
+ const confirmed = await Modal.confirm(I18n.t("archive.confirmDeleteAll", { count }));
302
307
  if (!confirmed) return;
303
- const res = await Archive.emptyAll();
308
+ const res = await Archive.deleteMany(selection);
304
309
  if (!res.ok) Modal.toast(I18n.t("archive.deleteFail", { msg: res.error }), "error");
305
310
  else Modal.toast(I18n.t("archive.deletedAll", { count: res.deleted_count }), "success");
306
311
  }
@@ -390,6 +395,11 @@ const ArchiveView = (() => {
390
395
  _wire();
391
396
  Archive.load({ reset: true });
392
397
  },
398
+
399
+ // Sidebar actions change the archive while this page may be open behind them.
400
+ refreshIfShown() {
401
+ if (Router.current === "archive") Archive.load({ reset: true });
402
+ },
393
403
  };
394
404
 
395
405
  return { init: _subscribe, api: viewApi };
@@ -35,10 +35,10 @@ const BackupView = (() => {
35
35
  if (!cfg.last_run_at) { el.textContent = ""; el.className = "model-test-result"; return; }
36
36
  if (cfg.last_status === "error") {
37
37
  el.textContent = I18n.t("settings.backup.lastError", { msg: cfg.last_error || "" });
38
- el.className = "model-test-result error";
38
+ el.className = "model-test-result result-fail";
39
39
  } else {
40
40
  el.textContent = I18n.t("settings.backup.lastOk", { time: _fmtDate(cfg.last_run_at) });
41
- el.className = "model-test-result success";
41
+ el.className = "model-test-result result-ok";
42
42
  }
43
43
  }
44
44
 
@@ -62,7 +62,7 @@ const BackupView = (() => {
62
62
  Modal.toast(I18n.t("settings.backup.downloaded"), "success");
63
63
  } else if (statusEl) {
64
64
  statusEl.textContent = I18n.t("settings.backup.lastError", { msg: res.error });
65
- statusEl.className = "model-test-result error";
65
+ statusEl.className = "model-test-result result-fail";
66
66
  }
67
67
  if (btn) btn.disabled = false;
68
68
  }
@@ -78,10 +78,10 @@ const BackupView = (() => {
78
78
  const buf = await file.arrayBuffer();
79
79
  const res = await Backup.restore(buf);
80
80
  if (res.ok) {
81
- if (statusEl) { statusEl.textContent = I18n.t("settings.backup.restoreOk"); statusEl.className = "model-test-result success"; }
81
+ if (statusEl) { statusEl.textContent = I18n.t("settings.backup.restoreOk"); statusEl.className = "model-test-result result-ok"; }
82
82
  _restoreStatusEl = statusEl;
83
83
  } else {
84
- if (statusEl) { statusEl.textContent = res.error || "Restore failed"; statusEl.className = "model-test-result error"; }
84
+ if (statusEl) { statusEl.textContent = res.error || "Restore failed"; statusEl.className = "model-test-result result-fail"; }
85
85
  }
86
86
  }
87
87
 
@@ -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) {
@@ -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();
@@ -426,18 +426,18 @@ const TrashView = (() => {
426
426
  async function _emptyBulk(daysOld, confirmKey) {
427
427
  const isSession = _activeTab === "session-trash";
428
428
  const items = isSession ? Trash.state.sessions : Trash.state.files;
429
- const matchCount = Trash.countMatching(items, daysOld);
429
+ const matches = Trash.matching(items, daysOld);
430
430
 
431
- if (matchCount === 0) {
431
+ if (matches.length === 0) {
432
432
  Modal.toast(_t(daysOld > 0 ? "trash.nothingOld" : "trash.empty"), "info");
433
433
  return;
434
434
  }
435
435
 
436
- const confirmed = await Modal.confirm(I18n.t(confirmKey, { count: matchCount }));
436
+ const confirmed = await Modal.confirm(I18n.t(confirmKey, { count: matches.length }));
437
437
  if (!confirmed) return;
438
438
 
439
439
  if (isSession) {
440
- const res = await Trash.emptySessionsBulk(daysOld);
440
+ const res = await Trash.deleteSessions(matches);
441
441
  if (!res.ok) { Modal.toast(I18n.t("trash.cleanFail", { msg: res.error }), "error"); return; }
442
442
  Modal.toast(I18n.t("trash.sessionsCleaned", { count: res.deleted_count }), "success");
443
443
  } else {
@@ -550,6 +550,11 @@ const TrashView = (() => {
550
550
  _activeTab = null;
551
551
  // Sessions are what people come here for; the file trash is the side trip.
552
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();
553
558
  }
554
559
  };
555
560