@sequent-org/moodboard 1.4.33 → 1.4.34

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequent-org/moodboard",
3
- "version": "1.4.33",
3
+ "version": "1.4.34",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -41,6 +41,7 @@ export class ChatSessionController {
41
41
  this._settingsStorage = settingsStorage || (typeof localStorage !== 'undefined' ? localStorage : null);
42
42
  this._listeners = new Set();
43
43
  this._abort = null;
44
+ this._aborts = new Map();
44
45
 
45
46
  this._state = {
46
47
  messages: this._history.load().map((m) => (m.pending ? { ...m, pending: false, error: m.error || 'Прервано' } : m)),
@@ -103,10 +104,11 @@ export class ChatSessionController {
103
104
  }
104
105
 
105
106
  abort() {
106
- if (this._abort) {
107
- try { this._abort.abort(); } catch { /* noop */ }
108
- this._abort = null;
107
+ for (const controller of this._aborts.values()) {
108
+ try { controller.abort(); } catch { /* noop */ }
109
109
  }
110
+ this._aborts.clear();
111
+ this._abort = null;
110
112
  }
111
113
 
112
114
  /**
@@ -116,14 +118,15 @@ export class ChatSessionController {
116
118
  */
117
119
  async send(text, options = {}) {
118
120
  const trimmed = (text || '').trim();
119
- if (!trimmed || this._state.status === 'streaming') return;
121
+ if (!trimmed) return;
120
122
 
121
123
  const imageCount = normalizeImageCount(options.imageCount);
124
+ const batchId = `batch_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
122
125
  const userMsg = makeMessage('user', trimmed);
123
126
  const assistantMsgs = Array.from({ length: imageCount }, (_, index) => makeMessage(
124
127
  'assistant',
125
128
  imageCount > 1 ? `Генерируется изображение ${index + 1} из ${imageCount}…` : '',
126
- { provider: 'yandex-art', pending: true, kind: 'image' }
129
+ { provider: 'yandex-art', pending: true, kind: 'image', batchId }
127
130
  ));
128
131
 
129
132
  this._state = {
@@ -136,6 +139,7 @@ export class ChatSessionController {
136
139
  this._emit();
137
140
 
138
141
  const abort = new AbortController();
142
+ this._aborts.set(batchId, abort);
139
143
  this._abort = abort;
140
144
  let lastError = null;
141
145
 
@@ -174,11 +178,13 @@ export class ChatSessionController {
174
178
  })
175
179
  );
176
180
  } finally {
177
- this._abort = null;
181
+ this._aborts.delete(batchId);
182
+ this._abort = this._aborts.size > 0 ? [...this._aborts.values()][this._aborts.size - 1] : null;
183
+ const stillStreaming = this._state.messages.some((m) => m.pending);
178
184
  this._state = {
179
185
  ...this._state,
180
- status: lastError ? 'error' : 'idle',
181
- error: lastError
186
+ status: stillStreaming ? 'streaming' : (lastError ? 'error' : 'idle'),
187
+ error: stillStreaming ? this._state.error : lastError
182
188
  };
183
189
  this._history.save(this._state.messages);
184
190
  this._emit();
@@ -47,7 +47,7 @@ export class ChatComposer {
47
47
  });
48
48
  this._on(this._send, 'click', () => {
49
49
  if (this._send.dataset.state === 'streaming') {
50
- this._handlers.onAbort?.();
50
+ if (this._hasContent()) this._submit();
51
51
  } else {
52
52
  this._submit();
53
53
  }
@@ -140,7 +140,6 @@ export class ChatComposer {
140
140
  const trimmed = text.trim();
141
141
  const hasAttachments = this._attachments.length > 0;
142
142
  if (!trimmed && !hasAttachments) return;
143
- if (this._send.dataset.state === 'streaming') return;
144
143
  const attachments = this._attachments.map((entry) => entry.file);
145
144
  this._textarea.value = '';
146
145
  this._attachments = [];
@@ -150,6 +149,10 @@ export class ChatComposer {
150
149
  this._handlers.onSubmit?.(trimmed, attachments);
151
150
  }
152
151
 
152
+ _hasContent() {
153
+ return this._textarea.value.trim().length > 0 || this._attachments.length > 0;
154
+ }
155
+
153
156
  _refreshSendState() {
154
157
  const hasText = this._textarea.value.trim().length > 0;
155
158
  const hasAttachments = this._attachments.length > 0;
@@ -144,8 +144,10 @@ export class ChatWindow {
144
144
  this._pendingOverlays = new Map();
145
145
  this._pendingOverlayTimers = new Map();
146
146
  this._boardImageShiftAnimations = new Map();
147
+ this._pendingBatchOffsets = new Map();
147
148
  this._clearSelectionOnSendClick = null;
148
149
  this._selectionHandlers = null;
150
+ this._viewportHandlers = null;
149
151
  // Окно от BoxSelectStart до BoxSelectCommit: в это время SelectionAdd
150
152
  // приходит на каждый mousemove и не должен пушить превью в чат —
151
153
  // финальный набор картинок мы получим из BoxSelectCommit по strict-contains.
@@ -180,6 +182,7 @@ export class ChatWindow {
180
182
  this._refs.send.addEventListener('click', this._clearSelectionOnSendClick);
181
183
  this._composer.attach();
182
184
  this._attachSelectionEvents();
185
+ this._attachViewportSync();
183
186
 
184
187
  this._extendedPromptModal = new ChatExtendedPromptModal(
185
188
  this._container,
@@ -268,8 +271,10 @@ export class ChatWindow {
268
271
  this._clearSelectionOnSendClick = null;
269
272
  }
270
273
  this._detachSelectionEvents();
274
+ this._detachViewportSync();
271
275
  this._shiftedForImageBatchKeys.clear();
272
276
  this._boardImageShiftHistory.clear();
277
+ this._pendingBatchOffsets.clear();
273
278
  this._composer?.destroy();
274
279
  this._extendedPromptModal?.destroy();
275
280
  this._contentTypeMenu?.destroy();
@@ -338,6 +343,7 @@ export class ChatWindow {
338
343
  if (state.status !== 'streaming') {
339
344
  this._revertFailedBatchShifts(state.messages);
340
345
  }
346
+ this._cleanupPlacedBatchOffsets(state.messages);
341
347
  this._messageList.render(state.messages);
342
348
  this._contentTypeMenu.refresh();
343
349
  this._modelMenu.refresh();
@@ -367,35 +373,62 @@ export class ChatWindow {
367
373
  const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
368
374
  const s = world?.scale?.x || 1;
369
375
 
370
- this._shiftExistingImagesForBatch(messages, pending[0].id, s);
376
+ // Смещаем уже размещённые board-объекты для каждого батча (дедупликация внутри метода)
377
+ const shiftedBids = new Set();
378
+ for (const m of pending) {
379
+ const bid = m.batchId || m.id;
380
+ if (!shiftedBids.has(bid)) {
381
+ shiftedBids.add(bid);
382
+ this._shiftExistingImagesForBatch(messages, m.id, s);
383
+ }
384
+ }
371
385
 
372
- const [wr, hr] = parseFormatRatio(this._formatId);
373
- const ratio = wr / hr;
374
- const wScreen = Math.round(BOARD_IMAGE_WIDTH * s);
375
- const hScreen = Math.round(wScreen / ratio);
376
- const enterDistance = Math.round(BOARD_IMAGE_STEP * s * BOARD_IMAGE_PENDING_ENTER_FACTOR);
386
+ // Смещаем pending-оверлеи для новых батчей (от старших к новейшему)
387
+ const pendingBatchMeta = [];
388
+ const seenBids = new Set();
389
+ for (const m of pending) {
390
+ if (m.batchId && !seenBids.has(m.batchId)) {
391
+ seenBids.add(m.batchId);
392
+ pendingBatchMeta.push({
393
+ batchId: m.batchId,
394
+ count: pending.filter((pm) => pm.batchId === m.batchId).length
395
+ });
396
+ }
397
+ }
398
+ for (const { batchId, count } of pendingBatchMeta) {
399
+ this._shiftPendingOverlaysForNewBatch(batchId, count, s);
400
+ }
401
+
402
+ const enterDistance = this._computeEnterDistance(messages, pending, s, Math.round(BOARD_IMAGE_WIDTH * s));
377
403
 
378
404
  let newIndex = 0;
379
405
  pending.forEach((message) => {
380
- const slot = this._getImageBatchSlot(messages, message.id, s);
381
- const left = Math.round(slot.x - wScreen / 2);
382
- const top = Math.round(slot.y - hScreen / 2);
383
-
384
406
  const existing = this._pendingOverlays.get(message.id);
385
407
  if (existing) {
386
- const el = existing.el;
387
- el.style.left = `${left}px`;
388
- el.style.top = `${top}px`;
389
- el.style.width = `${wScreen}px`;
390
- el.style.height = `${hScreen}px`;
391
- el.style.setProperty('--moodboard-chat-board-animation-ms', `${BOARD_IMAGE_REARRANGE_MS}ms`);
392
- el.style.setProperty('--moodboard-chat-pending-enter-x', `${enterDistance}px`);
408
+ // Мировые координаты оверлея зафиксированы в момент создания батча.
409
+ // Пересчитываем только экранную позицию из сохранённых world-координат,
410
+ // чтобы пан холста между рендерами не сдвигал worldX/worldY —
411
+ // иначе размещение реального изображения попадёт в неправильную точку.
412
+ const [wr2, hr2] = parseFormatRatio(this._formatId);
413
+ const wScreen2 = Math.round(BOARD_IMAGE_WIDTH * s);
414
+ const hScreen2 = Math.round(wScreen2 / (wr2 / hr2));
415
+ const screenLayout = {
416
+ left: Math.round(existing.worldX * s + (world?.x || 0) - wScreen2 / 2),
417
+ top: Math.round(existing.worldY * s + (world?.y || 0) - hScreen2 / 2),
418
+ width: wScreen2,
419
+ height: hScreen2,
420
+ worldX: existing.worldX,
421
+ worldY: existing.worldY
422
+ };
423
+ this._applyPendingOverlayScreenLayout(existing.el, screenLayout, { animate: true, enterDistance });
393
424
  return;
394
425
  }
395
426
 
427
+ const layout = this._computePendingOverlayScreenLayout(messages, message.id, s);
428
+
396
429
  const overlay = document.createElement('div');
397
430
  overlay.className = 'moodboard-chat__pending-overlay moodboard-chat__pending-overlay--enter';
398
- overlay.style.cssText = `left:${left}px;top:${top}px;width:${wScreen}px;height:${hScreen}px`;
431
+ overlay.style.cssText = `left:${layout.left}px;top:${layout.top}px;width:${layout.width}px;height:${layout.height}px`;
399
432
  overlay.style.setProperty('--moodboard-chat-board-animation-ms', `${BOARD_IMAGE_REARRANGE_MS}ms`);
400
433
  overlay.style.setProperty('--moodboard-chat-pending-enter-x', `${enterDistance}px`);
401
434
 
@@ -404,14 +437,19 @@ export class ChatWindow {
404
437
  label.textContent = 'В процессе...';
405
438
  overlay.appendChild(label);
406
439
 
407
- document.body.appendChild(overlay);
440
+ (this._container ?? document.body).appendChild(overlay);
408
441
 
409
442
  // Принудительный reflow: фиксируем стартовое состояние (translateX справа + opacity 0)
410
443
  // в layout до переключения класса. Без этого браузер может смерджить два состояния
411
444
  // в один кадр и transition не запустится — заглушка появится мгновенно.
412
445
  void overlay.offsetWidth;
413
446
 
414
- this._pendingOverlays.set(message.id, { el: overlay });
447
+ this._pendingOverlays.set(message.id, {
448
+ el: overlay,
449
+ batchId: message.batchId,
450
+ worldX: layout.worldX,
451
+ worldY: layout.worldY
452
+ });
415
453
 
416
454
  const stagger = newIndex * BOARD_IMAGE_PENDING_STAGGER_MS;
417
455
  newIndex += 1;
@@ -442,6 +480,39 @@ export class ChatWindow {
442
480
  }
443
481
  }
444
482
 
483
+ /**
484
+ * Вычисляет расстояние входа заглушки так, чтобы новая заглушка въезжала справа
485
+ * от уже размещённых AI-изображений на доске, а не накрывала их во время анимации
486
+ * сдвига. Без этого при N>1 изображений в батче enter-расстояние фиксированное
487
+ * (512px) не покрывало ширину существующего ряда (940px для трёх изображений),
488
+ * и заглушка пересекалась с ещё не ушедшими влево картинками.
489
+ */
490
+ _computeEnterDistance(messages, pending, scale, wScreen) {
491
+ const baseEnter = Math.round(BOARD_IMAGE_STEP * scale * BOARD_IMAGE_PENDING_ENTER_FACTOR);
492
+
493
+ const aiObjects = this._getBoardAiImageObjects();
494
+ if (aiObjects.length === 0) return baseEnter;
495
+
496
+ const firstNewPending = pending.find((m) => !this._pendingOverlays.has(m.id));
497
+ if (!firstNewPending) return baseEnter;
498
+
499
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
500
+ const worldX = world?.x || 0;
501
+ const existingRight_world = Math.max(...aiObjects.map((obj) => obj.position.x + getBoardObjectWidth(obj)));
502
+ const existingRight_screen = Math.round(existingRight_world * scale + worldX);
503
+
504
+ const step = Math.round(BOARD_IMAGE_STEP * scale);
505
+ const gap = Math.round(BOARD_IMAGE_GAP * scale);
506
+
507
+ const slot = this._getImageBatchSlot(messages, firstNewPending.id, scale);
508
+ const batch = findImageGenerationBatch(messages, firstNewPending.id);
509
+ const leftmostSlotX = Math.round(slot.x - batch.index * step);
510
+ const leftmostFinalLeft = leftmostSlotX - Math.round(wScreen / 2);
511
+
512
+ const neededEnter = existingRight_screen - leftmostFinalLeft + gap;
513
+ return Math.max(baseEnter, Math.ceil(neededEnter));
514
+ }
515
+
445
516
  _clearPendingOverlays() {
446
517
  for (const record of this._pendingOverlays.values()) {
447
518
  record.el.remove();
@@ -451,6 +522,54 @@ export class ChatWindow {
451
522
  clearTimeout(timer);
452
523
  }
453
524
  this._pendingOverlayTimers.clear();
525
+ this._pendingBatchOffsets.clear();
526
+ }
527
+
528
+ _shiftPendingOverlaysForNewBatch(batchId, count, scale) {
529
+ if (!batchId || this._pendingBatchOffsets.has(batchId)) return;
530
+
531
+ const step = Math.round(BOARD_IMAGE_STEP * scale);
532
+ const shiftAmount = count * step;
533
+
534
+ for (const [existingId, offset] of this._pendingBatchOffsets) {
535
+ this._pendingBatchOffsets.set(existingId, offset - shiftAmount);
536
+ }
537
+ this._pendingBatchOffsets.set(batchId, 0);
538
+
539
+ let existingOverlaysShifted = false;
540
+ const messages = this._session?.getState?.()?.messages || [];
541
+ for (const [messageId, record] of this._pendingOverlays) {
542
+ if (record.batchId === batchId) continue;
543
+ const layout = this._computePendingOverlayScreenLayout(messages, messageId, scale);
544
+ record.worldX = layout.worldX;
545
+ record.worldY = layout.worldY;
546
+ this._applyPendingOverlayScreenLayout(record.el, layout, { animate: true });
547
+ existingOverlaysShifted = true;
548
+ }
549
+
550
+ // Когда существующие заглушки сдвигаются влево, уже размещённые AI-изображения
551
+ // на доске должны сдвинуться на то же расстояние — иначе они окажутся
552
+ // правее заглушек и перекроются ими.
553
+ if (!existingOverlaysShifted) return;
554
+
555
+ const worldShift = shiftAmount / scale;
556
+ for (const obj of this._getBoardAiImageObjects()) {
557
+ if (obj?.position) {
558
+ const from = { x: obj.position.x, y: obj.position.y };
559
+ const to = { x: Math.round(obj.position.x - worldShift), y: obj.position.y };
560
+ this._animateBoardImageToPosition(obj.id, from, to);
561
+ }
562
+ }
563
+ }
564
+
565
+ _cleanupPlacedBatchOffsets(messages) {
566
+ if (this._pendingBatchOffsets.size === 0) return;
567
+ for (const batchId of [...this._pendingBatchOffsets.keys()]) {
568
+ const batchMessages = (messages || []).filter((m) => m.batchId === batchId);
569
+ if (batchMessages.length === 0 || batchMessages.every((m) => !m.pending)) {
570
+ this._pendingBatchOffsets.delete(batchId);
571
+ }
572
+ }
454
573
  }
455
574
 
456
575
  _getImageRequestOptions() {
@@ -463,6 +582,107 @@ export class ChatWindow {
463
582
  };
464
583
  }
465
584
 
585
+ _computePendingOverlayScreenLayout(messages, messageId, scale = 1) {
586
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
587
+ const s = scale || world?.scale?.x || 1;
588
+ const [wr, hr] = parseFormatRatio(this._formatId);
589
+ const ratio = wr / hr;
590
+ const wScreen = Math.round(BOARD_IMAGE_WIDTH * s);
591
+ const hScreen = Math.round(wScreen / ratio);
592
+ const slot = this._getImageBatchSlot(messages, messageId, s);
593
+ const worldX = (slot.x - (world?.x || 0)) / s;
594
+ const worldY = (slot.y - (world?.y || 0)) / s;
595
+ const screenX = Math.round(worldX * s + (world?.x || 0));
596
+ const screenY = Math.round(worldY * s + (world?.y || 0));
597
+
598
+ return {
599
+ left: Math.round(screenX - wScreen / 2),
600
+ top: Math.round(screenY - hScreen / 2),
601
+ width: wScreen,
602
+ height: hScreen,
603
+ worldX,
604
+ worldY
605
+ };
606
+ }
607
+
608
+ _applyPendingOverlayScreenLayout(el, layout, { animate = false, enterDistance } = {}) {
609
+ el.style.left = `${layout.left}px`;
610
+ el.style.top = `${layout.top}px`;
611
+ el.style.width = `${layout.width}px`;
612
+ el.style.height = `${layout.height}px`;
613
+ if (!animate) return;
614
+
615
+ el.style.setProperty('--moodboard-chat-board-animation-ms', `${BOARD_IMAGE_REARRANGE_MS}ms`);
616
+ if (enterDistance != null) {
617
+ el.style.setProperty('--moodboard-chat-pending-enter-x', `${enterDistance}px`);
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Пересчитывает screen-позиции заглушек из world-координат — вместе с AI-изображениями на доске при pan/zoom.
623
+ */
624
+ _syncPendingOverlaysToViewport({ disableTransition = false, recomputeWorld = false } = {}) {
625
+ if (this._pendingOverlays.size === 0) return;
626
+
627
+ const messages = this._session?.getState?.()?.messages;
628
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
629
+ const s = world?.scale?.x || 1;
630
+ const [wr, hr] = parseFormatRatio(this._formatId);
631
+ const wScreen = Math.round(BOARD_IMAGE_WIDTH * s);
632
+ const hScreen = Math.round(wScreen / (wr / hr));
633
+
634
+ for (const [messageId, record] of this._pendingOverlays) {
635
+ if (recomputeWorld || typeof record.worldX !== 'number' || typeof record.worldY !== 'number') {
636
+ const layout = this._computePendingOverlayScreenLayout(messages, messageId, s);
637
+ record.worldX = layout.worldX;
638
+ record.worldY = layout.worldY;
639
+ }
640
+
641
+ const screenX = Math.round(record.worldX * s + (world?.x || 0));
642
+ const screenY = Math.round(record.worldY * s + (world?.y || 0));
643
+ const el = record.el;
644
+ if (disableTransition) {
645
+ el.style.transition = 'none';
646
+ }
647
+ el.style.left = `${Math.round(screenX - wScreen / 2)}px`;
648
+ el.style.top = `${Math.round(screenY - hScreen / 2)}px`;
649
+ el.style.width = `${wScreen}px`;
650
+ el.style.height = `${hScreen}px`;
651
+ if (disableTransition) {
652
+ void el.offsetWidth;
653
+ el.style.removeProperty('transition');
654
+ }
655
+ }
656
+ }
657
+
658
+ _attachViewportSync() {
659
+ const eventBus = this._boardCore?.eventBus;
660
+ if (!eventBus || typeof eventBus.on !== 'function' || this._viewportHandlers) return;
661
+
662
+ const onPanUpdate = () => {
663
+ this._syncPendingOverlaysToViewport({ disableTransition: true });
664
+ };
665
+ const onViewportChange = () => {
666
+ this._syncPendingOverlaysToViewport({ disableTransition: true, recomputeWorld: false });
667
+ };
668
+
669
+ this._viewportHandlers = { onPanUpdate, onViewportChange };
670
+ eventBus.on(Events.Tool.PanUpdate, onPanUpdate);
671
+ eventBus.on(Events.UI.ZoomPercent, onViewportChange);
672
+ eventBus.on(Events.Viewport.Changed, onViewportChange);
673
+ }
674
+
675
+ _detachViewportSync() {
676
+ const eventBus = this._boardCore?.eventBus;
677
+ const handlers = this._viewportHandlers;
678
+ if (!eventBus || typeof eventBus.off !== 'function' || !handlers) return;
679
+
680
+ eventBus.off(Events.Tool.PanUpdate, handlers.onPanUpdate);
681
+ eventBus.off(Events.UI.ZoomPercent, handlers.onViewportChange);
682
+ eventBus.off(Events.Viewport.Changed, handlers.onViewportChange);
683
+ this._viewportHandlers = null;
684
+ }
685
+
466
686
  _attachSelectionEvents() {
467
687
  const eventBus = this._boardCore?.eventBus;
468
688
  if (!eventBus || typeof eventBus.on !== 'function' || this._selectionHandlers) return;
@@ -563,7 +783,8 @@ export class ChatWindow {
563
783
  const step = Math.round(BOARD_IMAGE_STEP * scale);
564
784
  const count = Math.max(batch.count, 1);
565
785
  const index = Math.min(Math.max(batch.index, 0), count - 1);
566
- const leftmostCenter = anchor.x - ((count - 1) * step) / 2;
786
+ const batchOffset = batch.batchId ? (this._pendingBatchOffsets.get(batch.batchId) ?? 0) : 0;
787
+ const leftmostCenter = anchor.x - ((count - 1) * step) / 2 + batchOffset;
567
788
 
568
789
  return {
569
790
  x: Math.round(leftmostCenter + index * step),
@@ -762,11 +983,24 @@ export class ChatWindow {
762
983
  const s = world?.scale?.x || 1;
763
984
  const messages = this._session.getState().messages;
764
985
  this._shiftExistingImagesForBatch(messages, msg.id, s);
765
- const slot = this._getImageBatchSlot(messages, msg.id, s);
766
-
986
+
987
+ // Pending-оверлей хранит worldX/worldY, зафиксированные в момент начала генерации.
988
+ // Используем их чтобы разместить изображение в той же мировой точке,
989
+ // независимо от того, сдвинул ли пользователь холст пока шла генерация.
990
+ const pendingRecord = this._pendingOverlays.get(msg.id);
991
+ let x, y;
992
+ if (pendingRecord && typeof pendingRecord.worldX === 'number' && typeof pendingRecord.worldY === 'number') {
993
+ x = Math.round(pendingRecord.worldX * s + (world?.x || 0));
994
+ y = Math.round(pendingRecord.worldY * s + (world?.y || 0));
995
+ } else {
996
+ const slot = this._getImageBatchSlot(messages, msg.id, s);
997
+ x = slot.x;
998
+ y = slot.y;
999
+ }
1000
+
767
1001
  this._boardCore.eventBus.emit(Events.UI.PasteImageAt, {
768
- x: slot.x,
769
- y: slot.y,
1002
+ x,
1003
+ y,
770
1004
  src: dataUrl,
771
1005
  name: 'ai-generated.jpg',
772
1006
  skipUpload: true
@@ -828,13 +1062,25 @@ function findImageGenerationBatch(messages, messageId) {
828
1062
  return { index: 0, count: 1 };
829
1063
  }
830
1064
 
1065
+ const target = list[targetIndex];
1066
+ if (target?.batchId) {
1067
+ const batchMessages = list.filter((m) => m.batchId === target.batchId);
1068
+ const index = batchMessages.findIndex((m) => m.id === messageId);
1069
+ return {
1070
+ index: Math.max(index, 0),
1071
+ count: batchMessages.length,
1072
+ ids: batchMessages.map((m) => m.id),
1073
+ batchId: target.batchId
1074
+ };
1075
+ }
1076
+
831
1077
  let start = targetIndex;
832
- while (start > 0 && isImageGenerationMessage(list[start - 1])) {
1078
+ while (start > 0 && isImageGenerationMessage(list[start - 1]) && !list[start - 1].batchId) {
833
1079
  start--;
834
1080
  }
835
1081
 
836
1082
  let end = targetIndex;
837
- while (end + 1 < list.length && isImageGenerationMessage(list[end + 1])) {
1083
+ while (end + 1 < list.length && isImageGenerationMessage(list[end + 1]) && !list[end + 1].batchId) {
838
1084
  end++;
839
1085
  }
840
1086
 
@@ -34,8 +34,9 @@
34
34
  scroll-behavior: smooth;
35
35
  }
36
36
 
37
+ /* TODO: установить flex, когда будем прикручивать историю */
37
38
  .moodboard-chat__history.is-visible {
38
- display: flex;
39
+ display: none;
39
40
  }
40
41
 
41
42
  .moodboard-chat__msg {
@@ -703,6 +704,7 @@
703
704
  pointer-events: none;
704
705
  z-index: 10;
705
706
  transition:
707
+ left var(--moodboard-chat-board-animation-ms) cubic-bezier(0.22, 1, 0.36, 1),
706
708
  transform var(--moodboard-chat-board-animation-ms) cubic-bezier(0.22, 1, 0.36, 1),
707
709
  opacity var(--moodboard-chat-board-animation-ms) ease;
708
710
  will-change: transform, opacity;