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