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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -0
- data/lib/clacky/access_key.rb +59 -0
- data/lib/clacky/agent/message_compressor_helper.rb +52 -1
- data/lib/clacky/agent/session_serializer.rb +35 -2
- data/lib/clacky/agent/time_machine.rb +9 -1
- data/lib/clacky/agent.rb +11 -7
- data/lib/clacky/agent_config.rb +40 -0
- data/lib/clacky/agent_profile.rb +5 -5
- data/lib/clacky/billing/platform_billing.rb +84 -1
- data/lib/clacky/brand_config.rb +4 -3
- data/lib/clacky/cli.rb +4 -2
- data/lib/clacky/client.rb +107 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +3 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +40 -21
- data/lib/clacky/default_extensions/git/ext.yml +1 -1
- data/lib/clacky/default_extensions/git/panels/git/view.js +483 -77
- data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
- data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
- data/lib/clacky/extension/packager.rb +4 -2
- data/lib/clacky/extension/verifier.rb +14 -1
- data/lib/clacky/message_format/open_ai.rb +5 -1
- data/lib/clacky/message_format/open_ai_responses.rb +409 -0
- data/lib/clacky/message_history.rb +6 -2
- data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
- data/lib/clacky/providers.rb +18 -6
- data/lib/clacky/server/dir_picker.rb +154 -0
- data/lib/clacky/server/git_panel.rb +61 -9
- data/lib/clacky/server/http_server.rb +147 -103
- data/lib/clacky/tools/terminal.rb +27 -3
- data/lib/clacky/ui2/markdown_renderer.rb +15 -12
- data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
- data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
- data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
- data/lib/clacky/utils/model_pricing.rb +70 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +412 -108
- data/lib/clacky/web/components/composer.js +45 -7
- data/lib/clacky/web/components/custom-select.js +136 -0
- data/lib/clacky/web/components/mentions.js +58 -2
- data/lib/clacky/web/components/model-picker.js +527 -0
- data/lib/clacky/web/components/onboard.js +29 -55
- data/lib/clacky/web/core/ext.js +38 -0
- data/lib/clacky/web/features/billing/view.js +20 -6
- data/lib/clacky/web/features/extensions/view.js +22 -10
- data/lib/clacky/web/features/new-session/store.js +15 -1
- data/lib/clacky/web/features/new-session/view.js +31 -22
- data/lib/clacky/web/features/trash/view.js +31 -19
- data/lib/clacky/web/features/workspace/store.js +1 -0
- data/lib/clacky/web/features/workspace/view.js +54 -3
- data/lib/clacky/web/i18n.js +73 -11
- data/lib/clacky/web/index.html +92 -37
- data/lib/clacky/web/sessions.js +550 -592
- data/lib/clacky/web/settings.js +163 -190
- data/lib/clacky.rb +3 -0
- 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.
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
.
|
|
37
|
-
.
|
|
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
|
-
.
|
|
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-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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,
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
144
|
-
|
|
464
|
+
h.replaceChildren(
|
|
465
|
+
document.createTextNode(t("changes.changedPre", "AI 改了 ")),
|
|
466
|
+
el("b", { text: `${count} ${t("changes.filesUnit", "个文件")}` }),
|
|
467
|
+
);
|
|
145
468
|
}
|
|
146
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
473
|
+
branchCode.textContent = branch;
|
|
474
|
+
branchNote.style.display = "flex";
|
|
475
|
+
} else {
|
|
476
|
+
branchNote.style.display = "none";
|
|
155
477
|
}
|
|
156
478
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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:
|
|
208
|
-
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
|