@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
@@ -2,6 +2,7 @@ export type Environment = "local" | "dev" | "qa" | "prod";
2
2
  export interface EnvConfigOverrides {
3
3
  bffUrl?: string;
4
4
  loginBffUrl?: string;
5
+ assistantBffUrl?: string;
5
6
  appUrl?: string;
6
7
  loginUrl?: string;
7
8
  dashboardUrl?: string;
@@ -11,6 +12,8 @@ export interface EnvConfig {
11
12
  env: Environment;
12
13
  bffUrl: string;
13
14
  loginBffUrl: string;
15
+ /** MED-1783: the assistant BFF (medecine360-assistant-bff), base path included. */
16
+ assistantBffUrl: string;
14
17
  appUrl: string;
15
18
  loginUrl: string;
16
19
  dashboardUrl: string;
@@ -1 +1 @@
1
- {"version":3,"file":"env-config.d.ts","sourceRoot":"","sources":["../../src/utils/env-config.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,KAAK,GAAG,IAAI,GAAG,MAAM,CAAC;AAE1D,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,WAAW,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;CAClB;AA6DD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,MAAM,EACX,SAAS,CAAC,EAAE,kBAAkB,GAC7B,SAAS,CAeX"}
1
+ {"version":3,"file":"env-config.d.ts","sourceRoot":"","sources":["../../src/utils/env-config.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,KAAK,GAAG,IAAI,GAAG,MAAM,CAAC;AAE1D,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,WAAW,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,mFAAmF;IACnF,eAAe,EAAE,MAAM,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAgED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,MAAM,EACX,SAAS,CAAC,EAAE,kBAAkB,GAC7B,SAAS,CAeX"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ews-admin/global-design-system",
3
- "version": "1.35.0",
3
+ "version": "1.36.0",
4
4
  "description": "EWS Global Design System - Reusable components for EWS applications",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.esm.js",
@@ -65,6 +65,7 @@
65
65
  "storybook": "^7.5.0",
66
66
  "tailwindcss": "^3.4.0",
67
67
  "ts-jest": "29",
68
+ "tslib": "^2.9.1",
68
69
  "typescript": "^5.2.2",
69
70
  "vite": "^4.5.0"
70
71
  },
@@ -0,0 +1,11 @@
1
+ export { createAssistantTransport, parseAssistantEventStream } from "./transport";
2
+ export type { AssistantTransportOptions } from "./transport";
3
+ export {
4
+ ASSISTANT_CLIENT_ID_KEY,
5
+ ASSISTANT_CONVERSATIONS_KEY,
6
+ ASSISTANT_MAX_CONVERSATIONS,
7
+ getAssistantClientId,
8
+ listAssistantConversations,
9
+ saveAssistantConversation,
10
+ } from "./store";
11
+ export type { AssistantStorage } from "./store";
@@ -0,0 +1,77 @@
1
+ import type { AssistantConversationSummary } from "../components/AssistantChat/AssistantChat.types";
2
+
3
+ /**
4
+ * Anonymous memory of the assistant, in the browser only (MED-1784, shared by
5
+ * every app since MED-1787): a stable client id and the conversations started
6
+ * here, newest first. Nothing is sent to a server to list them; signing in
7
+ * (phase 2) will attach them to the account.
8
+ */
9
+ export const ASSISTANT_CLIENT_ID_KEY = "assistant.clientId";
10
+ export const ASSISTANT_CONVERSATIONS_KEY = "assistant.conversations";
11
+ export const ASSISTANT_MAX_CONVERSATIONS = 20;
12
+
13
+ export type AssistantStorage = Pick<Storage, "getItem" | "setItem">;
14
+
15
+ function storage(): AssistantStorage | null {
16
+ try {
17
+ return typeof window !== "undefined" ? window.localStorage : null;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function randomId(): string {
24
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
25
+ return crypto.randomUUID();
26
+ }
27
+ return `c-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
28
+ }
29
+
30
+ /** The browser's client id, created on first use. A fresh id per page when storage is unavailable. */
31
+ export function getAssistantClientId(store: AssistantStorage | null = storage()): string {
32
+ const existing = store?.getItem(ASSISTANT_CLIENT_ID_KEY);
33
+ if (existing) return existing;
34
+ const id = randomId();
35
+ try {
36
+ store?.setItem(ASSISTANT_CLIENT_ID_KEY, id);
37
+ } catch {
38
+ // private mode: the id lasts for this page only
39
+ }
40
+ return id;
41
+ }
42
+
43
+ export function listAssistantConversations(
44
+ store: AssistantStorage | null = storage()
45
+ ): AssistantConversationSummary[] {
46
+ try {
47
+ const raw = store?.getItem(ASSISTANT_CONVERSATIONS_KEY);
48
+ if (!raw) return [];
49
+ const parsed = JSON.parse(raw) as unknown;
50
+ if (!Array.isArray(parsed)) return [];
51
+ return parsed.filter(
52
+ (entry): entry is AssistantConversationSummary =>
53
+ typeof entry === "object" &&
54
+ entry !== null &&
55
+ typeof (entry as { id?: unknown }).id === "string"
56
+ );
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+
62
+ /** Adds or refreshes a conversation at the top of the list, capped at ASSISTANT_MAX_CONVERSATIONS. */
63
+ export function saveAssistantConversation(
64
+ conversation: AssistantConversationSummary,
65
+ store: AssistantStorage | null = storage()
66
+ ): AssistantConversationSummary[] {
67
+ const next = [
68
+ conversation,
69
+ ...listAssistantConversations(store).filter((entry) => entry.id !== conversation.id),
70
+ ].slice(0, ASSISTANT_MAX_CONVERSATIONS);
71
+ try {
72
+ store?.setItem(ASSISTANT_CONVERSATIONS_KEY, JSON.stringify(next));
73
+ } catch {
74
+ // private mode: the list lasts for this page only
75
+ }
76
+ return next;
77
+ }
@@ -0,0 +1,159 @@
1
+ import type {
2
+ AssistantEvent,
3
+ AssistantLanguage,
4
+ AssistantMessage,
5
+ AssistantTransport,
6
+ } from "../components/AssistantChat/AssistantChat.types";
7
+
8
+ /**
9
+ * The assistant BFF client shared by every app (MED-1787; contract in
10
+ * projects/medecine360-mcp/docs/assistant-api-contract.md). Fetch rather than
11
+ * EventSource: the message endpoint is a POST. Requests carry the platform
12
+ * session cookie (`credentials: "include"`) so the assistant BFF can derive
13
+ * the persona server-side once it supports it.
14
+ */
15
+ export interface AssistantTransportOptions {
16
+ /** Base URL up to and including /api/v1 (createEnvConfig().assistantBffUrl). */
17
+ baseUrl: string;
18
+ /** Stable browser identifier, for rate limiting and conversation listing. */
19
+ clientId: string;
20
+ language: AssistantLanguage;
21
+ fetchImpl?: typeof fetch;
22
+ }
23
+
24
+ interface ErrorBody {
25
+ code?: string;
26
+ message?: string;
27
+ retryAfterSeconds?: number;
28
+ }
29
+
30
+ /** Turns one SSE text stream into assistant events; frames may arrive split across chunks. */
31
+ export async function* parseAssistantEventStream(
32
+ body: ReadableStream<Uint8Array>
33
+ ): AsyncGenerator<AssistantEvent> {
34
+ const reader = body.getReader();
35
+ const decoder = new TextDecoder();
36
+ let buffer = "";
37
+ try {
38
+ while (true) {
39
+ const { value, done } = await reader.read();
40
+ if (done) break;
41
+ buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
42
+ let separator = buffer.indexOf("\n\n");
43
+ while (separator >= 0) {
44
+ const frame = buffer.slice(0, separator);
45
+ buffer = buffer.slice(separator + 2);
46
+ const event = parseFrame(frame);
47
+ if (event) yield event;
48
+ separator = buffer.indexOf("\n\n");
49
+ }
50
+ }
51
+ const tail = parseFrame(buffer);
52
+ if (tail) yield tail;
53
+ } finally {
54
+ reader.releaseLock();
55
+ }
56
+ }
57
+
58
+ function parseFrame(frame: string): AssistantEvent | null {
59
+ let type = "";
60
+ const dataLines: string[] = [];
61
+ for (const rawLine of frame.split("\n")) {
62
+ const line = rawLine.replace(/\r$/, "");
63
+ if (line.startsWith("event:")) type = line.slice(6).trim();
64
+ else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
65
+ }
66
+ if (!type || dataLines.length === 0) return null;
67
+ try {
68
+ const data = JSON.parse(dataLines.join("\n")) as Record<string, unknown>;
69
+ return { type, ...data } as AssistantEvent;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ async function errorEvent(response: Response): Promise<AssistantEvent> {
76
+ let body: ErrorBody = {};
77
+ try {
78
+ body = (await response.json()) as ErrorBody;
79
+ } catch {
80
+ // no JSON body: keep the status
81
+ }
82
+ const retryAfterHeader = Number(response.headers.get("retry-after"));
83
+ const code =
84
+ body.code ??
85
+ (response.status === 429
86
+ ? "RATE_LIMITED"
87
+ : response.status === 404
88
+ ? "CONVERSATION_NOT_FOUND"
89
+ : "MODEL_UNAVAILABLE");
90
+ return {
91
+ type: "error",
92
+ code,
93
+ message: body.message ?? `HTTP ${response.status}`,
94
+ retryAfterSeconds:
95
+ body.retryAfterSeconds ??
96
+ (Number.isFinite(retryAfterHeader) && retryAfterHeader > 0 ? retryAfterHeader : undefined),
97
+ };
98
+ }
99
+
100
+ export function createAssistantTransport({
101
+ baseUrl,
102
+ clientId,
103
+ language,
104
+ fetchImpl,
105
+ }: AssistantTransportOptions): AssistantTransport {
106
+ const base = `${baseUrl.replace(/\/+$/, "")}/assistant`;
107
+ const doFetch: typeof fetch = (input, init) =>
108
+ (fetchImpl ?? fetch)(input, { credentials: "include", ...init });
109
+ const headers = { "Content-Type": "application/json", "Accept-Language": language };
110
+
111
+ return {
112
+ async startConversation(conversationLanguage) {
113
+ const response = await doFetch(`${base}/conversations`, {
114
+ method: "POST",
115
+ headers,
116
+ body: JSON.stringify({ language: conversationLanguage, persona: "visitor", clientId }),
117
+ });
118
+ if (!response.ok) {
119
+ const error = await errorEvent(response);
120
+ throw new Error(error.type === "error" ? error.message : `HTTP ${response.status}`);
121
+ }
122
+ const created = (await response.json()) as { id: string };
123
+ return { id: created.id };
124
+ },
125
+
126
+ async *sendMessage(conversationId, content, options) {
127
+ const response = await doFetch(
128
+ `${base}/conversations/${encodeURIComponent(conversationId)}/messages`,
129
+ {
130
+ method: "POST",
131
+ headers: { ...headers, Accept: "text/event-stream" },
132
+ body: JSON.stringify({ content }),
133
+ signal: options?.signal,
134
+ }
135
+ );
136
+ if (!response.ok) {
137
+ yield await errorEvent(response);
138
+ return;
139
+ }
140
+ if (!response.body) {
141
+ yield { type: "error", code: "NETWORK", message: "Empty response" };
142
+ return;
143
+ }
144
+ yield* parseAssistantEventStream(response.body);
145
+ },
146
+
147
+ async getConversation(conversationId) {
148
+ const response = await doFetch(`${base}/conversations/${encodeURIComponent(conversationId)}`, {
149
+ headers,
150
+ });
151
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
152
+ const data = (await response.json()) as { id: string; messages: AssistantMessage[] };
153
+ return {
154
+ id: data.id,
155
+ messages: data.messages.map((message) => ({ ...message, status: "done" as const })),
156
+ };
157
+ },
158
+ };
159
+ }
@@ -0,0 +1,390 @@
1
+ import { History, SquarePen } from "lucide-react";
2
+ import React, { useCallback, useEffect, useRef, useState } from "react";
3
+ import { cn, generateId } from "../../utils";
4
+ import type {
5
+ AssistantChatProps,
6
+ AssistantConversationSummary,
7
+ AssistantEvent,
8
+ AssistantMessage,
9
+ } from "./AssistantChat.types";
10
+ import { AssistantComposer } from "./AssistantComposer";
11
+ import { AssistantConfirmationCard } from "./AssistantConfirmationCard";
12
+ import { AssistantConversationList } from "./AssistantConversationList";
13
+ import { AssistantMessages } from "./AssistantMessages";
14
+ import { AssistantSuggestionCard } from "./AssistantSuggestionCard";
15
+
16
+ const DIRECTION: Record<AssistantChatProps["language"], "ltr" | "rtl"> = {
17
+ fr: "ltr",
18
+ en: "ltr",
19
+ ar: "rtl",
20
+ };
21
+
22
+ /** A conversation title is its first message, cut like the mockup's sidebar. */
23
+ export function conversationTitle(firstMessage: string, max = 60): string {
24
+ const oneLine = firstMessage.replace(/\s+/g, " ").trim();
25
+ return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
26
+ }
27
+
28
+ /**
29
+ * The chat home of the Medecine360 assistant (MED-1783, mockup med1):
30
+ * conversation list, header with brand badge, hero with suggestion cards,
31
+ * streamed thread, composer with push-to-talk. UI only: the app provides the
32
+ * transport, the texts, the conversation memory and the links.
33
+ *
34
+ * Server-rendering safe: nothing touches `window` at render time, the
35
+ * speech API is detected after mount.
36
+ */
37
+ export const AssistantChat: React.FC<AssistantChatProps> = ({
38
+ transport,
39
+ language,
40
+ texts,
41
+ suggestions,
42
+ conversations = [],
43
+ activeConversationId = null,
44
+ initialMessages,
45
+ onConversationCreated,
46
+ onSelectConversation,
47
+ onNewConversation,
48
+ signInHref,
49
+ headerAction,
50
+ disclaimer,
51
+ heroBackground,
52
+ voice = true,
53
+ pendingConfirmation = null,
54
+ onConfirm,
55
+ onCancelConfirmation,
56
+ layout = "page",
57
+ queuedPrompt = null,
58
+ onNavigate,
59
+ historyOpen = false,
60
+ onHistoryOpenChange,
61
+ className,
62
+ }) => {
63
+ const [messages, setMessages] = useState<AssistantMessage[]>(initialMessages ?? []);
64
+ const [activity, setActivity] = useState<string | null>(null);
65
+ const [busy, setBusy] = useState(false);
66
+ const conversationIdRef = useRef<string | null>(activeConversationId);
67
+ const abortRef = useRef<AbortController | null>(null);
68
+
69
+ // The app switched conversation: show its messages and talk to it from now on.
70
+ // Not when the app merely echoes the id this component just created (send()
71
+ // already set the ref): the thread in progress must stay on screen.
72
+ useEffect(() => {
73
+ if (conversationIdRef.current === activeConversationId) return;
74
+ conversationIdRef.current = activeConversationId;
75
+ setMessages(initialMessages ?? []);
76
+ setActivity(null);
77
+ }, [activeConversationId, initialMessages]);
78
+
79
+ useEffect(() => () => abortRef.current?.abort(), []);
80
+
81
+ const patchMessage = useCallback((id: string, patch: (message: AssistantMessage) => AssistantMessage) => {
82
+ setMessages((current) => current.map((message) => (message.id === id ? patch(message) : message)));
83
+ }, []);
84
+
85
+ const applyEvent = useCallback(
86
+ (assistantId: string, event: AssistantEvent) => {
87
+ switch (event.type) {
88
+ case "message_start":
89
+ patchMessage(assistantId, (message) => ({ ...message, id: event.messageId || message.id }));
90
+ return event.messageId || assistantId;
91
+ case "text_delta":
92
+ patchMessage(assistantId, (message) => ({ ...message, content: message.content + event.text }));
93
+ return assistantId;
94
+ case "tool_call":
95
+ setActivity(event.status === "started" ? event.label : null);
96
+ return assistantId;
97
+ case "citation":
98
+ patchMessage(assistantId, (message) => ({
99
+ ...message,
100
+ citations: [...(message.citations ?? []), { title: event.title, url: event.url }],
101
+ }));
102
+ return assistantId;
103
+ case "message_end":
104
+ setActivity(null);
105
+ patchMessage(assistantId, (message) => ({ ...message, status: "done" }));
106
+ return assistantId;
107
+ case "error":
108
+ setActivity(null);
109
+ patchMessage(assistantId, (message) => ({
110
+ ...message,
111
+ status: "error",
112
+ error: {
113
+ message:
114
+ event.code === "RATE_LIMITED" && event.retryAfterSeconds
115
+ ? texts.rateLimited(event.retryAfterSeconds)
116
+ : event.message || texts.genericError,
117
+ retryAfterSeconds: event.retryAfterSeconds,
118
+ },
119
+ }));
120
+ return assistantId;
121
+ default:
122
+ return assistantId;
123
+ }
124
+ },
125
+ [patchMessage, texts]
126
+ );
127
+
128
+ const send = useCallback(
129
+ async (content: string) => {
130
+ if (busy) return false;
131
+ setBusy(true);
132
+ const userMessage: AssistantMessage = { id: generateId("u"), role: "user", content };
133
+ let assistantId = generateId("a");
134
+ setMessages((current) => [
135
+ ...current,
136
+ userMessage,
137
+ { id: assistantId, role: "assistant", content: "", status: "streaming" },
138
+ ]);
139
+ const controller = new AbortController();
140
+ abortRef.current = controller;
141
+ try {
142
+ if (!conversationIdRef.current) {
143
+ const created = await transport.startConversation(language);
144
+ conversationIdRef.current = created.id;
145
+ const summary: AssistantConversationSummary = {
146
+ id: created.id,
147
+ title: conversationTitle(content),
148
+ createdAt: new Date().toISOString(),
149
+ };
150
+ onConversationCreated?.(summary);
151
+ }
152
+ for await (const event of transport.sendMessage(conversationIdRef.current, content, {
153
+ signal: controller.signal,
154
+ })) {
155
+ assistantId = applyEvent(assistantId, event);
156
+ }
157
+ } catch (error) {
158
+ setActivity(null);
159
+ patchMessage(assistantId, (message) => ({
160
+ ...message,
161
+ status: "error",
162
+ error: { message: error instanceof Error && error.message ? error.message : texts.genericError },
163
+ }));
164
+ } finally {
165
+ setBusy(false);
166
+ abortRef.current = null;
167
+ }
168
+ return true;
169
+ },
170
+ [applyEvent, busy, language, onConversationCreated, patchMessage, texts.genericError, transport]
171
+ );
172
+
173
+ // A prompt queued from outside (the launcher bar) is sent once, as soon as
174
+ // the component is free.
175
+ const handledPromptRef = useRef<string | null>(null);
176
+ useEffect(() => {
177
+ if (!queuedPrompt || queuedPrompt.id === handledPromptRef.current || busy) return;
178
+ handledPromptRef.current = queuedPrompt.id;
179
+ void send(queuedPrompt.content);
180
+ }, [busy, queuedPrompt, send]);
181
+
182
+ /** Retry re-sends the user message that preceded the failed answer. */
183
+ const retry = useCallback(
184
+ (failed: AssistantMessage) => {
185
+ const index = messages.findIndex((message) => message.id === failed.id);
186
+ const previous = index > 0 ? messages[index - 1] : undefined;
187
+ if (!previous || previous.role !== "user") return;
188
+ setMessages((current) => current.filter((message) => message.id !== failed.id && message.id !== previous.id));
189
+ void send(previous.content);
190
+ },
191
+ [messages, send]
192
+ );
193
+
194
+ const startNewConversation = useCallback(() => {
195
+ abortRef.current?.abort();
196
+ conversationIdRef.current = null;
197
+ setMessages([]);
198
+ setActivity(null);
199
+ onNewConversation?.();
200
+ }, [onNewConversation]);
201
+
202
+ const empty = messages.length === 0;
203
+ const dir = DIRECTION[language];
204
+ // panel: one compact column; wide: the slide-over expanded, list beside the thread.
205
+ const compact = layout === "panel";
206
+ const withList = layout !== "panel";
207
+ const historyLabel = texts.history ?? texts.recentConversations;
208
+
209
+ return (
210
+ <div
211
+ dir={dir}
212
+ lang={language}
213
+ className={cn(
214
+ "overflow-hidden bg-white",
215
+ layout === "page" && "grid min-h-[36rem] grid-cols-1 rounded-2xl border border-ews-gray-200 shadow-sm md:grid-cols-[16rem_1fr]",
216
+ layout === "wide" && "grid h-full min-h-0 grid-cols-1 md:grid-cols-[16rem_1fr]",
217
+ compact && "relative flex h-full min-h-0 flex-col",
218
+ className
219
+ )}
220
+ data-layout={layout}
221
+ data-testid="assistant-chat"
222
+ >
223
+ {withList && (
224
+ <AssistantConversationList
225
+ conversations={conversations}
226
+ activeConversationId={activeConversationId}
227
+ onSelect={onSelectConversation}
228
+ onNew={startNewConversation}
229
+ texts={texts}
230
+ signInHref={signInHref}
231
+ className="hidden border-e border-ews-gray-200 md:flex"
232
+ />
233
+ )}
234
+
235
+ {compact && historyOpen && (
236
+ <div className="absolute inset-0 z-10 flex" data-testid="assistant-history">
237
+ <AssistantConversationList
238
+ conversations={conversations}
239
+ activeConversationId={activeConversationId}
240
+ onSelect={(id) => {
241
+ onSelectConversation?.(id);
242
+ onHistoryOpenChange?.(false);
243
+ }}
244
+ onNew={() => {
245
+ startNewConversation();
246
+ onHistoryOpenChange?.(false);
247
+ }}
248
+ texts={texts}
249
+ signInHref={signInHref}
250
+ className="w-72 max-w-full border-e border-ews-gray-200 shadow-xl"
251
+ />
252
+ <div
253
+ className="flex-1 bg-black/20"
254
+ onClick={() => onHistoryOpenChange?.(false)}
255
+ data-testid="assistant-history-overlay"
256
+ />
257
+ </div>
258
+ )}
259
+
260
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col">
261
+ <header className="flex items-center justify-between gap-3 border-b border-ews-gray-200 px-4 py-3">
262
+ <div className="flex min-w-0 items-center gap-2">
263
+ <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">
264
+ {texts.brandName.charAt(0)}
265
+ </span>
266
+ <span className="truncate font-semibold text-ews-gray-900">{texts.brandName}</span>
267
+ <span className="rounded-full bg-ews-gray-100 px-2 py-0.5 text-xs font-medium text-ews-gray-600">AI</span>
268
+ <span className="flex items-center gap-1 text-xs text-ews-gray-500">
269
+ <span className="h-2 w-2 rounded-full bg-ews-success" aria-hidden="true" />
270
+ {texts.online}
271
+ </span>
272
+ </div>
273
+ <div className="flex shrink-0 items-center gap-1">
274
+ {compact && onHistoryOpenChange && (
275
+ <button
276
+ type="button"
277
+ onClick={() => onHistoryOpenChange(!historyOpen)}
278
+ aria-label={historyLabel}
279
+ aria-pressed={historyOpen}
280
+ title={historyLabel}
281
+ className={cn(
282
+ "rounded-full p-2 hover:bg-ews-gray-100 hover:text-ews-primary focus:outline-none focus:ring-2 focus:ring-ews-primary",
283
+ historyOpen ? "bg-ews-gray-100 text-ews-primary" : "text-ews-gray-500"
284
+ )}
285
+ data-testid="assistant-history-toggle"
286
+ >
287
+ <History className="h-4 w-4" aria-hidden="true" />
288
+ </button>
289
+ )}
290
+ {!withList && !empty && (
291
+ <button
292
+ type="button"
293
+ onClick={startNewConversation}
294
+ aria-label={texts.newConversation}
295
+ title={texts.newConversation}
296
+ 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"
297
+ data-testid="assistant-new-conversation"
298
+ >
299
+ <SquarePen className="h-4 w-4" aria-hidden="true" />
300
+ </button>
301
+ )}
302
+ {headerAction}
303
+ </div>
304
+ </header>
305
+
306
+ <div className="flex flex-1 flex-col overflow-y-auto">
307
+ {empty ? (
308
+ <>
309
+ <section
310
+ className={cn(
311
+ "relative overflow-hidden bg-ews-primary-light text-center",
312
+ compact ? "px-4 py-6" : "px-6 py-10"
313
+ )}
314
+ >
315
+ {heroBackground && <div className="absolute inset-0" aria-hidden="true">{heroBackground}</div>}
316
+ <div className="relative">
317
+ <h2 className={cn("font-bold text-ews-gray-900", compact ? "text-xl" : "text-2xl sm:text-3xl")}>
318
+ {texts.heroTitle}
319
+ </h2>
320
+ <p className={cn("mx-auto mt-2 max-w-xl text-ews-gray-700", compact && "text-sm")}>
321
+ {texts.heroSubtitle}
322
+ </p>
323
+ </div>
324
+ </section>
325
+ <div
326
+ className={cn("grid gap-3 px-4 py-5", compact ? "grid-cols-1" : "sm:grid-cols-2 lg:grid-cols-4")}
327
+ data-testid="assistant-suggestions"
328
+ >
329
+ {suggestions.map((suggestion) => (
330
+ <AssistantSuggestionCard
331
+ key={suggestion.id}
332
+ suggestion={suggestion}
333
+ onSelect={(selected) => void send(selected.prompt)}
334
+ exampleLabel={texts.exampleLabel}
335
+ disabled={busy}
336
+ />
337
+ ))}
338
+ </div>
339
+ </>
340
+ ) : (
341
+ <div className="flex-1 px-4 py-5">
342
+ <AssistantMessages
343
+ messages={messages}
344
+ activity={activity}
345
+ onRetry={retry}
346
+ texts={texts}
347
+ linkOptions={
348
+ onNavigate
349
+ ? {
350
+ onLinkClick: (href, event) => {
351
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey) return;
352
+ if (onNavigate(href) === true) event.preventDefault();
353
+ },
354
+ }
355
+ : undefined
356
+ }
357
+ />
358
+ {pendingConfirmation && onConfirm && onCancelConfirmation && (
359
+ <AssistantConfirmationCard
360
+ confirmation={pendingConfirmation}
361
+ onConfirm={onConfirm}
362
+ onCancel={onCancelConfirmation}
363
+ texts={texts}
364
+ className="mt-4"
365
+ />
366
+ )}
367
+ </div>
368
+ )}
369
+ </div>
370
+
371
+ <div className="border-t border-ews-gray-200 px-4 pb-3 pt-3">
372
+ <AssistantComposer
373
+ language={language}
374
+ onSend={(content) => void send(content)}
375
+ disabled={busy}
376
+ voice={voice}
377
+ texts={{
378
+ placeholder: texts.composerPlaceholder,
379
+ hint: texts.composerHint,
380
+ send: texts.send,
381
+ holdToTalk: texts.holdToTalk,
382
+ listening: texts.listening,
383
+ }}
384
+ />
385
+ {disclaimer && <div className="mt-2 px-2 text-xs text-ews-gray-500">{disclaimer}</div>}
386
+ </div>
387
+ </div>
388
+ </div>
389
+ );
390
+ };