@eshal-bot/chat-widget 0.1.60 → 0.1.61

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.
@@ -7986,6 +7986,22 @@
7986
7986
  * @typedef {function(string): void} MessageUpdater
7987
7987
  */
7988
7988
 
7989
+ /**
7990
+ * Thrown when the SSE stream's HTTP body closes without ever emitting a
7991
+ * terminal event (`complete` / `finish_reason`). The chat widget used to
7992
+ * silently return in this case, leaving the assistant bubble stuck in
7993
+ * isProcessing with whatever partial text had arrived. The caller catches
7994
+ * this specifically so it can either retry the request or surface a clear
7995
+ * "connection dropped" message to the user (UX-M08).
7996
+ */
7997
+ class IncompleteStreamError extends Error {
7998
+ constructor() {
7999
+ let message = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'SSE stream ended before completion';
8000
+ super(message);
8001
+ this.name = 'IncompleteStreamError';
8002
+ }
8003
+ }
8004
+
7989
8005
  // ============================================================================
7990
8006
  // NETWORK
7991
8007
  // ============================================================================
@@ -8273,6 +8289,11 @@
8273
8289
  const hasReceivedFirstChunk = {
8274
8290
  current: false
8275
8291
  };
8292
+ // UX-M08: tracks whether the stream emitted any terminal event
8293
+ // (`complete` event or a finish_reason chunk). If the HTTP body
8294
+ // closes without one, the connection dropped mid-response and we
8295
+ // throw IncompleteStreamError so the caller can retry.
8296
+ let streamCompleted = false;
8276
8297
  try {
8277
8298
  while (true) {
8278
8299
  const {
@@ -8345,6 +8366,7 @@
8345
8366
  // Handle complete events
8346
8367
  if (parsed.type === 'complete') {
8347
8368
  var _ref2, _parsed$Sources, _parsed$choices2;
8369
+ streamCompleted = true;
8348
8370
  // Normalize Sources from stream (API may send "Sources" or "sources")
8349
8371
  const rawSources = (_ref2 = (_parsed$Sources = parsed.Sources) !== null && _parsed$Sources !== void 0 ? _parsed$Sources : parsed.sources) !== null && _ref2 !== void 0 ? _ref2 : [];
8350
8372
  const sources = Array.isArray(rawSources) ? rawSources.map(s => {
@@ -8442,6 +8464,7 @@
8442
8464
 
8443
8465
  // Handle finish_reason (stream complete)
8444
8466
  if ((_parsed$choices4 = parsed.choices) !== null && _parsed$choices4 !== void 0 && (_parsed$choices4 = _parsed$choices4[0]) !== null && _parsed$choices4 !== void 0 && _parsed$choices4.finish_reason && parsed.choices[0].finish_reason !== null) {
8467
+ streamCompleted = true;
8445
8468
  const metadata = {
8446
8469
  prompts: parsed.prompts
8447
8470
  };
@@ -8452,6 +8475,15 @@
8452
8475
  } finally {
8453
8476
  reader.releaseLock();
8454
8477
  }
8478
+
8479
+ // UX-M08: HTTP body closed cleanly but no terminal event arrived —
8480
+ // treat as a dropped stream so the caller can retry. The `complete`
8481
+ // and finish_reason branches above are the only successful exits;
8482
+ // anything else means the network dropped or the server hung up
8483
+ // before sending the terminal event.
8484
+ if (!streamCompleted) {
8485
+ throw new IncompleteStreamError(accumulatedText.current ? 'Stream ended mid-response — partial content received' : 'Stream ended before any content was received');
8486
+ }
8455
8487
  };
8456
8488
 
8457
8489
  /**
@@ -9199,6 +9231,15 @@
9199
9231
  const [onboardingAnswers, setOnboardingAnswers] = reactExports.useState({});
9200
9232
  const pendingIntentProcessedRef = reactExports.useRef(false);
9201
9233
 
9234
+ // UX-M09: ref-based concurrency guard for sendMessage. The isLoading
9235
+ // state alone is not sufficient — setState is batched async, so two
9236
+ // rapid calls (e.g. the send button + an `onDirectSend` chip-click in
9237
+ // the same microtask) can both read isLoading=false and start two
9238
+ // overlapping SSE streams that interleave content into the same
9239
+ // assistant bubble. A ref flips synchronously and blocks the second
9240
+ // call before any state mutation runs.
9241
+ const isSendingRef = reactExports.useRef(false);
9242
+
9202
9243
  // Bidi/Voice state
9203
9244
  const [isConversationLoading, setIsConversationLoading] = reactExports.useState(false);
9204
9245
  const [isVoiceSessionActive, setIsVoiceSessionActive] = reactExports.useState(false);
@@ -9572,6 +9613,17 @@
9572
9613
  };
9573
9614
  }
9574
9615
 
9616
+ // UX-M09: synchronous concurrency guard. See isSendingRef declaration
9617
+ // above for the full rationale — covers send-button, onDirectSend
9618
+ // chip clicks, and any other call site routed through sendMessage.
9619
+ if (isSendingRef.current) {
9620
+ return {
9621
+ success: false,
9622
+ error: 'A message is already being sent'
9623
+ };
9624
+ }
9625
+ isSendingRef.current = true;
9626
+
9575
9627
  // PR-I + PR-P: cache the user's question so handleDecision can
9576
9628
  // re-send it after a routing-clarification chip pick. Update
9577
9629
  // BOTH the ref (synchronously, for the setTimeout closure) and
@@ -9581,6 +9633,7 @@
9581
9633
  setPendingRoutingQuestion(trimmed);
9582
9634
  }
9583
9635
  if (!apiBaseUrl) {
9636
+ isSendingRef.current = false;
9584
9637
  setMessages(prev => {
9585
9638
  const errorMessage = createMessage({
9586
9639
  id: getNextMessageId(),
@@ -9699,11 +9752,20 @@
9699
9752
  })
9700
9753
  });
9701
9754
 
9702
- // Send streaming request
9703
- const stream = await sendStreamingRequest(chatRequest, apiBaseUrl, organizationId);
9755
+ // UX-M08: retry the stream up to MAX_STREAM_RETRIES times if the
9756
+ // server hangs up mid-response (IncompleteStreamError). Each retry
9757
+ // re-issues the POST and re-runs processStream. The skeleton
9758
+ // bubble is cleared between attempts so a partial assistant
9759
+ // message from the dropped attempt doesn't accumulate alongside
9760
+ // the retried one. Retry only applies to chat-message types —
9761
+ // onboarding-field sends don't need it (they consume the body
9762
+ // raw and don't surface partial state to the user).
9763
+ const STREAM_RETRY_DELAYS_MS = [500, 1500]; // → 2 retries total
9704
9764
 
9705
- // For onboarding fields, just consume the stream without processing
9706
9765
  if (apiType === 'userName' || apiType === 'email' || apiType === 'phone') {
9766
+ // For onboarding fields, just consume the stream without processing.
9767
+ // Single attempt — no retry, no user-visible partial state to clean up.
9768
+ const stream = await sendStreamingRequest(chatRequest, apiBaseUrl, organizationId);
9707
9769
  const reader = stream.getReader();
9708
9770
  const decoder = new TextDecoder();
9709
9771
  while (true) {
@@ -9718,15 +9780,39 @@
9718
9780
  success: true
9719
9781
  };
9720
9782
  }
9721
-
9722
- // For regular messages, process the stream
9723
9783
  if (apiType === 'inquiry' || apiType === 'complaint' || apiType === 'technical') {
9724
- await processStream(stream, setMessages);
9725
- updateActivity(organizationId);
9726
- return {
9727
- success: true
9728
- };
9784
+ let lastErr;
9785
+ for (let attempt = 0; attempt <= STREAM_RETRY_DELAYS_MS.length; attempt += 1) {
9786
+ try {
9787
+ const stream = await sendStreamingRequest(chatRequest, apiBaseUrl, organizationId);
9788
+ await processStream(stream, setMessages);
9789
+ updateActivity(organizationId);
9790
+ return {
9791
+ success: true
9792
+ };
9793
+ } catch (err) {
9794
+ lastErr = err;
9795
+ if (err instanceof IncompleteStreamError && attempt < STREAM_RETRY_DELAYS_MS.length) {
9796
+ // Drop the half-rendered assistant bubble before retrying so
9797
+ // the next attempt doesn't paint into a stale skeleton.
9798
+ setMessages(prev => (prev || []).filter(msg => !msg.isProcessing));
9799
+ const delay = STREAM_RETRY_DELAYS_MS[attempt];
9800
+ console.warn("[chat] stream dropped (attempt ".concat(attempt + 1, "); retrying in ").concat(delay, "ms \u2014 ").concat(err.message));
9801
+ await new Promise(r => setTimeout(r, delay));
9802
+ continue;
9803
+ }
9804
+ throw err;
9805
+ }
9806
+ }
9807
+ // Unreachable under normal flow — the loop either returns
9808
+ // success, throws, or continues. Throw the last seen error
9809
+ // defensively so we never fall through silently.
9810
+ throw lastErr;
9729
9811
  }
9812
+
9813
+ // Non-chat, non-onboarding apiType (shouldn't currently happen):
9814
+ // fire and forget, single attempt.
9815
+ await sendStreamingRequest(chatRequest, apiBaseUrl, organizationId);
9730
9816
  updateActivity(organizationId);
9731
9817
  return {
9732
9818
  success: true
@@ -9751,9 +9837,16 @@
9751
9837
  error: error instanceof Error ? error.message : 'Failed to send message'
9752
9838
  };
9753
9839
  } finally {
9754
- if (apiType === 'inquiry' || apiType === 'complaint' || apiType === 'technical') {
9755
- setIsLoading(false);
9756
- }
9840
+ // Reset isLoading unconditionally — onboarding field sends
9841
+ // (userName/email/phone) used to skip this branch, so any ambient
9842
+ // loading state set elsewhere (voice flow, parent loop) was left
9843
+ // stuck true after the onboarding step resolved and blocked further
9844
+ // input until the next regular message ran. setIsLoading(false) is
9845
+ // a no-op when it's already false, so widening the condition can't
9846
+ // regress the inquiry/complaint/technical path.
9847
+ setIsLoading(false);
9848
+ // UX-M09: release the concurrency guard so the next send can proceed.
9849
+ isSendingRef.current = false;
9757
9850
  }
9758
9851
  }, [apiBaseUrl, apiKey, getNextMessageId, organizationId, messages, bidiSessionId, routingChoice, widgetFilter, provideCitations]);
9759
9852
  const handleOnboardingFormSubmit = reactExports.useCallback(async answers => {
@@ -91638,16 +91731,16 @@
91638
91731
  formatAxisMap: formatAxisMap
91639
91732
  });
91640
91733
 
91641
- /**
91642
- * Widget A2UI catalog — Recharts-based BarChart and KPICard renderers.
91643
- *
91644
- * Mirrors the agent-sdk's catalog.tsx renderers (ported to plain JS +
91645
- * dark-mode aware). These are called directly from A2uiBlock's dispatch
91646
- * for bar/kpi types, replacing the hand-rolled SVG implementations.
91647
- *
91648
- * The renderers accept the same `{ props }` interface as CopilotKit's
91649
- * catalog convention so they remain drop-in compatible if we ever wire up
91650
- * the full A2UIProvider pipeline here.
91734
+ /**
91735
+ * Widget A2UI catalog — Recharts-based BarChart and KPICard renderers.
91736
+ *
91737
+ * Mirrors the agent-sdk's catalog.tsx renderers (ported to plain JS +
91738
+ * dark-mode aware). These are called directly from A2uiBlock's dispatch
91739
+ * for bar/kpi types, replacing the hand-rolled SVG implementations.
91740
+ *
91741
+ * The renderers accept the same `{ props }` interface as CopilotKit's
91742
+ * catalog convention so they remain drop-in compatible if we ever wire up
91743
+ * the full A2UIProvider pipeline here.
91651
91744
  */
91652
91745
 
91653
91746
  function formatCompactNumber(n) {
@@ -91656,11 +91749,11 @@
91656
91749
  return String(n);
91657
91750
  }
91658
91751
 
91659
- /**
91660
- * Recharts bar chart — mirrors agent-sdk's eshalRenderers.BarChart.
91661
- *
91662
- * Expects `props.data` to be an array of `{ label, value }` objects
91663
- * (already normalized by A2uiBlock's numericData helper).
91752
+ /**
91753
+ * Recharts bar chart — mirrors agent-sdk's eshalRenderers.BarChart.
91754
+ *
91755
+ * Expects `props.data` to be an array of `{ label, value }` objects
91756
+ * (already normalized by A2uiBlock's numericData helper).
91664
91757
  */
91665
91758
  function BarChartRenderer(_ref) {
91666
91759
  let {
@@ -91730,7 +91823,7 @@
91730
91823
  margin: {
91731
91824
  top: 8,
91732
91825
  right: 12,
91733
- left: 4,
91826
+ left: 12,
91734
91827
  bottom: 20
91735
91828
  },
91736
91829
  children: [/*#__PURE__*/jsxRuntimeExports.jsx(CartesianGrid, {
@@ -91750,6 +91843,7 @@
91750
91843
  fill: labelColor
91751
91844
  } : undefined
91752
91845
  }), /*#__PURE__*/jsxRuntimeExports.jsx(YAxis, {
91846
+ width: 72,
91753
91847
  tick: {
91754
91848
  fontSize: 11,
91755
91849
  fill: labelColor
@@ -91782,9 +91876,9 @@
91782
91876
  });
91783
91877
  }
91784
91878
 
91785
- /**
91786
- * KPI card — mirrors agent-sdk's eshalRenderers.KPICard.
91787
- * Dark-mode aware.
91879
+ /**
91880
+ * KPI card — mirrors agent-sdk's eshalRenderers.KPICard.
91881
+ * Dark-mode aware.
91788
91882
  */
91789
91883
  function KPICardRenderer(_ref2) {
91790
91884
  let {
@@ -91844,10 +91938,10 @@
91844
91938
  }
91845
91939
  const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16', '#06b6d4', '#a855f7'];
91846
91940
 
91847
- /**
91848
- * Recharts pie / donut chart — replaces hand-rolled PieSvg.
91849
- * `props.data` must be an array of `{ label, value }` objects.
91850
- * Set `donut={true}` for a donut chart (innerRadius > 0).
91941
+ /**
91942
+ * Recharts pie / donut chart — replaces hand-rolled PieSvg.
91943
+ * `props.data` must be an array of `{ label, value }` objects.
91944
+ * Set `donut={true}` for a donut chart (innerRadius > 0).
91851
91945
  */
91852
91946
  function PieChartRenderer(_ref3) {
91853
91947
  let {
@@ -91928,10 +92022,10 @@
91928
92022
  });
91929
92023
  }
91930
92024
 
91931
- /**
91932
- * Recharts line / area chart — replaces hand-rolled LineSvg.
91933
- * `props.data` must be an array of `{ label, value }` objects.
91934
- * Set `area={true}` to fill under the line.
92025
+ /**
92026
+ * Recharts line / area chart — replaces hand-rolled LineSvg.
92027
+ * `props.data` must be an array of `{ label, value }` objects.
92028
+ * Set `area={true}` to fill under the line.
91935
92029
  */
91936
92030
  function LineChartRenderer(_ref4) {
91937
92031
  let {
@@ -92003,7 +92097,7 @@
92003
92097
  margin: {
92004
92098
  top: 8,
92005
92099
  right: 12,
92006
- left: 4,
92100
+ left: 12,
92007
92101
  bottom: 20
92008
92102
  },
92009
92103
  children: [/*#__PURE__*/jsxRuntimeExports.jsx(CartesianGrid, {
@@ -92023,6 +92117,7 @@
92023
92117
  fill: labelColor
92024
92118
  } : undefined
92025
92119
  }), /*#__PURE__*/jsxRuntimeExports.jsx(YAxis, {
92120
+ width: 72,
92026
92121
  tick: {
92027
92122
  fontSize: 11,
92028
92123
  fill: labelColor
@@ -92573,14 +92668,52 @@
92573
92668
  return typeof key === 'string' && CURRENCY_KEY_RE.test(key);
92574
92669
  }
92575
92670
 
92671
+ // ISO date / datetime prefix — matches the typical SQL output the
92672
+ // agent's tools produce when GROUP BY-ing by month
92673
+ // (`DATE_TRUNC('month', col)` → `YYYY-MM-DD`). Used by formatAxisTick
92674
+ // below so a chart of monthly expenses shows "Jan '26" / "Feb '26"
92675
+ // horizontally instead of "2026 01 01" slanted (EV-1187 follow-up).
92676
+ const ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})(?:T|$)/;
92677
+ function formatIsoDateAsMonthTick(value) {
92678
+ if (typeof value !== 'string') return null;
92679
+ const m = value.match(ISO_DATE_RE);
92680
+ if (!m) return null;
92681
+ const year = parseInt(m[1], 10);
92682
+ const month = parseInt(m[2], 10);
92683
+ if (!Number.isInteger(month) || month < 1 || month > 12) return null;
92684
+ return "".concat(MONTH_NAMES[month - 1], " '").concat(String(year).slice(-2));
92685
+ }
92686
+
92576
92687
  // Axis-tick formatter: month-number columns (column matches /month/i,
92577
- // value 1..12) render as 3-letter month names; everything else gets
92688
+ // value 1..12) render as 3-letter month names; ISO date strings
92689
+ // (`2026-01-01`) render as "Jan '26"; everything else gets
92578
92690
  // prettifyLabel applied so "office_supplies" reads "Office Supplies".
92579
92691
  function formatAxisTick(value, dataKey) {
92580
92692
  if (typeof dataKey === 'string' && MONTH_KEY_RE.test(dataKey)) {
92581
92693
  const n = typeof value === 'number' ? value : Number(value);
92582
92694
  if (Number.isInteger(n) && n >= 1 && n <= 12) return MONTH_NAMES[n - 1];
92583
92695
  }
92696
+ const dateMonth = formatIsoDateAsMonthTick(value);
92697
+ if (dateMonth) return dateMonth;
92698
+ return prettifyLabel(value);
92699
+ }
92700
+
92701
+ // Conservative snake_case detector for table cell category labels
92702
+ // (2026-06-19, EV-1187 follow-up). Mirrors the admin UI's
92703
+ // `prettifyCategoryCell` in `packages/frontend/.../chartShared.tsx` and
92704
+ // the SDK's copy. Self-contained because the widget can't reach across
92705
+ // package boundaries.
92706
+ //
92707
+ // Goal: when a category column shows `office_supplies` next to a chart
92708
+ // whose X-axis (via prettifyLabel) already renders "Office Supplies",
92709
+ // the table should agree. Conservative — only lowercase ASCII tokens
92710
+ // joined by underscores (≤ 40 chars) qualify, so IDs/paths/emails/
92711
+ // slugs/camelCase pass through verbatim.
92712
+ const SNAKE_CASE_CATEGORY_RE = /^[a-z]+(_[a-z]+)+$/;
92713
+ function prettifyCategoryCell(value) {
92714
+ if (typeof value !== 'string') return String(value !== null && value !== void 0 ? value : '');
92715
+ if (value.length > 40) return value;
92716
+ if (!SNAKE_CASE_CATEGORY_RE.test(value)) return value;
92584
92717
  return prettifyLabel(value);
92585
92718
  }
92586
92719
  function formatCell(v, columnKey) {
@@ -92592,7 +92725,12 @@
92592
92725
  return Number.isInteger(v) ? String(v) : v.toFixed(2);
92593
92726
  }
92594
92727
  if (typeof v === 'object') return JSON.stringify(v);
92595
- return String(v);
92728
+ // String cells: prettify snake_case category labels (e.g.
92729
+ // `office_supplies` → "Office Supplies") so the table reads
92730
+ // consistently with the chart's X-axis ticks on the same data.
92731
+ // Conservative: anything not a clean lowercase_underscore token is
92732
+ // returned verbatim, so paths/IDs/emails/slugs are preserved.
92733
+ return prettifyCategoryCell(v);
92596
92734
  }
92597
92735
 
92598
92736
  // ─────────────────────────────────────────────────────────────────────────
@@ -93626,8 +93764,22 @@
93626
93764
  // When streaming, hide trailing table lines that haven't formed a complete
93627
93765
  // table yet (no separator row). Without this, partial `| col |` lines flash
93628
93766
  // as raw pipe text before ReactMarkdown can recognise the block as a table.
93767
+ // Also hides incomplete A2UI fences (```eshal-ui / ```eshal-chart / ```chart)
93768
+ // that haven't received their closing ``` yet — prevents raw JSON flash.
93629
93769
  function sanitizeStreamingContent(text, isStreaming) {
93630
93770
  if (!isStreaming) return text;
93771
+
93772
+ // A2UI fence guard: if an opening fence exists without a matching closing
93773
+ // ```, suppress everything from that opening onwards so the partial JSON/
93774
+ // code never flashes as raw text in the bubble.
93775
+ const openMatch = /^```(?:eshal-ui|eshal-chart|chart)\b/m.exec(text);
93776
+ if (openMatch) {
93777
+ const rest = text.slice(openMatch.index + openMatch[0].length);
93778
+ const hasClose = rest.split('\n').some(l => l.trim() === '```');
93779
+ if (!hasClose) {
93780
+ return text.slice(0, openMatch.index).trimEnd();
93781
+ }
93782
+ }
93631
93783
  const lines = text.split('\n');
93632
93784
  // Find last non-empty line index
93633
93785
  let last = lines.length - 1;