@eshal-bot/chat-widget 0.2.12 → 0.2.13

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.
@@ -88402,6 +88402,26 @@
88402
88402
  return typeof u === 'string' && /^https?:\/\//i.test(u) ? u : undefined;
88403
88403
  }
88404
88404
 
88405
+ // Kept in its own tiny module (not A2uiBlock.jsx): MessageList imports it
88406
+ // eagerly, and A2uiBlock/MarkdownRenderer are lazy-loaded to keep charts and
88407
+ // react-markdown out of the widget's initial bundle.
88408
+
88409
+ // 2026-09-26 (P1, double-booking risk): the delivered user turn that
88410
+ // followed the message carrying a card, derived from conversation history by
88411
+ // MessageList. `undefined` = not answered yet. One-shot cards (confirmation,
88412
+ // form) render locked once answered, so a reload cannot re-arm an answered
88413
+ // "Confirm booking". Context rather than a prop so it does not have to be
88414
+ // threaded through MessageBubble -> StreamingMarkdown -> MarkdownRenderer.
88415
+ const A2uiAnsweredContext = /*#__PURE__*/React.createContext(undefined);
88416
+ const _normAnswer = v => String(v ?? '').trim().toLowerCase();
88417
+ function matchAnsweredChoice(choices, answer) {
88418
+ if (answer === undefined || answer === null) return null;
88419
+ const a = _normAnswer(answer);
88420
+ if (!a) return null;
88421
+ const hit = (choices || []).find(c => _normAnswer(c?.value) === a || _normAnswer(c?.label) === a);
88422
+ return hit ? String(hit.value ?? hit.label ?? '') : null;
88423
+ }
88424
+
88405
88425
  /**
88406
88426
  * A2uiBlock - render `eshal-ui` / `eshal-chart` / `chart` fence JSON as a
88407
88427
  * real visual block inside the embedded chat widget.
@@ -89343,6 +89363,9 @@
89343
89363
  const [values, setValues] = React.useState(initialValues);
89344
89364
  const [errors, setErrors] = React.useState({});
89345
89365
  const [submitted, setSubmitted] = React.useState(false);
89366
+ // A later user turn already answered this form (history), e.g. after a
89367
+ // reload: never re-arm the submit.
89368
+ const consumed = React.useContext(A2uiAnsweredContext) !== undefined;
89346
89369
  if (fields.length === 0) return null;
89347
89370
  const handleChange = (name, value) => {
89348
89371
  setValues(prev => ({
@@ -89356,7 +89379,7 @@
89356
89379
  };
89357
89380
  const handleSubmit = e => {
89358
89381
  e.preventDefault();
89359
- if (submitted || !onButtonClick) return;
89382
+ if (submitted || consumed || !onButtonClick) return;
89360
89383
  const next = {};
89361
89384
  let firstError = false;
89362
89385
  for (const f of fields) {
@@ -89440,17 +89463,17 @@
89440
89463
  autoId: `a2ui-form-${f.name || i}`
89441
89464
  }, f.name || i)), /*#__PURE__*/jsxRuntimeExports.jsx("button", {
89442
89465
  type: "submit",
89443
- disabled: !onButtonClick,
89466
+ disabled: !onButtonClick || consumed,
89444
89467
  style: {
89445
89468
  border: '1px solid ' + (isDark ? '#374151' : '#d1d5db'),
89446
- background: onButtonClick ? '#2563eb' : isDark ? '#1f2937' : '#f3f4f6',
89447
- color: onButtonClick ? '#ffffff' : isDark ? '#6b7280' : '#9ca3af',
89469
+ background: onButtonClick && !consumed ? '#2563eb' : isDark ? '#1f2937' : '#f3f4f6',
89470
+ color: onButtonClick && !consumed ? '#ffffff' : isDark ? '#6b7280' : '#9ca3af',
89448
89471
  padding: '6px 14px',
89449
89472
  borderRadius: 6,
89450
89473
  fontSize: 12,
89451
- cursor: onButtonClick ? 'pointer' : 'default'
89474
+ cursor: onButtonClick && !consumed ? 'pointer' : 'default'
89452
89475
  },
89453
- children: spec.submitLabel || 'Submit'
89476
+ children: consumed ? 'Submitted' : spec.submitLabel || 'Submit'
89454
89477
  })]
89455
89478
  })
89456
89479
  );
@@ -89474,6 +89497,26 @@
89474
89497
  label: 'No',
89475
89498
  value: 'no'
89476
89499
  }];
89500
+ // 2026-09-26 (P1): a confirmation is a one-shot commitment. It used to
89501
+ // stay clickable forever - after the pick AND after a reload - so a second
89502
+ // click re-sent "confirm" (double-booking risk). Lock on the local pick
89503
+ // (ref guard: a double click in one tick cannot send twice) or on the
89504
+ // history-recorded answer, and tick the chosen option.
89505
+ const consumedBy = React.useContext(A2uiAnsweredContext);
89506
+ const [localChosen, setLocalChosen] = React.useState(null);
89507
+ const sentRef = React.useRef(false);
89508
+ const chosen = localChosen ?? matchAnsweredChoice(buttons, consumedBy);
89509
+ const locked = chosen !== null || consumedBy !== undefined;
89510
+ const lockedButtons = locked ? buttons.map(b => String(b?.value ?? b?.label ?? '') === chosen ? {
89511
+ ...b,
89512
+ label: `${String(b.label ?? b.value ?? '')} \u2713`
89513
+ } : b) : buttons;
89514
+ const handleChoose = onButtonClick ? (value, label) => {
89515
+ if (sentRef.current || locked) return;
89516
+ sentRef.current = true;
89517
+ setLocalChosen(String(value));
89518
+ onButtonClick(value, label);
89519
+ } : undefined;
89477
89520
  return /*#__PURE__*/jsxRuntimeExports.jsxs("div", {
89478
89521
  style: card(isDark),
89479
89522
  children: [spec.title && /*#__PURE__*/jsxRuntimeExports.jsx("div", {
@@ -89493,10 +89536,10 @@
89493
89536
  children: String(spec.body)
89494
89537
  }), /*#__PURE__*/jsxRuntimeExports.jsx(ButtonGroup, {
89495
89538
  spec: {
89496
- buttons
89539
+ buttons: lockedButtons
89497
89540
  },
89498
89541
  isDark: isDark,
89499
- onButtonClick: onButtonClick
89542
+ onButtonClick: locked ? undefined : handleChoose
89500
89543
  })]
89501
89544
  });
89502
89545
  }
@@ -91573,6 +91616,70 @@
91573
91616
  const globalScope = typeof window !== "undefined" ? window : typeof globalThis !== "undefined" ? globalThis : undefined;
91574
91617
  const WidgetCitationContext = globalScope ? globalScope[GLOBAL_KEY] || (globalScope[GLOBAL_KEY] = /*#__PURE__*/React.createContext(null)) : /*#__PURE__*/React.createContext(null);
91575
91618
 
91619
+ /**
91620
+ * EV-4177 - can this source card actually be downloaded?
91621
+ *
91622
+ * The chat showed a download icon on every `source_type: "file"` card and then
91623
+ * did nothing when it was clicked. QA uploaded books.xlsx, asked about it, got
91624
+ * an answer citing the file, clicked the icon, and nothing happened - no
91625
+ * download, no error, no explanation.
91626
+ *
91627
+ * The download handler has always had preconditions. An attachment card needs
91628
+ * the presigned read URL the backend mints (`source.url`); a SharePoint card
91629
+ * needs its proxy URL; a knowledge-base card needs a `source_id` the
91630
+ * `/file-download` endpoint can resolve. When none of those are present the
91631
+ * handler logged a warning to a console nobody has open and returned.
91632
+ *
91633
+ * The bug is not really the failed download - it is the icon. Offering a
91634
+ * control that cannot work is worse than not offering it: the customer clicks
91635
+ * it repeatedly, assumes the product is broken, and has nothing to report
91636
+ * beyond "it doesn't work".
91637
+ *
91638
+ * So the same conditions the handler checks now decide whether the icon is
91639
+ * drawn at all, in one place used by both the message bubble and the citation
91640
+ * pill. Those two had grown separate copies of "is this a file", which is how
91641
+ * they came to disagree about what is downloadable.
91642
+ */
91643
+
91644
+ /** A SharePoint citation is served through our own proxy endpoint. */
91645
+ function isSharePointProxyUrl(url) {
91646
+ return typeof url === "string" && url.includes("/api/v1/sharepoint/file/");
91647
+ }
91648
+
91649
+ /**
91650
+ * Uploaded chat attachments are keyed `attachment:<storage_key>`, which the
91651
+ * legacy `/file-download` endpoint cannot resolve - they are fetched straight
91652
+ * from the presigned URL instead, so that URL is mandatory for them.
91653
+ */
91654
+ function isAttachmentSource(source) {
91655
+ if (!source) return false;
91656
+ const id = String(source.source_id || source.sourceid || "");
91657
+ return id.startsWith("attachment:");
91658
+ }
91659
+
91660
+ /**
91661
+ * True only when a click will actually produce a file.
91662
+ *
91663
+ * Deliberately mirrors the branches in the download handler. If you add a new
91664
+ * download path there, add it here too - the icon and the action have to agree,
91665
+ * and this function existing is what makes that a single edit rather than a
91666
+ * bug report.
91667
+ */
91668
+ function canDownloadSource(source) {
91669
+ if (!source) return false;
91670
+
91671
+ // Attachment cards: the presigned URL is the only way in.
91672
+ if (isAttachmentSource(source)) {
91673
+ return typeof source.url === "string" && source.url.length > 0;
91674
+ }
91675
+
91676
+ // SharePoint: the proxy URL is the only way in.
91677
+ if (isSharePointProxyUrl(source.url)) return true;
91678
+
91679
+ // Knowledge-base files: resolved server-side from the source id.
91680
+ return Boolean(source.source_id);
91681
+ }
91682
+
91576
91683
  function decodeName(name) {
91577
91684
  if (!name) return "";
91578
91685
  try {
@@ -91763,7 +91870,11 @@
91763
91870
  const first = referenced[0];
91764
91871
  const extraCount = referenced.length - 1;
91765
91872
  const firstLabel = displayLabel(first.src, first.index);
91766
- const firstIsFile = isFileLikeSource(first.src);
91873
+ // EV-4177: a card only gets the download affordance when a click will
91874
+ // actually produce a file. isFileLikeSource alone answers "is this a file",
91875
+ // not "can we fetch it" - which is how a download icon ended up on a source
91876
+ // that had no URL and no resolvable id behind it.
91877
+ const firstIsFile = isFileLikeSource(first.src) && canDownloadSource(first.src);
91767
91878
  const busy = ctx.downloadingKey && ctx.downloadingKey === pillKey(first.src);
91768
91879
  const activate = s => {
91769
91880
  if (typeof ctx.onSourceActivate === "function") {
@@ -91922,7 +92033,8 @@
91922
92033
  index
91923
92034
  }) => {
91924
92035
  const label = displayLabel(src, index);
91925
- const rowIsFile = isFileLikeSource(src);
92036
+ // EV-4177: same gate as firstIsFile above.
92037
+ const rowIsFile = isFileLikeSource(src) && canDownloadSource(src);
91926
92038
  const kBusy = ctx.downloadingKey && ctx.downloadingKey === pillKey(src);
91927
92039
  return /*#__PURE__*/jsxRuntimeExports.jsxs("button", {
91928
92040
  type: "button",