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
@@ -4,19 +4,31 @@
4
4
  // built-in git API (GET/POST /api/sessions/:id/git/*). Mounted as a tab in the
5
5
  // "session.aside" slot, attached to agents via `attach: [coding]` in ext.yml.
6
6
  //
7
+ // Layout mirrors the Files tab: changed files on the left (each with a
8
+ // checkbox), clicking a file shows its diff on the right and widens the aside
9
+ // to make room. "Save snapshot" prompts for an editable message (pre-filled
10
+ // with a timestamped default) and commits only the checked files. Each file
11
+ // can also be reverted to its HEAD state via a two-step inline confirm
12
+ // (first click arms a red "Discard?" button, second click fires POST
13
+ // /git/restore) - the same pattern the Time Machine uses for its
14
+ // destructive switch, no blocking dialog needed.
15
+ //
7
16
  // Deliberately hides git jargon: no porcelain status codes (M/??), no
8
- // branch/ahead/behind unless the branch is NOT the main line (main/master) —
9
- // then it's surfaced as a gentle notice. The only write is a zero-input
10
- // "save version" that auto-generates the commit message.
17
+ // branch/ahead/behind unless the branch is NOT the main line (main/master) -
18
+ // then it's surfaced as a gentle notice.
11
19
  //
12
- // Native DOM + textContent on all git output (paths, branch) so nothing can
13
- // inject. tab.badge tracks the number of changed files.
20
+ // Native DOM + textContent on all git output (paths, branch, diff) so nothing
21
+ // can inject. tab.badge tracks the number of changed files.
14
22
  // ───────────────────────────────────────────────────────────────────────────
15
23
 
16
24
  (() => {
17
25
  if (!window.Clacky || !Clacky.ext) return;
18
26
 
19
27
  const MAIN_BRANCHES = { main: true, master: true };
28
+ const DIFF_ASIDE_WIDTH = 860;
29
+ const MAX_DIFF_LINES = 3000;
30
+ 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>';
31
+ const ICON_REVERT = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>';
20
32
  const t = (k, fallback) => {
21
33
  const v = (typeof I18n !== "undefined") ? I18n.t(k) : null;
22
34
  return (v && v !== k) ? v : fallback;
@@ -27,24 +39,53 @@
27
39
  style.id = "changes-panel-style";
28
40
  style.textContent = `
29
41
  .changes-panel { display: flex; flex-direction: column; flex: 1; min-height: 0; }
30
- .changes-summary { flex: none; padding: 14px 16px 10px; border-bottom: 1px solid var(--color-border-secondary); }
42
+ .changes-summary { flex: none; padding: 10px 12px 8px; border-bottom: 1px solid var(--color-border-secondary); }
31
43
  .changes-summary .h { font-size: 13px; color: var(--color-text-secondary); }
32
44
  .changes-summary .h b { color: var(--color-text-primary); }
33
- .changes-summary .sub { font-size: 12px; color: var(--color-text-tertiary); margin-top: 3px; }
45
+ .changes-summary .sub { font-size: 12px; color: var(--color-text-tertiary); margin-top: 2px; }
34
46
  .changes-branch { display: flex; align-items: center; gap: 6px; margin-top: 8px; padding: 5px 8px; border-radius: var(--radius-sm); background: var(--color-warning-bg); color: var(--color-warning); font-size: 11.5px; }
35
47
  .changes-branch code { font-family: ui-monospace, monospace; font-weight: 600; }
36
- .changes-list { flex: 1; min-height: 0; overflow: auto; padding: 6px 8px; }
37
- .change-row { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: var(--radius-sm); }
48
+ .cg-body { flex: 1; min-height: 0; display: flex; }
49
+ .cg-list-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 0; }
50
+ .changes-panel.has-diff .cg-list-pane { flex: 0 0 232px; border-right: 1px solid var(--color-border-primary); }
51
+ .cg-listbar { flex: none; display: flex; align-items: center; gap: 6px; padding: 6px 10px; border-bottom: 1px solid var(--color-border-secondary); font-size: 11px; color: var(--color-text-secondary); user-select: none; }
52
+ .cg-listbar .cg-check { cursor: pointer; }
53
+ .cg-count { margin-left: auto; color: var(--color-text-tertiary); }
54
+ .cg-check { flex: none; margin: 0; }
55
+ .changes-list { flex: 1; min-height: 0; overflow: auto; padding: 4px 6px; }
56
+ .change-row { display: flex; align-items: center; gap: 7px; padding: 6px 6px; border-radius: var(--radius-sm); cursor: pointer; }
38
57
  .change-row:hover { background: var(--color-bg-hover); }
58
+ .change-row.active { background: var(--color-accent-soft); }
39
59
  .change-tag { flex: none; font-size: 11px; padding: 1px 7px; border-radius: var(--radius-pill); font-weight: 500; }
40
60
  .change-tag.add { background: var(--color-success-bg); color: var(--color-success); }
41
61
  .change-tag.mod { background: #eff6ff; color: #2563eb; }
42
62
  .change-tag.del { background: var(--color-error-bg); color: var(--color-error); }
43
63
  .change-path { font-size: 13px; color: var(--color-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
44
64
  .change-dir { color: var(--color-text-tertiary); }
45
- .changes-foot { flex: none; padding: 12px 16px; border-top: 1px solid var(--color-border-primary); }
65
+ .change-revert { flex: none; display: inline-flex; visibility: hidden; align-items: center; justify-content: center; height: 22px; min-width: 22px; padding: 0 4px; margin-left: auto; border: none; border-radius: var(--radius-sm); background: transparent; color: var(--color-text-tertiary); font-size: 11px; cursor: pointer; }
66
+ .change-row:hover .change-revert { visibility: visible; }
67
+ .change-revert:hover { background: var(--color-error-bg); color: var(--color-error); }
68
+ .change-revert.armed { visibility: visible; background: var(--color-error-bg); color: var(--color-error); font-weight: 600; }
69
+ .cg-diff-pane { display: none; flex: 1; min-width: 0; min-height: 0; flex-direction: column; }
70
+ .changes-panel.has-diff .cg-diff-pane { display: flex; }
71
+ .cg-diff-head { flex: none; display: flex; align-items: center; gap: 8px; padding: 6px 8px 6px 12px; border-bottom: 1px solid var(--color-border-secondary); }
72
+ .cg-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; }
73
+ .cg-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; }
74
+ .cg-diff-close:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
75
+ .cg-diff-revert { flex: none; display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border: 1px solid var(--color-error, #b03a3a); border-radius: var(--radius-sm); background: transparent; color: var(--color-error); font-size: 11px; cursor: pointer; white-space: nowrap; }
76
+ .cg-diff-revert:hover { background: var(--color-error-bg); }
77
+ .cg-diff-revert.armed { background: var(--color-error, #b03a3a); border-color: var(--color-error, #b03a3a); color: #fff; font-weight: 600; }
78
+ .cg-diff-revert:disabled { opacity: 0.5; cursor: default; }
79
+ .cg-diff { flex: 1; min-height: 0; overflow: auto; padding: 8px 0; font-family: ui-monospace, monospace; font-size: 11.5px; line-height: 1.6; }
80
+ .cg-diff-line { white-space: pre; padding: 0 12px; min-width: max-content; }
81
+ .cg-diff-line.add { background: var(--color-success-soft, #1f6e2c33); color: var(--color-success, #4eb965); }
82
+ .cg-diff-line.del { background: var(--color-error-soft, #b03a3a33); color: var(--color-error); }
83
+ .cg-diff-line.hunk { color: var(--color-text-tertiary); margin-top: 6px; }
84
+ .cg-diff-line.meta { color: var(--color-text-tertiary); }
85
+ .cg-diff-stub { color: var(--color-text-tertiary); padding: 20px 12px; text-align: center; }
86
+ .changes-foot { flex: none; padding: 10px 12px; border-top: 1px solid var(--color-border-primary); }
46
87
  .changes-save-btn { width: 100%; padding: 9px; border: none; border-radius: var(--radius-md); background: var(--color-accent-primary); color: var(--color-text-inverse); font-size: 13px; font-weight: 500; cursor: pointer; }
47
- .changes-save-btn:hover:not(:disabled) { background: var(--color-accent-hover); }
88
+ .changes-save-btn:hover:not(:disabled) { background: var(--color-button-primary-hover); }
48
89
  .changes-save-btn:disabled { opacity: 0.5; cursor: default; }
49
90
  .changes-hint { text-align: center; font-size: 11px; color: var(--color-text-tertiary); margin-top: 7px; min-height: 1em; }
50
91
  .changes-empty, .changes-loading, .changes-error { color: var(--color-text-tertiary); padding: 16px; font-size: 12px; text-align: center; }
@@ -99,112 +140,477 @@
99
140
  return `${t("changes.save.prefix", "手动存档")} · ${stamp}`;
100
141
  }
101
142
 
102
- function renderFiles(files) {
103
- const list = el("div", { class: "changes-list" });
104
- files.forEach((f) => {
143
+ // The diff pane needs horizontal room; widen the aside once when a diff is
144
+ // first opened (same trick as the Files tab, still user-resizable after).
145
+ function ensureAsideWidth() {
146
+ const aside = document.getElementById("session-aside");
147
+ if (!aside) return;
148
+ let w = 0;
149
+ try {
150
+ w = parseFloat(getComputedStyle(aside).getPropertyValue("--session-aside-width")) || 0;
151
+ } catch (_e) { w = 0; }
152
+ if (w < DIFF_ASIDE_WIDTH) {
153
+ aside.style.setProperty("--session-aside-width", DIFF_ASIDE_WIDTH + "px");
154
+ try { localStorage.setItem("clacky.aside.width", String(DIFF_ASIDE_WIDTH)); } catch (_e) { /* non-fatal */ }
155
+ }
156
+ }
157
+
158
+ // ── Panel state ─────────────────────────────────────────────────────────
159
+ // The skeleton is built once per mount; refresh() only updates the summary,
160
+ // the file list and the badge. `checked` / `selected` survive refreshes so a
161
+ // re-render (e.g. after the agent finishes a round) doesn't wipe the user's
162
+ // selection. Files that disappear drop out of the sets; new files start
163
+ // checked.
164
+
165
+ const state = {
166
+ sessionId: null,
167
+ files: [],
168
+ checked: new Set(),
169
+ allPaths: new Set(),
170
+ selected: null,
171
+ els: null,
172
+ ctx: null,
173
+ };
174
+
175
+ function checkedCount() {
176
+ return state.files.filter((f) => state.checked.has(f.path)).length;
177
+ }
178
+
179
+ function updateFoot() {
180
+ if (!state.els) return;
181
+ const { saveBtn, allCheck, countLabel } = state.els;
182
+ const total = state.files.length;
183
+ const n = checkedCount();
184
+
185
+ if (countLabel) {
186
+ countLabel.textContent = total
187
+ ? t("changes.select.count", "已选 {{n}}/{{total}}")
188
+ .replace("{{n}}", String(n)).replace("{{total}}", String(total))
189
+ : "";
190
+ }
191
+ if (allCheck) {
192
+ allCheck.disabled = total === 0;
193
+ allCheck.checked = total > 0 && n === total;
194
+ allCheck.indeterminate = n > 0 && n < total;
195
+ }
196
+ if (saveBtn) {
197
+ saveBtn.disabled = n === 0;
198
+ saveBtn.textContent = (n === 0 || n === total)
199
+ ? t("changes.save.btn", "存档当前版本")
200
+ : t("changes.save.btnN", "存档选中的 {{n}} 个文件").replace("{{n}}", String(n));
201
+ }
202
+ }
203
+
204
+ function syncRowChecks() {
205
+ if (!state.els) return;
206
+ state.els.listEl.querySelectorAll(".change-row").forEach((row) => {
207
+ const check = row.querySelector(".cg-check");
208
+ if (check) check.checked = state.checked.has(row.dataset.path);
209
+ });
210
+ }
211
+
212
+ function highlightSelected() {
213
+ if (!state.els) return;
214
+ state.els.listEl.querySelectorAll(".change-row.active").forEach((r) => r.classList.remove("active"));
215
+ if (!state.selected) return;
216
+ const row = state.els.listEl.querySelector(`.change-row[data-path="${CSS.escape(state.selected)}"]`);
217
+ if (row) row.classList.add("active");
218
+ }
219
+
220
+ function renderList() {
221
+ if (!state.els) return;
222
+ disarmRevert();
223
+ const list = state.els.listEl;
224
+ list.replaceChildren();
225
+ if (!state.files.length) {
226
+ list.appendChild(el("div", { class: "changes-empty", text: t("changes.clean", "工作区是干净的,没有未存档的改动。") }));
227
+ return;
228
+ }
229
+ state.files.forEach((f) => {
105
230
  const kind = classify(f);
106
231
  const { dir, name } = splitPath(f.path);
107
232
  const path = el("span", { class: "change-path" });
108
233
  if (dir) path.appendChild(el("span", { class: "change-dir", text: dir }));
109
234
  path.appendChild(document.createTextNode(name));
110
- list.appendChild(el("div", { class: "change-row" },
111
- el("span", { class: `change-tag ${kind}`, text: TAG_LABEL[kind]() }),
112
- path,
113
- ));
235
+
236
+ const check = el("input", { class: "cg-check", type: "checkbox" });
237
+ check.checked = state.checked.has(f.path);
238
+ check.addEventListener("click", (e) => e.stopPropagation());
239
+ check.addEventListener("change", () => {
240
+ if (check.checked) state.checked.add(f.path);
241
+ else state.checked.delete(f.path);
242
+ updateFoot();
243
+ });
244
+
245
+ const revert = el("button", { class: "change-revert" });
246
+ revert.type = "button";
247
+ revert.title = t("changes.restore.title", "撤销此文件的改动");
248
+ revert.innerHTML = ICON_REVERT;
249
+ revert.addEventListener("click", (e) => {
250
+ e.stopPropagation();
251
+ onRevertClick(revert, f.path);
252
+ });
253
+
254
+ const row = el("div", { class: "change-row" }, check,
255
+ el("span", { class: `change-tag ${kind}`, text: TAG_LABEL[kind]() }), path, revert);
256
+ row.dataset.path = f.path;
257
+ row.title = f.path;
258
+ row.addEventListener("click", () => selectFile(f.path));
259
+ list.appendChild(row);
114
260
  });
115
- return list;
261
+ highlightSelected();
262
+ }
263
+
264
+ function renderDiffText(diffText) {
265
+ const { diffCanvas } = state.els;
266
+ diffCanvas.replaceChildren();
267
+ if (!diffText || !diffText.trim()) {
268
+ diffCanvas.appendChild(el("div", { class: "cg-diff-stub", text: t("changes.diff.same", "这个文件的内容没有变化。") }));
269
+ return;
270
+ }
271
+ if (/^Binary files .* differ$/m.test(diffText)) {
272
+ diffCanvas.appendChild(el("div", { class: "cg-diff-stub", text: t("changes.diff.binary", "二进制文件,无法逐行对比。") }));
273
+ return;
274
+ }
275
+ const lines = diffText.split("\n");
276
+ const shown = Math.min(lines.length, MAX_DIFF_LINES);
277
+ const frag = document.createDocumentFragment();
278
+ for (let i = 0; i < shown; i++) {
279
+ const line = lines[i];
280
+ let cls = "cg-diff-line";
281
+ if (line.startsWith("@@")) cls += " hunk";
282
+ else if (line.startsWith("+") && !line.startsWith("+++")) cls += " add";
283
+ else if (line.startsWith("-") && !line.startsWith("---")) cls += " del";
284
+ else if (line.startsWith("diff --git") || line.startsWith("index ") ||
285
+ line.startsWith("---") || line.startsWith("+++")) cls += " meta";
286
+ frag.appendChild(el("div", { class: cls, text: line || " " }));
287
+ }
288
+ diffCanvas.appendChild(frag);
289
+ if (lines.length > MAX_DIFF_LINES) {
290
+ diffCanvas.appendChild(el("div", { class: "cg-diff-stub",
291
+ text: t("changes.diff.truncated", "差异过大,仅显示前 {{n}} 行").replace("{{n}}", String(MAX_DIFF_LINES)) }));
292
+ }
293
+ }
294
+
295
+ async function loadDiff(path) {
296
+ const { diffPathEl, diffCanvas } = state.els;
297
+ diffPathEl.textContent = path;
298
+ diffCanvas.replaceChildren(el("div", { class: "cg-diff-stub", text: t("changes.diff.loading", "正在读取差异…") }));
299
+ let res;
300
+ try {
301
+ res = await api(state.sessionId, `diff?file=${encodeURIComponent(path)}`);
302
+ } catch (_e) {
303
+ if (state.selected === path) {
304
+ diffCanvas.replaceChildren(el("div", { class: "cg-diff-stub", text: t("changes.diff.fail", "读取差异失败") }));
305
+ }
306
+ return;
307
+ }
308
+ if (state.selected !== path) return;
309
+ renderDiffText(res.diff || "");
310
+ }
311
+
312
+ function selectFile(path) {
313
+ if (state.selected === path) return;
314
+ state.selected = path;
315
+ state.els.panelEl.classList.add("has-diff");
316
+ highlightSelected();
317
+ ensureAsideWidth();
318
+ loadDiff(path);
319
+ }
320
+
321
+ function closeDiff() {
322
+ if (!state.els) return;
323
+ state.selected = null;
324
+ state.els.panelEl.classList.remove("has-diff");
325
+ state.els.diffPathEl.textContent = "";
326
+ state.els.diffCanvas.replaceChildren();
327
+ highlightSelected();
328
+ }
329
+
330
+ async function doCommit(sessionId, ctx) {
331
+ const { saveBtn, hint } = state.els;
332
+ const paths = state.files.filter((f) => state.checked.has(f.path)).map((f) => f.path);
333
+ if (!paths.length) return;
334
+
335
+ const message = await Modal.prompt(t("changes.save.promptMsg", "给这个版本写句说明"), autoMessage());
336
+ if (message == null) return;
337
+
338
+ saveBtn.disabled = true;
339
+ hint.textContent = t("changes.save.saving", "正在存档…");
340
+ try {
341
+ const res = await api(sessionId, "commit", {
342
+ method: "POST",
343
+ headers: { "Content-Type": "application/json" },
344
+ body: JSON.stringify({ message: message, files: paths }),
345
+ });
346
+ if (res.ok) {
347
+ hint.textContent = t("changes.save.done", "已存档");
348
+ refresh(sessionId, ctx);
349
+ } else {
350
+ hint.textContent = res.error || t("changes.save.failed", "存档失败");
351
+ saveBtn.disabled = false;
352
+ }
353
+ } catch (_e) {
354
+ hint.textContent = t("changes.save.failed", "存档失败");
355
+ saveBtn.disabled = false;
356
+ }
357
+ }
358
+
359
+ // ── Revert (single file, destructive) ────────────────────────────────────
360
+ // Two-step inline confirm: the first click arms the button into a red
361
+ // "Discard?" for a few seconds, the second click fires the request. Arming
362
+ // a button disarms any other, so at most one confirm is ever pending.
363
+
364
+ let revertArmed = null;
365
+ let revertTimer = null;
366
+ let revertPrevHtml = null;
367
+
368
+ function disarmRevert() {
369
+ if (revertTimer) { clearTimeout(revertTimer); revertTimer = null; }
370
+ const btn = revertArmed;
371
+ revertArmed = null;
372
+ if (!btn || !btn.isConnected) return;
373
+ btn.classList.remove("armed");
374
+ btn.innerHTML = revertPrevHtml;
375
+ }
376
+
377
+ function armRevert(btn) {
378
+ disarmRevert();
379
+ revertArmed = btn;
380
+ revertPrevHtml = btn.innerHTML;
381
+ btn.classList.add("armed");
382
+ btn.replaceChildren(document.createTextNode(t("changes.restore.confirm", "确认丢弃?")));
383
+ revertTimer = setTimeout(disarmRevert, 4000);
384
+ }
385
+
386
+ function onRevertClick(btn, path) {
387
+ if (btn.classList.contains("armed")) {
388
+ disarmRevert();
389
+ doRevert(path);
390
+ } else {
391
+ armRevert(btn);
392
+ }
393
+ }
394
+
395
+ async function doRevert(path) {
396
+ const { hint } = state.els;
397
+ hint.textContent = t("changes.restore.doing", "正在撤销…");
398
+ let res;
399
+ try {
400
+ res = await api(state.sessionId, "restore", {
401
+ method: "POST",
402
+ headers: { "Content-Type": "application/json" },
403
+ body: JSON.stringify({ file: path }),
404
+ });
405
+ } catch (_e) {
406
+ hint.textContent = t("changes.restore.failed", "撤销失败");
407
+ return;
408
+ }
409
+ if (!res.ok) {
410
+ hint.textContent = res.error || t("changes.restore.failed", "撤销失败");
411
+ return;
412
+ }
413
+ hint.textContent = t("changes.restore.done", "已撤销:{{path}}").replace("{{path}}", path);
414
+ refresh(state.sessionId, state.ctx);
415
+ }
416
+
417
+ function renderNotice(message, cls) {
418
+ if (!state.els) return;
419
+ closeDiff();
420
+ state.els.h.textContent = "";
421
+ state.els.sub.textContent = "";
422
+ state.els.branchNote.style.display = "none";
423
+ state.els.listEl.replaceChildren(el("div", { class: cls, text: message }));
424
+ state.files = [];
425
+ state.allPaths = new Set();
426
+ state.checked = new Set();
427
+ updateFoot();
116
428
  }
117
429
 
118
- async function refresh(sessionId, root, body, ctx) {
119
- body.replaceChildren(el("div", { class: "changes-loading", text: t("changes.loading", "正在读取改动…") }));
430
+ async function refresh(sessionId, ctx) {
431
+ if (!state.els) return;
432
+ const { h, sub, branchNote, branchCode } = state.els;
120
433
 
121
434
  let status;
122
435
  try {
123
436
  status = await api(sessionId, "status");
124
437
  } catch (_e) {
125
- body.replaceChildren(el("div", { class: "changes-error", text: t("changes.error", "读取改动失败") }));
438
+ renderNotice(t("changes.error", "读取改动失败"), "changes-error");
126
439
  return;
127
440
  }
128
441
  if (!status.repo) {
129
442
  if (ctx && ctx.setBadge) ctx.setBadge(null);
130
- body.replaceChildren(el("div", { class: "changes-empty", text: t("changes.noRepo", "这个项目还没有启用版本管理。") }));
443
+ renderNotice(t("changes.noRepo", "这个项目还没有启用版本管理。"), "changes-empty");
131
444
  return;
132
445
  }
133
446
 
447
+ state.sessionId = sessionId;
134
448
  const files = status.files || [];
449
+
450
+ const prevChecked = state.checked;
451
+ const prevAll = state.allPaths;
452
+ state.files = files;
453
+ state.allPaths = new Set(files.map((f) => f.path));
454
+ // Keep prior choices; files that just appeared default to checked.
455
+ state.checked = new Set(files.map((f) => f.path)
456
+ .filter((p) => prevChecked.has(p) || !prevAll.has(p)));
457
+
135
458
  if (ctx && ctx.setBadge) ctx.setBadge(files.length || null);
136
459
 
137
460
  const count = files.length;
138
- const summary = el("div", { class: "changes-summary" });
139
- const h = el("div", { class: "h" });
140
461
  if (count === 0) {
141
462
  h.textContent = t("changes.cleanTitle", "暂无改动");
142
463
  } else {
143
- h.appendChild(document.createTextNode(t("changes.changedPre", "AI 改了 ")));
144
- h.appendChild(el("b", { text: `${count} ${t("changes.filesUnit", "个文件")}` }));
464
+ h.replaceChildren(
465
+ document.createTextNode(t("changes.changedPre", "AI 改了 ")),
466
+ el("b", { text: `${count} ${t("changes.filesUnit", "个文件")}` }),
467
+ );
145
468
  }
146
- summary.appendChild(h);
147
- summary.appendChild(el("div", { class: "sub", text: t("changes.sub", "由 Git 管理 · 自上次存档以来") }));
469
+ sub.textContent = t("changes.sub", "由 Git 管理 · 自上次存档以来");
148
470
 
149
471
  const branch = (status.branch || "").trim();
150
472
  if (branch && !MAIN_BRANCHES[branch.toLowerCase()]) {
151
- const note = el("div", { class: "changes-branch" });
152
- note.appendChild(document.createTextNode(t("changes.branchPre", "当前分支:")));
153
- note.appendChild(el("code", { text: branch }));
154
- summary.appendChild(note);
473
+ branchCode.textContent = branch;
474
+ branchNote.style.display = "flex";
475
+ } else {
476
+ branchNote.style.display = "none";
155
477
  }
156
478
 
157
- const hint = el("div", { class: "changes-hint" });
158
- const saveBtn = el("button", { class: "changes-save-btn", type: "button", text: t("changes.save.btn", "存档当前版本") });
159
- saveBtn.disabled = count === 0;
160
- saveBtn.addEventListener("click", async () => {
161
- saveBtn.disabled = true;
162
- hint.textContent = t("changes.save.saving", "正在存档…");
163
- try {
164
- const paths = files.map((f) => f.path);
165
- const res = await api(sessionId, "commit", {
166
- method: "POST",
167
- headers: { "Content-Type": "application/json" },
168
- body: JSON.stringify({ message: autoMessage(), files: paths }),
169
- });
170
- if (res.ok) {
171
- hint.textContent = t("changes.save.done", "已存档,可在「时光机」里回到这个版本");
172
- refresh(sessionId, root, body, ctx);
173
- } else {
174
- hint.textContent = res.error || t("changes.save.failed", "存档失败");
175
- saveBtn.disabled = false;
176
- }
177
- } catch (_e) {
178
- hint.textContent = t("changes.save.failed", "存档失败");
179
- saveBtn.disabled = false;
479
+ renderList();
480
+ updateFoot();
481
+
482
+ if (state.selected) {
483
+ if (state.allPaths.has(state.selected)) {
484
+ loadDiff(state.selected);
485
+ } else {
486
+ closeDiff();
180
487
  }
181
- });
488
+ }
489
+ }
490
+
491
+ // The badge must be live from the moment the tab bar renders, not only after
492
+ // the tab body is lazily opened. These track the mounted body so the badge's
493
+ // subscriptions can also refresh the visible panel once it has been opened.
494
+ let activeRoot = null;
495
+ let activeBody = null;
496
+ let activeSessionId = null;
497
+
498
+ async function refreshBadge(sessionId, ctx) {
499
+ if (!ctx || !ctx.setBadge) return;
500
+ let status;
501
+ try {
502
+ status = await api(sessionId, "status");
503
+ } catch (_e) {
504
+ ctx.setBadge(null);
505
+ return;
506
+ }
507
+ ctx.setBadge(status.repo ? ((status.files || []).length || null) : null);
508
+ }
182
509
 
183
- body.replaceChildren(
184
- summary,
185
- count === 0 ? el("div", { class: "changes-empty", text: t("changes.clean", "工作区是干净的,没有未存档的改动。") }) : renderFiles(files),
186
- el("div", { class: "changes-foot" }, saveBtn, hint),
187
- );
510
+ // One status fetch per change event: refresh the body if it's already open
511
+ // (refresh() also updates the badge), otherwise just update the badge.
512
+ function onFilesChanged(sessionId, ctx) {
513
+ if (activeSessionId === sessionId && activeRoot && activeBody) {
514
+ refresh(sessionId, ctx);
515
+ } else {
516
+ refreshBadge(sessionId, ctx);
517
+ }
188
518
  }
189
519
 
190
520
  Clacky.ext.ui.mount("session.aside", (container, ctx) => {
191
521
  if (!ctx || !ctx.sessionId) return;
192
- const body = el("div", { class: "changes-panel" });
193
- const root = el("div", { class: "changes-root", "data-panel": "changes" }, body);
194
- container.appendChild(root);
195
- refresh(ctx.sessionId, root, body, ctx);
196
-
197
- // The agent only touches files during a round, so re-pull git status when
198
- // it finishes one. The returned function is the ext teardown, run when the
199
- // session switches away (otherwise the stale subscription would leak).
200
- const unsubscribe = Clacky.ext.subscribe("session:complete", (payload) => {
201
- if (payload && payload.sessionId === ctx.sessionId) {
202
- refresh(ctx.sessionId, root, body, ctx);
203
- }
522
+
523
+ const h = el("div", { class: "h" });
524
+ const sub = el("div", { class: "sub" });
525
+ const branchCode = el("code");
526
+ const branchNote = el("div", { class: "changes-branch" });
527
+ branchNote.appendChild(document.createTextNode(t("changes.branchPre", "当前分支:")));
528
+ branchNote.appendChild(branchCode);
529
+ branchNote.style.display = "none";
530
+
531
+ const allCheck = el("input", { class: "cg-check", type: "checkbox" });
532
+ allCheck.addEventListener("change", () => {
533
+ state.checked = allCheck.checked ? new Set(state.files.map((f) => f.path)) : new Set();
534
+ syncRowChecks();
535
+ updateFoot();
204
536
  });
205
- return unsubscribe;
537
+ const countLabel = el("span", { class: "cg-count" });
538
+ const listbar = el("div", { class: "cg-listbar" }, allCheck,
539
+ el("span", { text: t("changes.select.all", "全选") }), countLabel);
540
+
541
+ const listEl = el("div", { class: "changes-list" });
542
+ const listPane = el("div", { class: "cg-list-pane" }, listbar, listEl);
543
+
544
+ const diffPathEl = el("span", { class: "cg-diff-path" });
545
+ const diffRevert = el("button", { class: "cg-diff-revert", type: "button",
546
+ title: t("changes.restore.title", "撤销此文件的改动") });
547
+ diffRevert.innerHTML = ICON_REVERT;
548
+ diffRevert.appendChild(document.createTextNode(t("changes.restore.diffBtn", "撤销改动")));
549
+ diffRevert.addEventListener("click", () => {
550
+ if (state.selected) onRevertClick(diffRevert, state.selected);
551
+ });
552
+ const diffClose = el("button", { class: "cg-diff-close", type: "button", title: t("workspace.closeTab", "关闭") });
553
+ diffClose.innerHTML = ICON_CLOSE;
554
+ diffClose.addEventListener("click", closeDiff);
555
+ const diffCanvas = el("div", { class: "cg-diff" });
556
+ const diffPane = el("div", { class: "cg-diff-pane" },
557
+ el("div", { class: "cg-diff-head" }, diffPathEl, diffRevert, diffClose), diffCanvas);
558
+
559
+ const saveBtn = el("button", { class: "changes-save-btn", type: "button", text: t("changes.save.btn", "存档当前版本") });
560
+ const hint = el("div", { class: "changes-hint" });
561
+
562
+ const panel = el("div", { class: "changes-panel" },
563
+ el("div", { class: "changes-summary" }, h, sub, branchNote),
564
+ el("div", { class: "cg-body" }, listPane, diffPane),
565
+ el("div", { class: "changes-foot" }, saveBtn, hint));
566
+ saveBtn.addEventListener("click", () => doCommit(ctx.sessionId, ctx));
567
+
568
+ const root = el("div", { class: "changes-root", "data-panel": "changes" }, panel);
569
+ container.appendChild(root);
570
+
571
+ state.sessionId = ctx.sessionId;
572
+ state.ctx = ctx;
573
+ state.files = [];
574
+ state.checked = new Set();
575
+ state.allPaths = new Set();
576
+ state.selected = null;
577
+ state.els = { panelEl: panel, h, sub, branchNote, branchCode, allCheck, countLabel,
578
+ listEl, diffPathEl, diffCanvas, saveBtn, hint };
579
+
580
+ activeRoot = root;
581
+ activeBody = panel;
582
+ activeSessionId = ctx.sessionId;
583
+ refresh(ctx.sessionId, ctx);
206
584
  }, {
207
- order: 10,
208
- tab: { id: "changes", label: () => t("changes.tab") },
585
+ order: 30,
586
+ tab: {
587
+ id: "changes",
588
+ label: () => t("changes.tab"),
589
+ // Runs eagerly when the tab bar renders (before the user opens the tab):
590
+ // prime the badge, then keep it in sync as the agent finishes rounds or
591
+ // files are saved from the workspace viewer. The returned teardown is run
592
+ // on the next render pass / session switch.
593
+ onAttach(ctx) {
594
+ if (!ctx || !ctx.sessionId) return;
595
+ const sid = ctx.sessionId;
596
+ refreshBadge(sid, ctx);
597
+ const onChange = (payload) => {
598
+ if (payload && payload.sessionId === sid) onFilesChanged(sid, ctx);
599
+ };
600
+ const unsubscribeComplete = Clacky.ext.subscribe("session:complete", onChange);
601
+ const unsubscribeFileSaved = Clacky.ext.subscribe("workspace:fileSaved", onChange);
602
+ return () => {
603
+ unsubscribeComplete();
604
+ unsubscribeFileSaved();
605
+ if (activeSessionId === sid) {
606
+ activeRoot = null;
607
+ activeBody = null;
608
+ activeSessionId = null;
609
+ state.els = null;
610
+ state.selected = null;
611
+ }
612
+ };
613
+ },
614
+ },
209
615
  });
210
616
  })();
@@ -1,7 +1,7 @@
1
1
  id: meeting
2
2
  name: Meeting
3
3
  display_name: Meeting
4
- display_name_zh: 会议
4
+ display_name_zh: 会议助理
5
5
  description: Real-time meeting transcription and AI assistant
6
6
  description_zh: 实时会议转录与 AI 助手
7
7
  version: "0.1.0"
@@ -13,5 +13,6 @@ contributes:
13
13
  panels:
14
14
  - id: meeting
15
15
  view: panels/meeting/view.js
16
+ attach: ["general"]
16
17
  skills:
17
18
  - id: meeting-summarizer
@@ -1,7 +1,7 @@
1
1
  id: time_machine
2
2
  name: Time Machine
3
3
  display_name: Time Machine
4
- display_name_zh: 时间机器
4
+ display_name_zh: 时光机
5
5
  description: Vertical timeline of session tasks with per-task diff and one-click restore
6
6
  description_zh: 会话任务时间轴,支持按任务查看差异并一键还原
7
7
  version: "0.1.0"