@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,171 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /** Languages the assistant speaks; `ar` renders right-to-left. */
4
+ export type AssistantLanguage = "fr" | "en" | "ar";
5
+
6
+ /**
7
+ * Events streamed by the assistant BFF for one answer
8
+ * (projects/medecine360-mcp/docs/assistant-api-contract.md).
9
+ */
10
+ export type AssistantEvent =
11
+ | { type: "message_start"; messageId: string }
12
+ | { type: "text_delta"; text: string }
13
+ | {
14
+ type: "tool_call";
15
+ name: string;
16
+ status: "started" | "done" | "failed";
17
+ label: string;
18
+ }
19
+ | { type: "citation"; title: string; url: string }
20
+ | {
21
+ type: "message_end";
22
+ messageId: string;
23
+ usage?: { inputTokens: number; outputTokens: number };
24
+ }
25
+ | {
26
+ type: "error";
27
+ code:
28
+ | "MODEL_UNAVAILABLE"
29
+ | "RATE_LIMITED"
30
+ | "CONVERSATION_NOT_FOUND"
31
+ | "INVALID_MESSAGE"
32
+ | "NETWORK"
33
+ | string;
34
+ message: string;
35
+ retryAfterSeconds?: number;
36
+ };
37
+
38
+ export interface AssistantCitation {
39
+ title: string;
40
+ url: string;
41
+ }
42
+
43
+ export interface AssistantMessage {
44
+ id: string;
45
+ role: "user" | "assistant";
46
+ content: string;
47
+ citations?: AssistantCitation[];
48
+ /** Streaming state of an assistant message. */
49
+ status?: "streaming" | "done" | "error";
50
+ /** Set when `status` is `error`: what to show, and whether a retry makes sense. */
51
+ error?: { message: string; retryAfterSeconds?: number };
52
+ }
53
+
54
+ export interface AssistantConversationSummary {
55
+ id: string;
56
+ title: string;
57
+ createdAt: string;
58
+ }
59
+
60
+ /**
61
+ * The transport is the app's job: the component never knows a URL. The
62
+ * async iterable returned by `sendMessage` must end after `message_end` or
63
+ * `error`; a thrown error is treated as a network failure.
64
+ */
65
+ export interface AssistantTransport {
66
+ startConversation(language: AssistantLanguage): Promise<{ id: string }>;
67
+ sendMessage(
68
+ conversationId: string,
69
+ content: string,
70
+ options?: { signal?: AbortSignal }
71
+ ): AsyncIterable<AssistantEvent>;
72
+ getConversation?(
73
+ conversationId: string
74
+ ): Promise<{ id: string; messages: AssistantMessage[] }>;
75
+ }
76
+
77
+ export interface AssistantSuggestion {
78
+ id: string;
79
+ title: string;
80
+ description?: string;
81
+ /** What is sent when the card is clicked. */
82
+ prompt: string;
83
+ icon?: ReactNode;
84
+ /** Marks the "example question" card of the mockup. */
85
+ kind?: "action" | "example";
86
+ }
87
+
88
+ /** Every string the component shows; the app translates them. */
89
+ export interface AssistantChatTexts {
90
+ brandName: string;
91
+ online: string;
92
+ newConversation: string;
93
+ recentConversations: string;
94
+ signIn: string;
95
+ signInHint: string;
96
+ heroTitle: string;
97
+ heroSubtitle: string;
98
+ composerPlaceholder: string;
99
+ composerHint: string;
100
+ send: string;
101
+ /** Mic button: hold to talk. */
102
+ holdToTalk: string;
103
+ listening: string;
104
+ thinking: string;
105
+ retry: string;
106
+ rateLimited: (seconds: number) => string;
107
+ genericError: string;
108
+ sources: string;
109
+ exampleLabel: string;
110
+ confirm: string;
111
+ cancel: string;
112
+ /** The history button of the panel layout; falls back to `recentConversations`. */
113
+ history?: string;
114
+ }
115
+
116
+ export interface AssistantConfirmation {
117
+ id: string;
118
+ title: string;
119
+ details: Array<{ label: string; value: string }>;
120
+ }
121
+
122
+ export interface AssistantChatProps {
123
+ transport: AssistantTransport;
124
+ language: AssistantLanguage;
125
+ texts: AssistantChatTexts;
126
+ suggestions: AssistantSuggestion[];
127
+ /** Conversations the app remembers (local storage or account). */
128
+ conversations?: AssistantConversationSummary[];
129
+ activeConversationId?: string | null;
130
+ /** Messages of the active conversation, when the app restores one. */
131
+ initialMessages?: AssistantMessage[];
132
+ onConversationCreated?: (conversation: AssistantConversationSummary) => void;
133
+ onSelectConversation?: (conversationId: string) => void;
134
+ onNewConversation?: () => void;
135
+ /** Where "Se connecter" goes. Hidden when absent. */
136
+ signInHref?: string;
137
+ /** Rendered in the header, right side (e.g. "Réserver un appel"). */
138
+ headerAction?: ReactNode;
139
+ /** Rendered under the composer (sources, no medical advice, privacy link). */
140
+ disclaimer?: ReactNode;
141
+ /** Decorative image or gradient behind the hero. */
142
+ heroBackground?: ReactNode;
143
+ /** Push-to-talk through the browser speech API. Default true; hidden anyway where unsupported. */
144
+ voice?: boolean;
145
+ /** Phase 2: a confirmation the assistant asks before a write. */
146
+ pendingConfirmation?: AssistantConfirmation | null;
147
+ onConfirm?: (confirmation: AssistantConfirmation) => void;
148
+ onCancelConfirmation?: (confirmation: AssistantConfirmation) => void;
149
+ /**
150
+ * `page` (default): the chat home with the conversation list beside the
151
+ * thread. `panel` (MED-1787): one narrow column for a slide-over, the
152
+ * conversation list behind a history button, a "new conversation" button
153
+ * in the header. `wide`: the expanded slide-over, list beside the thread.
154
+ */
155
+ layout?: "page" | "panel" | "wide";
156
+ /** Panel layout: the conversation list is shown over the thread. */
157
+ historyOpen?: boolean;
158
+ onHistoryOpenChange?: (open: boolean) => void;
159
+ /**
160
+ * MED-1787: a message typed outside the chat (the launcher bar) to send as
161
+ * soon as possible. Each `id` is sent once; a new id sends again.
162
+ */
163
+ queuedPrompt?: { id: string; content: string } | null;
164
+ /**
165
+ * Called when a link in an answer is clicked. Return true when the host
166
+ * navigated itself (e.g. its router): the browser then does not follow the
167
+ * link. Links to other origins always open in a new tab.
168
+ */
169
+ onNavigate?: (href: string) => boolean | void;
170
+ className?: string;
171
+ }
@@ -0,0 +1,134 @@
1
+ import { Mic, SendHorizontal } from "lucide-react";
2
+ import React, { useCallback, useRef, useState } from "react";
3
+ import { useSpeechRecognition } from "../../hooks/useSpeechRecognition";
4
+ import { cn } from "../../utils";
5
+ import type { AssistantLanguage } from "./AssistantChat.types";
6
+
7
+ export interface AssistantComposerProps {
8
+ language: AssistantLanguage;
9
+ onSend: (content: string) => void;
10
+ disabled?: boolean;
11
+ /** Mic button; hidden anyway where the browser has no speech API. */
12
+ voice?: boolean;
13
+ texts: {
14
+ placeholder: string;
15
+ hint: string;
16
+ send: string;
17
+ holdToTalk: string;
18
+ listening: string;
19
+ };
20
+ className?: string;
21
+ }
22
+
23
+ export const MAX_MESSAGE_LENGTH = 2000;
24
+
25
+ /**
26
+ * The composer of the mockup: textarea, hint, push-to-talk mic (hold to
27
+ * record, release to send) and a send button. Enter sends, Shift+Enter breaks.
28
+ */
29
+ export const AssistantComposer: React.FC<AssistantComposerProps> = ({
30
+ language,
31
+ onSend,
32
+ disabled = false,
33
+ voice = true,
34
+ texts,
35
+ className,
36
+ }) => {
37
+ const [value, setValue] = useState("");
38
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
39
+
40
+ const submit = useCallback(
41
+ (content: string) => {
42
+ const trimmed = content.trim();
43
+ if (!trimmed || disabled) return;
44
+ onSend(trimmed.slice(0, MAX_MESSAGE_LENGTH));
45
+ setValue("");
46
+ },
47
+ [disabled, onSend]
48
+ );
49
+
50
+ const speech = useSpeechRecognition({
51
+ language,
52
+ onInterim: (text) => setValue(text),
53
+ onResult: (text) => submit(text),
54
+ });
55
+
56
+ const onKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
57
+ if (event.key === "Enter" && !event.shiftKey) {
58
+ event.preventDefault();
59
+ submit(value);
60
+ }
61
+ };
62
+
63
+ const showMic = voice && speech.supported;
64
+
65
+ // A plain div rather than a <form>: Enter and the send button submit, and a
66
+ // form followed by a sibling trips React hydration under happy-dom (the apps'
67
+ // server-rendering tests) although browsers are fine.
68
+ return (
69
+ <div className={cn("w-full", className)}>
70
+ <div
71
+ className={cn(
72
+ "flex items-end gap-2 rounded-2xl border border-ews-gray-200 bg-white px-3 py-2 shadow-sm",
73
+ "focus-within:border-ews-primary focus-within:ring-2 focus-within:ring-ews-primary/20"
74
+ )}
75
+ >
76
+ <textarea
77
+ ref={textareaRef}
78
+ value={value}
79
+ onChange={(event) => setValue(event.target.value)}
80
+ onKeyDown={onKeyDown}
81
+ rows={1}
82
+ maxLength={MAX_MESSAGE_LENGTH}
83
+ placeholder={speech.listening ? texts.listening : texts.placeholder}
84
+ aria-label={texts.placeholder}
85
+ disabled={disabled}
86
+ 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"
87
+ />
88
+ {showMic && (
89
+ <button
90
+ type="button"
91
+ aria-label={texts.holdToTalk}
92
+ aria-pressed={speech.listening}
93
+ title={texts.holdToTalk}
94
+ disabled={disabled}
95
+ onMouseDown={speech.start}
96
+ onMouseUp={speech.stop}
97
+ onMouseLeave={speech.listening ? speech.stop : undefined}
98
+ onTouchStart={speech.start}
99
+ onTouchEnd={speech.stop}
100
+ onKeyDown={(event) => {
101
+ if (event.key === " " || event.key === "Enter") {
102
+ event.preventDefault();
103
+ if (speech.listening) speech.stop();
104
+ else speech.start();
105
+ }
106
+ }}
107
+ className={cn(
108
+ "rounded-full p-2 transition-colors focus:outline-none focus:ring-2 focus:ring-ews-primary",
109
+ speech.listening
110
+ ? "bg-ews-error text-white animate-pulse"
111
+ : "text-ews-gray-500 hover:bg-ews-gray-100 hover:text-ews-primary",
112
+ "disabled:opacity-50"
113
+ )}
114
+ data-testid="assistant-mic"
115
+ >
116
+ <Mic className="h-5 w-5" aria-hidden="true" />
117
+ </button>
118
+ )}
119
+ <button
120
+ type="button"
121
+ onClick={() => submit(value)}
122
+ aria-label={texts.send}
123
+ title={texts.send}
124
+ disabled={disabled || value.trim().length === 0}
125
+ 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"
126
+ data-testid="assistant-send"
127
+ >
128
+ <SendHorizontal className="h-5 w-5 rtl:-scale-x-100" aria-hidden="true" />
129
+ </button>
130
+ </div>
131
+ <p className="mt-1.5 px-2 text-xs text-ews-gray-400">{texts.hint}</p>
132
+ </div>
133
+ );
134
+ };
@@ -0,0 +1,58 @@
1
+ import { ShieldCheck } from "lucide-react";
2
+ import React from "react";
3
+ import { cn } from "../../utils";
4
+ import { Button } from "../Button/Button";
5
+ import type { AssistantConfirmation } from "./AssistantChat.types";
6
+
7
+ export interface AssistantConfirmationCardProps {
8
+ confirmation: AssistantConfirmation;
9
+ onConfirm: (confirmation: AssistantConfirmation) => void;
10
+ onCancel: (confirmation: AssistantConfirmation) => void;
11
+ texts: { confirm: string; cancel: string };
12
+ busy?: boolean;
13
+ className?: string;
14
+ }
15
+
16
+ /**
17
+ * Reserved for phase 2 (MED-1779): the card the assistant shows before a
18
+ * write (booking, cancellation). Title, details, confirm and cancel wired to
19
+ * the app, so phase 2 needs no component change.
20
+ */
21
+ export const AssistantConfirmationCard: React.FC<AssistantConfirmationCardProps> = ({
22
+ confirmation,
23
+ onConfirm,
24
+ onCancel,
25
+ texts,
26
+ busy = false,
27
+ className,
28
+ }) => (
29
+ <section
30
+ aria-label={confirmation.title}
31
+ className={cn(
32
+ "rounded-xl border border-ews-primary/30 bg-ews-primary-light/30 p-4",
33
+ className
34
+ )}
35
+ data-testid="assistant-confirmation"
36
+ >
37
+ <h3 className="flex items-center gap-2 font-semibold text-ews-gray-900">
38
+ <ShieldCheck className="h-5 w-5 text-ews-primary" aria-hidden="true" />
39
+ {confirmation.title}
40
+ </h3>
41
+ <dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
42
+ {confirmation.details.map((detail) => (
43
+ <React.Fragment key={detail.label}>
44
+ <dt className="text-ews-gray-500">{detail.label}</dt>
45
+ <dd className="font-medium text-ews-gray-900">{detail.value}</dd>
46
+ </React.Fragment>
47
+ ))}
48
+ </dl>
49
+ <div className="mt-4 flex gap-2">
50
+ <Button size="sm" loading={busy} onClick={() => onConfirm(confirmation)}>
51
+ {texts.confirm}
52
+ </Button>
53
+ <Button size="sm" variant="ghost" disabled={busy} onClick={() => onCancel(confirmation)}>
54
+ {texts.cancel}
55
+ </Button>
56
+ </div>
57
+ </section>
58
+ );
@@ -0,0 +1,85 @@
1
+ import { MessageSquare, SquarePen, UserRound } from "lucide-react";
2
+ import React from "react";
3
+ import { cn } from "../../utils";
4
+ import type { AssistantConversationSummary } from "./AssistantChat.types";
5
+
6
+ export interface AssistantConversationListProps {
7
+ conversations: AssistantConversationSummary[];
8
+ activeConversationId?: string | null;
9
+ onSelect?: (conversationId: string) => void;
10
+ onNew?: () => void;
11
+ texts: {
12
+ newConversation: string;
13
+ recentConversations: string;
14
+ signIn: string;
15
+ signInHint: string;
16
+ };
17
+ signInHref?: string;
18
+ className?: string;
19
+ }
20
+
21
+ /** Left column of the mockup: new discussion, recent ones, sign-in prompt at the bottom. */
22
+ export const AssistantConversationList: React.FC<AssistantConversationListProps> = ({
23
+ conversations,
24
+ activeConversationId,
25
+ onSelect,
26
+ onNew,
27
+ texts,
28
+ signInHref,
29
+ className,
30
+ }) => (
31
+ <nav
32
+ aria-label={texts.recentConversations}
33
+ className={cn("flex h-full flex-col gap-4 bg-ews-gray-50 p-3", className)}
34
+ >
35
+ <button
36
+ type="button"
37
+ onClick={onNew}
38
+ 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"
39
+ >
40
+ <SquarePen className="h-4 w-4" aria-hidden="true" />
41
+ {texts.newConversation}
42
+ </button>
43
+
44
+ {conversations.length > 0 && (
45
+ <ul className="flex-1 space-y-1 overflow-y-auto" data-testid="assistant-conversation-list">
46
+ {conversations.map((conversation) => {
47
+ const active = conversation.id === activeConversationId;
48
+ return (
49
+ <li key={conversation.id}>
50
+ <button
51
+ type="button"
52
+ onClick={() => onSelect?.(conversation.id)}
53
+ aria-current={active ? "true" : undefined}
54
+ className={cn(
55
+ "flex w-full items-center gap-2 truncate rounded-md px-2 py-1.5 text-start text-sm",
56
+ active
57
+ ? "bg-white font-medium text-ews-primary shadow-sm"
58
+ : "text-ews-gray-700 hover:bg-white"
59
+ )}
60
+ >
61
+ <MessageSquare className="h-3.5 w-3.5 shrink-0 text-ews-gray-400" aria-hidden="true" />
62
+ <span className="truncate">{conversation.title}</span>
63
+ </button>
64
+ </li>
65
+ );
66
+ })}
67
+ </ul>
68
+ )}
69
+
70
+ {signInHref && (
71
+ <a
72
+ href={signInHref}
73
+ className="mt-auto flex items-start gap-2 rounded-lg px-2 py-2 text-sm hover:bg-white"
74
+ >
75
+ <span className="rounded-full bg-ews-primary-light p-1.5 text-ews-primary" aria-hidden="true">
76
+ <UserRound className="h-4 w-4" />
77
+ </span>
78
+ <span>
79
+ <span className="block font-medium text-ews-gray-900">{texts.signIn}</span>
80
+ <span className="block text-xs text-ews-gray-500">{texts.signInHint}</span>
81
+ </span>
82
+ </a>
83
+ )}
84
+ </nav>
85
+ );
@@ -0,0 +1,123 @@
1
+ import { AlertCircle, Loader2, RotateCcw } from "lucide-react";
2
+ import React, { useEffect, useRef } from "react";
3
+ import { cn } from "../../utils";
4
+ import type { AssistantMessage } from "./AssistantChat.types";
5
+ import { renderAssistantMarkdown } from "./assistantMarkdown";
6
+ import type { AssistantMarkdownOptions } from "./assistantMarkdown";
7
+
8
+ export interface AssistantMessagesProps {
9
+ messages: AssistantMessage[];
10
+ /** The tool activity line shown under the streaming answer, e.g. "Recherche de médecins…". */
11
+ activity?: string | null;
12
+ onRetry?: (message: AssistantMessage) => void;
13
+ texts: { thinking: string; retry: string; sources: string; brandName: string };
14
+ /** Link handling of the answers (same-tab navigation through the host router). */
15
+ linkOptions?: AssistantMarkdownOptions;
16
+ className?: string;
17
+ }
18
+
19
+ /** The thread: user bubbles on the end side, assistant answers on the start side, auto-scrolled. */
20
+ export const AssistantMessages: React.FC<AssistantMessagesProps> = ({
21
+ messages,
22
+ activity,
23
+ onRetry,
24
+ texts,
25
+ linkOptions,
26
+ className,
27
+ }) => {
28
+ const endRef = useRef<HTMLDivElement>(null);
29
+ const last = messages[messages.length - 1];
30
+
31
+ useEffect(() => {
32
+ endRef.current?.scrollIntoView?.({ block: "end" });
33
+ }, [messages.length, last?.content, activity]);
34
+
35
+ return (
36
+ <div
37
+ className={cn("flex flex-col gap-4", className)}
38
+ role="log"
39
+ aria-live="polite"
40
+ aria-relevant="additions text"
41
+ data-testid="assistant-messages"
42
+ >
43
+ {messages.map((message) => {
44
+ const isUser = message.role === "user";
45
+ return (
46
+ <div
47
+ key={message.id}
48
+ className={cn("flex", isUser ? "justify-end" : "justify-start")}
49
+ data-testid={`assistant-message-${message.role}`}
50
+ >
51
+ <div
52
+ className={cn(
53
+ "max-w-[85%] rounded-2xl px-4 py-3 text-base leading-relaxed",
54
+ isUser
55
+ ? "rounded-ee-md bg-ews-primary text-white"
56
+ : "rounded-es-md bg-ews-gray-100 text-ews-gray-900"
57
+ )}
58
+ >
59
+ {isUser ? (
60
+ <p className="whitespace-pre-wrap">{message.content}</p>
61
+ ) : (
62
+ <>
63
+ {message.content.length > 0 && renderAssistantMarkdown(message.content, linkOptions)}
64
+ {message.status === "streaming" && message.content.length === 0 && (
65
+ <span className="flex items-center gap-2 text-ews-gray-500">
66
+ <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
67
+ {texts.thinking}
68
+ </span>
69
+ )}
70
+ {message.status === "streaming" && activity && (
71
+ <p className="mt-2 flex items-center gap-2 text-sm text-ews-gray-500" data-testid="assistant-activity">
72
+ <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
73
+ {activity}
74
+ </p>
75
+ )}
76
+ {message.citations && message.citations.length > 0 && (
77
+ <p className="mt-3 border-t border-ews-gray-200 pt-2 text-xs text-ews-gray-500">
78
+ <span className="font-medium">{texts.sources}: </span>
79
+ {message.citations.map((citation, index) => (
80
+ <React.Fragment key={`${citation.url}-${index}`}>
81
+ {index > 0 && ", "}
82
+ <a
83
+ href={citation.url}
84
+ className="underline underline-offset-2 hover:text-ews-primary"
85
+ target="_blank"
86
+ rel="noopener noreferrer"
87
+ >
88
+ {citation.title}
89
+ </a>
90
+ </React.Fragment>
91
+ ))}
92
+ </p>
93
+ )}
94
+ {message.status === "error" && message.error && (
95
+ <div
96
+ role="alert"
97
+ className="mt-2 flex flex-wrap items-center gap-2 text-sm text-ews-error"
98
+ data-testid="assistant-error"
99
+ >
100
+ <AlertCircle className="h-4 w-4 shrink-0" aria-hidden="true" />
101
+ <span>{message.error.message}</span>
102
+ {onRetry && (
103
+ <button
104
+ type="button"
105
+ onClick={() => onRetry(message)}
106
+ 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"
107
+ >
108
+ <RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
109
+ {texts.retry}
110
+ </button>
111
+ )}
112
+ </div>
113
+ )}
114
+ </>
115
+ )}
116
+ </div>
117
+ </div>
118
+ );
119
+ })}
120
+ <div ref={endRef} />
121
+ </div>
122
+ );
123
+ };
@@ -0,0 +1,48 @@
1
+ import { ArrowRight, Sparkles } from "lucide-react";
2
+ import React from "react";
3
+ import { cn } from "../../utils";
4
+ import type { AssistantSuggestion } from "./AssistantChat.types";
5
+
6
+ export interface AssistantSuggestionCardProps {
7
+ suggestion: AssistantSuggestion;
8
+ onSelect: (suggestion: AssistantSuggestion) => void;
9
+ exampleLabel: string;
10
+ disabled?: boolean;
11
+ className?: string;
12
+ }
13
+
14
+ /** One of the cards under the hero (mockup med1): title, description, sent as a prompt on click. */
15
+ export const AssistantSuggestionCard: React.FC<AssistantSuggestionCardProps> = ({
16
+ suggestion,
17
+ onSelect,
18
+ exampleLabel,
19
+ disabled = false,
20
+ className,
21
+ }) => {
22
+ const isExample = suggestion.kind === "example";
23
+ return (
24
+ <button
25
+ type="button"
26
+ disabled={disabled}
27
+ onClick={() => onSelect(suggestion)}
28
+ className={cn(
29
+ "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",
30
+ "hover:border-ews-primary hover:bg-ews-gray-50 focus:outline-none focus:ring-2 focus:ring-ews-primary focus:ring-offset-1",
31
+ "disabled:cursor-not-allowed disabled:opacity-60",
32
+ className
33
+ )}
34
+ data-testid={`assistant-suggestion-${suggestion.id}`}
35
+ >
36
+ <span className="text-ews-gray-400" aria-hidden="true">
37
+ {suggestion.icon ?? (isExample ? <Sparkles className="h-4 w-4" /> : <ArrowRight className="h-4 w-4" />)}
38
+ </span>
39
+ <span className="font-semibold text-ews-gray-900">{suggestion.title}</span>
40
+ {suggestion.description && (
41
+ <span className="text-sm text-ews-gray-600">{suggestion.description}</span>
42
+ )}
43
+ {isExample && (
44
+ <span className="mt-auto text-xs uppercase tracking-wide text-ews-gray-400">{exampleLabel}</span>
45
+ )}
46
+ </button>
47
+ );
48
+ };