@truedat/ai 8.5.3 → 8.5.6

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 (24) hide show
  1. package/package.json +3 -3
  2. package/src/api.js +2 -0
  3. package/src/components/AiRoutes.js +3 -0
  4. package/src/components/assistant/Assistant.js +186 -78
  5. package/src/components/assistant/AssistantChat.js +91 -30
  6. package/src/components/assistant/AssistantConversations.js +78 -0
  7. package/src/components/assistant/AssistantPage.js +139 -0
  8. package/src/components/assistant/AssistantPageChat.js +447 -0
  9. package/src/components/assistant/__tests__/Assistant.spec.js +15 -0
  10. package/src/components/assistant/__tests__/AssistantChat.spec.js +18 -0
  11. package/src/components/assistant/__tests__/AssistantConversations.spec.js +111 -0
  12. package/src/components/assistant/__tests__/__snapshots__/Assistant.spec.js.snap +5 -5
  13. package/src/components/assistant/__tests__/__snapshots__/AssistantChat.spec.js.snap +18 -8
  14. package/src/components/assistant/__tests__/__snapshots__/AssistantConversations.spec.js.snap +73 -0
  15. package/src/components/assistant/hooks/useAgentConversation.js +9 -3
  16. package/src/components/assistant/hooks/useAssistantSocket.js +36 -3
  17. package/src/components/index.js +2 -1
  18. package/src/components/suggestions/SuggestionsWidget.js +11 -14
  19. package/src/hooks/__tests__/useAgentConversations.spec.js +83 -0
  20. package/src/hooks/useAgentConversations.js +15 -0
  21. package/src/styles/assistant.less +281 -121
  22. package/src/components/assistant/AssistantBubble.js +0 -56
  23. package/src/components/assistant/__tests__/AssistantBubble.spec.js +0 -14
  24. package/src/components/assistant/__tests__/__snapshots__/AssistantBubble.spec.js.snap +0 -17
@@ -0,0 +1,78 @@
1
+ import PropTypes from "prop-types";
2
+ import { FormattedMessage } from "react-intl";
3
+ import { Icon } from "semantic-ui-react";
4
+
5
+ const formatDate = (isoString) => {
6
+ if (!isoString) return "";
7
+ return new Date(isoString).toLocaleString(undefined, {
8
+ month: "short",
9
+ day: "numeric",
10
+ hour: "2-digit",
11
+ minute: "2-digit",
12
+ });
13
+ };
14
+
15
+ const AssistantConversations = ({
16
+ conversations,
17
+ activeId,
18
+ onSelect,
19
+ onNewConversation,
20
+ }) => (
21
+ <div className="ai-conversation-list">
22
+ <div className="ai-conversation-list__header">
23
+ <button
24
+ type="button"
25
+ className="ai-conversation-list__new"
26
+ onClick={onNewConversation}
27
+ >
28
+ <Icon name="edit outline" />
29
+ <FormattedMessage id="assistant.newConversation" />
30
+ </button>
31
+ </div>
32
+ <div className="ai-conversation-list__items">
33
+ {conversations.map((conv) => (
34
+ <button
35
+ key={conv.id}
36
+ type="button"
37
+ className={[
38
+ "ai-conversation-list__item",
39
+ String(conv.id) === String(activeId) &&
40
+ "ai-conversation-list__item--active",
41
+ ]
42
+ .filter(Boolean)
43
+ .join(" ")}
44
+ onClick={() => onSelect(conv.id)}
45
+ >
46
+ <span className="ai-conversation-list__item-preview">
47
+ {conv.title || conv.preview || (
48
+ <FormattedMessage id="assistant.conversation.noPreview" />
49
+ )}
50
+ </span>
51
+ <span className="ai-conversation-list__item-date">
52
+ {formatDate(conv.updated_at)}
53
+ </span>
54
+ </button>
55
+ ))}
56
+ </div>
57
+ </div>
58
+ );
59
+
60
+ AssistantConversations.propTypes = {
61
+ conversations: PropTypes.arrayOf(
62
+ PropTypes.shape({
63
+ id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
64
+ updated_at: PropTypes.string,
65
+ title: PropTypes.string,
66
+ preview: PropTypes.string,
67
+ })
68
+ ).isRequired,
69
+ activeId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
70
+ onSelect: PropTypes.func.isRequired,
71
+ onNewConversation: PropTypes.func.isRequired,
72
+ };
73
+
74
+ AssistantConversations.defaultProps = {
75
+ activeId: null,
76
+ };
77
+
78
+ export default AssistantConversations;
@@ -0,0 +1,139 @@
1
+ import { useRef, useState, useCallback, useEffect } from "react";
2
+ import { useLocation } from "react-router";
3
+ import { FormattedMessage } from "react-intl";
4
+ import { Icon } from "semantic-ui-react";
5
+ import { useWebContext } from "@truedat/core/webContext";
6
+
7
+ import useAssistantSocket from "./hooks/useAssistantSocket";
8
+ import useAgentConversation from "./hooks/useAgentConversation";
9
+ import useAgentConversations from "../../hooks/useAgentConversations";
10
+ import AssistantPageChat from "./AssistantPageChat";
11
+ import AssistantConversations from "./AssistantConversations";
12
+
13
+ const EMPTY_CONVERSATION = [];
14
+
15
+ const historyToConversation = (messages) =>
16
+ (messages || [])
17
+ .filter((m) => m.role === "user" || m.role === "assistant")
18
+ .map((m) =>
19
+ m.role === "user"
20
+ ? { eventType: "request", side: "user", content: m.message }
21
+ : {
22
+ eventType: "response",
23
+ side: "agent",
24
+ content: m.message,
25
+ streaming: false,
26
+ source: "history",
27
+ priority: 0,
28
+ }
29
+ );
30
+
31
+ const AssistantPage = () => {
32
+ const {
33
+ disable_td_ai: disableTdAi = false,
34
+ assistant: {
35
+ name: assistantName = null,
36
+ welcome_message: welcomeMessage = null,
37
+ exact = false,
38
+ } = {},
39
+ } = useWebContext();
40
+ const { state: locationState } = useLocation();
41
+
42
+ const { conversationId, startNewConversation, selectConversation } =
43
+ useAgentConversation(true, locationState?.conversationId ?? null);
44
+ const { conversations, refresh: refreshConversations } =
45
+ useAgentConversations();
46
+ const [conversation, setConversation] = useState(EMPTY_CONVERSATION);
47
+
48
+ const prevConversationIdRef = useRef(null);
49
+ const serverMessageHandlerRef = useRef(null);
50
+
51
+ useEffect(() => {
52
+ if (conversationId !== prevConversationIdRef.current) {
53
+ prevConversationIdRef.current = conversationId;
54
+ if (conversationId != null) {
55
+ setConversation(EMPTY_CONVERSATION);
56
+ }
57
+ }
58
+ }, [conversationId]);
59
+
60
+ const onChannelJoined = useCallback(() => {}, []);
61
+
62
+ const handleServerMessage = useCallback(
63
+ (event, payload) => {
64
+ if (event === "history") {
65
+ setConversation(historyToConversation(payload.messages));
66
+ return;
67
+ }
68
+ if (event === "title") {
69
+ refreshConversations();
70
+ return;
71
+ }
72
+ serverMessageHandlerRef.current?.(event, payload);
73
+ },
74
+ [setConversation, refreshConversations]
75
+ );
76
+
77
+ const handleSelectConversation = useCallback(
78
+ (id) => {
79
+ selectConversation(id);
80
+ },
81
+ [selectConversation]
82
+ );
83
+
84
+ const handleNewConversation = useCallback(() => {
85
+ startNewConversation();
86
+ refreshConversations();
87
+ }, [startNewConversation, refreshConversations]);
88
+
89
+ const handleContinueInPanel = useCallback(() => {
90
+ if (!conversationId) return;
91
+ window.dispatchEvent(
92
+ new CustomEvent("assistant:open", { detail: { conversationId } })
93
+ );
94
+ }, [conversationId]);
95
+
96
+ const { socketReady, sendPrompt } = useAssistantSocket(disableTdAi, {
97
+ conversationId,
98
+ onChannelJoined,
99
+ onServerMessage: handleServerMessage,
100
+ assistantName,
101
+ welcomeMessage,
102
+ exact,
103
+ });
104
+
105
+ if (!socketReady) return null;
106
+
107
+ return (
108
+ <div className="ai-assistant-page">
109
+ <AssistantConversations
110
+ conversations={conversations}
111
+ activeId={conversationId}
112
+ onSelect={handleSelectConversation}
113
+ onNewConversation={handleNewConversation}
114
+ />
115
+ <div className="ai-assistant-main">
116
+ {conversationId && (
117
+ <div className="ai-assistant-main__toolbar">
118
+ <button
119
+ type="button"
120
+ className="ai-assistant-main__continue"
121
+ onClick={handleContinueInPanel}
122
+ >
123
+ <Icon name="window restore outline" />
124
+ <FormattedMessage id="assistant.actions.continueInPanel" />
125
+ </button>
126
+ </div>
127
+ )}
128
+ <AssistantPageChat
129
+ conversation={conversation}
130
+ setConversation={setConversation}
131
+ onSendMessage={sendPrompt}
132
+ serverMessageHandlerRef={serverMessageHandlerRef}
133
+ />
134
+ </div>
135
+ </div>
136
+ );
137
+ };
138
+
139
+ export default AssistantPage;
@@ -0,0 +1,447 @@
1
+ import {
2
+ useState,
3
+ useCallback,
4
+ useMemo,
5
+ useRef,
6
+ useEffect,
7
+ useLayoutEffect,
8
+ } from "react";
9
+ import PropTypes from "prop-types";
10
+ import { FormattedMessage, useIntl } from "react-intl";
11
+ import { Icon } from "semantic-ui-react";
12
+ import { MarkdownReader } from "@truedat/core/components";
13
+ import ThinkingOutLoud, { getSourceLabel } from "../ThinkingOutLoud";
14
+
15
+ const SCROLL_AT_BOTTOM_THRESHOLD = 50;
16
+ const SCROLL_OVERFLOW_EPSILON = 4;
17
+ const MAX_INPUT_LINES = 10;
18
+
19
+ function getVisibleMessages(conversation) {
20
+ if (!conversation || conversation.length === 0) return [];
21
+ return conversation.map((item, idx) => ({ ...item, index: idx }));
22
+ }
23
+
24
+ function getContentFromPayload(event, payload) {
25
+ const priority = payload?.priority ?? null;
26
+ const source = payload?.source ?? null;
27
+ if (!payload || typeof payload !== "object")
28
+ return { content: "", isError: false, priority, source };
29
+ switch (event) {
30
+ case "stream": {
31
+ const c = payload.content;
32
+ const streamChunk =
33
+ typeof c === "string"
34
+ ? c
35
+ : c && typeof c === "object" && typeof c.message === "string"
36
+ ? c.message
37
+ : "";
38
+ return { content: streamChunk, isError: false, priority, source };
39
+ }
40
+ case "error": {
41
+ const err = payload.error;
42
+ const errMsg =
43
+ err && typeof err === "object"
44
+ ? err.message ?? err.reason ?? JSON.stringify(err)
45
+ : String(err ?? "");
46
+ return { content: errMsg, isError: true, priority, source };
47
+ }
48
+ case "log": {
49
+ const log = payload.content;
50
+ const logMsg =
51
+ log && typeof log === "object"
52
+ ? log.message ?? log.level ?? JSON.stringify(log)
53
+ : String(log ?? "");
54
+ return { content: logMsg, isError: false, priority, source };
55
+ }
56
+ default:
57
+ return { content: "", isError: false, priority, source };
58
+ }
59
+ }
60
+
61
+ function appendStreamChunk(prev, source, chunk, priority) {
62
+ const last = prev[prev.length - 1];
63
+ const sameSource =
64
+ last?.eventType === "response" &&
65
+ last?.streaming &&
66
+ last?.source === source;
67
+ if (sameSource) {
68
+ return [...prev.slice(0, -1), { ...last, content: last.content + chunk }];
69
+ }
70
+ return [
71
+ ...prev,
72
+ {
73
+ eventType: "response",
74
+ side: "agent",
75
+ content: chunk || " ",
76
+ streaming: true,
77
+ source,
78
+ priority,
79
+ },
80
+ ];
81
+ }
82
+
83
+ function isThinkingStreamMessage(msg) {
84
+ return (
85
+ msg.eventType === "log" ||
86
+ (msg.eventType === "error" && Number(msg.priority) === 2)
87
+ );
88
+ }
89
+
90
+ function buildRenderItems(messages) {
91
+ const result = [];
92
+ let logBuffer = [];
93
+ let startIdx = null;
94
+ messages.forEach((msg) => {
95
+ if (isThinkingStreamMessage(msg)) {
96
+ if (startIdx === null) startIdx = msg.index;
97
+ logBuffer.push(msg);
98
+ } else {
99
+ if (logBuffer.length > 0) {
100
+ const isFollowedByP0 =
101
+ msg.eventType === "response" && msg.priority === 0;
102
+ result.push({
103
+ type: "thinking-block",
104
+ items: logBuffer,
105
+ active: false,
106
+ collapsed: isFollowedByP0,
107
+ key: `thinking-${startIdx}`,
108
+ });
109
+ logBuffer = [];
110
+ startIdx = null;
111
+ }
112
+ result.push({
113
+ type: "message",
114
+ msg,
115
+ key: `${msg.eventType}-${msg.index}`,
116
+ });
117
+ }
118
+ });
119
+ if (logBuffer.length > 0) {
120
+ result.push({
121
+ type: "thinking-block",
122
+ items: logBuffer,
123
+ active: true,
124
+ collapsed: false,
125
+ key: `thinking-${startIdx}`,
126
+ });
127
+ }
128
+ return result;
129
+ }
130
+
131
+ const AssistantPageChat = ({
132
+ conversation,
133
+ setConversation,
134
+ onSendMessage,
135
+ serverMessageHandlerRef,
136
+ }) => {
137
+ const { formatMessage } = useIntl();
138
+ const [inputValue, setInputValue] = useState("");
139
+ const [inputExpanded, setInputExpanded] = useState(false);
140
+ const [showScrollToBottom, setShowScrollToBottom] = useState(false);
141
+ const messagesRef = useRef(null);
142
+ const messagesEndRef = useRef(null);
143
+ const inputRef = useRef(null);
144
+ const scrollToBottomAfterSendRef = useRef(false);
145
+
146
+ const syncTextareaHeight = useCallback(() => {
147
+ const el = inputRef.current;
148
+ if (!el) return;
149
+ const style = window.getComputedStyle(el);
150
+ const lh = parseFloat(style.lineHeight) || 20;
151
+ const padY = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
152
+ const maxHeight = lh * MAX_INPUT_LINES + padY;
153
+ el.style.overflowY = "hidden";
154
+ el.style.height = "auto";
155
+ const h = Math.min(el.scrollHeight, maxHeight);
156
+ el.style.height = `${h}px`;
157
+ el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden";
158
+ setInputExpanded(h > lh + padY + 2);
159
+ }, []);
160
+
161
+ useLayoutEffect(() => {
162
+ syncTextareaHeight();
163
+ }, [inputValue, syncTextareaHeight]);
164
+
165
+ useEffect(() => {
166
+ const ref = serverMessageHandlerRef;
167
+ if (!ref) return;
168
+ ref.current = (event, payload) => {
169
+ const { content, isError, priority, source } = getContentFromPayload(
170
+ event,
171
+ payload
172
+ );
173
+ if (event === "stream") {
174
+ requestAnimationFrame(() => {
175
+ setConversation((prev) =>
176
+ appendStreamChunk(prev, source, content, priority)
177
+ );
178
+ });
179
+ } else if (event === "log") {
180
+ setConversation((prev) => [
181
+ ...prev,
182
+ {
183
+ eventType: "log",
184
+ side: "agent",
185
+ content: content || " ",
186
+ priority,
187
+ source,
188
+ },
189
+ ]);
190
+ } else if (event === "error" && Number(priority) === 2) {
191
+ setConversation((prev) => [
192
+ ...prev,
193
+ {
194
+ eventType: "error",
195
+ side: "agent",
196
+ content: content || " ",
197
+ priority: 2,
198
+ source,
199
+ isError: true,
200
+ },
201
+ ]);
202
+ } else {
203
+ setConversation((prev) => [
204
+ ...prev,
205
+ {
206
+ eventType: "response",
207
+ side: "agent",
208
+ content: content || "Error",
209
+ isError: true,
210
+ priority,
211
+ source,
212
+ },
213
+ ]);
214
+ }
215
+ scrollToBottomAfterSendRef.current = true;
216
+ };
217
+ return () => {
218
+ ref.current = null;
219
+ };
220
+ }, [serverMessageHandlerRef, setConversation]);
221
+
222
+ const visibleMessages = useMemo(
223
+ () => getVisibleMessages(conversation),
224
+ [conversation]
225
+ );
226
+
227
+ const renderItems = useMemo(
228
+ () => buildRenderItems(visibleMessages),
229
+ [visibleMessages]
230
+ );
231
+
232
+ const canSendMessages = useMemo(
233
+ () => conversation.some((m) => m.side === "agent"),
234
+ [conversation]
235
+ );
236
+
237
+ const handleSubmit = useCallback(
238
+ (e) => {
239
+ e?.preventDefault?.();
240
+ if (!canSendMessages) return;
241
+ const text = inputValue.trim();
242
+ if (!text) return;
243
+ scrollToBottomAfterSendRef.current = true;
244
+ setConversation((prev) => [
245
+ ...prev,
246
+ { eventType: "request", side: "user", content: text },
247
+ ]);
248
+ setInputValue("");
249
+ if (typeof onSendMessage === "function") onSendMessage(text);
250
+ },
251
+ [inputValue, onSendMessage, canSendMessages, setConversation]
252
+ );
253
+
254
+ const handleKeyDown = useCallback(
255
+ (e) => {
256
+ if (e.key === "Enter" && !e.shiftKey) {
257
+ e.preventDefault();
258
+ if (!canSendMessages) return;
259
+ handleSubmit();
260
+ }
261
+ },
262
+ [handleSubmit, canSendMessages]
263
+ );
264
+
265
+ const scrollToBottom = useCallback(() => {
266
+ const el = messagesRef.current;
267
+ if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
268
+ }, []);
269
+
270
+ const checkScrollPositionFallback = useCallback(() => {
271
+ const el = messagesRef.current;
272
+ if (!el) return;
273
+ const { scrollHeight, scrollTop, clientHeight } = el;
274
+ if (clientHeight < 2) { setShowScrollToBottom(false); return; }
275
+ const hasOverflow = scrollHeight > clientHeight + SCROLL_OVERFLOW_EPSILON;
276
+ if (!hasOverflow) { setShowScrollToBottom(false); return; }
277
+ const atBottom = scrollHeight - scrollTop - clientHeight <= SCROLL_AT_BOTTOM_THRESHOLD;
278
+ setShowScrollToBottom(!atBottom);
279
+ }, []);
280
+
281
+ useLayoutEffect(() => {
282
+ const root = messagesRef.current;
283
+ const sentinel = messagesEndRef.current;
284
+ if (!root || !sentinel) return undefined;
285
+
286
+ if (typeof IntersectionObserver !== "function") {
287
+ checkScrollPositionFallback();
288
+ const rafId = requestAnimationFrame(() => checkScrollPositionFallback());
289
+ root.addEventListener("scroll", checkScrollPositionFallback);
290
+ return () => {
291
+ cancelAnimationFrame(rafId);
292
+ root.removeEventListener("scroll", checkScrollPositionFallback);
293
+ };
294
+ }
295
+
296
+ const io = new IntersectionObserver(
297
+ ([entry]) => {
298
+ if (!entry) return;
299
+ setShowScrollToBottom(!entry.isIntersecting);
300
+ },
301
+ { root, threshold: 0 }
302
+ );
303
+ io.observe(sentinel);
304
+ return () => io.disconnect();
305
+ // eslint-disable-next-line react-hooks/exhaustive-deps
306
+ }, []);
307
+
308
+ useLayoutEffect(() => {
309
+ const el = messagesRef.current;
310
+ if (!el || !scrollToBottomAfterSendRef.current) return;
311
+ scrollToBottomAfterSendRef.current = false;
312
+ requestAnimationFrame(() => {
313
+ el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
314
+ });
315
+ }, [visibleMessages.length]);
316
+
317
+ return (
318
+ <div className="ai-assistant-chat">
319
+ <div className="assistant-chat__messages-wrapper">
320
+ <div
321
+ ref={messagesRef}
322
+ className="assistant-chat__messages"
323
+ role="log"
324
+ aria-label={formatMessage({ id: "assistant.messages.history" })}
325
+ >
326
+ {renderItems.map((item) => {
327
+ if (item.type === "thinking-block") {
328
+ return (
329
+ <ThinkingOutLoud
330
+ key={item.key}
331
+ items={item.items}
332
+ active={item.active}
333
+ collapsed={item.collapsed}
334
+ />
335
+ );
336
+ }
337
+ const { msg } = item;
338
+ const isP0 = msg.priority === 0;
339
+ const isErrorP0 = msg.isError && isP0;
340
+ const sourceLabel = isP0 && msg.source ? getSourceLabel(msg.source) : null;
341
+ return (
342
+ <div
343
+ key={item.key}
344
+ className={[
345
+ "assistant-chat__message",
346
+ `assistant-chat__message--${msg.eventType}`,
347
+ `assistant-chat__message--${msg.side}`,
348
+ isErrorP0 && "assistant-chat__message--error",
349
+ ]
350
+ .filter(Boolean)
351
+ .join(" ")}
352
+ >
353
+ {sourceLabel && (
354
+ <div className="assistant-chat__message-source">
355
+ {sourceLabel}
356
+ </div>
357
+ )}
358
+ <div className="assistant-chat__message-content">
359
+ {msg.content?.trim() ? (
360
+ <MarkdownReader content={msg.content} />
361
+ ) : (
362
+ msg.content
363
+ )}
364
+ </div>
365
+ </div>
366
+ );
367
+ })}
368
+ <div
369
+ ref={messagesEndRef}
370
+ className="assistant-chat__messages-end"
371
+ aria-hidden
372
+ />
373
+ </div>
374
+ {!canSendMessages && (
375
+ <div
376
+ className="assistant-chat__waiting"
377
+ role="status"
378
+ aria-live="polite"
379
+ aria-label={formatMessage({ id: "assistant.chat.loading" })}
380
+ >
381
+ <span className="assistant-chat__waiting-spinner" aria-hidden />
382
+ </div>
383
+ )}
384
+ {showScrollToBottom && (
385
+ <button
386
+ type="button"
387
+ className="assistant-chat__scroll-to-bottom"
388
+ onClick={scrollToBottom}
389
+ aria-label={formatMessage({
390
+ id: "assistant.actions.scrollToBottom",
391
+ })}
392
+ >
393
+ <Icon name="chevron down" />
394
+ </button>
395
+ )}
396
+ </div>
397
+ <div className="assistant-chat__footer">
398
+ <form
399
+ className={[
400
+ "assistant-chat__input-row",
401
+ inputExpanded && "assistant-chat__input-row--expanded",
402
+ ]
403
+ .filter(Boolean)
404
+ .join(" ")}
405
+ onSubmit={handleSubmit}
406
+ >
407
+ <textarea
408
+ ref={inputRef}
409
+ rows={1}
410
+ className="assistant-chat__input"
411
+ placeholder={formatMessage({ id: "assistant.input.placeholder" })}
412
+ value={inputValue}
413
+ onChange={(e) => setInputValue(e.target.value)}
414
+ onKeyDown={handleKeyDown}
415
+ aria-label={formatMessage({ id: "assistant.input.label" })}
416
+ disabled={!canSendMessages}
417
+ />
418
+ <button
419
+ type="submit"
420
+ className="assistant-chat__send"
421
+ aria-label={formatMessage({ id: "assistant.actions.send" })}
422
+ disabled={!canSendMessages}
423
+ >
424
+ <Icon name="arrow up" />
425
+ </button>
426
+ </form>
427
+ <div className="assistant-chat__disclaimer">
428
+ <FormattedMessage id="assistant.disclaimer" />
429
+ </div>
430
+ </div>
431
+ </div>
432
+ );
433
+ };
434
+
435
+ AssistantPageChat.propTypes = {
436
+ conversation: PropTypes.arrayOf(PropTypes.object).isRequired,
437
+ setConversation: PropTypes.func.isRequired,
438
+ onSendMessage: PropTypes.func,
439
+ serverMessageHandlerRef: PropTypes.shape({ current: PropTypes.any }),
440
+ };
441
+
442
+ AssistantPageChat.defaultProps = {
443
+ onSendMessage: undefined,
444
+ serverMessageHandlerRef: null,
445
+ };
446
+
447
+ export default AssistantPageChat;
@@ -19,9 +19,24 @@ jest.mock("../hooks/useAgentConversation", () =>
19
19
  jest.fn(() => ({
20
20
  conversationId: null,
21
21
  startNewConversation: jest.fn(),
22
+ selectConversation: jest.fn(),
22
23
  }))
23
24
  );
24
25
 
26
+ jest.mock("../../../hooks/useAgentConversations", () =>
27
+ jest.fn(() => ({
28
+ conversations: [],
29
+ loading: false,
30
+ error: null,
31
+ refresh: jest.fn(),
32
+ }))
33
+ );
34
+
35
+ jest.mock("react-router", () => ({
36
+ ...jest.requireActual("react-router"),
37
+ useNavigate: jest.fn(() => jest.fn()),
38
+ }));
39
+
25
40
  describe("<Assistant />", () => {
26
41
  it("renders nothing when socket is not ready", async () => {
27
42
  useAssistantSocket.mockReturnValue({