openclacky 1.5.7 → 1.5.9

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/README.md +3 -3
  4. data/README_CN.md +2 -2
  5. data/README_JA.md +2 -2
  6. data/lib/clacky/agent.rb +2 -3
  7. data/lib/clacky/agent_config.rb +40 -18
  8. data/lib/clacky/brand_config.rb +12 -7
  9. data/lib/clacky/cli.rb +0 -1
  10. data/lib/clacky/default_extensions/ext-studio/ext.yml +1 -1
  11. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +27 -14
  12. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +22 -5
  13. data/lib/clacky/default_extensions/general/ext.yml +1 -1
  14. data/lib/clacky/extension/api_loader.rb +4 -11
  15. data/lib/clacky/extension/packager.rb +1 -1
  16. data/lib/clacky/extension/scaffold/templates/hello/test/handler_test.rb.erb +22 -0
  17. data/lib/clacky/extension/verifier.rb +1 -1
  18. data/lib/clacky/media/generator.rb +3 -3
  19. data/lib/clacky/message_format/anthropic.rb +19 -2
  20. data/lib/clacky/message_format/bedrock.rb +8 -3
  21. data/lib/clacky/message_history.rb +1 -1
  22. data/lib/clacky/providers.rb +159 -105
  23. data/lib/clacky/server/channel/adapters/feishu/ws_client.rb +6 -0
  24. data/lib/clacky/server/http_server.rb +67 -79
  25. data/lib/clacky/session_manager.rb +7 -0
  26. data/lib/clacky/skill_loader.rb +4 -0
  27. data/lib/clacky/tools/base.rb +11 -1
  28. data/lib/clacky/tools/edit.rb +28 -0
  29. data/lib/clacky/tools/terminal.rb +5 -0
  30. data/lib/clacky/tools/trash_manager.rb +1 -1
  31. data/lib/clacky/ui2/components/inline_input.rb +1 -0
  32. data/lib/clacky/ui2/components/input_area.rb +1 -0
  33. data/lib/clacky/ui2/components/modal_component.rb +0 -1
  34. data/lib/clacky/ui2/components/todo_area.rb +1 -0
  35. data/lib/clacky/ui2/line_editor.rb +1 -0
  36. data/lib/clacky/ui2/markdown_renderer.rb +5 -1
  37. data/lib/clacky/ui2/ui_controller.rb +1 -0
  38. data/lib/clacky/utils/model_pricing.rb +62 -27
  39. data/lib/clacky/utils/string_matcher.rb +29 -2
  40. data/lib/clacky/version.rb +1 -1
  41. data/lib/clacky/web/app.css +278 -221
  42. data/lib/clacky/web/components/chat-navigator.js +1 -1
  43. data/lib/clacky/web/features/billing/store.js +3 -3
  44. data/lib/clacky/web/features/billing/view.js +5 -61
  45. data/lib/clacky/web/features/extensions/store.js +65 -41
  46. data/lib/clacky/web/features/extensions/view.js +74 -10
  47. data/lib/clacky/web/features/new-session/view.js +24 -0
  48. data/lib/clacky/web/features/version/view.js +2 -2
  49. data/lib/clacky/web/i18n.js +36 -22
  50. data/lib/clacky/web/index.html +17 -17
  51. data/lib/clacky/web/projects.js +83 -285
  52. data/lib/clacky/web/sessions.js +151 -17
  53. data/lib/clacky/web/settings.js +6 -1
  54. metadata +2 -1
@@ -163,7 +163,7 @@
163
163
  popup.style.bottom = "";
164
164
  const popupHeight = popup.offsetHeight;
165
165
  let top = barCenter - popupHeight / 2;
166
- top = Math.max(0, Math.min(top, navRect.height - popupHeight));
166
+ top = Math.max(48, Math.min(top, navRect.height - popupHeight));
167
167
  popup.style.top = top + "px";
168
168
 
169
169
  const target = items[idx].popupItem;
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Owns billing state (summary / daily / sessions / model list / current period
4
4
  // & model filter), currency conversion utilities (localStorage-backed), the
5
- // data fetch, and clear-data actions. It never renders.
5
+ // data fetch, and clear-data actions (retained for API use). It never renders.
6
6
  //
7
7
  // Currency utilities are pure and read by both the view and other modules, so
8
8
  // they live with the data layer. Emits store events the view reacts to; mirrors
@@ -103,8 +103,8 @@ const BillingStore = (() => {
103
103
  if (_summary) _emit("billing:changed");
104
104
  },
105
105
 
106
- setPeriod(period) { _currentPeriod = period; return Billing.load(); },
107
- setModel(model) { _currentModel = model; return Billing.load(); },
106
+ setPeriod(period) { _currentPeriod = period; _emit("billing:changed"); return Billing.load(); },
107
+ setModel(model) { _currentModel = model; _emit("billing:changed"); return Billing.load(); },
108
108
 
109
109
  async load() {
110
110
  const isFirstLoad = !_summary;
@@ -1,8 +1,8 @@
1
1
  // ── Billing · view — dashboard rendering, charts, tooltips, DOM wiring ─────
2
2
  //
3
3
  // Owns every render function (stats, heatmap, trend, breakdowns, sessions),
4
- // tooltip binding, and the clear-data popup. Reads through BillingStore.state
5
- // and currency helpers; period / model / clear / share go through store actions.
4
+ // tooltip binding. Reads through BillingStore.state
5
+ // and currency helpers; period / model / share go through store actions.
6
6
  //
7
7
  // Augments the `Billing` facade with onPanelShow.
8
8
  //
@@ -10,7 +10,6 @@
10
10
  // ───────────────────────────────────────────────────────────────────────────
11
11
 
12
12
  const BillingView = (() => {
13
- let _clearPopupVisible = false;
14
13
 
15
14
  // ── Loading / error ───────────────────────────────────────────────────────
16
15
 
@@ -118,7 +117,7 @@ const BillingView = (() => {
118
117
 
119
118
  const periods = ["day", "week", "month", "year", "all"];
120
119
  const periodBtns = periods.map(p =>
121
- `<button class="billing-period-btn ${p === BillingStore.state.currentPeriod ? 'active' : ''}" data-period="${p}">${_periodLabel(p)}</button>`
120
+ `<button class="segmented-control__btn ${p === BillingStore.state.currentPeriod ? 'active' : ''}" data-period="${p}">${_periodLabel(p)}</button>`
122
121
  ).join("");
123
122
 
124
123
  const allModels = BillingStore.state.allModels;
@@ -135,21 +134,12 @@ const BillingView = (() => {
135
134
  <span class="billing-subtitle">${_getSummaryHint()}</span>
136
135
  </div>
137
136
  <div class="billing-controls">
138
- <div class="billing-period-group">${periodBtns}</div>
137
+ <div class="segmented-control">${periodBtns}</div>
139
138
  <select id="billing-model-filter" class="billing-model-filter">${modelOptions}</select>
140
139
  <button id="billing-share-btn" class="billing-share-btn" title="${I18n.t('billing.share.tooltip') || 'Share scorecard'}">
141
140
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><polyline points="16 6 12 2 8 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><line x1="12" y1="2" x2="12" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
142
141
  ${I18n.t('billing.share.btn') || 'Share scorecard'}
143
142
  </button>
144
- <div class="billing-clear-container">
145
- <button id="billing-clear-btn" class="billing-clear-btn" title="${I18n.t('billing.clearData') || 'Clear Data'}">
146
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><polyline points="3 6 5 6 21 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M10 11v6M14 11v6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
147
- </button>
148
- <div id="billing-clear-popup" class="billing-clear-popup" style="display: none;">
149
- <button id="billing-clear-today" class="billing-clear-option">${I18n.t('billing.clearToday') || 'Clear Today'}</button>
150
- <button id="billing-clear-all" class="billing-clear-option billing-clear-danger">${I18n.t('billing.clearAll') || 'Clear All'}</button>
151
- </div>
152
- </div>
153
143
  </div>
154
144
  </div>
155
145
 
@@ -217,7 +207,7 @@ const BillingView = (() => {
217
207
  </div>
218
208
  `;
219
209
 
220
- document.querySelectorAll(".billing-period-btn").forEach(btn => {
210
+ document.querySelectorAll(".segmented-control__btn").forEach(btn => {
221
211
  btn.addEventListener("click", (e) => Billing.setPeriod(e.target.dataset.period));
222
212
  });
223
213
 
@@ -225,7 +215,6 @@ const BillingView = (() => {
225
215
  Billing.setModel(e.target.value);
226
216
  });
227
217
 
228
- _bindClearHandlers();
229
218
  document.getElementById("billing-share-btn")?.addEventListener("click", _openScorecardShare);
230
219
 
231
220
  _bindChartTooltip();
@@ -416,50 +405,6 @@ const BillingView = (() => {
416
405
  });
417
406
  }
418
407
 
419
- // ── Clear popup ────────────────────────────────────────────────────────────
420
-
421
- function _bindClearHandlers() {
422
- const clearBtn = document.getElementById("billing-clear-btn");
423
- const clearPopup = document.getElementById("billing-clear-popup");
424
- const clearToday = document.getElementById("billing-clear-today");
425
- const clearAll = document.getElementById("billing-clear-all");
426
-
427
- if (!clearBtn || !clearPopup) return;
428
-
429
- clearBtn.addEventListener("click", (e) => {
430
- e.stopPropagation();
431
- _clearPopupVisible = !_clearPopupVisible;
432
- clearPopup.style.display = _clearPopupVisible ? "flex" : "none";
433
- });
434
-
435
- clearToday?.addEventListener("click", (e) => {
436
- e.stopPropagation();
437
- _hideClearPopup();
438
- Billing.clearData("today");
439
- });
440
-
441
- clearAll?.addEventListener("click", (e) => {
442
- e.stopPropagation();
443
- _hideClearPopup();
444
- Billing.clearData("all");
445
- });
446
-
447
- document.addEventListener("click", _closeClearPopup);
448
- }
449
-
450
- function _hideClearPopup() {
451
- const clearPopup = document.getElementById("billing-clear-popup");
452
- if (clearPopup) clearPopup.style.display = "none";
453
- _clearPopupVisible = false;
454
- }
455
-
456
- function _closeClearPopup(e) {
457
- const clearPopup = document.getElementById("billing-clear-popup");
458
- const clearBtn = document.getElementById("billing-clear-btn");
459
- if (!clearPopup || !clearBtn) return;
460
- if (!clearPopup.contains(e.target) && !clearBtn.contains(e.target)) _hideClearPopup();
461
- }
462
-
463
408
  // ── Section renderers ────────────────────────────────────────────────────────
464
409
 
465
410
  function _getSummaryHint() {
@@ -678,7 +623,6 @@ const BillingView = (() => {
678
623
  <div class="billing-chart-card billing-trend-card">
679
624
  <div class="billing-chart-header">
680
625
  <h4>${I18n.t("billing.costTrend") || "Cost Trend"}</h4>
681
- <span class="billing-trend-total">${currencySymbol}${_formatCost(costs.reduce((a, b) => a + b, 0))}</span>
682
626
  </div>
683
627
  <div class="billing-trend-chart">
684
628
  <svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid meet" class="billing-trend-svg">
@@ -32,7 +32,11 @@ const ExtensionsStore = (() => {
32
32
  let _filterSystem = false; // when true, show system (builtin) extensions
33
33
  let _filterMarket = true; // when true, show marketplace extensions
34
34
  let _onlyInstalled = (function() { try { return localStorage.getItem(EXT_INSTALLED_KEY) === "installed"; } catch (_) { return false; } })();
35
+ const PAGE_SIZE = 20; // requested page size, sent to the platform as per_page
36
+ let _page = 1; // current marketplace page (1-based)
37
+ let _hasMore = false; // whether the marketplace has another page
35
38
  let _loading = false;
39
+ let _loadingMore = false;
36
40
  let _error = null; // soft warning when the store is unreachable
37
41
  let _detail = null; // currently opened extension detail, or null
38
42
  let _detailLoading = false;
@@ -69,6 +73,8 @@ const ExtensionsStore = (() => {
69
73
  get filterMarket() { return _filterMarket; },
70
74
  get onlyInstalled() { return _onlyInstalled; },
71
75
  get loading() { return _loading; },
76
+ get loadingMore(){ return _loadingMore; },
77
+ get hasMore() { return _hasMore; },
72
78
  get error() { return _error; },
73
79
  get detail() { return _detail; },
74
80
  get detailLoading() { return _detailLoading; },
@@ -86,31 +92,7 @@ const ExtensionsStore = (() => {
86
92
  if (_filterSystem) return Extensions.loadSystemExtensions();
87
93
  if (_filterInstalled) return;
88
94
  if (_filterBrand) return Extensions.loadBrandExtensions();
89
- if (_filterMarket) return Extensions.loadMarketExtensions();
90
- _loading = true;
91
- _error = null;
92
- _emit("extensions:loading");
93
- try {
94
- const params = new URLSearchParams();
95
- if (_query) params.set("q", _query);
96
- if (_sort) params.set("sort", _sort);
97
- const qs = params.toString();
98
- const res = await fetch("/api/store/extensions" + (qs ? "?" + qs : ""));
99
- const data = await res.json();
100
- _allExtensions = data.extensions || [];
101
- _error = data.warning || null;
102
- _loading = false;
103
- _extensions = _allExtensions;
104
- _emit("extensions:changed", { extensions: _extensions, warning: _error });
105
- } catch (e) {
106
- console.error("[Extensions] load failed", e);
107
- _extensions = [];
108
- _error = I18n.t("extensions.loadFailed");
109
- _loading = false;
110
- _emit("extensions:error", { network: true });
111
- } finally {
112
- _loading = false;
113
- }
95
+ return Extensions.loadMarketExtensions();
114
96
  },
115
97
 
116
98
  /** Load builtin system extensions from /api/store/extensions/system. */
@@ -140,31 +122,61 @@ const ExtensionsStore = (() => {
140
122
  }
141
123
  },
142
124
 
143
- /** Load marketplace extensions (all, not filtered by installed). */
125
+ /** Load marketplace extensions (all, not filtered by installed) — first page. */
144
126
  async loadMarketExtensions() {
145
- _loading = true;
146
- _error = null;
147
- _emit("extensions:loading");
127
+ _page = 1;
128
+ return Extensions._loadMarketPage(true);
129
+ },
130
+
131
+ /** Load the next marketplace page and append it to the current list. */
132
+ async loadMoreMarketExtensions() {
133
+ if (_loading || _loadingMore || !_hasMore) return;
134
+ return Extensions._loadMarketPage(false);
135
+ },
136
+
137
+ async _loadMarketPage(reset) {
138
+ const page = reset ? 1 : _page + 1;
139
+ if (reset) {
140
+ _loading = true;
141
+ _error = null;
142
+ _emit("extensions:loading");
143
+ } else {
144
+ _loadingMore = true;
145
+ _emit("extensions:loadingMore");
146
+ }
148
147
  try {
149
148
  const params = new URLSearchParams();
150
149
  if (_query) params.set("q", _query);
151
150
  if (_sort) params.set("sort", _sort);
151
+ if (page > 1) params.set("page", String(page));
152
+ params.set("per_page", String(PAGE_SIZE));
152
153
  const qs = params.toString();
153
154
  const res = await fetch("/api/store/extensions" + (qs ? "?" + qs : ""));
154
155
  const data = await res.json();
155
- _allExtensions = data.extensions || [];
156
- _error = data.warning || null;
157
- _loading = false;
158
- _extensions = _onlyInstalled ? _allExtensions.filter(e => e.installed) : _allExtensions;
159
- _emit("extensions:changed", { extensions: _extensions, warning: _error });
156
+ const incoming = data.extensions || [];
157
+ _error = data.warning || null;
158
+
159
+ if (reset) {
160
+ _allExtensions = incoming;
161
+ _extensions = _onlyInstalled ? incoming.filter(e => e.installed) : incoming;
162
+ } else {
163
+ _allExtensions = _allExtensions.concat(incoming);
164
+ const incomingFiltered = _onlyInstalled ? incoming.filter(e => e.installed) : incoming;
165
+ _extensions = _extensions.concat(incomingFiltered);
166
+ }
167
+
168
+ _page = page;
169
+ _hasMore = _computeHasMore(data.meta, incoming);
160
170
  } catch (e) {
161
171
  console.error("[Extensions] loadMarketExtensions failed", e);
162
- _extensions = [];
163
- _error = I18n.t("extensions.loadFailed");
164
- _loading = false;
165
- _emit("extensions:error", { network: true });
172
+ if (reset) {
173
+ _extensions = [];
174
+ _error = I18n.t("extensions.loadFailed");
175
+ }
166
176
  } finally {
167
- _loading = false;
177
+ _loading = false;
178
+ _loadingMore = false;
179
+ _emit("extensions:changed", { extensions: _extensions, warning: _error, reset: reset });
168
180
  }
169
181
  },
170
182
 
@@ -191,7 +203,8 @@ const ExtensionsStore = (() => {
191
203
  try { localStorage.setItem(EXT_INSTALLED_KEY, _onlyInstalled ? "installed" : "all"); } catch (_) {}
192
204
  if (_filterSystem) return Extensions.loadSystemExtensions();
193
205
  if (_filterBrand) return Extensions.loadBrandExtensions();
194
- return Extensions.loadMarketExtensions();
206
+ _extensions = _onlyInstalled ? _allExtensions.filter(e => e.installed) : _allExtensions;
207
+ _emit("extensions:changed", { extensions: _extensions, warning: _error, reset: true });
195
208
  },
196
209
 
197
210
  /** Switch to the system (builtin) extensions tab. */
@@ -362,7 +375,7 @@ const ExtensionsStore = (() => {
362
375
  });
363
376
  const data = await res.json();
364
377
  if (!res.ok || !data.ok) throw new Error(data.error || "toggle failed");
365
- const activeTab = _filterSystem ? "system" : _filterBrand ? "brand" : "market";
378
+ const activeTab = _filterSystem ? "system" : _filterBrand ? "brand" : _filterInstalled ? "installed" : "market";
366
379
  try { sessionStorage.setItem(EXT_TAB_KEY, activeTab); } catch (_) {}
367
380
  location.reload();
368
381
  } catch (e) {
@@ -453,6 +466,17 @@ const ExtensionsStore = (() => {
453
466
 
454
467
  };
455
468
 
469
+ // Whether another marketplace page exists. Prefers the platform's pagination
470
+ // meta; falls back to "a full page came back" for older platform builds that
471
+ // don't return meta yet.
472
+ function _computeHasMore(meta, incoming) {
473
+ if (meta && meta.total_pages != null) {
474
+ const current = meta.current_page != null ? Number(meta.current_page) : _page;
475
+ return current < Number(meta.total_pages);
476
+ }
477
+ return (incoming || []).length >= PAGE_SIZE;
478
+ }
479
+
456
480
  // Poll /api/store/extension/install/status until done, updating _installJob each tick.
457
481
  // `token` is the _pollToken snapshot taken when the poll started; if the
458
482
  // user closes the detail panel, closeDetail() bumps _pollToken and this
@@ -14,10 +14,12 @@
14
14
  const ExtensionsView = (() => {
15
15
  let _domWired = false;
16
16
  let _searchTimer = null;
17
+ let _renderedCount = 0; // cards already appended to the list DOM
17
18
 
18
19
  function _renderLoading() {
19
20
  const container = $("extensions-list");
20
21
  if (!container) return;
22
+ _renderedCount = 0;
21
23
  container.innerHTML = Array.from({ length: 4 }).map(() => `
22
24
  <div class="extension-card">
23
25
  <div class="extension-card-main">
@@ -33,25 +35,63 @@ const ExtensionsView = (() => {
33
35
  function _renderEmpty() {
34
36
  const container = $("extensions-list");
35
37
  if (!container) return;
38
+ _renderedCount = 0;
36
39
  const key = ExtensionsStore.state.query ? "extensions.noResults" : "extensions.empty";
37
40
  container.innerHTML = `<div class="extensions-empty">${escapeHtml(I18n.t(key))}</div>`;
38
41
  }
39
42
 
40
- function _renderList() {
43
+ function _renderList(payload) {
41
44
  const container = $("extensions-list");
42
45
  if (!container) return;
43
46
 
44
- if (ExtensionsStore.state.loading) { _renderLoading(); return; }
47
+ const st = ExtensionsStore.state;
48
+ const reset = !payload || payload.reset !== false;
49
+
50
+ if (st.loading && reset) { _renderLoading(); return; }
45
51
 
46
- const list = ExtensionsStore.state.extensions;
52
+ const list = st.extensions;
47
53
  if (!list || list.length === 0) { _renderEmpty(); return; }
48
54
 
49
- container.innerHTML = "";
50
- list.forEach(ext => container.appendChild(_renderCard(ext)));
55
+ if (reset) {
56
+ container.innerHTML = "";
57
+ _renderedCount = 0;
58
+ }
59
+
60
+ for (let i = _renderedCount; i < list.length; i++) {
61
+ container.appendChild(_renderCard(list[i]));
62
+ }
63
+ _renderedCount = list.length;
64
+
65
+ _renderLoadMore(container, st.hasMore, st.loadingMore);
51
66
 
52
67
  _applyWarning(ExtensionsStore.state.error);
53
68
  }
54
69
 
70
+ function _renderLoadMore(container, hasMore, loadingMore) {
71
+ const existing = container.querySelector(".extensions-load-more");
72
+ if (existing) existing.remove();
73
+ if (!hasMore) return;
74
+ // Only the marketplace tab is paginated; the other tabs are full loads.
75
+ if (!ExtensionsStore.state.filterMarket) return;
76
+
77
+ const btn = document.createElement("button");
78
+ btn.type = "button";
79
+ btn.className = "extensions-load-more";
80
+ btn.textContent = loadingMore ? I18n.t("extensions.loadingMore") : I18n.t("extensions.loadMore");
81
+ btn.disabled = loadingMore;
82
+ btn.addEventListener("click", () => Extensions.loadMoreMarketExtensions());
83
+ container.appendChild(btn);
84
+ }
85
+
86
+ function _renderLoadingMore() {
87
+ const container = $("extensions-list");
88
+ const btn = container && container.querySelector(".extensions-load-more");
89
+ if (btn) {
90
+ btn.disabled = true;
91
+ btn.textContent = I18n.t("extensions.loadingMore");
92
+ }
93
+ }
94
+
55
95
  function _applyWarning(warning) {
56
96
  const banner = $("extensions-warning");
57
97
  if (!banner) return;
@@ -108,6 +148,8 @@ const ExtensionsView = (() => {
108
148
  ? `<span class="extension-updatable">${escapeHtml(I18n.t("extensions.updatable"))}</span>` : "";
109
149
  const unlistedHtml = (ext.unlisted && ext.installed)
110
150
  ? `<span class="extension-unlisted">${escapeHtml(I18n.t("extensions.unlisted"))}</span>` : "";
151
+ const verifiedHtml = (ext.verified && !ext.installed)
152
+ ? `<span class="extension-verified">${escapeHtml(I18n.t("extensions.verified"))}</span>` : "";
111
153
  const unitsText = _formatUnits(ext.units);
112
154
  const unitsHtml = unitsText
113
155
  ? `<span class="extension-units">${escapeHtml(unitsText)}</span>` : "";
@@ -137,6 +179,7 @@ const ExtensionsView = (() => {
137
179
  ${installedHtml}
138
180
  ${updatableHtml}
139
181
  ${unlistedHtml}
182
+ ${verifiedHtml}
140
183
  ${unitsHtml}
141
184
  ${authorHtml}
142
185
  ${installsHtml}
@@ -160,6 +203,7 @@ const ExtensionsView = (() => {
160
203
  } else {
161
204
  card.addEventListener("click", (e) => {
162
205
  if (e.target.closest("a")) return;
206
+ _saveCurrentTab();
163
207
  const router = window.Clacky && window.Clacky.Router;
164
208
  const isBrand = card.dataset.extOrigin === "self" || Extensions.state.filterBrand;
165
209
  const source = isBrand ? "brand" : null;
@@ -181,6 +225,17 @@ const ExtensionsView = (() => {
181
225
  </button>`;
182
226
  }
183
227
 
228
+ function _saveCurrentTab() {
229
+ try {
230
+ var s = ExtensionsStore.state;
231
+ var tab = "market";
232
+ if (s.filterSystem) tab = "system";
233
+ else if (s.filterBrand) tab = "brand";
234
+ else if (s.filterInstalled) tab = "installed";
235
+ sessionStorage.setItem(EXT_TAB_KEY, tab);
236
+ } catch (_) {}
237
+ }
238
+
184
239
  // Returns a human-readable button label for a given install job state.
185
240
  function _installJobLabel(job) {
186
241
  if (!job) return I18n.t("extensions.action.install");
@@ -300,13 +355,14 @@ const ExtensionsView = (() => {
300
355
 
301
356
  const installBtn = document.querySelector("[data-ext-install]");
302
357
  if (installBtn) {
303
- installBtn.addEventListener("click", () => {
358
+ installBtn.addEventListener("click", async () => {
304
359
  const id = installBtn.getAttribute("data-ext-install");
360
+ const ext = ExtensionsStore.state.detail;
361
+ if (ext && !ext.verified && !(typeof Brand !== "undefined" && Brand.branded)) {
362
+ const confirmed = await Modal.confirm(I18n.t("extensions.unverifiedInstallWarning"));
363
+ if (!confirmed) return;
364
+ }
305
365
  installBtn.disabled = true;
306
- // Show a "downloading" placeholder rather than a generic "installing" —
307
- // the next real state is always the download starting, so this avoids
308
- // a jarring "installing → downloading → installing" flash before any
309
- // real progress data has arrived.
310
366
  installBtn.textContent = _installJobLabel({ stage: "downloading", progress: null });
311
367
  Extensions.install(id);
312
368
  });
@@ -350,6 +406,10 @@ const ExtensionsView = (() => {
350
406
  ? `<span class="extension-installed">${installedLabel}</span>` : "";
351
407
  const unlistedHtml = ext.unlisted
352
408
  ? `<span class="extension-unlisted">${escapeHtml(I18n.t("extensions.unlisted"))}</span>` : "";
409
+ const verifiedHtml = ext.verified
410
+ ? `<span class="extension-verified">${escapeHtml(I18n.t("extensions.verified"))}</span>` : "";
411
+ const unverifiedBannerHtml = (!ext.verified && !ext.installed && !(typeof Brand !== "undefined" && Brand.branded))
412
+ ? `<div class="extension-unverified-banner"><svg class="extension-unverified-icon" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="7" cy="7" r="6.25" stroke="currentColor" stroke-width="1.2"/><path d="M7 4V7.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><circle cx="7" cy="10" r="0.7" fill="currentColor"/></svg>${escapeHtml(I18n.t("extensions.unverifiedBanner"))}</div>` : "";
353
413
  const unitsText = _formatUnits(ext.units);
354
414
  const unitsHtml = unitsText
355
415
  ? `<span class="extension-units">${escapeHtml(unitsText)}</span>` : "";
@@ -370,12 +430,14 @@ const ExtensionsView = (() => {
370
430
  ${versionHtml}
371
431
  ${installedHtml}
372
432
  ${unlistedHtml}
433
+ ${verifiedHtml}
373
434
  ${unitsHtml}
374
435
  ${authorHtml}
375
436
  ${installsHtml}
376
437
  </div>
377
438
  ${description ? `<div class="extension-desc extension-desc-detail">${escapeHtml(description)}</div>` : ""}
378
439
  ${homepageHtml ? `<div class="extension-meta">${homepageHtml}</div>` : ""}
440
+ ${unverifiedBannerHtml}
379
441
  ${_renderActions(ext)}
380
442
  </div>
381
443
  </div>
@@ -584,6 +646,7 @@ const ExtensionsView = (() => {
584
646
  if (e.target.closest("a")) return;
585
647
  const card = e.target.closest(".extension-card-clickable");
586
648
  if (card && card.dataset.extId) {
649
+ _saveCurrentTab();
587
650
  const router = window.Clacky && window.Clacky.Router;
588
651
  // Use origin stored on the card; origin="self" means brand-private extension.
589
652
  const isBrand = card.dataset.extOrigin === "self" || Extensions.state.filterBrand;
@@ -605,6 +668,7 @@ const ExtensionsView = (() => {
605
668
 
606
669
  function _subscribe() {
607
670
  Extensions.on("extensions:loading", _renderLoading);
671
+ Extensions.on("extensions:loadingMore", _renderLoadingMore);
608
672
  Extensions.on("extensions:changed", _renderList);
609
673
  Extensions.on("extensions:error", _renderList);
610
674
  Extensions.on("extensions:detail", _renderDetail);
@@ -620,6 +620,27 @@ const NewSessionView = (() => {
620
620
  _renderContextBar();
621
621
  });
622
622
  pop.appendChild(clearItem);
623
+
624
+ // "Create new project" action
625
+ const actionSep = document.createElement("div");
626
+ actionSep.style.cssText = "height:1px;background:var(--color-border-primary);margin:4px 8px";
627
+ pop.appendChild(actionSep);
628
+ const createItem = document.createElement("button");
629
+ createItem.type = "button";
630
+ createItem.className = "ns-ctx-popover-item";
631
+ createItem.style.cssText = "display:flex;align-items:center;gap:6px";
632
+ const plusIcon = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><path d="M12 5v14M5 12h14"/></svg>';
633
+ createItem.innerHTML = plusIcon + `<span>${zh ? "新建项目" : "New project"}</span>`;
634
+ createItem.addEventListener("click", async () => {
635
+ _dismissCtxPopover();
636
+ const project = await Projects.promptCreate();
637
+ if (project) {
638
+ const dir = project.working_dir || NewSessionStore.state.defaultDir || "";
639
+ NewSessionStore.updateAdvanced({ projectId: project.id, workingDir: dir });
640
+ _renderContextBar();
641
+ }
642
+ });
643
+ pop.appendChild(createItem);
623
644
  });
624
645
  }
625
646
 
@@ -786,6 +807,9 @@ const NewSessionView = (() => {
786
807
  const display = hasFiles ? _buildBubbleHtml(finalContent) : null;
787
808
 
788
809
  Sessions.add(session);
810
+ if (session.project_id && typeof Projects !== "undefined" && Projects.expand) {
811
+ Projects.expand(session.project_id);
812
+ }
789
813
  Sessions.setPendingMessage(session.id, finalContent, display, files);
790
814
  input.value = "";
791
815
  _pendingImages.length = 0;
@@ -41,6 +41,7 @@ const VersionView = (() => {
41
41
  if (!badge || !text) return;
42
42
 
43
43
  text.textContent = s.current ? `v${s.current}` : "";
44
+ text.href = "https://github.com/clacky-ai/openclacky/releases";
44
45
 
45
46
  if (dot) dot.style.display = "none";
46
47
  if (restartDot) restartDot.style.display = "none";
@@ -142,11 +143,10 @@ const VersionView = (() => {
142
143
  <p class="vup-versions">v${s.current} <span class="vup-arrow">→</span> v${s.latest}</p>
143
144
  <div class="vup-actions">
144
145
  <button id="vup-btn-upgrade" class="vup-btn-primary">${I18n.t("upgrade.btn.upgrade")}</button>
145
- <button id="vup-btn-cancel" class="vup-btn-cancel">${I18n.t("upgrade.btn.cancel")}</button>
146
+ ${(typeof Brand !== "undefined" && Brand.branded) ? "" : `<a id="vup-btn-changelog" class="vup-btn-changelog" href="https://github.com/clacky-ai/openclacky/releases" target="_blank" rel="noopener noreferrer">${I18n.t("upgrade.btn.changelog")}</a>`}
146
147
  </div>
147
148
  `;
148
149
  $("vup-btn-upgrade").addEventListener("click", () => Version.startUpgrade());
149
- $("vup-btn-cancel").addEventListener("click", _closePopover);
150
150
  }
151
151
 
152
152
  function _renderProgressState(pop, s) {