kingkont 0.18.4 → 0.18.6

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.
package/index.html CHANGED
@@ -63,8 +63,10 @@
63
63
  </div>
64
64
  <div class="sidebar-footer">
65
65
  <div id="balancesAll" style="display:flex; flex-direction:column; gap:4px;"></div>
66
- <span id="jobsInfo" class="jobs-info" style="display:none;"></span>
67
- <span class="hint">Перетаскивай файлы на холст · @имя для ссылок</span>
66
+ <!-- jobsInfo + hint убраны (юзер: «убери инфу 'в фоне' и 'перетаскивай
67
+ файлы на холст'»). Отступ сохранён через min-height в CSS — UI
68
+ не должен «прыгнуть» вверх. Активные процессы показываются на
69
+ кнопке 🔔 (см. notifyPanel.js _renderBellState). -->
68
70
  </div>
69
71
  </aside>
70
72
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.18.4",
3
+ "version": "0.18.6",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
package/renderer/board.js CHANGED
@@ -976,6 +976,30 @@ function showCloudCardContextMenu(p, clientX, clientY) {
976
976
  b.addEventListener('click', () => { menu.classList.add('hidden'); fn(); });
977
977
  menu.appendChild(b);
978
978
  };
979
+ add('✏️ Переименовать…', async () => {
980
+ const newName = await askName('Новое имя проекта:', '', p.name || '', { okText: 'Переименовать' });
981
+ if (!newName || newName.trim() === (p.name || '').trim()) return;
982
+ try {
983
+ // POST /api/projects/<id> с {name} → providers.updateProject → Chatium ~update.
984
+ const r = await fetch('/api/projects/' + encodeURIComponent(p.id), {
985
+ method: 'POST',
986
+ headers: { 'Content-Type': 'application/json' },
987
+ body: JSON.stringify({ name: newName.trim() }),
988
+ });
989
+ if (!r.ok) {
990
+ const err = await r.json().catch(() => ({}));
991
+ throw new Error(err.error || 'HTTP ' + r.status);
992
+ }
993
+ // Обновляем кэш локально, чтобы welcome не дёргал сеть для перерисовки.
994
+ try {
995
+ const cached = JSON.parse(localStorage.getItem('cloudProjectsCache') || '[]');
996
+ const i = cached.findIndex(c => c.id === p.id);
997
+ if (i >= 0) { cached[i].name = newName.trim(); cached[i].updatedAt = Date.now(); }
998
+ localStorage.setItem('cloudProjectsCache', JSON.stringify(cached));
999
+ } catch {}
1000
+ await renderWelcomeRecents();
1001
+ } catch (e) { alert('Не удалось переименовать: ' + (e?.message || e)); }
1002
+ });
979
1003
  add('📂 Открыть в Finder', async () => {
980
1004
  if (!window.cloudFs?.openInFinder) {
981
1005
  alert('Доступно только в Electron-приложении');
package/renderer/chat.js CHANGED
@@ -77,24 +77,63 @@
77
77
  },
78
78
 
79
79
  read_scene: {
80
- description: 'Текущая доска: список нод (id, type, name?, prompt?, file?, x, y) и связи.',
80
+ description: 'Текущая доска: список нод (id, type, name?, prompt?, file?, x, y, width, height) и связи. ВАЖНО: x/y — top-left ноды; width/height — реальные размеры (либо измеренные ResizeObserver\'ом, либо дефолт по типу). Используй при размещении новых нод чтобы не накладывались. Также возвращается scene.bbox (общий rect используемой области) и canvasSize (где можно ставить — типичный canvas 6000×4000).',
81
81
  params: '{}',
82
82
  async handler() {
83
83
  const b = state.currentBoard;
84
84
  if (!b) throw new Error('доска не выбрана — используй select_scene');
85
- const nodes = (b.metadata.nodes || []).map(n => ({
86
- id: n.id,
87
- type: n.type,
88
- name: n.name || null,
89
- x: n.x, y: n.y,
90
- prompt: n.generated?.rawPrompt || n.generated?.prompt || null,
91
- file: n.file || null,
92
- status: n.status || (n.file ? 'done' : 'draft'),
93
- modelKey: n.generated?.modelKey || null,
94
- aspectRatio: n.generated?.aspectRatio || null,
95
- }));
85
+ // Дефолты width/height по типу синхронизированы с _defaultH в board.js
86
+ // и overlap-detection в add_node ниже. Если ResizeObserver уже измерил
87
+ // ноду — используем кэш n.width/n.height (точнее).
88
+ function defW(t) { return (t === 'text' || t === 'label' || t === 'audio') ? 200 : 280; }
89
+ function defH(t) {
90
+ if (t === 'text') return 120;
91
+ if (t === 'audio') return 110;
92
+ if (t === 'image' || t === 'video') return 220;
93
+ if (t === 'label') return 60;
94
+ return 80;
95
+ }
96
+ const nodes = (b.metadata.nodes || []).map(n => {
97
+ const w = n.width || defW(n.type);
98
+ const h = n.height || defH(n.type);
99
+ return {
100
+ id: n.id,
101
+ type: n.type,
102
+ name: n.name || null,
103
+ x: n.x, y: n.y,
104
+ width: w, height: h,
105
+ // Удобно для LLM: rect = {left, top, right, bottom} — не надо
106
+ // считать самому при поиске свободного места.
107
+ rect: { left: n.x, top: n.y, right: n.x + w, bottom: n.y + h },
108
+ prompt: n.generated?.rawPrompt || n.generated?.prompt || null,
109
+ file: n.file || null,
110
+ status: n.status || (n.file ? 'done' : 'draft'),
111
+ modelKey: n.generated?.modelKey || null,
112
+ aspectRatio: n.generated?.aspectRatio || null,
113
+ };
114
+ });
96
115
  const connections = (b.metadata.connections || []).map(c => ({ from: c.from, to: c.to, toPort: c.toPort || null }));
97
- return { kind: b.kind, name: b.name, nodes, connections, settings: b.metadata.settings || {} };
116
+ // BBox всей сцены даёт LLM понимание «где плотно, где свободно».
117
+ let bbox = null;
118
+ if (nodes.length) {
119
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
120
+ for (const n of nodes) {
121
+ if (n.rect.left < minX) minX = n.rect.left;
122
+ if (n.rect.top < minY) minY = n.rect.top;
123
+ if (n.rect.right > maxX) maxX = n.rect.right;
124
+ if (n.rect.bottom > maxY) maxY = n.rect.bottom;
125
+ }
126
+ bbox = { left: minX, top: minY, right: maxX, bottom: maxY,
127
+ width: maxX - minX, height: maxY - minY };
128
+ }
129
+ return {
130
+ kind: b.kind, name: b.name,
131
+ nodes, connections,
132
+ settings: b.metadata.settings || {},
133
+ bbox,
134
+ canvasSize: { width: 6000, height: 4000 },
135
+ hint: 'Размещая новую ноду, проверь чтобы её rect (x, y, x+width, y+height) не пересекался с существующими. Шаг 40-60px между нодами выглядит аккуратно.',
136
+ };
98
137
  },
99
138
  },
100
139
 
@@ -643,6 +682,17 @@
643
682
  // Забираем историю с сервера (server-side state — source of truth).
644
683
  // Если на сервере есть pending tool-loop — сразу стартуем polling.
645
684
  async function loadHistoryFromCurrentProject() {
685
+ // Восстанавливаем «был ли чат открыт» в этом проекте. Юзер просил:
686
+ // «если в проекте был открыт чат — открывай его при переходе в проект».
687
+ // Делаем ДО загрузки истории — чтобы UI открылся сразу, история подтянется.
688
+ try {
689
+ const k = _chatOpenKey();
690
+ if (k && localStorage.getItem(k) === '1') {
691
+ ensureUI();
692
+ $('chatPanel').classList.remove('hidden');
693
+ // Не зовём _persistChatOpen(true) — флаг и так стоит.
694
+ }
695
+ } catch {}
646
696
  const key = sessionKey();
647
697
  if (!key) { history = []; renderHistory(); return; }
648
698
  try {
@@ -1230,14 +1280,16 @@
1230
1280
  // Финальный ответ — показать toast + system notification
1231
1281
  // (даже если чат-панель скрыта).
1232
1282
  if (m.event?.kind === 'final') {
1283
+ // target.chat — navigateToTarget откроет чат-панель вместо доски.
1284
+ const chatTarget = { target: { chat: true } };
1233
1285
  if (m.event.error) {
1234
- if (typeof showToast === 'function') showToast(`💬 KingKont: ⚠ ${m.event.error.slice(0, 80)}`, 'error');
1286
+ if (typeof showToast === 'function') showToast(`💬 KingKont: ⚠ ${m.event.error.slice(0, 80)}`, 'error', chatTarget);
1235
1287
  if (typeof systemNotify === 'function' && document.hidden) {
1236
1288
  systemNotify('KingKont chat', '⚠ ' + m.event.error.slice(0, 100), { tag: 'chat-final' }).catch(() => {});
1237
1289
  }
1238
1290
  } else if (m.event.text) {
1239
1291
  const preview = m.event.text.length > 100 ? m.event.text.slice(0, 100) + '…' : m.event.text;
1240
- if (typeof showToast === 'function') showToast(`💬 KingKont: ${preview}`, 'ok');
1292
+ if (typeof showToast === 'function') showToast(`💬 KingKont: ${preview}`, 'ok', chatTarget);
1241
1293
  if (typeof systemNotify === 'function' && document.hidden) {
1242
1294
  systemNotify('KingKont chat', preview, { tag: 'chat-final' }).catch(() => {});
1243
1295
  }
@@ -1570,7 +1622,10 @@
1570
1622
  e.preventDefault();
1571
1623
  for (const f of files) await attachFileToChat(f);
1572
1624
  });
1573
- $('chatClose').addEventListener('click', () => panel.classList.add('hidden'));
1625
+ $('chatClose').addEventListener('click', () => {
1626
+ panel.classList.add('hidden');
1627
+ _persistChatOpen(false);
1628
+ });
1574
1629
  $('chatClear').addEventListener('click', () => {
1575
1630
  if (!confirm('Очистить историю чата?')) return;
1576
1631
  history = [];
@@ -1591,11 +1646,29 @@
1591
1646
  });
1592
1647
  }
1593
1648
 
1649
+ // Per-project флаг «чат был открыт». При openFilm восстанавливаем
1650
+ // (юзер: «если в проекте был открыт чат — открывай его при переходе в проект»).
1651
+ function _chatOpenKey() {
1652
+ const pk = state.cloudProjectId ? 'cloud:' + state.cloudProjectId
1653
+ : state.filmHandle?.name ? 'folder:' + state.filmHandle.name : null;
1654
+ return pk ? 'chatOpen:' + pk : null;
1655
+ }
1656
+ function _persistChatOpen(open) {
1657
+ const k = _chatOpenKey();
1658
+ if (!k) return;
1659
+ try {
1660
+ if (open) localStorage.setItem(k, '1');
1661
+ else localStorage.removeItem(k);
1662
+ } catch {}
1663
+ }
1664
+
1594
1665
  function toggle() {
1595
1666
  ensureUI();
1596
1667
  const panel = $('chatPanel');
1597
1668
  panel.classList.toggle('hidden');
1598
- if (!panel.classList.contains('hidden')) {
1669
+ const isOpen = !panel.classList.contains('hidden');
1670
+ _persistChatOpen(isOpen);
1671
+ if (isOpen) {
1599
1672
  // Z-index сам разруливает: preview.collapsed → ниже чата (z=40 vs 45),
1600
1673
  // preview открыт → поверх (z=50). Auto-collapse больше не нужен.
1601
1674
  // Отрисовываем сохранённую историю при показе (на случай если она
@@ -1627,7 +1700,17 @@
1627
1700
  // Public API.
1628
1701
  window.kingChat = {
1629
1702
  toggle,
1630
- open: () => { ensureUI(); $('chatPanel').classList.remove('hidden'); setTimeout(() => $('chatInput')?.focus(), 50); },
1703
+ open: () => {
1704
+ ensureUI();
1705
+ $('chatPanel').classList.remove('hidden');
1706
+ _persistChatOpen(true);
1707
+ renderHistory();
1708
+ renderContextRow();
1709
+ setTimeout(() => $('chatInput')?.focus(), 50);
1710
+ },
1711
+ // close() — implicit (board.js closeProject) — НЕ persist'им флаг,
1712
+ // иначе при следующем открытии проекта чат не восстановится.
1713
+ // Юзерское × persists через свой handler ниже (chatClose).
1631
1714
  close: () => $('chatPanel')?.classList.add('hidden'),
1632
1715
  send,
1633
1716
  // User-clear: стираем И на сервере (юзер явно нажал ⌫).
@@ -55,21 +55,36 @@
55
55
  `;
56
56
  document.head.appendChild(style);
57
57
  }
58
- // Кнопка-колокольчик в нижнем-левом углу там есть свободное место,
59
- // не перекрывает sidebar/canvas-toolbar/welcome-status.
58
+ // Контейнер: 🔔-кнопка + описание справа. Раньше была только кнопка,
59
+ // теперь юзер хочет видеть «что происходит в фоне» рядом с ней
60
+ // (info про активные генерации убрана из sidebar-footer).
61
+ const wrap = document.createElement('div');
62
+ wrap.id = 'notifyBtnWrap';
63
+ wrap.style.cssText = 'position:fixed; bottom:12px; left:12px; z-index:9998; display:flex; align-items:center; gap:8px; pointer-events:none;';
64
+ document.body.appendChild(wrap);
65
+
60
66
  const btn = document.createElement('button');
61
67
  btn.id = 'notifyBtn';
62
- btn.title = 'События (генерации, чат, ошибки)';
63
- btn.style.cssText = 'position:fixed; bottom:12px; left:12px; z-index:9998; background:rgba(30,30,40,0.85); border:1px solid #444; color:#ccc; font-size:14px; width:32px; height:32px; border-radius:50%; cursor:pointer; backdrop-filter:blur(4px); display:flex; align-items:center; justify-content:center; transition:background 0.2s;';
64
- btn.innerHTML = '🔔';
65
- document.body.appendChild(btn);
68
+ btn.title = 'События (генерации, чат, ошибки) — клик чтобы открыть';
69
+ btn.style.cssText = 'position:relative; background:rgba(30,30,40,0.85); border:1px solid #444; color:#ccc; font-size:14px; width:32px; height:32px; border-radius:50%; cursor:pointer; backdrop-filter:blur(4px); display:flex; align-items:center; justify-content:center; transition:background 0.2s; pointer-events:auto; flex-shrink:0;';
70
+ btn.innerHTML = '<span id="notifyBtnIcon">🔔</span>';
71
+ wrap.appendChild(btn);
66
72
  btn.addEventListener('click', toggle);
67
- // Бейдж со счётчиком unread.
73
+ // Бейдж со счётчиком unread (события, не задачи).
68
74
  const badge = document.createElement('span');
69
75
  badge.id = 'notifyBadge';
70
76
  badge.style.cssText = 'position:absolute; top:-4px; right:-4px; background:#e33377; color:#fff; font-size:9px; min-width:14px; height:14px; line-height:14px; text-align:center; border-radius:8px; padding:0 4px; display:none; pointer-events:none;';
71
77
  btn.appendChild(badge);
72
78
 
79
+ // Описание текущей фоновой активности — справа от кнопки.
80
+ // Показывается когда есть активные jobs; иначе hidden.
81
+ const desc = document.createElement('div');
82
+ desc.id = 'notifyActivityDesc';
83
+ desc.style.cssText = 'background:rgba(30,30,40,0.85); border:1px solid #444; color:#cde; font-size:11px; line-height:1.3; padding:5px 10px; border-radius:14px; backdrop-filter:blur(4px); max-width:380px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:none; pointer-events:auto; cursor:pointer;';
84
+ desc.title = 'Текущие фоновые задачи (клик — раскрыть события)';
85
+ desc.addEventListener('click', () => setOpen(true, 'manual'));
86
+ wrap.appendChild(desc);
87
+
73
88
  // Панель — bottom-left как и кнопка.
74
89
  const panel = document.createElement('div');
75
90
  panel.id = 'notifyPanel';
@@ -217,6 +232,7 @@
217
232
  render();
218
233
  }
219
234
  _pulseBell();
235
+ _renderBellState();
220
236
  // Авто-открытие панели на 3.5s — юзер видит событие сразу, потом
221
237
  // панель прячется. Если юзер сам открыл — не трогаем (проверка в
222
238
  // openAuto). Каждое новое событие сбрасывает таймер.
@@ -232,6 +248,91 @@
232
248
  void btn.offsetWidth;
233
249
  btn.classList.add('pulsing');
234
250
  }
251
+
252
+ // === Bell-state: счётчик активных задач + описание ===
253
+ // Юзер: «количество актуальных процессов выводи на месте колокольчика,
254
+ // справа от него описывай что происходит». Источник — state.jobs (local
255
+ // pollers) + window.bgJobsAll() (server-side jobs hub). Берём максимум
256
+ // (server может видеть больше, если renderer недавно открылся).
257
+ function _renderBellState() {
258
+ const icon = $('notifyBtnIcon');
259
+ const desc = $('notifyActivityDesc');
260
+ if (!icon || !desc) return;
261
+ // Считаем АКТИВНЫЕ задачи только для ТЕКУЩЕГО проекта (если открыт).
262
+ // Иначе welcome-экран показал бы все задачи всех проектов — confusing.
263
+ const items = _collectActiveJobs();
264
+ if (!items.length) {
265
+ icon.textContent = '🔔';
266
+ desc.style.display = 'none';
267
+ desc.textContent = '';
268
+ return;
269
+ }
270
+ // На месте колокольчика — число.
271
+ icon.textContent = String(items.length);
272
+ icon.style.fontSize = items.length > 9 ? '11px' : '12px';
273
+ icon.style.fontWeight = '700';
274
+ // Справа — описание. Формат: «🖼 image «name» + N ещё», или, если
275
+ // одна — полное описание; если несколько типов — kind-counters.
276
+ desc.textContent = _describeActivity(items);
277
+ desc.style.display = '';
278
+ }
279
+ function _collectActiveJobs() {
280
+ const out = [];
281
+ const seen = new Set();
282
+ // 1) state.jobs (local pollers) — это in-flight для текущего board.
283
+ if (window.state?.jobs) {
284
+ for (const [nodeId, j] of window.state.jobs.entries?.() || []) {
285
+ if (seen.has(nodeId)) continue;
286
+ seen.add(nodeId);
287
+ out.push({ nodeId, kind: j.kind || 'gen', name: _resolveNodeName(nodeId) });
288
+ }
289
+ }
290
+ // 2) bgJobsAll (localStorage source) — для текущего проекта.
291
+ const pk = _currentProjectKey();
292
+ if (pk && typeof window.bgJobsAll === 'function') {
293
+ const all = window.bgJobsAll();
294
+ const list = all[pk] || [];
295
+ for (const j of list) {
296
+ if (seen.has(j.nodeId)) continue;
297
+ seen.add(j.nodeId);
298
+ out.push({ nodeId: j.nodeId, kind: j.kind || 'gen', name: j.name || null });
299
+ }
300
+ }
301
+ return out;
302
+ }
303
+ function _currentProjectKey() {
304
+ const s = window.state;
305
+ if (!s) return null;
306
+ if (s.cloudProjectId) return 'cloud:' + s.cloudProjectId;
307
+ if (s.filmHandle?.name) return 'folder:' + s.filmHandle.name;
308
+ return null;
309
+ }
310
+ function _resolveNodeName(nodeId) {
311
+ const nodes = window.state?.currentBoard?.metadata?.nodes;
312
+ if (!Array.isArray(nodes)) return null;
313
+ const n = nodes.find(x => x.id === nodeId);
314
+ return n?.name || null;
315
+ }
316
+ function _describeActivity(items) {
317
+ const KIND_ICON = { image: '🖼', video: '🎬', text: '📝', audio: '🎙', chat: '💬' };
318
+ if (items.length === 1) {
319
+ const it = items[0];
320
+ const ic = KIND_ICON[it.kind] || '⚙';
321
+ const label = it.name ? `«${it.name}»` : '(без имени)';
322
+ return `${ic} ${it.kind} ${label}`;
323
+ }
324
+ // Группируем по kind для краткой сводки.
325
+ const counts = {};
326
+ for (const it of items) counts[it.kind] = (counts[it.kind] || 0) + 1;
327
+ const parts = Object.entries(counts).map(([k, n]) => {
328
+ const ic = KIND_ICON[k] || '⚙';
329
+ return `${ic} ${k}×${n}`;
330
+ });
331
+ return parts.join(' · ');
332
+ }
333
+ // Public, чтобы generate.js / state.js / etc могли явно ткнуть rerender.
334
+ window.notifyPanel = window.notifyPanel || {};
335
+ // (расширим объект публичного API ниже — здесь просто резерв.)
235
336
  // Auto-flash: открыть в auto-режиме и закрыть через таймер. Срабатывает
236
337
  // на КАЖДОЕ addEvent. Если юзер уже manual'но открыл — открытие no-op'ится.
237
338
  let _autoFlashT = null;
@@ -245,10 +346,18 @@
245
346
  }, 5000);
246
347
  }
247
348
 
248
- // Navigate from notification to scene/node.
249
- // target: {projectKey, boardKey, nodeId?}
349
+ // Navigate from notification to scene/node — или в чат для chat-event'ов.
350
+ // target: {projectKey, boardKey, nodeId?} | {chat: true}
250
351
  async function navigateToTarget(target) {
251
- if (!target?.projectKey) return;
352
+ if (!target) return;
353
+ // Чат-нотификация — открыть чат-панель (а не доску). Юзер: «когда
354
+ // уведомление приходит из чата — клик по уведомлению должен вести в чат».
355
+ if (target.chat) {
356
+ setOpen(false);
357
+ if (window.kingChat?.open) window.kingChat.open();
358
+ return;
359
+ }
360
+ if (!target.projectKey) return;
252
361
  setOpen(false);
253
362
  const [pkType, pkId] = String(target.projectKey).split(':', 2);
254
363
  const currentKey = state.cloudProjectId ? 'cloud:' + state.cloudProjectId
@@ -333,6 +442,15 @@
333
442
  q.length = 0;
334
443
  }
335
444
  } catch {}
445
+ // Bell-state: первичная отрисовка + подписка на bgjobs:changed.
446
+ // Этот event кидают bgJobStart/bgJobEnd (state.js) — счётчик и
447
+ // описание мгновенно обновляются.
448
+ _renderBellState();
449
+ window.addEventListener('bgjobs:changed', _renderBellState);
450
+ // Selection / current-board меняются без event'а — лёгкий poll
451
+ // даёт мгновенное название ноды («когда state.jobs обновился, но
452
+ // bgjobs:changed не прилетел»).
453
+ setInterval(_renderBellState, 1500);
336
454
  // WS-канал jobs:all для start/end/done/failed events. Connect к /ws.
337
455
  function _connect() {
338
456
  let ws;
@@ -1062,6 +1062,22 @@ async function deleteNode(node, el) {
1062
1062
  const ok = confirm(`Эта нода используется в таймлайне (${timelineRefs} клип${timelineRefs === 1 ? '' : 'ов'}). Удалить вместе с клипами?`);
1063
1063
  if (!ok) return;
1064
1064
  }
1065
+ // Останавливаем активные генерации для этой ноды (юзер: «если нода
1066
+ // удаляется — останавливай связанные с ней задания»). Иначе server
1067
+ // продолжал бы поллить провайдера, скачивал бы результат и пытался
1068
+ // записать файл к удалённой ноде → битое состояние.
1069
+ // 1) Local poll loop — выйдет на следующей итерации (state.jobs.delete).
1070
+ if (state.jobs?.has(node.id)) {
1071
+ state.jobs.delete(node.id);
1072
+ if (typeof updateJobsBadge === 'function') updateJobsBadge();
1073
+ }
1074
+ // 2) Server-side jobsHub — explicit end, чтобы _startPoller остановился
1075
+ // сразу, не ждал auto-end через 60s.
1076
+ if (typeof window.bgJobEnd === 'function') {
1077
+ const pk = state.cloudProjectId ? 'cloud:' + state.cloudProjectId
1078
+ : state.filmHandle?.name ? 'folder:' + state.filmHandle.name : null;
1079
+ if (pk) window.bgJobEnd(node.id, pk);
1080
+ }
1065
1081
  // Snapshot ДО мутации — чтобы undo вернул всё как было.
1066
1082
  const snap = captureScene();
1067
1083
  const movedFiles = [];
@@ -1130,6 +1146,15 @@ async function deleteSelectedNodes() {
1130
1146
  if (!confirm(`Удалить ${nodesToDelete.length} нод?`)) return;
1131
1147
  }
1132
1148
 
1149
+ // Останавливаем активные генерации для каждой удаляемой ноды (см. deleteNode).
1150
+ const _pk = state.cloudProjectId ? 'cloud:' + state.cloudProjectId
1151
+ : state.filmHandle?.name ? 'folder:' + state.filmHandle.name : null;
1152
+ for (const n of nodesToDelete) {
1153
+ if (state.jobs?.has(n.id)) state.jobs.delete(n.id);
1154
+ if (_pk && typeof window.bgJobEnd === 'function') window.bgJobEnd(n.id, _pk);
1155
+ }
1156
+ if (typeof updateJobsBadge === 'function') updateJobsBadge();
1157
+
1133
1158
  // Снимок сцены ДО — для undo
1134
1159
  const snapshot = JSON.stringify({
1135
1160
  nodes: board.metadata.nodes,
@@ -123,12 +123,12 @@
123
123
  border-top: 1px solid #333; padding: 10px 12px;
124
124
  display: flex; flex-direction: column; gap: 6px;
125
125
  font-size: 11px; color: #777;
126
+ /* Отступ сохранён даже когда jobsInfo и hint убраны — иначе sidebar
127
+ «прыгнет» вверх. 56px ≈ высота прежних двух строк + padding. */
128
+ min-height: 56px;
126
129
  }
127
- /* «В фоне: и hint «Перетаскивай файлы…» отступаем влево на ширину
128
- 🔔-кнопки (32px + 12px gap слева от sidebar = 44px), чтобы кнопка
129
- событий (position:fixed; left:12px; bottom:12px) их не перекрывала. */
130
- .sidebar-footer .hint { color: #777; font-size: 11px; line-height: 1.4; padding-left: 44px; }
131
- .sidebar-footer .jobs-info { color: #aaccdd; font-size: 11px; padding-left: 44px; }
130
+ .sidebar-footer .hint { color: #777; font-size: 11px; line-height: 1.4; }
131
+ .sidebar-footer .jobs-info { color: #aaccdd; font-size: 11px; }
132
132
  .sidebar-footer .balance-info {
133
133
  display: flex; align-items: center; gap: 6px; font-size: 11px;
134
134
  color: #c4c4c4; padding: 4px 8px; background: #2a2a2a;
@@ -163,11 +163,18 @@
163
163
  margin-left: 4px;
164
164
  }
165
165
  .sidebar-header .brand .sub { font-size: 10px; color: #888; letter-spacing: 0.5px; text-transform: uppercase; }
166
- .sidebar-header .brand .sub.has-project { color: #aaccdd; text-transform: none; letter-spacing: 0; font-size: 11px; }
166
+ /* Когда проект открыт название проекта показывается КРУПНО (juser попросил
167
+ «название проекта показывай крупным сверху-слева»). Title «KingKont vX»
168
+ остаётся, но визуально вторичен — sub перебивает по размеру. */
169
+ .sidebar-header .brand .sub.has-project {
170
+ color: #fff; text-transform: none; letter-spacing: 0;
171
+ font-size: 18px; font-weight: 600; line-height: 1.2;
172
+ word-break: break-word;
173
+ }
174
+ /* Имя выбранной сцены спрятано в шапке (юзер: «название выбранной сцены
175
+ скрывай»). Сама подсветка сцены остаётся в sidebar-list. */
167
176
  .sidebar-header .brand .board {
168
- margin-top: 3px; font-size: 13px; font-weight: 600; color: #e0e0e0;
169
- word-break: break-all; line-height: 1.2;
170
- display: flex; align-items: center; gap: 6px;
177
+ display: none !important;
171
178
  }
172
179
  .sidebar-header .brand .board .kind {
173
180
  font-size: 9px; font-weight: 600; letter-spacing: 0.5px;