@djangocfg/ui-tools 2.1.445 → 2.1.447

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": "@djangocfg/ui-tools",
3
- "version": "2.1.445",
3
+ "version": "2.1.447",
4
4
  "description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
5
5
  "keywords": [
6
6
  "ui-tools",
@@ -334,8 +334,8 @@
334
334
  "test:watch": "vitest"
335
335
  },
336
336
  "peerDependencies": {
337
- "@djangocfg/i18n": "^2.1.445",
338
- "@djangocfg/ui-core": "^2.1.445",
337
+ "@djangocfg/i18n": "^2.1.447",
338
+ "@djangocfg/ui-core": "^2.1.447",
339
339
  "consola": "^3.4.2",
340
340
  "lodash-es": "^4.18.1",
341
341
  "lucide-react": "^0.545.0",
@@ -418,9 +418,9 @@
418
418
  "@maplibre/maplibre-gl-geocoder": "^1.7.0"
419
419
  },
420
420
  "devDependencies": {
421
- "@djangocfg/i18n": "^2.1.445",
422
- "@djangocfg/typescript-config": "^2.1.445",
423
- "@djangocfg/ui-core": "^2.1.445",
421
+ "@djangocfg/i18n": "^2.1.447",
422
+ "@djangocfg/typescript-config": "^2.1.447",
423
+ "@djangocfg/ui-core": "^2.1.447",
424
424
  "@types/lodash-es": "^4.17.12",
425
425
  "@types/mapbox__mapbox-gl-draw": "^1.4.8",
426
426
  "@types/node": "^25.2.3",
@@ -622,7 +622,10 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
622
622
  {...dragProps}
623
623
  onKeyDownCapture={handleSlashKeyDownCapture}
624
624
  className={cn(
625
- 'relative border-t border-border bg-background/95',
625
+ // No border-t: the composer's own rounded field separates it from
626
+ // the transcript — a hard rule above the input reads as chrome
627
+ // (ChatGPT/Claude float the composer the same way).
628
+ 'relative bg-background/95',
626
629
  sz.containerPadding,
627
630
  ap.containerPadding,
628
631
  className,
@@ -744,7 +747,9 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
744
747
  {...dragProps}
745
748
  onKeyDownCapture={handleSlashKeyDownCapture}
746
749
  className={cn(
747
- 'relative border-t border-border bg-background/95',
750
+ // No border-t: the composer's own rounded field separates it from
751
+ // the transcript (same rationale as the inline layout above).
752
+ 'relative bg-background/95',
748
753
  sz.containerPadding,
749
754
  ap.containerPadding,
750
755
  className,
@@ -191,23 +191,84 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
191
191
  );
192
192
 
193
193
  const itemRenderer = renderItem ?? defaultRenderItem;
194
- // Jump to bottom on the first non-empty messages batch. Wrapped in
195
- // rAF so virtuoso has measured the new items before we ask it to
196
- // scroll otherwise the call happens while the list is still
197
- // mid-layout and lands on the wrong offset.
194
+ // Jump to bottom on the first non-empty messages batch. A SINGLE rAF +
195
+ // scrollToIndex is not enough when the list mounts with a long transcript
196
+ // (opening / switching a contact): virtuoso measures the dynamic bubble
197
+ // heights ASYNCHRONOUSLY, seconds after the data lands — until then the
198
+ // scroller reports scrollHeight === clientHeight (gap 0), so any "stop when
199
+ // the bottom sticks" exit fires on that transient zero and the late height
200
+ // growth is never corrected (live incident 2026-07-03: contact switch left
201
+ // a 112-message room at scrollTop 0 / blank until measured). The landing is
202
+ // therefore a PHASE, not a one-shot: ONE scrollToIndex seeds the tail
203
+ // render, then per-frame DIRECT scrollTop writes chase every height change
204
+ // for the whole landing window (~2.5s) — ending early ONLY on real user
205
+ // scroll intent, never on a momentarily-zero gap. Direct writes are
206
+ // ordinary scrolls to virtuoso — repeating scrollToIndex instead re-enters
207
+ // its initial-location seek and blanks the list (zero rows,
208
+ // visibility:hidden).
198
209
  useEffect(() => {
199
210
  if (didInitialScrollRef.current) return;
200
211
  if (messages.length === 0) return;
201
212
  didInitialScrollRef.current = true;
202
- const id = requestAnimationFrame(() => {
203
- virtuosoRef.current?.scrollToIndex({
204
- index: 'LAST',
205
- align: 'end',
206
- offset: BOTTOM_GAP_PX,
207
- behavior: 'auto',
208
- });
209
- });
210
- return () => cancelAnimationFrame(id);
213
+ let raf = 0;
214
+ let framesLeft = 150;
215
+ let seeded = false;
216
+ let userTookOver = false;
217
+ const cancelOnUserScroll = () => {
218
+ userTookOver = true;
219
+ };
220
+ // Real scroll intent (wheel / touch / keys) ends the landing phase — the
221
+ // user reading history must never be yanked back down. Programmatic
222
+ // scrollTop writes fire none of these, so our own corrections don't
223
+ // self-cancel.
224
+ const scroller = scrollerRef.current;
225
+ const scrollerEl =
226
+ scroller != null && scroller !== window && scroller instanceof HTMLElement
227
+ ? scroller
228
+ : null;
229
+ scrollerEl?.addEventListener('wheel', cancelOnUserScroll, { passive: true });
230
+ scrollerEl?.addEventListener('touchstart', cancelOnUserScroll, { passive: true });
231
+ scrollerEl?.addEventListener('keydown', cancelOnUserScroll);
232
+ const land = () => {
233
+ framesLeft -= 1;
234
+ if (userTookOver || framesLeft <= 0) return;
235
+ if (!seeded) {
236
+ // ONE virtuoso-level jump renders the tail items into the window.
237
+ // Never re-issue it per frame: repeated scrollToIndex on a list whose
238
+ // bubble heights are still being measured keeps virtuoso in its
239
+ // initial-location seek (zero rows, visibility:hidden) — the exact
240
+ // blank-transcript deadlock initialTopMostItemIndex causes.
241
+ seeded = true;
242
+ virtuosoRef.current?.scrollToIndex({
243
+ index: 'LAST',
244
+ align: 'end',
245
+ offset: BOTTOM_GAP_PX,
246
+ behavior: 'auto',
247
+ });
248
+ raf = requestAnimationFrame(land);
249
+ return;
250
+ }
251
+ // Correction frames: as measurements land and the total height grows,
252
+ // keep pinning the bottom with DIRECT scrollTop writes (an ordinary
253
+ // scroll to virtuoso, no seek mode). A zero gap does NOT end the phase —
254
+ // it is usually the not-yet-measured state, and the growth we exist to
255
+ // chase arrives after it.
256
+ const el = scrollerRef.current;
257
+ if (el != null && el !== window && el instanceof HTMLElement) {
258
+ const gap = el.scrollHeight - el.clientHeight - el.scrollTop;
259
+ if (gap > 4) {
260
+ el.scrollTop = el.scrollHeight - el.clientHeight;
261
+ }
262
+ }
263
+ raf = requestAnimationFrame(land);
264
+ };
265
+ raf = requestAnimationFrame(land);
266
+ return () => {
267
+ cancelAnimationFrame(raf);
268
+ scrollerEl?.removeEventListener('wheel', cancelOnUserScroll);
269
+ scrollerEl?.removeEventListener('touchstart', cancelOnUserScroll);
270
+ scrollerEl?.removeEventListener('keydown', cancelOnUserScroll);
271
+ };
211
272
  }, [messages.length]);
212
273
 
213
274
  // Force-scroll to bottom whenever the consumer bumps `scrollAnchorId`
@@ -400,16 +461,26 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
400
461
  // than the viewport (centers items near the bottom). Combined
401
462
  // with dynamic bubble heights it triggered the same measure loop:
402
463
  // virtuoso recomputes top padding on every size change to keep
403
- // the cluster bottom-aligned. `initialTopMostItemIndex` + the
404
- // mount-time imperative scroll already land us at the bottom on
405
- // open, which is what users actually want.
464
+ // the cluster bottom-aligned. The mount-time imperative scroll
465
+ // already lands us at the bottom on open, which is what users
466
+ // actually want.
467
+ //
468
+ // No `initialTopMostItemIndex` — when the list mounts WITH
469
+ // history already present (a resumed engine / remounted chat),
470
+ // virtuoso enters its initial-location seek: it renders ZERO rows
471
+ // and keeps the item list `visibility: hidden` until the seek
472
+ // settles — and with dynamic unmeasured bubble heights that seek
473
+ // can never settle, leaving the whole transcript permanently
474
+ // blank (0 items, no error). The mount-time imperative
475
+ // `scrollToIndex('LAST')` effect below covers the land-at-bottom
476
+ // job for both mount paths (empty-then-fill AND mount-with-data),
477
+ // at worst costing one frame of top-anchored paint.
406
478
  //
407
479
  // No `increaseViewportBy` overscan — virtuoso's default (~0px)
408
480
  // is the right call for chat: every overscanned bubble re-renders
409
481
  // on every streaming token delta, so a 400px buffer means 3–4
410
482
  // extra bubbles re-rendering at 60Hz during a stream. Default
411
483
  // keeps the working set tight.
412
- initialTopMostItemIndex={messages.length > 0 ? messages.length - 1 : 0}
413
484
  atBottomThreshold={atBottomThreshold}
414
485
  followOutput={(isAtBottom) => (isAtBottom ? 'auto' : false)}
415
486
  scrollerRef={(el) => {