openclacky 1.5.12 → 1.5.13

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/lib/clacky/access_key.rb +59 -0
  4. data/lib/clacky/agent/message_compressor_helper.rb +52 -1
  5. data/lib/clacky/agent/session_serializer.rb +35 -2
  6. data/lib/clacky/agent/time_machine.rb +9 -1
  7. data/lib/clacky/agent.rb +11 -7
  8. data/lib/clacky/agent_config.rb +40 -0
  9. data/lib/clacky/agent_profile.rb +5 -5
  10. data/lib/clacky/billing/platform_billing.rb +84 -1
  11. data/lib/clacky/brand_config.rb +4 -3
  12. data/lib/clacky/cli.rb +4 -2
  13. data/lib/clacky/client.rb +107 -0
  14. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +3 -0
  15. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +40 -21
  16. data/lib/clacky/default_extensions/git/ext.yml +1 -1
  17. data/lib/clacky/default_extensions/git/panels/git/view.js +483 -77
  18. data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
  19. data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
  20. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
  21. data/lib/clacky/extension/packager.rb +4 -2
  22. data/lib/clacky/extension/verifier.rb +14 -1
  23. data/lib/clacky/message_format/open_ai.rb +5 -1
  24. data/lib/clacky/message_format/open_ai_responses.rb +409 -0
  25. data/lib/clacky/message_history.rb +6 -2
  26. data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
  27. data/lib/clacky/providers.rb +18 -6
  28. data/lib/clacky/server/dir_picker.rb +154 -0
  29. data/lib/clacky/server/git_panel.rb +61 -9
  30. data/lib/clacky/server/http_server.rb +147 -103
  31. data/lib/clacky/tools/terminal.rb +27 -3
  32. data/lib/clacky/ui2/markdown_renderer.rb +15 -12
  33. data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
  34. data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
  35. data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
  36. data/lib/clacky/utils/model_pricing.rb +70 -4
  37. data/lib/clacky/version.rb +1 -1
  38. data/lib/clacky/web/app.css +412 -108
  39. data/lib/clacky/web/components/composer.js +45 -7
  40. data/lib/clacky/web/components/custom-select.js +136 -0
  41. data/lib/clacky/web/components/mentions.js +58 -2
  42. data/lib/clacky/web/components/model-picker.js +527 -0
  43. data/lib/clacky/web/components/onboard.js +29 -55
  44. data/lib/clacky/web/core/ext.js +38 -0
  45. data/lib/clacky/web/features/billing/view.js +20 -6
  46. data/lib/clacky/web/features/extensions/view.js +22 -10
  47. data/lib/clacky/web/features/new-session/store.js +15 -1
  48. data/lib/clacky/web/features/new-session/view.js +31 -22
  49. data/lib/clacky/web/features/trash/view.js +31 -19
  50. data/lib/clacky/web/features/workspace/store.js +1 -0
  51. data/lib/clacky/web/features/workspace/view.js +54 -3
  52. data/lib/clacky/web/i18n.js +73 -11
  53. data/lib/clacky/web/index.html +92 -37
  54. data/lib/clacky/web/sessions.js +550 -592
  55. data/lib/clacky/web/settings.js +163 -190
  56. data/lib/clacky.rb +3 -0
  57. metadata +8 -1
@@ -1,11 +1,11 @@
1
1
  // ── Official panel: time_machine ──────────────────────────────────────────
2
2
  //
3
3
  // Vertical timeline of the session's tasks (mounted in session.aside tab slot).
4
- // Clicking a past row opens a right-side drawer with the diff details — the
5
- // 288px aside is too narrow to host both a file list and a unified diff, so
6
- // the drawer escapes to ~720px / 90vw and overlays everything.
7
- //
8
- // Backed by:
4
+ // Clicking a past row expands an inline card (top files + actions). "View
5
+ // details" swaps the whole panel to a two-pane detail view - file list on the
6
+ // left, unified diff on the right - mirroring the Git tab layout inside the
7
+ // aside (the aside widens to fit the diff, same as the Files/Git tabs).
8
+ //// Backed by:
9
9
  // GET /api/sessions/:id/time_machine — task list
10
10
  // GET /api/sessions/:id/time_machine/:tid/diff — files this task touched
11
11
  // GET /api/sessions/:id/time_machine/:tid/diff?path=… — unified diff for one file
@@ -43,6 +43,11 @@
43
43
  return (v && v !== k) ? v : fallback;
44
44
  };
45
45
 
46
+ const DIFF_ASIDE_WIDTH = 860;
47
+ const MAX_DIFF_LINES = 3000;
48
+ const ICON_BACK = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>';
49
+ const ICON_CLOSE = '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>';
50
+
46
51
  if (!document.getElementById("tm-panel-style")) {
47
52
  const style = document.createElement("style");
48
53
  style.id = "tm-panel-style";
@@ -109,7 +114,7 @@
109
114
  .tm-mini-btn { padding: 4px 10px; font-size: 11px; line-height: 16px; cursor: pointer; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); background: var(--color-bg-primary); color: var(--color-text-secondary); }
110
115
  .tm-mini-btn:hover { background: var(--color-bg-hover); }
111
116
  .tm-mini-btn.primary { background: var(--color-accent-primary); color: var(--color-text-inverse); border-color: var(--color-accent-primary); }
112
- .tm-mini-btn.primary:hover { background: var(--color-accent-hover); border-color: var(--color-accent-hover); }
117
+ .tm-mini-btn.primary:hover { background: var(--color-button-primary-hover); border-color: var(--color-button-primary-hover); }
113
118
  .tm-mini-btn:disabled { opacity: 0.5; cursor: default; }
114
119
 
115
120
  .tm-mini-confirm {
@@ -126,44 +131,33 @@
126
131
 
127
132
  .tm-foot { flex: none; padding: 8px 14px; font-size: 11px; color: var(--color-text-tertiary); border-top: 1px solid var(--color-border-secondary); }
128
133
 
129
- /* ── Drawer ───────────────────────────────────────────────────────── */
130
- .tm-drawer-mask {
131
- position: fixed; inset: 0; background: rgba(0, 0, 0, 0.4);
132
- z-index: 1000; opacity: 0; transition: opacity 0.2s;
133
- }
134
- .tm-drawer-mask.open { opacity: 1; }
135
- .tm-drawer {
136
- position: fixed; top: 0; right: 0; bottom: 0;
137
- width: min(720px, 90vw);
138
- background: var(--color-bg-primary);
139
- border-left: 1px solid var(--color-border-primary);
140
- box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
141
- z-index: 1001;
142
- display: flex; flex-direction: column;
143
- transform: translateX(100%);
144
- transition: transform 0.25s cubic-bezier(0.2, 0.8, 0.2, 1);
145
- }
146
- .tm-drawer.open { transform: translateX(0); }
147
-
148
- .tm-drawer-head { flex: none; padding: 14px 18px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--color-border-secondary); }
149
- .tm-drawer-title-wrap { flex: 1; min-width: 0; }
150
- .tm-drawer-title { font-size: 14px; color: var(--color-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
151
- .tm-drawer-time { font-size: 11px; color: var(--color-text-tertiary); margin-top: 2px; }
152
- .tm-drawer-close { flex: none; padding: 4px 10px; font-size: 12px; cursor: pointer; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); background: var(--color-bg-secondary); color: var(--color-text-secondary); }
153
- .tm-drawer-close:hover { background: var(--color-bg-hover); }
154
- .tm-restore-btn { flex: none; padding: 5px 14px; font-size: 12px; cursor: pointer; border: 1px solid transparent; border-radius: var(--radius-sm); background: var(--color-accent-primary); color: var(--color-text-inverse); }
155
- .tm-restore-btn:hover { background: var(--color-accent-hover); }
156
- .tm-restore-btn:disabled { opacity: 0.5; cursor: default; }
157
-
158
- .tm-confirm-row { flex: none; display: flex; gap: 8px; padding: 10px 18px; align-items: center; background: var(--color-bg-secondary); border-bottom: 1px solid var(--color-border-secondary); }
159
- .tm-confirm-msg { flex: 1; font-size: 12px; color: var(--color-text-secondary); }
160
- .tm-confirm-btn { padding: 4px 12px; cursor: pointer; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); background: var(--color-bg-secondary); font-size: 12px; }
161
- .tm-confirm-btn.go { background: var(--color-accent-primary); color: var(--color-text-inverse); border-color: transparent; }
162
- .tm-confirm-btn:disabled { opacity: 0.5; cursor: default; }
163
-
164
- .tm-drawer-body { flex: 1; min-height: 0; display: flex; }
165
- .tm-files { flex: 0 0 220px; overflow: auto; border-right: 1px solid var(--color-border-secondary); padding: 6px 0; }
166
- .tm-file { padding: 7px 14px; font-size: 12px; cursor: pointer; display: flex; align-items: center; gap: 8px; }
134
+ /* ── Detail view (in-aside, mirrors the Git tab layout) ───────────── */
135
+ .tm-detail { display: none; flex: 1; min-height: 0; flex-direction: column; }
136
+ .tm-panel.in-detail .tm-list { display: none; }
137
+ .tm-panel.in-detail .tm-detail { display: flex; }
138
+
139
+ .tm-detail-head { flex: none; display: flex; align-items: center; gap: 8px; padding: 10px 10px 10px 6px; border-bottom: 1px solid var(--color-border-secondary); }
140
+ .tm-detail-back { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border: none; border-radius: var(--radius-sm); background: transparent; color: var(--color-text-secondary); cursor: pointer; }
141
+ .tm-detail-back:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
142
+ .tm-detail-title-wrap { flex: 1; min-width: 0; }
143
+ .tm-detail-title { font-size: 13px; color: var(--color-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
144
+ .tm-detail-time { font-size: 11px; color: var(--color-text-tertiary); margin-top: 2px; }
145
+ .tm-detail-restore { flex: none; padding: 5px 12px; font-size: 12px; cursor: pointer; border: 1px solid transparent; border-radius: var(--radius-sm); background: var(--color-accent-primary); color: var(--color-text-inverse); white-space: nowrap; }
146
+ .tm-detail-restore:hover { background: var(--color-button-primary-hover); }
147
+ .tm-detail-restore:disabled { opacity: 0.5; cursor: default; }
148
+ .tm-detail-restore.armed { background: var(--color-error, #b03a3a); }
149
+
150
+ .tm-detail-body { flex: 1; min-height: 0; display: flex; }
151
+ .tm-detail-files { flex: 1; min-width: 0; overflow: auto; padding: 6px 6px; }
152
+ .tm-panel.has-detail-diff .tm-detail-files { flex: 0 0 232px; border-right: 1px solid var(--color-border-primary); }
153
+ .tm-detail-diff { display: none; flex: 1; min-width: 0; min-height: 0; flex-direction: column; }
154
+ .tm-panel.has-detail-diff .tm-detail-diff { display: flex; }
155
+
156
+ .tm-detail-diff-head { flex: none; display: flex; align-items: center; gap: 8px; padding: 6px 8px 6px 12px; border-bottom: 1px solid var(--color-border-secondary); }
157
+ .tm-detail-diff-path { flex: 1; min-width: 0; font-family: ui-monospace, monospace; font-size: 11.5px; color: var(--color-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
158
+ .tm-detail-diff-close { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border: none; border-radius: var(--radius-sm); background: transparent; color: var(--color-text-tertiary); cursor: pointer; }
159
+ .tm-detail-diff-close:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
160
+ .tm-file { padding: 6px 8px; font-size: 12px; cursor: pointer; display: flex; align-items: center; gap: 8px; border-radius: var(--radius-sm); }
167
161
  .tm-file:hover { background: var(--color-bg-hover); }
168
162
  .tm-file.active { background: var(--color-accent-soft); }
169
163
  .tm-file-tag { flex: none; font-size: 9px; padding: 1px 6px; border-radius: var(--radius-sm); }
@@ -172,10 +166,7 @@
172
166
  .tm-file-tag.deleted { background: var(--color-error-soft, #b03a3a33); color: var(--color-error); }
173
167
  .tm-file-path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-text-secondary); }
174
168
 
175
- .tm-diff-wrap { flex: 1; min-width: 0; display: flex; flex-direction: column; }
176
- .tm-diff-path { flex: none; padding: 8px 16px; font-size: 11px; color: var(--color-text-tertiary); border-bottom: 1px solid var(--color-border-secondary); font-family: ui-monospace, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; }
177
- .tm-diff-path:empty { display: none; }
178
- .tm-diff { flex: 1; min-width: 0; overflow: auto; padding: 10px 0; font-family: ui-monospace, monospace; font-size: 12px; line-height: 1.55; }
169
+ .tm-diff { flex: 1; min-height: 0; overflow: auto; padding: 10px 0; font-family: ui-monospace, monospace; font-size: 12px; line-height: 1.55; }
179
170
  .tm-diff-stub, .tm-diff-loading { color: var(--color-text-tertiary); padding: 20px; text-align: center; }
180
171
  .tm-diff-line { white-space: pre; padding: 0 16px; min-width: max-content; }
181
172
  .tm-diff-line.add { background: var(--color-success-soft, #1f6e2c33); color: var(--color-success, #4eb965); }
@@ -217,6 +208,21 @@
217
208
  return dt.toLocaleString();
218
209
  }
219
210
 
211
+ // The diff pane needs horizontal room; widen the aside once when a diff is
212
+ // first opened (same trick as the Files/Git tabs, still user-resizable).
213
+ function ensureAsideWidth() {
214
+ const aside = document.getElementById("session-aside");
215
+ if (!aside) return;
216
+ let w = 0;
217
+ try {
218
+ w = parseFloat(getComputedStyle(aside).getPropertyValue("--session-aside-width")) || 0;
219
+ } catch (_e) { w = 0; }
220
+ if (w < DIFF_ASIDE_WIDTH) {
221
+ aside.style.setProperty("--session-aside-width", DIFF_ASIDE_WIDTH + "px");
222
+ try { localStorage.setItem("clacky.aside.width", String(DIFF_ASIDE_WIDTH)); } catch (_e) { /* non-fatal */ }
223
+ }
224
+ }
225
+
220
226
  function renderTimeline(state) {
221
227
  const { listEl, tasks } = state;
222
228
  listEl.replaceChildren();
@@ -277,7 +283,7 @@
277
283
  const actions = el("div", { class: "tm-mini-actions" }, detailsBtn, restoreBtn);
278
284
  const card = el("div", { class: `tm-mini ${isUndone ? "undone" : ""}` }, filesWrap, actions);
279
285
 
280
- detailsBtn.addEventListener("click", (e) => { e.stopPropagation(); openDrawer(state, task); });
286
+ detailsBtn.addEventListener("click", (e) => { e.stopPropagation(); openDetail(state, task); });
281
287
  restoreBtn.addEventListener("click", (e) => {
282
288
  e.stopPropagation();
283
289
  openConfirm(state, task, actions);
@@ -396,6 +402,7 @@
396
402
  if (res.ok) {
397
403
  state.footEl.textContent = res.message || t("tm.restored", "已恢复");
398
404
  state.expanded = null;
405
+ closeDetail(state);
399
406
  await loadHistory(state);
400
407
  } else {
401
408
  state.footEl.textContent = res.error || t("tm.restoreFailed", "恢复失败");
@@ -411,72 +418,123 @@
411
418
  wrap.appendChild(el("div", { class: "tm-diff-stub", text: t("tm.diff.same", "这一步没有改动这个文件的内容。") }));
412
419
  return wrap;
413
420
  }
414
- patch.split("\n").forEach((line) => {
421
+ const lines = patch.split("\n");
422
+ const shown = Math.min(lines.length, MAX_DIFF_LINES);
423
+ for (let i = 0; i < shown; i++) {
424
+ const line = lines[i];
415
425
  let cls = "tm-diff-line";
416
426
  if (line.startsWith("+++") || line.startsWith("---")) cls += " meta";
417
427
  else if (line.startsWith("@@")) cls += " hunk";
418
428
  else if (line.startsWith("+")) cls += " add";
419
429
  else if (line.startsWith("-")) cls += " del";
420
430
  wrap.appendChild(el("div", { class: cls, text: line || " " }));
421
- });
431
+ }
432
+ if (lines.length > shown) {
433
+ wrap.appendChild(el("div", { class: "tm-diff-stub",
434
+ text: t("tm.diff.truncated", "差异过大,仅显示前 %d 行").replace("%d", String(shown)) }));
435
+ }
422
436
  return wrap;
423
437
  }
424
438
 
425
- async function loadFileDiff(state, rel) {
426
- state.diffEl.replaceChildren(el("div", { class: "tm-diff-loading", text: t("tm.diff.loading", "正在读取差异…") }));
439
+ async function loadFileDiff(state, taskId, rel) {
440
+ state.detailDiffEl.replaceChildren(el("div", { class: "tm-diff-loading", text: t("tm.diff.loading", "正在读取差异…") }));
427
441
  try {
428
- const res = await api(state.sessionId, `/${state.selected}/diff?path=${encodeURIComponent(rel)}`);
442
+ const res = await api(state.sessionId, `/${taskId}/diff?path=${encodeURIComponent(rel)}`);
443
+ if (state.detailSelected !== rel) return;
429
444
  if (!res.ok) {
430
- state.diffEl.replaceChildren(el("div", { class: "tm-diff-stub", text: res.error || t("tm.diff.fail", "读取差异失败") }));
445
+ state.detailDiffEl.replaceChildren(el("div", { class: "tm-diff-stub", text: res.error || t("tm.diff.fail", "读取差异失败") }));
431
446
  return;
432
447
  }
433
448
  if (res.binary) {
434
- state.diffEl.replaceChildren(el("div", { class: "tm-diff-stub", text: t("tm.diff.binary", "二进制文件,跳过逐行对比。") }));
449
+ state.detailDiffEl.replaceChildren(el("div", { class: "tm-diff-stub", text: t("tm.diff.binary", "二进制文件,跳过逐行对比。") }));
435
450
  return;
436
451
  }
437
- state.diffEl.replaceChildren(renderDiffText(res.patch));
452
+ state.detailDiffEl.replaceChildren(renderDiffText(res.patch));
438
453
  } catch (_e) {
439
- state.diffEl.replaceChildren(el("div", { class: "tm-diff-stub", text: t("tm.diff.fail", "读取差异失败") }));
454
+ state.detailDiffEl.replaceChildren(el("div", { class: "tm-diff-stub", text: t("tm.diff.fail", "读取差异失败") }));
440
455
  }
441
456
  }
442
457
 
443
- function openDrawer(state, task) {
444
- state.selected = task.task_id;
445
-
446
- state.drawerTitleEl.textContent = task.summary;
447
- state.drawerTimeEl.textContent = task.started_at ? relTime(task.started_at) : "";
448
- state.confirmRow.style.display = "none";
449
- state.restoreBtn.disabled = false;
450
- state.filesEl.replaceChildren(el("div", { class: "tm-diff-loading", text: t("tm.diff.loading", "正在读取改动…") }));
451
- state.diffEl.replaceChildren();
452
- state.diffPathEl.textContent = "";
453
-
454
- state.maskEl.style.display = "block";
455
- state.drawerEl.style.display = "flex";
456
- requestAnimationFrame(() => {
457
- state.maskEl.classList.add("open");
458
- state.drawerEl.classList.add("open");
459
- });
458
+ // ── Detail view (in-aside) ────────────────────────────────────────────────
459
+ // Mirrors the Git tab: file list on the left, diff on the right. The restore
460
+ // button uses the same two-step armed confirm as the Git tab's revert.
461
+
462
+ let detailRestoreTimer = null;
460
463
 
461
- loadDrawerFiles(state, task.task_id);
464
+ function disarmDetailRestore(state) {
465
+ if (detailRestoreTimer) { clearTimeout(detailRestoreTimer); detailRestoreTimer = null; }
466
+ const btn = state.detailRestoreEl;
467
+ if (!btn) return;
468
+ btn.classList.remove("armed");
469
+ btn.textContent = t("tm.restore.go", "回到这里");
462
470
  }
463
471
 
464
- async function loadDrawerFiles(state, taskId) {
472
+ function armDetailRestore(state) {
473
+ disarmDetailRestore(state);
474
+ state.detailRestoreEl.classList.add("armed");
475
+ state.detailRestoreEl.textContent = t("tm.restore.confirm", "确认回到这里");
476
+ detailRestoreTimer = setTimeout(() => disarmDetailRestore(state), 4000);
477
+ }
478
+
479
+ function onDetailRestoreClick(state) {
480
+ if (!state.detailTask) return;
481
+ if (state.detailRestoreEl.classList.contains("armed")) {
482
+ disarmDetailRestore(state);
483
+ performRestoreInline(state, state.detailTask.task_id);
484
+ } else {
485
+ armDetailRestore(state);
486
+ }
487
+ }
488
+
489
+ function openDetail(state, task) {
490
+ state.view = "detail";
491
+ state.detailTask = task;
492
+ state.detailSelected = null;
493
+ disarmDetailRestore(state);
494
+ state.panelEl.classList.add("in-detail");
495
+ state.panelEl.classList.remove("has-detail-diff");
496
+ state.detailTitleEl.textContent = task.summary;
497
+ state.detailTimeEl.textContent = task.started_at ? relTime(task.started_at) : "";
498
+ state.detailRestoreEl.disabled = false;
499
+ state.detailFilesEl.replaceChildren(el("div", { class: "tm-diff-loading", text: t("tm.diff.loading", "正在读取改动…") }));
500
+ state.detailDiffPathEl.textContent = "";
501
+ state.detailDiffEl.replaceChildren();
502
+ loadDetailFiles(state, task);
503
+ }
504
+
505
+ function closeDetail(state) {
506
+ state.view = "timeline";
507
+ state.detailTask = null;
508
+ state.detailSelected = null;
509
+ disarmDetailRestore(state);
510
+ state.panelEl.classList.remove("in-detail", "has-detail-diff");
511
+ }
512
+
513
+ function closeDetailDiff(state) {
514
+ state.detailSelected = null;
515
+ state.panelEl.classList.remove("has-detail-diff");
516
+ state.detailDiffPathEl.textContent = "";
517
+ state.detailDiffEl.replaceChildren();
518
+ state.detailFilesEl.querySelectorAll(".tm-file.active").forEach((n) => n.classList.remove("active"));
519
+ }
520
+
521
+ async function loadDetailFiles(state, task) {
522
+ const taskId = task.task_id;
465
523
  let res;
466
524
  try {
467
525
  res = await api(state.sessionId, `/${taskId}/diff`);
468
526
  } catch (_e) {
469
- state.filesEl.replaceChildren(el("div", { class: "tm-diff-stub", text: t("tm.diff.fail", "读取改动失败") }));
527
+ state.detailFilesEl.replaceChildren(el("div", { class: "tm-diff-stub", text: t("tm.diff.fail", "读取改动失败") }));
470
528
  return;
471
529
  }
472
- if (state.selected !== taskId) return;
530
+ if (state.view !== "detail" || !state.detailTask || state.detailTask.task_id !== taskId) return;
473
531
  if (!res.ok) {
474
- state.filesEl.replaceChildren(el("div", { class: "tm-diff-stub", text: res.error || t("tm.diff.fail", "读取改动失败") }));
532
+ state.detailFilesEl.replaceChildren(el("div", { class: "tm-diff-stub", text: res.error || t("tm.diff.fail", "读取改动失败") }));
475
533
  return;
476
534
  }
477
535
  const files = res.files || [];
478
536
  if (files.length === 0) {
479
- state.filesEl.replaceChildren(el("div", { class: "tm-diff-stub", text: t("tm.diff.noFiles", "没有文件改动。") }));
537
+ state.detailFilesEl.replaceChildren(el("div", { class: "tm-diff-stub", text: t("tm.diff.noFiles", "没有文件改动。") }));
480
538
  return;
481
539
  }
482
540
  const tagText = { added: t("tm.tag.added", "新增"), modified: t("tm.tag.modified", "修改"), deleted: t("tm.tag.deleted", "删除") };
@@ -486,52 +544,24 @@
486
544
  el("span", { class: `tm-file-tag ${f.status}`, text: tagText[f.status] || f.status }),
487
545
  el("span", { class: "tm-file-path", text: basename }),
488
546
  );
489
- node.addEventListener("click", () => {
490
- state.filesEl.querySelectorAll(".tm-file.active").forEach((n) => n.classList.remove("active"));
491
- node.classList.add("active");
492
- state.diffPathEl.textContent = f.path;
493
- if (f.binary) {
494
- state.diffEl.replaceChildren(el("div", { class: "tm-diff-stub", text: t("tm.diff.binary", "二进制文件,跳过逐行对比。") }));
495
- } else {
496
- loadFileDiff(state, f.path);
497
- }
498
- });
547
+ node.addEventListener("click", () => selectDetailFile(state, task, f, node));
499
548
  return node;
500
549
  });
501
- state.filesEl.replaceChildren(...fileNodes);
502
- fileNodes[0].click();
503
- }
504
-
505
- function closeDrawer(state) {
506
- state.maskEl.classList.remove("open");
507
- state.drawerEl.classList.remove("open");
508
- setTimeout(() => {
509
- state.maskEl.style.display = "none";
510
- state.drawerEl.style.display = "none";
511
- }, 250);
512
- state.selected = null;
550
+ state.detailFilesEl.replaceChildren(...fileNodes);
551
+ selectDetailFile(state, task, files[0], fileNodes[0]);
513
552
  }
514
553
 
515
- async function performRestore(state) {
516
- state.restoreBtn.disabled = true;
517
- state.footEl.textContent = t("tm.restoring", "正在恢复…");
518
- try {
519
- const res = await api(state.sessionId, "/switch", {
520
- method: "POST",
521
- headers: { "Content-Type": "application/json" },
522
- body: JSON.stringify({ task_id: state.selected }),
523
- });
524
- if (res.ok) {
525
- state.footEl.textContent = res.message || t("tm.restored", "已恢复");
526
- closeDrawer(state);
527
- await loadHistory(state);
528
- } else {
529
- state.footEl.textContent = res.error || t("tm.restoreFailed", "恢复失败");
530
- state.restoreBtn.disabled = false;
531
- }
532
- } catch (_e) {
533
- state.footEl.textContent = t("tm.restoreFailed", "恢复失败");
534
- state.restoreBtn.disabled = false;
554
+ function selectDetailFile(state, task, f, node) {
555
+ state.detailSelected = f.path;
556
+ state.detailFilesEl.querySelectorAll(".tm-file.active").forEach((n) => n.classList.remove("active"));
557
+ node.classList.add("active");
558
+ state.panelEl.classList.add("has-detail-diff");
559
+ ensureAsideWidth();
560
+ state.detailDiffPathEl.textContent = f.path;
561
+ if (f.binary) {
562
+ state.detailDiffEl.replaceChildren(el("div", { class: "tm-diff-stub", text: t("tm.diff.binary", "二进制文件,跳过逐行对比。") }));
563
+ } else {
564
+ loadFileDiff(state, task.task_id, f.path);
535
565
  }
536
566
  }
537
567
 
@@ -552,99 +582,50 @@
552
582
  renderTimeline(state);
553
583
  }
554
584
 
555
- // The drawer is global — only one can be open at a time across mounts, and
556
- // it lives on document.body so it can escape the narrow aside column.
557
- function buildDrawer() {
558
- if (document.getElementById("tm-drawer-root")) {
559
- return {
560
- mask: document.querySelector(".tm-drawer-mask"),
561
- drawer: document.getElementById("tm-drawer-root"),
562
- title: document.querySelector(".tm-drawer-title"),
563
- time: document.querySelector(".tm-drawer-time"),
564
- restore: document.querySelector(".tm-restore-btn"),
565
- close: document.querySelector(".tm-drawer-close"),
566
- confirmRow: document.querySelector(".tm-confirm-row"),
567
- confirmYes: document.querySelector(".tm-confirm-btn.go"),
568
- confirmNo: document.querySelector(".tm-confirm-btn:not(.go)"),
569
- files: document.querySelector(".tm-files"),
570
- diff: document.querySelector(".tm-diff"),
571
- diffPath: document.querySelector(".tm-diff-path"),
572
- };
573
- }
574
-
575
- const mask = el("div", { class: "tm-drawer-mask" });
576
- const titleEl = el("div", { class: "tm-drawer-title" });
577
- const timeEl = el("div", { class: "tm-drawer-time" });
578
- const restoreBtn = el("button", { class: "tm-restore-btn", type: "button", text: t("tm.restore.go", "回到这里") });
579
- const closeBtn = el("button", { class: "tm-drawer-close", type: "button", text: t("tm.detail.close", "关闭") });
580
- const head = el("div", { class: "tm-drawer-head" },
581
- el("div", { class: "tm-drawer-title-wrap" }, titleEl, timeEl),
582
- restoreBtn, closeBtn,
583
- );
584
-
585
- const confirmYes = el("button", { class: "tm-confirm-btn go", type: "button", text: t("tm.restore.confirm", "确认恢复") });
586
- const confirmNo = el("button", { class: "tm-confirm-btn", type: "button", text: t("tm.restore.cancel", "取消") });
587
- const confirmRow = el("div", { class: "tm-confirm-row" },
588
- el("span", { class: "tm-confirm-msg", text: t("tm.restore.msg", "回到这一步会把文件恢复到当时的状态。") }),
589
- confirmYes, confirmNo,
590
- );
591
- confirmRow.style.display = "none";
592
-
593
- const filesEl = el("div", { class: "tm-files" });
594
- const diffPathEl = el("div", { class: "tm-diff-path" });
595
- const diffEl = el("div", { class: "tm-diff" });
596
- const diffWrap = el("div", { class: "tm-diff-wrap" }, diffPathEl, diffEl);
597
- const body = el("div", { class: "tm-drawer-body" }, filesEl, diffWrap);
598
-
599
- const drawer = el("div", { id: "tm-drawer-root", class: "tm-drawer" }, head, confirmRow, body);
600
- drawer.style.display = "none";
601
- mask.style.display = "none";
602
-
603
- document.body.appendChild(mask);
604
- document.body.appendChild(drawer);
605
-
606
- return { mask, drawer, title: titleEl, time: timeEl, restore: restoreBtn, close: closeBtn,
607
- confirmRow, confirmYes, confirmNo, files: filesEl, diff: diffEl, diffPath: diffPathEl };
608
- }
609
-
610
585
  Clacky.ext.ui.mount("session.aside", {
611
586
  create(ctx) {
612
587
  const list = el("div", { class: "tm-list" });
613
588
  const foot = el("div", { class: "tm-foot", text: t("tm.foot", "每完成一步会自动存档。点击想回到的版本即可恢复。") });
614
- const root = el("div", { class: "tm-panel", "data-panel": "tm" }, list, foot);
615
589
 
616
- const d = buildDrawer();
590
+ const backBtn = el("button", { class: "tm-detail-back", type: "button", title: t("tm.detail.back", "返回") });
591
+ backBtn.innerHTML = ICON_BACK;
592
+ const detailTitleEl = el("div", { class: "tm-detail-title" });
593
+ const detailTimeEl = el("div", { class: "tm-detail-time" });
594
+ const detailRestoreEl = el("button", { class: "tm-detail-restore", type: "button", text: t("tm.restore.go", "回到这里") });
595
+ const detailHead = el("div", { class: "tm-detail-head" },
596
+ backBtn,
597
+ el("div", { class: "tm-detail-title-wrap" }, detailTitleEl, detailTimeEl),
598
+ detailRestoreEl,
599
+ );
600
+
601
+ const detailFilesEl = el("div", { class: "tm-detail-files" });
602
+ const diffCloseBtn = el("button", { class: "tm-detail-diff-close", type: "button", title: t("tm.diff.close", "关闭") });
603
+ diffCloseBtn.innerHTML = ICON_CLOSE;
604
+ const detailDiffPathEl = el("span", { class: "tm-detail-diff-path" });
605
+ const detailDiffEl = el("div", { class: "tm-diff" });
606
+ const detailDiff = el("div", { class: "tm-detail-diff" },
607
+ el("div", { class: "tm-detail-diff-head" }, detailDiffPathEl, diffCloseBtn),
608
+ detailDiffEl,
609
+ );
610
+ const detailBody = el("div", { class: "tm-detail-body" }, detailFilesEl, detailDiff);
611
+ const detail = el("div", { class: "tm-detail" }, detailHead, detailBody);
612
+
613
+ const root = el("div", { class: "tm-panel", "data-panel": "tm" }, list, detail, foot);
617
614
 
618
615
  const state = {
619
616
  sessionId: ctx.sessionId,
620
617
  tasks: [],
621
- selected: null,
622
618
  expanded: null,
619
+ view: "timeline",
620
+ detailTask: null,
621
+ detailSelected: null,
623
622
  panelEl: root, listEl: list, footEl: foot,
624
- maskEl: d.mask, drawerEl: d.drawer,
625
- drawerTitleEl: d.title, drawerTimeEl: d.time,
626
- restoreBtn: d.restore, filesEl: d.files, diffEl: d.diff, diffPathEl: d.diffPath, confirmRow: d.confirmRow,
627
- };
628
-
629
- const onClose = () => closeDrawer(state);
630
- const onRestoreClick = () => {
631
- d.confirmRow.style.display = "flex";
632
- d.restore.disabled = true;
633
- };
634
- const onCancelClick = () => {
635
- d.confirmRow.style.display = "none";
636
- d.restore.disabled = false;
623
+ detailTitleEl, detailTimeEl, detailRestoreEl, detailFilesEl, detailDiffPathEl, detailDiffEl,
637
624
  };
638
- const onGoClick = () => performRestore(state);
639
- const onMaskClick = () => closeDrawer(state);
640
- const onKey = (e) => { if (e.key === "Escape" && d.drawer.classList.contains("open")) closeDrawer(state); };
641
625
 
642
- d.close.onclick = onClose;
643
- d.restore.onclick = onRestoreClick;
644
- d.confirmNo.onclick = onCancelClick;
645
- d.confirmYes.onclick = onGoClick;
646
- d.mask.onclick = onMaskClick;
647
- document.addEventListener("keydown", onKey);
626
+ backBtn.addEventListener("click", () => closeDetail(state));
627
+ detailRestoreEl.addEventListener("click", () => onDetailRestoreClick(state));
628
+ diffCloseBtn.addEventListener("click", () => closeDetailDiff(state));
648
629
 
649
630
  _activeState = state;
650
631
  _hookWs();
@@ -655,9 +636,7 @@
655
636
  state,
656
637
  root,
657
638
  dispose() {
658
- document.removeEventListener("keydown", onKey);
659
- try { d.mask.remove(); } catch (_) {}
660
- try { d.drawer.remove(); } catch (_) {}
639
+ if (detailRestoreTimer) { clearTimeout(detailRestoreTimer); detailRestoreTimer = null; }
661
640
  if (_activeState === state) _activeState = null;
662
641
  },
663
642
  };
@@ -666,8 +645,7 @@
666
645
  container.appendChild(runtime.root);
667
646
  _activeState = runtime.state;
668
647
  },
669
- }, {
670
- order: 20,
648
+ }, { order: 40,
671
649
  tab: { id: "tm", label: () => t("tm.tab") },
672
650
  });
673
651
  })();
@@ -300,10 +300,12 @@ module Clacky
300
300
  ) do |io| # rubocop:disable Security/Open
301
301
  File.open(dest, "wb") { |out| out.write(io.read) }
302
302
  end
303
- rescue OpenURI::HTTPError, SocketError => e
304
- raise Error, "failed to download #{url}: #{e.message}"
305
303
  rescue Net::OpenTimeout, Net::ReadTimeout => e
306
304
  raise Error, "download timed out: #{e.message}"
305
+ # Catch-all so any transport-layer failure triggers download()'s
306
+ # secondary-host fallback instead of surfacing a raw error.
307
+ rescue StandardError => e
308
+ raise Error, "failed to download #{url}: #{e.message}"
307
309
  end
308
310
 
309
311
  private def extract_zip(zip_path, dest_root)
@@ -16,7 +16,7 @@ module Clacky
16
16
  module ExtensionVerifier
17
17
  Issue = Struct.new(:ext, :unit, :level, :code, :message, :file, :hint, keyword_init: true)
18
18
 
19
- KNOWN_TOP_KEYS = %w[id name name_zh display_name display_name_zh title description description_zh emoji version origin author homepage license public license_required keywords contributes].freeze
19
+ KNOWN_TOP_KEYS = %w[id name name_zh display_name display_name_zh title description description_zh emoji version origin author homepage license public license_required keywords config contributes].freeze
20
20
  KNOWN_CONTRIBUTES = %w[panels api skills agents channels patches hooks tools].freeze
21
21
 
22
22
  PANEL_KEYS = %w[id title title_zh description description_zh view order attach entry_points].freeze
@@ -29,6 +29,7 @@ module Clacky
29
29
  TOOL_KEYS = %w[id file].freeze
30
30
 
31
31
  ATTACH_TOKEN_RE = /\A(\*|[\w\-]+)\z/.freeze
32
+ VERSION_RE = /\A[0-9]+\.[0-9]+\.[0-9]+\z/.freeze
32
33
 
33
34
  class << self
34
35
  # Run all checks against a fully-loaded ExtensionLoader::Result. Returns
@@ -43,6 +44,10 @@ module Clacky
43
44
  issues
44
45
  end
45
46
 
47
+ def valid_version?(version)
48
+ version.to_s.match?(VERSION_RE)
49
+ end
50
+
46
51
  private def loader_errors_as_issues(result)
47
52
  Array(result.errors).map do |err|
48
53
  Issue.new(
@@ -78,6 +83,14 @@ module Clacky
78
83
  )
79
84
  end
80
85
 
86
+ if manifest.key?("version") && !valid_version?(manifest["version"])
87
+ issues << Issue.new(
88
+ ext: ext_id, unit: nil, level: :error, code: "schema.invalid_version",
89
+ message: "version must contain exactly three numeric segments (for example, 1.0.0)",
90
+ file: File.join(dir, "ext.yml"), hint: "Use the format x.x.x with digits only."
91
+ )
92
+ end
93
+
81
94
  contributes = manifest["contributes"]
82
95
  next unless contributes.is_a?(Hash)
83
96
 
@@ -215,7 +215,11 @@ module Clacky
215
215
  #
216
216
  # Zero side-effects: when reasoning_effort is nil/empty the body is
217
217
  # unchanged, preserving the provider default for all models.
218
- private_class_method def self.apply_reasoning_params(body, model, reasoning_effort)
218
+ #
219
+ # Single source of truth for reasoning param mapping — shared with
220
+ # MessageFormat::OpenAIResponses (the Responses API accepts the same
221
+ # top-level fields).
222
+ def self.apply_reasoning_params(body, model, reasoning_effort)
219
223
  effort_str = reasoning_effort.to_s
220
224
 
221
225
  if model.to_s.match?(/\Aglm-5[-.]3/i)