openclacky 1.3.7 → 1.3.8

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/lib/clacky/agent/hook_manager.rb +10 -1
  4. data/lib/clacky/agent/system_prompt_builder.rb +1 -1
  5. data/lib/clacky/agent.rb +9 -2
  6. data/lib/clacky/agent_profile.rb +7 -4
  7. data/lib/clacky/billing/billing_store.rb +3 -2
  8. data/lib/clacky/default_extensions/coding/agents/coding/avatar.png +0 -0
  9. data/lib/clacky/default_extensions/coding/ext.yml +4 -3
  10. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/avatar.png +0 -0
  11. data/lib/clacky/default_extensions/ext-studio/ext.yml +1 -0
  12. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +24 -5
  13. data/lib/clacky/default_extensions/general/agents/general/avatar.png +0 -0
  14. data/lib/clacky/default_extensions/general/ext.yml +4 -3
  15. data/lib/clacky/default_extensions/git/panels/git/view.js +3 -3
  16. data/lib/clacky/default_extensions/meeting/panels/meeting/view.js +1 -2
  17. data/lib/clacky/extension/loader.rb +6 -0
  18. data/lib/clacky/extension/verifier.rb +1 -1
  19. data/lib/clacky/server/http_server.rb +129 -5
  20. data/lib/clacky/shell_hook_loader.rb +266 -22
  21. data/lib/clacky/ui2/components/welcome_banner.rb +1 -1
  22. data/lib/clacky/utils/workspace_rules.rb +2 -2
  23. data/lib/clacky/version.rb +1 -1
  24. data/lib/clacky/web/app.css +230 -46
  25. data/lib/clacky/web/core/ext.js +130 -24
  26. data/lib/clacky/web/features/extensions/view.js +4 -3
  27. data/lib/clacky/web/features/new-session/store.js +13 -0
  28. data/lib/clacky/web/features/new-session/view.js +314 -14
  29. data/lib/clacky/web/features/trash/view.js +4 -5
  30. data/lib/clacky/web/i18n.js +34 -0
  31. data/lib/clacky/web/index.html +43 -11
  32. data/lib/clacky/web/sessions.js +3 -3
  33. data/lib/clacky/web/skills.js +86 -48
  34. data/lib/clacky/web/theme.js +3 -0
  35. data/lib/clacky/web/ws-dispatcher.js +11 -2
  36. metadata +7 -7
@@ -168,6 +168,18 @@ const NewSessionStore = (() => {
168
168
  _emit("newSession:reset", {});
169
169
  }
170
170
 
171
+ async function fetchSkillsForAgent(agentId) {
172
+ try {
173
+ const res = await fetch(`/api/agents/${encodeURIComponent(agentId)}/skills`);
174
+ if (!res.ok) return [];
175
+ const data = await res.json();
176
+ return data.skills || [];
177
+ } catch (e) {
178
+ console.error("Failed to load skills:", e);
179
+ return [];
180
+ }
181
+ }
182
+
171
183
  return {
172
184
  get state() { return _state; },
173
185
  on: _on,
@@ -179,6 +191,7 @@ const NewSessionStore = (() => {
179
191
  loadModels,
180
192
  loadDefaultDirectory,
181
193
  createSession,
194
+ fetchSkillsForAgent,
182
195
  reset,
183
196
  };
184
197
  })();
@@ -72,6 +72,23 @@ const NewSessionView = (() => {
72
72
  card.className = "agent-card" + (a.id === selected ? " agent-card--selected" : "");
73
73
  card.dataset.agentId = a.id;
74
74
 
75
+ const header = document.createElement("div");
76
+ header.className = "agent-card-header";
77
+
78
+ const avatar = document.createElement("div");
79
+ avatar.className = "agent-card-avatar";
80
+ if (a.avatar) {
81
+ const img = document.createElement("img");
82
+ img.src = a.avatar;
83
+ img.alt = "";
84
+ img.loading = "lazy";
85
+ avatar.appendChild(img);
86
+ } else {
87
+ avatar.classList.add("agent-card-avatar--fallback");
88
+ avatar.textContent = (_agentLabel(a) || "?").trim().charAt(0).toUpperCase();
89
+ }
90
+ header.appendChild(avatar);
91
+
75
92
  const title = document.createElement("div");
76
93
  title.className = "agent-card-title";
77
94
  title.textContent = _agentLabel(a);
@@ -81,7 +98,8 @@ const NewSessionView = (() => {
81
98
  isNew.textContent = "NEW";
82
99
  title.appendChild(isNew);
83
100
  }
84
- card.appendChild(title);
101
+ header.appendChild(title);
102
+ card.appendChild(header);
85
103
 
86
104
  const descText = _agentDesc(a);
87
105
  if (descText) {
@@ -120,17 +138,45 @@ const NewSessionView = (() => {
120
138
  });
121
139
 
122
140
  _updatePlaceholder();
141
+ _renderAgentPanels();
142
+ }
143
+
144
+ function _renderAgentPanels() {
145
+ const box = $("new-session-agent-panels");
146
+ if (!box) return;
147
+ const agent = NewSessionStore.currentAgent();
148
+ const ext = window.Clacky && Clacky.ext;
149
+ const contrib = (agent && ext && typeof ext.contributionsForAgent === "function")
150
+ ? ext.contributionsForAgent(agent.id)
151
+ : null;
152
+ const panels = (contrib && contrib.panels) || [];
153
+ const skills = (contrib && contrib.skills) || [];
154
+ if (!panels.length && !skills.length) {
155
+ box.style.display = "none";
156
+ box.replaceChildren();
157
+ return;
158
+ }
159
+ const zh = _isZh();
160
+ const label = document.createElement("span");
161
+ label.className = "ns-panels-label";
162
+ label.textContent = zh ? "此助手额外提供" : "This agent adds";
163
+ box.replaceChildren(label);
164
+ panels.forEach((p) => box.appendChild(_contribChip((zh && p.title_zh) ? p.title_zh : p.title, "panel")));
165
+ skills.forEach((s) => box.appendChild(_contribChip((zh && s.title_zh) ? s.title_zh : s.title, "skill")));
166
+ box.style.display = "flex";
167
+ }
168
+
169
+ function _contribChip(text, kind) {
170
+ const chip = document.createElement("span");
171
+ chip.className = `ns-panel-chip ns-panel-chip--${kind}`;
172
+ chip.textContent = text;
173
+ return chip;
123
174
  }
124
175
 
125
176
  function _updatePlaceholder() {
126
177
  const input = $("new-session-input");
127
178
  if (!input) return;
128
- const agent = NewSessionStore.currentAgent();
129
- const label = agent ? _agentLabel(agent) : "";
130
- const tpl = I18n.t("sessions.new.placeholder");
131
- input.placeholder = label
132
- ? tpl.replace("{{agent}}", label)
133
- : tpl.replace("{{agent}}", "");
179
+ input.placeholder = I18n.t("chat.input.placeholder");
134
180
  }
135
181
 
136
182
  async function _populateModels() {
@@ -185,14 +231,214 @@ const NewSessionView = (() => {
185
231
  const input = $("new-session-input");
186
232
  if (!btn || !input) return;
187
233
  const hasText = input.value.trim().length > 0;
188
- btn.disabled = !hasText || NewSessionStore.state.creating;
234
+ const hasFiles = _pendingImages.length > 0 || _pendingFiles.length > 0;
235
+ btn.disabled = (!hasText && !hasFiles) || NewSessionStore.state.creating;
236
+ }
237
+
238
+ // ── Attachments (image compression + generic file upload) ───────────────
239
+ // A trimmed mirror of the chat composer's pipeline (sessions.js). New-session
240
+ // has no live session, so attachments are staged here and handed to the chat
241
+ // pipeline as a pending message once the session is created & subscribed.
242
+ const _pendingImages = [];
243
+ const _pendingFiles = [];
244
+ let _imageSeq = 0;
245
+ const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
246
+ const MAX_IMAGE_BYTES_SEND = 512 * 1024;
247
+ const MAX_IMAGE_LONG_EDGE = 1920;
248
+ const MAX_FILE_BYTES = 32 * 1024 * 1024;
249
+ const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
250
+
251
+ function _docTypeIcon(mimeType, filename) {
252
+ const lower = (filename || "").toLowerCase();
253
+ if (mimeType === "application/pdf" || lower.endsWith(".pdf")) return "📄";
254
+ if (mimeType === "application/zip" || lower.endsWith(".zip")) return "🗜️";
255
+ if (lower.endsWith(".tar") || lower.endsWith(".gz") || lower.endsWith(".tgz") ||
256
+ lower.endsWith(".tar.gz") || lower.endsWith(".rar") || lower.endsWith(".7z")) return "🗜️";
257
+ if ((mimeType && mimeType.includes("wordprocessingml")) || lower.endsWith(".doc") || lower.endsWith(".docx")) return "📝";
258
+ if ((mimeType && mimeType.includes("spreadsheetml")) || lower.endsWith(".xls") || lower.endsWith(".xlsx")) return "📊";
259
+ if ((mimeType && mimeType.includes("presentationml")) || lower.endsWith(".ppt") || lower.endsWith(".pptx")) return "📋";
260
+ if (mimeType === "text/csv" || lower.endsWith(".csv")) return "📊";
261
+ if (lower.endsWith(".md") || lower.endsWith(".markdown")) return "📝";
262
+ return "📎";
263
+ }
264
+
265
+ function _compressImage(file) {
266
+ return new Promise((resolve, reject) => {
267
+ const reader = new FileReader();
268
+ reader.onerror = () => reject(new Error("Failed to read image"));
269
+ reader.onload = (e) => {
270
+ const img = new Image();
271
+ img.onerror = () => reject(new Error("Failed to decode image"));
272
+ img.onload = () => {
273
+ let { width, height } = img;
274
+ if (width > MAX_IMAGE_LONG_EDGE || height > MAX_IMAGE_LONG_EDGE) {
275
+ const ratio = Math.min(MAX_IMAGE_LONG_EDGE / width, MAX_IMAGE_LONG_EDGE / height);
276
+ width = Math.round(width * ratio);
277
+ height = Math.round(height * ratio);
278
+ }
279
+ const canvas = document.createElement("canvas");
280
+ canvas.width = width;
281
+ canvas.height = height;
282
+ const ctx = canvas.getContext("2d");
283
+ ctx.drawImage(img, 0, 0, width, height);
284
+ const isPNG = file.type === "image/png";
285
+ if (isPNG) {
286
+ let dataUrl = canvas.toDataURL("image/png");
287
+ let scale = 0.9;
288
+ while (dataUrl.length * 0.75 > MAX_IMAGE_BYTES_SEND && scale > 0.3) {
289
+ const sw = Math.round(width * scale);
290
+ const sh = Math.round(height * scale);
291
+ canvas.width = sw;
292
+ canvas.height = sh;
293
+ ctx.drawImage(img, 0, 0, sw, sh);
294
+ dataUrl = canvas.toDataURL("image/png");
295
+ scale -= 0.1;
296
+ }
297
+ resolve(dataUrl);
298
+ } else {
299
+ let quality = 0.85;
300
+ let dataUrl = canvas.toDataURL("image/jpeg", quality);
301
+ while (dataUrl.length * 0.75 > MAX_IMAGE_BYTES_SEND && quality > 0.2) {
302
+ quality -= 0.1;
303
+ dataUrl = canvas.toDataURL("image/jpeg", quality);
304
+ }
305
+ resolve(dataUrl);
306
+ }
307
+ };
308
+ img.src = e.target.result;
309
+ };
310
+ reader.readAsDataURL(file);
311
+ });
312
+ }
313
+
314
+ function _addImageFile(file) {
315
+ if (file.size > MAX_IMAGE_SIZE) {
316
+ alert(I18n.t("chat.file.imageTooLarge", { name: file.name, max: "5 MB" }));
317
+ return;
318
+ }
319
+ const seq = ++_imageSeq;
320
+ const ext = (file.name.split(".").pop() || "png").toLowerCase();
321
+ const displayName = `IMG_${String(seq).padStart(3, "0")}.${ext}`;
322
+ _compressImage(file)
323
+ .then((dataUrl) => {
324
+ _pendingImages.push({ dataUrl, name: displayName, mimeType: file.type === "image/png" ? "image/png" : "image/jpeg", seq });
325
+ _renderAttachmentPreviews();
326
+ })
327
+ .catch((err) => alert(I18n.t("chat.file.processFailed", { msg: err.message })));
328
+ }
329
+
330
+ function _addGenericFile(file) {
331
+ if (file.size > MAX_FILE_BYTES) {
332
+ alert(I18n.t("chat.file.tooLarge", { name: file.name, max: "32 MB" }));
333
+ return;
334
+ }
335
+ const formData = new FormData();
336
+ formData.append("file", file);
337
+ fetch("/api/upload", { method: "POST", body: formData })
338
+ .then((r) => r.json())
339
+ .then((data) => {
340
+ if (!data.ok) { alert(I18n.t("chat.file.uploadFailed", { msg: data.error })); return; }
341
+ _pendingFiles.push({ name: data.name, path: data.path, mime_type: file.type });
342
+ _renderAttachmentPreviews();
343
+ })
344
+ .catch((err) => alert(`Upload error: ${err.message}`));
345
+ }
346
+
347
+ function _addAttachmentFile(file) {
348
+ if (file && ACCEPTED_IMAGE_TYPES.includes(file.type)) _addImageFile(file);
349
+ else _addGenericFile(file);
350
+ }
351
+
352
+ function _renderAttachmentPreviews() {
353
+ const strip = $("ns-image-preview-strip");
354
+ if (!strip) return;
355
+ strip.innerHTML = "";
356
+ const hasContent = _pendingImages.length > 0 || _pendingFiles.length > 0;
357
+ strip.style.display = hasContent ? "flex" : "none";
358
+ _updateSendButton();
359
+ if (!hasContent) return;
360
+
361
+ _pendingImages.forEach((img, idx) => {
362
+ const item = document.createElement("div");
363
+ item.className = "img-preview-item";
364
+ item.title = img.name;
365
+ const thumbnail = document.createElement("img");
366
+ thumbnail.src = img.dataUrl;
367
+ thumbnail.alt = img.name;
368
+ const removeBtn = document.createElement("button");
369
+ removeBtn.className = "img-preview-remove";
370
+ removeBtn.textContent = "✕";
371
+ removeBtn.title = "Remove";
372
+ removeBtn.addEventListener("click", () => { _pendingImages.splice(idx, 1); _renderAttachmentPreviews(); });
373
+ item.appendChild(thumbnail);
374
+ item.appendChild(removeBtn);
375
+ strip.appendChild(item);
376
+ });
377
+
378
+ _pendingFiles.forEach((f, idx) => {
379
+ const item = document.createElement("div");
380
+ item.className = "pdf-preview-item";
381
+ item.title = f.name;
382
+ const icon = document.createElement("div");
383
+ icon.className = "pdf-preview-icon";
384
+ icon.textContent = _docTypeIcon(f.mime_type, f.name);
385
+ const info = document.createElement("div");
386
+ info.className = "pdf-preview-info";
387
+ const name = document.createElement("div");
388
+ name.className = "pdf-preview-name";
389
+ name.textContent = f.name;
390
+ const typeLabel = document.createElement("div");
391
+ typeLabel.className = "pdf-preview-type";
392
+ typeLabel.textContent = (f.name.split(".").pop() || "file").toUpperCase();
393
+ info.appendChild(name);
394
+ info.appendChild(typeLabel);
395
+ const removeBtn = document.createElement("button");
396
+ removeBtn.className = "pdf-preview-remove";
397
+ removeBtn.textContent = "✕";
398
+ removeBtn.title = "Remove";
399
+ removeBtn.addEventListener("click", () => { _pendingFiles.splice(idx, 1); _renderAttachmentPreviews(); });
400
+ item.appendChild(icon);
401
+ item.appendChild(info);
402
+ item.appendChild(removeBtn);
403
+ strip.appendChild(item);
404
+ });
405
+ }
406
+
407
+ function _buildPendingFiles() {
408
+ _pendingImages.sort((a, b) => a.seq - b.seq);
409
+ return [
410
+ ..._pendingImages.map((img) => ({ name: img.name, mime_type: img.mimeType || "image/jpeg", data_url: img.dataUrl })),
411
+ ..._pendingFiles.map((f) => ({ name: f.name, path: f.path, mime_type: f.mime_type })),
412
+ ];
413
+ }
414
+
415
+ function _buildBubbleHtml(content) {
416
+ let html = content ? escapeHtml(content) : "";
417
+ if (_pendingImages.length > 0) {
418
+ const thumbs = _pendingImages
419
+ .map((img) => `<img src="${img.dataUrl}" alt="${escapeHtml(img.name)}" class="msg-image-thumb">`)
420
+ .join("");
421
+ html = thumbs + (html ? "<br>" + html : "");
422
+ }
423
+ if (_pendingFiles.length > 0) {
424
+ const badges = _pendingFiles.map((f) => {
425
+ const icon = _docTypeIcon(f.mime_type, f.name);
426
+ const ext = (f.name.split(".").pop() || "file").toUpperCase();
427
+ return `<span class="msg-pdf-badge"><span class="msg-pdf-badge-icon">${icon}</span>` +
428
+ `<span class="msg-pdf-badge-info"><span class="msg-pdf-badge-name">${escapeHtml(f.name)}</span>` +
429
+ `<span class="msg-pdf-badge-type">${escapeHtml(ext)}</span></span></span>`;
430
+ }).join(" ");
431
+ html = badges + (html ? "<br>" + html : "");
432
+ }
433
+ return html;
189
434
  }
190
435
 
191
436
  async function _submit() {
192
437
  const input = $("new-session-input");
193
438
  if (!input) return;
194
439
  const content = input.value.trim();
195
- if (!content) return;
440
+ const hasFiles = _pendingImages.length > 0 || _pendingFiles.length > 0;
441
+ if (!content && !hasFiles) return;
196
442
 
197
443
  const initCheckbox = $("new-session-init-project");
198
444
  const initProject = initCheckbox && initCheckbox.checked;
@@ -208,9 +454,16 @@ const NewSessionView = (() => {
208
454
  return;
209
455
  }
210
456
 
457
+ const files = hasFiles ? _buildPendingFiles() : null;
458
+ const display = hasFiles ? _buildBubbleHtml(content) : null;
459
+
211
460
  Sessions.add(session);
212
- Sessions.setPendingMessage(session.id, content);
461
+ Sessions.setPendingMessage(session.id, content, display, files);
213
462
  input.value = "";
463
+ _pendingImages.length = 0;
464
+ _pendingFiles.length = 0;
465
+ _imageSeq = 0;
466
+ _renderAttachmentPreviews();
214
467
  NewSessionStore.reset();
215
468
 
216
469
  // Hand off to the normal chat pipeline: this switches the panel, wires
@@ -233,11 +486,55 @@ const NewSessionView = (() => {
233
486
  const input = $("new-session-input");
234
487
  if (input) {
235
488
  input.addEventListener("input", _updateSendButton);
236
- input.addEventListener("keydown", (e) => {
237
- if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
238
- e.preventDefault();
239
- _submit();
489
+ // Enter-to-send + slash-command navigation are owned by SkillAC.attach().
490
+ }
491
+
492
+ // Slash-command skill autocomplete for this composer (mirrors chat).
493
+ if (typeof SkillAC !== "undefined" && input) {
494
+ SkillAC.attach({
495
+ input: "new-session-input",
496
+ dropdown: "ns-skill-autocomplete",
497
+ list: "ns-skill-autocomplete-list",
498
+ slashBtn: "ns-btn-slash",
499
+ systemChk: "ns-chk-ac-show-system-skills",
500
+ fetchSkills: async () => {
501
+ const agent = NewSessionStore.currentAgent();
502
+ if (!agent) return [];
503
+ return NewSessionStore.fetchSkillsForAgent(agent.id);
504
+ },
505
+ onSend: _submit,
506
+ });
507
+ }
508
+
509
+ // Attachments: attach button → file picker, drag-drop, paste.
510
+ const fileInput = $("ns-file-input");
511
+ const attachBtn = $("ns-btn-attach");
512
+ if (attachBtn && fileInput) {
513
+ attachBtn.addEventListener("click", () => fileInput.click());
514
+ fileInput.addEventListener("change", (e) => {
515
+ Array.from(e.target.files).forEach(_addAttachmentFile);
516
+ e.target.value = "";
517
+ });
518
+ }
519
+ if (input) {
520
+ input.addEventListener("paste", (e) => {
521
+ const items = (e.clipboardData && e.clipboardData.items) || [];
522
+ let handled = false;
523
+ for (const it of items) {
524
+ if (it.kind === "file") {
525
+ const file = it.getAsFile();
526
+ if (file) { _addAttachmentFile(file); handled = true; }
527
+ }
240
528
  }
529
+ if (handled) e.preventDefault();
530
+ });
531
+ }
532
+ const composer = document.querySelector("#welcome .new-session-composer");
533
+ if (composer) {
534
+ composer.addEventListener("dragover", (e) => { e.preventDefault(); });
535
+ composer.addEventListener("drop", (e) => {
536
+ e.preventDefault();
537
+ Array.from(e.dataTransfer.files || []).forEach(_addAttachmentFile);
241
538
  });
242
539
  }
243
540
 
@@ -254,6 +551,9 @@ const NewSessionView = (() => {
254
551
  if (open) {
255
552
  await _populateModels();
256
553
  await _prefillDefaultDir();
554
+ requestAnimationFrame(() =>
555
+ advanced.scrollIntoView({ behavior: "smooth", block: "end" })
556
+ );
257
557
  }
258
558
  NewSessionStore.toggleAdvanced(open);
259
559
  });
@@ -77,12 +77,8 @@ const TrashView = (() => {
77
77
 
78
78
  function _load() {
79
79
  if (_activeTab === "file-trash") {
80
- const list = $("trash-list");
81
- if (list) list.innerHTML = `<div class="trash-loading">${_t("trash.loading")}</div>`;
82
80
  Trash.loadFiles();
83
81
  } else {
84
- const list = $("trash-session-list");
85
- if (list) list.innerHTML = `<div class="creator-loading">${_t("trash.loading")}</div>`;
86
82
  Trash.loadSessions();
87
83
  }
88
84
  }
@@ -182,7 +178,8 @@ const TrashView = (() => {
182
178
  // ── Session trash rendering ──────────────────────────────────────────
183
179
 
184
180
  function _renderSkeleton() {
185
- const list = $("trash-session-list");
181
+ const isSession = _activeTab === "session-trash";
182
+ const list = $(isSession ? "trash-session-list" : "trash-list");
186
183
  if (!list) return;
187
184
  let html = "";
188
185
  for (let i = 0; i < 3; i++) {
@@ -425,6 +422,8 @@ const TrashView = (() => {
425
422
  }
426
423
 
427
424
  function _subscribe() {
425
+ Trash.on("trash:filesLoading", _renderSkeleton);
426
+ Trash.on("trash:sessionsLoading", _renderSkeleton);
428
427
  Trash.on("trash:filesChanged", _renderFiles);
429
428
  Trash.on("trash:sessionsChanged", _renderSessions);
430
429
  Trash.on("trash:filesError", (e) => {
@@ -136,6 +136,23 @@ const I18n = (() => {
136
136
  "workspace.revealInFinder": "Reveal in Finder",
137
137
  "workspace.revealFailed": "Failed to reveal file",
138
138
  "changes.tab": "Git",
139
+ "changes.loading": "Loading changes…",
140
+ "changes.error": "Failed to load changes",
141
+ "changes.noRepo": "This project has no version control enabled.",
142
+ "changes.cleanTitle": "No changes",
143
+ "changes.changedPre": "AI changed ",
144
+ "changes.filesUnit": "file(s)",
145
+ "changes.sub": "Managed by Git · since last snapshot",
146
+ "changes.branchPre": "Current branch: ",
147
+ "changes.clean": "Working tree is clean — no unsaved changes.",
148
+ "changes.tag.add": "Added",
149
+ "changes.tag.mod": "Modified",
150
+ "changes.tag.del": "Deleted",
151
+ "changes.save.prefix": "Manual snapshot",
152
+ "changes.save.btn": "Save snapshot",
153
+ "changes.save.saving": "Saving…",
154
+ "changes.save.done": "Saved — restore it from History",
155
+ "changes.save.failed": "Save failed",
139
156
  "tm.tab": "History",
140
157
  "sib.model.tooltip": "Click to switch model",
141
158
  "sib.model.tooltip.busy": "Model switching is disabled while the agent is responding",
@@ -1077,6 +1094,23 @@ const I18n = (() => {
1077
1094
  "workspace.revealInFinder": "打开所在文件夹",
1078
1095
  "workspace.revealFailed": "无法打开文件位置",
1079
1096
  "changes.tab": "Git 管理",
1097
+ "changes.loading": "正在读取改动…",
1098
+ "changes.error": "读取改动失败",
1099
+ "changes.noRepo": "这个项目还没有启用版本管理。",
1100
+ "changes.cleanTitle": "暂无改动",
1101
+ "changes.changedPre": "AI 改了 ",
1102
+ "changes.filesUnit": "个文件",
1103
+ "changes.sub": "由 Git 管理 · 自上次存档以来",
1104
+ "changes.branchPre": "当前分支:",
1105
+ "changes.clean": "工作区是干净的,没有未存档的改动。",
1106
+ "changes.tag.add": "新增",
1107
+ "changes.tag.mod": "修改",
1108
+ "changes.tag.del": "删除",
1109
+ "changes.save.prefix": "手动存档",
1110
+ "changes.save.btn": "存档当前版本",
1111
+ "changes.save.saving": "正在存档…",
1112
+ "changes.save.done": "已存档,可在「时光机」里回到这个版本",
1113
+ "changes.save.failed": "存档失败",
1080
1114
  "tm.tab": "时光机",
1081
1115
  "sib.model.tooltip": "点击切换模型",
1082
1116
  "sib.model.tooltip.busy": "Agent 回复中,暂时无法切换模型",
@@ -311,11 +311,15 @@
311
311
  <!-- New Session page (default landing view; hash: #new or empty) -->
312
312
  <div id="welcome" class="centered new-session-page">
313
313
  <div class="new-session-inner">
314
+ <div class="new-session-body">
314
315
  <h2 class="new-session-title" data-i18n="sessions.new.title">Start a new session</h2>
315
316
  <p class="new-session-subtitle" data-i18n="sessions.new.subtitle">Pick who you want to chat with, then type your first message.</p>
316
317
 
317
318
  <div id="new-session-agents" class="new-session-agents"></div>
318
319
 
320
+ <!-- Panels the selected agent exposes (filled when an agent is picked) -->
321
+ <div id="new-session-agent-panels" class="new-session-agent-panels" style="display:none"></div>
322
+
319
323
  <div class="new-session-advanced-wrap">
320
324
  <button id="new-session-toggle-advanced" class="new-session-advanced-toggle" type="button" aria-expanded="false">
321
325
  <span data-i18n="sessions.new.moreOptions">More options</span>
@@ -356,16 +360,43 @@
356
360
  </div>
357
361
  </div>
358
362
  </div>
363
+ </div><!-- /.new-session-body -->
359
364
 
360
365
  <div class="new-session-composer">
361
- <textarea id="new-session-input"
362
- class="new-session-textarea"
363
- rows="3"
364
- data-i18n-placeholder="sessions.new.placeholder"
365
- placeholder="Type your first message and press Enter…"></textarea>
366
- <div class="new-session-composer-actions">
367
- <button id="new-session-send" class="new-session-send" type="button" disabled>
368
- <span data-i18n="sessions.new.send">Send</span>
366
+ <!-- Skill autocomplete dropdown (shown when user types /xxx) -->
367
+ <div id="ns-skill-autocomplete" class="skill-autocomplete" style="display:none" role="listbox" aria-label="Skills">
368
+ <div class="skill-ac-header-row">
369
+ <div class="skill-ac-left">
370
+ <div class="skill-ac-title" data-i18n="sidebar.skills">Skills</div>
371
+ <label class="skill-ac-filter-toggle">
372
+ <input type="checkbox" id="ns-chk-ac-show-system-skills">
373
+ <span class="skill-ac-toggle-track"></span>
374
+ <span class="skill-ac-filter-label" data-i18n="skills.filter.showSystemShort">System</span>
375
+ </label>
376
+ </div>
377
+ </div>
378
+ <div id="ns-skill-autocomplete-list"></div>
379
+ </div>
380
+ <!-- Attachment preview strip (hidden when empty) -->
381
+ <div id="ns-image-preview-strip" style="display:none"></div>
382
+ <div id="ns-input-bar" class="new-session-input-bar">
383
+ <input type="file" id="ns-file-input" accept="*/*" multiple style="display:none">
384
+ <button id="ns-btn-attach" type="button" class="new-session-composer-btn"
385
+ title="Attach file (image, pdf, docx, md, tar.gz…) — drag & drop / Ctrl+V also work">
386
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
387
+ <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66L9.41 17.41a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
388
+ </svg>
389
+ </button>
390
+ <button id="ns-btn-slash" type="button" class="new-session-composer-btn" title="Insert skill command (/)">
391
+ <span>/</span>
392
+ </button>
393
+ <textarea id="new-session-input"
394
+ class="new-session-textarea"
395
+ rows="1"
396
+ data-i18n-placeholder="chat.input.placeholder"
397
+ placeholder="Message… (Enter to send, Shift+Enter for newline)"></textarea>
398
+ <button id="new-session-send" class="new-session-send" type="button" title="Send message" disabled>
399
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
369
400
  </button>
370
401
  </div>
371
402
  </div>
@@ -404,6 +435,8 @@
404
435
  <div id="new-message-banner" class="new-message-banner" style="display:none">
405
436
  <span data-i18n="chat.newMessageHint">New messages ↓</span>
406
437
  </div>
438
+ <!-- Composer slot: agent-scoped controls above the session info bar. -->
439
+ <div id="ext-slot-session-composer" class="session-composer" data-slot="session.composer"></div>
407
440
  <!-- Session info bar — mirrors CLI bottom status bar -->
408
441
  <div id="session-info-bar" style="display:none">
409
442
  <!-- Order: status | id | dir | model | mode | tasks | cost -->
@@ -448,8 +481,7 @@
448
481
  </span>
449
482
  </div>
450
483
 
451
- <!-- Composer slot: agent-scoped controls just above the input bar. -->
452
- <div id="ext-slot-session-composer" class="session-composer" data-slot="session.composer"></div>
484
+ <!-- Composer slot: moved above the session info bar. -->
453
485
  <div id="input-area">
454
486
  <div id="ws-disconnect-hint" style="display:none"></div>
455
487
  <!-- Skill autocomplete dropdown (shown when user types /xxx) -->
@@ -630,12 +662,12 @@
630
662
  </div>
631
663
 
632
664
  <div class="extensions-toolbar">
633
- <input type="text" id="extensions-search-input" class="extensions-search" placeholder="Search extensions…" />
634
665
  <select id="extensions-sort" class="extensions-sort">
635
666
  <option value="newest" data-i18n="extensions.sort.newest">Newest</option>
636
667
  <option value="updated" data-i18n="extensions.sort.updated">Recently updated</option>
637
668
  <option value="downloads" data-i18n="extensions.sort.downloads">Most downloaded</option>
638
669
  </select>
670
+ <input type="text" id="extensions-search-input" class="extensions-search" placeholder="Search extensions…" />
639
671
  </div>
640
672
 
641
673
  <div id="extensions-warning" class="brand-skills-warning" style="display:none"></div>
@@ -3989,9 +3989,9 @@ const Sessions = (() => {
3989
3989
  return id;
3990
3990
  },
3991
3991
 
3992
- /** Register a slash-command message to send after subscribe is confirmed. */
3993
- setPendingMessage(sessionId, content, display = null) {
3994
- _pendingMessage = { session_id: sessionId, content, display };
3992
+ /** Register a message (optionally with attachments) to send after subscribe is confirmed. */
3993
+ setPendingMessage(sessionId, content, display = null, files = null) {
3994
+ _pendingMessage = { session_id: sessionId, content, display, files };
3995
3995
  },
3996
3996
 
3997
3997
  /** Consume and return the pending message (clears it). */