@ews-admin/global-design-system 1.35.0 → 1.36.0

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.
Files changed (61) hide show
  1. package/dist/assistant/index.d.ts +5 -0
  2. package/dist/assistant/index.d.ts.map +1 -0
  3. package/dist/assistant/store.d.ts +17 -0
  4. package/dist/assistant/store.d.ts.map +1 -0
  5. package/dist/assistant/transport.d.ts +20 -0
  6. package/dist/assistant/transport.d.ts.map +1 -0
  7. package/dist/components/AssistantChat/AssistantChat.d.ts +15 -0
  8. package/dist/components/AssistantChat/AssistantChat.d.ts.map +1 -0
  9. package/dist/components/AssistantChat/AssistantChat.types.d.ts +173 -0
  10. package/dist/components/AssistantChat/AssistantChat.types.d.ts.map +1 -0
  11. package/dist/components/AssistantChat/AssistantComposer.d.ts +24 -0
  12. package/dist/components/AssistantChat/AssistantComposer.d.ts.map +1 -0
  13. package/dist/components/AssistantChat/AssistantConfirmationCard.d.ts +20 -0
  14. package/dist/components/AssistantChat/AssistantConfirmationCard.d.ts.map +1 -0
  15. package/dist/components/AssistantChat/AssistantConversationList.d.ts +19 -0
  16. package/dist/components/AssistantChat/AssistantConversationList.d.ts.map +1 -0
  17. package/dist/components/AssistantChat/AssistantMessages.d.ts +21 -0
  18. package/dist/components/AssistantChat/AssistantMessages.d.ts.map +1 -0
  19. package/dist/components/AssistantChat/AssistantSuggestionCard.d.ts +12 -0
  20. package/dist/components/AssistantChat/AssistantSuggestionCard.d.ts.map +1 -0
  21. package/dist/components/AssistantChat/assistantMarkdown.d.ts +14 -0
  22. package/dist/components/AssistantChat/assistantMarkdown.d.ts.map +1 -0
  23. package/dist/components/AssistantChat/index.d.ts +15 -0
  24. package/dist/components/AssistantChat/index.d.ts.map +1 -0
  25. package/dist/components/AssistantLauncher/AssistantLauncher.d.ts +58 -0
  26. package/dist/components/AssistantLauncher/AssistantLauncher.d.ts.map +1 -0
  27. package/dist/components/AssistantLauncher/index.d.ts +3 -0
  28. package/dist/components/AssistantLauncher/index.d.ts.map +1 -0
  29. package/dist/hooks/index.d.ts +2 -0
  30. package/dist/hooks/index.d.ts.map +1 -1
  31. package/dist/hooks/useSpeechRecognition.d.ts +40 -0
  32. package/dist/hooks/useSpeechRecognition.d.ts.map +1 -0
  33. package/dist/index.css +1 -1
  34. package/dist/index.d.ts +418 -2
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.esm.css +1 -1
  37. package/dist/index.esm.js +732 -2
  38. package/dist/index.esm.js.map +1 -1
  39. package/dist/index.js +751 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/utils/env-config.d.ts +3 -0
  42. package/dist/utils/env-config.d.ts.map +1 -1
  43. package/package.json +2 -1
  44. package/src/assistant/index.ts +11 -0
  45. package/src/assistant/store.ts +77 -0
  46. package/src/assistant/transport.ts +159 -0
  47. package/src/components/AssistantChat/AssistantChat.tsx +390 -0
  48. package/src/components/AssistantChat/AssistantChat.types.ts +171 -0
  49. package/src/components/AssistantChat/AssistantComposer.tsx +134 -0
  50. package/src/components/AssistantChat/AssistantConfirmationCard.tsx +58 -0
  51. package/src/components/AssistantChat/AssistantConversationList.tsx +85 -0
  52. package/src/components/AssistantChat/AssistantMessages.tsx +123 -0
  53. package/src/components/AssistantChat/AssistantSuggestionCard.tsx +48 -0
  54. package/src/components/AssistantChat/assistantMarkdown.tsx +105 -0
  55. package/src/components/AssistantChat/index.ts +25 -0
  56. package/src/components/AssistantLauncher/AssistantLauncher.tsx +408 -0
  57. package/src/components/AssistantLauncher/index.ts +2 -0
  58. package/src/hooks/index.ts +2 -0
  59. package/src/hooks/useSpeechRecognition.ts +95 -0
  60. package/src/index.ts +50 -0
  61. package/src/utils/env-config.ts +6 -0
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@ function r(e){var t,f,n="";if("string"==typeof e||"number"==typeof e)n+=e;else i
29
29
  const LOCAL_PORTS = {
30
30
  bff: 8082,
31
31
  loginBff: 8080,
32
+ assistantBff: 8083,
32
33
  app: 3000,
33
34
  login: 3001,
34
35
  dashboard: 3002,
@@ -42,6 +43,7 @@ function buildUrls(environment) {
42
43
  return {
43
44
  bffUrl: `http://api.${domain}:${LOCAL_PORTS.bff}/api/v1`,
44
45
  loginBffUrl: `http://api.${domain}:${LOCAL_PORTS.loginBff}/api/v1`,
46
+ assistantBffUrl: `http://api.${domain}:${LOCAL_PORTS.assistantBff}/api/v1`,
45
47
  appUrl: `http://local.app.${domain}:${LOCAL_PORTS.app}`,
46
48
  loginUrl: `http://local.login.${domain}:${LOCAL_PORTS.login}`,
47
49
  dashboardUrl: `http://local.dashboard.${domain}:${LOCAL_PORTS.dashboard}`,
@@ -53,6 +55,7 @@ function buildUrls(environment) {
53
55
  return {
54
56
  bffUrl: `https://${prefix}api.${domain}/bff/api/v1`,
55
57
  loginBffUrl: `https://${prefix}api.${domain}/login-bff/api/v1`,
58
+ assistantBffUrl: `https://${prefix}api.${domain}/assistant/api/v1`,
56
59
  appUrl: environment === "prod" ? `https://${prefix}${domain}` : `https://${prefix}app.${domain}`,
57
60
  loginUrl: `https://${prefix}login.${domain}`,
58
61
  dashboardUrl: `https://${prefix}dashboard.${domain}`,
@@ -1027,6 +1030,65 @@ function useSelectField({ name, control, options: _options, rules, defaultValue,
1027
1030
  };
1028
1031
  }
1029
1032
 
1033
+ const BCP47 = { fr: "fr-FR", en: "en-US", ar: "ar-SA" };
1034
+ function getSpeechRecognition() {
1035
+ if (typeof window === "undefined")
1036
+ return undefined;
1037
+ const w = window;
1038
+ return w.SpeechRecognition ?? w.webkitSpeechRecognition;
1039
+ }
1040
+ /**
1041
+ * Push-to-talk on the browser speech API (MED-1783). `supported` is false on
1042
+ * the server and in browsers without the API, so the app can hide the mic.
1043
+ */
1044
+ function useSpeechRecognition({ language, onResult, onInterim, }) {
1045
+ const [supported, setSupported] = React.useState(false);
1046
+ const [listening, setListening] = React.useState(false);
1047
+ const recognitionRef = React.useRef(null);
1048
+ const transcriptRef = React.useRef("");
1049
+ const callbacks = React.useRef({ onResult, onInterim });
1050
+ callbacks.current = { onResult, onInterim };
1051
+ // Detected after mount: the server never has the API, and the markup must match.
1052
+ React.useEffect(() => {
1053
+ setSupported(getSpeechRecognition() !== undefined);
1054
+ }, []);
1055
+ const stop = React.useCallback(() => {
1056
+ recognitionRef.current?.stop();
1057
+ }, []);
1058
+ const start = React.useCallback(() => {
1059
+ const Ctor = getSpeechRecognition();
1060
+ if (!Ctor || recognitionRef.current)
1061
+ return;
1062
+ const recognition = new Ctor();
1063
+ recognition.lang = BCP47[language] ?? language;
1064
+ recognition.interimResults = true;
1065
+ recognition.continuous = true;
1066
+ transcriptRef.current = "";
1067
+ recognition.onresult = (event) => {
1068
+ let text = "";
1069
+ for (let i = 0; i < event.results.length; i++) {
1070
+ text += event.results[i][0]?.transcript ?? "";
1071
+ }
1072
+ transcriptRef.current = text.trim();
1073
+ callbacks.current.onInterim?.(transcriptRef.current);
1074
+ };
1075
+ recognition.onerror = () => {
1076
+ recognition.stop();
1077
+ };
1078
+ recognition.onend = () => {
1079
+ recognitionRef.current = null;
1080
+ setListening(false);
1081
+ if (transcriptRef.current)
1082
+ callbacks.current.onResult(transcriptRef.current);
1083
+ };
1084
+ recognitionRef.current = recognition;
1085
+ setListening(true);
1086
+ recognition.start();
1087
+ }, [language]);
1088
+ React.useEffect(() => () => recognitionRef.current?.abort(), []);
1089
+ return { supported, listening, start, stop };
1090
+ }
1091
+
1030
1092
  function SearchAutocomplete({ onSelect, selectedId, searchFunction, getEntityById, getDisplayValue, getSecondaryText, placeholder, icon = lucideReact.Search, disabled = false, minSearchLength = 2, debounceTime = 300, error, getMinCharactersHint = (min) => `(min. ${min} characters)`, noResultsText = "No results found. Try a different search term.", }) {
1031
1093
  const [searchTerm, setSearchTerm] = React.useState("");
1032
1094
  const [entities, setEntities] = React.useState([]);
@@ -3414,6 +3476,674 @@ const PinCodeInput = ({ length = 4, onComplete, onChange, masked = true, autoCom
3414
3476
  }, type: "text", inputMode: "numeric", pattern: "[0-9]*", autoComplete: index === 0 ? autoComplete : "off", autoCorrect: "off", spellCheck: false, value: digit && masked ? MASK : digit, disabled: disabled, "aria-label": digitLabel(index + 1, length), "aria-invalid": invalid || undefined, "data-filled": !!digit, onChange: (event) => handleChange(index, event), onKeyDown: (event) => handleKeyDown(index, event), onPaste: (event) => handlePaste(index, event), onFocus: (event) => event.target.select(), className: cn("h-14 w-12 rounded-lg border bg-white text-center text-2xl font-medium text-ews-gray-900 outline-none", "transition-colors focus:border-ews-primary focus:ring-2 focus:ring-ews-primary", "disabled:cursor-not-allowed disabled:opacity-60", invalid ? "border-ews-error" : digit ? "border-ews-primary" : "border-ews-gray-300", disabled && digit && "animate-pulse") }, index))) }));
3415
3477
  };
3416
3478
 
3479
+ const MAX_MESSAGE_LENGTH = 2000;
3480
+ /**
3481
+ * The composer of the mockup: textarea, hint, push-to-talk mic (hold to
3482
+ * record, release to send) and a send button. Enter sends, Shift+Enter breaks.
3483
+ */
3484
+ const AssistantComposer = ({ language, onSend, disabled = false, voice = true, texts, className, }) => {
3485
+ const [value, setValue] = React.useState("");
3486
+ const textareaRef = React.useRef(null);
3487
+ const submit = React.useCallback((content) => {
3488
+ const trimmed = content.trim();
3489
+ if (!trimmed || disabled)
3490
+ return;
3491
+ onSend(trimmed.slice(0, MAX_MESSAGE_LENGTH));
3492
+ setValue("");
3493
+ }, [disabled, onSend]);
3494
+ const speech = useSpeechRecognition({
3495
+ language,
3496
+ onInterim: (text) => setValue(text),
3497
+ onResult: (text) => submit(text),
3498
+ });
3499
+ const onKeyDown = (event) => {
3500
+ if (event.key === "Enter" && !event.shiftKey) {
3501
+ event.preventDefault();
3502
+ submit(value);
3503
+ }
3504
+ };
3505
+ const showMic = voice && speech.supported;
3506
+ // A plain div rather than a <form>: Enter and the send button submit, and a
3507
+ // form followed by a sibling trips React hydration under happy-dom (the apps'
3508
+ // server-rendering tests) although browsers are fine.
3509
+ return (jsxRuntime.jsxs("div", { className: cn("w-full", className), children: [jsxRuntime.jsxs("div", { className: cn("flex items-end gap-2 rounded-2xl border border-ews-gray-200 bg-white px-3 py-2 shadow-sm", "focus-within:border-ews-primary focus-within:ring-2 focus-within:ring-ews-primary/20"), children: [jsxRuntime.jsx("textarea", { ref: textareaRef, value: value, onChange: (event) => setValue(event.target.value), onKeyDown: onKeyDown, rows: 1, maxLength: MAX_MESSAGE_LENGTH, placeholder: speech.listening ? texts.listening : texts.placeholder, "aria-label": texts.placeholder, disabled: disabled, className: "max-h-40 min-h-[2.5rem] flex-1 resize-none bg-transparent py-2 text-base text-ews-gray-900 placeholder:text-ews-gray-400 focus:outline-none disabled:opacity-60" }), showMic && (jsxRuntime.jsx("button", { type: "button", "aria-label": texts.holdToTalk, "aria-pressed": speech.listening, title: texts.holdToTalk, disabled: disabled, onMouseDown: speech.start, onMouseUp: speech.stop, onMouseLeave: speech.listening ? speech.stop : undefined, onTouchStart: speech.start, onTouchEnd: speech.stop, onKeyDown: (event) => {
3510
+ if (event.key === " " || event.key === "Enter") {
3511
+ event.preventDefault();
3512
+ if (speech.listening)
3513
+ speech.stop();
3514
+ else
3515
+ speech.start();
3516
+ }
3517
+ }, className: cn("rounded-full p-2 transition-colors focus:outline-none focus:ring-2 focus:ring-ews-primary", speech.listening
3518
+ ? "bg-ews-error text-white animate-pulse"
3519
+ : "text-ews-gray-500 hover:bg-ews-gray-100 hover:text-ews-primary", "disabled:opacity-50"), "data-testid": "assistant-mic", children: jsxRuntime.jsx(lucideReact.Mic, { className: "h-5 w-5", "aria-hidden": "true" }) })), jsxRuntime.jsx("button", { type: "button", onClick: () => submit(value), "aria-label": texts.send, title: texts.send, disabled: disabled || value.trim().length === 0, className: "rounded-full bg-ews-primary p-2 text-white transition-colors hover:bg-ews-primary-hover focus:outline-none focus:ring-2 focus:ring-ews-primary focus:ring-offset-1 disabled:opacity-40", "data-testid": "assistant-send", children: jsxRuntime.jsx(lucideReact.SendHorizontal, { className: "h-5 w-5 rtl:-scale-x-100", "aria-hidden": "true" }) })] }), jsxRuntime.jsx("p", { className: "mt-1.5 px-2 text-xs text-ews-gray-400", children: texts.hint })] }));
3520
+ };
3521
+
3522
+ /**
3523
+ * Reserved for phase 2 (MED-1779): the card the assistant shows before a
3524
+ * write (booking, cancellation). Title, details, confirm and cancel wired to
3525
+ * the app, so phase 2 needs no component change.
3526
+ */
3527
+ const AssistantConfirmationCard = ({ confirmation, onConfirm, onCancel, texts, busy = false, className, }) => (jsxRuntime.jsxs("section", { "aria-label": confirmation.title, className: cn("rounded-xl border border-ews-primary/30 bg-ews-primary-light/30 p-4", className), "data-testid": "assistant-confirmation", children: [jsxRuntime.jsxs("h3", { className: "flex items-center gap-2 font-semibold text-ews-gray-900", children: [jsxRuntime.jsx(lucideReact.ShieldCheck, { className: "h-5 w-5 text-ews-primary", "aria-hidden": "true" }), confirmation.title] }), jsxRuntime.jsx("dl", { className: "mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm", children: confirmation.details.map((detail) => (jsxRuntime.jsxs(React.Fragment, { children: [jsxRuntime.jsx("dt", { className: "text-ews-gray-500", children: detail.label }), jsxRuntime.jsx("dd", { className: "font-medium text-ews-gray-900", children: detail.value })] }, detail.label))) }), jsxRuntime.jsxs("div", { className: "mt-4 flex gap-2", children: [jsxRuntime.jsx(Button, { size: "sm", loading: busy, onClick: () => onConfirm(confirmation), children: texts.confirm }), jsxRuntime.jsx(Button, { size: "sm", variant: "ghost", disabled: busy, onClick: () => onCancel(confirmation), children: texts.cancel })] })] }));
3528
+
3529
+ /** Left column of the mockup: new discussion, recent ones, sign-in prompt at the bottom. */
3530
+ const AssistantConversationList = ({ conversations, activeConversationId, onSelect, onNew, texts, signInHref, className, }) => (jsxRuntime.jsxs("nav", { "aria-label": texts.recentConversations, className: cn("flex h-full flex-col gap-4 bg-ews-gray-50 p-3", className), children: [jsxRuntime.jsxs("button", { type: "button", onClick: onNew, className: "flex items-center gap-2 rounded-lg border border-ews-gray-200 bg-white px-3 py-2 text-sm font-medium text-ews-gray-800 hover:border-ews-primary hover:text-ews-primary", children: [jsxRuntime.jsx(lucideReact.SquarePen, { className: "h-4 w-4", "aria-hidden": "true" }), texts.newConversation] }), conversations.length > 0 && (jsxRuntime.jsx("ul", { className: "flex-1 space-y-1 overflow-y-auto", "data-testid": "assistant-conversation-list", children: conversations.map((conversation) => {
3531
+ const active = conversation.id === activeConversationId;
3532
+ return (jsxRuntime.jsx("li", { children: jsxRuntime.jsxs("button", { type: "button", onClick: () => onSelect?.(conversation.id), "aria-current": active ? "true" : undefined, className: cn("flex w-full items-center gap-2 truncate rounded-md px-2 py-1.5 text-start text-sm", active
3533
+ ? "bg-white font-medium text-ews-primary shadow-sm"
3534
+ : "text-ews-gray-700 hover:bg-white"), children: [jsxRuntime.jsx(lucideReact.MessageSquare, { className: "h-3.5 w-3.5 shrink-0 text-ews-gray-400", "aria-hidden": "true" }), jsxRuntime.jsx("span", { className: "truncate", children: conversation.title })] }) }, conversation.id));
3535
+ }) })), signInHref && (jsxRuntime.jsxs("a", { href: signInHref, className: "mt-auto flex items-start gap-2 rounded-lg px-2 py-2 text-sm hover:bg-white", children: [jsxRuntime.jsx("span", { className: "rounded-full bg-ews-primary-light p-1.5 text-ews-primary", "aria-hidden": "true", children: jsxRuntime.jsx(lucideReact.UserRound, { className: "h-4 w-4" }) }), jsxRuntime.jsxs("span", { children: [jsxRuntime.jsx("span", { className: "block font-medium text-ews-gray-900", children: texts.signIn }), jsxRuntime.jsx("span", { className: "block text-xs text-ews-gray-500", children: texts.signInHint })] })] }))] }));
3536
+
3537
+ /**
3538
+ * The tiny markdown subset the assistant contract allows: paragraphs, bullet
3539
+ * lists, **bold** and [links](url). Everything else is plain text, so a
3540
+ * payload can never inject markup. Links to the current origin open in the
3541
+ * same tab (the host app may route them itself through `onLinkClick`);
3542
+ * links to other sites open in a new tab.
3543
+ */
3544
+ const INLINE = /(\*\*[^*]+\*\*|\[[^\]]+\]\((?:https?:\/\/|\/)[^)\s]+\))/g;
3545
+ /** Absolute URL on another origin. Without a window (server rendering) every absolute URL counts as external. */
3546
+ function isExternalLink(href) {
3547
+ if (!/^https?:\/\//i.test(href))
3548
+ return false;
3549
+ if (typeof window === "undefined" || !window.location?.origin)
3550
+ return true;
3551
+ try {
3552
+ return new URL(href).origin !== window.location.origin;
3553
+ }
3554
+ catch {
3555
+ return true;
3556
+ }
3557
+ }
3558
+ function renderInline(text, keyPrefix, options) {
3559
+ const nodes = [];
3560
+ const isExternal = options.isExternal ?? isExternalLink;
3561
+ let last = 0;
3562
+ let index = 0;
3563
+ for (const match of text.matchAll(INLINE)) {
3564
+ const start = match.index ?? 0;
3565
+ if (start > last)
3566
+ nodes.push(text.slice(last, start));
3567
+ const token = match[0];
3568
+ if (token.startsWith("**")) {
3569
+ nodes.push(jsxRuntime.jsx("strong", { children: token.slice(2, -2) }, `${keyPrefix}-b${index}`));
3570
+ }
3571
+ else {
3572
+ const close = token.indexOf("](");
3573
+ const label = token.slice(1, close);
3574
+ const href = token.slice(close + 2, -1);
3575
+ const external = isExternal(href);
3576
+ nodes.push(jsxRuntime.jsx("a", { href: href, className: "font-medium text-ews-primary underline underline-offset-2 hover:text-ews-primary-hover", target: external ? "_blank" : undefined, rel: external ? "noopener noreferrer" : undefined, onClick: options.onLinkClick ? (event) => options.onLinkClick?.(href, event) : undefined, children: label }, `${keyPrefix}-a${index}`));
3577
+ }
3578
+ last = start + token.length;
3579
+ index++;
3580
+ }
3581
+ if (last < text.length)
3582
+ nodes.push(text.slice(last));
3583
+ return nodes;
3584
+ }
3585
+ function renderAssistantMarkdown(content, options = {}) {
3586
+ const blocks = content.replace(/\r\n/g, "\n").split(/\n{2,}/);
3587
+ const out = [];
3588
+ blocks.forEach((block, blockIndex) => {
3589
+ const lines = block.split("\n").filter((line) => line.trim().length > 0);
3590
+ if (lines.length === 0)
3591
+ return;
3592
+ const isList = lines.every((line) => /^\s*[-*•]\s+/.test(line));
3593
+ if (isList) {
3594
+ out.push(jsxRuntime.jsx("ul", { className: "my-2 list-disc space-y-1 ps-5", children: lines.map((line, lineIndex) => (jsxRuntime.jsx("li", { children: renderInline(line.replace(/^\s*[-*•]\s+/, ""), `l${blockIndex}-${lineIndex}`, options) }, lineIndex))) }, `l${blockIndex}`));
3595
+ }
3596
+ else {
3597
+ out.push(jsxRuntime.jsx("p", { className: "my-2 first:mt-0 last:mb-0", children: lines.map((line, lineIndex) => (jsxRuntime.jsxs(React.Fragment, { children: [lineIndex > 0 && jsxRuntime.jsx("br", {}), renderInline(line, `p${blockIndex}-${lineIndex}`, options)] }, lineIndex))) }, `p${blockIndex}`));
3598
+ }
3599
+ });
3600
+ return out;
3601
+ }
3602
+
3603
+ /** The thread: user bubbles on the end side, assistant answers on the start side, auto-scrolled. */
3604
+ const AssistantMessages = ({ messages, activity, onRetry, texts, linkOptions, className, }) => {
3605
+ const endRef = React.useRef(null);
3606
+ const last = messages[messages.length - 1];
3607
+ React.useEffect(() => {
3608
+ endRef.current?.scrollIntoView?.({ block: "end" });
3609
+ }, [messages.length, last?.content, activity]);
3610
+ return (jsxRuntime.jsxs("div", { className: cn("flex flex-col gap-4", className), role: "log", "aria-live": "polite", "aria-relevant": "additions text", "data-testid": "assistant-messages", children: [messages.map((message) => {
3611
+ const isUser = message.role === "user";
3612
+ return (jsxRuntime.jsx("div", { className: cn("flex", isUser ? "justify-end" : "justify-start"), "data-testid": `assistant-message-${message.role}`, children: jsxRuntime.jsx("div", { className: cn("max-w-[85%] rounded-2xl px-4 py-3 text-base leading-relaxed", isUser
3613
+ ? "rounded-ee-md bg-ews-primary text-white"
3614
+ : "rounded-es-md bg-ews-gray-100 text-ews-gray-900"), children: isUser ? (jsxRuntime.jsx("p", { className: "whitespace-pre-wrap", children: message.content })) : (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [message.content.length > 0 && renderAssistantMarkdown(message.content, linkOptions), message.status === "streaming" && message.content.length === 0 && (jsxRuntime.jsxs("span", { className: "flex items-center gap-2 text-ews-gray-500", children: [jsxRuntime.jsx(lucideReact.Loader2, { className: "h-4 w-4 animate-spin", "aria-hidden": "true" }), texts.thinking] })), message.status === "streaming" && activity && (jsxRuntime.jsxs("p", { className: "mt-2 flex items-center gap-2 text-sm text-ews-gray-500", "data-testid": "assistant-activity", children: [jsxRuntime.jsx(lucideReact.Loader2, { className: "h-3.5 w-3.5 animate-spin", "aria-hidden": "true" }), activity] })), message.citations && message.citations.length > 0 && (jsxRuntime.jsxs("p", { className: "mt-3 border-t border-ews-gray-200 pt-2 text-xs text-ews-gray-500", children: [jsxRuntime.jsxs("span", { className: "font-medium", children: [texts.sources, ": "] }), message.citations.map((citation, index) => (jsxRuntime.jsxs(React.Fragment, { children: [index > 0 && ", ", jsxRuntime.jsx("a", { href: citation.url, className: "underline underline-offset-2 hover:text-ews-primary", target: "_blank", rel: "noopener noreferrer", children: citation.title })] }, `${citation.url}-${index}`)))] })), message.status === "error" && message.error && (jsxRuntime.jsxs("div", { role: "alert", className: "mt-2 flex flex-wrap items-center gap-2 text-sm text-ews-error", "data-testid": "assistant-error", children: [jsxRuntime.jsx(lucideReact.AlertCircle, { className: "h-4 w-4 shrink-0", "aria-hidden": "true" }), jsxRuntime.jsx("span", { children: message.error.message }), onRetry && (jsxRuntime.jsxs("button", { type: "button", onClick: () => onRetry(message), className: "inline-flex items-center gap-1 rounded-md border border-ews-error/40 px-2 py-0.5 font-medium hover:bg-ews-error/10", children: [jsxRuntime.jsx(lucideReact.RotateCcw, { className: "h-3.5 w-3.5", "aria-hidden": "true" }), texts.retry] }))] }))] })) }) }, message.id));
3615
+ }), jsxRuntime.jsx("div", { ref: endRef })] }));
3616
+ };
3617
+
3618
+ /** One of the cards under the hero (mockup med1): title, description, sent as a prompt on click. */
3619
+ const AssistantSuggestionCard = ({ suggestion, onSelect, exampleLabel, disabled = false, className, }) => {
3620
+ const isExample = suggestion.kind === "example";
3621
+ return (jsxRuntime.jsxs("button", { type: "button", disabled: disabled, onClick: () => onSelect(suggestion), className: cn("flex h-full w-full flex-col items-start gap-2 rounded-xl border border-ews-gray-200 bg-white p-4 text-start shadow-sm transition-colors", "hover:border-ews-primary hover:bg-ews-gray-50 focus:outline-none focus:ring-2 focus:ring-ews-primary focus:ring-offset-1", "disabled:cursor-not-allowed disabled:opacity-60", className), "data-testid": `assistant-suggestion-${suggestion.id}`, children: [jsxRuntime.jsx("span", { className: "text-ews-gray-400", "aria-hidden": "true", children: suggestion.icon ?? (isExample ? jsxRuntime.jsx(lucideReact.Sparkles, { className: "h-4 w-4" }) : jsxRuntime.jsx(lucideReact.ArrowRight, { className: "h-4 w-4" })) }), jsxRuntime.jsx("span", { className: "font-semibold text-ews-gray-900", children: suggestion.title }), suggestion.description && (jsxRuntime.jsx("span", { className: "text-sm text-ews-gray-600", children: suggestion.description })), isExample && (jsxRuntime.jsx("span", { className: "mt-auto text-xs uppercase tracking-wide text-ews-gray-400", children: exampleLabel }))] }));
3622
+ };
3623
+
3624
+ const DIRECTION$1 = {
3625
+ fr: "ltr",
3626
+ en: "ltr",
3627
+ ar: "rtl",
3628
+ };
3629
+ /** A conversation title is its first message, cut like the mockup's sidebar. */
3630
+ function conversationTitle(firstMessage, max = 60) {
3631
+ const oneLine = firstMessage.replace(/\s+/g, " ").trim();
3632
+ return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
3633
+ }
3634
+ /**
3635
+ * The chat home of the Medecine360 assistant (MED-1783, mockup med1):
3636
+ * conversation list, header with brand badge, hero with suggestion cards,
3637
+ * streamed thread, composer with push-to-talk. UI only: the app provides the
3638
+ * transport, the texts, the conversation memory and the links.
3639
+ *
3640
+ * Server-rendering safe: nothing touches `window` at render time, the
3641
+ * speech API is detected after mount.
3642
+ */
3643
+ const AssistantChat = ({ transport, language, texts, suggestions, conversations = [], activeConversationId = null, initialMessages, onConversationCreated, onSelectConversation, onNewConversation, signInHref, headerAction, disclaimer, heroBackground, voice = true, pendingConfirmation = null, onConfirm, onCancelConfirmation, layout = "page", queuedPrompt = null, onNavigate, historyOpen = false, onHistoryOpenChange, className, }) => {
3644
+ const [messages, setMessages] = React.useState(initialMessages ?? []);
3645
+ const [activity, setActivity] = React.useState(null);
3646
+ const [busy, setBusy] = React.useState(false);
3647
+ const conversationIdRef = React.useRef(activeConversationId);
3648
+ const abortRef = React.useRef(null);
3649
+ // The app switched conversation: show its messages and talk to it from now on.
3650
+ // Not when the app merely echoes the id this component just created (send()
3651
+ // already set the ref): the thread in progress must stay on screen.
3652
+ React.useEffect(() => {
3653
+ if (conversationIdRef.current === activeConversationId)
3654
+ return;
3655
+ conversationIdRef.current = activeConversationId;
3656
+ setMessages(initialMessages ?? []);
3657
+ setActivity(null);
3658
+ }, [activeConversationId, initialMessages]);
3659
+ React.useEffect(() => () => abortRef.current?.abort(), []);
3660
+ const patchMessage = React.useCallback((id, patch) => {
3661
+ setMessages((current) => current.map((message) => (message.id === id ? patch(message) : message)));
3662
+ }, []);
3663
+ const applyEvent = React.useCallback((assistantId, event) => {
3664
+ switch (event.type) {
3665
+ case "message_start":
3666
+ patchMessage(assistantId, (message) => ({ ...message, id: event.messageId || message.id }));
3667
+ return event.messageId || assistantId;
3668
+ case "text_delta":
3669
+ patchMessage(assistantId, (message) => ({ ...message, content: message.content + event.text }));
3670
+ return assistantId;
3671
+ case "tool_call":
3672
+ setActivity(event.status === "started" ? event.label : null);
3673
+ return assistantId;
3674
+ case "citation":
3675
+ patchMessage(assistantId, (message) => ({
3676
+ ...message,
3677
+ citations: [...(message.citations ?? []), { title: event.title, url: event.url }],
3678
+ }));
3679
+ return assistantId;
3680
+ case "message_end":
3681
+ setActivity(null);
3682
+ patchMessage(assistantId, (message) => ({ ...message, status: "done" }));
3683
+ return assistantId;
3684
+ case "error":
3685
+ setActivity(null);
3686
+ patchMessage(assistantId, (message) => ({
3687
+ ...message,
3688
+ status: "error",
3689
+ error: {
3690
+ message: event.code === "RATE_LIMITED" && event.retryAfterSeconds
3691
+ ? texts.rateLimited(event.retryAfterSeconds)
3692
+ : event.message || texts.genericError,
3693
+ retryAfterSeconds: event.retryAfterSeconds,
3694
+ },
3695
+ }));
3696
+ return assistantId;
3697
+ default:
3698
+ return assistantId;
3699
+ }
3700
+ }, [patchMessage, texts]);
3701
+ const send = React.useCallback(async (content) => {
3702
+ if (busy)
3703
+ return false;
3704
+ setBusy(true);
3705
+ const userMessage = { id: generateId("u"), role: "user", content };
3706
+ let assistantId = generateId("a");
3707
+ setMessages((current) => [
3708
+ ...current,
3709
+ userMessage,
3710
+ { id: assistantId, role: "assistant", content: "", status: "streaming" },
3711
+ ]);
3712
+ const controller = new AbortController();
3713
+ abortRef.current = controller;
3714
+ try {
3715
+ if (!conversationIdRef.current) {
3716
+ const created = await transport.startConversation(language);
3717
+ conversationIdRef.current = created.id;
3718
+ const summary = {
3719
+ id: created.id,
3720
+ title: conversationTitle(content),
3721
+ createdAt: new Date().toISOString(),
3722
+ };
3723
+ onConversationCreated?.(summary);
3724
+ }
3725
+ for await (const event of transport.sendMessage(conversationIdRef.current, content, {
3726
+ signal: controller.signal,
3727
+ })) {
3728
+ assistantId = applyEvent(assistantId, event);
3729
+ }
3730
+ }
3731
+ catch (error) {
3732
+ setActivity(null);
3733
+ patchMessage(assistantId, (message) => ({
3734
+ ...message,
3735
+ status: "error",
3736
+ error: { message: error instanceof Error && error.message ? error.message : texts.genericError },
3737
+ }));
3738
+ }
3739
+ finally {
3740
+ setBusy(false);
3741
+ abortRef.current = null;
3742
+ }
3743
+ return true;
3744
+ }, [applyEvent, busy, language, onConversationCreated, patchMessage, texts.genericError, transport]);
3745
+ // A prompt queued from outside (the launcher bar) is sent once, as soon as
3746
+ // the component is free.
3747
+ const handledPromptRef = React.useRef(null);
3748
+ React.useEffect(() => {
3749
+ if (!queuedPrompt || queuedPrompt.id === handledPromptRef.current || busy)
3750
+ return;
3751
+ handledPromptRef.current = queuedPrompt.id;
3752
+ void send(queuedPrompt.content);
3753
+ }, [busy, queuedPrompt, send]);
3754
+ /** Retry re-sends the user message that preceded the failed answer. */
3755
+ const retry = React.useCallback((failed) => {
3756
+ const index = messages.findIndex((message) => message.id === failed.id);
3757
+ const previous = index > 0 ? messages[index - 1] : undefined;
3758
+ if (!previous || previous.role !== "user")
3759
+ return;
3760
+ setMessages((current) => current.filter((message) => message.id !== failed.id && message.id !== previous.id));
3761
+ void send(previous.content);
3762
+ }, [messages, send]);
3763
+ const startNewConversation = React.useCallback(() => {
3764
+ abortRef.current?.abort();
3765
+ conversationIdRef.current = null;
3766
+ setMessages([]);
3767
+ setActivity(null);
3768
+ onNewConversation?.();
3769
+ }, [onNewConversation]);
3770
+ const empty = messages.length === 0;
3771
+ const dir = DIRECTION$1[language];
3772
+ // panel: one compact column; wide: the slide-over expanded, list beside the thread.
3773
+ const compact = layout === "panel";
3774
+ const withList = layout !== "panel";
3775
+ const historyLabel = texts.history ?? texts.recentConversations;
3776
+ return (jsxRuntime.jsxs("div", { dir: dir, lang: language, className: cn("overflow-hidden bg-white", layout === "page" && "grid min-h-[36rem] grid-cols-1 rounded-2xl border border-ews-gray-200 shadow-sm md:grid-cols-[16rem_1fr]", layout === "wide" && "grid h-full min-h-0 grid-cols-1 md:grid-cols-[16rem_1fr]", compact && "relative flex h-full min-h-0 flex-col", className), "data-layout": layout, "data-testid": "assistant-chat", children: [withList && (jsxRuntime.jsx(AssistantConversationList, { conversations: conversations, activeConversationId: activeConversationId, onSelect: onSelectConversation, onNew: startNewConversation, texts: texts, signInHref: signInHref, className: "hidden border-e border-ews-gray-200 md:flex" })), compact && historyOpen && (jsxRuntime.jsxs("div", { className: "absolute inset-0 z-10 flex", "data-testid": "assistant-history", children: [jsxRuntime.jsx(AssistantConversationList, { conversations: conversations, activeConversationId: activeConversationId, onSelect: (id) => {
3777
+ onSelectConversation?.(id);
3778
+ onHistoryOpenChange?.(false);
3779
+ }, onNew: () => {
3780
+ startNewConversation();
3781
+ onHistoryOpenChange?.(false);
3782
+ }, texts: texts, signInHref: signInHref, className: "w-72 max-w-full border-e border-ews-gray-200 shadow-xl" }), jsxRuntime.jsx("div", { className: "flex-1 bg-black/20", onClick: () => onHistoryOpenChange?.(false), "data-testid": "assistant-history-overlay" })] })), jsxRuntime.jsxs("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col", children: [jsxRuntime.jsxs("header", { className: "flex items-center justify-between gap-3 border-b border-ews-gray-200 px-4 py-3", children: [jsxRuntime.jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [jsxRuntime.jsx("span", { className: "flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-ews-primary text-sm font-bold text-white", "aria-hidden": "true", children: texts.brandName.charAt(0) }), jsxRuntime.jsx("span", { className: "truncate font-semibold text-ews-gray-900", children: texts.brandName }), jsxRuntime.jsx("span", { className: "rounded-full bg-ews-gray-100 px-2 py-0.5 text-xs font-medium text-ews-gray-600", children: "AI" }), jsxRuntime.jsxs("span", { className: "flex items-center gap-1 text-xs text-ews-gray-500", children: [jsxRuntime.jsx("span", { className: "h-2 w-2 rounded-full bg-ews-success", "aria-hidden": "true" }), texts.online] })] }), jsxRuntime.jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [compact && onHistoryOpenChange && (jsxRuntime.jsx("button", { type: "button", onClick: () => onHistoryOpenChange(!historyOpen), "aria-label": historyLabel, "aria-pressed": historyOpen, title: historyLabel, className: cn("rounded-full p-2 hover:bg-ews-gray-100 hover:text-ews-primary focus:outline-none focus:ring-2 focus:ring-ews-primary", historyOpen ? "bg-ews-gray-100 text-ews-primary" : "text-ews-gray-500"), "data-testid": "assistant-history-toggle", children: jsxRuntime.jsx(lucideReact.History, { className: "h-4 w-4", "aria-hidden": "true" }) })), !withList && !empty && (jsxRuntime.jsx("button", { type: "button", onClick: startNewConversation, "aria-label": texts.newConversation, title: texts.newConversation, className: "rounded-full p-2 text-ews-gray-500 hover:bg-ews-gray-100 hover:text-ews-primary focus:outline-none focus:ring-2 focus:ring-ews-primary", "data-testid": "assistant-new-conversation", children: jsxRuntime.jsx(lucideReact.SquarePen, { className: "h-4 w-4", "aria-hidden": "true" }) })), headerAction] })] }), jsxRuntime.jsx("div", { className: "flex flex-1 flex-col overflow-y-auto", children: empty ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsxs("section", { className: cn("relative overflow-hidden bg-ews-primary-light text-center", compact ? "px-4 py-6" : "px-6 py-10"), children: [heroBackground && jsxRuntime.jsx("div", { className: "absolute inset-0", "aria-hidden": "true", children: heroBackground }), jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsx("h2", { className: cn("font-bold text-ews-gray-900", compact ? "text-xl" : "text-2xl sm:text-3xl"), children: texts.heroTitle }), jsxRuntime.jsx("p", { className: cn("mx-auto mt-2 max-w-xl text-ews-gray-700", compact && "text-sm"), children: texts.heroSubtitle })] })] }), jsxRuntime.jsx("div", { className: cn("grid gap-3 px-4 py-5", compact ? "grid-cols-1" : "sm:grid-cols-2 lg:grid-cols-4"), "data-testid": "assistant-suggestions", children: suggestions.map((suggestion) => (jsxRuntime.jsx(AssistantSuggestionCard, { suggestion: suggestion, onSelect: (selected) => void send(selected.prompt), exampleLabel: texts.exampleLabel, disabled: busy }, suggestion.id))) })] })) : (jsxRuntime.jsxs("div", { className: "flex-1 px-4 py-5", children: [jsxRuntime.jsx(AssistantMessages, { messages: messages, activity: activity, onRetry: retry, texts: texts, linkOptions: onNavigate
3783
+ ? {
3784
+ onLinkClick: (href, event) => {
3785
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey)
3786
+ return;
3787
+ if (onNavigate(href) === true)
3788
+ event.preventDefault();
3789
+ },
3790
+ }
3791
+ : undefined }), pendingConfirmation && onConfirm && onCancelConfirmation && (jsxRuntime.jsx(AssistantConfirmationCard, { confirmation: pendingConfirmation, onConfirm: onConfirm, onCancel: onCancelConfirmation, texts: texts, className: "mt-4" }))] })) }), jsxRuntime.jsxs("div", { className: "border-t border-ews-gray-200 px-4 pb-3 pt-3", children: [jsxRuntime.jsx(AssistantComposer, { language: language, onSend: (content) => void send(content), disabled: busy, voice: voice, texts: {
3792
+ placeholder: texts.composerPlaceholder,
3793
+ hint: texts.composerHint,
3794
+ send: texts.send,
3795
+ holdToTalk: texts.holdToTalk,
3796
+ listening: texts.listening,
3797
+ } }), disclaimer && jsxRuntime.jsx("div", { className: "mt-2 px-2 text-xs text-ews-gray-500", children: disclaimer })] })] })] }));
3798
+ };
3799
+
3800
+ /**
3801
+ * Anonymous memory of the assistant, in the browser only (MED-1784, shared by
3802
+ * every app since MED-1787): a stable client id and the conversations started
3803
+ * here, newest first. Nothing is sent to a server to list them; signing in
3804
+ * (phase 2) will attach them to the account.
3805
+ */
3806
+ const ASSISTANT_CLIENT_ID_KEY = "assistant.clientId";
3807
+ const ASSISTANT_CONVERSATIONS_KEY = "assistant.conversations";
3808
+ const ASSISTANT_MAX_CONVERSATIONS = 20;
3809
+ function storage() {
3810
+ try {
3811
+ return typeof window !== "undefined" ? window.localStorage : null;
3812
+ }
3813
+ catch {
3814
+ return null;
3815
+ }
3816
+ }
3817
+ function randomId() {
3818
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
3819
+ return crypto.randomUUID();
3820
+ }
3821
+ return `c-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
3822
+ }
3823
+ /** The browser's client id, created on first use. A fresh id per page when storage is unavailable. */
3824
+ function getAssistantClientId(store = storage()) {
3825
+ const existing = store?.getItem(ASSISTANT_CLIENT_ID_KEY);
3826
+ if (existing)
3827
+ return existing;
3828
+ const id = randomId();
3829
+ try {
3830
+ store?.setItem(ASSISTANT_CLIENT_ID_KEY, id);
3831
+ }
3832
+ catch {
3833
+ // private mode: the id lasts for this page only
3834
+ }
3835
+ return id;
3836
+ }
3837
+ function listAssistantConversations(store = storage()) {
3838
+ try {
3839
+ const raw = store?.getItem(ASSISTANT_CONVERSATIONS_KEY);
3840
+ if (!raw)
3841
+ return [];
3842
+ const parsed = JSON.parse(raw);
3843
+ if (!Array.isArray(parsed))
3844
+ return [];
3845
+ return parsed.filter((entry) => typeof entry === "object" &&
3846
+ entry !== null &&
3847
+ typeof entry.id === "string");
3848
+ }
3849
+ catch {
3850
+ return [];
3851
+ }
3852
+ }
3853
+ /** Adds or refreshes a conversation at the top of the list, capped at ASSISTANT_MAX_CONVERSATIONS. */
3854
+ function saveAssistantConversation(conversation, store = storage()) {
3855
+ const next = [
3856
+ conversation,
3857
+ ...listAssistantConversations(store).filter((entry) => entry.id !== conversation.id),
3858
+ ].slice(0, ASSISTANT_MAX_CONVERSATIONS);
3859
+ try {
3860
+ store?.setItem(ASSISTANT_CONVERSATIONS_KEY, JSON.stringify(next));
3861
+ }
3862
+ catch {
3863
+ // private mode: the list lasts for this page only
3864
+ }
3865
+ return next;
3866
+ }
3867
+
3868
+ const DIRECTION = {
3869
+ fr: "ltr",
3870
+ en: "ltr",
3871
+ ar: "rtl",
3872
+ };
3873
+ /**
3874
+ * MED-1787: the in-app entry to the assistant for Medecine360-ui and the
3875
+ * dashboard. A floating bar at the bottom of the page (brand badge, a text
3876
+ * field "Posez une question…", push-to-talk mic, voice button, send) opens a
3877
+ * slide-over panel hosting AssistantChat in its `panel` layout; clicking the
3878
+ * field opens the panel too, a spoken question is sent as it opens. From
3879
+ * tablets up the panel can be expanded to a dashboard reaching the middle of
3880
+ * the screen, with the conversation list beside the thread; in the narrow
3881
+ * layout a history button shows that list over the thread.
3882
+ *
3883
+ * Conversation history is managed here with the shared browser store unless
3884
+ * the host passes `conversations` itself: new conversations are remembered,
3885
+ * selecting one loads it through `transport.getConversation`.
3886
+ *
3887
+ * The chat stays mounted while the panel is closed, so the conversation in
3888
+ * progress survives open/close. Escape and the overlay close the panel; focus
3889
+ * goes to the composer on open and back to the bar on close. In Arabic the
3890
+ * panel slides from the left (`end-0` follows `dir`).
3891
+ */
3892
+ const AssistantLauncher = ({ launcherTexts, open, defaultOpen = false, onOpenChange, expanded, defaultExpanded = false, onExpandedChange, bottomOffset = 16, hidden = false, className, panelClassName, headerAction, voice = true, conversations: hostConversations, activeConversationId: hostActiveId, initialMessages: hostInitialMessages, onConversationCreated, onSelectConversation, onNewConversation, ...chatProps }) => {
3893
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
3894
+ const isOpen = open ?? uncontrolledOpen;
3895
+ const [uncontrolledExpanded, setUncontrolledExpanded] = React.useState(defaultExpanded);
3896
+ const isExpanded = expanded ?? uncontrolledExpanded;
3897
+ const [historyOpen, setHistoryOpen] = React.useState(false);
3898
+ const [draft, setDraft] = React.useState("");
3899
+ const [queuedPrompt, setQueuedPrompt] = React.useState(null);
3900
+ const panelId = React.useId();
3901
+ const barRef = React.useRef(null);
3902
+ const panelRef = React.useRef(null);
3903
+ const wasOpenRef = React.useRef(isOpen);
3904
+ // Managed history (browser store) unless the host provides the list.
3905
+ const hostManaged = hostConversations !== undefined;
3906
+ const [storedConversations, setStoredConversations] = React.useState([]);
3907
+ const [managedActiveId, setManagedActiveId] = React.useState(null);
3908
+ const [managedMessages, setManagedMessages] = React.useState(undefined);
3909
+ React.useEffect(() => {
3910
+ if (!hostManaged)
3911
+ setStoredConversations(listAssistantConversations());
3912
+ }, [hostManaged]);
3913
+ const setOpen = React.useCallback((next) => {
3914
+ if (open === undefined)
3915
+ setUncontrolledOpen(next);
3916
+ onOpenChange?.(next);
3917
+ }, [onOpenChange, open]);
3918
+ const setExpanded = React.useCallback((next) => {
3919
+ if (expanded === undefined)
3920
+ setUncontrolledExpanded(next);
3921
+ onExpandedChange?.(next);
3922
+ if (next)
3923
+ setHistoryOpen(false);
3924
+ }, [expanded, onExpandedChange]);
3925
+ /** Opens the panel; a non-empty text is sent as the first message. */
3926
+ const submitFromBar = React.useCallback((content) => {
3927
+ const trimmed = content.trim();
3928
+ if (trimmed) {
3929
+ setQueuedPrompt({ id: generateId("q"), content: trimmed.slice(0, MAX_MESSAGE_LENGTH) });
3930
+ }
3931
+ setDraft("");
3932
+ setOpen(true);
3933
+ }, [setOpen]);
3934
+ const speech = useSpeechRecognition({
3935
+ language: chatProps.language,
3936
+ onInterim: (text) => setDraft(text),
3937
+ onResult: (text) => submitFromBar(text),
3938
+ });
3939
+ const handleConversationCreated = React.useCallback((conversation) => {
3940
+ if (!hostManaged) {
3941
+ setStoredConversations(saveAssistantConversation(conversation));
3942
+ setManagedActiveId(conversation.id);
3943
+ }
3944
+ onConversationCreated?.(conversation);
3945
+ }, [hostManaged, onConversationCreated]);
3946
+ const handleSelectConversation = React.useCallback((conversationId) => {
3947
+ onSelectConversation?.(conversationId);
3948
+ if (hostManaged || conversationId === managedActiveId)
3949
+ return;
3950
+ const load = async () => {
3951
+ try {
3952
+ const loaded = await chatProps.transport.getConversation?.(conversationId);
3953
+ setManagedMessages(loaded?.messages ?? []);
3954
+ }
3955
+ catch {
3956
+ setManagedMessages([]);
3957
+ }
3958
+ setManagedActiveId(conversationId);
3959
+ };
3960
+ void load();
3961
+ }, [chatProps.transport, hostManaged, managedActiveId, onSelectConversation]);
3962
+ const handleNewConversation = React.useCallback(() => {
3963
+ if (!hostManaged) {
3964
+ setManagedActiveId(null);
3965
+ setManagedMessages(undefined);
3966
+ }
3967
+ onNewConversation?.();
3968
+ }, [hostManaged, onNewConversation]);
3969
+ // Escape closes; the page behind does not scroll while the panel is open.
3970
+ React.useEffect(() => {
3971
+ if (!isOpen || hidden)
3972
+ return;
3973
+ const onKeyDown = (event) => {
3974
+ if (event.key === "Escape")
3975
+ setOpen(false);
3976
+ };
3977
+ document.addEventListener("keydown", onKeyDown);
3978
+ const previousOverflow = document.body.style.overflow;
3979
+ document.body.style.overflow = "hidden";
3980
+ return () => {
3981
+ document.removeEventListener("keydown", onKeyDown);
3982
+ document.body.style.overflow = previousOverflow;
3983
+ };
3984
+ }, [hidden, isOpen, setOpen]);
3985
+ // Focus: into the composer on open, back to the bar on close.
3986
+ React.useEffect(() => {
3987
+ if (isOpen) {
3988
+ panelRef.current?.querySelector("textarea")?.focus();
3989
+ }
3990
+ else if (wasOpenRef.current) {
3991
+ barRef.current?.focus();
3992
+ }
3993
+ wasOpenRef.current = isOpen;
3994
+ }, [isOpen]);
3995
+ if (hidden)
3996
+ return null;
3997
+ const dir = DIRECTION[chatProps.language];
3998
+ const showMic = voice && speech.supported;
3999
+ const expandLabel = launcherTexts.expand ?? launcherTexts.open;
4000
+ const collapseLabel = launcherTexts.collapse ?? launcherTexts.close;
4001
+ const texts = launcherTexts.history ? { ...chatProps.texts, history: launcherTexts.history } : chatProps.texts;
4002
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [!isOpen && (jsxRuntime.jsx("div", { dir: dir, className: "pointer-events-none fixed inset-x-0 z-40 flex justify-center px-4", style: { bottom: bottomOffset }, "data-testid": "assistant-launcher-bar", children: jsxRuntime.jsxs("div", { role: "group", "aria-label": launcherTexts.open, className: cn("pointer-events-auto flex w-full max-w-2xl items-center gap-2 rounded-2xl border border-ews-gray-100 bg-white py-2 pe-2 ps-3", "shadow-[0_8px_30px_rgba(15,23,42,0.14)]", className), children: [jsxRuntime.jsx("button", { ref: barRef, type: "button", onClick: () => setOpen(true), "aria-label": launcherTexts.open, "aria-haspopup": "dialog", "aria-expanded": false, "aria-controls": panelId, className: "flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-ews-primary to-ews-primary-hover text-base font-bold text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-ews-primary/40", "data-testid": "assistant-launcher", children: chatProps.texts.brandName.charAt(0) }), jsxRuntime.jsx("input", { type: "text", readOnly: true, value: draft, onMouseDown: (event) => {
4003
+ event.preventDefault();
4004
+ setOpen(true);
4005
+ }, onFocus: () => setOpen(true), onKeyDown: (event) => {
4006
+ if (event.key === "Enter" || event.key === " ") {
4007
+ event.preventDefault();
4008
+ setOpen(true);
4009
+ }
4010
+ }, placeholder: speech.listening ? chatProps.texts.listening : launcherTexts.open, "aria-label": launcherTexts.open, "aria-haspopup": "dialog", "aria-controls": panelId, className: "min-w-0 flex-1 cursor-text bg-transparent py-2 text-lg text-ews-gray-800 placeholder:text-ews-gray-400 focus:outline-none", "data-testid": "assistant-launcher-input" }), showMic && (jsxRuntime.jsx("button", { type: "button", "aria-label": chatProps.texts.holdToTalk, "aria-pressed": speech.listening, title: chatProps.texts.holdToTalk, onMouseDown: speech.start, onMouseUp: speech.stop, onMouseLeave: speech.listening ? speech.stop : undefined, onTouchStart: speech.start, onTouchEnd: speech.stop, onKeyDown: (event) => {
4011
+ if (event.key === " " || event.key === "Enter") {
4012
+ event.preventDefault();
4013
+ if (speech.listening)
4014
+ speech.stop();
4015
+ else
4016
+ speech.start();
4017
+ }
4018
+ }, className: cn("flex h-10 w-10 shrink-0 items-center justify-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-ews-primary", speech.listening
4019
+ ? "bg-ews-error text-white animate-pulse"
4020
+ : "text-ews-gray-600 hover:bg-ews-gray-100 hover:text-ews-primary"), "data-testid": "assistant-launcher-mic", children: jsxRuntime.jsx(lucideReact.Mic, { className: "h-5 w-5", "aria-hidden": "true" }) })), jsxRuntime.jsx("button", { type: "button", onClick: () => setOpen(true), "aria-label": launcherTexts.voice ?? launcherTexts.open, title: launcherTexts.voice ?? launcherTexts.open, className: "flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-ews-gray-100 text-ews-gray-700 transition-colors hover:bg-ews-gray-200 focus:outline-none focus:ring-2 focus:ring-ews-primary", "data-testid": "assistant-launcher-voice", children: jsxRuntime.jsx(lucideReact.AudioLines, { className: "h-5 w-5", "aria-hidden": "true" }) }), jsxRuntime.jsx("button", { type: "button", onClick: () => submitFromBar(draft), "aria-label": chatProps.texts.send, title: chatProps.texts.send, className: "flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-ews-primary text-white shadow-sm transition-colors hover:bg-ews-primary-hover focus:outline-none focus:ring-2 focus:ring-ews-primary focus:ring-offset-1", "data-testid": "assistant-launcher-send", children: jsxRuntime.jsx(lucideReact.ArrowUp, { className: "h-5 w-5", "aria-hidden": "true" }) })] }) })), jsxRuntime.jsxs("div", { dir: dir, className: cn("fixed inset-0 z-[90]", !isOpen && "hidden"), "aria-hidden": !isOpen, "data-testid": "assistant-launcher-panel", children: [jsxRuntime.jsx("div", { className: "absolute inset-0 bg-black/40", onClick: () => setOpen(false), "data-testid": "assistant-launcher-overlay" }), jsxRuntime.jsx("section", { ref: panelRef, id: panelId, role: "dialog", "aria-modal": "true", "aria-label": launcherTexts.open, "data-expanded": isExpanded, className: cn("absolute inset-y-0 end-0 flex w-full flex-col bg-white shadow-2xl transition-[width,max-width] duration-200",
4021
+ // Expanded: a dashboard reaching the middle of the screen (never full width), tablets and up.
4022
+ isExpanded ? "sm:max-w-md md:w-[max(50vw,40rem)] md:max-w-none" : "sm:max-w-md", panelClassName), children: jsxRuntime.jsx(AssistantChat, { ...chatProps, texts: texts, voice: voice, layout: isExpanded ? "wide" : "panel", queuedPrompt: queuedPrompt, conversations: hostManaged ? hostConversations : storedConversations, activeConversationId: hostManaged ? hostActiveId : managedActiveId, initialMessages: hostManaged ? hostInitialMessages : managedMessages, onConversationCreated: handleConversationCreated, onSelectConversation: handleSelectConversation, onNewConversation: handleNewConversation, historyOpen: historyOpen, onHistoryOpenChange: setHistoryOpen, className: "flex-1", headerAction: jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [headerAction, jsxRuntime.jsx("button", { type: "button", onClick: () => setExpanded(!isExpanded), "aria-label": isExpanded ? collapseLabel : expandLabel, "aria-pressed": isExpanded, title: isExpanded ? collapseLabel : expandLabel, className: "hidden rounded-full p-2 text-ews-gray-500 hover:bg-ews-gray-100 hover:text-ews-gray-900 focus:outline-none focus:ring-2 focus:ring-ews-primary md:inline-flex", "data-testid": "assistant-launcher-expand", children: isExpanded ? (jsxRuntime.jsx(lucideReact.Minimize2, { className: "h-5 w-5", "aria-hidden": "true" })) : (jsxRuntime.jsx(lucideReact.Maximize2, { className: "h-5 w-5", "aria-hidden": "true" })) }), jsxRuntime.jsx("button", { type: "button", onClick: () => setOpen(false), "aria-label": launcherTexts.close, title: launcherTexts.close, className: "rounded-full p-2 text-ews-gray-500 hover:bg-ews-gray-100 hover:text-ews-gray-900 focus:outline-none focus:ring-2 focus:ring-ews-primary", "data-testid": "assistant-launcher-close", children: jsxRuntime.jsx(lucideReact.X, { className: "h-5 w-5", "aria-hidden": "true" }) })] }) }) })] })] }));
4023
+ };
4024
+
4025
+ /** Turns one SSE text stream into assistant events; frames may arrive split across chunks. */
4026
+ async function* parseAssistantEventStream(body) {
4027
+ const reader = body.getReader();
4028
+ const decoder = new TextDecoder();
4029
+ let buffer = "";
4030
+ try {
4031
+ while (true) {
4032
+ const { value, done } = await reader.read();
4033
+ if (done)
4034
+ break;
4035
+ buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
4036
+ let separator = buffer.indexOf("\n\n");
4037
+ while (separator >= 0) {
4038
+ const frame = buffer.slice(0, separator);
4039
+ buffer = buffer.slice(separator + 2);
4040
+ const event = parseFrame(frame);
4041
+ if (event)
4042
+ yield event;
4043
+ separator = buffer.indexOf("\n\n");
4044
+ }
4045
+ }
4046
+ const tail = parseFrame(buffer);
4047
+ if (tail)
4048
+ yield tail;
4049
+ }
4050
+ finally {
4051
+ reader.releaseLock();
4052
+ }
4053
+ }
4054
+ function parseFrame(frame) {
4055
+ let type = "";
4056
+ const dataLines = [];
4057
+ for (const rawLine of frame.split("\n")) {
4058
+ const line = rawLine.replace(/\r$/, "");
4059
+ if (line.startsWith("event:"))
4060
+ type = line.slice(6).trim();
4061
+ else if (line.startsWith("data:"))
4062
+ dataLines.push(line.slice(5).trimStart());
4063
+ }
4064
+ if (!type || dataLines.length === 0)
4065
+ return null;
4066
+ try {
4067
+ const data = JSON.parse(dataLines.join("\n"));
4068
+ return { type, ...data };
4069
+ }
4070
+ catch {
4071
+ return null;
4072
+ }
4073
+ }
4074
+ async function errorEvent(response) {
4075
+ let body = {};
4076
+ try {
4077
+ body = (await response.json());
4078
+ }
4079
+ catch {
4080
+ // no JSON body: keep the status
4081
+ }
4082
+ const retryAfterHeader = Number(response.headers.get("retry-after"));
4083
+ const code = body.code ??
4084
+ (response.status === 429
4085
+ ? "RATE_LIMITED"
4086
+ : response.status === 404
4087
+ ? "CONVERSATION_NOT_FOUND"
4088
+ : "MODEL_UNAVAILABLE");
4089
+ return {
4090
+ type: "error",
4091
+ code,
4092
+ message: body.message ?? `HTTP ${response.status}`,
4093
+ retryAfterSeconds: body.retryAfterSeconds ??
4094
+ (Number.isFinite(retryAfterHeader) && retryAfterHeader > 0 ? retryAfterHeader : undefined),
4095
+ };
4096
+ }
4097
+ function createAssistantTransport({ baseUrl, clientId, language, fetchImpl, }) {
4098
+ const base = `${baseUrl.replace(/\/+$/, "")}/assistant`;
4099
+ const doFetch = (input, init) => (fetchImpl ?? fetch)(input, { credentials: "include", ...init });
4100
+ const headers = { "Content-Type": "application/json", "Accept-Language": language };
4101
+ return {
4102
+ async startConversation(conversationLanguage) {
4103
+ const response = await doFetch(`${base}/conversations`, {
4104
+ method: "POST",
4105
+ headers,
4106
+ body: JSON.stringify({ language: conversationLanguage, persona: "visitor", clientId }),
4107
+ });
4108
+ if (!response.ok) {
4109
+ const error = await errorEvent(response);
4110
+ throw new Error(error.type === "error" ? error.message : `HTTP ${response.status}`);
4111
+ }
4112
+ const created = (await response.json());
4113
+ return { id: created.id };
4114
+ },
4115
+ async *sendMessage(conversationId, content, options) {
4116
+ const response = await doFetch(`${base}/conversations/${encodeURIComponent(conversationId)}/messages`, {
4117
+ method: "POST",
4118
+ headers: { ...headers, Accept: "text/event-stream" },
4119
+ body: JSON.stringify({ content }),
4120
+ signal: options?.signal,
4121
+ });
4122
+ if (!response.ok) {
4123
+ yield await errorEvent(response);
4124
+ return;
4125
+ }
4126
+ if (!response.body) {
4127
+ yield { type: "error", code: "NETWORK", message: "Empty response" };
4128
+ return;
4129
+ }
4130
+ yield* parseAssistantEventStream(response.body);
4131
+ },
4132
+ async getConversation(conversationId) {
4133
+ const response = await doFetch(`${base}/conversations/${encodeURIComponent(conversationId)}`, {
4134
+ headers,
4135
+ });
4136
+ if (!response.ok)
4137
+ throw new Error(`HTTP ${response.status}`);
4138
+ const data = (await response.json());
4139
+ return {
4140
+ id: data.id,
4141
+ messages: data.messages.map((message) => ({ ...message, status: "done" })),
4142
+ };
4143
+ },
4144
+ };
4145
+ }
4146
+
3417
4147
  const FIRST_STEP = {
3418
4148
  create: "new",
3419
4149
  unlock: "unlock",
@@ -3651,6 +4381,16 @@ const SpecialtySearchAutocomplete = ({ selectedSpecialties = [], onSpecialtiesCh
3651
4381
  : `${selectedSpecialties.length} specialty${selectedSpecialties.length !== 1 ? "ies" : ""} selected` }), maxSelections && (jsxRuntime.jsxs("span", { className: "text-gray-400", children: [selectedSpecialties.length, "/", maxSelections] }))] }))] }));
3652
4382
  };
3653
4383
 
4384
+ exports.ASSISTANT_CLIENT_ID_KEY = ASSISTANT_CLIENT_ID_KEY;
4385
+ exports.ASSISTANT_CONVERSATIONS_KEY = ASSISTANT_CONVERSATIONS_KEY;
4386
+ exports.ASSISTANT_MAX_CONVERSATIONS = ASSISTANT_MAX_CONVERSATIONS;
4387
+ exports.AssistantChat = AssistantChat;
4388
+ exports.AssistantComposer = AssistantComposer;
4389
+ exports.AssistantConfirmationCard = AssistantConfirmationCard;
4390
+ exports.AssistantConversationList = AssistantConversationList;
4391
+ exports.AssistantLauncher = AssistantLauncher;
4392
+ exports.AssistantMessages = AssistantMessages;
4393
+ exports.AssistantSuggestionCard = AssistantSuggestionCard;
3654
4394
  exports.BLOOD_TYPES = BLOOD_TYPES;
3655
4395
  exports.Button = Button;
3656
4396
  exports.COUNTRY_CODES = COUNTRY_CODES;
@@ -3661,6 +4401,7 @@ exports.ImageCropModal = ImageCropModal;
3661
4401
  exports.Input = Input;
3662
4402
  exports.LockScreen = LockScreen;
3663
4403
  exports.Logo = Logo;
4404
+ exports.MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH;
3664
4405
  exports.Modal = Modal;
3665
4406
  exports.MultiSearchAutocomplete = MultiSearchAutocomplete;
3666
4407
  exports.PatientIcon = PatientIcon;
@@ -3681,6 +4422,8 @@ exports.ThemeProvider = ThemeProvider;
3681
4422
  exports.ThemeToggle = ThemeToggle;
3682
4423
  exports.UserIcon = UserIcon;
3683
4424
  exports.cn = cn;
4425
+ exports.conversationTitle = conversationTitle;
4426
+ exports.createAssistantTransport = createAssistantTransport;
3684
4427
  exports.createEnvConfig = createEnvConfig;
3685
4428
  exports.debounce = debounce;
3686
4429
  exports.extractCountryCodeFromPhoneNumber = extractCountryCodeFromPhoneNumber;
@@ -3690,16 +4433,24 @@ exports.formatNumeric = formatNumeric;
3690
4433
  exports.formatPhoneNumberForDisplay = formatPhoneNumberForDisplay;
3691
4434
  exports.formatPhoneNumberWithCountryCode = formatPhoneNumberWithCountryCode;
3692
4435
  exports.generateId = generateId;
4436
+ exports.getAssistantClientId = getAssistantClientId;
3693
4437
  exports.getDefaultCountryCode = getDefaultCountryCode;
3694
4438
  exports.getRegionOptions = getRegionOptions;
3695
4439
  exports.getRegionsByCountry = getRegionsByCountry;
4440
+ exports.getSpeechRecognition = getSpeechRecognition;
4441
+ exports.isExternalLink = isExternalLink;
3696
4442
  exports.isValidPhoneNumber = isValidPhoneNumber;
4443
+ exports.listAssistantConversations = listAssistantConversations;
4444
+ exports.parseAssistantEventStream = parseAssistantEventStream;
3697
4445
  exports.parseDateSafe = parseDateSafe;
4446
+ exports.renderAssistantMarkdown = renderAssistantMarkdown;
4447
+ exports.saveAssistantConversation = saveAssistantConversation;
3698
4448
  exports.useDebounce = useDebounce;
3699
4449
  exports.useDebouncedCallback = useDebouncedCallback;
3700
4450
  exports.useIdleLock = useIdleLock;
3701
4451
  exports.useMediaQuery = useMediaQuery;
3702
4452
  exports.useSelectField = useSelectField;
4453
+ exports.useSpeechRecognition = useSpeechRecognition;
3703
4454
  exports.useTheme = useTheme;
3704
4455
  Object.keys(lucideReact).forEach(function (k) {
3705
4456
  if (k !== 'default' && !Object.prototype.hasOwnProperty.call(exports, k)) Object.defineProperty(exports, k, {