openclacky 1.5.21 → 1.5.22

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -0
  3. data/lib/clacky/billing/platform_billing.rb +3 -0
  4. data/lib/clacky/default_extensions/git/panels/git/view.js +1911 -398
  5. data/lib/clacky/default_extensions/preview/panels/preview/view.js +199 -34
  6. data/lib/clacky/providers.rb +43 -17
  7. data/lib/clacky/search_config.rb +33 -11
  8. data/lib/clacky/server/git_panel.rb +360 -34
  9. data/lib/clacky/server/http_server.rb +145 -47
  10. data/lib/clacky/server/preview.rb +118 -17
  11. data/lib/clacky/server/scheduler.rb +81 -2
  12. data/lib/clacky/server/web_ui_controller.rb +35 -27
  13. data/lib/clacky/session_manager.rb +52 -5
  14. data/lib/clacky/utils/mac_app_detector.rb +8 -7
  15. data/lib/clacky/utils/model_pricing.rb +60 -2
  16. data/lib/clacky/utils/windows_app_detector.rb +3 -1
  17. data/lib/clacky/version.rb +1 -1
  18. data/lib/clacky/web/app.css +650 -96
  19. data/lib/clacky/web/app.js +59 -6
  20. data/lib/clacky/web/auth.js +1 -1
  21. data/lib/clacky/web/components/composer.js +107 -8
  22. data/lib/clacky/web/components/image-zoom.js +165 -0
  23. data/lib/clacky/web/components/model-picker.js +6 -4
  24. data/lib/clacky/web/components/notify.js +2 -2
  25. data/lib/clacky/web/core/aside.js +60 -3
  26. data/lib/clacky/web/features/billing/view.js +2 -2
  27. data/lib/clacky/web/features/channels/view.js +5 -2
  28. data/lib/clacky/web/features/extensions/view.js +2 -2
  29. data/lib/clacky/web/features/new-session/store.js +79 -2
  30. data/lib/clacky/web/features/new-session/view.js +496 -58
  31. data/lib/clacky/web/features/skills/store.js +1 -1
  32. data/lib/clacky/web/features/tasks/view.js +162 -51
  33. data/lib/clacky/web/features/workspace/store.js +13 -6
  34. data/lib/clacky/web/features/workspace/view.js +153 -163
  35. data/lib/clacky/web/i18n.js +238 -56
  36. data/lib/clacky/web/index.html +173 -157
  37. data/lib/clacky/web/projects.js +37 -4
  38. data/lib/clacky/web/sessions.js +181 -105
  39. data/lib/clacky/web/settings.js +26 -8
  40. data/lib/clacky/web/skills.js +2 -2
  41. data/lib/clacky/web/theme.js +17 -2
  42. data/lib/clacky/web/utils.js +17 -2
  43. data/lib/clacky/web/weixin-qr.html +2 -1
  44. data/lib/clacky/web/ws-dispatcher.js +8 -29
  45. metadata +2 -1
@@ -135,9 +135,9 @@ const TasksView = (() => {
135
135
  }
136
136
 
137
137
  const ICONS = {
138
- play: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3"/></svg>',
138
+ play: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.5 4.2a1 1 0 0 1 1.53-.85l10.3 7.8a1 1 0 0 1 0 1.7l-10.3 7.8a1 1 0 0 1-1.53-.85Z"/></svg>',
139
139
  more: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>',
140
- edit: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>',
140
+ edit: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>',
141
141
  pause: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><line x1="10" y1="9" x2="10" y2="15"/><line x1="14" y1="9" x2="14" y2="15"/></svg>',
142
142
  resume: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polygon points="10 8.5 16 12 10 15.5 10 8.5"/></svg>',
143
143
  history:'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 2"/></svg>',
@@ -146,6 +146,7 @@ const TasksView = (() => {
146
146
 
147
147
  let _tab = "tasks";
148
148
  let _query = "";
149
+ let _filter = "all"; // all | active | paused
149
150
  let _runsTask = null; // narrow the run list to one task (from a row's menu)
150
151
  let _menuEl = null;
151
152
  let _menuBtn = null;
@@ -163,6 +164,31 @@ const TasksView = (() => {
163
164
  return `${_pad(d.getMonth() + 1)}/${_pad(d.getDate())} ${_pad(d.getHours())}:${_pad(d.getMinutes())}`;
164
165
  }
165
166
 
167
+ function _nextRunLabel(iso) {
168
+ if (!iso) return "";
169
+ const d = new Date(iso);
170
+ if (isNaN(d)) return "";
171
+ const mins = Math.round((d - new Date()) / 60000);
172
+ if (mins <= 0) return I18n.t("tasks.next.soon");
173
+ if (mins < 60) return I18n.t("tasks.next.minutes", { n: mins });
174
+ const hours = Math.round(mins / 60);
175
+ if (hours < 24) return I18n.t("tasks.next.hours", { n: hours });
176
+ return I18n.t("tasks.next.days", { n: Math.round(hours / 24) });
177
+ }
178
+
179
+ function _plainPreview(md) {
180
+ return (md || "")
181
+ .replace(/```[\s\S]*?```/g, " ")
182
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
183
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
184
+ .replace(/^[ \t]{0,3}#{1,6}[ \t]*/gm, "")
185
+ .replace(/^[ \t]{0,3}>[ \t]?/gm, "")
186
+ .replace(/^[ \t]{0,3}([-*+]|\d+\.)[ \t]+/gm, "")
187
+ .replace(/[*_`~]+/g, "")
188
+ .replace(/\s+/g, " ")
189
+ .trim();
190
+ }
191
+
166
192
  function _formatDuration(startIso, endIso) {
167
193
  if (!startIso || !endIso) return "";
168
194
  const secs = Math.max(0, Math.round((new Date(endIso) - new Date(startIso)) / 1000));
@@ -204,50 +230,108 @@ const TasksView = (() => {
204
230
  }
205
231
  }
206
232
 
207
- function _renderTaskRow(t) {
208
- const row = document.createElement("div");
209
- row.className = "cron-row";
210
- row.dataset.name = t.name;
211
-
233
+ // Full prompt lives in a modal: a grid cell is too narrow to read a long task body.
234
+ function _openDetail(t) {
212
235
  const isPaused = t.scheduled && t.enabled === false;
213
- row.classList.toggle("cron-row-paused", isPaused);
236
+ const sched = t.scheduled ? _humanCron(t.cron) : I18n.t("tasks.manual");
237
+ const next = isPaused ? I18n.t("tasks.section.paused")
238
+ : t.scheduled ? _nextRunLabel(t.next_run)
239
+ : "";
240
+ const meta = [sched, next].filter(Boolean).join(" · ");
241
+
242
+ const overlay = document.createElement("div");
243
+ overlay.className = "modal-overlay";
244
+ overlay.innerHTML = `
245
+ <div class="modal-box lg task-detail-modal" role="dialog" aria-modal="true" aria-labelledby="task-detail-title">
246
+ <div class="modal-header">
247
+ <h3 class="modal-title" id="task-detail-title">${escapeHtml(t.name)}</h3>
248
+ <button type="button" class="modal-close-btn" aria-label="${escapeHtml(I18n.t("modal.close"))}"></button>
249
+ </div>
250
+ <div class="modal-body">
251
+ ${meta ? `<p class="form-hint">${escapeHtml(meta)}</p>` : ""}
252
+ <div class="extension-readme-body">${_markdownHtml(t.content)}</div>
253
+ </div>
254
+ </div>`;
255
+ document.body.appendChild(overlay);
214
256
 
215
- const sched = t.scheduled
216
- ? `<span class="cron-row-cron" title="${escapeHtml(t.cron)}">${escapeHtml(_humanCron(t.cron))}</span>`
217
- : `<span class="cron-row-cron cron-row-cron-manual">${escapeHtml(I18n.t("tasks.manual"))}</span>`;
257
+ const close = () => {
258
+ document.removeEventListener("keydown", onKey);
259
+ overlay.remove();
260
+ };
261
+ const onKey = e => { if (e.key === "Escape") close(); };
262
+ document.addEventListener("keydown", onKey);
263
+ overlay.addEventListener("click", e => { if (e.target === overlay) close(); });
264
+ overlay.querySelector(".modal-close-btn").addEventListener("click", close);
265
+ }
218
266
 
219
- const content = t.content || "";
220
- const preview = content.replace(/\s+/g, " ").trim();
221
- row.innerHTML = `
222
- <div class="cron-row-main">
223
- <span class="cron-row-name">${escapeHtml(t.name)}</span>
224
- <span class="cron-row-preview">${escapeHtml(preview) || escapeHtml(I18n.t("tasks.empty"))}</span>
225
- <div class="cron-row-end">
226
- <div class="cron-row-meta">${sched}</div>
227
- <div class="cron-row-actions">
228
- <button type="button" class="cron-row-btn task-btn-run" title="${escapeHtml(I18n.t("tasks.btn.run"))}" aria-label="${escapeHtml(I18n.t("tasks.btn.run"))}">${ICONS.play}</button>
229
- <button type="button" class="cron-row-btn ext-row-more task-btn-more" aria-haspopup="menu" aria-expanded="false" title="${escapeHtml(I18n.t("tasks.btn.more"))}">${ICONS.more}</button>
267
+ function _renderTaskCard(t) {
268
+ const isPaused = t.scheduled && t.enabled === false;
269
+ const card = document.createElement("article");
270
+ card.className = "task-card";
271
+ card.dataset.name = t.name;
272
+ card.classList.toggle("is-paused", isPaused);
273
+ card.classList.toggle("is-manual", !t.scheduled);
274
+
275
+ const content = _plainPreview(t.content);
276
+ const sched = t.scheduled ? _humanCron(t.cron) : I18n.t("tasks.manual");
277
+ const next = isPaused ? I18n.t("tasks.section.paused")
278
+ : t.scheduled ? _nextRunLabel(t.next_run)
279
+ : "";
280
+ const nextAt = t.next_run ? new Date(t.next_run) : null;
281
+ const nextTip = nextAt && !isNaN(nextAt) ? nextAt.toLocaleString() : "";
282
+
283
+ card.innerHTML = `
284
+ <div class="task-card-head">
285
+ <h3 class="task-card-title" title="${escapeHtml(t.name)}">${escapeHtml(t.name)}</h3>
286
+ <div class="task-card-actions">
287
+ <div class="task-card-tools">
288
+ <button type="button" class="task-card-tool task-btn-run" data-tooltip="${escapeHtml(I18n.t("tasks.btn.run"))}" data-i18n-tooltip="tasks.btn.run" aria-label="${escapeHtml(I18n.t("tasks.btn.run"))}">${ICONS.play}</button>
289
+ <button type="button" class="task-card-tool task-btn-edit" data-tooltip="${escapeHtml(I18n.t("tasks.btn.edit"))}" data-i18n-tooltip="tasks.btn.edit" aria-label="${escapeHtml(I18n.t("tasks.btn.edit"))}">${ICONS.edit}</button>
290
+ <button type="button" class="task-card-tool ext-row-more task-btn-more" aria-haspopup="menu" aria-expanded="false" title="${escapeHtml(I18n.t("tasks.btn.more"))}" aria-label="${escapeHtml(I18n.t("tasks.btn.more"))}">${ICONS.more}</button>
230
291
  </div>
292
+ ${t.scheduled ? `<label class="toggle-switch task-card-toggle" title="${escapeHtml(I18n.t(isPaused ? "tasks.btn.resume" : "tasks.btn.pause"))}">
293
+ <input type="checkbox"${isPaused ? "" : " checked"} />
294
+ <span class="toggle-slider"></span>
295
+ </label>` : ""}
231
296
  </div>
232
297
  </div>
233
- ${content.trim() ? `<div class="cron-row-detail" hidden><div class="task-card-detail-content extension-readme-body">${_markdownHtml(content)}</div></div>` : ""}`;
298
+ <p class="task-card-desc">${escapeHtml(content) || escapeHtml(I18n.t("tasks.empty"))}</p>
299
+ <div class="task-card-foot">
300
+ <span class="task-card-sched">
301
+ <svg class="task-card-clock" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15.5 14"/></svg>
302
+ <span class="task-card-sched-text" title="${escapeHtml(t.cron || sched)}">${escapeHtml(sched)}</span>
303
+ </span>
304
+ ${next ? `<span class="task-card-next"${nextTip ? ` title="${escapeHtml(nextTip)}"` : ""}>${escapeHtml(next)}</span>` : ""}
305
+ </div>`;
234
306
 
235
- row.querySelector(".task-btn-run").addEventListener("click", e => {
307
+ const toggle = card.querySelector(".task-card-toggle input");
308
+ if (toggle) {
309
+ toggle.addEventListener("change", e => {
310
+ e.stopPropagation();
311
+ Tasks.toggleEnabled(t.name, isPaused);
312
+ });
313
+ toggle.addEventListener("click", e => e.stopPropagation());
314
+ }
315
+ card.querySelector(".task-btn-run").addEventListener("click", e => {
236
316
  e.stopPropagation();
237
317
  Tasks.run(t.name);
238
318
  });
239
- row.querySelector(".task-btn-more").addEventListener("click", e => {
319
+ card.querySelector(".task-btn-edit").addEventListener("click", e => {
320
+ e.stopPropagation();
321
+ Tasks.editInSession(t.name);
322
+ });
323
+ card.querySelector(".task-btn-more").addEventListener("click", e => {
240
324
  e.stopPropagation();
241
325
  _toggleMenu(e.currentTarget, t, isPaused);
242
326
  });
243
- const detail = row.querySelector(".cron-row-detail");
244
- if (detail) {
245
- row.querySelector(".cron-row-main").addEventListener("click", () => {
246
- detail.hidden = !detail.hidden;
247
- row.classList.toggle("cron-row-expanded", !detail.hidden);
327
+
328
+ if (t.content) {
329
+ card.addEventListener("click", e => {
330
+ if (e.target.closest("button, label, a")) return;
331
+ _openDetail(t);
248
332
  });
249
333
  }
250
- return row;
334
+ return card;
251
335
  }
252
336
 
253
337
  function _menuItems(t, isPaused) {
@@ -297,6 +381,8 @@ const TasksView = (() => {
297
381
  el.hidden = false;
298
382
  _menuBtn = btn;
299
383
  btn.setAttribute("aria-expanded", "true");
384
+ const card = btn.closest(".task-card");
385
+ if (card) card.classList.add("is-menu-open");
300
386
 
301
387
  const rect = btn.getBoundingClientRect();
302
388
  const w = el.offsetWidth, h = el.offsetHeight;
@@ -309,7 +395,11 @@ const TasksView = (() => {
309
395
  function _closeMenu() {
310
396
  if (!_menuEl || _menuEl.hidden) return;
311
397
  _menuEl.hidden = true;
312
- if (_menuBtn) _menuBtn.setAttribute("aria-expanded", "false");
398
+ if (_menuBtn) {
399
+ _menuBtn.setAttribute("aria-expanded", "false");
400
+ const card = _menuBtn.closest(".task-card");
401
+ if (card) card.classList.remove("is-menu-open");
402
+ }
313
403
  _menuBtn = null;
314
404
  }
315
405
 
@@ -320,13 +410,6 @@ const TasksView = (() => {
320
410
  return h;
321
411
  }
322
412
 
323
- function _sectionEmpty(text) {
324
- const e = document.createElement("div");
325
- e.className = "task-section-empty";
326
- e.textContent = text;
327
- return e;
328
- }
329
-
330
413
  function _emptyState(text) {
331
414
  const el = document.createElement("div");
332
415
  el.className = "task-table-empty";
@@ -334,6 +417,30 @@ const TasksView = (() => {
334
417
  return el;
335
418
  }
336
419
 
420
+ function _renderFilterBar(matched) {
421
+ const isPaused = t => t.scheduled && t.enabled === false;
422
+ const counts = {
423
+ all: matched.length,
424
+ active: matched.filter(t => !isPaused(t)).length,
425
+ paused: matched.filter(isPaused).length,
426
+ };
427
+ const bar = document.createElement("div");
428
+ bar.className = "segmented-control";
429
+ bar.setAttribute("role", "tablist");
430
+ bar.innerHTML = ["all", "active", "paused"].map(kind => `
431
+ <button type="button" role="tab" class="segmented-control__btn${_filter === kind ? " active" : ""}"
432
+ data-filter="${kind}" aria-selected="${_filter === kind}">
433
+ ${escapeHtml(I18n.t("tasks.filter." + kind))}<span class="segmented-control__count">(${counts[kind]})</span>
434
+ </button>`).join("");
435
+ bar.querySelectorAll("[data-filter]").forEach(btn => {
436
+ btn.addEventListener("click", () => {
437
+ _filter = btn.dataset.filter;
438
+ _renderTable();
439
+ });
440
+ });
441
+ return bar;
442
+ }
443
+
337
444
  function _renderTable() {
338
445
  const table = $("task-list-table");
339
446
  if (!table) return;
@@ -354,23 +461,27 @@ const TasksView = (() => {
354
461
  return;
355
462
  }
356
463
 
357
- const tasks = _query ? all.filter(t => _matches(t.name) || _matches(t.content)) : all;
358
- if (tasks.length === 0) {
464
+ const matched = _query ? all.filter(t => _matches(t.name) || _matches(t.content)) : all;
465
+ table.appendChild(_renderFilterBar(matched));
466
+
467
+ if (matched.length === 0) {
359
468
  table.appendChild(_emptyState(I18n.t("tasks.search.empty")));
360
469
  return;
361
470
  }
362
471
 
363
- const active = tasks.filter(t => !(t.scheduled && t.enabled === false));
364
- const paused = tasks.filter(t => t.scheduled && t.enabled === false);
365
- if (active.length || !_query) {
366
- table.appendChild(_sectionHeader(I18n.t("tasks.section.active")));
367
- if (active.length) active.forEach(t => table.appendChild(_renderTaskRow(t)));
368
- else table.appendChild(_sectionEmpty(I18n.t("tasks.section.activeEmpty")));
369
- }
370
- if (paused.length) {
371
- table.appendChild(_sectionHeader(I18n.t("tasks.section.paused")));
372
- paused.forEach(t => table.appendChild(_renderTaskRow(t)));
472
+ const isPaused = t => t.scheduled && t.enabled === false;
473
+ const shown = _filter === "active" ? matched.filter(t => !isPaused(t))
474
+ : _filter === "paused" ? matched.filter(isPaused)
475
+ : matched;
476
+ if (shown.length === 0) {
477
+ table.appendChild(_emptyState(I18n.t(_filter === "active" ? "tasks.section.activeEmpty" : "tasks.section.pausedEmpty")));
478
+ return;
373
479
  }
480
+
481
+ const grid = document.createElement("div");
482
+ grid.className = "task-card-grid";
483
+ shown.forEach(t => grid.appendChild(_renderTaskCard(t)));
484
+ table.appendChild(grid);
374
485
  }
375
486
 
376
487
  function _renderRunRow(r) {
@@ -40,13 +40,20 @@ const WorkspaceStore = (() => {
40
40
 
41
41
  // Throw with the backend's error message when available so toasts show the
42
42
  // real cause ("file not found", "unsupported OS", …) instead of "HTTP 404".
43
+ // The machine-readable `code` rides along so callers can react to a specific
44
+ // cause (a deleted working dir) rather than matching on prose.
43
45
  async function _respError(resp) {
44
46
  let detail = `HTTP ${resp.status}`;
47
+ let code = null;
45
48
  try {
46
49
  const data = await resp.json();
47
50
  if (data && data.error) detail = data.error;
51
+ if (data && data.code) code = data.code;
48
52
  } catch (_e) { /* non-JSON body */ }
49
- throw new Error(detail);
53
+ const err = new Error(detail);
54
+ err.status = resp.status;
55
+ err.code = code;
56
+ throw err;
50
57
  }
51
58
 
52
59
  const state = {
@@ -110,18 +117,18 @@ const WorkspaceStore = (() => {
110
117
  },
111
118
 
112
119
  /** Default application the OS would use to open this file; null if none.
113
- * Cached per extension so tab switches don't refetch. */
120
+ * Cached per extension so tab switches don't refetch. Extension-less
121
+ * files (Dockerfile, Makefile) resolve per path and skip the cache. */
114
122
  async fetchDefaultApp(entry) {
115
123
  const ext = (entry.path.match(/\.([^./\\]+)$/) || [])[1];
116
- if (!ext) return null;
117
- const key = ext.toLowerCase();
118
- if (Object.prototype.hasOwnProperty.call(_defaultAppCache, key)) {
124
+ const key = ext ? ext.toLowerCase() : null;
125
+ if (key && Object.prototype.hasOwnProperty.call(_defaultAppCache, key)) {
119
126
  return _defaultAppCache[key];
120
127
  }
121
128
  const resp = await fetch("/api/file/default-app?path=" + encodeURIComponent(_absPath(entry.path)));
122
129
  const data = resp.ok ? await resp.json() : null;
123
130
  const app = data && data.ok ? data.app || null : null;
124
- _defaultAppCache[key] = app;
131
+ if (key) _defaultAppCache[key] = app;
125
132
  return app;
126
133
  },
127
134