openclacky 1.3.1 → 1.3.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a39f825dd6192443ca9f0d30139e20a4b7fe366f9415d721c9a2401bfed1dc4a
4
- data.tar.gz: f825a682fad6a33983e6a582a52d5a9378b7bd29d118b5a2fe1d5c26dc7b3195
3
+ metadata.gz: ed703cddac3ffa9887b5c500c5dfbe57c895eb5aab0e9dc3338592c9317555ec
4
+ data.tar.gz: 67d21c3c857e72bb729cfcecbdb3749eaaac1c76b21e1d0600081d1e4d79e880
5
5
  SHA512:
6
- metadata.gz: 62cf37fe9eb083438853faeaf8f28a4579300d51607d2fbd8eef9caba69dc82d4b2c8dc98ae70f666500c7913b47325b0a6b58a5f3043b7139e8265b12035525
7
- data.tar.gz: 706a5237c4aae5092068c91dfbc6c3cb7b1a93cc27b6878cbf32f043de5ec0c038321228d626efb3b2db744e8b81b4d6889fc518ee8c2495496714e628bf7a29
6
+ metadata.gz: 4fe46c92c67e5937bf24e325be47046a9ebf555afb722aaaaf108a17b2c13403c7e42c08d4b1a7abafad5c5ffb5d79d2749264c051572d157d0094dcb067ac78
7
+ data.tar.gz: 5fa8e909159560aa13944c0435716e7a98265504c6f61e8cfc298bc09553aa218ac404423d4e9b45f5a8d17563f0ae8a7a50547460fb497e06a98bc46b55aa53
data/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.2] - 2026-06-18
9
+
10
+ ### Added
11
+ - Right-click context menu on workspace file tree with "Reveal in Finder" support (macOS/Linux/WSL)
12
+ - Resizable workspace panel with a drag handle
13
+ - Expandable task prompt preview — click a task card to view the full prompt
14
+
15
+ ### Improved
16
+ - Skill cards now show a delete button, with shimmer skeleton loading while fetching
17
+
18
+ ### Fixed
19
+ - Every-N-hours cron schedule label now renders correctly (e.g. `0 */2 * * *`)
20
+ - Billing sessions list now shows all rows instead of being clipped
21
+ - Session replay skips reasoning-only assistant messages to match the live view
22
+ - Set-as-default button in settings aligned to the footer row with the top-up link
23
+
8
24
  ## [1.3.1] - 2026-06-17
9
25
 
10
26
  ### Added
@@ -502,14 +502,24 @@ module Clacky
502
502
 
503
503
  case msg[:role].to_s
504
504
  when "assistant"
505
- # Text content prepend reasoning/thinking content wrapped in <think> tags
506
- # so the Web UI renders it as a collapsible thinking block
507
- text = extract_text_from_content(msg[:content]).to_s.strip
505
+ # Mirror the live guard at agent.rb (`if response[:content] && !response[:content].empty?`):
506
+ # only emit an assistant_message when the model produced actual content.
507
+ # Reasoning-only turns (empty content + reasoning_content + tool_calls)
508
+ # are silent in live mode; on replay they must stay silent too — otherwise
509
+ # a phantom <think>-only bubble splits consecutive tool_calls into separate
510
+ # UI groups, breaking the "N tool(s) used" collapse after refresh (C-5672).
511
+ raw_text = extract_text_from_content(msg[:content]).to_s.strip
508
512
  reasoning = msg[:reasoning_content]
509
- if reasoning && !reasoning.to_s.strip.empty?
510
- text = "<think>\n#{reasoning}\n</think>\n#{text}"
513
+ unless raw_text.empty?
514
+ text = if reasoning && !reasoning.to_s.strip.empty?
515
+ # Prepend reasoning wrapped in <think> tags so the Web UI renders it
516
+ # as a collapsible thinking block.
517
+ "<think>\n#{reasoning}\n</think>\n#{raw_text}"
518
+ else
519
+ raw_text
520
+ end
521
+ ui.show_assistant_message(text, files: [])
511
522
  end
512
- ui.show_assistant_message(text, files: []) unless text.empty?
513
523
 
514
524
  # Tool calls embedded in assistant message
515
525
  Array(msg[:tool_calls]).each do |tc|
@@ -961,7 +961,7 @@ module Clacky
961
961
  #
962
962
  # @param skill_name [String] The slug/name of the skill to remove.
963
963
  # @return [void]
964
- private def delete_brand_skill!(skill_name)
964
+ def delete_brand_skill!(skill_name)
965
965
  # Remove files from disk.
966
966
  skill_dir = File.join(brand_skills_dir, skill_name)
967
967
  FileUtils.rm_rf(skill_dir) if Dir.exist?(skill_dir)
@@ -630,6 +630,9 @@ module Clacky
630
630
  elsif method == "DELETE" && path.match?(%r{^/api/skills/[^/]+$})
631
631
  name = URI.decode_www_form_component(path.sub("/api/skills/", ""))
632
632
  api_delete_skill(name, res)
633
+ elsif method == "DELETE" && path.match?(%r{^/api/brand/skills/[^/]+$})
634
+ slug = URI.decode_www_form_component(path.sub("/api/brand/skills/", ""))
635
+ api_delete_brand_skill(slug, res)
633
636
  elsif method == "POST" && path.match?(%r{^/api/brand/skills/[^/]+/install$})
634
637
  slug = URI.decode_www_form_component(path.sub("/api/brand/skills/", "").sub("/install", ""))
635
638
  api_brand_skill_install(slug, req, res)
@@ -1942,6 +1945,23 @@ module Clacky
1942
1945
  json_response(res, 500, { ok: false, error: e.message })
1943
1946
  end
1944
1947
 
1948
+ # DELETE /api/brand/skills/:slug
1949
+ # Uninstalls a brand skill by removing its files and metadata.
1950
+ def api_delete_brand_skill(slug, res)
1951
+ brand = Clacky::BrandConfig.load
1952
+ installed = brand.installed_brand_skills
1953
+ unless installed.key?(slug)
1954
+ json_response(res, 404, { ok: false, error: "Brand skill '#{slug}' is not installed" })
1955
+ return
1956
+ end
1957
+
1958
+ brand.delete_brand_skill!(slug)
1959
+ @skill_loader = Clacky::SkillLoader.new(working_dir: nil, brand_config: brand)
1960
+ json_response(res, 200, { ok: true })
1961
+ rescue StandardError => e
1962
+ json_response(res, 500, { ok: false, error: e.message })
1963
+ end
1964
+
1945
1965
  # GET /api/brand
1946
1966
  # Returns brand metadata consumed by the WebUI on boot
1947
1967
  # to dynamically replace branding strings.
@@ -2895,10 +2915,13 @@ module Clacky
2895
2915
  result = Utils::EnvironmentDetector.open_file(linux_path)
2896
2916
  return json_response(res, 501, { error: "unsupported OS" }) if result.nil?
2897
2917
  json_response(res, 200, { ok: true })
2918
+ when "reveal"
2919
+ Utils::EnvironmentDetector.reveal_file(linux_path)
2920
+ json_response(res, 200, { ok: true })
2898
2921
  when "download"
2899
2922
  serve_file_download(res, linux_path)
2900
2923
  else
2901
- json_response(res, 400, { error: "invalid action. Must be 'open' or 'download'" })
2924
+ json_response(res, 400, { error: "invalid action. Must be 'open', 'reveal' or 'download'" })
2902
2925
  end
2903
2926
  rescue => e
2904
2927
  json_response(res, 500, { ok: false, error: e.message })
@@ -36,6 +36,22 @@ module Clacky
36
36
  end
37
37
  end
38
38
 
39
+ # Reveal a file in the OS file manager (select/highlight it).
40
+ # macOS: open -R; Linux: xdg-open on parent dir; WSL: explorer /select
41
+ # @param path [String] Linux-side file path
42
+ # @return [Boolean, nil] true/false from system(), or nil on unsupported OS
43
+ def self.reveal_file(path)
44
+ case os_type
45
+ when :macos then system("open", "-R", path)
46
+ when :linux then system("xdg-open", File.dirname(path))
47
+ when :wsl
48
+ win_path = linux_to_win_path(path)
49
+ system("explorer.exe", "/select,#{win_path}")
50
+ else
51
+ nil
52
+ end
53
+ end
54
+
39
55
  # Convert a Windows-style path to a WSL/Linux-side path.
40
56
  # e.g. "C:/Users/foo/file.txt" → "/mnt/c/Users/foo/file.txt"
41
57
  # Returns the original path unchanged on non-WSL or if already a Linux path.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.3.1"
4
+ VERSION = "1.3.2"
5
5
  end
@@ -1754,6 +1754,30 @@ body {
1754
1754
  overflow: hidden;
1755
1755
  text-overflow: ellipsis;
1756
1756
  }
1757
+ .task-card-preview-expandable {
1758
+ cursor: pointer;
1759
+ }
1760
+ .task-card-preview-expandable:hover {
1761
+ color: var(--color-accent-primary);
1762
+ }
1763
+ .task-card-detail {
1764
+ margin-top: 0.625rem;
1765
+ padding: 0.625rem 0.75rem;
1766
+ background: var(--color-bg-primary);
1767
+ border: 1px solid var(--color-border-primary);
1768
+ border-radius: 6px;
1769
+ }
1770
+ .task-card-detail-content {
1771
+ margin: 0;
1772
+ font-size: 0.75rem;
1773
+ font-family: inherit;
1774
+ color: var(--color-text-secondary);
1775
+ line-height: 1.6;
1776
+ white-space: pre-wrap;
1777
+ word-break: break-word;
1778
+ max-height: 12rem;
1779
+ overflow-y: auto;
1780
+ }
1757
1781
  .task-card-actions {
1758
1782
  display: flex;
1759
1783
  align-items: center;
@@ -1907,7 +1931,8 @@ body {
1907
1931
 
1908
1932
  /* ── Workspace panel (right file browser) ───────────────────────────────── */
1909
1933
  #workspace-panel {
1910
- width: 280px;
1934
+ --workspace-width: 280px;
1935
+ width: var(--workspace-width);
1911
1936
  flex-shrink: 0;
1912
1937
  display: flex;
1913
1938
  flex-direction: column;
@@ -1915,8 +1940,21 @@ body {
1915
1940
  background: var(--color-bg-primary);
1916
1941
  overflow: hidden;
1917
1942
  transition: width var(--transition-base);
1943
+ position: relative;
1918
1944
  }
1919
1945
  #workspace-panel.collapsed { width: 0; border-left: none; }
1946
+ #workspace-resize-handle {
1947
+ position: absolute;
1948
+ top: 0;
1949
+ left: -3px;
1950
+ bottom: 0;
1951
+ width: 6px;
1952
+ cursor: col-resize;
1953
+ z-index: 10;
1954
+ }
1955
+ #workspace-panel:has(#workspace-resize-handle.active) {
1956
+ transition: background-color var(--transition-base);
1957
+ }
1920
1958
  #workspace-header {
1921
1959
  display: flex;
1922
1960
  align-items: center;
@@ -4633,14 +4671,15 @@ body {
4633
4671
  }
4634
4672
  .model-card-grid-toolbar {
4635
4673
  display: flex;
4636
- flex-wrap: wrap;
4674
+ flex-wrap: nowrap;
4637
4675
  gap: 0.375rem;
4638
4676
  margin-left: auto;
4639
4677
  justify-content: flex-end;
4640
4678
  }
4641
4679
  .model-card-grid-footer {
4642
4680
  display: flex;
4643
- justify-content: flex-end;
4681
+ justify-content: space-between;
4682
+ align-items: center;
4644
4683
  margin-top: 0.25rem;
4645
4684
  }
4646
4685
  .model-card-grid-link {
@@ -11442,7 +11481,7 @@ body.setup-mode[data-theme="dark"] {
11442
11481
  min-height: 0;
11443
11482
  }
11444
11483
  #skills-body {
11445
- padding: 1rem 1rem 5rem;
11484
+ padding: 1rem 1rem 1rem;
11446
11485
  gap: 0.75rem;
11447
11486
  overflow-y: auto;
11448
11487
  -webkit-overflow-scrolling: touch;
@@ -11543,9 +11582,9 @@ body.setup-mode[data-theme="dark"] {
11543
11582
  }
11544
11583
 
11545
11584
  /* ── Billing / Trash / Creator pages ── */
11546
- #billing-body { padding: 1rem 1rem 5rem; }
11547
- #trash-body { padding: 1rem 1rem 5rem; }
11548
- #creator-body { padding: 1rem 1rem 5rem; }
11585
+ #billing-body { padding: 1rem 1rem 1rem; }
11586
+ #trash-body { padding: 1rem 1rem 1rem; }
11587
+ #creator-body { padding: 1rem 1rem 1rem; }
11549
11588
 
11550
11589
  .billing-heatmap-row { grid-template-columns: 1fr; }
11551
11590
 
@@ -878,7 +878,7 @@ const Billing = (() => {
878
878
  function _renderSessionList() {
879
879
  if (!_sessions || _sessions.length === 0) {
880
880
  return `
881
- <div class="billing-section billing-sessions-section">
881
+ <div class="billing-sessions-section">
882
882
  <h3>${I18n.t("billing.sessions") || "Sessions"}</h3>
883
883
  <div class="billing-sessions-empty">${I18n.t("billing.noSessions") || "No session data"}</div>
884
884
  </div>
@@ -926,7 +926,7 @@ const Billing = (() => {
926
926
  }).join("");
927
927
 
928
928
  return `
929
- <div class="billing-section billing-sessions-section">
929
+ <div class="billing-sessions-section">
930
930
  <h3>${I18n.t("billing.sessions") || "Sessions"}</h3>
931
931
  <div class="billing-sessions-header">
932
932
  <span class="billing-cell billing-cell-index">#</span>
@@ -114,6 +114,8 @@ const I18n = (() => {
114
114
  "workspace.loading": "Loading…",
115
115
  "workspace.error": "Failed to load files",
116
116
  "workspace.downloadFailed": "Download failed",
117
+ "workspace.revealInFinder": "Reveal in Finder",
118
+ "workspace.revealFailed": "Failed to reveal file",
117
119
  "sib.model.tooltip": "Click to switch model",
118
120
  "sib.model.tooltip.busy": "Model switching is disabled while the agent is responding",
119
121
  "sib.variant.header": "Quick switch",
@@ -999,6 +1001,8 @@ const I18n = (() => {
999
1001
  "workspace.loading": "加载中…",
1000
1002
  "workspace.error": "加载文件失败",
1001
1003
  "workspace.downloadFailed": "下载失败",
1004
+ "workspace.revealInFinder": "打开所在文件夹",
1005
+ "workspace.revealFailed": "无法打开文件位置",
1002
1006
  "sib.model.tooltip": "点击切换模型",
1003
1007
  "sib.model.tooltip.busy": "Agent 回复中,暂时无法切换模型",
1004
1008
  "sib.variant.header": "快速切换",
@@ -443,6 +443,7 @@
443
443
 
444
444
  <!-- ── WORKSPACE PANEL (right) ──────────────────────────────────── -->
445
445
  <aside id="workspace-panel" class="collapsed">
446
+ <div id="workspace-resize-handle"></div>
446
447
  <div id="workspace-header">
447
448
  <span id="workspace-title" data-i18n="workspace.title">Workspace</span>
448
449
  <div class="workspace-header-actions">
@@ -104,10 +104,6 @@ const Settings = (() => {
104
104
  </div>
105
105
  </div>
106
106
  <div class="model-card-grid-actions">
107
- ${!isDefault ? `<button class="btn-card-grid-action btn-card-grid-action-primary" data-index="${index}" data-action="default">
108
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
109
- <span>${I18n.t("settings.models.btn.setDefault")}</span>
110
- </button>` : ""}
111
107
  <div class="model-card-grid-toolbar">
112
108
  <button class="btn-card-grid-action" data-index="${index}" data-action="test">
113
109
  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
@@ -123,12 +119,16 @@ const Settings = (() => {
123
119
  </button>` : ""}
124
120
  </div>
125
121
  </div>
126
- ${websiteUrl ? `<div class="model-card-grid-footer">
127
- <a class="model-card-grid-link" href="${_esc(websiteUrl)}" target="_blank" rel="noopener noreferrer">
122
+ <div class="model-card-grid-footer">
123
+ ${!isDefault ? `<button class="btn-card-grid-action btn-card-grid-action-primary" data-index="${index}" data-action="default">
124
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
125
+ <span>${I18n.t("settings.models.btn.setDefault")}</span>
126
+ </button>` : `<span></span>`}
127
+ ${websiteUrl ? `<a class="model-card-grid-link" href="${_esc(websiteUrl)}" target="_blank" rel="noopener noreferrer">
128
128
  ${I18n.t("settings.models.link.topUp")}
129
129
  <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17L17 7"/><path d="M8 7h9v9"/></svg>
130
- </a>
131
- </div>` : ""}
130
+ </a>` : ""}
131
+ </div>
132
132
  `;
133
133
 
134
134
  container.appendChild(card);
@@ -51,7 +51,21 @@ const Skills = (() => {
51
51
  async function _loadBrandSkills() {
52
52
  const container = $("brand-skills-list");
53
53
  if (!container) return;
54
- container.innerHTML = `<div class="brand-skills-loading">${I18n.t("skills.loading")}</div>`;
54
+ container.innerHTML = Array.from({ length: 4 }).map(() => `
55
+ <div class="brand-skill-card">
56
+ <div class="brand-skill-card-main">
57
+ <div class="brand-skill-info">
58
+ <div class="brand-skill-title">
59
+ <span class="skel skel-title"></span>
60
+ <span class="skel" style="height:1rem;width:3.5rem;border-radius:4px;"></span>
61
+ </div>
62
+ <span class="skel skel-subtitle"></span>
63
+ </div>
64
+ <div class="brand-skill-actions">
65
+ <span class="skel" style="height:1.75rem;width:4.5rem;border-radius:6px;"></span>
66
+ </div>
67
+ </div>
68
+ </div>`).join("");
55
69
 
56
70
  try {
57
71
  const res = await fetch("/api/brand/skills");
@@ -163,11 +177,16 @@ const Skills = (() => {
163
177
  <span class="brand-skill-version latest">v${escapeHtml(latestVersion)}</span>
164
178
  <button class="btn-brand-update" data-name="${escapeHtml(name)}">${I18n.t("skills.brand.btn.update")}</button>`;
165
179
  } else {
166
- // Installed and up-to-date — show version badge + "Use" button
180
+ // Installed and up-to-date — show version badge + "Use" + delete buttons
167
181
  const displayVersion = installedVersion || latestVersion;
168
182
  statusHtml = `
169
183
  <span class="brand-skill-version installed">v${escapeHtml(displayVersion)} ✓</span>
170
- <button class="btn-brand-use" data-name="${escapeHtml(name)}">${I18n.t("skills.brand.btn.use")}</button>`;
184
+ <button class="btn-brand-use" data-name="${escapeHtml(name)}">${I18n.t("skills.brand.btn.use")}</button>
185
+ <button class="btn-skill-delete btn-brand-delete" data-name="${escapeHtml(name)}" title="${I18n.t("skills.btn.delete")}">
186
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
187
+ <polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
188
+ </svg>
189
+ </button>`;
171
190
  }
172
191
 
173
192
  // Free skills show a "Free" badge; paid (encrypted) brand skills show "Private".
@@ -196,12 +215,17 @@ const Skills = (() => {
196
215
  </div>`;
197
216
 
198
217
  // Bind install/update/use buttons
199
- const installBtn = card.querySelector(".btn-brand-install");
200
- const updateBtn = card.querySelector(".btn-brand-update");
201
- const useBtn = card.querySelector(".btn-brand-use");
218
+ const installBtn = card.querySelector(".btn-brand-install");
219
+ const updateBtn = card.querySelector(".btn-brand-update");
220
+ const useBtn = card.querySelector(".btn-brand-use");
221
+ const deleteBtn = card.querySelector(".btn-brand-delete");
202
222
  if (installBtn) installBtn.addEventListener("click", () => _installBrandSkill(name, installBtn));
203
223
  if (updateBtn) updateBtn.addEventListener("click", () => _installBrandSkill(name, updateBtn));
204
224
  if (useBtn) useBtn.addEventListener("click", () => _useInstalledSkill(name));
225
+ if (deleteBtn) deleteBtn.addEventListener("click", (e) => {
226
+ e.stopPropagation();
227
+ _deleteBrandSkill(name);
228
+ });
205
229
 
206
230
  return card;
207
231
  }
@@ -266,6 +290,24 @@ const Skills = (() => {
266
290
  }
267
291
  }
268
292
 
293
+ async function _deleteBrandSkill(name) {
294
+ if (!confirm(I18n.t("skills.deleteConfirm", { name }))) return;
295
+ try {
296
+ const res = await fetch(`/api/brand/skills/${encodeURIComponent(name)}`, { method: "DELETE" });
297
+ const data = await res.json();
298
+ if (!res.ok || !data.ok) { alert(data.error || I18n.t("skills.deleteError")); return; }
299
+
300
+ const skill = _brandSkills.find(s => s.name === name);
301
+ if (skill) skill.installed_version = null;
302
+
303
+ _renderBrandSkills();
304
+ await Skills.load();
305
+ Modal.toast(I18n.t("skills.deleted", { name }), "success");
306
+ } catch (e) {
307
+ console.error("[Skills] brand skill delete failed", e);
308
+ }
309
+ }
310
+
269
311
  /** Open a new session and trigger a brand skill by sending "/{name}" as the first message. */
270
312
  async function _useInstalledSkill(name) {
271
313
  const maxN = Sessions.all.reduce((max, s) => {
@@ -49,7 +49,7 @@ const Tasks = (() => {
49
49
  return isZh ? `每 ${n} 分钟` : `Every ${n} min`;
50
50
  }
51
51
  // Every N hours
52
- if (isAny(min) && hour.startsWith("*/") && isAny(dom) && isAny(month) && isAny(dow)) {
52
+ if ((isAny(min) || isNum(min)) && hour.startsWith("*/") && isAny(dom) && isAny(month) && isAny(dow)) {
53
53
  const n = hour.slice(2);
54
54
  return isZh ? `每 ${n} 小时` : `Every ${n} hr`;
55
55
  }
@@ -108,13 +108,9 @@ const Tasks = (() => {
108
108
  ? `<span class="task-card-badge task-card-badge-paused">${I18n.t("tasks.paused")}</span>`
109
109
  : "";
110
110
 
111
- const preview = (t.content || "")
112
- .split("\n")
113
- .map(l => l.trim())
114
- .find(l => l.length > 0) || I18n.t("tasks.empty");
115
- const previewText = preview.length > 120
116
- ? escapeHtml(preview.slice(0, 120)) + "…"
117
- : escapeHtml(preview);
111
+ const content = t.content || "";
112
+ const isTruncated = content.trim().length > 0;
113
+ const previewText = escapeHtml(content.replace(/\s+/g, " ").trim()) || escapeHtml(I18n.t("tasks.empty"));
118
114
 
119
115
  const toggleBtnHtml = t.scheduled ? (isPaused
120
116
  ? `<button class="task-action-btn task-btn-toggle task-btn-resume">
@@ -146,7 +142,7 @@ const Tasks = (() => {
146
142
  ${pausedBadge}
147
143
  ${schedLabel}
148
144
  </div>
149
- <div class="task-card-preview">${previewText}</div>
145
+ <div class="task-card-preview${isTruncated ? " task-card-preview-expandable" : ""}">${previewText}</div>
150
146
  </div>
151
147
  <div class="task-card-actions">
152
148
  <button class="task-run-btn task-btn-run" title="${I18n.t("tasks.btn.run")}">
@@ -170,12 +166,24 @@ const Tasks = (() => {
170
166
  <span>${I18n.t("tasks.btn.delete")}</span>
171
167
  </button>
172
168
  </div>
173
- </div>`;
169
+ </div>
170
+ ${isTruncated ? `<div class="task-card-detail" hidden><pre class="task-card-detail-content">${escapeHtml(content)}</pre></div>` : ""}`;
174
171
 
175
172
  row.querySelector(".task-btn-run").addEventListener("click", e => {
176
173
  e.stopPropagation();
177
174
  Tasks.run(t.name);
178
175
  });
176
+
177
+ if (isTruncated) {
178
+ const previewEl = row.querySelector(".task-card-preview");
179
+ const detailEl = row.querySelector(".task-card-detail");
180
+ previewEl.addEventListener("click", e => {
181
+ e.stopPropagation();
182
+ const expanded = !detailEl.hidden;
183
+ detailEl.hidden = expanded;
184
+ row.classList.toggle("task-card-expanded", !expanded);
185
+ });
186
+ }
179
187
  const toggleBtn = row.querySelector(".task-btn-toggle");
180
188
  if (toggleBtn) {
181
189
  toggleBtn.addEventListener("click", e => {
@@ -93,9 +93,71 @@ const Workspace = (() => {
93
93
  row.addEventListener("click", () => downloadFile(entry));
94
94
  }
95
95
 
96
+ row.addEventListener("contextmenu", (e) => {
97
+ e.preventDefault();
98
+ showContextMenu(e, entry);
99
+ });
100
+
96
101
  return node;
97
102
  }
98
103
 
104
+ function showContextMenu(e, entry) {
105
+ closeContextMenu();
106
+
107
+ const iconFolder = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><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"/></svg>';
108
+
109
+ const menu = document.createElement("div");
110
+ menu.className = "wt-context-menu session-context-menu";
111
+ menu.innerHTML = `
112
+ <div class="session-actions-menu-item" data-action="reveal">
113
+ <span class="session-actions-menu-icon">${iconFolder}</span>
114
+ <span class="session-actions-menu-label">${t("workspace.revealInFinder")}</span>
115
+ </div>
116
+ `;
117
+
118
+ document.body.appendChild(menu);
119
+ menu.addEventListener("contextmenu", (e) => e.preventDefault());
120
+ menu.style.position = "fixed";
121
+ menu.style.top = e.clientY + "px";
122
+ menu.style.left = e.clientX + "px";
123
+ requestAnimationFrame(() => {
124
+ const r = menu.getBoundingClientRect();
125
+ if (r.right > window.innerWidth) menu.style.left = (window.innerWidth - r.width - 8) + "px";
126
+ if (r.bottom > window.innerHeight) menu.style.top = (window.innerHeight - r.height - 8) + "px";
127
+ });
128
+
129
+ menu.addEventListener("click", async (ev) => {
130
+ const item = ev.target.closest(".session-actions-menu-item");
131
+ if (!item) return;
132
+ closeContextMenu();
133
+ if (item.dataset.action === "reveal") await revealFile(entry);
134
+ });
135
+
136
+ setTimeout(() => {
137
+ document.addEventListener("click", closeContextMenu, { once: true });
138
+ }, 0);
139
+ }
140
+
141
+ function closeContextMenu() {
142
+ const existing = document.querySelector(".wt-context-menu");
143
+ if (existing) existing.remove();
144
+ }
145
+
146
+ async function revealFile(entry) {
147
+ const fullPath = _workingDir.replace(/\/+$/, "") + "/" + entry.path;
148
+ try {
149
+ const resp = await fetch("/api/file-action", {
150
+ method: "POST",
151
+ headers: { "Content-Type": "application/json" },
152
+ body: JSON.stringify({ path: fullPath, action: "reveal" })
153
+ });
154
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
155
+ } catch (err) {
156
+ console.error("reveal failed:", err);
157
+ if (typeof Modal !== "undefined") Modal.toast(t("workspace.revealFailed"), "error");
158
+ }
159
+ }
160
+
99
161
  async function toggleDir(entry, caret, children) {
100
162
  const isOpen = caret.classList.contains("open");
101
163
  if (isOpen) {
@@ -208,5 +270,47 @@ const Workspace = (() => {
208
270
  };
209
271
  })();
210
272
 
273
+ (function _initWorkspaceResize() {
274
+ const panel = document.getElementById("workspace-panel");
275
+ const handle = document.getElementById("workspace-resize-handle");
276
+ if (!panel || !handle) return;
277
+
278
+ const MIN_W = 160;
279
+ const MAX_W = 600;
280
+
281
+ const saved = localStorage.getItem("workspace-width");
282
+ if (saved) {
283
+ const w = parseFloat(saved);
284
+ if (w >= MIN_W && w <= MAX_W) panel.style.setProperty("--workspace-width", w + "px");
285
+ }
286
+
287
+ let startX = 0;
288
+ let startW = 0;
289
+
290
+ handle.addEventListener("mousedown", (e) => {
291
+ e.preventDefault();
292
+ startX = e.clientX;
293
+ startW = parseFloat(getComputedStyle(panel).getPropertyValue("--workspace-width"));
294
+ handle.classList.add("active");
295
+ document.body.style.cursor = "col-resize";
296
+ document.body.style.userSelect = "none";
297
+ });
298
+
299
+ document.addEventListener("mousemove", (e) => {
300
+ if (!handle.classList.contains("active")) return;
301
+ const dx = startX - e.clientX;
302
+ const newW = Math.min(MAX_W, Math.max(MIN_W, startW + dx));
303
+ panel.style.setProperty("--workspace-width", newW + "px");
304
+ });
305
+
306
+ document.addEventListener("mouseup", () => {
307
+ if (!handle.classList.contains("active")) return;
308
+ handle.classList.remove("active");
309
+ document.body.style.cursor = "";
310
+ document.body.style.userSelect = "";
311
+ localStorage.setItem("workspace-width", parseFloat(getComputedStyle(panel).getPropertyValue("--workspace-width")));
312
+ });
313
+ })();
314
+
211
315
  document.addEventListener("DOMContentLoaded", () => Workspace.init());
212
316
  window.Workspace = Workspace;
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.1
4
+ version: 1.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy