@eshal-bot/chat-widget 0.2.28 → 0.2.30

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.
@@ -88504,54 +88504,219 @@
88504
88504
  }
88505
88505
 
88506
88506
  /**
88507
- * Strip internal DB identifiers from an LLM-generated chart/card title before
88508
- * it's shown to the user (EV pie-chart bug, issue 1). Mirror of the frontend +
88509
- * agent-sdk helpers - the widget is a separate package.
88507
+ * EV-2850 (WAPT) - the agent's `eshal-ui`/`eshal-chart` fence output can be
88508
+ * influenced by the end user's own message or by prompt-injection planted in
88509
+ * a knowledge-base document. A2uiBlock renders `href`/`src` values straight
88510
+ * from that JSON with no filtering - a `javascript:` action URL renders as a
88511
+ * legitimate-looking button that executes attacker JS in the visitor's page
88512
+ * when clicked (an authenticated victim's session cookie, DOM, etc. are all
88513
+ * in scope; no account needed to trigger it).
88510
88514
  *
88511
- * The database_query sub-agent writes `title` freeform and sometimes appends how
88512
- * the value was computed - e.g. "…by department (summed from the employees.salary
88513
- * field)". The prompt now forbids this; this is the deterministic guarantee a
88514
- * table/column name never reaches the user.
88515
+ * Every `href`/`src` sink in A2uiBlock must go through this first. Only
88516
+ * absolute http(s) URLs pass; anything else (javascript:, data:, vbscript:,
88517
+ * a bare relative path, non-string) is dropped rather than rendered.
88518
+ *
88519
+ * EV-3956 / EV-4798 MERN-19: this is the ONE implementation. The agent-sdk,
88520
+ * widget and frontend renderers all import this file (the published SDK and
88521
+ * widget bundle it at build time, so their consumers never resolve
88522
+ * @eshal/shared). A fix made here reaches all three surfaces. Do not re-inline.
88523
+ *
88524
+ * Plain JavaScript on purpose: the widget's Rollup/Babel build compiles no
88525
+ * TypeScript from another package. Types live in safeUrl.d.ts.
88526
+ */
88527
+ function safeUrl(u) {
88528
+ return typeof u === 'string' && /^https?:\/\//i.test(u) ? u : undefined;
88529
+ }
88530
+
88531
+ /**
88532
+ * Framework-free A2UI core shared by the three A2uiBlock renderers - the
88533
+ * agent-sdk, the widget and the frontend Testing Lab (EV-4798 MERN-19).
88534
+ *
88535
+ * It owns what the renderers must agree on and that is not presentation:
88536
+ * - the component registry: which `type` values route to a chart and which
88537
+ * to a component, and how a type string is normalised;
88538
+ * - sanitizeChartTitle: no DB/SQL annotation reaches a visible title;
88539
+ * - toolConfirmationFor: the structured answer of an approval card;
88540
+ * - linkSegments: which spans of card text become links (every href passes
88541
+ * safeUrl, so a `javascript:` link stays plain text);
88542
+ * - matchAnsweredChoice / findNextUserAnswer: the answered state of a
88543
+ * one-shot card, read from conversation history.
88544
+ *
88545
+ * Each renderer keeps its own components and styling (inline SVG in the SDK,
88546
+ * lucide + inline styles in the widget, Tailwind + shadcn in the frontend).
88515
88547
  *
88516
- * Conservative: only strips a TRAILING parenthetical, and only when it reads as
88517
- * a DB/SQL annotation (mentions field/column/table/sum/SQL, or contains a
88518
- * `table.column` dotted identifier). A legit "(2024)"/"(YoY)" is kept.
88548
+ * Plain JavaScript (types in a2ui.d.ts) because the widget's Rollup/Babel
88549
+ * build compiles no TypeScript from another package; conservative syntax
88550
+ * (no `??`, no `?.`) because that build does not run this file through its
88551
+ * Babel preset either.
88519
88552
  */
88553
+
88554
+ // ── Component registry ─────────────────────────────────────────────────
88555
+
88556
+ /** Chart-family types the dispatchers route into their chart renderer. */
88557
+ const A2UI_CHART_TYPES = Object.freeze(['bar', 'line', 'area', 'pie', 'donut', 'scatter', 'bubble', 'radar', 'radial-bar', 'radial',
88558
+ // alias for radial-bar
88559
+ 'radialbar',
88560
+ // alias for radial-bar
88561
+ 'treemap', 'funnel', 'sankey', 'composed', 'waterfall', 'gauge', 'histogram', 'kpi']);
88562
+
88563
+ /**
88564
+ * Routing form of a spec's `type`: trimmed, lowercase, underscore to kebab,
88565
+ * so "Bar", " Donut " and `key_observations` all route.
88566
+ */
88567
+ function normalizeA2uiType(type) {
88568
+ return String(type || '').trim().toLowerCase().replace(/_/g, '-');
88569
+ }
88570
+
88571
+ // ── Titles ─────────────────────────────────────────────────────────────
88572
+
88520
88573
  const SQLISH = /\b(?:field|fields|column|columns|table|tables|sql|summed?|aggregat\w*|averaged?|counted)\b/i;
88574
+ // A dotted identifier like `employees.salary` (table.column).
88521
88575
  const DOTTED = /[A-Za-z_]\w*\.[A-Za-z_]\w*/;
88576
+
88577
+ /**
88578
+ * Strip internal DB identifiers from an LLM-generated chart/card title (EV
88579
+ * pie-chart bug, issue 1). The database_query sub-agent sometimes appends how
88580
+ * a value was computed - "... by department (summed from the employees.salary
88581
+ * field)". The prompt forbids this; this is the deterministic guarantee a
88582
+ * table/column name never reaches the user.
88583
+ *
88584
+ * Conservative: only a TRAILING parenthetical that reads as a DB/SQL
88585
+ * annotation is removed (repeatedly, in case several are stacked). A
88586
+ * legitimate "(2024)" or "(YoY)" is kept.
88587
+ */
88522
88588
  function sanitizeChartTitle(title) {
88523
88589
  if (typeof title !== 'string') return '';
88524
88590
  let t = title.trim();
88525
88591
  let prev;
88526
88592
  do {
88527
88593
  prev = t;
88528
- t = t.replace(/\s*\(([^()]*)\)\s*$/, (match, inner) => SQLISH.test(inner) || DOTTED.test(inner) ? '' : match).trim();
88594
+ t = t.replace(/\s*\(([^()]*)\)\s*$/, function (match, inner) {
88595
+ return SQLISH.test(inner) || DOTTED.test(inner) ? '' : match;
88596
+ }).trim();
88529
88597
  } while (t !== prev);
88530
88598
  return t;
88531
88599
  }
88532
88600
 
88601
+ // ── Approval cards (EV-4798 MERN-12) ───────────────────────────────────
88602
+
88603
+ const APPROVE_VALUES = new Set(['yes', 'approve', 'approved', 'confirm']);
88604
+ const DECLINE_VALUES = new Set(['no', 'decline', 'declined', 'cancel']);
88605
+ const ID_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/;
88606
+ function cleanId(value) {
88607
+ if (typeof value !== 'string') return null;
88608
+ const id = value.trim();
88609
+ return ID_PATTERN.test(id) ? id : null;
88610
+ }
88611
+ function firstDefined$1(a, b) {
88612
+ return a !== null && a !== undefined ? a : b;
88613
+ }
88614
+
88615
+ /** true / false for a choice the card marks as approve / decline; null when unknown. */
88616
+ function choiceApproves(choice) {
88617
+ if (!choice || typeof choice !== 'object') return null;
88618
+ if (typeof choice.approved === 'boolean') return choice.approved;
88619
+ const value = String(firstDefined$1(choice.value, '')).trim().toLowerCase();
88620
+ if (APPROVE_VALUES.has(value)) return true;
88621
+ if (DECLINE_VALUES.has(value)) return false;
88622
+ return null;
88623
+ }
88624
+
88533
88625
  /**
88534
- * EV-2850 (WAPT) - the agent's `eshal-ui`/`eshal-chart` fence output can be
88535
- * influenced by the end user's own message or by prompt-injection planted in
88536
- * a knowledge-base document. A2uiBlock renders `href`/`src` values straight
88537
- * from that JSON with no filtering - a `javascript:` action URL renders as a
88538
- * legitimate-looking button that executes attacker JS in the visitor's page
88539
- * when clicked (an authenticated victim's session cookie, DOM, etc. are all
88540
- * in scope; no account needed to trigger it).
88541
- *
88542
- * Every `href`/`src` sink in A2uiBlock must go through this first. Only
88543
- * absolute http(s) URLs pass; anything else (javascript:, data:, vbscript:,
88544
- * a bare relative path, non-string) is dropped rather than rendered.
88626
+ * The `tool_confirmation` for a pick on an approval card, or null when the
88627
+ * card does not carry the held call's id (the text answer then stands alone).
88545
88628
  *
88546
- * EV-3956: the canonical copy lives in `@eshal/shared`
88547
- * (`src/a2ui/safeUrl.ts`). This package is published to npm while
88548
- * @eshal/shared is private, so it keeps its own copy rather than declaring
88549
- * a dependency consumers cannot resolve. Parity with the canonical version
88550
- * is enforced by packages/shared/src/__tests__/safeUrlParity.test.ts - if
88551
- * you change the logic here, change it there too or the build fails.
88629
+ * A Skill tool that needs approval pauses the turn and the runtime draws a
88630
+ * `confirmation` card. The turn contract carries `tool_confirmation`
88631
+ * ({ tool_call_id, invocation_id, approved }), which the runtime prefers
88632
+ * because it names the exact call. Built only when the card carries the id
88633
+ * and the decision can be read from the choice.
88552
88634
  */
88553
- function safeUrl(u) {
88554
- return typeof u === 'string' && /^https?:\/\//i.test(u) ? u : undefined;
88635
+ function toolConfirmationFor(spec, choice) {
88636
+ if (!spec || typeof spec !== 'object') return null;
88637
+ const toolCallId = cleanId(firstDefined$1(spec.tool_call_id, spec.toolCallId));
88638
+ if (!toolCallId) return null;
88639
+ const approved = choiceApproves(choice);
88640
+ if (approved === null) return null;
88641
+ return {
88642
+ tool_call_id: toolCallId,
88643
+ invocation_id: cleanId(firstDefined$1(spec.invocation_id, spec.invocationId)) || '',
88644
+ approved: approved
88645
+ };
88646
+ }
88647
+
88648
+ // ── Links in card text ─────────────────────────────────────────────────
88649
+
88650
+ // `[label](https://...)` or a bare http(s) URL (trailing punctuation is not
88651
+ // part of the URL).
88652
+ const LINK_RE = /\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/g;
88653
+
88654
+ /**
88655
+ * A2UI card text is plain text, but agents put markdown links in it (a
88656
+ * booked meeting's "[Join Google Meet](https://...)"). Splits `text` into
88657
+ * text and link segments, or returns null when nothing in it is a safe link
88658
+ * (the caller then renders the text unchanged). Every href passes safeUrl.
88659
+ */
88660
+ function linkSegments(text) {
88661
+ if (!text) return null;
88662
+ const s = String(text);
88663
+ const out = [];
88664
+ let last = 0;
88665
+ LINK_RE.lastIndex = 0;
88666
+ let m;
88667
+ while ((m = LINK_RE.exec(s)) !== null) {
88668
+ const idx = m.index;
88669
+ const label = m[1] !== undefined ? m[1] : m[3];
88670
+ const href = safeUrl(m[2] !== undefined ? m[2] : m[3]);
88671
+ if (!href) continue;
88672
+ if (idx > last) out.push({
88673
+ type: 'text',
88674
+ value: s.slice(last, idx)
88675
+ });
88676
+ out.push({
88677
+ type: 'link',
88678
+ label: label,
88679
+ href: href
88680
+ });
88681
+ last = idx + m[0].length;
88682
+ }
88683
+ if (out.length === 0) return null;
88684
+ if (last < s.length) out.push({
88685
+ type: 'text',
88686
+ value: s.slice(last)
88687
+ });
88688
+ return out;
88689
+ }
88690
+
88691
+ // ── Answered state of one-shot cards ───────────────────────────────────
88692
+ //
88693
+ // Cards used to hold "already answered" only in component state, so a reload
88694
+ // rendered an answered "Confirm booking" card clickable again and a second
88695
+ // click risked a double booking. The conversation history is the durable
88696
+ // record: a card is consumed once ANY delivered user turn follows the
88697
+ // assistant message that carried it, and that turn is the recorded answer.
88698
+
88699
+ function normAnswer(v) {
88700
+ return String(firstDefined$1(v, '')).trim().toLowerCase();
88701
+ }
88702
+
88703
+ /**
88704
+ * Which choice the recorded answer picked, matched on the choice's `value`
88705
+ * or `label` (surfaces send `value || label`). null when unanswered or when
88706
+ * the user moved on by typing something else.
88707
+ */
88708
+ function matchAnsweredChoice(choices, answer) {
88709
+ if (answer === undefined || answer === null) return null;
88710
+ const a = normAnswer(answer);
88711
+ if (!a) return null;
88712
+ const list = choices || [];
88713
+ for (let i = 0; i < list.length; i += 1) {
88714
+ const c = list[i];
88715
+ if (normAnswer(c ? c.value : undefined) === a || normAnswer(c ? c.label : undefined) === a) {
88716
+ return String(firstDefined$1(firstDefined$1(c.value, c.label), ''));
88717
+ }
88718
+ }
88719
+ return null;
88555
88720
  }
88556
88721
 
88557
88722
  /**
@@ -88569,35 +88734,145 @@
88569
88734
  };
88570
88735
  }
88571
88736
 
88572
- const LINK_RE = /\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/g;
88737
+ /**
88738
+ * Table-cell and label formatting shared by the three A2UI renderers - the
88739
+ * agent-sdk, the widget and the frontend Testing Lab (EV-4798 MERN-19). Each
88740
+ * used to carry a copy commented "kept in lockstep", so Testing Lab, the
88741
+ * installed widget and the SDK render DB tables identically only for as long
88742
+ * as nobody forgot one.
88743
+ *
88744
+ * Rules: ISO date/datetime -> "Dec 9, 2021"; a BARE numeric string in a
88745
+ * money-named column -> comma-grouped WITHOUT synthesizing a currency symbol
88746
+ * (the DB is the source of truth for "$"); a value already carrying a symbol,
88747
+ * or any non-currency column, passes through verbatim or prettified.
88748
+ *
88749
+ * Plain JavaScript (types in cellFormat.d.ts), conservative syntax: the
88750
+ * widget's Rollup/Babel build compiles neither TypeScript nor this file.
88751
+ */
88752
+
88753
+ const MONTH_NAMES$1 = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
88754
+
88755
+ /**
88756
+ * Humanise a key: snake / kebab to spaces, camelCase split, each lower-case
88757
+ * word capitalised ("total_expenses" -> "Total Expenses").
88758
+ *
88759
+ * Only a column-style value is reworded: one with an underscore
88760
+ * (office_supplies), one without capitals (kebab-case, all-lower words) or a
88761
+ * camelCase key. A value written with capitals is a name and stays as written
88762
+ * ("DD Sports", "Republic TV", "GEC"), as does a word written with capitals
88763
+ * inside a key (GRP_pre -> "GRP Pre"). Lower-casing every word but its first
88764
+ * letter read those channels as "Dd Sports", "Republic Tv" and "Gec" (Media
88765
+ * Prowess, Oct 2026).
88766
+ */
88767
+ function prettifyLabel(value) {
88768
+ if (value === null || value === undefined) return '';
88769
+ const s = String(value).replace(/\s+/g, ' ').trim();
88770
+ if (!s) return '';
88771
+ if (!isColumnStyle(s)) return s;
88772
+ return s.replace(/[_\-]+/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/\s+/g, ' ').trim().split(' ').map(function (w) {
88773
+ return w === w.toLowerCase() ? w.charAt(0).toUpperCase() + w.slice(1) : w;
88774
+ }).join(' ');
88775
+ }
88776
+
88777
+ // A camelCase key ("camelCaseKey", "totalSpend"): two lower-case letters
88778
+ // before the first capital, so names such as "iPhone", "eBay" and "kWh" stay
88779
+ // as written.
88780
+ const CAMEL_CASE_KEY_RE = /^[a-z]{2}[a-z0-9]*[A-Z][A-Za-z0-9]*$/;
88781
+ function isColumnStyle(s) {
88782
+ return s.indexOf('_') !== -1 || s === s.toLowerCase() || CAMEL_CASE_KEY_RE.test(s);
88783
+ }
88784
+
88785
+ // The leading `(^|[_\W])` stands in for `\b`, which does not fire next to `_`
88786
+ // (so "total_expenses" still matches "expense").
88787
+ const CURRENCY_KEY_RE = /(^|[_\W])(price|salary|cost|amount|revenue|expense|total|value|budget|spend|income|sale|balance)/i;
88788
+
88789
+ /** Does this column name suggest money? Conservative: false negatives are fine. */
88790
+ function isCurrencyKey(key) {
88791
+ if (typeof key !== 'string') return false;
88792
+ return CURRENCY_KEY_RE.test(key);
88793
+ }
88794
+ const ISO_DATETIME_CELL_RE = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/;
88795
+
88796
+ /**
88797
+ * "Dec 9, 2021" for an ISO date, "Dec 9, 2021, 2:30 PM" when a non-midnight
88798
+ * time is present; null when the value is not an ISO date/datetime.
88799
+ */
88800
+ function formatDateCell(value) {
88801
+ if (typeof value !== 'string') return null;
88802
+ const trimmed = value.trim();
88803
+ if (!ISO_DATETIME_CELL_RE.test(trimmed)) return null;
88804
+ // A date-time string with no trailing 'Z'/offset is parsed by `new Date()`
88805
+ // as LOCAL time (ECMA-262), but it is read back below via UTC getters - so a
88806
+ // timezone-less value is normalised to UTC first, or a non-UTC runtime
88807
+ // renders the wrong calendar day/time (EV-2783 follow-up). Date-only
88808
+ // strings are already parsed as UTC midnight per spec.
88809
+ const hasTime = /[T ]\d{2}:\d{2}/.test(trimmed);
88810
+ const hasTz = /(Z|[+-]\d{2}:?\d{2})$/.test(trimmed);
88811
+ const parseable = hasTime && !hasTz ? trimmed.replace(' ', 'T') + 'Z' : trimmed;
88812
+ const d = new Date(parseable);
88813
+ if (isNaN(d.getTime())) return null;
88814
+ const hasNonMidnightTime = hasTime && (d.getUTCHours() !== 0 || d.getUTCMinutes() !== 0 || d.getUTCSeconds() !== 0);
88815
+ const dateStr = MONTH_NAMES$1[d.getUTCMonth()] + ' ' + d.getUTCDate() + ', ' + d.getUTCFullYear();
88816
+ if (!hasNonMidnightTime) return dateStr;
88817
+ const h = d.getUTCHours();
88818
+ const ampm = h >= 12 ? 'PM' : 'AM';
88819
+ const h12 = h % 12 || 12;
88820
+ const mm = String(d.getUTCMinutes()).padStart(2, '0');
88821
+ return dateStr + ', ' + h12 + ':' + mm + ' ' + ampm;
88822
+ }
88823
+
88824
+ // Lowercase ASCII tokens joined by underscores (one or more joins). IDs,
88825
+ // paths, emails, slugs and camelCase do not match and pass through verbatim.
88826
+ const SNAKE_CASE_CATEGORY_RE = /^[a-z]+(_[a-z]+)+$/;
88827
+
88828
+ /**
88829
+ * Show a snake_case category value ("office_supplies") as "Office Supplies";
88830
+ * everything else (and anything over 40 characters) is left as it is.
88831
+ */
88832
+ function prettifyCategoryCell(value) {
88833
+ if (typeof value !== 'string') return String(value === null || value === undefined ? '' : value);
88834
+ if (value.length > 40) return value;
88835
+ if (!SNAKE_CASE_CATEGORY_RE.test(value)) return value;
88836
+ return prettifyLabel(value);
88837
+ }
88838
+
88839
+ /** Comma-group a number for display: integers as-is, others to 2 decimals. */
88840
+ function commaGroup(n) {
88841
+ return Number.isInteger(n) ? n.toLocaleString('en-US') : Number(n.toFixed(2)).toLocaleString('en-US');
88842
+ }
88843
+ const BARE_NUMBER_RE = /^-?\d+(\.\d+)?$/;
88844
+
88845
+ /**
88846
+ * Comma-group a BARE numeric string in a currency-named column, without
88847
+ * adding a currency symbol. null when not applicable (not a string, not a
88848
+ * money column, or the value already carries a symbol), so callers fall
88849
+ * through to their next rule.
88850
+ */
88851
+ function formatCurrencyCellString(value, key) {
88852
+ if (typeof value !== 'string' || !isCurrencyKey(key)) return null;
88853
+ const s = value.trim();
88854
+ if (!BARE_NUMBER_RE.test(s)) return null;
88855
+ const n = Number(s);
88856
+ if (!Number.isFinite(n)) return null;
88857
+ return commaGroup(n);
88858
+ }
88859
+
88573
88860
  const LINK_STYLE = {
88574
88861
  color: '#1d4ed8',
88575
88862
  textDecoration: 'underline',
88576
88863
  overflowWrap: 'anywhere'
88577
88864
  };
88578
88865
  function linkifyText(text) {
88579
- if (!text) return text;
88580
- const out = [];
88581
- let last = 0;
88866
+ const segments = linkSegments(text);
88867
+ if (!segments) return text;
88582
88868
  let key = 0;
88583
- for (const m of String(text).matchAll(LINK_RE)) {
88584
- const idx = m.index ?? 0;
88585
- const label = m[1] ?? m[3];
88586
- const url = safeUrl(m[2] ?? m[3]);
88587
- if (!url) continue;
88588
- if (idx > last) out.push(text.slice(last, idx));
88589
- out.push(/*#__PURE__*/jsxRuntimeExports.jsx("a", {
88590
- href: url,
88591
- target: "_blank",
88592
- rel: "noopener noreferrer",
88593
- style: LINK_STYLE,
88594
- children: label
88595
- }, key++));
88596
- last = idx + m[0].length;
88597
- }
88598
- if (out.length === 0) return text;
88599
- if (last < text.length) out.push(text.slice(last));
88600
- return out;
88869
+ return segments.map(seg => seg.type === 'link' ? /*#__PURE__*/jsxRuntimeExports.jsx("a", {
88870
+ href: seg.href,
88871
+ target: "_blank",
88872
+ rel: "noopener noreferrer",
88873
+ style: LINK_STYLE,
88874
+ children: seg.label
88875
+ }, key++) : seg.value);
88601
88876
  }
88602
88877
 
88603
88878
  // Kept in its own tiny module (not A2uiBlock.jsx): MessageList imports it
@@ -88611,14 +88886,6 @@
88611
88886
  // "Confirm booking". Context rather than a prop so it does not have to be
88612
88887
  // threaded through MessageBubble -> StreamingMarkdown -> MarkdownRenderer.
88613
88888
  const A2uiAnsweredContext = /*#__PURE__*/React.createContext(undefined);
88614
- const _normAnswer = v => String(v ?? '').trim().toLowerCase();
88615
- function matchAnsweredChoice(choices, answer) {
88616
- if (answer === undefined || answer === null) return null;
88617
- const a = _normAnswer(answer);
88618
- if (!a) return null;
88619
- const hit = (choices || []).find(c => _normAnswer(c?.value) === a || _normAnswer(c?.label) === a);
88620
- return hit ? String(hit.value ?? hit.label ?? '') : null;
88621
- }
88622
88889
 
88623
88890
  /**
88624
88891
  * A2uiBlock - render `eshal-ui` / `eshal-chart` / `chart` fence JSON as a
@@ -88647,6 +88914,10 @@
88647
88914
  */
88648
88915
 
88649
88916
 
88917
+ // The chart-family types the dispatcher routes to <Chart>, from the shared
88918
+ // A2UI registry (EV-4798 MERN-19).
88919
+ const CHART_TYPES = new Set(A2UI_CHART_TYPES);
88920
+
88650
88921
  // Card images (banner, list item, avatar, gallery) go through the chat image
88651
88922
  // policy too. Unchanged while it is off, which is the default.
88652
88923
  const cardImageSrc = u => policedImageSrc(safeUrl(u));
@@ -88667,10 +88938,10 @@
88667
88938
  };
88668
88939
  // Normalize underscore_case → kebab-case so the agent can emit either
88669
88940
  // form. Both `key_observations` and `key-observations` route here.
88670
- const t = String(spec.type || '').trim().toLowerCase().replace(/_/g, '-');
88941
+ const t = normalizeA2uiType(spec.type);
88671
88942
 
88672
- // Charts (incl. graceful fallback for chart-shaped data with unknown type)
88673
- const CHART_TYPES = new Set(['bar', 'line', 'area', 'pie', 'donut', 'scatter', 'bubble', 'radar', 'radial-bar', 'radial', 'radialbar', 'treemap', 'funnel', 'sankey', 'composed', 'waterfall', 'gauge', 'histogram', 'kpi']);
88943
+ // Charts (incl. graceful fallback for chart-shaped data with unknown type).
88944
+ // CHART_TYPES is the shared chat core's registry (module scope, below the imports).
88674
88945
 
88675
88946
  // ── On-demand analytical insight cards (12-06-2026) ──
88676
88947
  // Six types the database_query agent emits when the user explicitly
@@ -89507,36 +89778,43 @@
89507
89778
  }
89508
89779
  if (field.type === 'radio') {
89509
89780
  const opts = Array.isArray(field.options) ? field.options : [];
89510
- return /*#__PURE__*/jsxRuntimeExports.jsxs("div", {
89511
- style: {
89512
- marginBottom: 10
89513
- },
89514
- children: [/*#__PURE__*/jsxRuntimeExports.jsxs("div", {
89515
- style: labelStyle,
89516
- children: [field.label, field.required ? ' *' : '']
89517
- }), opts.map(o => /*#__PURE__*/jsxRuntimeExports.jsxs("label", {
89781
+ return (
89782
+ /*#__PURE__*/
89783
+ // EV-4798 (MERN-21): the visible label names the radio group.
89784
+ jsxRuntimeExports.jsxs("div", {
89518
89785
  style: {
89519
- display: 'flex',
89520
- alignItems: 'center',
89521
- gap: 6,
89522
- fontSize: 12,
89523
- marginBottom: 2
89524
- },
89525
- children: [/*#__PURE__*/jsxRuntimeExports.jsx("input", {
89526
- type: "radio",
89527
- name: autoId,
89528
- checked: value === o.value,
89529
- onChange: () => onChange(o.value)
89530
- }), o.label]
89531
- }, o.value)), error && /*#__PURE__*/jsxRuntimeExports.jsx("div", {
89532
- style: {
89533
- fontSize: 11,
89534
- color: '#ef4444',
89535
- marginTop: 2
89536
- },
89537
- children: error
89538
- })]
89539
- });
89786
+ marginBottom: 10
89787
+ },
89788
+ role: "radiogroup",
89789
+ "aria-labelledby": `${autoId}-label`,
89790
+ children: [/*#__PURE__*/jsxRuntimeExports.jsxs("div", {
89791
+ id: `${autoId}-label`,
89792
+ style: labelStyle,
89793
+ children: [field.label, field.required ? ' *' : '']
89794
+ }), opts.map(o => /*#__PURE__*/jsxRuntimeExports.jsxs("label", {
89795
+ style: {
89796
+ display: 'flex',
89797
+ alignItems: 'center',
89798
+ gap: 6,
89799
+ fontSize: 12,
89800
+ marginBottom: 2
89801
+ },
89802
+ children: [/*#__PURE__*/jsxRuntimeExports.jsx("input", {
89803
+ type: "radio",
89804
+ name: autoId,
89805
+ checked: value === o.value,
89806
+ onChange: () => onChange(o.value)
89807
+ }), o.label]
89808
+ }, o.value)), error && /*#__PURE__*/jsxRuntimeExports.jsx("div", {
89809
+ style: {
89810
+ fontSize: 11,
89811
+ color: '#ef4444',
89812
+ marginTop: 2
89813
+ },
89814
+ children: error
89815
+ })]
89816
+ })
89817
+ );
89540
89818
  }
89541
89819
  if (field.type === 'textarea') {
89542
89820
  return /*#__PURE__*/jsxRuntimeExports.jsxs("div", {
@@ -89769,7 +90047,14 @@
89769
90047
  if (sentRef.current || locked) return;
89770
90048
  sentRef.current = true;
89771
90049
  setLocalChosen(String(value));
89772
- onButtonClick(value, label);
90050
+ // EV-4798 MERN-12: a card that carries the held call's id is also
90051
+ // answered by that id (utils/toolConfirmation.js); the value text
90052
+ // still goes as the message either way.
90053
+ const choice = buttons.find(b => String(b?.value || b?.label || '') === String(value));
90054
+ const toolConfirmation = toolConfirmationFor(spec, choice);
90055
+ if (toolConfirmation) onButtonClick(value, label, {
90056
+ toolConfirmation
90057
+ });else onButtonClick(value, label);
89773
90058
  } : undefined;
89774
90059
  return /*#__PURE__*/jsxRuntimeExports.jsxs("div", {
89775
90060
  style: card(isDark),
@@ -90525,46 +90810,14 @@
90525
90810
  }
90526
90811
 
90527
90812
  // ── Bug 1 + Bug 3 helpers (2026-06-16) ───────────────────────────────────
90528
- // PORTED from packages/frontend/src/components/charts/eshal-chart/
90529
- // chartShared.tsx. Duplicated rather than shared because the widget is
90530
- // bundle-size-conscious (a separate package import here would pull a
90531
- // chunk of the frontend with it). The authoritative tests live with
90532
- // the frontend copy in __tests__/chartShared.test.ts - keep this code
90533
- // in lockstep with that file when either is touched. Regex boundaries
90534
- // use `(^|[_\W])` instead of `\b` because JS regex `\b` doesn't fire
90535
- // next to `_` (underscore is a word char), so "total_expenses" with
90536
- // `\bexpense\b` would miss the match.
90813
+ // Chart value and axis-tick formatting, ported from packages/frontend/src/
90814
+ // components/charts/eshal-chart/chartShared.tsx. The label and currency-key
90815
+ // helpers they build on (prettifyLabel, isCurrencyKey) are the shared chat
90816
+ // core's (EV-4798 MERN-19); the chart-only rules below stay with the widget.
90537
90817
 
90538
90818
  const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
90539
- const CURRENCY_KEY_RE = /(^|[_\W])(price|salary|cost|amount|revenue|expense|total|value|budget|spend|income|sale|balance)/i;
90540
90819
  const MONTH_KEY_RE = /(^|[_\W])month/i;
90541
90820
 
90542
- // Only a column-style value is reworded: one with an underscore
90543
- // (office_supplies), one without capitals (kebab-case, all-lower words)
90544
- // or a camelCase key. A value written with capitals is a name and stays as
90545
- // written ("DD Sports", "Republic TV", "GEC"), as does a word written with
90546
- // capitals inside a key (GRP_pre -> "GRP Pre"). Lower-casing every word but
90547
- // its first letter read those channels as "Dd Sports", "Republic Tv" and
90548
- // "Gec" (Media Prowess, Oct 2026).
90549
- function prettifyLabel(value) {
90550
- if (value === null || value === undefined) return '';
90551
- const s = String(value).replace(/\s+/g, ' ').trim();
90552
- if (!s) return '';
90553
- if (!isColumnStyle(s)) return s;
90554
- return s.replace(/[_\-]+/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/\s+/g, ' ').trim().split(' ').map(w => w === w.toLowerCase() ? w.charAt(0).toUpperCase() + w.slice(1) : w).join(' ');
90555
- }
90556
-
90557
- // A camelCase key ("camelCaseKey", "totalSpend"): two lower-case letters
90558
- // before the first capital, so names such as "iPhone", "eBay" and "kWh"
90559
- // stay as written.
90560
- const CAMEL_CASE_KEY_RE = /^[a-z]{2}[a-z0-9]*[A-Z][A-Za-z0-9]*$/;
90561
- function isColumnStyle(s) {
90562
- return s.includes('_') || s === s.toLowerCase() || CAMEL_CASE_KEY_RE.test(s);
90563
- }
90564
- function isCurrencyKey(key) {
90565
- return typeof key === 'string' && CURRENCY_KEY_RE.test(key);
90566
- }
90567
-
90568
90821
  // ISO date / datetime prefix - matches the typical SQL output the
90569
90822
  // agent's tools produce when GROUP BY-ing by month
90570
90823
  // (`DATE_TRUNC('month', col)` → `YYYY-MM-DD`, or `to_char(..,'YYYY-MM')`
@@ -90597,80 +90850,25 @@
90597
90850
  return prettifyLabel(value);
90598
90851
  }
90599
90852
 
90600
- // Conservative snake_case detector for table cell category labels
90601
- // (2026-06-19, EV-1187 follow-up). Mirrors the admin UI's
90602
- // `prettifyCategoryCell` in `packages/frontend/.../chartShared.tsx` and
90603
- // the SDK's copy. Self-contained because the widget can't reach across
90604
- // package boundaries.
90605
- //
90606
- // Goal: when a category column shows `office_supplies` next to a chart
90607
- // whose X-axis (via prettifyLabel) already renders "Office Supplies",
90608
- // the table should agree. Conservative - only lowercase ASCII tokens
90609
- // joined by underscores (≤ 40 chars) qualify, so IDs/paths/emails/
90610
- // slugs/camelCase pass through verbatim.
90611
- const SNAKE_CASE_CATEGORY_RE = /^[a-z]+(_[a-z]+)+$/;
90612
- function prettifyCategoryCell(value) {
90613
- if (typeof value !== 'string') return String(value ?? '');
90614
- if (value.length > 40) return value;
90615
- if (!SNAKE_CASE_CATEGORY_RE.test(value)) return value;
90616
- return prettifyLabel(value);
90617
- }
90618
-
90619
- // Numeric string under a money column, e.g. "81,200" / "105444" / "-1,250.50".
90620
- // Table-cell formatting, kept IN LOCKSTEP with the Testing-Lab (frontend
90621
- // chartShared.tsx: formatDateCell / formatCurrencyCellString) and SDK copies so
90622
- // all three surfaces render DB tables identically. Two rules:
90623
- // 1. ISO date/datetime → "Dec 9, 2021" (parity with Testing Lab).
90624
- // 2. Bare numeric string in a money-named column → comma-group, and NEVER
90625
- // synthesize a currency symbol - the DB is the source of truth for "$".
90626
- // A value that already carries a symbol ("$71000", "₹560") fails
90627
- // _BARE_NUMBER_RE and is passed through verbatim, so we don't double-format.
90628
- const _BARE_NUMBER_RE = /^-?\d+(\.\d+)?$/;
90629
- const _MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
90630
- const _ISO_DATETIME_CELL_RE = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/;
90631
- function _formatDateCell(value) {
90632
- if (typeof value !== 'string') return null;
90633
- const trimmed = value.trim();
90634
- if (!_ISO_DATETIME_CELL_RE.test(trimmed)) return null;
90635
- // A date-time string with no trailing 'Z'/offset is parsed by `new
90636
- // Date()` as LOCAL time (ECMA-262), but we read it back below via UTC
90637
- // getters - so a timezone-less value must be normalized to UTC first,
90638
- // or a non-UTC runtime renders the wrong calendar day/time (EV-2783
90639
- // follow-up). Date-only strings (no time part) are already parsed as
90640
- // UTC midnight per spec and need no change.
90641
- const hasTime = /[T ]\d{2}:\d{2}/.test(trimmed);
90642
- const hasTz = /(Z|[+-]\d{2}:?\d{2})$/.test(trimmed);
90643
- const parseable = hasTime && !hasTz ? `${trimmed.replace(' ', 'T')}Z` : trimmed;
90644
- const d = new Date(parseable);
90645
- if (isNaN(d.getTime())) return null;
90646
- const hasNonMidnightTime = hasTime && (d.getUTCHours() !== 0 || d.getUTCMinutes() !== 0 || d.getUTCSeconds() !== 0);
90647
- const dateStr = `${_MONTH_NAMES[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
90648
- if (!hasNonMidnightTime) return dateStr;
90649
- const h = d.getUTCHours();
90650
- const ampm = h >= 12 ? 'PM' : 'AM';
90651
- const h12 = h % 12 || 12;
90652
- const mm = String(d.getUTCMinutes()).padStart(2, '0');
90653
- return `${dateStr}, ${h12}:${mm} ${ampm}`;
90654
- }
90655
- function _commaGroup(n) {
90656
- return Number.isInteger(n) ? n.toLocaleString('en-US') : Number(n.toFixed(2)).toLocaleString('en-US');
90657
- }
90853
+ // Table cells: the date, money-string and snake_case-category rules are the
90854
+ // shared chat core's (packages/shared/src/chatCore/cellFormat.js, EV-4798
90855
+ // MERN-19), so Testing Lab, the widget and the SDK render DB tables
90856
+ // identically by construction. formatCell keeps the widget's own handling of
90857
+ // raw numbers and objects.
90658
90858
  function formatCell(v, columnKey) {
90659
90859
  if (v == null) return '';
90660
90860
  if (typeof v === 'number') {
90661
90861
  // Comma-group; NEVER synthesize a currency symbol (DB owns symbols).
90662
- return _commaGroup(v);
90862
+ return commaGroup(v);
90663
90863
  }
90664
90864
  if (typeof v === 'object') return JSON.stringify(v);
90665
90865
  const s = String(v).trim();
90666
90866
  // 1. ISO date/datetime → friendly date.
90667
- const dateCell = _formatDateCell(s);
90867
+ const dateCell = formatDateCell(s);
90668
90868
  if (dateCell) return dateCell;
90669
90869
  // 2. Bare numeric string in a money column → comma-group, NO "$".
90670
- if (isCurrencyKey(columnKey) && _BARE_NUMBER_RE.test(s)) {
90671
- const n = Number(s);
90672
- if (Number.isFinite(n)) return _commaGroup(n);
90673
- }
90870
+ const money = formatCurrencyCellString(s, columnKey);
90871
+ if (money !== null) return money;
90674
90872
  // 3. Everything else: prettify snake_case labels; IDs/paths/emails/slugs and
90675
90873
  // any DB value already carrying a symbol pass through verbatim.
90676
90874
  return prettifyCategoryCell(v);
@@ -91648,48 +91846,46 @@
91648
91846
  }
91649
91847
 
91650
91848
  /**
91651
- * Parse an assistant message body into a mix of TEXT and CITATION-CLUSTER
91652
- * segments, so the renderer can swap each cluster for an inline
91653
- * `<CitationPill>` while leaving prose intact.
91849
+ * Inline citation core shared by every chat renderer (EV-4798 MERN-19).
91654
91850
  *
91655
- * Cluster rule: consecutive `[N]` markers separated only by whitespace
91656
- * (`[1][2]`, `[1] [2]`, `[1,3]` - same intent) collapse into ONE cluster.
91657
- * A break in-between (`[1] some text [2]`) yields two separate clusters.
91851
+ * Before this file the same logic lived three times - in the agent-sdk, the
91852
+ * widget and the frontend Testing Lab - each copy headed "keep in sync". It
91853
+ * owns everything about an inline `[N]` citation that is not presentation:
91854
+ *
91855
+ * - parseCitationClusters: assistant text -> text and citation-cluster
91856
+ * segments (cluster rule, Devanagari digits, punctuation reorder).
91857
+ * - rehypeCitations: the rehype plugin that swaps `[N]` markers in HAST
91858
+ * text nodes for `<span data-eshal-citation="1,3">` placeholders.
91859
+ * - the CitationPill's source resolution: which source cards a cluster
91860
+ * points at, de-duplicated, plus the pill's label / file / key rules.
91658
91861
  *
91659
- * Preserves Markdown links `[label](url)` - the negative lookahead in
91660
- * the regex skips any `]` immediately followed by `(`.
91862
+ * Each surface keeps its own CitationPill component (lucide icons on the
91863
+ * widget, inline SVG in the SDK, Tailwind in the frontend). The three pills
91864
+ * historically read source ids from slightly different fields (`source_id`
91865
+ * vs `sourceid`), so the id-reading helpers take the surface's field list
91866
+ * rather than silently unifying it - unifying would change which sources a
91867
+ * surface treats as files.
91661
91868
  *
91662
- * DUPLICATED across three surfaces - copies live at:
91663
- * * packages/frontend/src/lib/messages/parseCitationClusters.ts
91664
- * * packages/widget/src/utils/parseCitationClusters.js (this file)
91665
- * * packages/agent-sdk/src/utils/parseCitationClusters.ts
91666
- * Regex + cluster rule kept byte-identical across all three; a parity
91667
- * test asserts the same input → same segment list on every surface.
91869
+ * Plain JavaScript with no imports, on purpose: the widget's Rollup/Babel
91870
+ * build compiles no TypeScript from another package, and the published SDK
91871
+ * and widget bundle this file directly. Types live in citations.d.ts.
91872
+ * Conservative syntax (no `??`, no `?.`): files from another package are not
91873
+ * run through the widget's Babel preset, so they reach its bundle as written.
91668
91874
  */
91669
91875
 
91670
- // Latin `[N]` / `[N,M]` markers, plus Devanagari digits `[१]` / `[१,२]`.
91671
- // The negative lookahead `(?!\()` excludes Markdown link syntax
91672
- // `[label](url)`.
91876
+ // Latin `[N]` / `[N,M]` markers, plus Devanagari digits. The negative
91877
+ // lookahead `(?!\()` excludes Markdown link syntax `[label](url)`.
91673
91878
  const MARKER_RE = /\[([\d०-९][\d०-९\s,]*)\](?!\()/g;
91674
- const DEV_DIGIT_MAP = {
91675
- "०": "0",
91676
- "१": "1",
91677
- "२": "2",
91678
- "३": "3",
91679
- "४": "4",
91680
- "५": "5",
91681
- "६": "6",
91682
- "७": "7",
91683
- "८": "8",
91684
- "९": "9"
91685
- };
91686
91879
  function normalizeDigits(s) {
91687
- return s.replace(/[०-९]/g, d => DEV_DIGIT_MAP[d] ?? d);
91880
+ return s.replace(/[०-९]/g, function (d) {
91881
+ return String(d.charCodeAt(0) - 0x0966);
91882
+ });
91688
91883
  }
91689
91884
  function parseIndicesInside(inner) {
91690
91885
  const out = [];
91691
- for (const part of normalizeDigits(inner).split(",")) {
91692
- const t = part.trim();
91886
+ const parts = normalizeDigits(inner).split(',');
91887
+ for (let i = 0; i < parts.length; i += 1) {
91888
+ const t = parts[i].trim();
91693
91889
  if (/^\d+$/.test(t)) {
91694
91890
  const n = parseInt(t, 10);
91695
91891
  if (n >= 1) out.push(n);
@@ -91700,7 +91896,8 @@
91700
91896
  function dedupPreserveOrder(nums) {
91701
91897
  const seen = new Set();
91702
91898
  const out = [];
91703
- for (const n of nums) {
91899
+ for (let i = 0; i < nums.length; i += 1) {
91900
+ const n = nums[i];
91704
91901
  if (!seen.has(n)) {
91705
91902
  seen.add(n);
91706
91903
  out.push(n);
@@ -91716,8 +91913,18 @@
91716
91913
  // Markdown links `[label](url)` untouched.
91717
91914
  const CITE_PUNCT_RE = /(\s*)((?:\[[\d०-९][\d०-९\s,]*\](?!\()\s*)+)([.!?])/g;
91718
91915
  function reorderCitationPunctuation(text) {
91719
- return text.replace(CITE_PUNCT_RE, (_all, _lead, cluster, punct) => punct + " " + cluster.replace(/\s+$/, ""));
91916
+ return text.replace(CITE_PUNCT_RE, function (_all, _lead, cluster, punct) {
91917
+ return punct + ' ' + cluster.replace(/\s+$/, '');
91918
+ });
91720
91919
  }
91920
+
91921
+ /**
91922
+ * Parse an assistant message body into TEXT and CITATION-CLUSTER segments.
91923
+ *
91924
+ * Cluster rule: consecutive `[N]` markers separated only by whitespace
91925
+ * (`[1][2]`, `[1] [2]`, `[1,3]` - same intent) collapse into ONE cluster.
91926
+ * A break in-between (`[1] some text [2]`) yields two separate clusters.
91927
+ */
91721
91928
  function parseCitationClusters(text) {
91722
91929
  if (!text) return [];
91723
91930
  text = reorderCitationPunctuation(text);
@@ -91730,122 +91937,228 @@
91730
91937
  markers.push({
91731
91938
  start: m.index,
91732
91939
  end: MARKER_RE.lastIndex,
91733
- indices
91940
+ indices: indices
91734
91941
  });
91735
91942
  }
91736
91943
  if (markers.length === 0) {
91737
91944
  return [{
91738
- type: "text",
91945
+ type: 'text',
91739
91946
  value: text
91740
91947
  }];
91741
91948
  }
91742
91949
 
91743
- // Merge markers that are separated only by whitespace into a single
91744
- // cluster. `[1][2]`, `[1] [2]`, and `[1,2]` all read as one citation
91745
- // cluster attached to the preceding sentence.
91950
+ // Merge markers that are separated only by whitespace into one cluster.
91746
91951
  const clusters = [];
91747
- for (const cur of markers) {
91952
+ for (let i = 0; i < markers.length; i += 1) {
91953
+ const cur = markers[i];
91748
91954
  const last = clusters.length ? clusters[clusters.length - 1] : null;
91749
91955
  if (last && /^\s*$/.test(text.slice(last.end, cur.start))) {
91750
91956
  last.end = cur.end;
91751
- last.indices.push(...cur.indices);
91957
+ for (let j = 0; j < cur.indices.length; j += 1) last.indices.push(cur.indices[j]);
91752
91958
  } else {
91753
91959
  clusters.push({
91754
91960
  start: cur.start,
91755
91961
  end: cur.end,
91756
- indices: [...cur.indices]
91962
+ indices: cur.indices.slice()
91757
91963
  });
91758
91964
  }
91759
91965
  }
91760
91966
  const out = [];
91761
91967
  let cursor = 0;
91762
- for (const c of clusters) {
91968
+ for (let i = 0; i < clusters.length; i += 1) {
91969
+ const c = clusters[i];
91763
91970
  if (c.start > cursor) {
91764
91971
  out.push({
91765
- type: "text",
91972
+ type: 'text',
91766
91973
  value: text.slice(cursor, c.start)
91767
91974
  });
91768
91975
  }
91769
91976
  out.push({
91770
- type: "cite",
91977
+ type: 'cite',
91771
91978
  indices: dedupPreserveOrder(c.indices)
91772
91979
  });
91773
91980
  cursor = c.end;
91774
91981
  }
91775
91982
  if (cursor < text.length) {
91776
91983
  out.push({
91777
- type: "text",
91984
+ type: 'text',
91778
91985
  value: text.slice(cursor)
91779
91986
  });
91780
91987
  }
91781
91988
  return out;
91782
91989
  }
91990
+
91991
+ /** `[1,3]` -> `"1,3"`, the wire format of the `data-eshal-citation` attribute. */
91783
91992
  function serializeCitationIndices(indices) {
91784
- return indices.join(",");
91993
+ return indices.join(',');
91785
91994
  }
91995
+
91996
+ /** `"1,3"` -> `[1, 3]`; invalid and repeated entries dropped. */
91786
91997
  function parseCitationIndicesFromAttr(attr) {
91787
91998
  if (!attr) return [];
91788
91999
  const out = [];
91789
- for (const t of String(attr).split(",")) {
91790
- const n = parseInt(t.trim(), 10);
92000
+ const parts = String(attr).split(',');
92001
+ for (let i = 0; i < parts.length; i += 1) {
92002
+ const n = parseInt(parts[i].trim(), 10);
91791
92003
  if (Number.isFinite(n) && n >= 1) out.push(n);
91792
92004
  }
91793
92005
  return dedupPreserveOrder(out);
91794
92006
  }
92007
+ function isCodeParent(parent) {
92008
+ return parent.type === 'element' && (parent.tagName === 'code' || parent.tagName === 'pre');
92009
+ }
92010
+
92011
+ // Replace the text node at `parent.children[index]` with its citation
92012
+ // segments. Returns the index to continue from.
92013
+ function replaceCitationText(node, index, parent) {
92014
+ // Text inside <code> / <pre> is not a citation (someone showing example
92015
+ // markdown).
92016
+ if (isCodeParent(parent)) return index + 1;
92017
+ const segments = parseCitationClusters(node.value);
92018
+ // No markers found - leave the text node untouched.
92019
+ if (segments.length === 1 && segments[0].type === 'text') return index + 1;
92020
+
92021
+ // 2026-07-14 - glue the pill to its preceding word: a regular space right
92022
+ // before a cite segment becomes a non-breaking space, so the pill never
92023
+ // wraps alone onto a new line.
92024
+ const replacements = segments.map(function (seg, i) {
92025
+ if (seg.type === 'text') {
92026
+ const nextIsCite = i + 1 < segments.length && segments[i + 1].type === 'cite';
92027
+ const value = nextIsCite ? seg.value.replace(/[ \t]+$/, ' ') : seg.value;
92028
+ return {
92029
+ type: 'text',
92030
+ value: value
92031
+ };
92032
+ }
92033
+ return {
92034
+ type: 'element',
92035
+ tagName: 'span',
92036
+ properties: {
92037
+ dataEshalCitation: serializeCitationIndices(seg.indices)
92038
+ },
92039
+ children: []
92040
+ };
92041
+ });
92042
+ parent.children.splice.apply(parent.children, [index, 1].concat(replacements));
92043
+ return index + replacements.length;
92044
+ }
92045
+ function walkCitationText(parent) {
92046
+ const children = parent.children;
92047
+ if (!Array.isArray(children)) return;
92048
+ let i = 0;
92049
+ while (i < children.length) {
92050
+ const node = children[i];
92051
+ if (node && node.type === 'text') {
92052
+ i = replaceCitationText(node, i, parent);
92053
+ continue;
92054
+ }
92055
+ if (node && Array.isArray(node.children)) walkCitationText(node);
92056
+ i += 1;
92057
+ }
92058
+ }
91795
92059
 
91796
92060
  /**
91797
- * rehype plugin - walks HAST text nodes emitted by remark and replaces
91798
- * `[N]` citation markers with `<span data-eshal-citation="1,3">`
91799
- * placeholders. A `components.span` override in MessageBubble swaps
91800
- * each placeholder for a `<CitationPill>` React node at render time.
92061
+ * rehype plugin: replaces `[N]` citation markers in HAST text nodes with
92062
+ * `<span data-eshal-citation="1,3">` placeholders, which each surface's
92063
+ * `components.span` override swaps for its CitationPill.
91801
92064
  *
91802
- * Why a HAST plugin and not a pre-ReactMarkdown string transform:
91803
- * Markdown spans (bold, italic, links) can wrap the marker text -
91804
- * pre-splitting the raw string breaks formatting boundaries. HAST-time
91805
- * replacement only touches text nodes, so `**bold [1]**` stays one
91806
- * bold span with the pill nested at the right place.
92065
+ * Why a HAST plugin and not a string transform: Markdown spans (bold, links)
92066
+ * can wrap the marker - HAST-time replacement only touches text nodes, so
92067
+ * `**bold [1]**` stays one bold span with the pill nested inside it.
91807
92068
  *
91808
- * Ported byte-for-byte from packages/agent-sdk/src/utils/rehypeCitations.ts -
91809
- * keep in sync.
92069
+ * Dependency-free tree walk (no unist-util-visit) so the shared file has no
92070
+ * imports. Same order and the same skip-past-the-replacements rule.
91810
92071
  */
91811
92072
  function rehypeCitations() {
91812
- return tree => {
91813
- visit(tree, "text", (node, index, parent) => {
91814
- if (index == null || parent == null) return;
91815
- // Skip text inside <code> / <pre> - markers there are not
91816
- // citations (someone's showing example markdown).
91817
- if (parent.type === "element" && (parent.tagName === "code" || parent.tagName === "pre")) {
91818
- return;
92073
+ return function (tree) {
92074
+ if (tree) walkCitationText(tree);
92075
+ };
92076
+ }
92077
+
92078
+ // ── CitationPill source resolution ─────────────────────────────────────
92079
+
92080
+ function firstDefined(src, fields) {
92081
+ for (let i = 0; i < fields.length; i += 1) {
92082
+ const v = src[fields[i]];
92083
+ if (v !== null && v !== undefined) return v;
92084
+ }
92085
+ return undefined;
92086
+ }
92087
+ function firstTruthy(src, fields) {
92088
+ for (let i = 0; i < fields.length; i += 1) {
92089
+ const v = src[fields[i]];
92090
+ if (v) return v;
92091
+ }
92092
+ return undefined;
92093
+ }
92094
+
92095
+ /** Percent-decoded source name; the raw value when it is not valid encoding. */
92096
+ function decodeSourceName(name) {
92097
+ if (!name) return '';
92098
+ try {
92099
+ return decodeURIComponent(String(name));
92100
+ } catch (e) {
92101
+ return String(name);
92102
+ }
92103
+ }
92104
+
92105
+ /** What a pill or popover row shows for a source. */
92106
+ function citationDisplayLabel(src, fallbackIndex) {
92107
+ return decodeSourceName(src.source_name) || src.url || src.source_type || 'Source ' + fallbackIndex;
92108
+ }
92109
+
92110
+ /**
92111
+ * Is this source a file (as opposed to a web page)? `idFields` are the id
92112
+ * fields the surface reads: a source with an id and no url is a file.
92113
+ */
92114
+ function isFileLikeSource$1(src, idFields) {
92115
+ if (!src) return false;
92116
+ const url = src.url;
92117
+ return src.source_type === 'file' || typeof url === 'string' && url.indexOf('/api/v1/sharepoint/file/') !== -1 || !!firstTruthy(src, idFields) && !url;
92118
+ }
92119
+
92120
+ /** The key a surface's download state (`downloadingKey`) is matched on. */
92121
+ function citationSourceKey(src, idFields) {
92122
+ return src.url || firstTruthy(src, idFields) || src.source_name || '';
92123
+ }
92124
+
92125
+ /**
92126
+ * Resolve a cluster's 1-based indices to source cards.
92127
+ *
92128
+ * Prefers each source's backend-emitted `citation_indices` (keeps the LLM's
92129
+ * original chunk position through filter, dedup and ACL), falling back to the
92130
+ * array position for older backends. Out-of-range indices are skipped so a
92131
+ * hallucinated `[99]` cannot blank the pill. Sources are de-duplicated by
92132
+ * identity - url first (unique per web page), then the id fields, then the
92133
+ * name - keeping the first index as the display anchor.
92134
+ */
92135
+ function resolveCitedSources(sources, indices, idFields) {
92136
+ const out = [];
92137
+ if (!Array.isArray(sources)) return out;
92138
+ const keyFields = ['url'].concat(idFields, ['source_name']);
92139
+ const seen = new Set();
92140
+ for (let i = 0; i < indices.length; i += 1) {
92141
+ const n = indices[i];
92142
+ let src;
92143
+ for (let j = 0; j < sources.length; j += 1) {
92144
+ const s = sources[j];
92145
+ if (s && Array.isArray(s.citation_indices) && s.citation_indices.indexOf(n) !== -1) {
92146
+ src = s;
92147
+ break;
91819
92148
  }
91820
- const segments = parseCitationClusters(node.value);
91821
- // No markers found - leave the text node untouched.
91822
- if (segments.length === 1 && segments[0].type === "text") return;
91823
-
91824
- // 2026-07-14 - glue pill to preceding word (non-breaking space)
91825
- // so HTML doesn't wrap the pill alone onto a new line. Preserves
91826
- // the "sentence ending [pill]" reading flow.
91827
- const replacements = segments.map((seg, i) => {
91828
- if (seg.type === "text") {
91829
- const nextIsCite = i + 1 < segments.length && segments[i + 1].type === "cite";
91830
- const value = nextIsCite ? seg.value.replace(/[ \t]+$/, "\u00a0") : seg.value;
91831
- return {
91832
- type: "text",
91833
- value
91834
- };
91835
- }
91836
- return {
91837
- type: "element",
91838
- tagName: "span",
91839
- properties: {
91840
- dataEshalCitation: serializeCitationIndices(seg.indices)
91841
- },
91842
- children: []
91843
- };
91844
- });
91845
- parent.children.splice(index, 1, ...replacements);
91846
- return [SKIP, index + replacements.length];
92149
+ }
92150
+ if (src === undefined) src = sources[n - 1];
92151
+ if (!src) continue;
92152
+ const raw = firstDefined(src, keyFields);
92153
+ const key = String(raw === undefined ? '' : raw).toLowerCase();
92154
+ if (key && seen.has(key)) continue;
92155
+ if (key) seen.add(key);
92156
+ out.push({
92157
+ src: src,
92158
+ index: n
91847
92159
  });
91848
- };
92160
+ }
92161
+ return out;
91849
92162
  }
91850
92163
 
91851
92164
  /**
@@ -91952,25 +92265,17 @@
91952
92265
  return Boolean(source.source_id);
91953
92266
  }
91954
92267
 
91955
- function decodeName(name) {
91956
- if (!name) return "";
91957
- try {
91958
- return decodeURIComponent(String(name));
91959
- } catch {
91960
- return String(name);
91961
- }
91962
- }
92268
+ // Label, file and key rules and the cluster -> source resolution are the
92269
+ // shared chat core's (packages/shared/src/chatCore/citations.js, EV-4798
92270
+ // MERN-19), shared with the SDK and frontend pills. The widget reads
92271
+ // `source_id` for "is this a file" and both id spellings for identity.
92272
+ const FILE_ID_FIELDS = ["source_id"];
92273
+ const KEY_ID_FIELDS = ["source_id", "sourceid"];
91963
92274
  function isFileLikeSource(s) {
91964
- if (!s) return false;
91965
- const src_type = s.source_type;
91966
- const url = s.url;
91967
- return src_type === "file" || typeof url === "string" && url.includes("/api/v1/sharepoint/file/") || !!s.source_id && !url;
91968
- }
91969
- function displayLabel(s, fallbackIndex) {
91970
- return decodeName(s.source_name) || s.url || s.source_type || `Source ${fallbackIndex}`;
92275
+ return isFileLikeSource$1(s, FILE_ID_FIELDS);
91971
92276
  }
91972
92277
  function pillKey(s) {
91973
- return s.url || s.source_id || s.sourceid || s.source_name || "";
92278
+ return citationSourceKey(s, KEY_ID_FIELDS);
91974
92279
  }
91975
92280
  function CitationPill({
91976
92281
  indices
@@ -92002,26 +92307,7 @@
92002
92307
  // FIRST index encountered as the display anchor.
92003
92308
  const referenced = React.useMemo(() => {
92004
92309
  if (!ctx || !Array.isArray(ctx.sources)) return [];
92005
- const out = [];
92006
- const seen = new Set();
92007
- for (const n of indices) {
92008
- const byCitationIndex = ctx.sources.find(s => Array.isArray(s?.citation_indices) && s.citation_indices.includes(n));
92009
- const src = byCitationIndex ?? ctx.sources[n - 1];
92010
- if (!src) continue;
92011
- // 2026-07-15 - prioritise URL (unique per web page) over
92012
- // sourceid (shared across every page of the same Knowledge row).
92013
- // Website-connector sources emit distinct URLs for distinct
92014
- // pages but a single Knowledge row id - dedupping on sourceid
92015
- // first collapsed them to one row in the popover.
92016
- const key = String(src.url ?? src.source_id ?? src.sourceid ?? src.source_name ?? "").toLowerCase();
92017
- if (key && seen.has(key)) continue;
92018
- if (key) seen.add(key);
92019
- out.push({
92020
- src,
92021
- index: n
92022
- });
92023
- }
92024
- return out;
92310
+ return resolveCitedSources(ctx.sources, indices, KEY_ID_FIELDS);
92025
92311
  }, [ctx, indices]);
92026
92312
 
92027
92313
  // Close on Esc / outside click.
@@ -92141,7 +92427,7 @@
92141
92427
  }
92142
92428
  const first = referenced[0];
92143
92429
  const extraCount = referenced.length - 1;
92144
- const firstLabel = displayLabel(first.src, first.index);
92430
+ const firstLabel = citationDisplayLabel(first.src, first.index);
92145
92431
  // EV-4177: a card only gets the download affordance when a click will
92146
92432
  // actually produce a file. isFileLikeSource alone answers "is this a file",
92147
92433
  // not "can we fetch it" - which is how a download icon ended up on a source
@@ -92163,14 +92449,22 @@
92163
92449
  if (open) setOpen(false);else openPopover();
92164
92450
  };
92165
92451
  const iconSize = 12;
92452
+
92453
+ // EV-4798 (MERN-21): accessible names in the widget locale. `ctx.t` is
92454
+ // optional (a provider from an older bundle may not pass it), so each
92455
+ // string keeps its English form as the fallback.
92456
+ const tr = (key, english) => typeof ctx.t === "function" ? ctx.t(key) : english;
92457
+ const pillName = firstLabel + (extraCount ? ` (${tr("moreSources", "+{count} more").replace("{count}", String(extraCount))})` : "");
92166
92458
  return /*#__PURE__*/jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, {
92167
92459
  children: [/*#__PURE__*/jsxRuntimeExports.jsxs("span", {
92168
92460
  ref: anchorRef,
92169
92461
  role: "button",
92170
92462
  tabIndex: 0,
92171
92463
  dir: "ltr",
92172
- title: firstLabel + (extraCount ? ` (+${extraCount} more)` : ""),
92173
- "aria-label": firstLabel + (extraCount ? ` (+${extraCount} more)` : ""),
92464
+ title: pillName,
92465
+ "aria-label": pillName,
92466
+ "aria-haspopup": referenced.length > 1 ? "dialog" : undefined,
92467
+ "aria-expanded": referenced.length > 1 ? open : undefined,
92174
92468
  onClick: onPillClick,
92175
92469
  onKeyDown: e => {
92176
92470
  if (e.key === "Enter" || e.key === " ") {
@@ -92265,11 +92559,18 @@
92265
92559
  strokeWidth: 2
92266
92560
  })
92267
92561
  })]
92268
- }), open && pos && typeof document !== "undefined" && /*#__PURE__*/reactDomExports.createPortal(/*#__PURE__*/jsxRuntimeExports.jsxs("div", {
92562
+ }), open && pos && typeof document !== "undefined" && /*#__PURE__*/reactDomExports.createPortal(
92563
+ /*#__PURE__*/
92564
+ // The click/mousedown handlers below only stop propagation so the
92565
+ // bubble's own handlers do not see clicks inside the popover; the
92566
+ // dialog is not itself a click target, and Escape plus the Tab trap
92567
+ // are handled by the effect above.
92568
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events
92569
+ jsxRuntimeExports.jsxs("div", {
92269
92570
  ref: popoverRef,
92270
92571
  role: "dialog",
92271
92572
  dir: "ltr",
92272
- "aria-label": `${referenced.length} sources cited`,
92573
+ "aria-label": tr("sourcesCited", "{count} sources cited").replace("{count}", String(referenced.length)),
92273
92574
  style: {
92274
92575
  position: "absolute",
92275
92576
  top: pos.top,
@@ -92304,7 +92605,7 @@
92304
92605
  src,
92305
92606
  index
92306
92607
  }) => {
92307
- const label = displayLabel(src, index);
92608
+ const label = citationDisplayLabel(src, index);
92308
92609
  // EV-4177: same gate as firstIsFile above.
92309
92610
  const rowIsFile = isFileLikeSource(src) && canDownloadSource(src);
92310
92611
  const kBusy = ctx.downloadingKey && ctx.downloadingKey === pillKey(src);