@eshal-bot/chat-widget 0.2.7 → 0.2.9

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.
@@ -1 +1 @@
1
- export{S as default}from"./chat-widget-index-8wTGWK67.esm.js";import"react";import"react-dom/client";import"react/jsx-runtime";import"react-dom";
1
+ export{S as default}from"./chat-widget-index-CUDFhUWj.esm.js";import"react";import"react-dom/client";import"react/jsx-runtime";import"react-dom";
@@ -8372,7 +8372,7 @@
8372
8372
  stale: s.stale
8373
8373
  })).filter(s => s.source_id || s.url || s.source_type === 'database') : [];
8374
8374
  };
8375
- const processStream = async (stream, setMessages) => {
8375
+ const processStream = async (stream, setMessages, onTerminal) => {
8376
8376
  const updateMessage = createMessageUpdater(setMessages);
8377
8377
  const reader = stream.getReader();
8378
8378
  const decoder = new TextDecoder();
@@ -8388,6 +8388,35 @@
8388
8388
  // closes without one, the connection dropped mid-response and we
8389
8389
  // throw IncompleteStreamError so the caller can retry.
8390
8390
  let streamCompleted = false;
8391
+ // The id of THIS turn's assistant bubble, captured when the terminal
8392
+ // event finalizes it. Used to address the trailing chips chunk below:
8393
+ // once the composer unlocks at `complete`, "the last message" may
8394
+ // already belong to a NEWER turn.
8395
+ let turnAssistantId = null;
8396
+
8397
+ // EV-2872 completion: the backend emits `complete` WITHOUT waiting for
8398
+ // chip generation, precisely so the composer can unlock at last-token
8399
+ // time - then keeps the stream open for the trailing
8400
+ // {type:'follow_up_prompts'} chunk and [DONE]. Measured on dev
8401
+ // 2026-09-18, that tail runs 5.9-7.5s (budgeted up to 15s by
8402
+ // RAG_AUTO_PROMPT_TIMEOUT_S), and the widget spent all of it with the
8403
+ // composer disabled because the caller only cleared isLoading when the
8404
+ // HTTP body closed. Firing here hands the caller the real unlock point
8405
+ // while the reader keeps draining the chips in the background. Fires
8406
+ // at most ONCE per stream, from whichever terminal event arrives first.
8407
+ const markTerminal = () => {
8408
+ if (streamCompleted) return;
8409
+ streamCompleted = true;
8410
+ if (typeof onTerminal === 'function') {
8411
+ try {
8412
+ onTerminal();
8413
+ } catch (cbErr) {
8414
+ // A caller-side throw must never turn a delivered answer into a
8415
+ // dropped stream (the catch below would surface it as a retry).
8416
+ console.warn('[chat] onTerminal callback threw:', cbErr);
8417
+ }
8418
+ }
8419
+ };
8391
8420
  try {
8392
8421
  while (true) {
8393
8422
  const {
@@ -8460,7 +8489,7 @@
8460
8489
  // would retry-send the message 3× and render an error bubble). The
8461
8490
  // human agent's reply arrives via the SSE EventSource instead.
8462
8491
  if (parsed.type === 'human_handoff') {
8463
- streamCompleted = true;
8492
+ markTerminal();
8464
8493
  continue;
8465
8494
  }
8466
8495
 
@@ -8474,7 +8503,7 @@
8474
8503
  // just get suppressed again). Mirrors packages/frontend/src/
8475
8504
  // services/ChatService.ts's handling of this event.
8476
8505
  if (parsed.type === 'duplicate_suppressed') {
8477
- streamCompleted = true;
8506
+ markTerminal();
8478
8507
  continue;
8479
8508
  }
8480
8509
 
@@ -8494,14 +8523,25 @@
8494
8523
  setMessages(prev => {
8495
8524
  const updated = [...(prev || [])];
8496
8525
  const lastMsg = updated[updated.length - 1];
8497
- if (lastMsg && lastMsg.role === 'assistant') {
8498
- // Same storage shape updateFinalMessage uses on the
8499
- // complete chunk: message.prompts, normalized.
8500
- updated[updated.length - 1] = {
8501
- ...lastMsg,
8502
- prompts: normalizePrompts(latePrompts)
8503
- };
8526
+ if (!lastMsg || lastMsg.role !== 'assistant') return prev;
8527
+ // The composer now unlocks at `complete`, so by the time this
8528
+ // chunk lands the user may already have asked the NEXT
8529
+ // question - in which case the last message belongs to that
8530
+ // turn, and writing our chips onto it would caption a new
8531
+ // answer with the old one's follow-ups. Worse, sendMessage
8532
+ // deliberately clears prior chips on every send (EV-3080:
8533
+ // stale chips must never look current), so this write would
8534
+ // undo that. Land the chips only while this turn's bubble is
8535
+ // still the one in front of the user; otherwise drop them.
8536
+ if (turnAssistantId != null && lastMsg.id !== turnAssistantId) {
8537
+ return prev;
8504
8538
  }
8539
+ // Same storage shape updateFinalMessage uses on the
8540
+ // complete chunk: message.prompts, normalized.
8541
+ updated[updated.length - 1] = {
8542
+ ...lastMsg,
8543
+ prompts: normalizePrompts(latePrompts)
8544
+ };
8505
8545
  return updated;
8506
8546
  });
8507
8547
  }
@@ -8570,7 +8610,6 @@
8570
8610
 
8571
8611
  // Handle complete events
8572
8612
  if (parsed.type === 'complete') {
8573
- streamCompleted = true;
8574
8613
  // Normalize Sources from stream. citation_indices are
8575
8614
  // preserved so <CitationPill> resolves [N] markers past the
8576
8615
  // inline-cite filter; database "Data used" entries keep their
@@ -8614,6 +8653,13 @@
8614
8653
  setMessages(prev => {
8615
8654
  const updatedMessages = [...(prev || [])];
8616
8655
  const lastMessage = updatedMessages[updatedMessages.length - 1];
8656
+ if (lastMessage?.role === 'assistant') {
8657
+ // Remember which bubble this turn owns, before the composer
8658
+ // unlocks below and a newer turn can append past it. The
8659
+ // assignment is idempotent (same id every call), so a
8660
+ // StrictMode double-invoke is harmless.
8661
+ turnAssistantId = lastMessage.id ?? null;
8662
+ }
8617
8663
  if (lastMessage?.role === 'assistant' && (sources.length > 0 || lastMessage.sourcesPreliminary)) {
8618
8664
  const {
8619
8665
  sourcesPreliminary,
@@ -8629,6 +8675,11 @@
8629
8675
  }
8630
8676
  return updatedMessages;
8631
8677
  });
8678
+
8679
+ // Answer + citations are committed - release the composer now
8680
+ // rather than after the trailing chips chunk and [DONE]. The
8681
+ // reader keeps draining below.
8682
+ markTerminal();
8632
8683
  continue;
8633
8684
  }
8634
8685
 
@@ -8679,11 +8730,19 @@
8679
8730
 
8680
8731
  // Handle finish_reason (stream complete)
8681
8732
  if (parsed.choices?.[0]?.finish_reason && parsed.choices[0].finish_reason !== null) {
8682
- streamCompleted = true;
8683
8733
  const metadata = {
8684
8734
  prompts: parsed.prompts
8685
8735
  };
8686
8736
  updateFinalMessage(setMessages, accumulatedText.current, metadata);
8737
+ // Same bubble capture the `complete` branch does, so this terminal
8738
+ // shape gets the same late-chip targeting. Read-only: returning
8739
+ // `prev` unchanged makes React bail out of the re-render.
8740
+ setMessages(prev => {
8741
+ const last = (prev || [])[(prev || []).length - 1];
8742
+ if (last?.role === 'assistant') turnAssistantId = last.id ?? null;
8743
+ return prev;
8744
+ });
8745
+ markTerminal();
8687
8746
  }
8688
8747
  }
8689
8748
  }
@@ -12041,6 +12100,15 @@
12041
12100
  // call before any state mutation runs.
12042
12101
  const isSendingRef = reactExports.useRef(false);
12043
12102
 
12103
+ // Monotonic id for each sendMessage call. Since the composer now unlocks at
12104
+ // the stream's `complete` event rather than when the HTTP body closes, turn
12105
+ // N's stream is still draining its trailing chips chunk while turn N+1 may
12106
+ // already be in flight - so turn N's `finally` would otherwise clear the
12107
+ // loading state that belongs to turn N+1 and unlock the composer mid-answer.
12108
+ // Every release is stamped with the send it came from and ignored when a
12109
+ // newer send has taken over.
12110
+ const sendSeqRef = reactExports.useRef(0);
12111
+
12044
12112
  // Bidi/Voice state
12045
12113
  const [isConversationLoading, setIsConversationLoading] = reactExports.useState(false);
12046
12114
  const [isVoiceSessionActive, setIsVoiceSessionActive] = reactExports.useState(false);
@@ -12603,6 +12671,13 @@
12603
12671
  };
12604
12672
  }
12605
12673
  isSendingRef.current = true;
12674
+ const mySendSeq = sendSeqRef.current += 1;
12675
+ // Idempotent, and a no-op once a newer send owns the composer.
12676
+ const releaseComposer = () => {
12677
+ if (sendSeqRef.current !== mySendSeq) return;
12678
+ setIsLoading(false);
12679
+ isSendingRef.current = false;
12680
+ };
12606
12681
  if (trimmed === HANDOFF_TRIGGER) {
12607
12682
  const fireWidgetHandoff = async () => {
12608
12683
  if (apiBaseUrl && bidiSessionId) {
@@ -12877,7 +12952,15 @@
12877
12952
  // exactly the attempt that is streaming.
12878
12953
  streamAbortRef.current = new AbortController();
12879
12954
  const stream = await sendStreamingRequest(chatRequest, apiBaseUrl, getAuthToken, streamAbortRef.current.signal);
12880
- await processStream(stream, setMessages);
12955
+ // EV-2872 completion: unlock the composer on the stream's
12956
+ // terminal event (`complete`), not when the body closes. The
12957
+ // backend holds the stream open after `complete` for the
12958
+ // trailing suggestion-chip chunk - measured at 5.9-7.5s on dev
12959
+ // 2026-09-18, budgeted up to 15s - and the customer spent all of
12960
+ // it unable to type their next question. Retries are unaffected:
12961
+ // IncompleteStreamError is only thrown when NO terminal event
12962
+ // arrived, so nothing that reaches this callback is ever retried.
12963
+ await processStream(stream, setMessages, releaseComposer);
12881
12964
  setUserMessageStatus(sentUserMessageId, "sent");
12882
12965
  updateActivity(organizationId);
12883
12966
  return {
@@ -12956,9 +13039,14 @@
12956
13039
  // input until the next regular message ran. setIsLoading(false) is
12957
13040
  // a no-op when it's already false, so widening the condition can't
12958
13041
  // regress the inquiry/complaint/technical path.
12959
- setIsLoading(false);
12960
- // UX-M09: release the concurrency guard so the next send can proceed.
12961
- isSendingRef.current = false;
13042
+ //
13043
+ // Still the ONLY release for every non-streaming path (onboarding,
13044
+ // handoff, the error branches above) and the backstop for a stream
13045
+ // that ends without a terminal event. Streaming turns have usually
13046
+ // released already, at `complete`; this call is then a no-op. It is
13047
+ // sequence-guarded so a turn whose chips are still draining cannot
13048
+ // unlock the composer out from under the turn that followed it.
13049
+ releaseComposer();
12962
13050
  }
12963
13051
  }, [apiBaseUrl, apiKey, getNextMessageId, getAuthToken, messages, bidiSessionId, routingChoice, widgetFilter, provideCitations]);
12964
13052
 
@@ -20893,7 +20981,7 @@
20893
20981
  * value by its own test (that constant sat at 0.1.113 while this package was
20894
20982
  * at 0.2.2 precisely because nothing enforced it - EV-2431).
20895
20983
  */
20896
- const WIDGET_VERSION = '0.2.7';
20984
+ const WIDGET_VERSION = '0.2.9';
20897
20985
 
20898
20986
  /**
20899
20987
  * API utility functions