@eshal-bot/chat-widget 0.1.60 → 0.1.62

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,36 @@
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
+ // ── Currency-aware value formatting ──────────────────────────────────────
91753
+ // A2UI chart data is normalized to `{ label, value }` by A2uiBlock's
91754
+ // numericData(), which drops the original Y-column key. A2uiBlock now threads
91755
+ // that key through as `props.yKey` so these renderers can decide whether the
91756
+ // axis / tooltip values are money. Heuristic mirrors the frontend's
91757
+ // chartShared.tsx `isCurrencyKey` — keep the keyword list in lockstep.
91758
+ // `(^|[_\W])` (not `\b`) so "average_salary" / "total_expenses" still match —
91759
+ // JS `\b` doesn't fire next to `_`.
91760
+ const CURRENCY_KEY_RE$1 = /(^|[_\W])(price|salary|cost|amount|revenue|expense|total|value|budget|spend|income|sale|balance)/i;
91761
+ function isCurrencyKey$1(key) {
91762
+ return typeof key === 'string' && CURRENCY_KEY_RE$1.test(key);
91763
+ }
91764
+
91765
+ // Compact currency for axis ticks → "$120.0k". Full currency for tooltips →
91766
+ // "$87,600" (comma-grouped, up to 2 decimals, no forced trailing zeros so a
91767
+ // whole-dollar salary reads "$81,200" not "$81,200.00").
91768
+ function formatCurrencyCompact(v) {
91769
+ return "$".concat(formatCompactNumber(v));
91770
+ }
91771
+ function formatCurrencyFull(v) {
91772
+ return "$".concat(Number(v).toLocaleString('en-US', {
91773
+ maximumFractionDigits: 2
91774
+ }));
91775
+ }
91776
+
91777
+ /**
91778
+ * Recharts bar chart — mirrors agent-sdk's eshalRenderers.BarChart.
91779
+ *
91780
+ * Expects `props.data` to be an array of `{ label, value }` objects
91781
+ * (already normalized by A2uiBlock's numericData helper).
91664
91782
  */
91665
91783
  function BarChartRenderer(_ref) {
91666
91784
  let {
@@ -91668,6 +91786,11 @@
91668
91786
  isDark = false
91669
91787
  } = _ref;
91670
91788
  const data = Array.isArray(props.data) ? props.data : [];
91789
+ // Whether the Y values are money — drives the `$` on axis ticks + tooltip.
91790
+ const currency = isCurrencyKey$1(props.yKey);
91791
+ // Human series name for the tooltip ("Average Salary"), so it no longer
91792
+ // reads the generic dataKey "value".
91793
+ const seriesName = props.valueLabel || undefined;
91671
91794
  const borderColor = isDark ? '#374151' : '#e2e8f0';
91672
91795
  const bgColor = isDark ? '#1f2937' : '#ffffff';
91673
91796
  const labelColor = isDark ? '#9ca3af' : '#64748b';
@@ -91730,7 +91853,7 @@
91730
91853
  margin: {
91731
91854
  top: 8,
91732
91855
  right: 12,
91733
- left: 4,
91856
+ left: 12,
91734
91857
  bottom: 20
91735
91858
  },
91736
91859
  children: [/*#__PURE__*/jsxRuntimeExports.jsx(CartesianGrid, {
@@ -91750,6 +91873,7 @@
91750
91873
  fill: labelColor
91751
91874
  } : undefined
91752
91875
  }), /*#__PURE__*/jsxRuntimeExports.jsx(YAxis, {
91876
+ width: 72,
91753
91877
  tick: {
91754
91878
  fontSize: 11,
91755
91879
  fill: labelColor
@@ -91761,9 +91885,9 @@
91761
91885
  fontSize: 11,
91762
91886
  fill: labelColor
91763
91887
  } : undefined,
91764
- tickFormatter: v => props.unit ? "".concat(formatCompactNumber(v), " ").concat(props.unit) : formatCompactNumber(v)
91888
+ tickFormatter: v => currency ? formatCurrencyCompact(v) : props.unit ? "".concat(formatCompactNumber(v), " ").concat(props.unit) : formatCompactNumber(v)
91765
91889
  }), /*#__PURE__*/jsxRuntimeExports.jsx(Tooltip, {
91766
- formatter: v => props.unit ? "".concat(Number(v).toLocaleString(), " ").concat(props.unit) : Number(v).toLocaleString(),
91890
+ formatter: v => currency ? formatCurrencyFull(v) : props.unit ? "".concat(Number(v).toLocaleString(), " ").concat(props.unit) : Number(v).toLocaleString(),
91767
91891
  contentStyle: {
91768
91892
  background: tooltipBg,
91769
91893
  border: "1px solid ".concat(borderColor),
@@ -91773,6 +91897,7 @@
91773
91897
  }
91774
91898
  }), /*#__PURE__*/jsxRuntimeExports.jsx(Bar, {
91775
91899
  dataKey: "value",
91900
+ name: seriesName,
91776
91901
  fill: "#3b82f6",
91777
91902
  radius: [2, 2, 0, 0]
91778
91903
  })]
@@ -91782,9 +91907,9 @@
91782
91907
  });
91783
91908
  }
91784
91909
 
91785
- /**
91786
- * KPI card — mirrors agent-sdk's eshalRenderers.KPICard.
91787
- * Dark-mode aware.
91910
+ /**
91911
+ * KPI card — mirrors agent-sdk's eshalRenderers.KPICard.
91912
+ * Dark-mode aware.
91788
91913
  */
91789
91914
  function KPICardRenderer(_ref2) {
91790
91915
  let {
@@ -91844,10 +91969,10 @@
91844
91969
  }
91845
91970
  const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16', '#06b6d4', '#a855f7'];
91846
91971
 
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).
91972
+ /**
91973
+ * Recharts pie / donut chart — replaces hand-rolled PieSvg.
91974
+ * `props.data` must be an array of `{ label, value }` objects.
91975
+ * Set `donut={true}` for a donut chart (innerRadius > 0).
91851
91976
  */
91852
91977
  function PieChartRenderer(_ref3) {
91853
91978
  let {
@@ -91856,6 +91981,7 @@
91856
91981
  donut = false
91857
91982
  } = _ref3;
91858
91983
  const data = Array.isArray(props.data) ? props.data : [];
91984
+ const currency = isCurrencyKey$1(props.yKey);
91859
91985
  const borderColor = isDark ? '#374151' : '#e2e8f0';
91860
91986
  const bgColor = isDark ? '#1f2937' : '#ffffff';
91861
91987
  const titleColor = isDark ? '#f3f4f6' : '#0f172a';
@@ -91906,7 +92032,7 @@
91906
92032
  fill: PIE_COLORS[i % PIE_COLORS.length]
91907
92033
  }, i))
91908
92034
  }), /*#__PURE__*/jsxRuntimeExports.jsx(Tooltip, {
91909
- formatter: v => Number(v).toLocaleString(),
92035
+ formatter: v => currency ? formatCurrencyFull(v) : Number(v).toLocaleString(),
91910
92036
  contentStyle: {
91911
92037
  background: tooltipBg,
91912
92038
  border: "1px solid ".concat(borderColor),
@@ -91928,10 +92054,10 @@
91928
92054
  });
91929
92055
  }
91930
92056
 
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.
92057
+ /**
92058
+ * Recharts line / area chart — replaces hand-rolled LineSvg.
92059
+ * `props.data` must be an array of `{ label, value }` objects.
92060
+ * Set `area={true}` to fill under the line.
91935
92061
  */
91936
92062
  function LineChartRenderer(_ref4) {
91937
92063
  let {
@@ -91940,6 +92066,8 @@
91940
92066
  area = false
91941
92067
  } = _ref4;
91942
92068
  const data = Array.isArray(props.data) ? props.data : [];
92069
+ const currency = isCurrencyKey$1(props.yKey);
92070
+ const seriesName = props.valueLabel || undefined;
91943
92071
  const borderColor = isDark ? '#374151' : '#e2e8f0';
91944
92072
  const bgColor = isDark ? '#1f2937' : '#ffffff';
91945
92073
  const labelColor = isDark ? '#9ca3af' : '#64748b';
@@ -92003,7 +92131,7 @@
92003
92131
  margin: {
92004
92132
  top: 8,
92005
92133
  right: 12,
92006
- left: 4,
92134
+ left: 12,
92007
92135
  bottom: 20
92008
92136
  },
92009
92137
  children: [/*#__PURE__*/jsxRuntimeExports.jsx(CartesianGrid, {
@@ -92023,13 +92151,14 @@
92023
92151
  fill: labelColor
92024
92152
  } : undefined
92025
92153
  }), /*#__PURE__*/jsxRuntimeExports.jsx(YAxis, {
92154
+ width: 72,
92026
92155
  tick: {
92027
92156
  fontSize: 11,
92028
92157
  fill: labelColor
92029
92158
  },
92030
- tickFormatter: v => props.unit ? "".concat(formatCompactNumber(v), " ").concat(props.unit) : formatCompactNumber(v)
92159
+ tickFormatter: v => currency ? formatCurrencyCompact(v) : props.unit ? "".concat(formatCompactNumber(v), " ").concat(props.unit) : formatCompactNumber(v)
92031
92160
  }), /*#__PURE__*/jsxRuntimeExports.jsx(Tooltip, {
92032
- formatter: v => props.unit ? "".concat(Number(v).toLocaleString(), " ").concat(props.unit) : Number(v).toLocaleString(),
92161
+ formatter: v => currency ? formatCurrencyFull(v) : props.unit ? "".concat(Number(v).toLocaleString(), " ").concat(props.unit) : Number(v).toLocaleString(),
92033
92162
  contentStyle: {
92034
92163
  background: tooltipBg,
92035
92164
  border: "1px solid ".concat(borderColor),
@@ -92040,6 +92169,7 @@
92040
92169
  }), area ? /*#__PURE__*/jsxRuntimeExports.jsx(Area, {
92041
92170
  type: "monotone",
92042
92171
  dataKey: "value",
92172
+ name: seriesName,
92043
92173
  stroke: "#3b82f6",
92044
92174
  fill: "#3b82f6",
92045
92175
  fillOpacity: 0.15,
@@ -92051,6 +92181,7 @@
92051
92181
  }) : /*#__PURE__*/jsxRuntimeExports.jsx(Line, {
92052
92182
  type: "monotone",
92053
92183
  dataKey: "value",
92184
+ name: seriesName,
92054
92185
  stroke: "#3b82f6",
92055
92186
  strokeWidth: 2,
92056
92187
  dot: {
@@ -92248,7 +92379,14 @@
92248
92379
  data: rows,
92249
92380
  unit: spec.unit,
92250
92381
  xAxisLabel: spec.xAxisLabel,
92251
- yAxisLabel: spec.yAxisLabel
92382
+ yAxisLabel: spec.yAxisLabel,
92383
+ // numericData() collapses the rows to { label, value }, dropping the
92384
+ // original Y-column key. Pass it through so the renderer can detect a
92385
+ // currency column (→ "$" on the Y-axis + tooltip) and label the tooltip
92386
+ // series with the human column name ("Average Salary") instead of the
92387
+ // generic dataKey "value".
92388
+ yKey: y,
92389
+ valueLabel: prettifyLabel(y)
92252
92390
  };
92253
92391
  if (t === 'pie') return /*#__PURE__*/jsxRuntimeExports.jsx(PieChartRenderer, {
92254
92392
  props: chartProps,
@@ -92573,16 +92711,62 @@
92573
92711
  return typeof key === 'string' && CURRENCY_KEY_RE.test(key);
92574
92712
  }
92575
92713
 
92714
+ // ISO date / datetime prefix — matches the typical SQL output the
92715
+ // agent's tools produce when GROUP BY-ing by month
92716
+ // (`DATE_TRUNC('month', col)` → `YYYY-MM-DD`). Used by formatAxisTick
92717
+ // below so a chart of monthly expenses shows "Jan '26" / "Feb '26"
92718
+ // horizontally instead of "2026 01 01" slanted (EV-1187 follow-up).
92719
+ const ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})(?:T|$)/;
92720
+ function formatIsoDateAsMonthTick(value) {
92721
+ if (typeof value !== 'string') return null;
92722
+ const m = value.match(ISO_DATE_RE);
92723
+ if (!m) return null;
92724
+ const year = parseInt(m[1], 10);
92725
+ const month = parseInt(m[2], 10);
92726
+ if (!Number.isInteger(month) || month < 1 || month > 12) return null;
92727
+ return "".concat(MONTH_NAMES[month - 1], " '").concat(String(year).slice(-2));
92728
+ }
92729
+
92576
92730
  // Axis-tick formatter: month-number columns (column matches /month/i,
92577
- // value 1..12) render as 3-letter month names; everything else gets
92731
+ // value 1..12) render as 3-letter month names; ISO date strings
92732
+ // (`2026-01-01`) render as "Jan '26"; everything else gets
92578
92733
  // prettifyLabel applied so "office_supplies" reads "Office Supplies".
92579
92734
  function formatAxisTick(value, dataKey) {
92580
92735
  if (typeof dataKey === 'string' && MONTH_KEY_RE.test(dataKey)) {
92581
92736
  const n = typeof value === 'number' ? value : Number(value);
92582
92737
  if (Number.isInteger(n) && n >= 1 && n <= 12) return MONTH_NAMES[n - 1];
92583
92738
  }
92739
+ const dateMonth = formatIsoDateAsMonthTick(value);
92740
+ if (dateMonth) return dateMonth;
92584
92741
  return prettifyLabel(value);
92585
92742
  }
92743
+
92744
+ // Conservative snake_case detector for table cell category labels
92745
+ // (2026-06-19, EV-1187 follow-up). Mirrors the admin UI's
92746
+ // `prettifyCategoryCell` in `packages/frontend/.../chartShared.tsx` and
92747
+ // the SDK's copy. Self-contained because the widget can't reach across
92748
+ // package boundaries.
92749
+ //
92750
+ // Goal: when a category column shows `office_supplies` next to a chart
92751
+ // whose X-axis (via prettifyLabel) already renders "Office Supplies",
92752
+ // the table should agree. Conservative — only lowercase ASCII tokens
92753
+ // joined by underscores (≤ 40 chars) qualify, so IDs/paths/emails/
92754
+ // slugs/camelCase pass through verbatim.
92755
+ const SNAKE_CASE_CATEGORY_RE = /^[a-z]+(_[a-z]+)+$/;
92756
+ function prettifyCategoryCell(value) {
92757
+ if (typeof value !== 'string') return String(value !== null && value !== void 0 ? value : '');
92758
+ if (value.length > 40) return value;
92759
+ if (!SNAKE_CASE_CATEGORY_RE.test(value)) return value;
92760
+ return prettifyLabel(value);
92761
+ }
92762
+
92763
+ // Numeric string under a money column, e.g. "81,200" / "105444" / "-1,250.50".
92764
+ // Markdown tables (the common concierge path) arrive as text cells, so a
92765
+ // salary value is the STRING "81,200" — not a number — and skips the
92766
+ // USD_FORMATTER branch below. We prefix "$" while keeping the source's own
92767
+ // grouping/decimals so it reads "$81,200" (not "$81,200.00"). A leading "$"
92768
+ // already present makes the regex miss, so we never double-prefix.
92769
+ const _NUMERIC_STRING_RE = /^-?\d[\d,]*(\.\d+)?$/;
92586
92770
  function formatCell(v, columnKey) {
92587
92771
  if (v == null) return '';
92588
92772
  if (typeof v === 'number') {
@@ -92592,7 +92776,17 @@
92592
92776
  return Number.isInteger(v) ? String(v) : v.toFixed(2);
92593
92777
  }
92594
92778
  if (typeof v === 'object') return JSON.stringify(v);
92595
- return String(v);
92779
+ // Numeric string under a money-named column → prefix "$" (Bug: concierge
92780
+ // salary table showed "81,200" with no currency symbol).
92781
+ if (isCurrencyKey(columnKey) && _NUMERIC_STRING_RE.test(String(v).trim())) {
92782
+ return "$".concat(String(v).trim());
92783
+ }
92784
+ // String cells: prettify snake_case category labels (e.g.
92785
+ // `office_supplies` → "Office Supplies") so the table reads
92786
+ // consistently with the chart's X-axis ticks on the same data.
92787
+ // Conservative: anything not a clean lowercase_underscore token is
92788
+ // returned verbatim, so paths/IDs/emails/slugs are preserved.
92789
+ return prettifyCategoryCell(v);
92596
92790
  }
92597
92791
 
92598
92792
  // ─────────────────────────────────────────────────────────────────────────
@@ -93626,8 +93820,22 @@
93626
93820
  // When streaming, hide trailing table lines that haven't formed a complete
93627
93821
  // table yet (no separator row). Without this, partial `| col |` lines flash
93628
93822
  // as raw pipe text before ReactMarkdown can recognise the block as a table.
93823
+ // Also hides incomplete A2UI fences (```eshal-ui / ```eshal-chart / ```chart)
93824
+ // that haven't received their closing ``` yet — prevents raw JSON flash.
93629
93825
  function sanitizeStreamingContent(text, isStreaming) {
93630
93826
  if (!isStreaming) return text;
93827
+
93828
+ // A2UI fence guard: if an opening fence exists without a matching closing
93829
+ // ```, suppress everything from that opening onwards so the partial JSON/
93830
+ // code never flashes as raw text in the bubble.
93831
+ const openMatch = /^```(?:eshal-ui|eshal-chart|chart)\b/m.exec(text);
93832
+ if (openMatch) {
93833
+ const rest = text.slice(openMatch.index + openMatch[0].length);
93834
+ const hasClose = rest.split('\n').some(l => l.trim() === '```');
93835
+ if (!hasClose) {
93836
+ return text.slice(0, openMatch.index).trimEnd();
93837
+ }
93838
+ }
93631
93839
  const lines = text.split('\n');
93632
93840
  // Find last non-empty line index
93633
93841
  let last = lines.length - 1;