openclacky 1.5.3 → 1.5.5

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +13 -0
  3. data/CHANGELOG.md +57 -0
  4. data/Dockerfile +21 -1
  5. data/README.md +26 -7
  6. data/README_CN.md +26 -7
  7. data/README_JA.md +26 -7
  8. data/lib/clacky/agent/message_compressor.rb +4 -29
  9. data/lib/clacky/agent/message_compressor_helper.rb +17 -16
  10. data/lib/clacky/agent/session_serializer.rb +7 -0
  11. data/lib/clacky/agent.rb +14 -2
  12. data/lib/clacky/agent_config.rb +10 -5
  13. data/lib/clacky/agent_profile.rb +25 -3
  14. data/lib/clacky/brand_config.rb +32 -12
  15. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +15 -0
  16. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +1 -5
  17. data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -2
  18. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +43 -34
  19. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +9 -3
  20. data/lib/clacky/default_extensions/git/ext.yml +3 -0
  21. data/lib/clacky/default_extensions/meeting/ext.yml +3 -0
  22. data/lib/clacky/default_extensions/time_machine/ext.yml +4 -0
  23. data/lib/clacky/default_parsers/xlsx_parser.py +66 -0
  24. data/lib/clacky/default_skills/new/SKILL.md +17 -12
  25. data/lib/clacky/extension/api_extension.rb +5 -2
  26. data/lib/clacky/extension/loader.rb +1 -0
  27. data/lib/clacky/extension/packager.rb +60 -15
  28. data/lib/clacky/extension/verifier.rb +1 -1
  29. data/lib/clacky/message_format/anthropic.rb +1 -1
  30. data/lib/clacky/message_format/bedrock.rb +1 -1
  31. data/lib/clacky/message_format/open_ai.rb +34 -3
  32. data/lib/clacky/providers.rb +77 -8
  33. data/lib/clacky/server/http_server.rb +495 -39
  34. data/lib/clacky/server/project_manager.rb +150 -0
  35. data/lib/clacky/server/server_master.rb +7 -0
  36. data/lib/clacky/server/session_registry.rb +14 -1
  37. data/lib/clacky/server/web_ui_controller.rb +12 -2
  38. data/lib/clacky/session_manager.rb +6 -4
  39. data/lib/clacky/tools/file_reader.rb +2 -1
  40. data/lib/clacky/tools/terminal.rb +30 -3
  41. data/lib/clacky/utils/model_pricing.rb +69 -18
  42. data/lib/clacky/utils/parser_manager.rb +115 -7
  43. data/lib/clacky/version.rb +1 -1
  44. data/lib/clacky/web/app.css +1216 -318
  45. data/lib/clacky/web/app.js +27 -4
  46. data/lib/clacky/web/components/notify.js +139 -22
  47. data/lib/clacky/web/core/aside.js +21 -0
  48. data/lib/clacky/web/features/brand/store.js +3 -1
  49. data/lib/clacky/web/features/brand/view.js +29 -5
  50. data/lib/clacky/web/features/extensions/store.js +214 -9
  51. data/lib/clacky/web/features/extensions/view.js +156 -11
  52. data/lib/clacky/web/features/new-session/store.js +25 -3
  53. data/lib/clacky/web/features/new-session/view.js +339 -142
  54. data/lib/clacky/web/features/skills/view.js +1 -1
  55. data/lib/clacky/web/features/trash/store.js +23 -9
  56. data/lib/clacky/web/features/trash/view.js +51 -0
  57. data/lib/clacky/web/i18n.js +124 -15
  58. data/lib/clacky/web/index.html +105 -79
  59. data/lib/clacky/web/projects.js +1143 -0
  60. data/lib/clacky/web/sessions.js +320 -538
  61. data/lib/clacky/web/settings.js +138 -5
  62. data/lib/clacky/web/utils.js +1 -0
  63. data/lib/clacky/web/ws-dispatcher.js +98 -4
  64. data/scripts/build/src/install_system_deps.sh.cc +11 -3
  65. data/scripts/install_system_deps.sh +11 -3
  66. metadata +5 -2
  67. data/lib/clacky/default_parsers/xlsx_parser.rb +0 -121
@@ -17,19 +17,29 @@
17
17
  // Depends on: I18n, global $ / escapeHtml helpers, Clacky.ext (core/ext.js)
18
18
  // ───────────────────────────────────────────────────────────────────────────
19
19
 
20
+ const EXT_TAB_KEY = "clacky-ext-tab";
21
+ const EXT_SORT_KEY = "clacky-ext-sort";
22
+ const EXT_INSTALLED_KEY = "clacky-ext-installed-filter";
23
+
20
24
  const ExtensionsStore = (() => {
21
25
  // ── State (single source of truth) ─────────────────────────────────────
22
26
  let _extensions = []; // [{ id, name, name_zh, description, ..., units }]
23
27
  let _allExtensions = []; // unfiltered result from server
24
28
  let _query = ""; // current search text
25
- let _sort = "downloads"; // "newest" | "updated" | "downloads"
29
+ let _sort = (function() { try { return localStorage.getItem(EXT_SORT_KEY) || "downloads"; } catch (_) { return "downloads"; } })();
26
30
  let _filterInstalled = false; // when true, show only installed extensions
27
31
  let _filterBrand = false; // when true, show only brand-private extensions
32
+ let _filterSystem = false; // when true, show system (builtin) extensions
33
+ let _filterMarket = true; // when true, show marketplace extensions
34
+ let _onlyInstalled = (function() { try { return localStorage.getItem(EXT_INSTALLED_KEY) === "installed"; } catch (_) { return false; } })();
28
35
  let _loading = false;
29
36
  let _error = null; // soft warning when the store is unreachable
30
37
  let _detail = null; // currently opened extension detail, or null
31
38
  let _detailLoading = false;
32
39
  let _detailError = null;
40
+ let _installJob = null; // { stage, progress } while install is running, null otherwise
41
+ let _installError = null; // error message from a failed install attempt
42
+ let _pollToken = 0; // bumped by closeDetail() to abandon an in-flight poll loop
33
43
 
34
44
  // ── Internal event bus ──────────────────────────────────────────────────
35
45
  const _listeners = {}; // event => [handler]
@@ -55,11 +65,16 @@ const ExtensionsStore = (() => {
55
65
  get sort() { return _sort; },
56
66
  get filterInstalled() { return _filterInstalled; },
57
67
  get filterBrand() { return _filterBrand; },
68
+ get filterSystem() { return _filterSystem; },
69
+ get filterMarket() { return _filterMarket; },
70
+ get onlyInstalled() { return _onlyInstalled; },
58
71
  get loading() { return _loading; },
59
72
  get error() { return _error; },
60
73
  get detail() { return _detail; },
61
74
  get detailLoading() { return _detailLoading; },
62
75
  get detailError() { return _detailError; },
76
+ get installJob() { return _installJob; },
77
+ get installError() { return _installError; },
63
78
  };
64
79
 
65
80
  const Extensions = {
@@ -68,8 +83,10 @@ const ExtensionsStore = (() => {
68
83
 
69
84
  /** Fetch the catalog from the server for the current query + sort. */
70
85
  async load() {
86
+ if (_filterSystem) return Extensions.loadSystemExtensions();
71
87
  if (_filterInstalled) return;
72
88
  if (_filterBrand) return Extensions.loadBrandExtensions();
89
+ if (_filterMarket) return Extensions.loadMarketExtensions();
73
90
  _loading = true;
74
91
  _error = null;
75
92
  _emit("extensions:loading");
@@ -96,26 +113,111 @@ const ExtensionsStore = (() => {
96
113
  }
97
114
  },
98
115
 
116
+ /** Load builtin system extensions from /api/store/extensions/system. */
117
+ async loadSystemExtensions() {
118
+ _loading = true;
119
+ _error = null;
120
+ _emit("extensions:loading");
121
+ try {
122
+ const res = await fetch("/api/store/extensions/system");
123
+ const data = await res.json();
124
+ const all = data.extensions || [];
125
+ _extensions = _query
126
+ ? all.filter(e => [e.name, e.display_name, e.display_name_zh, e.description].some(
127
+ f => f && f.toLowerCase().includes(_query.toLowerCase())))
128
+ : all;
129
+ _error = null;
130
+ _loading = false;
131
+ _emit("extensions:changed", { extensions: _extensions, warning: _error });
132
+ } catch (e) {
133
+ console.error("[Extensions] loadSystemExtensions failed", e);
134
+ _extensions = [];
135
+ _error = I18n.t("extensions.loadFailed");
136
+ _loading = false;
137
+ _emit("extensions:error", { network: true });
138
+ } finally {
139
+ _loading = false;
140
+ }
141
+ },
142
+
143
+ /** Load marketplace extensions (all, not filtered by installed). */
144
+ async loadMarketExtensions() {
145
+ _loading = true;
146
+ _error = null;
147
+ _emit("extensions:loading");
148
+ try {
149
+ const params = new URLSearchParams();
150
+ if (_query) params.set("q", _query);
151
+ if (_sort) params.set("sort", _sort);
152
+ const qs = params.toString();
153
+ const res = await fetch("/api/store/extensions" + (qs ? "?" + qs : ""));
154
+ 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 });
160
+ } catch (e) {
161
+ console.error("[Extensions] loadMarketExtensions failed", e);
162
+ _extensions = [];
163
+ _error = I18n.t("extensions.loadFailed");
164
+ _loading = false;
165
+ _emit("extensions:error", { network: true });
166
+ } finally {
167
+ _loading = false;
168
+ }
169
+ },
170
+
99
171
  /** Set the search text and reload. */
100
172
  setQuery(query) {
101
173
  _query = (query || "").trim();
102
- if (_filterInstalled) return Extensions.setFilterInstalled(true);
174
+ if (_filterSystem) return Extensions.loadSystemExtensions();
103
175
  if (_filterBrand) return Extensions.loadBrandExtensions();
104
- return Extensions.load();
176
+ return Extensions.loadMarketExtensions();
105
177
  },
106
178
 
107
179
  /** Set the sort order and reload. */
108
180
  setSort(sort) {
109
181
  _sort = sort || "downloads";
182
+ try { localStorage.setItem(EXT_SORT_KEY, _sort); } catch (_) {}
183
+ if (_filterSystem) return Extensions.loadSystemExtensions();
184
+ if (_filterBrand) return Extensions.loadBrandExtensions();
185
+ return Extensions.loadMarketExtensions();
186
+ },
187
+
188
+ /** Toggle the global "only installed" overlay and reload the current tab. */
189
+ async setOnlyInstalled(only) {
190
+ _onlyInstalled = !!only;
191
+ try { localStorage.setItem(EXT_INSTALLED_KEY, _onlyInstalled ? "installed" : "all"); } catch (_) {}
192
+ if (_filterSystem) return Extensions.loadSystemExtensions();
110
193
  if (_filterBrand) return Extensions.loadBrandExtensions();
111
- if (_filterInstalled) return Extensions.setFilterInstalled(true);
112
- return Extensions.load();
194
+ return Extensions.loadMarketExtensions();
195
+ },
196
+
197
+ /** Switch to the system (builtin) extensions tab. */
198
+ async setFilterSystem() {
199
+ _filterSystem = true;
200
+ _filterMarket = false;
201
+ _filterInstalled = false;
202
+ _filterBrand = false;
203
+ return Extensions.loadSystemExtensions();
204
+ },
205
+
206
+ /** Switch to the marketplace extensions tab. */
207
+ async setFilterMarket() {
208
+ _filterMarket = true;
209
+ _filterSystem = false;
210
+ _filterInstalled = false;
211
+ _filterBrand = false;
212
+ return Extensions.loadMarketExtensions();
113
213
  },
114
214
 
115
215
  /** Toggle the "installed only" filter — fetches from local store when enabled. */
116
216
  async setFilterInstalled(onlyInstalled) {
117
217
  _filterInstalled = !!onlyInstalled;
118
218
  _filterBrand = false;
219
+ _filterSystem = false;
220
+ _filterMarket = !onlyInstalled;
119
221
  if (_filterInstalled) {
120
222
  _loading = true;
121
223
  _emit("extensions:loading");
@@ -143,10 +245,12 @@ const ExtensionsStore = (() => {
143
245
  async setFilterBrand(onlyBrand) {
144
246
  _filterBrand = !!onlyBrand;
145
247
  _filterInstalled = false;
248
+ _filterSystem = false;
249
+ _filterMarket = !onlyBrand;
146
250
  if (_filterBrand) {
147
251
  return Extensions.loadBrandExtensions();
148
252
  } else {
149
- return Extensions.load();
253
+ return Extensions.loadMarketExtensions();
150
254
  }
151
255
  },
152
256
 
@@ -159,10 +263,11 @@ const ExtensionsStore = (() => {
159
263
  const res = await fetch("/api/store/extensions/brand");
160
264
  const data = await res.json();
161
265
  const all = data.extensions || [];
162
- _extensions = _query
266
+ const matched = _query
163
267
  ? all.filter(e => [e.name, e.name_zh, e.description].some(
164
268
  f => f && f.toLowerCase().includes(_query.toLowerCase())))
165
269
  : all;
270
+ _extensions = _onlyInstalled ? matched.filter(e => e.installed) : matched;
166
271
  _error = data.warning || null;
167
272
  _loading = false;
168
273
  _emit("extensions:changed", { extensions: _extensions, warning: _error });
@@ -183,6 +288,14 @@ const ExtensionsStore = (() => {
183
288
  _detail = null;
184
289
  _detailLoading = true;
185
290
  _detailError = null;
291
+ // Reopening/refreshing this detail (back button, switching panels and
292
+ // returning, etc.) must not resurrect a stale install/update job
293
+ // snapshot from a previous visit — bump the token so any poll loop
294
+ // still running for that old visit becomes a no-op, and clear the
295
+ // snapshot itself so the button starts from a clean slate.
296
+ _pollToken += 1;
297
+ _installJob = null;
298
+ _installError = null;
186
299
  _emit("extensions:detail");
187
300
  try {
188
301
  const isBrand = source === "brand" || _filterBrand;
@@ -224,6 +337,16 @@ const ExtensionsStore = (() => {
224
337
  _detail = null;
225
338
  _detailLoading = false;
226
339
  _detailError = null;
340
+ // Abandon any install/update poll still running for this detail — bumping
341
+ // the token makes the next loop iteration in _pollInstallStatus a no-op,
342
+ // so we stop hammering the status endpoint once nobody is watching.
343
+ // Also clear the job/error snapshot itself: since nothing will update it
344
+ // anymore, leaving a stale value around would make the button look frozen
345
+ // (e.g. stuck on "downloading 22%") the next time this extension's detail
346
+ // is reopened.
347
+ _pollToken += 1;
348
+ _installJob = null;
349
+ _installError = null;
227
350
  _emit("extensions:detail");
228
351
  },
229
352
 
@@ -239,6 +362,8 @@ const ExtensionsStore = (() => {
239
362
  });
240
363
  const data = await res.json();
241
364
  if (!res.ok || !data.ok) throw new Error(data.error || "toggle failed");
365
+ const activeTab = _filterSystem ? "system" : _filterBrand ? "brand" : "market";
366
+ try { sessionStorage.setItem(EXT_TAB_KEY, activeTab); } catch (_) {}
242
367
  location.reload();
243
368
  } catch (e) {
244
369
  console.error("[Extensions] setEnabled failed", e);
@@ -250,6 +375,11 @@ const ExtensionsStore = (() => {
250
375
  /** Install a marketplace extension by fetching its download_url then posting to the local server. */
251
376
  async install(id) {
252
377
  if (!id) return;
378
+ _installError = null;
379
+ // Capture the current token so that if the user closes the detail panel
380
+ // mid-install, both the poll loop and this catch block notice and stop
381
+ // touching state for a view nobody is looking at anymore.
382
+ const token = _pollToken;
253
383
  try {
254
384
  // Prefer the already-loaded detail (avoids a second round-trip and correctly
255
385
  // handles brand-private extensions whose detail was fetched with &source=brand).
@@ -265,6 +395,8 @@ const ExtensionsStore = (() => {
265
395
  }
266
396
  const download_url = ext.download_url;
267
397
  if (!download_url) throw new Error("No download URL available");
398
+
399
+ // Start async install — server returns job_id immediately
268
400
  const res = await fetch("/api/store/extension/install", {
269
401
  method: "POST",
270
402
  headers: { "Content-Type": "application/json" },
@@ -272,10 +404,15 @@ const ExtensionsStore = (() => {
272
404
  });
273
405
  const data = await res.json();
274
406
  if (!res.ok || !data.ok) throw new Error(data.error || "install failed");
275
- location.reload();
407
+
408
+ // Poll for progress until done.
409
+ const jobId = data.job_id;
410
+ await _pollInstallStatus(jobId, token);
276
411
  } catch (e) {
277
412
  console.error("[Extensions] install failed", e);
278
- _detailError = e.message;
413
+ if (token !== _pollToken) return; // panel was closed — nothing to update
414
+ _installJob = null;
415
+ _installError = e.message;
279
416
  _emit("extensions:detail");
280
417
  }
281
418
  },
@@ -303,8 +440,76 @@ const ExtensionsStore = (() => {
303
440
  _emit("extensions:detail");
304
441
  }
305
442
  },
443
+
444
+ /** Create a new extension via ext-studio and return the session_id. */
445
+ async createNew(idea) {
446
+ const res = await fetch("/api/ext/ext-studio/develop", {
447
+ method: "POST",
448
+ headers: { "Content-Type": "application/json" },
449
+ body: JSON.stringify({ idea: idea || null }),
450
+ });
451
+ return res.json();
452
+ },
453
+
306
454
  };
307
455
 
456
+ // Poll /api/store/extension/install/status until done, updating _installJob each tick.
457
+ // `token` is the _pollToken snapshot taken when the poll started; if the
458
+ // user closes the detail panel, closeDetail() bumps _pollToken and this
459
+ // loop notices on its next tick and quietly stops (no more state writes,
460
+ // no more network requests).
461
+ const POLL_MAX_DURATION_MS = 30 * 60 * 1000; // give up after 30 minutes total
462
+
463
+ async function _pollInstallStatus(jobId, token) {
464
+ const INTERVAL = 1000;
465
+ const startedAt = Date.now();
466
+ let first = true;
467
+ while (true) {
468
+ // Check immediately on the first iteration — the job already exists
469
+ // server-side by the time we get here, so there's no reason to sit on
470
+ // the initial "downloading" placeholder for a full INTERVAL before
471
+ // fetching real progress. Subsequent iterations still wait normally.
472
+ if (first) {
473
+ first = false;
474
+ } else {
475
+ await new Promise(r => setTimeout(r, INTERVAL));
476
+ }
477
+
478
+ // Panel closed mid-poll — abandon silently, nothing left to update.
479
+ if (token !== _pollToken) return;
480
+
481
+ if (Date.now() - startedAt > POLL_MAX_DURATION_MS) {
482
+ throw new Error(I18n.t("extensions.action.installTimeout"));
483
+ }
484
+
485
+ let data;
486
+ try {
487
+ const r = await fetch("/api/store/extension/install/status?job_id=" + encodeURIComponent(jobId));
488
+ data = await r.json();
489
+ } catch (e) {
490
+ // network hiccup — keep polling
491
+ continue;
492
+ }
493
+ if (!data.ok) {
494
+ throw new Error(data.error || "install failed");
495
+ }
496
+ if (token !== _pollToken) return; // closed while the fetch was in flight
497
+
498
+ _installJob = { stage: data.stage, progress: data.progress };
499
+ _emit("extensions:detail");
500
+
501
+ if (data.stage === "done") {
502
+ _installJob = null;
503
+ location.reload();
504
+ return;
505
+ }
506
+ if (data.stage === "error") {
507
+ _installJob = null;
508
+ throw new Error(data.error || "install failed");
509
+ }
510
+ }
511
+ }
512
+
308
513
  return Extensions;
309
514
  })();
310
515
 
@@ -102,7 +102,7 @@ const ExtensionsView = (() => {
102
102
  const versionHtml = ext.version
103
103
  ? `<span class="extension-version">v${escapeHtml(String(ext.version))}</span>` : "";
104
104
  const canUpdate = ext.installed_version && ext.version && ext.installed_version !== ext.version;
105
- const installedHtml = ext.installed
105
+ const installedHtml = (ext.installed && ext.layer !== "builtin")
106
106
  ? `<span class="extension-installed">${escapeHtml(I18n.t("extensions.installed"))}</span>` : "";
107
107
  const updatableHtml = canUpdate
108
108
  ? `<span class="extension-updatable">${escapeHtml(I18n.t("extensions.updatable"))}</span>` : "";
@@ -119,8 +119,12 @@ const ExtensionsView = (() => {
119
119
  const installsHtml = ext.download_count > 0
120
120
  ? `<span class="extension-installs">${escapeHtml(String(ext.download_count))} ${escapeHtml(I18n.t("extensions.installs"))}</span>` : "";
121
121
 
122
+ const isSystem = ext.layer === "builtin";
123
+ const slug = ext.slug || (ext.id != null ? String(ext.id) : (ext.name || ""));
124
+ const toggleHtml = isSystem ? _renderCardToggle(slug, ext.disabled) : "";
125
+
122
126
  const card = document.createElement("div");
123
- card.className = "extension-card extension-card-clickable";
127
+ card.className = isSystem ? "extension-card extension-card-system" : "extension-card extension-card-clickable";
124
128
  card.dataset.extId = ext.id != null ? String(ext.id) : (ext.name || "");
125
129
  card.dataset.extOrigin = ext.origin || "";
126
130
  card.innerHTML = `
@@ -140,10 +144,59 @@ const ExtensionsView = (() => {
140
144
  ${description ? `<div class="extension-desc">${escapeHtml(description)}</div>` : ""}
141
145
  ${homepageHtml ? `<div class="extension-meta">${homepageHtml}</div>` : ""}
142
146
  </div>
147
+ ${toggleHtml}
143
148
  </div>`;
149
+
150
+ if (isSystem) {
151
+ const toggle = card.querySelector(".ext-card-toggle");
152
+ if (toggle) {
153
+ toggle.addEventListener("click", (e) => {
154
+ e.stopPropagation();
155
+ const id = toggle.dataset.extToggle;
156
+ const enabled = toggle.dataset.extEnabled === "1";
157
+ Extensions.setEnabled(id, !enabled);
158
+ });
159
+ }
160
+ } else {
161
+ card.addEventListener("click", (e) => {
162
+ if (e.target.closest("a")) return;
163
+ const router = window.Clacky && window.Clacky.Router;
164
+ const isBrand = card.dataset.extOrigin === "self" || Extensions.state.filterBrand;
165
+ const source = isBrand ? "brand" : null;
166
+ if (router) router.navigate("extensions", { detailId: card.dataset.extId, source });
167
+ else Extensions.loadDetail(card.dataset.extId, source);
168
+ });
169
+ }
170
+
144
171
  return card;
145
172
  }
146
173
 
174
+ function _renderCardToggle(id, disabled) {
175
+ const checked = !disabled;
176
+ return `
177
+ <button type="button" class="ext-card-toggle${checked ? " ext-card-toggle-on" : ""}"
178
+ data-ext-toggle="${escapeHtml(id)}" data-ext-enabled="${checked ? "1" : "0"}"
179
+ title="${escapeHtml(I18n.t(checked ? "extensions.action.disable" : "extensions.action.enable"))}">
180
+ <span class="ext-card-toggle-thumb"></span>
181
+ </button>`;
182
+ }
183
+
184
+ // Returns a human-readable button label for a given install job state.
185
+ function _installJobLabel(job) {
186
+ if (!job) return I18n.t("extensions.action.install");
187
+ switch (job.stage) {
188
+ case "downloading": {
189
+ // Suppress "0%" — it's shown for a very brief instant right after the
190
+ // download starts (or never, for fast installs) and just looks like a stall.
191
+ const pct = (job.progress != null && job.progress > 0) ? ` ${job.progress}%` : "...";
192
+ return I18n.t("extensions.action.downloading") + pct;
193
+ }
194
+ case "extracting": return I18n.t("extensions.action.extracting") + "...";
195
+ case "verifying": return I18n.t("extensions.action.verifying") + "...";
196
+ default: return I18n.t("extensions.action.installing");
197
+ }
198
+ }
199
+
147
200
  function _renderDetail() {
148
201
  const panel = $("extensions-detail");
149
202
  const body = $("extensions-body");
@@ -180,6 +233,33 @@ const ExtensionsView = (() => {
180
233
 
181
234
  panel.innerHTML = _detailShell(_detailContent(st.detail));
182
235
  _wireDetail();
236
+
237
+ // If an install/update job is in-progress, update the button text to reflect current stage.
238
+ // Note: both the "install" and "update" buttons drive the same job/poll flow, so we must
239
+ // look for whichever one is actually present in the freshly-rendered DOM.
240
+ const job = st.installJob;
241
+ const actionBtn = document.querySelector("[data-ext-install], [data-ext-update]");
242
+ if (job) {
243
+ if (actionBtn) {
244
+ actionBtn.disabled = true;
245
+ actionBtn.textContent = _installJobLabel(job);
246
+ }
247
+ }
248
+
249
+ // If the last install/update attempt failed, show an inline error banner
250
+ if (st.installError) {
251
+ if (actionBtn) {
252
+ actionBtn.disabled = false;
253
+ // Insert error message right after the button if not already there
254
+ let errEl = panel.querySelector(".extension-install-error");
255
+ if (!errEl) {
256
+ errEl = document.createElement("p");
257
+ errEl.className = "extension-install-error";
258
+ actionBtn.insertAdjacentElement("afterend", errEl);
259
+ }
260
+ errEl.textContent = st.installError;
261
+ }
262
+ }
183
263
  }
184
264
 
185
265
  function _detailShell(inner) {
@@ -223,7 +303,11 @@ const ExtensionsView = (() => {
223
303
  installBtn.addEventListener("click", () => {
224
304
  const id = installBtn.getAttribute("data-ext-install");
225
305
  installBtn.disabled = true;
226
- installBtn.textContent = I18n.t("extensions.action.installing");
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
+ installBtn.textContent = _installJobLabel({ stage: "downloading", progress: null });
227
311
  Extensions.install(id);
228
312
  });
229
313
  }
@@ -233,7 +317,7 @@ const ExtensionsView = (() => {
233
317
  updateBtn.addEventListener("click", () => {
234
318
  const id = updateBtn.getAttribute("data-ext-update");
235
319
  updateBtn.disabled = true;
236
- updateBtn.textContent = I18n.t("extensions.action.updating");
320
+ updateBtn.textContent = _installJobLabel({ stage: "downloading", progress: null });
237
321
  Extensions.update(id);
238
322
  });
239
323
  }
@@ -327,7 +411,7 @@ const ExtensionsView = (() => {
327
411
  }
328
412
  return "";
329
413
  }
330
- const slug = ext.name || ext.slug || id;
414
+ const slug = ext.slug || id;
331
415
  const toggleKey = ext.disabled ? "extensions.action.enable" : "extensions.action.disable";
332
416
  const toggleCls = ext.disabled ? "extension-action-enable" : "extension-action-disable";
333
417
  const disabledBadge = ext.disabled
@@ -445,16 +529,36 @@ const ExtensionsView = (() => {
445
529
  sortSelect.addEventListener("change", () => Extensions.setSort(sortSelect.value));
446
530
  }
447
531
 
532
+ const installedFilter = $("extensions-installed-filter");
533
+ if (installedFilter) {
534
+ installedFilter.value = ExtensionsStore.state.onlyInstalled ? "installed" : "all";
535
+ installedFilter.addEventListener("change", () => {
536
+ Extensions.setOnlyInstalled(installedFilter.value === "installed");
537
+ });
538
+ }
448
539
  document.querySelectorAll(".extensions-filter-tab").forEach(btn => {
449
540
  btn.addEventListener("click", () => {
450
541
  document.querySelectorAll(".extensions-filter-tab").forEach(b => b.classList.remove("extensions-filter-tab-active"));
451
542
  btn.classList.add("extensions-filter-tab-active");
452
543
  const filter = btn.dataset.filter;
453
- if (filter === "installed") {
454
- Extensions.setFilterInstalled(true);
544
+ const sortEl = $("extensions-sort");
545
+ const installedFilterEl = $("extensions-installed-filter");
546
+ if (filter === "system") {
547
+ if (sortEl) sortEl.style.display = "none";
548
+ if (installedFilterEl) installedFilterEl.style.display = "none";
549
+ Extensions.setFilterSystem();
455
550
  } else if (filter === "brand") {
551
+ if (sortEl) sortEl.style.display = "";
552
+ if (installedFilterEl) installedFilterEl.style.display = "";
456
553
  Extensions.setFilterBrand(true);
554
+ } else if (filter === "market") {
555
+ if (sortEl) sortEl.style.display = "";
556
+ if (installedFilterEl) installedFilterEl.style.display = "";
557
+ Extensions.setFilterMarket();
558
+ } else if (filter === "installed") {
559
+ Extensions.setFilterInstalled(true);
457
560
  } else {
561
+ if (sortEl) sortEl.style.display = "";
458
562
  Extensions.setFilterInstalled(false);
459
563
  }
460
564
  });
@@ -484,8 +588,24 @@ const ExtensionsView = (() => {
484
588
  });
485
589
  }
486
590
 
591
+ const newBtn = $("extensions-new-btn");
592
+ if (newBtn) {
593
+ newBtn.addEventListener("click", () => {
594
+ const seed = I18n.t("extensions.newExt.seed");
595
+ ExtensionsStore.createNew(seed || null)
596
+ .then((data) => {
597
+ if (data && data.session_id) {
598
+ const router = window.Clacky && window.Clacky.Router;
599
+ if (router) router.navigate("session", { id: data.session_id });
600
+ }
601
+ })
602
+ .catch(() => {});
603
+ });
604
+ }
605
+
487
606
  document.addEventListener("langchange", () => {
488
607
  if (input) input.placeholder = I18n.t("extensions.searchPlaceholder");
608
+ if (newBtn) newBtn.textContent = I18n.t("extensions.newExt.btn");
489
609
  _renderList();
490
610
  _renderDetail();
491
611
  });
@@ -503,17 +623,42 @@ const ExtensionsView = (() => {
503
623
  const viewApi = {
504
624
  async onPanelShow(opts) {
505
625
  _wireDom();
506
- // Apply brand tab visibility based on current status (fast path),
507
- // then also refresh in background so it stays up-to-date.
508
626
  _applyBrandTab();
509
627
  if (window.Skills && Skills.refreshBrandStatus) Skills.refreshBrandStatus();
628
+
629
+ const sortEl = $("extensions-sort");
510
630
  const detailId = opts && opts.detailId;
511
631
  if (detailId) {
512
- Extensions.load();
632
+ if (sortEl) sortEl.style.display = "";
633
+ Extensions.loadMarketExtensions();
513
634
  Extensions.loadDetail(detailId, opts && opts.source);
514
635
  } else {
515
636
  Extensions.closeDetail();
516
- Extensions.load();
637
+
638
+ let restoredTab = null;
639
+ try { restoredTab = sessionStorage.getItem(EXT_TAB_KEY); } catch (_) {}
640
+ if (restoredTab) {
641
+ try { sessionStorage.removeItem(EXT_TAB_KEY); } catch (_) {}
642
+ }
643
+
644
+ const tab = restoredTab || "market";
645
+ document.querySelectorAll(".extensions-filter-tab").forEach(b => {
646
+ b.classList.toggle("extensions-filter-tab-active", b.dataset.filter === tab);
647
+ });
648
+ const installedFilterEl = $("extensions-installed-filter");
649
+ if (tab === "system") {
650
+ if (sortEl) sortEl.style.display = "none";
651
+ if (installedFilterEl) installedFilterEl.style.display = "none";
652
+ Extensions.setFilterSystem();
653
+ } else if (tab === "brand") {
654
+ if (sortEl) sortEl.style.display = "";
655
+ if (installedFilterEl) installedFilterEl.style.display = "";
656
+ Extensions.setFilterBrand(true);
657
+ } else {
658
+ if (sortEl) sortEl.style.display = "";
659
+ if (installedFilterEl) installedFilterEl.style.display = "";
660
+ Extensions.setFilterMarket();
661
+ }
517
662
  }
518
663
  },
519
664
  };