kingkont 0.18.5 → 0.18.7

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.5",
3
+ "version": "0.18.7",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
package/renderer/board.js CHANGED
@@ -2461,11 +2461,16 @@ async function createNodeEl(node) {
2461
2461
  el.dataset.id = node.id;
2462
2462
  el.style.left = node.x + 'px';
2463
2463
  el.style.top = node.y + 'px';
2464
- // Label-ноды всегда auto-размер (по тексту) — фиксированный width/height
2465
- // создавал «невидимые боксы» вокруг текста, что ломало hit-testing когда
2466
- // несколько label оказывались рядом (клик попадал в перекрывающий, а не
2467
- // в видимый текст). Игнорируем сохранённые width/height для label-нод.
2468
- if (node.type !== 'label') {
2464
+ // Label-ноды: высоту НЕ применяем (auto по контенту), но ширину ДА,
2465
+ // если юзер явно её задал через label-width-handle. Это позволяет
2466
+ // сделать многострочный label («ширина задаёт перенос»). Без width
2467
+ // auto-width по тексту (как раньше).
2468
+ if (node.type === 'label') {
2469
+ if (node.width) {
2470
+ el.style.width = node.width + 'px';
2471
+ el.dataset.fixedWidth = '1'; // CSS: разрешаем label-text занять 100%
2472
+ }
2473
+ } else {
2469
2474
  if (node.width) el.style.width = node.width + 'px';
2470
2475
  if (node.height) el.style.height = node.height + 'px';
2471
2476
  }
@@ -2514,6 +2519,18 @@ async function createNodeEl(node) {
2514
2519
  el.appendChild(rh);
2515
2520
  attachResize(el, node, rh);
2516
2521
 
2522
+ // Label-ноды получают ВТОРОЙ хендл — на правом краю — для управления
2523
+ // только ШИРИНОЙ (юзер просил: «ноде лейбл позволь менять ширину
2524
+ // отдельно, чтобы менялось количество строк если текст длинный»).
2525
+ // Существующий .resize-handle (правый-нижний угол) меняет fontSize.
2526
+ if (node.type === 'label') {
2527
+ const wh = document.createElement('div');
2528
+ wh.className = 'label-width-handle';
2529
+ wh.title = 'Тяни — ширина (для переноса строк)';
2530
+ el.appendChild(wh);
2531
+ attachLabelWidthResize(el, node, wh);
2532
+ }
2533
+
2517
2534
  const anchor = document.createElement('div');
2518
2535
  anchor.className = 'anchor';
2519
2536
  anchor.title = 'Тяни, чтобы сослаться или сгенерировать ноду со ссылкой';
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;
@@ -746,6 +746,48 @@ async function openGenerateForRef(fromNode, clientX, clientY, forceKind) {
746
746
  }, 50);
747
747
  }
748
748
 
749
+ // Width-only resize для label-нод. Юзер тянет хендл на правом краю —
750
+ // устанавливаем node.width, label-text оборачивается. Высота auto.
751
+ // Двойной клик по хендлу — снять width (вернуть auto-width по тексту).
752
+ function attachLabelWidthResize(el, node, handle) {
753
+ handle.addEventListener('mousedown', e => {
754
+ e.preventDefault();
755
+ e.stopPropagation();
756
+ const startX = e.clientX;
757
+ const startW = el.offsetWidth;
758
+ const labelText = el.querySelector('.label-text');
759
+ const onMove = ev => {
760
+ const dx = (ev.clientX - startX) / state.zoom;
761
+ const newW = Math.max(80, Math.min(2000, startW + dx));
762
+ node.width = Math.round(newW);
763
+ el.style.width = node.width + 'px';
764
+ el.dataset.fixedWidth = '1';
765
+ // Force label-text to fill — иначе inline-block остаётся узким.
766
+ if (labelText) labelText.style.width = '100%';
767
+ renderConnections();
768
+ };
769
+ const onUp = () => {
770
+ document.removeEventListener('mousemove', onMove);
771
+ document.removeEventListener('mouseup', onUp);
772
+ scheduleSave();
773
+ };
774
+ document.addEventListener('mousemove', onMove);
775
+ document.addEventListener('mouseup', onUp);
776
+ });
777
+ // dblclick — сбросить ширину (вернуть auto-size).
778
+ handle.addEventListener('dblclick', e => {
779
+ e.preventDefault();
780
+ e.stopPropagation();
781
+ delete node.width;
782
+ el.style.width = '';
783
+ delete el.dataset.fixedWidth;
784
+ const labelText = el.querySelector('.label-text');
785
+ if (labelText) labelText.style.width = '';
786
+ renderConnections();
787
+ scheduleSave();
788
+ });
789
+ }
790
+
749
791
  function attachResize(el, node, handle) {
750
792
  handle.addEventListener('mousedown', e => {
751
793
  e.preventDefault();
@@ -1062,6 +1104,22 @@ async function deleteNode(node, el) {
1062
1104
  const ok = confirm(`Эта нода используется в таймлайне (${timelineRefs} клип${timelineRefs === 1 ? '' : 'ов'}). Удалить вместе с клипами?`);
1063
1105
  if (!ok) return;
1064
1106
  }
1107
+ // Останавливаем активные генерации для этой ноды (юзер: «если нода
1108
+ // удаляется — останавливай связанные с ней задания»). Иначе server
1109
+ // продолжал бы поллить провайдера, скачивал бы результат и пытался
1110
+ // записать файл к удалённой ноде → битое состояние.
1111
+ // 1) Local poll loop — выйдет на следующей итерации (state.jobs.delete).
1112
+ if (state.jobs?.has(node.id)) {
1113
+ state.jobs.delete(node.id);
1114
+ if (typeof updateJobsBadge === 'function') updateJobsBadge();
1115
+ }
1116
+ // 2) Server-side jobsHub — explicit end, чтобы _startPoller остановился
1117
+ // сразу, не ждал auto-end через 60s.
1118
+ if (typeof window.bgJobEnd === 'function') {
1119
+ const pk = state.cloudProjectId ? 'cloud:' + state.cloudProjectId
1120
+ : state.filmHandle?.name ? 'folder:' + state.filmHandle.name : null;
1121
+ if (pk) window.bgJobEnd(node.id, pk);
1122
+ }
1065
1123
  // Snapshot ДО мутации — чтобы undo вернул всё как было.
1066
1124
  const snap = captureScene();
1067
1125
  const movedFiles = [];
@@ -1130,6 +1188,15 @@ async function deleteSelectedNodes() {
1130
1188
  if (!confirm(`Удалить ${nodesToDelete.length} нод?`)) return;
1131
1189
  }
1132
1190
 
1191
+ // Останавливаем активные генерации для каждой удаляемой ноды (см. deleteNode).
1192
+ const _pk = state.cloudProjectId ? 'cloud:' + state.cloudProjectId
1193
+ : state.filmHandle?.name ? 'folder:' + state.filmHandle.name : null;
1194
+ for (const n of nodesToDelete) {
1195
+ if (state.jobs?.has(n.id)) state.jobs.delete(n.id);
1196
+ if (_pk && typeof window.bgJobEnd === 'function') window.bgJobEnd(n.id, _pk);
1197
+ }
1198
+ if (typeof updateJobsBadge === 'function') updateJobsBadge();
1199
+
1133
1200
  // Снимок сцены ДО — для undo
1134
1201
  const snapshot = JSON.stringify({
1135
1202
  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;
@@ -519,6 +519,12 @@
519
519
  overflow: hidden;
520
520
  }
521
521
  .welcome-card-thumb {
522
+ /* position:absolute + inset:0 — заполняем всю карточку (4:3).
523
+ БАДЖИ внутри (cloud/bg) тоже absolute → этот thumb работает как
524
+ positioning context для них. РАНЬШЕ ниже было `.welcome-card-thumb
525
+ { position: relative; }` — оно переопределяло absolute → thumb
526
+ shrink'ался до размера контента (символ «+» / «☁» / «🎬») и
527
+ прибивался к верху карточки. Слились в один rule. */
522
528
  position: absolute; inset: 0;
523
529
  background: #1a1a1a;
524
530
  display: flex; align-items: center; justify-content: center;
@@ -528,7 +534,6 @@
528
534
  .welcome-card-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
529
535
  /* ☁-бейдж в углу обложки облачного проекта. Делает cloud/folder-проекты
530
536
  визуально различимыми в общем grid'е (без необходимости второго рядa). */
531
- .welcome-card-thumb { position: relative; }
532
537
  .welcome-card-cloud-badge {
533
538
  position: absolute; right: 6px; bottom: 6px;
534
539
  background: rgba(20, 30, 50, 0.85); color: #9cf;
@@ -764,6 +769,32 @@
764
769
  }
765
770
  .node.label-node:hover .resize-handle,
766
771
  .node.label-node:focus-within .resize-handle { opacity: 1; }
772
+ /* Width-only handle на правом краю — отдельно от font-size handle.
773
+ Тянуть → меняется ширина label, текст переносится. dblclick →
774
+ сбросить ширину обратно к auto. */
775
+ .node.label-node .label-width-handle {
776
+ position: absolute;
777
+ right: -3px; top: 50%;
778
+ transform: translateY(-50%);
779
+ width: 6px; height: 36px;
780
+ background: rgba(90,168,255,0.7);
781
+ border: 1px solid rgba(255,255,255,0.35);
782
+ border-radius: 3px;
783
+ cursor: ew-resize;
784
+ opacity: 0; transition: opacity 0.15s;
785
+ z-index: 11;
786
+ }
787
+ .node.label-node:hover .label-width-handle,
788
+ .node.label-node:focus-within .label-width-handle { opacity: 1; }
789
+ /* Когда label имеет explicit width (data-fixed-width="1") — внутренний
790
+ label-text должен ЗАПОЛНЯТЬ родителя, иначе inline-block остаётся
791
+ узким и текст не переносится. Сбрасываем min/max-width inner'a. */
792
+ .node.label-node[data-fixed-width="1"] .label-text {
793
+ width: 100%;
794
+ min-width: 0;
795
+ max-width: none;
796
+ box-sizing: border-box;
797
+ }
767
798
 
768
799
  /* Body — wrapper без визуального вклада. */
769
800
  .node.label-node .node-body {