@djangocfg/ui-tools 2.1.444 → 2.1.446

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.444",
3
+ "version": "2.1.446",
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.444",
338
- "@djangocfg/ui-core": "^2.1.444",
337
+ "@djangocfg/i18n": "^2.1.446",
338
+ "@djangocfg/ui-core": "^2.1.446",
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.444",
422
- "@djangocfg/typescript-config": "^2.1.444",
423
- "@djangocfg/ui-core": "^2.1.444",
421
+ "@djangocfg/i18n": "^2.1.446",
422
+ "@djangocfg/typescript-config": "^2.1.446",
423
+ "@djangocfg/ui-core": "^2.1.446",
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,
@@ -272,6 +272,16 @@ export function useChat(config: UseChatConfig): UseChatReturn {
272
272
  let serverMessageId: string | null = null;
273
273
  let chunkCount = 0;
274
274
  let charsReceived = 0;
275
+ // Tracks whether the stream already delivered a TERMINAL event
276
+ // (`message_end` or `error`) via handleEvent. Guards the manual-finalize
277
+ // below. Without it, the finalize reads `stateRef.current.isStreaming` —
278
+ // a snapshot NOT updated synchronously after a `dispatch` — so right
279
+ // after a STREAM_ERROR it still sees `isStreaming: true` and fires a
280
+ // spurious STREAM_DONE, whose reducer clears `state.error`. Net effect:
281
+ // the error is set then wiped in the same tick and the ErrorBanner never
282
+ // paints. `sawTerminal` is a closure flag, so it reflects the truth the
283
+ // ref can't. See @dev/web2/chat/reports/COMPARE-new-vs-old-chat-error-handling.md (layer 3).
284
+ let sawTerminal = false;
275
285
  const t0 = performance.now();
276
286
 
277
287
  try {
@@ -311,8 +321,12 @@ export function useChat(config: UseChatConfig): UseChatReturn {
311
321
  }
312
322
  tokenBuffer.flush();
313
323
 
314
- // If transport never emitted message_end, finalize manually.
315
- if (stateRef.current.isStreaming) {
324
+ // If transport never emitted a terminal event (message_end / error),
325
+ // finalize manually. Gated on `sawTerminal` — NOT on
326
+ // `stateRef.current.isStreaming`, which is a stale snapshot right after
327
+ // a STREAM_ERROR dispatch and would otherwise fire a STREAM_DONE that
328
+ // wipes `state.error`.
329
+ if (!sawTerminal && stateRef.current.isStreaming) {
316
330
  dispatch({ type: 'STREAM_DONE', id: targetId });
317
331
  }
318
332
 
@@ -397,6 +411,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
397
411
  log.tools.info('call_end', { toolId: ev.toolId, status: ev.status });
398
412
  return;
399
413
  case 'message_end':
414
+ sawTerminal = true;
400
415
  tokenBuffer.flush();
401
416
  dispatch({
402
417
  type: 'STREAM_DONE',
@@ -440,6 +455,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
440
455
  });
441
456
  return;
442
457
  case 'error':
458
+ sawTerminal = true;
443
459
  tokenBuffer.flush();
444
460
  dispatch({
445
461
  type: 'STREAM_ERROR',
@@ -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) => {