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
@@ -5,8 +5,12 @@
5
5
  // in the sidebar (e.g. "new session") belongs to its own domain module
6
6
  // (Sessions, Skills, …), not here.
7
7
  //
8
+ // The "Data" rail entry opens the data-management page (archive / recycle
9
+ // bin / backup tabs). It routes to the archive tab — the page's default —
10
+ // and the tab bar takes over from there.
11
+ //
8
12
  // Contract:
9
- // Sidebar.init() — attach click handlers. Must be called after DOM ready.
13
+ // Sidebar.init() — attach click handlers. Must be called after DOM ready.
10
14
  // ──────────────────────────────────────────────────────────────────────────
11
15
 
12
16
  const Sidebar = (() => {
@@ -24,7 +28,7 @@ const Sidebar = (() => {
24
28
  document.getElementById("channels-sidebar-item").addEventListener("click", () => Router.navigate("channels"));
25
29
  document.getElementById("mcp-sidebar-item").addEventListener("click", () => Router.navigate("mcp"));
26
30
  document.getElementById("extensions-sidebar-item").addEventListener("click", () => Router.navigate("extensions"));
27
- document.getElementById("trash-sidebar-item").addEventListener("click", () => Router.navigate("trash"));
31
+ document.getElementById("data-management-sidebar-item").addEventListener("click", () => Router.navigate("archive"));
28
32
  document.getElementById("profile-sidebar-item").addEventListener("click", () => Router.navigate("profile"));
29
33
  document.getElementById("billing-sidebar-item").addEventListener("click", () => Router.navigate("billing"));
30
34
 
@@ -0,0 +1,196 @@
1
+ // ── Archive · store — archived chats + their network calls ────────────────
2
+ //
3
+ // Owns the archived-session list (a paged read of GET /api/sessions?archived=1)
4
+ // plus every mutation: archive, unarchive, delete (soft, lands in the recycle
5
+ // bin), and bulk delete-all. It never renders.
6
+ //
7
+ // Emits mirror to the extension bus via Clacky.ext.emit.
8
+ //
9
+ // Depends on: Sessions, Projects, Clacky.ext.
10
+ // ───────────────────────────────────────────────────────────────────────────
11
+
12
+ const ArchiveStore = (() => {
13
+ const PAGE_SIZE = 50; // server caps `limit` at 50
14
+
15
+ let _items = [];
16
+ let _hasMore = false;
17
+ let _cursor = null; // server-issued sort key of the last loaded row
18
+ let _loading = false;
19
+ let _archivedTotal = 0; // whole archive, not just the loaded page
20
+
21
+ const _listeners = {};
22
+
23
+ function _on(event, handler) {
24
+ (_listeners[event] ||= []).push(handler);
25
+ return () => {
26
+ const list = _listeners[event];
27
+ const i = list ? list.indexOf(handler) : -1;
28
+ if (i >= 0) list.splice(i, 1);
29
+ };
30
+ }
31
+
32
+ function _emit(event, payload) {
33
+ (_listeners[event] || []).forEach((h) => h(payload));
34
+ if (window.Clacky && Clacky.ext) Clacky.ext.emit(event, payload);
35
+ }
36
+
37
+ async function _patchArchived(sessionId, archived) {
38
+ const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, {
39
+ method: "PATCH",
40
+ headers: { "Content-Type": "application/json" },
41
+ body: JSON.stringify({ archived }),
42
+ });
43
+ const data = await res.json();
44
+ if (!res.ok || !data.ok) return { ok: false, error: data.error || res.statusText };
45
+ return { ok: true, archived_at: data.archived_at || null };
46
+ }
47
+
48
+ function _removeLocal(sessionId) {
49
+ const before = _items.length;
50
+ _items = _items.filter(s => (s.id || s.session_id) !== sessionId);
51
+ if (_items.length < before && _archivedTotal > 0) _archivedTotal--;
52
+ }
53
+
54
+ const Archive = {
55
+ on: _on,
56
+
57
+ state: {
58
+ get items() { return _items; },
59
+ get hasMore() { return _hasMore; },
60
+ get loading() { return _loading; },
61
+ get count() { return _items.length; },
62
+ // Server-side size of the archive. `count` above is only what has been
63
+ // paged in, so anything that talks about "all archived chats" (the
64
+ // summary line, the delete-all confirmation) has to use this instead.
65
+ get archivedTotal() { return _archivedTotal; },
66
+ },
67
+
68
+ /** Load the first page (reset = true) or append the next one. */
69
+ async load(opts = {}) {
70
+ if (_loading) return;
71
+ _loading = true;
72
+ _emit("archive:loading", { append: opts.reset === false });
73
+
74
+ try {
75
+ const params = new URLSearchParams({ archived: "1", limit: String(PAGE_SIZE) });
76
+ if (!opts.reset && _cursor) {
77
+ params.set("before", _cursor.before);
78
+ params.set("before_id", _cursor.before_id);
79
+ }
80
+
81
+ const res = await fetch(`/api/sessions?${params.toString()}`);
82
+ const data = await res.json();
83
+ if (!res.ok) throw new Error(data.error || "Load failed");
84
+
85
+ const rows = data.sessions || [];
86
+ _items = opts.reset === false ? _items.concat(rows) : rows;
87
+ _hasMore = !!data.has_more;
88
+ _cursor = data.next_cursor || (opts.reset === false ? _cursor : null);
89
+ if (typeof data.archived_count === "number") _archivedTotal = data.archived_count;
90
+ _emit("archive:changed");
91
+ } catch (e) {
92
+ console.error("[Archive] load failed", e);
93
+ _emit("archive:error", { message: e.message });
94
+ } finally {
95
+ _loading = false;
96
+ }
97
+ },
98
+
99
+ /** Archive a live session: it drops out of the sidebar and every list. */
100
+ async archiveSession(session) {
101
+ const id = session && (session.id || session.session_id);
102
+ if (!id) return { ok: false, error: "missing session id" };
103
+ try {
104
+ const res = await _patchArchived(id, true);
105
+ if (!res.ok) return res;
106
+ _archivedTotal++;
107
+ if (typeof Sessions !== "undefined") {
108
+ Sessions.remove(id);
109
+ Sessions.renderList();
110
+ }
111
+ if (typeof Projects !== "undefined") Projects.renderSection();
112
+ Archive.refreshIfShown();
113
+ return { ok: true };
114
+ } catch (e) {
115
+ return { ok: false, error: e.message };
116
+ }
117
+ },
118
+
119
+ /** Bring an archived chat back into the sidebar. */
120
+ async unarchive(session) {
121
+ const id = session && (session.id || session.session_id);
122
+ if (!id) return { ok: false, error: "missing session id" };
123
+ try {
124
+ const res = await _patchArchived(id, false);
125
+ if (!res.ok) return res;
126
+ _removeLocal(id);
127
+ _emit("archive:changed");
128
+ if (typeof Sessions !== "undefined") {
129
+ const row = await fetch(`/api/sessions/${encodeURIComponent(id)}`)
130
+ .then(r => (r.ok ? r.json() : null)).catch(() => null);
131
+ if (row && row.session) Sessions.add(row.session);
132
+ Sessions.renderList();
133
+ }
134
+ if (typeof Projects !== "undefined") Projects.renderSection();
135
+ return { ok: true };
136
+ } catch (e) {
137
+ return { ok: false, error: e.message };
138
+ }
139
+ },
140
+
141
+ /** Delete one archived chat — soft delete, so it lands in the recycle bin. */
142
+ async remove(session) {
143
+ const id = session && (session.id || session.session_id);
144
+ if (!id) return { ok: false, error: "missing session id" };
145
+ try {
146
+ const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE" });
147
+ const data = await res.json();
148
+ if (!res.ok || !data.ok) return { ok: false, error: data.error || res.statusText };
149
+ _removeLocal(id);
150
+ _emit("archive:changed");
151
+ return { ok: true };
152
+ } catch (e) {
153
+ return { ok: false, error: e.message };
154
+ }
155
+ },
156
+
157
+ /**
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.
163
+ */
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
+ }
171
+ try {
172
+ const res = await fetch("/api/archive/sessions/delete", opts);
173
+ const data = await res.json();
174
+ if (!res.ok || !data.ok) return { ok: false, error: data.error || res.statusText };
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
+ }
185
+ _emit("archive:changed");
186
+ return { ok: true, deleted_count: deleted };
187
+ } catch (e) {
188
+ return { ok: false, error: e.message };
189
+ }
190
+ },
191
+ };
192
+
193
+ return Archive;
194
+ })();
195
+
196
+ const Archive = ArchiveStore;
@@ -0,0 +1,409 @@
1
+ // ── Archive · view — archived-chat rendering, filters, DOM wiring ─────────
2
+ //
3
+ // Renders the archive list grouped by project, drives the toolbar (search,
4
+ // project / type filters, delete-all, load more) and all confirm/toast dialogs.
5
+ // Reads through ArchiveStore.state and mutates only via store actions.
6
+ //
7
+ // Augments the `Archive` facade with onPanelShow.
8
+ //
9
+ // Depends on: ArchiveStore, Projects, I18n, Modal.
10
+ // ───────────────────────────────────────────────────────────────────────────
11
+
12
+ const ArchiveView = (() => {
13
+ let _wired = false;
14
+ let _query = "";
15
+ let _filterProject = null; // null = every project (including "no project")
16
+ let _filterType = ""; // "" = every source
17
+
18
+ function $(id) { return document.getElementById(id); }
19
+
20
+ function escapeHtml(s) {
21
+ return String(s ?? "")
22
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;")
23
+ .replace(/>/g, "&gt;").replace(/"/g, "&quot;");
24
+ }
25
+
26
+ function _t(key) {
27
+ return I18n.t ? I18n.t(key) : key;
28
+ }
29
+
30
+ function _humanTime(iso) {
31
+ if (!iso) return "";
32
+ const d = new Date(iso);
33
+ if (isNaN(d.getTime())) return iso;
34
+ const ms = Date.now() - d;
35
+ const mins = Math.floor(ms / 60000);
36
+ const hours = Math.floor(ms / 3600000);
37
+ const days = Math.floor(ms / 86400000);
38
+ if (mins < 1) return I18n.t("time.justNow");
39
+ if (mins < 60) return I18n.t("time.minsAgo", { n: mins });
40
+ if (hours < 24) return I18n.t("time.hoursAgo", { n: hours });
41
+ if (days < 7) return I18n.t("time.daysAgo", { n: days });
42
+ return d.toLocaleDateString();
43
+ }
44
+
45
+ // ── Filtering & grouping ─────────────────────────────────────────────
46
+
47
+ function _visibleItems() {
48
+ const q = _query.trim().toLowerCase();
49
+ return Archive.state.items.filter(s => {
50
+ if (_filterProject !== null && String(s.project_id ?? "") !== _filterProject) return false;
51
+ if (_filterType && (s.source || "manual") !== _filterType) return false;
52
+ if (q) {
53
+ const name = (s.name || "").toLowerCase();
54
+ const id = (s.id || s.session_id || "").toLowerCase();
55
+ if (!name.includes(q) && !id.includes(q)) return false;
56
+ }
57
+ return true;
58
+ });
59
+ }
60
+
61
+ // Projects keep their sidebar identity (name + colour); everything without a
62
+ // project falls into one trailing bucket, which is what the API returns too.
63
+ function _groups(items) {
64
+ const known = (typeof Projects !== "undefined" && Projects.all) ? Projects.all() : [];
65
+ const byId = new Map();
66
+ const order = [];
67
+
68
+ items.forEach(s => {
69
+ const key = String(s.project_id ?? "");
70
+ if (!byId.has(key)) {
71
+ const project = key && known.find(p => String(p.id) === key);
72
+ byId.set(key, {
73
+ key,
74
+ name: project ? (project.name || key) : (key ? key : _t("archive.groupNoProject")),
75
+ color: project ? project.color : null,
76
+ items: [],
77
+ });
78
+ order.push(key);
79
+ }
80
+ byId.get(key).items.push(s);
81
+ });
82
+
83
+ // Named projects first (in sidebar order), "no project" last.
84
+ return order
85
+ .sort((a, b) => (a === "" ? 1 : b === "" ? -1 : 0))
86
+ .map(k => byId.get(k));
87
+ }
88
+
89
+ function _isNarrowed() {
90
+ return _query.trim() !== "" || _filterProject !== null || _filterType !== "";
91
+ }
92
+
93
+ // ── Rendering ────────────────────────────────────────────────────────
94
+
95
+ function _renderSkeleton() {
96
+ const list = $("archive-list");
97
+ if (!list) return;
98
+ list.innerHTML = Array.from({ length: 3 }, () => `
99
+ <div class="trash-session-skeleton">
100
+ <div class="trash-session-skeleton-info">
101
+ <div class="skel" style="width:40%;height:10px"></div>
102
+ <div class="skel" style="width:65%;height:9px"></div>
103
+ </div>
104
+ </div>`).join("");
105
+ }
106
+
107
+ // Appending a page keeps the rows on screen, so the button carries the progress
108
+ // itself — same "Loading…" swap the session list and extensions page use.
109
+ function _setMoreLoading(loading) {
110
+ const btn = $("btn-archive-load-more");
111
+ if (!btn) return;
112
+ btn.disabled = loading;
113
+ btn.textContent = loading ? _t("archive.loadingMore") : _t("archive.loadMore");
114
+ }
115
+
116
+ function _renderError(message) {
117
+ const list = $("archive-list");
118
+ if (!list) return;
119
+ _setMoreLoading(false);
120
+ list.innerHTML = `<div class="archive-empty">${escapeHtml(message)}</div>`;
121
+ }
122
+
123
+ function _buildRow(session) {
124
+ const id = session.id || session.session_id || "";
125
+ const name = session.name || id.slice(0, 8);
126
+ const tasks = I18n.t("trash.sessionTasks", { n: session.total_tasks || 0 });
127
+ const when = _humanTime(session.archived_at || session.updated_at || session.created_at);
128
+
129
+ const row = document.createElement("div");
130
+ row.className = "trash-session-card";
131
+ row.dataset.sessionId = id;
132
+ row.innerHTML = `
133
+ <div class="trash-session-card-info">
134
+ <div class="trash-session-card-name" title="${escapeHtml(name)}">${escapeHtml(name)}</div>
135
+ <div class="trash-session-card-meta">
136
+ ${session.model ? `<span>${escapeHtml(session.model)}</span>` : ""}
137
+ <span>${escapeHtml(tasks)}</span>
138
+ ${when ? `<span title="${escapeHtml(session.archived_at || "")}">${escapeHtml(when)}</span>` : ""}
139
+ </div>
140
+ </div>
141
+ <div class="trash-session-card-actions">
142
+ <button class="btn-archive-unarchive" data-action="unarchive">${escapeHtml(_t("archive.unarchive"))}</button>
143
+ <button class="btn-archive-delete" data-action="delete"
144
+ title="${escapeHtml(_t("archive.deleteOne"))}" aria-label="${escapeHtml(_t("archive.deleteOne"))}">
145
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
146
+ <polyline points="3 6 5 6 21 6"/>
147
+ <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
148
+ <path d="M10 11v6"/><path d="M14 11v6"/>
149
+ </svg>
150
+ </button>
151
+ </div>`;
152
+ return row;
153
+ }
154
+
155
+ function _render() {
156
+ const list = $("archive-list");
157
+ const summary = $("archive-summary");
158
+ const btnAll = $("btn-archive-delete-all");
159
+ const btnMore = $("btn-archive-load-more");
160
+ if (!list) return;
161
+
162
+ _refreshProjectOptions();
163
+
164
+ const items = _visibleItems();
165
+ const narrowed = _isNarrowed();
166
+ if (summary) {
167
+ // With a filter or a search on, the number describes what the toolbar is
168
+ // showing. Without one it describes the whole archive, which can be
169
+ // bigger than the loaded page.
170
+ const n = narrowed ? items.length : (Archive.state.archivedTotal || items.length);
171
+ summary.textContent = n ? I18n.t("archive.summary", { n }) : "";
172
+ }
173
+ if (btnAll) btnAll.disabled = (narrowed ? items.length : Archive.state.archivedTotal) === 0;
174
+ if (btnMore) {
175
+ btnMore.style.display = Archive.state.hasMore ? "" : "none";
176
+ _setMoreLoading(false);
177
+ }
178
+
179
+ if (items.length === 0) {
180
+ list.innerHTML = `<div class="archive-empty">${escapeHtml(
181
+ Archive.state.archivedTotal === 0 ? _t("archive.empty") : _t("archive.noMatch"))}</div>`;
182
+ return;
183
+ }
184
+
185
+ list.innerHTML = "";
186
+ _groups(items).forEach(group => {
187
+ const section = document.createElement("section");
188
+ section.className = "archive-group";
189
+
190
+ const header = document.createElement("div");
191
+ header.className = "archive-group-header";
192
+ header.innerHTML = group.color
193
+ ? `<span class="archive-group-dot" style="background:${escapeHtml(group.color)}"></span>`
194
+ : `<svg class="archive-group-icon" xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
195
+ <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"/>
196
+ </svg>`;
197
+ const label = document.createElement("span");
198
+ label.textContent = group.name;
199
+ const count = document.createElement("span");
200
+ count.className = "archive-group-count";
201
+ count.textContent = String(group.items.length);
202
+ header.append(label, count);
203
+ section.appendChild(header);
204
+
205
+ const wrap = document.createElement("div");
206
+ wrap.className = "archive-group-items";
207
+ group.items.forEach(s => wrap.appendChild(_buildRow(s)));
208
+ section.appendChild(wrap);
209
+
210
+ list.appendChild(section);
211
+ });
212
+ }
213
+
214
+ // ── Project / type filter selects ────────────────────────────────────
215
+
216
+ function _refreshProjectOptions() {
217
+ const wrap = $("archive-filter-project");
218
+ if (!wrap) return;
219
+ const dropdown = wrap.querySelector(".custom-select-dropdown");
220
+ if (!dropdown) return;
221
+ const ctl = dropdown.__customSelect;
222
+
223
+ const projects = (typeof Projects !== "undefined" && Projects.all) ? Projects.all() : [];
224
+ const allLabel = _t("archive.filterAllProjects");
225
+
226
+ dropdown.innerHTML = "";
227
+ const all = document.createElement("div");
228
+ all.className = "custom-select-option";
229
+ all.dataset.value = "";
230
+ all.dataset.label = allLabel;
231
+ all.textContent = allLabel;
232
+ dropdown.appendChild(all);
233
+
234
+ projects.forEach(p => {
235
+ const opt = document.createElement("div");
236
+ opt.className = "custom-select-option";
237
+ opt.dataset.value = p.id;
238
+ opt.textContent = p.name || p.id;
239
+ dropdown.appendChild(opt);
240
+ });
241
+
242
+ if (ctl) {
243
+ const restore = (_filterProject !== null && projects.some(p => String(p.id) === _filterProject))
244
+ ? _filterProject : "";
245
+ ctl.setValue(restore);
246
+ }
247
+ }
248
+
249
+ function _buildTypeOptions() {
250
+ const wrap = $("archive-filter-type");
251
+ if (!wrap) return;
252
+ const dropdown = wrap.querySelector(".custom-select-dropdown");
253
+ if (!dropdown || dropdown.dataset.built === "1") return;
254
+
255
+ const opts = [
256
+ { value: "", label: _t("archive.filterAllTypes") },
257
+ { value: "manual", label: _t("archive.filterRegular") },
258
+ { value: "cron", label: _t("archive.filterCron") },
259
+ { value: "channel", label: _t("archive.filterChannel") },
260
+ ];
261
+ dropdown.innerHTML = "";
262
+ opts.forEach(o => {
263
+ const el = document.createElement("div");
264
+ el.className = "custom-select-option";
265
+ el.dataset.value = o.value;
266
+ el.textContent = o.label;
267
+ dropdown.appendChild(el);
268
+ });
269
+ dropdown.dataset.built = "1";
270
+ }
271
+
272
+ // ── Actions ──────────────────────────────────────────────────────────
273
+
274
+ async function _unarchive(session) {
275
+ const res = await Archive.unarchive(session);
276
+ if (!res.ok) {
277
+ Modal.toast(I18n.t("archive.unarchiveFail", { msg: res.error }), "error");
278
+ return;
279
+ }
280
+ // The row is gone from this page by now, so the toast is the only way back
281
+ // to the chat the user just restored — same shortcut the recycle bin offers.
282
+ const id = session.id || session.session_id;
283
+ Modal.toast(I18n.t("archive.unarchived", { name: session.name || "" }), "success",
284
+ id ? { action: { label: _t("archive.unarchivedAction"), onClick: () => Sessions.select(id) } } : {});
285
+ }
286
+
287
+ async function _deleteOne(session) {
288
+ const name = session.name || (session.id || "").slice(0, 8);
289
+ const confirmed = await Modal.confirm(I18n.t("archive.confirmDelete", { name }));
290
+ if (!confirmed) return;
291
+ const res = await Archive.remove(session);
292
+ if (!res.ok) Modal.toast(I18n.t("archive.deleteFail", { msg: res.error }), "error");
293
+ else Modal.toast(I18n.t("archive.deleted", { name }), "success");
294
+ }
295
+
296
+ async function _deleteAll() {
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");
304
+ return;
305
+ }
306
+ const confirmed = await Modal.confirm(I18n.t("archive.confirmDeleteAll", { count }));
307
+ if (!confirmed) return;
308
+ const res = await Archive.deleteMany(selection);
309
+ if (!res.ok) Modal.toast(I18n.t("archive.deleteFail", { msg: res.error }), "error");
310
+ else Modal.toast(I18n.t("archive.deletedAll", { count: res.deleted_count }), "success");
311
+ }
312
+
313
+ // ── Event wiring ─────────────────────────────────────────────────────
314
+
315
+ function _wire() {
316
+ if (_wired) return;
317
+ _wired = true;
318
+
319
+ const search = $("archive-search");
320
+ if (search) {
321
+ let timer = null;
322
+ search.addEventListener("input", () => {
323
+ clearTimeout(timer);
324
+ timer = setTimeout(() => {
325
+ _query = search.value || "";
326
+ _render();
327
+ }, 150);
328
+ });
329
+ }
330
+
331
+ _buildTypeOptions();
332
+
333
+ const projWrap = $("archive-filter-project");
334
+ if (projWrap && window.CustomSelect) {
335
+ const dropdown = projWrap.querySelector(".custom-select-dropdown");
336
+ if (!dropdown.__customSelect) {
337
+ window.CustomSelect.init({
338
+ trigger: projWrap.querySelector(".custom-select-trigger"),
339
+ dropdown: dropdown,
340
+ onSelect: (value) => {
341
+ _filterProject = value ? String(value) : null;
342
+ _render();
343
+ },
344
+ });
345
+ }
346
+ }
347
+
348
+ const typeWrap = $("archive-filter-type");
349
+ if (typeWrap && window.CustomSelect) {
350
+ const dropdown = typeWrap.querySelector(".custom-select-dropdown");
351
+ if (!dropdown.__customSelect) {
352
+ window.CustomSelect.init({
353
+ trigger: typeWrap.querySelector(".custom-select-trigger"),
354
+ dropdown: dropdown,
355
+ onSelect: (value) => {
356
+ _filterType = value || "";
357
+ _render();
358
+ },
359
+ });
360
+ }
361
+ }
362
+
363
+ const btnAll = $("btn-archive-delete-all");
364
+ if (btnAll) btnAll.addEventListener("click", _deleteAll);
365
+
366
+ const btnMore = $("btn-archive-load-more");
367
+ if (btnMore) btnMore.addEventListener("click", () => Archive.load({ reset: false }));
368
+
369
+ const list = $("archive-list");
370
+ if (list) {
371
+ list.addEventListener("click", (e) => {
372
+ const btn = e.target.closest("button[data-action]");
373
+ if (!btn) return;
374
+ const row = btn.closest("[data-session-id]");
375
+ if (!row) return;
376
+ const session = Archive.state.items.find(s => (s.id || s.session_id) === row.dataset.sessionId);
377
+ if (!session) return;
378
+ if (btn.dataset.action === "unarchive") _unarchive(session);
379
+ if (btn.dataset.action === "delete") _deleteOne(session);
380
+ });
381
+ }
382
+ }
383
+
384
+ function _subscribe() {
385
+ Archive.on("archive:loading", (payload) => {
386
+ if (payload && payload.append) _setMoreLoading(true);
387
+ else _renderSkeleton();
388
+ });
389
+ Archive.on("archive:changed", _render);
390
+ Archive.on("archive:error", (e) => _renderError(e.message));
391
+ }
392
+
393
+ const viewApi = {
394
+ onPanelShow() {
395
+ _wire();
396
+ Archive.load({ reset: true });
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
+ },
403
+ };
404
+
405
+ return { init: _subscribe, api: viewApi };
406
+ })();
407
+
408
+ Object.assign(Archive, ArchiveView.api);
409
+ ArchiveView.init();
@@ -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
 
@@ -111,11 +111,21 @@ const BackupView = (() => {
111
111
  _restoreStatusEl = null;
112
112
  if (el) { el.textContent = ""; el.className = "model-test-result"; }
113
113
  Modal.toast(I18n.t("settings.backup.restartOk"), "success");
114
- if (typeof Settings !== "undefined") Settings.open();
114
+ // The backup UI lives on its own page now — refresh it in place instead of
115
+ // bouncing the user into Settings.
116
+ Backup.load();
115
117
  });
116
118
  }
117
119
 
118
- return { init: _subscribe };
120
+ const viewApi = {
121
+ onPanelShow() {
122
+ _bind();
123
+ Backup.load();
124
+ },
125
+ };
126
+
127
+ return { init: _subscribe, api: viewApi };
119
128
  })();
120
129
 
130
+ Object.assign(Backup, BackupView.api);
121
131
  BackupView.init();