@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
@@ -0,0 +1,105 @@
1
+ import React from "react";
2
+
3
+ /**
4
+ * The tiny markdown subset the assistant contract allows: paragraphs, bullet
5
+ * lists, **bold** and [links](url). Everything else is plain text, so a
6
+ * payload can never inject markup. Links to the current origin open in the
7
+ * same tab (the host app may route them itself through `onLinkClick`);
8
+ * links to other sites open in a new tab.
9
+ */
10
+ const INLINE = /(\*\*[^*]+\*\*|\[[^\]]+\]\((?:https?:\/\/|\/)[^)\s]+\))/g;
11
+
12
+ export interface AssistantMarkdownOptions {
13
+ /**
14
+ * Called when a link is clicked, before the browser follows it. Call
15
+ * `event.preventDefault()` to navigate otherwise (e.g. the host router).
16
+ */
17
+ onLinkClick?: (href: string, event: React.MouseEvent<HTMLAnchorElement>) => void;
18
+ /** Whether a link leaves the current site; such links open in a new tab. */
19
+ isExternal?: (href: string) => boolean;
20
+ }
21
+
22
+ /** Absolute URL on another origin. Without a window (server rendering) every absolute URL counts as external. */
23
+ export function isExternalLink(href: string): boolean {
24
+ if (!/^https?:\/\//i.test(href)) return false;
25
+ if (typeof window === "undefined" || !window.location?.origin) return true;
26
+ try {
27
+ return new URL(href).origin !== window.location.origin;
28
+ } catch {
29
+ return true;
30
+ }
31
+ }
32
+
33
+ function renderInline(text: string, keyPrefix: string, options: AssistantMarkdownOptions): React.ReactNode[] {
34
+ const nodes: React.ReactNode[] = [];
35
+ const isExternal = options.isExternal ?? isExternalLink;
36
+ let last = 0;
37
+ let index = 0;
38
+ for (const match of text.matchAll(INLINE)) {
39
+ const start = match.index ?? 0;
40
+ if (start > last) nodes.push(text.slice(last, start));
41
+ const token = match[0];
42
+ if (token.startsWith("**")) {
43
+ nodes.push(
44
+ <strong key={`${keyPrefix}-b${index}`}>{token.slice(2, -2)}</strong>
45
+ );
46
+ } else {
47
+ const close = token.indexOf("](");
48
+ const label = token.slice(1, close);
49
+ const href = token.slice(close + 2, -1);
50
+ const external = isExternal(href);
51
+ nodes.push(
52
+ <a
53
+ key={`${keyPrefix}-a${index}`}
54
+ href={href}
55
+ className="font-medium text-ews-primary underline underline-offset-2 hover:text-ews-primary-hover"
56
+ target={external ? "_blank" : undefined}
57
+ rel={external ? "noopener noreferrer" : undefined}
58
+ onClick={options.onLinkClick ? (event) => options.onLinkClick?.(href, event) : undefined}
59
+ >
60
+ {label}
61
+ </a>
62
+ );
63
+ }
64
+ last = start + token.length;
65
+ index++;
66
+ }
67
+ if (last < text.length) nodes.push(text.slice(last));
68
+ return nodes;
69
+ }
70
+
71
+ export function renderAssistantMarkdown(
72
+ content: string,
73
+ options: AssistantMarkdownOptions = {}
74
+ ): React.ReactNode[] {
75
+ const blocks = content.replace(/\r\n/g, "\n").split(/\n{2,}/);
76
+ const out: React.ReactNode[] = [];
77
+ blocks.forEach((block, blockIndex) => {
78
+ const lines = block.split("\n").filter((line) => line.trim().length > 0);
79
+ if (lines.length === 0) return;
80
+ const isList = lines.every((line) => /^\s*[-*•]\s+/.test(line));
81
+ if (isList) {
82
+ out.push(
83
+ <ul key={`l${blockIndex}`} className="my-2 list-disc space-y-1 ps-5">
84
+ {lines.map((line, lineIndex) => (
85
+ <li key={lineIndex}>
86
+ {renderInline(line.replace(/^\s*[-*•]\s+/, ""), `l${blockIndex}-${lineIndex}`, options)}
87
+ </li>
88
+ ))}
89
+ </ul>
90
+ );
91
+ } else {
92
+ out.push(
93
+ <p key={`p${blockIndex}`} className="my-2 first:mt-0 last:mb-0">
94
+ {lines.map((line, lineIndex) => (
95
+ <React.Fragment key={lineIndex}>
96
+ {lineIndex > 0 && <br />}
97
+ {renderInline(line, `p${blockIndex}-${lineIndex}`, options)}
98
+ </React.Fragment>
99
+ ))}
100
+ </p>
101
+ );
102
+ }
103
+ });
104
+ return out;
105
+ }
@@ -0,0 +1,25 @@
1
+ export { AssistantChat, conversationTitle } from "./AssistantChat";
2
+ export { AssistantComposer, MAX_MESSAGE_LENGTH } from "./AssistantComposer";
3
+ export type { AssistantComposerProps } from "./AssistantComposer";
4
+ export { AssistantConfirmationCard } from "./AssistantConfirmationCard";
5
+ export type { AssistantConfirmationCardProps } from "./AssistantConfirmationCard";
6
+ export { AssistantConversationList } from "./AssistantConversationList";
7
+ export type { AssistantConversationListProps } from "./AssistantConversationList";
8
+ export { AssistantMessages } from "./AssistantMessages";
9
+ export type { AssistantMessagesProps } from "./AssistantMessages";
10
+ export { AssistantSuggestionCard } from "./AssistantSuggestionCard";
11
+ export type { AssistantSuggestionCardProps } from "./AssistantSuggestionCard";
12
+ export { isExternalLink, renderAssistantMarkdown } from "./assistantMarkdown";
13
+ export type { AssistantMarkdownOptions } from "./assistantMarkdown";
14
+ export type {
15
+ AssistantChatProps,
16
+ AssistantChatTexts,
17
+ AssistantCitation,
18
+ AssistantConfirmation,
19
+ AssistantConversationSummary,
20
+ AssistantEvent,
21
+ AssistantLanguage,
22
+ AssistantMessage,
23
+ AssistantSuggestion,
24
+ AssistantTransport,
25
+ } from "./AssistantChat.types";
@@ -0,0 +1,408 @@
1
+ import { ArrowUp, AudioLines, Maximize2, Mic, Minimize2, X } from "lucide-react";
2
+ import React, { useCallback, useEffect, useId, useRef, useState } from "react";
3
+ import { listAssistantConversations, saveAssistantConversation } from "../../assistant/store";
4
+ import { useSpeechRecognition } from "../../hooks/useSpeechRecognition";
5
+ import { cn, generateId } from "../../utils";
6
+ import { AssistantChat } from "../AssistantChat/AssistantChat";
7
+ import type {
8
+ AssistantChatProps,
9
+ AssistantConversationSummary,
10
+ AssistantMessage,
11
+ } from "../AssistantChat/AssistantChat.types";
12
+ import { MAX_MESSAGE_LENGTH } from "../AssistantChat/AssistantComposer";
13
+
14
+ export interface AssistantLauncherTexts {
15
+ /** Placeholder of the bar ("Posez une question…"); also the dialog's accessible name. */
16
+ open: string;
17
+ /** The close button of the panel. */
18
+ close: string;
19
+ /** The voice button of the bar; falls back to `open`. */
20
+ voice?: string;
21
+ /** The expand / collapse button of the panel; fall back to `open` / `close`. */
22
+ expand?: string;
23
+ collapse?: string;
24
+ /** The history button of the panel; falls back to `texts.recentConversations`. */
25
+ history?: string;
26
+ }
27
+
28
+ export interface AssistantLauncherProps
29
+ extends Omit<AssistantChatProps, "className" | "layout" | "queuedPrompt" | "historyOpen" | "onHistoryOpenChange"> {
30
+ launcherTexts: AssistantLauncherTexts;
31
+ /** Controlled open state; the launcher manages it when absent. */
32
+ open?: boolean;
33
+ defaultOpen?: boolean;
34
+ onOpenChange?: (open: boolean) => void;
35
+ /** Controlled expanded (wide) state; the launcher manages it when absent. */
36
+ expanded?: boolean;
37
+ defaultExpanded?: boolean;
38
+ onExpandedChange?: (expanded: boolean) => void;
39
+ /**
40
+ * Pixels between the bar and the bottom of the viewport, so the bar sits
41
+ * above a host bottom navigation. Default 16.
42
+ */
43
+ bottomOffset?: number;
44
+ /** Renders nothing at all (e.g. a feature toggle off). */
45
+ hidden?: boolean;
46
+ /** Extra classes on the bar. */
47
+ className?: string;
48
+ /** Extra classes on the panel (e.g. a wider max width). */
49
+ panelClassName?: string;
50
+ }
51
+
52
+ const DIRECTION: Record<AssistantChatProps["language"], "ltr" | "rtl"> = {
53
+ fr: "ltr",
54
+ en: "ltr",
55
+ ar: "rtl",
56
+ };
57
+
58
+ /**
59
+ * MED-1787: the in-app entry to the assistant for Medecine360-ui and the
60
+ * dashboard. A floating bar at the bottom of the page (brand badge, a text
61
+ * field "Posez une question…", push-to-talk mic, voice button, send) opens a
62
+ * slide-over panel hosting AssistantChat in its `panel` layout; clicking the
63
+ * field opens the panel too, a spoken question is sent as it opens. From
64
+ * tablets up the panel can be expanded to a dashboard reaching the middle of
65
+ * the screen, with the conversation list beside the thread; in the narrow
66
+ * layout a history button shows that list over the thread.
67
+ *
68
+ * Conversation history is managed here with the shared browser store unless
69
+ * the host passes `conversations` itself: new conversations are remembered,
70
+ * selecting one loads it through `transport.getConversation`.
71
+ *
72
+ * The chat stays mounted while the panel is closed, so the conversation in
73
+ * progress survives open/close. Escape and the overlay close the panel; focus
74
+ * goes to the composer on open and back to the bar on close. In Arabic the
75
+ * panel slides from the left (`end-0` follows `dir`).
76
+ */
77
+ export const AssistantLauncher: React.FC<AssistantLauncherProps> = ({
78
+ launcherTexts,
79
+ open,
80
+ defaultOpen = false,
81
+ onOpenChange,
82
+ expanded,
83
+ defaultExpanded = false,
84
+ onExpandedChange,
85
+ bottomOffset = 16,
86
+ hidden = false,
87
+ className,
88
+ panelClassName,
89
+ headerAction,
90
+ voice = true,
91
+ conversations: hostConversations,
92
+ activeConversationId: hostActiveId,
93
+ initialMessages: hostInitialMessages,
94
+ onConversationCreated,
95
+ onSelectConversation,
96
+ onNewConversation,
97
+ ...chatProps
98
+ }) => {
99
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
100
+ const isOpen = open ?? uncontrolledOpen;
101
+ const [uncontrolledExpanded, setUncontrolledExpanded] = useState(defaultExpanded);
102
+ const isExpanded = expanded ?? uncontrolledExpanded;
103
+ const [historyOpen, setHistoryOpen] = useState(false);
104
+ const [draft, setDraft] = useState("");
105
+ const [queuedPrompt, setQueuedPrompt] = useState<{ id: string; content: string } | null>(null);
106
+ const panelId = useId();
107
+ const barRef = useRef<HTMLButtonElement>(null);
108
+ const panelRef = useRef<HTMLElement>(null);
109
+ const wasOpenRef = useRef(isOpen);
110
+
111
+ // Managed history (browser store) unless the host provides the list.
112
+ const hostManaged = hostConversations !== undefined;
113
+ const [storedConversations, setStoredConversations] = useState<AssistantConversationSummary[]>([]);
114
+ const [managedActiveId, setManagedActiveId] = useState<string | null>(null);
115
+ const [managedMessages, setManagedMessages] = useState<AssistantMessage[] | undefined>(undefined);
116
+ useEffect(() => {
117
+ if (!hostManaged) setStoredConversations(listAssistantConversations());
118
+ }, [hostManaged]);
119
+
120
+ const setOpen = useCallback(
121
+ (next: boolean) => {
122
+ if (open === undefined) setUncontrolledOpen(next);
123
+ onOpenChange?.(next);
124
+ },
125
+ [onOpenChange, open]
126
+ );
127
+
128
+ const setExpanded = useCallback(
129
+ (next: boolean) => {
130
+ if (expanded === undefined) setUncontrolledExpanded(next);
131
+ onExpandedChange?.(next);
132
+ if (next) setHistoryOpen(false);
133
+ },
134
+ [expanded, onExpandedChange]
135
+ );
136
+
137
+ /** Opens the panel; a non-empty text is sent as the first message. */
138
+ const submitFromBar = useCallback(
139
+ (content: string) => {
140
+ const trimmed = content.trim();
141
+ if (trimmed) {
142
+ setQueuedPrompt({ id: generateId("q"), content: trimmed.slice(0, MAX_MESSAGE_LENGTH) });
143
+ }
144
+ setDraft("");
145
+ setOpen(true);
146
+ },
147
+ [setOpen]
148
+ );
149
+
150
+ const speech = useSpeechRecognition({
151
+ language: chatProps.language,
152
+ onInterim: (text) => setDraft(text),
153
+ onResult: (text) => submitFromBar(text),
154
+ });
155
+
156
+ const handleConversationCreated = useCallback(
157
+ (conversation: AssistantConversationSummary) => {
158
+ if (!hostManaged) {
159
+ setStoredConversations(saveAssistantConversation(conversation));
160
+ setManagedActiveId(conversation.id);
161
+ }
162
+ onConversationCreated?.(conversation);
163
+ },
164
+ [hostManaged, onConversationCreated]
165
+ );
166
+
167
+ const handleSelectConversation = useCallback(
168
+ (conversationId: string) => {
169
+ onSelectConversation?.(conversationId);
170
+ if (hostManaged || conversationId === managedActiveId) return;
171
+ const load = async () => {
172
+ try {
173
+ const loaded = await chatProps.transport.getConversation?.(conversationId);
174
+ setManagedMessages(loaded?.messages ?? []);
175
+ } catch {
176
+ setManagedMessages([]);
177
+ }
178
+ setManagedActiveId(conversationId);
179
+ };
180
+ void load();
181
+ },
182
+ [chatProps.transport, hostManaged, managedActiveId, onSelectConversation]
183
+ );
184
+
185
+ const handleNewConversation = useCallback(() => {
186
+ if (!hostManaged) {
187
+ setManagedActiveId(null);
188
+ setManagedMessages(undefined);
189
+ }
190
+ onNewConversation?.();
191
+ }, [hostManaged, onNewConversation]);
192
+
193
+ // Escape closes; the page behind does not scroll while the panel is open.
194
+ useEffect(() => {
195
+ if (!isOpen || hidden) return;
196
+ const onKeyDown = (event: KeyboardEvent) => {
197
+ if (event.key === "Escape") setOpen(false);
198
+ };
199
+ document.addEventListener("keydown", onKeyDown);
200
+ const previousOverflow = document.body.style.overflow;
201
+ document.body.style.overflow = "hidden";
202
+ return () => {
203
+ document.removeEventListener("keydown", onKeyDown);
204
+ document.body.style.overflow = previousOverflow;
205
+ };
206
+ }, [hidden, isOpen, setOpen]);
207
+
208
+ // Focus: into the composer on open, back to the bar on close.
209
+ useEffect(() => {
210
+ if (isOpen) {
211
+ panelRef.current?.querySelector("textarea")?.focus();
212
+ } else if (wasOpenRef.current) {
213
+ barRef.current?.focus();
214
+ }
215
+ wasOpenRef.current = isOpen;
216
+ }, [isOpen]);
217
+
218
+ if (hidden) return null;
219
+
220
+ const dir = DIRECTION[chatProps.language];
221
+ const showMic = voice && speech.supported;
222
+ const expandLabel = launcherTexts.expand ?? launcherTexts.open;
223
+ const collapseLabel = launcherTexts.collapse ?? launcherTexts.close;
224
+ const texts = launcherTexts.history ? { ...chatProps.texts, history: launcherTexts.history } : chatProps.texts;
225
+
226
+ return (
227
+ <>
228
+ {!isOpen && (
229
+ <div
230
+ dir={dir}
231
+ className="pointer-events-none fixed inset-x-0 z-40 flex justify-center px-4"
232
+ style={{ bottom: bottomOffset }}
233
+ data-testid="assistant-launcher-bar"
234
+ >
235
+ <div
236
+ role="group"
237
+ aria-label={launcherTexts.open}
238
+ className={cn(
239
+ "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",
240
+ "shadow-[0_8px_30px_rgba(15,23,42,0.14)]",
241
+ className
242
+ )}
243
+ >
244
+ <button
245
+ ref={barRef}
246
+ type="button"
247
+ onClick={() => setOpen(true)}
248
+ aria-label={launcherTexts.open}
249
+ aria-haspopup="dialog"
250
+ aria-expanded={false}
251
+ aria-controls={panelId}
252
+ 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"
253
+ data-testid="assistant-launcher"
254
+ >
255
+ {chatProps.texts.brandName.charAt(0)}
256
+ </button>
257
+ {/* The field opens the panel: typing happens in the panel composer, which gets the focus. */}
258
+ <input
259
+ type="text"
260
+ readOnly
261
+ value={draft}
262
+ onMouseDown={(event) => {
263
+ event.preventDefault();
264
+ setOpen(true);
265
+ }}
266
+ onFocus={() => setOpen(true)}
267
+ onKeyDown={(event) => {
268
+ if (event.key === "Enter" || event.key === " ") {
269
+ event.preventDefault();
270
+ setOpen(true);
271
+ }
272
+ }}
273
+ placeholder={speech.listening ? chatProps.texts.listening : launcherTexts.open}
274
+ aria-label={launcherTexts.open}
275
+ aria-haspopup="dialog"
276
+ aria-controls={panelId}
277
+ 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"
278
+ data-testid="assistant-launcher-input"
279
+ />
280
+ {showMic && (
281
+ <button
282
+ type="button"
283
+ aria-label={chatProps.texts.holdToTalk}
284
+ aria-pressed={speech.listening}
285
+ title={chatProps.texts.holdToTalk}
286
+ onMouseDown={speech.start}
287
+ onMouseUp={speech.stop}
288
+ onMouseLeave={speech.listening ? speech.stop : undefined}
289
+ onTouchStart={speech.start}
290
+ onTouchEnd={speech.stop}
291
+ onKeyDown={(event) => {
292
+ if (event.key === " " || event.key === "Enter") {
293
+ event.preventDefault();
294
+ if (speech.listening) speech.stop();
295
+ else speech.start();
296
+ }
297
+ }}
298
+ className={cn(
299
+ "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",
300
+ speech.listening
301
+ ? "bg-ews-error text-white animate-pulse"
302
+ : "text-ews-gray-600 hover:bg-ews-gray-100 hover:text-ews-primary"
303
+ )}
304
+ data-testid="assistant-launcher-mic"
305
+ >
306
+ <Mic className="h-5 w-5" aria-hidden="true" />
307
+ </button>
308
+ )}
309
+ <button
310
+ type="button"
311
+ onClick={() => setOpen(true)}
312
+ aria-label={launcherTexts.voice ?? launcherTexts.open}
313
+ title={launcherTexts.voice ?? launcherTexts.open}
314
+ 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"
315
+ data-testid="assistant-launcher-voice"
316
+ >
317
+ <AudioLines className="h-5 w-5" aria-hidden="true" />
318
+ </button>
319
+ <button
320
+ type="button"
321
+ onClick={() => submitFromBar(draft)}
322
+ aria-label={chatProps.texts.send}
323
+ title={chatProps.texts.send}
324
+ 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"
325
+ data-testid="assistant-launcher-send"
326
+ >
327
+ <ArrowUp className="h-5 w-5" aria-hidden="true" />
328
+ </button>
329
+ </div>
330
+ </div>
331
+ )}
332
+
333
+ <div
334
+ dir={dir}
335
+ className={cn("fixed inset-0 z-[90]", !isOpen && "hidden")}
336
+ aria-hidden={!isOpen}
337
+ data-testid="assistant-launcher-panel"
338
+ >
339
+ <div
340
+ className="absolute inset-0 bg-black/40"
341
+ onClick={() => setOpen(false)}
342
+ data-testid="assistant-launcher-overlay"
343
+ />
344
+ <section
345
+ ref={panelRef}
346
+ id={panelId}
347
+ role="dialog"
348
+ aria-modal="true"
349
+ aria-label={launcherTexts.open}
350
+ data-expanded={isExpanded}
351
+ className={cn(
352
+ "absolute inset-y-0 end-0 flex w-full flex-col bg-white shadow-2xl transition-[width,max-width] duration-200",
353
+ // Expanded: a dashboard reaching the middle of the screen (never full width), tablets and up.
354
+ isExpanded ? "sm:max-w-md md:w-[max(50vw,40rem)] md:max-w-none" : "sm:max-w-md",
355
+ panelClassName
356
+ )}
357
+ >
358
+ <AssistantChat
359
+ {...chatProps}
360
+ texts={texts}
361
+ voice={voice}
362
+ layout={isExpanded ? "wide" : "panel"}
363
+ queuedPrompt={queuedPrompt}
364
+ conversations={hostManaged ? hostConversations : storedConversations}
365
+ activeConversationId={hostManaged ? hostActiveId : managedActiveId}
366
+ initialMessages={hostManaged ? hostInitialMessages : managedMessages}
367
+ onConversationCreated={handleConversationCreated}
368
+ onSelectConversation={handleSelectConversation}
369
+ onNewConversation={handleNewConversation}
370
+ historyOpen={historyOpen}
371
+ onHistoryOpenChange={setHistoryOpen}
372
+ className="flex-1"
373
+ headerAction={
374
+ <>
375
+ {headerAction}
376
+ <button
377
+ type="button"
378
+ onClick={() => setExpanded(!isExpanded)}
379
+ aria-label={isExpanded ? collapseLabel : expandLabel}
380
+ aria-pressed={isExpanded}
381
+ title={isExpanded ? collapseLabel : expandLabel}
382
+ 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"
383
+ data-testid="assistant-launcher-expand"
384
+ >
385
+ {isExpanded ? (
386
+ <Minimize2 className="h-5 w-5" aria-hidden="true" />
387
+ ) : (
388
+ <Maximize2 className="h-5 w-5" aria-hidden="true" />
389
+ )}
390
+ </button>
391
+ <button
392
+ type="button"
393
+ onClick={() => setOpen(false)}
394
+ aria-label={launcherTexts.close}
395
+ title={launcherTexts.close}
396
+ 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"
397
+ data-testid="assistant-launcher-close"
398
+ >
399
+ <X className="h-5 w-5" aria-hidden="true" />
400
+ </button>
401
+ </>
402
+ }
403
+ />
404
+ </section>
405
+ </div>
406
+ </>
407
+ );
408
+ };
@@ -0,0 +1,2 @@
1
+ export { AssistantLauncher } from "./AssistantLauncher";
2
+ export type { AssistantLauncherProps, AssistantLauncherTexts } from "./AssistantLauncher";
@@ -4,3 +4,5 @@ export { useIdleLock } from "./useIdleLock";
4
4
  export type { UseIdleLockOptions, UseIdleLockResult } from "./useIdleLock";
5
5
  export { useSelectField } from "./useSelectField";
6
6
  export type { UseSelectFieldProps } from "./useSelectField";
7
+ export { useSpeechRecognition, getSpeechRecognition } from "./useSpeechRecognition";
8
+ export type { UseSpeechRecognitionOptions } from "./useSpeechRecognition";
@@ -0,0 +1,95 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ /** The subset of the Web Speech API the hook uses; browsers expose it unprefixed or as webkit*. */
4
+ interface SpeechRecognitionLike {
5
+ lang: string;
6
+ interimResults: boolean;
7
+ continuous: boolean;
8
+ onresult: ((event: { results: ArrayLike<ArrayLike<{ transcript: string }>> }) => void) | null;
9
+ onerror: ((event: { error?: string }) => void) | null;
10
+ onend: (() => void) | null;
11
+ start(): void;
12
+ stop(): void;
13
+ abort(): void;
14
+ }
15
+
16
+ type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
17
+
18
+ const BCP47: Record<string, string> = { fr: "fr-FR", en: "en-US", ar: "ar-SA" };
19
+
20
+ export function getSpeechRecognition(): SpeechRecognitionCtor | undefined {
21
+ if (typeof window === "undefined") return undefined;
22
+ const w = window as unknown as {
23
+ SpeechRecognition?: SpeechRecognitionCtor;
24
+ webkitSpeechRecognition?: SpeechRecognitionCtor;
25
+ };
26
+ return w.SpeechRecognition ?? w.webkitSpeechRecognition;
27
+ }
28
+
29
+ export interface UseSpeechRecognitionOptions {
30
+ /** `fr`, `en`, `ar` or a BCP 47 tag. */
31
+ language: string;
32
+ /** Called with the final transcript when recognition stops. */
33
+ onResult: (transcript: string) => void;
34
+ /** Called with interim text while the user speaks. */
35
+ onInterim?: (transcript: string) => void;
36
+ }
37
+
38
+ /**
39
+ * Push-to-talk on the browser speech API (MED-1783). `supported` is false on
40
+ * the server and in browsers without the API, so the app can hide the mic.
41
+ */
42
+ export function useSpeechRecognition({
43
+ language,
44
+ onResult,
45
+ onInterim,
46
+ }: UseSpeechRecognitionOptions) {
47
+ const [supported, setSupported] = useState(false);
48
+ const [listening, setListening] = useState(false);
49
+ const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
50
+ const transcriptRef = useRef("");
51
+ const callbacks = useRef({ onResult, onInterim });
52
+ callbacks.current = { onResult, onInterim };
53
+
54
+ // Detected after mount: the server never has the API, and the markup must match.
55
+ useEffect(() => {
56
+ setSupported(getSpeechRecognition() !== undefined);
57
+ }, []);
58
+
59
+ const stop = useCallback(() => {
60
+ recognitionRef.current?.stop();
61
+ }, []);
62
+
63
+ const start = useCallback(() => {
64
+ const Ctor = getSpeechRecognition();
65
+ if (!Ctor || recognitionRef.current) return;
66
+ const recognition = new Ctor();
67
+ recognition.lang = BCP47[language] ?? language;
68
+ recognition.interimResults = true;
69
+ recognition.continuous = true;
70
+ transcriptRef.current = "";
71
+ recognition.onresult = (event) => {
72
+ let text = "";
73
+ for (let i = 0; i < event.results.length; i++) {
74
+ text += event.results[i][0]?.transcript ?? "";
75
+ }
76
+ transcriptRef.current = text.trim();
77
+ callbacks.current.onInterim?.(transcriptRef.current);
78
+ };
79
+ recognition.onerror = () => {
80
+ recognition.stop();
81
+ };
82
+ recognition.onend = () => {
83
+ recognitionRef.current = null;
84
+ setListening(false);
85
+ if (transcriptRef.current) callbacks.current.onResult(transcriptRef.current);
86
+ };
87
+ recognitionRef.current = recognition;
88
+ setListening(true);
89
+ recognition.start();
90
+ }, [language]);
91
+
92
+ useEffect(() => () => recognitionRef.current?.abort(), []);
93
+
94
+ return { supported, listening, start, stop };
95
+ }