@sybilion/uilib 1.2.19 → 1.2.21

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 (33) hide show
  1. package/dist/esm/components/ui/Chat/ChatChrome/ChatChrome.js +1 -1
  2. package/dist/esm/components/ui/Chat/ChatMessage/ChatMessage.js +4 -3
  3. package/dist/esm/components/ui/Chat/ChatMessage/ChatMessage.styl.js +2 -2
  4. package/dist/esm/components/ui/Chat/ChatMessage/UserCsvAttachmentBubble.js +11 -0
  5. package/dist/esm/components/ui/Chat/ChatMessage/icons/CsvIcon.js +8 -0
  6. package/dist/esm/components/ui/Chat/ChatSheet/useChatPanelChromeModel.js +12 -18
  7. package/dist/esm/components/ui/Chat/chat-preset-utils.js +12 -3
  8. package/dist/esm/contexts/chat-context.js +69 -10
  9. package/dist/esm/index.js +1 -1
  10. package/dist/esm/types/src/components/ui/Chat/Chat.types.d.ts +14 -0
  11. package/dist/esm/types/src/components/ui/Chat/ChatMessage/ChatMessage.d.ts +1 -1
  12. package/dist/esm/types/src/components/ui/Chat/ChatMessage/UserCsvAttachmentBubble.d.ts +4 -0
  13. package/dist/esm/types/src/components/ui/Chat/ChatMessage/icons/CsvIcon.d.ts +3 -0
  14. package/dist/esm/types/src/components/ui/Chat/index.d.ts +1 -1
  15. package/dist/esm/types/src/contexts/chat-context.d.ts +21 -7
  16. package/dist/esm/types/src/docs/pages/ChatUserCsvAttachmentPage.d.ts +1 -0
  17. package/dist/esm/types/src/utils/downloadTextFile.d.ts +2 -0
  18. package/dist/esm/utils/downloadTextFile.js +14 -0
  19. package/package.json +1 -1
  20. package/src/components/ui/Chat/Chat.types.ts +16 -0
  21. package/src/components/ui/Chat/ChatChrome/ChatChrome.tsx +1 -0
  22. package/src/components/ui/Chat/ChatMessage/ChatMessage.styl +67 -0
  23. package/src/components/ui/Chat/ChatMessage/ChatMessage.styl.d.ts +6 -0
  24. package/src/components/ui/Chat/ChatMessage/ChatMessage.tsx +8 -1
  25. package/src/components/ui/Chat/ChatMessage/UserCsvAttachmentBubble.tsx +36 -0
  26. package/src/components/ui/Chat/ChatMessage/icons/CsvIcon.tsx +7 -0
  27. package/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.tsx +15 -15
  28. package/src/components/ui/Chat/chat-preset-utils.ts +12 -6
  29. package/src/components/ui/Chat/index.ts +3 -1
  30. package/src/contexts/chat-context.tsx +124 -13
  31. package/src/docs/pages/ChatUserCsvAttachmentPage.tsx +171 -0
  32. package/src/docs/registry.ts +6 -0
  33. package/src/utils/downloadTextFile.ts +16 -0
@@ -17,6 +17,13 @@
17
17
  .role-user
18
18
  align-items flex-end
19
19
 
20
+ .userColumn
21
+ display flex
22
+ flex-direction column
23
+ align-items flex-end
24
+ gap var(--p-2)
25
+ max-width 100%
26
+
20
27
  .text
21
28
  padding var(--p-3) var(--p-4)
22
29
 
@@ -29,6 +36,66 @@
29
36
  :global(.dark) &
30
37
  background-color var(--sb-gray-800)
31
38
 
39
+ .userCsvCard
40
+ appearance none
41
+ border 0
42
+ margin 0
43
+ font inherit
44
+ display flex
45
+ align-items center
46
+ gap var(--p-4)
47
+ padding var(--p-3)
48
+ padding-right var(--p-4)
49
+ background-color var(--sb-slate-100)
50
+ box-shadow 0 0 0 1px var(--border)
51
+ border-radius var(--p-4)
52
+ border-bottom-right-radius 0
53
+ width fit-content
54
+ max-width 100%
55
+ text-align left
56
+ cursor pointer
57
+ transition background-color 150ms
58
+ color var(--sb-green-600)
59
+
60
+ &:hover
61
+ background-color var(--sb-gray-50)
62
+
63
+ &:focus-visible
64
+ outline 2px solid var(--ring)
65
+ outline-offset 2px
66
+
67
+ :global(.dark) &
68
+ background-color var(--sb-gray-800)
69
+ color var(--sb-green-400)
70
+
71
+ &:hover
72
+ background-color var(--sb-gray-900)
73
+
74
+ .userCsvCardIcon
75
+ display flex
76
+ align-items center
77
+ justify-content center
78
+ width 32px
79
+ height 32px
80
+ flex-shrink 0
81
+
82
+ .userCsvCardContent
83
+ display flex
84
+ flex-direction column
85
+ flex 1
86
+ min-width 0
87
+
88
+ .userCsvCardTitle
89
+ font-size var(--text-base)
90
+ font-weight 600
91
+ line-height 1.4
92
+ color var(--text-secondary)
93
+
94
+ .userCsvCardSubtitle
95
+ font-size var(--text-sm)
96
+ color var(--muted-foreground)
97
+ line-height 1.4
98
+
32
99
  .role-system
33
100
  align-items center
34
101
 
@@ -17,6 +17,12 @@ interface CssExports {
17
17
  'root': string;
18
18
  'scrollHorizontal': string;
19
19
  'text': string;
20
+ 'userColumn': string;
21
+ 'userCsvCard': string;
22
+ 'userCsvCardContent': string;
23
+ 'userCsvCardIcon': string;
24
+ 'userCsvCardSubtitle': string;
25
+ 'userCsvCardTitle': string;
20
26
  }
21
27
  export const cssExports: CssExports;
22
28
  export default cssExports;
@@ -8,10 +8,12 @@ import {
8
8
  } from '../Chat.types';
9
9
  import { AgentMessageContent } from './AgentMessageContent';
10
10
  import S from './ChatMessage.styl';
11
+ import { UserCsvAttachmentBubble } from './UserCsvAttachmentBubble';
11
12
 
12
13
  export function ChatMessage({
13
14
  role,
14
15
  text,
16
+ userCsvAttachment,
15
17
  onQuickReply,
16
18
  suppressedQuickReplyKeys,
17
19
  quickReplyDisabled,
@@ -45,7 +47,12 @@ export function ChatMessage({
45
47
  renderMessageChart={renderMessageChart}
46
48
  />
47
49
  ) : (
48
- <div className={S.text}>{text}</div>
50
+ <div className={S.userColumn}>
51
+ <div className={S.text}>{text}</div>
52
+ {userCsvAttachment ? (
53
+ <UserCsvAttachmentBubble attachment={userCsvAttachment} />
54
+ ) : null}
55
+ </div>
49
56
  )}
50
57
  </div>
51
58
  );
@@ -0,0 +1,36 @@
1
+ import type { UserCsvAttachment } from '../Chat.types';
2
+ import { downloadTextFile } from '#uilib/utils/downloadTextFile';
3
+
4
+ import { CsvIcon } from './icons/CsvIcon';
5
+ import S from './ChatMessage.styl';
6
+
7
+ const CSV_DOWNLOAD_HINT = 'Download .CSV file';
8
+
9
+ export function UserCsvAttachmentBubble({
10
+ attachment,
11
+ }: {
12
+ attachment: UserCsvAttachment;
13
+ }) {
14
+ return (
15
+ <button
16
+ type="button"
17
+ className={S.userCsvCard}
18
+ aria-label={`${CSV_DOWNLOAD_HINT}: ${attachment.displayName}`}
19
+ onClick={() =>
20
+ downloadTextFile(
21
+ attachment.content,
22
+ attachment.filename,
23
+ 'text/csv;charset=utf-8',
24
+ )
25
+ }
26
+ >
27
+ <div className={S.userCsvCardIcon}>
28
+ <CsvIcon size={32} />
29
+ </div>
30
+ <div className={S.userCsvCardContent}>
31
+ <div className={S.userCsvCardTitle}>{attachment.displayName}</div>
32
+ <div className={S.userCsvCardSubtitle}>{CSV_DOWNLOAD_HINT}</div>
33
+ </div>
34
+ </button>
35
+ );
36
+ }
@@ -0,0 +1,7 @@
1
+ import { FileSpreadsheet } from 'lucide-react';
2
+
3
+ export function CsvIcon({ size = 32 }: { size?: number }) {
4
+ return (
5
+ <FileSpreadsheet size={size} aria-hidden strokeWidth={1.75} color="currentColor" />
6
+ );
7
+ }
@@ -26,6 +26,7 @@ import {
26
26
  import {
27
27
  isChatEmpty,
28
28
  useChat,
29
+ useChatOutboundPending,
29
30
  useChatsForScopeId,
30
31
  } from '#uilib/contexts/chat-context';
31
32
  import useEvent from '#uilib/hooks/useEvent';
@@ -112,6 +113,7 @@ export function useChatPanelChromeModel({
112
113
  getShellWidth,
113
114
  setChatPanelOpen,
114
115
  } = useSidebar();
116
+ const [localUiBusy, setLocalUiBusy] = useState(false);
115
117
  const {
116
118
  chats,
117
119
  currentChatId,
@@ -122,6 +124,11 @@ export function useChatPanelChromeModel({
122
124
  removeMessageById,
123
125
  } = useChatsForScopeId(effectiveScopeId);
124
126
  const chat = useChat(effectiveScopeId, currentChatId);
127
+ const isOutboundPending = useChatOutboundPending(
128
+ effectiveScopeId,
129
+ currentChatId,
130
+ );
131
+ const isLoading = isOutboundPending || localUiBusy;
125
132
 
126
133
  const {
127
134
  searchParams,
@@ -130,7 +137,6 @@ export function useChatPanelChromeModel({
130
137
  mutateSearchParams,
131
138
  } = useQueryParams();
132
139
  const chatOpen = searchParams.has(CHAT_QUERY_PARAM);
133
- const [isLoading, setIsLoading] = useState(false);
134
140
  const [isOpen, setIsOpen] = useState(false);
135
141
  /** `?prompt=` deep link text for one-shot composer pre-fill (see ChatPrompt). */
136
142
  const [promptLinkPrefill, setPromptLinkPrefill] = useState<string | null>(
@@ -355,7 +361,7 @@ export function useChatPanelChromeModel({
355
361
  ) {
356
362
  if (quickReplyLockRef.current) return;
357
363
  quickReplyLockRef.current = true;
358
- setIsLoading(true);
364
+ setLocalUiBusy(true);
359
365
  setUsedScriptBranchKeysByChat(prev => ({
360
366
  ...prev,
361
367
  [chatId]: [...new Set([...(prev[chatId] || []), branchKey])],
@@ -413,7 +419,7 @@ export function useChatPanelChromeModel({
413
419
  } catch (error) {
414
420
  logger.error('Error resolving preset quick reply:', error);
415
421
  } finally {
416
- setIsLoading(false);
422
+ setLocalUiBusy(false);
417
423
  quickReplyLockRef.current = false;
418
424
  }
419
425
  })();
@@ -422,14 +428,11 @@ export function useChatPanelChromeModel({
422
428
 
423
429
  endLocalDemoFlow(chatId);
424
430
  void (async () => {
425
- setIsLoading(true);
426
431
  try {
427
432
  await sendMessage(displayLabel);
428
433
  onMessage?.(displayLabel);
429
434
  } catch (error) {
430
435
  logger.error('Error sending chat message:', error);
431
- } finally {
432
- setIsLoading(false);
433
436
  }
434
437
  })();
435
438
  },
@@ -499,7 +502,7 @@ export function useChatPanelChromeModel({
499
502
  ) {
500
503
  if (quickReplyLockRef.current) return;
501
504
  quickReplyLockRef.current = true;
502
- setIsLoading(true);
505
+ setLocalUiBusy(true);
503
506
  const newAnswers = {
504
507
  ...intake.answers,
505
508
  [intake.scriptStepId]: message,
@@ -544,7 +547,7 @@ export function useChatPanelChromeModel({
544
547
  } catch (e) {
545
548
  logger.error('Error advancing freeform preset script:', e);
546
549
  } finally {
547
- setIsLoading(false);
550
+ setLocalUiBusy(false);
548
551
  quickReplyLockRef.current = false;
549
552
  }
550
553
  })();
@@ -569,15 +572,12 @@ export function useChatPanelChromeModel({
569
572
  }
570
573
  }
571
574
 
572
- setIsLoading(true);
573
575
  try {
574
576
  if (chatId) endLocalDemoFlow(chatId);
575
577
  await sendMessage(message);
576
578
  onMessage?.(message);
577
579
  } catch (error) {
578
580
  logger.error('Error sending chat message:', error);
579
- } finally {
580
- setIsLoading(false);
581
581
  }
582
582
  },
583
583
  [
@@ -613,7 +613,7 @@ export function useChatPanelChromeModel({
613
613
  return;
614
614
  }
615
615
 
616
- setIsLoading(true);
616
+ setLocalUiBusy(true);
617
617
  try {
618
618
  if (!currentChatId) return;
619
619
  setScriptCompleteByChatId(prev => {
@@ -683,7 +683,7 @@ export function useChatPanelChromeModel({
683
683
  } catch (error) {
684
684
  logger.error('Error sending chat message:', error);
685
685
  } finally {
686
- setIsLoading(false);
686
+ setLocalUiBusy(false);
687
687
  }
688
688
  };
689
689
 
@@ -717,7 +717,7 @@ export function useChatPanelChromeModel({
717
717
 
718
718
  const chatId = currentChatId;
719
719
  scriptAdvanceLockRef.current = true;
720
- setIsLoading(true);
720
+ setLocalUiBusy(true);
721
721
  addMessage(chatId, MessageRole.USER, step.buttonLabel);
722
722
 
723
723
  void (async () => {
@@ -761,7 +761,7 @@ export function useChatPanelChromeModel({
761
761
  } catch (error) {
762
762
  logger.error('Error advancing preset script:', error);
763
763
  } finally {
764
- setIsLoading(false);
764
+ setLocalUiBusy(false);
765
765
  scriptAdvanceLockRef.current = false;
766
766
  }
767
767
  })();
@@ -11,17 +11,23 @@ export function normalizePresetMatchText(s: string): string {
11
11
  return s.trim().normalize('NFC');
12
12
  }
13
13
 
14
+ function presetMatchesUserText(presetText: string, userTextNorm: string): boolean {
15
+ const presetNorm = normalizePresetMatchText(presetText);
16
+ if (!presetNorm) return false;
17
+ if (userTextNorm === presetNorm) return true;
18
+ const prefix = `${presetNorm} `;
19
+ return userTextNorm.startsWith(prefix);
20
+ }
21
+
14
22
  export function usedPresetIdsFromMessages(
15
23
  messages: Message[] | undefined,
16
24
  presets: ChatPreset[] | undefined,
17
25
  ): string[] {
18
26
  if (!messages?.length || !presets?.length) return [];
19
- const userTexts = new Set(
20
- messages
21
- .filter(m => m.role === MessageRole.USER)
22
- .map(m => normalizePresetMatchText(m.text)),
23
- );
27
+ const userTexts = messages
28
+ .filter(m => m.role === MessageRole.USER)
29
+ .map(m => normalizePresetMatchText(m.text));
24
30
  return presets
25
- .filter(p => userTexts.has(normalizePresetMatchText(p.text)))
31
+ .filter(p => userTexts.some(ut => presetMatchesUserText(p.text, ut)))
26
32
  .map(p => p.id);
27
33
  }
@@ -17,8 +17,10 @@ export { ChatPrompt } from './ChatPrompt';
17
17
  export { ChatPresets } from './ChatPresets';
18
18
  export type {
19
19
  Chat as ChatType,
20
- Message,
20
+ ChatSendMessagePayload,
21
21
  ChatProps,
22
22
  ChatPreset as ChatPresetType,
23
+ Message,
24
+ UserCsvAttachment,
23
25
  } from './Chat.types';
24
26
  export { MessageRole } from './Chat.types';
@@ -10,8 +10,10 @@ import {
10
10
 
11
11
  import {
12
12
  type Chat,
13
+ type ChatSendMessagePayload,
13
14
  type Message,
14
15
  MessageRole,
16
+ type UserCsvAttachment,
15
17
  } from '#uilib/components/ui/Chat/Chat.types';
16
18
  import { stripJsonDashboardFences } from '#uilib/lib/dashboard-spec/stripJsonDashboardFences';
17
19
  import type { ChatResponse } from '#uilib/types/chat-api.types';
@@ -22,9 +24,18 @@ export type SendChatMessageFn = (
22
24
  targetChatId: string,
23
25
  ) => Promise<ChatResponse>;
24
26
 
27
+ export type {
28
+ ChatSendMessagePayload,
29
+ UserCsvAttachment,
30
+ } from '#uilib/components/ui/Chat/Chat.types';
31
+
25
32
  const CHATS_PREFIX = 'chats-';
26
33
  const CHAT_SCOPE_IDS_REGISTRY_KEY = 'chat-scope-ids';
27
34
 
35
+ export type AddChatMessageOptions = {
36
+ userCsvAttachment?: UserCsvAttachment;
37
+ };
38
+
28
39
  export interface ChatContextType {
29
40
  /** Returns the new session id, or undefined if no user / not created. */
30
41
  newChat: (scopeId: string) => string | undefined;
@@ -34,6 +45,7 @@ export interface ChatContextType {
34
45
  chatId: string,
35
46
  role: MessageRole,
36
47
  text: string,
48
+ options?: AddChatMessageOptions,
37
49
  ) => string | undefined;
38
50
  removeMessageById: (
39
51
  scopeId: string,
@@ -42,16 +54,26 @@ export interface ChatContextType {
42
54
  ) => void;
43
55
  sendMessage: (
44
56
  scopeId: string,
45
- message: string,
57
+ message: string | ChatSendMessagePayload,
46
58
  chatId?: string,
47
59
  ) => Promise<string>;
48
60
  getChatsForScopeId: (scopeId: string) => Chat[];
49
61
  getCurrentChatId: (scopeId: string) => string | null;
50
62
  deleteChat: (scopeId: string, sessionId: string) => void;
63
+ /**
64
+ * Ref-count of in-flight `sendChatMessage` requests keyed by
65
+ * `outboundPendingKey(scopeId, chatSessionId)` (session id at send start).
66
+ */
67
+ outboundPendingByKey: Readonly<Record<string, number>>;
51
68
  }
52
69
 
53
70
  const ChatContext = createContext<ChatContextType | undefined>(undefined);
54
71
 
72
+ /** Stable composite key; avoids collisions if `scopeId` contains `:`. */
73
+ export function outboundPendingKey(scopeId: string, chatSessionId: string) {
74
+ return `${scopeId}\0${chatSessionId}`;
75
+ }
76
+
55
77
  function getCurrentChatIdKey(scopeId: string) {
56
78
  return `chat-current-id-${scopeId}`;
57
79
  }
@@ -149,6 +171,35 @@ export function ChatProvider({
149
171
  return loadChatsFromLS(userSwitchKey).currentChatId;
150
172
  });
151
173
 
174
+ const [outboundPendingByKey, setOutboundPendingByKey] = useState<
175
+ Record<string, number>
176
+ >({});
177
+
178
+ const beginOutboundPending = useCallback(
179
+ (scopeId: string, chatSessionId: string) => {
180
+ const key = outboundPendingKey(scopeId, chatSessionId);
181
+ setOutboundPendingByKey(prev => ({
182
+ ...prev,
183
+ [key]: (prev[key] ?? 0) + 1,
184
+ }));
185
+ },
186
+ [],
187
+ );
188
+
189
+ const endOutboundPending = useCallback(
190
+ (scopeId: string, chatSessionId: string) => {
191
+ const key = outboundPendingKey(scopeId, chatSessionId);
192
+ setOutboundPendingByKey(prev => {
193
+ const next = { ...prev };
194
+ const n = (next[key] ?? 0) - 1;
195
+ if (n <= 0) delete next[key];
196
+ else next[key] = n;
197
+ return next;
198
+ });
199
+ },
200
+ [],
201
+ );
202
+
152
203
  const getChatsForScopeId = useCallback(
153
204
  (scopeId: string): Chat[] => chats[scopeId] ?? [],
154
205
  [chats],
@@ -239,15 +290,24 @@ export function ChatProvider({
239
290
  }, []);
240
291
 
241
292
  const addMessage = useCallback(
242
- (scopeId: string, chatId: string, role: MessageRole, text: string) => {
293
+ (
294
+ scopeId: string,
295
+ chatId: string,
296
+ role: MessageRole,
297
+ text: string,
298
+ options?: AddChatMessageOptions,
299
+ ) => {
243
300
  if (userSwitchKey === null) return undefined;
244
301
  addScopeIdToRegistry(scopeId);
245
302
  const storedText = stripJsonDashboardFences(text);
303
+ const attachment =
304
+ role === MessageRole.USER ? options?.userCsvAttachment : undefined;
246
305
  const newMessage: Message = {
247
306
  id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
248
307
  role,
249
308
  text: storedText,
250
309
  timestamp: Date.now(),
310
+ ...(attachment ? { userCsvAttachment: attachment } : {}),
251
311
  };
252
312
 
253
313
  setChats(prev => {
@@ -292,7 +352,7 @@ export function ChatProvider({
292
352
  const sendMessage = useCallback(
293
353
  async (
294
354
  scopeId: string,
295
- message: string,
355
+ message: string | ChatSendMessagePayload,
296
356
  chatId?: string,
297
357
  ): Promise<string> => {
298
358
  const targetChatId = chatId ?? getCurrentChatId(scopeId);
@@ -300,16 +360,33 @@ export function ChatProvider({
300
360
  throw new Error('No chat selected');
301
361
  }
302
362
 
303
- addMessage(scopeId, targetChatId, MessageRole.USER, message);
363
+ const apiPayload =
364
+ typeof message === 'string' ? message : message.apiMessage;
304
365
 
366
+ if (typeof message === 'string') {
367
+ addMessage(scopeId, targetChatId, MessageRole.USER, message);
368
+ } else {
369
+ addMessage(
370
+ scopeId,
371
+ targetChatId,
372
+ MessageRole.USER,
373
+ message.displayText,
374
+ {
375
+ userCsvAttachment: message.userCsvAttachment,
376
+ },
377
+ );
378
+ }
379
+
380
+ const pendingChatSessionId = targetChatId;
381
+ beginOutboundPending(scopeId, pendingChatSessionId);
305
382
  try {
306
- const data = await sendChatMessageFn(message, targetChatId);
383
+ const data = await sendChatMessageFn(apiPayload, pendingChatSessionId);
307
384
 
308
- if (data.session_id && data.session_id !== targetChatId) {
385
+ if (data.session_id && data.session_id !== pendingChatSessionId) {
309
386
  setChats(prev => {
310
387
  const scopeChats = prev[scopeId] ?? [];
311
388
  const updatedChats = scopeChats.map(chat =>
312
- chat.session_id === targetChatId
389
+ chat.session_id === pendingChatSessionId
313
390
  ? { ...chat, session_id: data.session_id! }
314
391
  : chat,
315
392
  );
@@ -324,7 +401,7 @@ export function ChatProvider({
324
401
 
325
402
  addMessage(
326
403
  scopeId,
327
- data.session_id ? data.session_id : targetChatId,
404
+ data.session_id ? data.session_id : pendingChatSessionId,
328
405
  MessageRole.ASSISTANT,
329
406
  data.response,
330
407
  );
@@ -336,17 +413,32 @@ export function ChatProvider({
336
413
  ? error.message
337
414
  : 'Sorry, I encountered an error processing your message. Please try again.';
338
415
 
339
- addMessage(scopeId, targetChatId, MessageRole.ASSISTANT, errorMessage);
416
+ addMessage(
417
+ scopeId,
418
+ pendingChatSessionId,
419
+ MessageRole.ASSISTANT,
420
+ errorMessage,
421
+ );
340
422
  throw error;
423
+ } finally {
424
+ endOutboundPending(scopeId, pendingChatSessionId);
341
425
  }
342
426
  },
343
- [addMessage, getCurrentChatId, sendChatMessageFn, setCurrentChatId],
427
+ [
428
+ addMessage,
429
+ beginOutboundPending,
430
+ endOutboundPending,
431
+ getCurrentChatId,
432
+ sendChatMessageFn,
433
+ setCurrentChatId,
434
+ ],
344
435
  );
345
436
 
346
437
  useEffect(() => {
347
438
  if (userSwitchKey === null) {
348
439
  setChats({});
349
440
  setCurrentChatIdState({});
441
+ setOutboundPendingByKey({});
350
442
  return;
351
443
  }
352
444
 
@@ -377,6 +469,7 @@ export function ChatProvider({
377
469
  getChatsForScopeId,
378
470
  getCurrentChatId,
379
471
  deleteChat,
472
+ outboundPendingByKey,
380
473
  }}
381
474
  >
382
475
  {children}
@@ -409,6 +502,18 @@ export function useChat(
409
502
  }, [scopeId, chatId, getChatsForScopeId]);
410
503
  }
411
504
 
505
+ export function useChatOutboundPending(
506
+ scopeId: string | undefined | null,
507
+ chatSessionId: string | null | undefined,
508
+ ): boolean {
509
+ const { outboundPendingByKey } = useChats();
510
+ return useMemo(() => {
511
+ if (!scopeId || !chatSessionId) return false;
512
+ const key = outboundPendingKey(scopeId, chatSessionId);
513
+ return (outboundPendingByKey[key] ?? 0) > 0;
514
+ }, [scopeId, chatSessionId, outboundPendingByKey]);
515
+ }
516
+
412
517
  export function useChatsForScopeId(scopeId: string) {
413
518
  const {
414
519
  getChatsForScopeId,
@@ -423,18 +528,24 @@ export function useChatsForScopeId(scopeId: string) {
423
528
  const chats = getChatsForScopeId(scopeId);
424
529
  const currentChatId = getCurrentChatId(scopeId);
425
530
  const currentChat = useChat(scopeId, currentChatId ?? undefined);
531
+ const isOutboundPending = useChatOutboundPending(scopeId, currentChatId);
426
532
 
427
533
  return {
428
534
  chats,
429
535
  currentChat,
430
536
  currentChatId,
537
+ isOutboundPending,
431
538
  setCurrentChatId: (targetId: string) => setCurrentChatId(scopeId, targetId),
432
539
  newChat: () => newChat(scopeId),
433
- addMessage: (chatId: string, role: MessageRole, text: string) =>
434
- addMessage(scopeId, chatId, role, text),
540
+ addMessage: (
541
+ chatId: string,
542
+ role: MessageRole,
543
+ text: string,
544
+ options?: AddChatMessageOptions,
545
+ ) => addMessage(scopeId, chatId, role, text, options),
435
546
  removeMessageById: (chatId: string, messageId: string) =>
436
547
  removeMessageById(scopeId, chatId, messageId),
437
- sendMessage: (message: string, chatId?: string) =>
548
+ sendMessage: (message: string | ChatSendMessagePayload, chatId?: string) =>
438
549
  sendMessage(scopeId, message, chatId),
439
550
  deleteChat: (sessionId: string) => deleteChat(scopeId, sessionId),
440
551
  };