openclacky 1.5.11 → 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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/lib/clacky/access_key.rb +59 -0
  4. data/lib/clacky/agent/cost_tracker.rb +2 -0
  5. data/lib/clacky/agent/message_compressor_helper.rb +53 -2
  6. data/lib/clacky/agent/session_serializer.rb +72 -19
  7. data/lib/clacky/agent/time_machine.rb +10 -1
  8. data/lib/clacky/agent.rb +107 -26
  9. data/lib/clacky/agent_config.rb +40 -0
  10. data/lib/clacky/agent_profile.rb +5 -5
  11. data/lib/clacky/billing/billing_record.rb +3 -0
  12. data/lib/clacky/billing/billing_store.rb +31 -5
  13. data/lib/clacky/billing/platform_billing.rb +231 -0
  14. data/lib/clacky/brand_config.rb +4 -3
  15. data/lib/clacky/cancel_flag.rb +23 -0
  16. data/lib/clacky/cli.rb +4 -2
  17. data/lib/clacky/client.rb +107 -0
  18. data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
  19. data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
  20. data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
  21. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
  22. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
  23. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
  24. data/lib/clacky/default_extensions/git/ext.yml +1 -1
  25. data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
  26. data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
  27. data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
  28. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
  29. data/lib/clacky/extension/packager.rb +4 -2
  30. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
  31. data/lib/clacky/extension/verifier.rb +14 -1
  32. data/lib/clacky/fanout.rb +49 -2
  33. data/lib/clacky/json_ui_controller.rb +3 -2
  34. data/lib/clacky/message_format/open_ai.rb +5 -1
  35. data/lib/clacky/message_format/open_ai_responses.rb +409 -0
  36. data/lib/clacky/message_history.rb +43 -1
  37. data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
  38. data/lib/clacky/plain_ui_controller.rb +15 -5
  39. data/lib/clacky/providers.rb +24 -7
  40. data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
  41. data/lib/clacky/server/channel/channel_manager.rb +93 -18
  42. data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
  43. data/lib/clacky/server/dir_picker.rb +154 -0
  44. data/lib/clacky/server/git_panel.rb +61 -9
  45. data/lib/clacky/server/http_server.rb +366 -42
  46. data/lib/clacky/server/web_ui_controller.rb +7 -5
  47. data/lib/clacky/session_manager.rb +74 -17
  48. data/lib/clacky/tools/ask_user.rb +3 -2
  49. data/lib/clacky/tools/terminal.rb +30 -3
  50. data/lib/clacky/ui2/markdown_renderer.rb +15 -12
  51. data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
  52. data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
  53. data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
  54. data/lib/clacky/ui2/ui_controller.rb +2 -2
  55. data/lib/clacky/ui_interface.rb +2 -2
  56. data/lib/clacky/utils/model_pricing.rb +92 -4
  57. data/lib/clacky/version.rb +1 -1
  58. data/lib/clacky/web/app.css +915 -142
  59. data/lib/clacky/web/components/code-editor.js +33 -1
  60. data/lib/clacky/web/components/composer.js +315 -0
  61. data/lib/clacky/web/components/custom-select.js +136 -0
  62. data/lib/clacky/web/components/mentions.js +655 -0
  63. data/lib/clacky/web/components/model-picker.js +527 -0
  64. data/lib/clacky/web/components/onboard.js +29 -55
  65. data/lib/clacky/web/core/aside.js +3 -3
  66. data/lib/clacky/web/core/ext.js +38 -0
  67. data/lib/clacky/web/features/billing/store.js +2 -6
  68. data/lib/clacky/web/features/billing/view.js +32 -16
  69. data/lib/clacky/web/features/extensions/view.js +22 -10
  70. data/lib/clacky/web/features/new-session/store.js +15 -1
  71. data/lib/clacky/web/features/new-session/view.js +80 -34
  72. data/lib/clacky/web/features/trash/view.js +31 -19
  73. data/lib/clacky/web/features/workspace/store.js +1 -0
  74. data/lib/clacky/web/features/workspace/view.js +766 -80
  75. data/lib/clacky/web/i18n.js +107 -17
  76. data/lib/clacky/web/index.html +125 -45
  77. data/lib/clacky/web/sessions.js +704 -635
  78. data/lib/clacky/web/settings.js +164 -190
  79. data/lib/clacky/web/skills.js +11 -17
  80. data/lib/clacky/web/ws-dispatcher.js +11 -33
  81. data/lib/clacky.rb +4 -0
  82. metadata +12 -1
@@ -379,6 +379,19 @@ Clacky.ext = (() => {
379
379
  map.set(key, teardown);
380
380
  }
381
381
 
382
+ // Run and forget a single teardown by key. Used when a tab re-renders within
383
+ // the same session (e.g. an eager onAttach re-running) so its previous
384
+ // subscriptions are released before a new one is registered.
385
+ function _runViewTeardown(sessionId, key) {
386
+ if (!sessionId) return;
387
+ const map = _viewTeardowns.get(sessionId);
388
+ if (!map) return;
389
+ const fn = map.get(key);
390
+ if (typeof fn !== "function") return;
391
+ map.delete(key);
392
+ try { fn(); } catch (_e) { /* already guarded by caller */ }
393
+ }
394
+
382
395
  // Invoke a renderer's render function. Handles the (container, ctx, runtime)
383
396
  // signature used everywhere; if the render returns a function it is stored
384
397
  // as the view teardown, run before the next render or on session exit.
@@ -632,6 +645,31 @@ Clacky.ext = (() => {
632
645
  body.setAttribute("data-panel", id);
633
646
  bodyEls[id] = body;
634
647
  bodies.appendChild(body);
648
+
649
+ // Eager init: a tab can run setup (surface a badge, pre-fetch data,
650
+ // start a subscription) before the user ever opens it. onAttach runs
651
+ // immediately with a ctx carrying sessionId + setBadge; returning a
652
+ // function registers it as a teardown run on the next render pass /
653
+ // session switch. Any tab may opt in — no per-extension wiring needed.
654
+ if (typeof entry.tab.onAttach === "function") {
655
+ const attachKey = _mountKey(slot, entry) + "::attach";
656
+ // Re-running within the same session (rare, e.g. a host re-render)
657
+ // must release the previous teardown first to avoid leaks.
658
+ _runViewTeardown(renderCtx.sessionId, attachKey);
659
+ const attachCtx = Object.assign({}, renderCtx, {
660
+ setBadge: (n) => _setTabBadge(tabBtns[id], n),
661
+ });
662
+ let teardown;
663
+ try {
664
+ teardown = entry.tab.onAttach(attachCtx);
665
+ } catch (err) {
666
+ console.error(`[Clacky.ext] onAttach for tab "${id}" threw:`, err);
667
+ teardown = null;
668
+ }
669
+ if (typeof teardown === "function" && renderCtx.sessionId) {
670
+ _rememberViewTeardown(renderCtx.sessionId, attachKey, teardown);
671
+ }
672
+ }
635
673
  });
636
674
 
637
675
  container.appendChild(tabBar);
@@ -112,19 +112,15 @@ const BillingStore = (() => {
112
112
 
113
113
  try {
114
114
  const modelParam = (_currentModel && _currentModel !== "all") ? `&model=${encodeURIComponent(_currentModel)}` : "";
115
- const [summaryRes, dailyRes, sessionsRes] = await Promise.all([
115
+ const [summaryRes, dailyRes] = await Promise.all([
116
116
  fetch(`/api/billing/summary?period=${_currentPeriod}${modelParam}`),
117
- fetch(`/api/billing/daily?days=30${modelParam}`),
118
- fetch(`/api/billing/sessions?period=${_currentPeriod}${modelParam}&limit=100`)
117
+ fetch(`/api/billing/daily?days=30${modelParam}`)
119
118
  ]);
120
119
 
121
120
  _summary = await summaryRes.json();
122
121
  const dailyData = await dailyRes.json();
123
122
  _daily = dailyData.days || [];
124
123
 
125
- const sessionsData = await sessionsRes.json();
126
- _sessions = sessionsData.sessions || [];
127
-
128
124
  if (!_currentModel || _currentModel === "all") {
129
125
  _allModels = _summary.by_model ? Object.keys(_summary.by_model) : [];
130
126
  }
@@ -122,8 +122,8 @@ const BillingView = (() => {
122
122
 
123
123
  const allModels = BillingStore.state.allModels;
124
124
  const models = allModels.length > 0 ? allModels : (summary.by_model ? Object.keys(summary.by_model) : []);
125
- const modelOptions = [`<option value="all">${I18n.t("billing.allModels") || "All Models"}</option>`]
126
- .concat(models.filter(m => m).map(m => `<option value="${_esc(m)}" ${m === BillingStore.state.currentModel ? "selected" : ""}>${_esc(m)}</option>`))
125
+ const modelOptions = [`<div class="custom-select-option" data-value="all">${I18n.t("billing.allModels") || "All Models"}</div>`]
126
+ .concat(models.filter(m => m).map(m => `<div class="custom-select-option" data-value="${_esc(m)}">${_esc(m)}</div>`))
127
127
  .join("");
128
128
 
129
129
  container.innerHTML = `
@@ -135,7 +135,15 @@ const BillingView = (() => {
135
135
  </div>
136
136
  <div class="billing-controls">
137
137
  <div class="segmented-control">${periodBtns}</div>
138
- <select id="billing-model-filter" class="billing-model-filter">${modelOptions}</select>
138
+ <div class="custom-select-wrapper billing-model-filter" id="billing-model-filter">
139
+ <div class="custom-select-trigger" tabindex="0">
140
+ <span class="custom-select-value">${I18n.t("billing.allModels") || "All Models"}</span>
141
+ <svg class="custom-select-arrow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
142
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
143
+ </svg>
144
+ </div>
145
+ <div class="custom-select-dropdown">${modelOptions}</div>
146
+ </div>
139
147
  <button id="billing-share-btn" class="billing-share-btn" title="${I18n.t('billing.share.tooltip') || 'Share scorecard'}">
140
148
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><polyline points="16 6 12 2 8 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><line x1="12" y1="2" x2="12" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
141
149
  ${I18n.t('billing.share.btn') || 'Share scorecard'}
@@ -200,10 +208,6 @@ const BillingView = (() => {
200
208
  <div class="billing-chart-row">
201
209
  ${_renderCombinedChart()}
202
210
  </div>
203
-
204
- <div class="billing-sessions-row">
205
- ${_renderSessionList()}
206
- </div>
207
211
  </div>
208
212
  `;
209
213
 
@@ -211,9 +215,15 @@ const BillingView = (() => {
211
215
  btn.addEventListener("click", (e) => Billing.setPeriod(e.target.dataset.period));
212
216
  });
213
217
 
214
- document.getElementById("billing-model-filter")?.addEventListener("change", (e) => {
215
- Billing.setModel(e.target.value);
216
- });
218
+ const billingModelWrap = document.getElementById("billing-model-filter");
219
+ if (billingModelWrap && window.CustomSelect) {
220
+ const sel = window.CustomSelect.init({
221
+ trigger: billingModelWrap.querySelector(".custom-select-trigger"),
222
+ dropdown: billingModelWrap.querySelector(".custom-select-dropdown"),
223
+ onSelect: (value) => Billing.setModel(value)
224
+ });
225
+ sel.setValue(BillingStore.state.currentModel || "all");
226
+ }
217
227
 
218
228
  document.getElementById("billing-share-btn")?.addEventListener("click", _openScorecardShare);
219
229
 
@@ -238,7 +248,7 @@ const BillingView = (() => {
238
248
  function _cacheHitRateOf(summary) {
239
249
  const prompt = summary.prompt_tokens || 0;
240
250
  const cacheRead = summary.cache_read_tokens || 0;
241
- return prompt === 0 ? "0" : ((cacheRead / prompt) * 100).toFixed(1);
251
+ return prompt === 0 ? "0" : Math.min((cacheRead / prompt) * 100, 100).toFixed(1);
242
252
  }
243
253
 
244
254
  function _heatmapDays() {
@@ -325,12 +335,18 @@ const BillingView = (() => {
325
335
  </div>
326
336
  `;
327
337
  tooltip.style.display = "block";
328
- tooltip.style.left = `${e.clientX + 15}px`;
329
- tooltip.style.top = `${e.clientY - 10}px`;
338
+ tooltip.style.visibility = "hidden";
339
+ const rect = tooltip.getBoundingClientRect();
340
+ const ovfX = e.clientX + 15 + rect.width - window.innerWidth;
341
+ const ovfY = e.clientY - 10 + rect.height - window.innerHeight;
342
+ tooltip.style.left = ovfX > 0 ? `${e.clientX - 15 - rect.width}px` : `${e.clientX + 15}px`;
343
+ tooltip.style.top = ovfY > 0 ? `${e.clientY - 10 - rect.height}px` : `${e.clientY - 10}px`;
344
+ tooltip.style.visibility = "visible";
330
345
  });
331
346
 
332
347
  container.addEventListener("mouseleave", () => {
333
348
  tooltip.style.display = "none";
349
+ tooltip.style.visibility = "";
334
350
  });
335
351
  }
336
352
 
@@ -431,7 +447,7 @@ const BillingView = (() => {
431
447
  const promptTokens = summary.prompt_tokens || 0;
432
448
  const completionTokens = summary.completion_tokens || 0;
433
449
  const cacheReadTokens = summary.cache_read_tokens || 0;
434
- const cacheMissTokens = promptTokens - cacheReadTokens;
450
+ const cacheMissTokens = Math.max(promptTokens - cacheReadTokens, 0);
435
451
 
436
452
  return `
437
453
  <div class="billing-section billing-token-section">
@@ -668,7 +684,7 @@ const BillingView = (() => {
668
684
  const chartBars = recentDays.map((d, i) => {
669
685
  const cacheHit = d.cache_read_tokens || 0;
670
686
  const totalPrompt = d.prompt_tokens || 0;
671
- const cacheMiss = totalPrompt - cacheHit;
687
+ const cacheMiss = Math.max(totalPrompt - cacheHit, 0);
672
688
  const output = d.completion_tokens || 0;
673
689
  const totalInput = totalPrompt;
674
690
  const totalTokens = totalInput + output;
@@ -746,7 +762,7 @@ const BillingView = (() => {
746
762
  const totalTokens = s.total_tokens || 0;
747
763
  const promptTokens = s.prompt_tokens || 0;
748
764
  const cacheHit = s.cache_read_tokens || 0;
749
- const cacheMiss = promptTokens - cacheHit;
765
+ const cacheMiss = Math.max(promptTokens - cacheHit, 0);
750
766
  const completionTokens = s.completion_tokens || 0;
751
767
  const requests = s.requests || 0;
752
768
  const models = (s.models || []).join(", ");
@@ -587,18 +587,30 @@ const ExtensionsView = (() => {
587
587
  });
588
588
  }
589
589
 
590
- const sortSelect = $("extensions-sort");
591
- if (sortSelect) {
592
- sortSelect.value = ExtensionsStore.state.sort;
593
- sortSelect.addEventListener("change", () => Extensions.setSort(sortSelect.value));
590
+ const sortWrap = $("extensions-sort");
591
+ if (sortWrap && window.CustomSelect) {
592
+ const sortDropdown = sortWrap.querySelector(".custom-select-dropdown");
593
+ if (!sortDropdown.__customSelect) {
594
+ window.CustomSelect.init({
595
+ trigger: sortWrap.querySelector(".custom-select-trigger"),
596
+ dropdown: sortDropdown,
597
+ onSelect: (value) => Extensions.setSort(value)
598
+ });
599
+ }
600
+ sortDropdown.__customSelect.setValue(ExtensionsStore.state.sort);
594
601
  }
595
602
 
596
- const installedFilter = $("extensions-installed-filter");
597
- if (installedFilter) {
598
- installedFilter.value = ExtensionsStore.state.onlyInstalled ? "installed" : "all";
599
- installedFilter.addEventListener("change", () => {
600
- Extensions.setOnlyInstalled(installedFilter.value === "installed");
601
- });
603
+ const installedWrap = $("extensions-installed-filter");
604
+ if (installedWrap && window.CustomSelect) {
605
+ const installedDropdown = installedWrap.querySelector(".custom-select-dropdown");
606
+ if (!installedDropdown.__customSelect) {
607
+ window.CustomSelect.init({
608
+ trigger: installedWrap.querySelector(".custom-select-trigger"),
609
+ dropdown: installedDropdown,
610
+ onSelect: (value) => Extensions.setOnlyInstalled(value === "installed")
611
+ });
612
+ }
613
+ installedDropdown.__customSelect.setValue(ExtensionsStore.state.onlyInstalled ? "installed" : "all");
602
614
  }
603
615
  document.querySelectorAll(".extensions-filter-tab").forEach(btn => {
604
616
  btn.addEventListener("click", () => {
@@ -53,7 +53,9 @@ const NewSessionStore = (() => {
53
53
  const res = await fetch("/api/agents");
54
54
  if (!res.ok) return;
55
55
  const data = await res.json();
56
- _state.agents = (data && data.agents) || [];
56
+ // Hidden agents are still returned (so existing sessions can resolve
57
+ // their display names) but must not appear in the new-session picker.
58
+ _state.agents = ((data && data.agents) || []).filter((a) => !a.hidden);
57
59
 
58
60
  // Restore last-used agent from localStorage; fall back to the first
59
61
  // available one (typically "general").
@@ -206,6 +208,17 @@ const NewSessionStore = (() => {
206
208
  }
207
209
  }
208
210
 
211
+ async function loadAgentName() {
212
+ try {
213
+ const res = await fetch("/api/profile");
214
+ if (!res.ok) return "";
215
+ const data = await res.json();
216
+ return (data.soul && data.soul.name) || "";
217
+ } catch (e) {
218
+ return "";
219
+ }
220
+ }
221
+
209
222
  return {
210
223
  get state() { return _state; },
211
224
  on: _on,
@@ -218,6 +231,7 @@ const NewSessionStore = (() => {
218
231
  loadDefaultDirectory,
219
232
  createSession,
220
233
  fetchSkillsForAgent,
234
+ loadAgentName,
221
235
  reset,
222
236
  };
223
237
  })();
@@ -15,10 +15,33 @@ const NewSessionView = (() => {
15
15
  let _initialized = false;
16
16
  let _modelsLoaded = false;
17
17
 
18
+ let _agentName = "";
19
+ let _agentNamePromise = null;
20
+
18
21
  function _isZh() {
19
22
  return I18n.lang && I18n.lang().startsWith("zh");
20
23
  }
21
24
 
25
+ function _loadAgentName() {
26
+ if (_agentName) return Promise.resolve(_agentName);
27
+ if (_agentNamePromise) return _agentNamePromise;
28
+ _agentNamePromise = NewSessionStore.loadAgentName()
29
+ .then(name => {
30
+ _agentName = name || "";
31
+ return _agentName;
32
+ })
33
+ .finally(() => { _agentNamePromise = null; });
34
+ return _agentNamePromise;
35
+ }
36
+
37
+ function _renderTitle() {
38
+ const el = document.querySelector(".new-session-title");
39
+ if (!el) return;
40
+ el.textContent = _agentName
41
+ ? I18n.t("sessions.new.title.named", { name: _agentName })
42
+ : I18n.t("sessions.new.title");
43
+ }
44
+
22
45
  function _agentLabel(a) {
23
46
  if (_isZh() && a.title_zh) return a.title_zh;
24
47
  return a.title || a.id;
@@ -191,8 +214,7 @@ const NewSessionView = (() => {
191
214
  btn.addEventListener("click", () => {
192
215
  const input = $("new-session-input");
193
216
  if (!input) return;
194
- input.value = zh ? p.zh : p.en;
195
- input.focus();
217
+ Composer.setText(input, zh ? p.zh : p.en);
196
218
  _updateSendButton();
197
219
  });
198
220
  list.appendChild(btn);
@@ -204,7 +226,7 @@ const NewSessionView = (() => {
204
226
  function _updatePlaceholder() {
205
227
  const input = $("new-session-input");
206
228
  if (!input) return;
207
- input.placeholder = I18n.t("chat.input.placeholder");
229
+ Composer.setPlaceholder(input, I18n.t("chat.input.placeholder"));
208
230
  }
209
231
 
210
232
  async function _populateModels() {
@@ -245,10 +267,10 @@ const NewSessionView = (() => {
245
267
  const btn = $("new-session-send");
246
268
  const input = $("new-session-input");
247
269
  if (!btn || !input) return;
248
- const hasText = input.value.trim().length > 0;
270
+ const hasContent = Composer.hasContent(input);
249
271
  const hasFiles = _pendingImages.length > 0 || _pendingFiles.length > 0;
250
272
  const initProject = NewSessionStore.state.advanced.initProject === true;
251
- btn.disabled = (!hasText && !hasFiles && !initProject) || NewSessionStore.state.creating;
273
+ btn.disabled = (!hasContent && !hasFiles && !initProject) || NewSessionStore.state.creating;
252
274
  }
253
275
 
254
276
  // ── Attachments (image compression + generic file upload) ───────────────
@@ -428,8 +450,18 @@ const NewSessionView = (() => {
428
450
  ];
429
451
  }
430
452
 
431
- function _buildBubbleHtml(content) {
453
+ function _buildBubbleHtml(content, mentionChips = []) {
432
454
  let html = content ? escapeHtml(content) : "";
455
+ if (mentionChips.length > 0) {
456
+ const badges = mentionChips.map((ch) => {
457
+ const path = ch.type === "session"
458
+ ? MentionAC.icons.message
459
+ : (ch.type === "directory" ? MentionAC.icons.folder : MentionAC.icons.file);
460
+ return `<span class="msg-mention-badge"><span class="msg-mention-badge-icon">${MentionAC.svgHtml(path, 13)}</span>` +
461
+ `<span class="msg-mention-badge-name">${escapeHtml(ch.name)}</span></span>`;
462
+ }).join(" ");
463
+ html = badges + (html ? "<br>" + html : "");
464
+ }
433
465
  if (_pendingImages.length > 0) {
434
466
  const thumbs = _pendingImages
435
467
  .map((img) => `<img src="${img.dataUrl}" alt="${escapeHtml(img.name)}" class="msg-image-thumb">`)
@@ -663,32 +695,15 @@ const NewSessionView = (() => {
663
695
  pop.appendChild(empty);
664
696
  return;
665
697
  }
666
- models.forEach((m) => {
667
- const isDefault = m.type === "default";
668
- const modelName = m.model || m.id || "";
669
-
670
- const item = document.createElement("button");
671
- item.type = "button";
672
- item.className = "ns-ctx-popover-item sib-model-option" + ((m.id || "") === currentId ? " active" : "");
673
- item.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:0.5rem;";
674
-
675
- const nameSpan = document.createElement("span");
676
- nameSpan.textContent = modelName;
677
- item.appendChild(nameSpan);
678
-
679
- if (isDefault) {
680
- const badge = document.createElement("span");
681
- badge.className = "model-badge default";
682
- badge.textContent = "default";
683
- item.appendChild(badge);
684
- }
685
698
 
686
- item.addEventListener("click", () => {
699
+ ModelPicker.populate(pop, {
700
+ models,
701
+ currentId,
702
+ onSelect: (m) => {
687
703
  NewSessionStore.updateAdvanced({ modelId: m.id || "" });
688
704
  _dismissCtxPopover();
689
705
  _renderContextBar();
690
- });
691
- pop.appendChild(item);
706
+ },
692
707
  });
693
708
  });
694
709
  }
@@ -780,11 +795,12 @@ const NewSessionView = (() => {
780
795
  async function _submit() {
781
796
  const input = $("new-session-input");
782
797
  if (!input) return;
783
- const content = input.value.trim();
798
+ const content = Composer.text(input).trim();
799
+ const mentionChips = Composer.chips(input);
784
800
  const hasFiles = _pendingImages.length > 0 || _pendingFiles.length > 0;
785
801
  const initProject = NewSessionStore.state.advanced.initProject === true;
786
802
  // Allow empty input when initProject is checked — /new will be auto-sent.
787
- if (!content && !hasFiles && !initProject) return;
803
+ if (!content && mentionChips.length === 0 && !hasFiles && !initProject) return;
788
804
 
789
805
  const btn = $("new-session-send");
790
806
  if (btn) btn.disabled = true;
@@ -803,15 +819,30 @@ const NewSessionView = (() => {
803
819
  const finalContent = initProject
804
820
  ? (content ? `/new\n${content}` : "/new")
805
821
  : content;
806
- const files = hasFiles ? _buildPendingFiles() : null;
807
- const display = hasFiles ? _buildBubbleHtml(finalContent) : null;
822
+
823
+ // Mention chips: file/directory references ride the files payload (agent.run
824
+ // lists directory contents), session references ride references.
825
+ const fileDirChips = mentionChips.filter(c => c.type === "file" || c.type === "directory");
826
+ const references = mentionChips.map(c => {
827
+ const ref = { type: c.type, name: c.name };
828
+ if (c.type === "session") ref.session_id = c.session_id;
829
+ else ref.path = c.path;
830
+ return ref;
831
+ });
832
+
833
+ const files = (hasFiles || fileDirChips.length > 0)
834
+ ? _buildPendingFiles().concat(fileDirChips.map(c => ({ name: c.name, path: c.path, reference: true })))
835
+ : null;
836
+ const display = (hasFiles || mentionChips.length > 0)
837
+ ? _buildBubbleHtml(finalContent, mentionChips)
838
+ : null;
808
839
 
809
840
  Sessions.add(session);
810
841
  if (session.project_id && typeof Projects !== "undefined" && Projects.expand) {
811
842
  Projects.expand(session.project_id);
812
843
  }
813
- Sessions.setPendingMessage(session.id, finalContent, display, files);
814
- input.value = "";
844
+ Sessions.setPendingMessage(session.id, finalContent, display, files, references);
845
+ Composer.clear(input);
815
846
  _pendingImages.length = 0;
816
847
  _pendingFiles.length = 0;
817
848
  _imageSeq = 0;
@@ -828,6 +859,8 @@ const NewSessionView = (() => {
828
859
  if (_initialized) return;
829
860
  _initialized = true;
830
861
 
862
+ document.addEventListener("langchange", _renderTitle);
863
+
831
864
  NewSessionStore.on("newSession:agents-loaded", _renderAgents);
832
865
  NewSessionStore.on("newSession:selection-changed", () => {
833
866
  _renderAgents();
@@ -841,6 +874,18 @@ const NewSessionView = (() => {
841
874
  // Enter-to-send + slash-command navigation are owned by SkillAC.attach().
842
875
  }
843
876
 
877
+ // @ mention autocomplete — register before Composer/SkillAC so its keydown
878
+ // stopImmediatePropagation wins over chip deletion / Enter-send.
879
+ if (input && typeof MentionAC !== "undefined") {
880
+ MentionAC.attach({
881
+ input: "new-session-input",
882
+ menu: "ns-mention-autocomplete",
883
+ getWorkingDir: () => NewSessionStore.state.advanced.workingDir || "",
884
+ });
885
+ }
886
+ // contenteditable composer: chip deletion, plain-text paste, Shift+Enter.
887
+ if (input) Composer.init(input);
888
+
844
889
  // Slash-command skill autocomplete for this composer (mirrors chat).
845
890
  if (typeof SkillAC !== "undefined" && input) {
846
891
  SkillAC.attach({
@@ -901,6 +946,7 @@ const NewSessionView = (() => {
901
946
 
902
947
  async function onPanelShow() {
903
948
  _bindOnce();
949
+ _loadAgentName().then(_renderTitle);
904
950
  await NewSessionStore.loadAgents();
905
951
  // Must run after loadAgents(): that call restores the remembered
906
952
  // projectId from localStorage, which decides whether the working dir is
@@ -52,26 +52,33 @@ const TrashView = (() => {
52
52
  // ── Project filter select helpers ────────────────────────────────────
53
53
 
54
54
  function _refreshProjectOptions() {
55
- const sel = $("trash-filter-project");
56
- if (!sel) return;
55
+ const wrap = $("trash-filter-project");
56
+ if (!wrap) return;
57
+ const dropdown = wrap.querySelector(".custom-select-dropdown");
58
+ if (!dropdown) return;
59
+ const ctl = dropdown.__customSelect;
60
+ const currentVal = ctl ? ctl.value : "";
57
61
 
58
62
  const projects = (typeof Projects !== "undefined") ? Projects.all() : [];
59
- const currentVal = sel.value;
60
-
61
- // Rebuild options: keep the "all" placeholder, then one per project
62
- sel.innerHTML = `<option value="">${I18n.t ? I18n.t("trash.filterAllProjects") : "所有项目"}</option>`;
63
+ const allLabel = I18n.t ? I18n.t("trash.filterAllProjects") : "所有项目";
64
+
65
+ dropdown.innerHTML = "";
66
+ const all = document.createElement("div");
67
+ all.className = "custom-select-option";
68
+ all.dataset.value = "";
69
+ all.textContent = allLabel;
70
+ dropdown.appendChild(all);
63
71
  projects.forEach(p => {
64
- const opt = document.createElement("option");
65
- opt.value = p.id;
72
+ const opt = document.createElement("div");
73
+ opt.className = "custom-select-option";
74
+ opt.dataset.value = p.id;
66
75
  opt.textContent = p.name || p.id;
67
- sel.appendChild(opt);
76
+ dropdown.appendChild(opt);
68
77
  });
69
78
 
70
- // Restore previous selection if still valid
71
- if (currentVal && projects.some(p => String(p.id) === currentVal)) {
72
- sel.value = currentVal;
73
- } else {
74
- sel.value = "";
79
+ if (ctl) {
80
+ const restore = (currentVal && projects.some(p => String(p.id) === currentVal)) ? currentVal : "";
81
+ ctl.setValue(restore);
75
82
  }
76
83
  }
77
84
 
@@ -464,11 +471,16 @@ const TrashView = (() => {
464
471
  });
465
472
 
466
473
  // Project filter select
467
- const projSel = $("trash-filter-project");
468
- if (projSel) {
469
- projSel.addEventListener("change", () => {
470
- Trash.setFilterProject(projSel.value || null);
471
- });
474
+ const projWrap = $("trash-filter-project");
475
+ if (projWrap && window.CustomSelect) {
476
+ const dropdown = projWrap.querySelector(".custom-select-dropdown");
477
+ if (!dropdown.__customSelect) {
478
+ window.CustomSelect.init({
479
+ trigger: projWrap.querySelector(".custom-select-trigger"),
480
+ dropdown: dropdown,
481
+ onSelect: (value) => Trash.setFilterProject(value || null)
482
+ });
483
+ }
472
484
  }
473
485
  }
474
486
 
@@ -110,6 +110,7 @@ const WorkspaceStore = (() => {
110
110
  body: JSON.stringify({ path: _absPath(entry.path), action: "save", content })
111
111
  });
112
112
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
113
+ _emit("workspace:fileSaved", { sessionId: _sessionId, path: entry.path, name: entry.name });
113
114
  },
114
115
  };
115
116