@sybilion/uilib 1.2.20 → 1.2.22

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.
@@ -56,6 +56,20 @@ const injectBold = (content) => {
56
56
  length: matches[0].length,
57
57
  };
58
58
  };
59
+ /** `_italic_` (underscore) — avoids clashing with `* ` bullet markers. */
60
+ const injectItalic = (content) => {
61
+ const matches = content.match(/(?<![A-Za-z0-9])_([^_\n]+?)_(?![A-Za-z0-9])/);
62
+ if (!matches || matches.index === undefined)
63
+ return null;
64
+ if (isInsideHtmlListOrTable(content, matches.index)) {
65
+ return null;
66
+ }
67
+ return {
68
+ elem: jsx("em", { children: matches[1] }),
69
+ index: matches.index,
70
+ length: matches[0].length,
71
+ };
72
+ };
59
73
  const injectBullet = (content) => {
60
74
  // Match bullet points: * or - followed by space
61
75
  // Match at start of string/line or after whitespace/colon (for nested bullets)
@@ -229,6 +243,33 @@ const isAllowedHref = (href) => {
229
243
  return true;
230
244
  return false;
231
245
  };
246
+ const normalizeWwwToHttps = (raw) => {
247
+ const t = raw.trim();
248
+ if (/^www\./i.test(t))
249
+ return `https://${t}`;
250
+ return t;
251
+ };
252
+ /** Strip ASCII closing punctuation often pasted after URLs. */
253
+ const stripTrailingUrlPunctuation = (s) => {
254
+ let u = s;
255
+ while (u.length > 0 && /[.,;:!?)}\]]$/u.test(u)) {
256
+ u = u.slice(0, -1);
257
+ }
258
+ return u;
259
+ };
260
+ function linkTargetRelForHref(href, explicitTarget) {
261
+ if (explicitTarget) {
262
+ const t = explicitTarget.trim();
263
+ if (t.toLowerCase() === '_blank') {
264
+ return { target: '_blank', rel: 'noopener noreferrer' };
265
+ }
266
+ return { target: t || undefined, rel: undefined };
267
+ }
268
+ if (/^https?:\/\//i.test(href) || /^mailto:/i.test(href)) {
269
+ return { target: '_blank', rel: 'noopener noreferrer' };
270
+ }
271
+ return { target: undefined, rel: undefined };
272
+ }
232
273
  const runFormattingPipeline = (text, injectors) => {
233
274
  let result = [text];
234
275
  try {
@@ -260,6 +301,76 @@ const runFormattingPipeline = (text, injectors) => {
260
301
  }
261
302
  return result;
262
303
  };
304
+ const linkLabelInjectors = [
305
+ injectHTMLTags,
306
+ injectBold,
307
+ injectItalic,
308
+ injectNewlines,
309
+ ];
310
+ const injectMarkdownLink = (content) => {
311
+ const matches = content.match(/\[([^\]]+)\]\(([^)]+)\)/);
312
+ if (!matches || matches.index === undefined)
313
+ return null;
314
+ if (isInsideHtmlListOrTable(content, matches.index)) {
315
+ return null;
316
+ }
317
+ const href = normalizeWwwToHttps(matches[2].trim());
318
+ if (!isAllowedHref(href))
319
+ return null;
320
+ const { target, rel } = linkTargetRelForHref(href, undefined);
321
+ const label = matches[1];
322
+ return {
323
+ elem: (jsx("a", { href: href, target: target, rel: rel, children: runFormattingPipeline(label, linkLabelInjectors) })),
324
+ index: matches.index,
325
+ length: matches[0].length,
326
+ };
327
+ };
328
+ const injectAutolinkUrl = (content) => {
329
+ const candidates = [];
330
+ const reHttp = /https?:\/\/[^\s<>"']+/gi;
331
+ let hm;
332
+ while ((hm = reHttp.exec(content)) !== null) {
333
+ const href = stripTrailingUrlPunctuation(hm[0]);
334
+ if (href.length >= (href.startsWith('https://') ? 8 : 7) &&
335
+ isAllowedHref(href) &&
336
+ /^https?:\/\//i.test(href)) {
337
+ candidates.push({
338
+ index: hm.index,
339
+ length: href.length,
340
+ href,
341
+ display: href,
342
+ });
343
+ }
344
+ }
345
+ const reWww = /(^|[^A-Za-z0-9/])(www\.[^\s<>"']+)/gi;
346
+ while ((hm = reWww.exec(content)) !== null) {
347
+ const prefix = hm[1] ?? '';
348
+ const rawWww = hm[2];
349
+ const body = stripTrailingUrlPunctuation(rawWww);
350
+ const href = `https://${body}`;
351
+ if (body.length > 4 && isAllowedHref(href)) {
352
+ candidates.push({
353
+ index: hm.index + prefix.length,
354
+ length: body.length,
355
+ href,
356
+ display: body,
357
+ });
358
+ }
359
+ }
360
+ if (candidates.length === 0)
361
+ return null;
362
+ candidates.sort((a, b) => a.index - b.index);
363
+ const c = candidates[0];
364
+ if (isInsideHtmlListOrTable(content, c.index)) {
365
+ return null;
366
+ }
367
+ const { target, rel } = linkTargetRelForHref(c.href, undefined);
368
+ return {
369
+ elem: (jsx("a", { href: c.href, target: target, rel: rel, children: c.display })),
370
+ index: c.index,
371
+ length: c.length,
372
+ };
373
+ };
263
374
  const injectAnchor = (content) => {
264
375
  const regex = /<a\s+([^>]+)>([\s\S]*?)<\/a\s*>/i;
265
376
  const matches = content.match(regex);
@@ -281,15 +392,9 @@ const injectAnchor = (content) => {
281
392
  if (!href || !isAllowedHref(href)) {
282
393
  return null;
283
394
  }
284
- const opensNewTab = targetRaw.toLowerCase() === '_blank';
285
- const target = opensNewTab ? '_blank' : targetRaw || undefined;
286
- const rel = target === '_blank' ? 'noopener noreferrer' : undefined;
395
+ const { target, rel } = linkTargetRelForHref(href, targetRaw || undefined);
287
396
  return {
288
- elem: (jsx("a", { href: href, target: target, rel: rel, children: runFormattingPipeline(inner, [
289
- injectHTMLTags,
290
- injectBold,
291
- injectNewlines,
292
- ]) })),
397
+ elem: (jsx("a", { href: href, target: target, rel: rel, children: runFormattingPipeline(inner, linkLabelInjectors) })),
293
398
  index: matches.index,
294
399
  length: matches[0].length,
295
400
  };
@@ -297,16 +402,22 @@ const injectAnchor = (content) => {
297
402
  const applyFormatting = (text) => runFormattingPipeline(text, [
298
403
  injectHeaders,
299
404
  injectAnchor,
405
+ injectMarkdownLink,
300
406
  injectHTMLTags,
301
407
  injectBullet,
302
408
  injectBold,
409
+ injectItalic,
410
+ injectAutolinkUrl,
303
411
  injectNewlines,
304
412
  ]);
305
413
  const applyFormattingInline = (text) => runFormattingPipeline(text, [
306
414
  injectAnchor,
415
+ injectMarkdownLink,
307
416
  injectHTMLTags,
308
417
  injectBold,
418
+ injectItalic,
419
+ injectAutolinkUrl,
309
420
  injectNewlines,
310
421
  ]);
311
422
 
312
- export { applyFormatting, applyFormattingInline, convertMarkdownTableToHTML, injectAnchor, injectBold, injectBullet, injectHeaders, injectNewlines };
423
+ export { applyFormatting, applyFormattingInline, convertMarkdownTableToHTML, injectAnchor, injectBold, injectBullet, injectHeaders, injectItalic, injectNewlines };
@@ -1,5 +1,6 @@
1
1
  import { jsx, jsxs } from 'react/jsx-runtime';
2
2
  import cn from 'classnames';
3
+ import { InteractiveContent } from '../../InteractiveContent/InteractiveContent.js';
3
4
  import { TextShimmer } from '../../TextShimmer/TextShimmer.js';
4
5
  import { MessageRole, GENERATING_DASHBOARD_SYSTEM_TEXT } from '../Chat.types.js';
5
6
  import { AgentMessageContent } from './AgentMessageContent.js';
@@ -9,7 +10,7 @@ import { UserCsvAttachmentBubble } from './UserCsvAttachmentBubble.js';
9
10
  function ChatMessage({ role, text, userCsvAttachment, onQuickReply, suppressedQuickReplyKeys, quickReplyDisabled, isLastMessage = true, scriptContinue, onScriptContinue, renderMessageChart, }) {
10
11
  const isAssistant = role === MessageRole.ASSISTANT;
11
12
  const isSystem = role === MessageRole.SYSTEM;
12
- return (jsx("div", { className: cn(S.root, S[`role-${role}`]), children: isSystem ? (jsx("div", { className: S.text, children: text === GENERATING_DASHBOARD_SYSTEM_TEXT ? (jsx(TextShimmer, { as: "span", children: text })) : (text) })) : isAssistant ? (jsx(AgentMessageContent, { text: text, onQuickReply: onQuickReply, suppressedQuickReplyKeys: suppressedQuickReplyKeys, quickReplyDisabled: quickReplyDisabled, isLastMessage: isLastMessage, scriptContinue: scriptContinue, onScriptContinue: onScriptContinue, renderMessageChart: renderMessageChart })) : (jsxs("div", { className: S.userColumn, children: [jsx("div", { className: S.text, children: text }), userCsvAttachment ? (jsx(UserCsvAttachmentBubble, { attachment: userCsvAttachment })) : null] })) }));
13
+ return (jsx("div", { className: cn(S.root, S[`role-${role}`]), children: isSystem ? (jsx("div", { className: S.text, children: text === GENERATING_DASHBOARD_SYSTEM_TEXT ? (jsx(TextShimmer, { as: "span", children: text })) : (text) })) : isAssistant ? (jsx(AgentMessageContent, { text: text, onQuickReply: onQuickReply, suppressedQuickReplyKeys: suppressedQuickReplyKeys, quickReplyDisabled: quickReplyDisabled, isLastMessage: isLastMessage, scriptContinue: scriptContinue, onScriptContinue: onScriptContinue, renderMessageChart: renderMessageChart })) : (jsxs("div", { className: S.userColumn, children: [jsx("div", { className: S.text, children: jsx(InteractiveContent, { text: text }) }), userCsvAttachment ? (jsx(UserCsvAttachmentBubble, { attachment: userCsvAttachment })) : null] })) }));
13
14
  }
14
15
 
15
16
  export { ChatMessage };
@@ -3,7 +3,7 @@ import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
3
3
  import { MessageRole, GENERATING_DASHBOARD_SYSTEM_TEXT } from '../Chat.types.js';
4
4
  import { isGraphIntakeAssistantStepComplete, matchUserTextToQuickReply, parseScriptLine, textHasQuickReplyMarkers, branchKeysUsedFromChatHistory, branchKeysUsedByUserMessages, extractQuickReplyLabelKeyPairsFromText, entryBranchKeyBeforeLastAssistant, isPresetScriptGraph, branchesFromPresetScriptGraph } from '../ChatMessage/presetScript.js';
5
5
  import { usedPresetIdsFromMessages, formatChatTranscript } from '../chat-preset-utils.js';
6
- import { useChatsForScopeId, useChat, isChatEmpty } from '../../../../contexts/chat-context.js';
6
+ import { useChatsForScopeId, useChat, useChatOutboundPending, isChatEmpty } from '../../../../contexts/chat-context.js';
7
7
  import useEvent from '../../../../hooks/useEvent.js';
8
8
  import { useIsMobile } from '../../../../hooks/useIsMobile.js';
9
9
  import { useQueryParams } from '../../../../hooks/useQueryParams.js';
@@ -25,11 +25,13 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
25
25
  const effectiveScopeId = scopeId ?? NO_SCOPE_FALLBACK;
26
26
  const isMobile = useIsMobile();
27
27
  const { chatPanelContainer, isOpen: sidebarNavOpen, setOpen: setSidebarNavOpen, chatWidthPx, setChatWidthPx, getShellWidth, setChatPanelOpen, } = useSidebar();
28
+ const [localUiBusy, setLocalUiBusy] = useState(false);
28
29
  const { chats, currentChatId, setCurrentChatId, newChat, sendMessage, addMessage, removeMessageById, } = useChatsForScopeId(effectiveScopeId);
29
30
  const chat = useChat(effectiveScopeId, currentChatId);
31
+ const isOutboundPending = useChatOutboundPending(effectiveScopeId, currentChatId);
32
+ const isLoading = isOutboundPending || localUiBusy;
30
33
  const { searchParams, addSearchParams, removeSearchParams, mutateSearchParams, } = useQueryParams();
31
34
  const chatOpen = searchParams.has(CHAT_QUERY_PARAM);
32
- const [isLoading, setIsLoading] = useState(false);
33
35
  const [isOpen, setIsOpen] = useState(false);
34
36
  /** `?prompt=` deep link text for one-shot composer pre-fill (see ChatPrompt). */
35
37
  const [promptLinkPrefill, setPromptLinkPrefill] = useState(null);
@@ -215,7 +217,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
215
217
  if (quickReplyLockRef.current)
216
218
  return;
217
219
  quickReplyLockRef.current = true;
218
- setIsLoading(true);
220
+ setLocalUiBusy(true);
219
221
  setUsedScriptBranchKeysByChat(prev => ({
220
222
  ...prev,
221
223
  [chatId]: [...new Set([...(prev[chatId] || []), branchKey])],
@@ -266,7 +268,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
266
268
  logger.error('Error resolving preset quick reply:', error);
267
269
  }
268
270
  finally {
269
- setIsLoading(false);
271
+ setLocalUiBusy(false);
270
272
  quickReplyLockRef.current = false;
271
273
  }
272
274
  })();
@@ -274,7 +276,6 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
274
276
  }
275
277
  endLocalDemoFlow(chatId);
276
278
  void (async () => {
277
- setIsLoading(true);
278
279
  try {
279
280
  await sendMessage(displayLabel);
280
281
  onMessage?.(displayLabel);
@@ -282,9 +283,6 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
282
283
  catch (error) {
283
284
  logger.error('Error sending chat message:', error);
284
285
  }
285
- finally {
286
- setIsLoading(false);
287
- }
288
286
  })();
289
287
  }, [
290
288
  currentChatId,
@@ -337,7 +335,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
337
335
  if (quickReplyLockRef.current)
338
336
  return;
339
337
  quickReplyLockRef.current = true;
340
- setIsLoading(true);
338
+ setLocalUiBusy(true);
341
339
  const newAnswers = {
342
340
  ...intake.answers,
343
341
  [intake.scriptStepId]: message,
@@ -381,7 +379,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
381
379
  logger.error('Error advancing freeform preset script:', e);
382
380
  }
383
381
  finally {
384
- setIsLoading(false);
382
+ setLocalUiBusy(false);
385
383
  quickReplyLockRef.current = false;
386
384
  }
387
385
  })();
@@ -401,7 +399,6 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
401
399
  }
402
400
  }
403
401
  }
404
- setIsLoading(true);
405
402
  try {
406
403
  if (chatId)
407
404
  endLocalDemoFlow(chatId);
@@ -411,9 +408,6 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
411
408
  catch (error) {
412
409
  logger.error('Error sending chat message:', error);
413
410
  }
414
- finally {
415
- setIsLoading(false);
416
- }
417
411
  }, [
418
412
  currentChatId,
419
413
  chat?.messages,
@@ -443,7 +437,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
443
437
  await handlePromptSubmit(preset.text);
444
438
  return;
445
439
  }
446
- setIsLoading(true);
440
+ setLocalUiBusy(true);
447
441
  try {
448
442
  if (!currentChatId)
449
443
  return;
@@ -518,7 +512,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
518
512
  logger.error('Error sending chat message:', error);
519
513
  }
520
514
  finally {
521
- setIsLoading(false);
515
+ setLocalUiBusy(false);
522
516
  }
523
517
  };
524
518
  const activeScript = currentChatId
@@ -550,7 +544,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
550
544
  return;
551
545
  const chatId = currentChatId;
552
546
  scriptAdvanceLockRef.current = true;
553
- setIsLoading(true);
547
+ setLocalUiBusy(true);
554
548
  addMessage(chatId, MessageRole.USER, step.buttonLabel);
555
549
  void (async () => {
556
550
  try {
@@ -593,7 +587,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
593
587
  logger.error('Error advancing preset script:', error);
594
588
  }
595
589
  finally {
596
- setIsLoading(false);
590
+ setLocalUiBusy(false);
597
591
  scriptAdvanceLockRef.current = false;
598
592
  }
599
593
  })();
@@ -1,6 +1,6 @@
1
1
  import styleInject from 'style-inject';
2
2
 
3
- var css_248z = ".InteractiveContent_root__FHnlY strong{font-weight:600}";
3
+ var css_248z = ".InteractiveContent_root__FHnlY strong{font-weight:600}.InteractiveContent_root__FHnlY em{font-style:italic}.InteractiveContent_root__FHnlY a{color:var(--sb-green-600);text-decoration:underline;text-underline-offset:2px}.InteractiveContent_root__FHnlY a:hover{color:var(--sb-green-700)}.dark .InteractiveContent_root__FHnlY a{color:var(--sb-green-400)}.dark .InteractiveContent_root__FHnlY a:hover{color:var(--sb-green-300)}";
4
4
  var S = {"root":"InteractiveContent_root__FHnlY"};
5
5
  styleInject(css_248z);
6
6
 
@@ -7,6 +7,10 @@ import { LS } from '@homecode/ui';
7
7
  const CHATS_PREFIX = 'chats-';
8
8
  const CHAT_SCOPE_IDS_REGISTRY_KEY = 'chat-scope-ids';
9
9
  const ChatContext = createContext(undefined);
10
+ /** Stable composite key; avoids collisions if `scopeId` contains `:`. */
11
+ function outboundPendingKey(scopeId, chatSessionId) {
12
+ return `${scopeId}\0${chatSessionId}`;
13
+ }
10
14
  function getCurrentChatIdKey(scopeId) {
11
15
  return `chat-current-id-${scopeId}`;
12
16
  }
@@ -86,6 +90,26 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
86
90
  return {};
87
91
  return loadChatsFromLS(userSwitchKey).currentChatId;
88
92
  });
93
+ const [outboundPendingByKey, setOutboundPendingByKey] = useState({});
94
+ const beginOutboundPending = useCallback((scopeId, chatSessionId) => {
95
+ const key = outboundPendingKey(scopeId, chatSessionId);
96
+ setOutboundPendingByKey(prev => ({
97
+ ...prev,
98
+ [key]: (prev[key] ?? 0) + 1,
99
+ }));
100
+ }, []);
101
+ const endOutboundPending = useCallback((scopeId, chatSessionId) => {
102
+ const key = outboundPendingKey(scopeId, chatSessionId);
103
+ setOutboundPendingByKey(prev => {
104
+ const next = { ...prev };
105
+ const n = (next[key] ?? 0) - 1;
106
+ if (n <= 0)
107
+ delete next[key];
108
+ else
109
+ next[key] = n;
110
+ return next;
111
+ });
112
+ }, []);
89
113
  const getChatsForScopeId = useCallback((scopeId) => chats[scopeId] ?? [], [chats]);
90
114
  const getCurrentChatId = useCallback((scopeId) => {
91
115
  const v = currentChatId[scopeId];
@@ -211,12 +235,14 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
211
235
  userCsvAttachment: message.userCsvAttachment,
212
236
  });
213
237
  }
238
+ const pendingChatSessionId = targetChatId;
239
+ beginOutboundPending(scopeId, pendingChatSessionId);
214
240
  try {
215
- const data = await sendChatMessageFn(apiPayload, targetChatId);
216
- if (data.session_id && data.session_id !== targetChatId) {
241
+ const data = await sendChatMessageFn(apiPayload, pendingChatSessionId);
242
+ if (data.session_id && data.session_id !== pendingChatSessionId) {
217
243
  setChats(prev => {
218
244
  const scopeChats = prev[scopeId] ?? [];
219
- const updatedChats = scopeChats.map(chat => chat.session_id === targetChatId
245
+ const updatedChats = scopeChats.map(chat => chat.session_id === pendingChatSessionId
220
246
  ? { ...chat, session_id: data.session_id }
221
247
  : chat);
222
248
  const chatsKey = getChatsKey(scopeId);
@@ -225,21 +251,32 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
225
251
  });
226
252
  setCurrentChatId(scopeId, data.session_id);
227
253
  }
228
- addMessage(scopeId, data.session_id ? data.session_id : targetChatId, MessageRole.ASSISTANT, data.response);
254
+ addMessage(scopeId, data.session_id ? data.session_id : pendingChatSessionId, MessageRole.ASSISTANT, data.response);
229
255
  return data.response;
230
256
  }
231
257
  catch (error) {
232
258
  const errorMessage = error instanceof Error
233
259
  ? error.message
234
260
  : 'Sorry, I encountered an error processing your message. Please try again.';
235
- addMessage(scopeId, targetChatId, MessageRole.ASSISTANT, errorMessage);
261
+ addMessage(scopeId, pendingChatSessionId, MessageRole.ASSISTANT, errorMessage);
236
262
  throw error;
237
263
  }
238
- }, [addMessage, getCurrentChatId, sendChatMessageFn, setCurrentChatId]);
264
+ finally {
265
+ endOutboundPending(scopeId, pendingChatSessionId);
266
+ }
267
+ }, [
268
+ addMessage,
269
+ beginOutboundPending,
270
+ endOutboundPending,
271
+ getCurrentChatId,
272
+ sendChatMessageFn,
273
+ setCurrentChatId,
274
+ ]);
239
275
  useEffect(() => {
240
276
  if (userSwitchKey === null) {
241
277
  setChats({});
242
278
  setCurrentChatIdState({});
279
+ setOutboundPendingByKey({});
243
280
  return;
244
281
  }
245
282
  const loaded = loadChatsFromLS(userSwitchKey);
@@ -263,6 +300,7 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
263
300
  getChatsForScopeId,
264
301
  getCurrentChatId,
265
302
  deleteChat,
303
+ outboundPendingByKey,
266
304
  }, children: children }));
267
305
  }
268
306
  const isChatEmpty = (chat) => chat?.messages.length === 0;
@@ -282,15 +320,26 @@ function useChat(scopeId, chatId) {
282
320
  null);
283
321
  }, [scopeId, chatId, getChatsForScopeId]);
284
322
  }
323
+ function useChatOutboundPending(scopeId, chatSessionId) {
324
+ const { outboundPendingByKey } = useChats();
325
+ return useMemo(() => {
326
+ if (!scopeId || !chatSessionId)
327
+ return false;
328
+ const key = outboundPendingKey(scopeId, chatSessionId);
329
+ return (outboundPendingByKey[key] ?? 0) > 0;
330
+ }, [scopeId, chatSessionId, outboundPendingByKey]);
331
+ }
285
332
  function useChatsForScopeId(scopeId) {
286
333
  const { getChatsForScopeId, getCurrentChatId, setCurrentChatId, newChat, addMessage, removeMessageById, sendMessage, deleteChat, } = useChats();
287
334
  const chats = getChatsForScopeId(scopeId);
288
335
  const currentChatId = getCurrentChatId(scopeId);
289
336
  const currentChat = useChat(scopeId, currentChatId ?? undefined);
337
+ const isOutboundPending = useChatOutboundPending(scopeId, currentChatId);
290
338
  return {
291
339
  chats,
292
340
  currentChat,
293
341
  currentChatId,
342
+ isOutboundPending,
294
343
  setCurrentChatId: (targetId) => setCurrentChatId(scopeId, targetId),
295
344
  newChat: () => newChat(scopeId),
296
345
  addMessage: (chatId, role, text, options) => addMessage(scopeId, chatId, role, text, options),
@@ -309,4 +358,4 @@ function useCurrentChat(scopeId) {
309
358
  return useChat(scopeId, chatId ?? undefined);
310
359
  }
311
360
 
312
- export { ChatContext, ChatProvider, isChatEmpty, useChat, useChats, useChatsForDataset, useChatsForScopeId, useCurrentChat };
361
+ export { ChatContext, ChatProvider, isChatEmpty, outboundPendingKey, useChat, useChatOutboundPending, useChats, useChatsForDataset, useChatsForScopeId, useCurrentChat };
package/dist/esm/index.js CHANGED
@@ -4,7 +4,7 @@ export { DEFAULT_THEME_ACTIVE_COLOR } from './docs/lib/theme.js';
4
4
  export { SybilionAuthProvider, createSybilionApiFetch, getSybilionApiOriginFromSdk, sybilionApiFetch, useSybilionApiFetch, useSybilionAuth } from './sybilion-auth/SybilionAuthProvider.js';
5
5
  export { SYBILION_AUTH_LOGIN_PATH, normalizeApiBaseUrl } from './sybilion-auth/authPaths.js';
6
6
  export { exchangeAuth0AccessTokenForSybilionJwt } from './sybilion-auth/exchangeSybilionToken.js';
7
- export { ChatContext, ChatProvider, isChatEmpty, useChat, useChats, useChatsForDataset, useChatsForScopeId, useCurrentChat } from './contexts/chat-context.js';
7
+ export { ChatContext, ChatProvider, isChatEmpty, outboundPendingKey, useChat, useChatOutboundPending, useChats, useChatsForDataset, useChatsForScopeId, useCurrentChat } from './contexts/chat-context.js';
8
8
  export { AnalysesSelector } from './components/ui/AnalysesSelector/AnalysesSelector.js';
9
9
  export { AnalysisLineIcon } from './components/ui/AnalysisLineIcon/AnalysisLineIcon.js';
10
10
  export { AppHeaderHost, AppHeaderPortal } from './components/ui/AppHeader/AppHeader.js';
@@ -9,6 +9,12 @@ declare const injectBold: (content: string) => {
9
9
  index: number;
10
10
  length: number;
11
11
  };
12
+ /** `_italic_` (underscore) — avoids clashing with `* ` bullet markers. */
13
+ declare const injectItalic: (content: string) => {
14
+ elem: import("react/jsx-runtime").JSX.Element;
15
+ index: number;
16
+ length: number;
17
+ };
12
18
  declare const injectBullet: (content: string) => {
13
19
  elem: import("react/jsx-runtime").JSX.Element;
14
20
  index: number;
@@ -28,4 +34,4 @@ type Injector = (content: string) => {
28
34
  declare const injectAnchor: Injector;
29
35
  declare const applyFormatting: (text: string) => React.ReactNode[];
30
36
  declare const applyFormattingInline: (text: string) => React.ReactNode[];
31
- export { injectHeaders, injectAnchor, injectBold, injectBullet, injectNewlines, convertMarkdownTableToHTML, applyFormatting, applyFormattingInline, };
37
+ export { injectHeaders, injectAnchor, injectBold, injectItalic, injectBullet, injectNewlines, convertMarkdownTableToHTML, applyFormatting, applyFormattingInline, };
@@ -1,8 +1,8 @@
1
1
  import { ReactNode } from 'react';
2
- import { type Chat, type ChatSendMessagePayload, type UserCsvAttachment, MessageRole } from '#uilib/components/ui/Chat/Chat.types';
2
+ import { type Chat, type ChatSendMessagePayload, MessageRole, type UserCsvAttachment } from '#uilib/components/ui/Chat/Chat.types';
3
3
  import type { ChatResponse } from '#uilib/types/chat-api.types';
4
4
  export type SendChatMessageFn = (message: string, targetChatId: string) => Promise<ChatResponse>;
5
- export type { ChatSendMessagePayload, UserCsvAttachment } from '#uilib/components/ui/Chat/Chat.types';
5
+ export type { ChatSendMessagePayload, UserCsvAttachment, } from '#uilib/components/ui/Chat/Chat.types';
6
6
  export type AddChatMessageOptions = {
7
7
  userCsvAttachment?: UserCsvAttachment;
8
8
  };
@@ -16,8 +16,15 @@ export interface ChatContextType {
16
16
  getChatsForScopeId: (scopeId: string) => Chat[];
17
17
  getCurrentChatId: (scopeId: string) => string | null;
18
18
  deleteChat: (scopeId: string, sessionId: string) => void;
19
+ /**
20
+ * Ref-count of in-flight `sendChatMessage` requests keyed by
21
+ * `outboundPendingKey(scopeId, chatSessionId)` (session id at send start).
22
+ */
23
+ outboundPendingByKey: Readonly<Record<string, number>>;
19
24
  }
20
25
  declare const ChatContext: import("react").Context<ChatContextType>;
26
+ /** Stable composite key; avoids collisions if `scopeId` contains `:`. */
27
+ export declare function outboundPendingKey(scopeId: string, chatSessionId: string): string;
21
28
  export interface ChatProviderProps {
22
29
  children: ReactNode;
23
30
  /** When null, chat state is cleared (logged out). When set, only LS rows for scopes starting with `${userId}-` are loaded. */
@@ -28,10 +35,12 @@ export declare function ChatProvider({ children, userSwitchKey, sendChatMessage:
28
35
  export declare const isChatEmpty: (chat: Chat | null) => boolean;
29
36
  export declare function useChats(): ChatContextType;
30
37
  export declare function useChat(scopeId: string | undefined, chatId: string | undefined): Chat | null;
38
+ export declare function useChatOutboundPending(scopeId: string | undefined | null, chatSessionId: string | null | undefined): boolean;
31
39
  export declare function useChatsForScopeId(scopeId: string): {
32
40
  chats: Chat[];
33
41
  currentChat: Chat;
34
42
  currentChatId: string;
43
+ isOutboundPending: boolean;
35
44
  setCurrentChatId: (targetId: string) => void;
36
45
  newChat: () => string;
37
46
  addMessage: (chatId: string, role: MessageRole, text: string, options?: AddChatMessageOptions) => string;
@@ -44,6 +53,7 @@ export declare function useChatsForDataset(scopeId: string): {
44
53
  chats: Chat[];
45
54
  currentChat: Chat;
46
55
  currentChatId: string;
56
+ isOutboundPending: boolean;
47
57
  setCurrentChatId: (targetId: string) => void;
48
58
  newChat: () => string;
49
59
  addMessage: (chatId: string, role: MessageRole, text: string, options?: AddChatMessageOptions) => string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sybilion/uilib",
3
- "version": "1.2.20",
3
+ "version": "1.2.22",
4
4
  "description": "Sybilion Design System — React UI components (Webpack + Stylus)",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -77,6 +77,23 @@ const injectBold = (content: string) => {
77
77
  };
78
78
  };
79
79
 
80
+ /** `_italic_` (underscore) — avoids clashing with `* ` bullet markers. */
81
+ const injectItalic = (content: string) => {
82
+ const matches = content.match(/(?<![A-Za-z0-9])_([^_\n]+?)_(?![A-Za-z0-9])/);
83
+
84
+ if (!matches || matches.index === undefined) return null;
85
+
86
+ if (isInsideHtmlListOrTable(content, matches.index)) {
87
+ return null;
88
+ }
89
+
90
+ return {
91
+ elem: <em>{matches[1]}</em>,
92
+ index: matches.index,
93
+ length: matches[0].length,
94
+ };
95
+ };
96
+
80
97
  const injectBullet = (content: string) => {
81
98
  // Match bullet points: * or - followed by space
82
99
  // Match at start of string/line or after whitespace/colon (for nested bullets)
@@ -281,6 +298,38 @@ const isAllowedHref = (href: string): boolean => {
281
298
  return false;
282
299
  };
283
300
 
301
+ const normalizeWwwToHttps = (raw: string): string => {
302
+ const t = raw.trim();
303
+ if (/^www\./i.test(t)) return `https://${t}`;
304
+ return t;
305
+ };
306
+
307
+ /** Strip ASCII closing punctuation often pasted after URLs. */
308
+ const stripTrailingUrlPunctuation = (s: string): string => {
309
+ let u = s;
310
+ while (u.length > 0 && /[.,;:!?)}\]]$/u.test(u)) {
311
+ u = u.slice(0, -1);
312
+ }
313
+ return u;
314
+ };
315
+
316
+ function linkTargetRelForHref(
317
+ href: string,
318
+ explicitTarget?: string,
319
+ ): { target?: string; rel?: string } {
320
+ if (explicitTarget) {
321
+ const t = explicitTarget.trim();
322
+ if (t.toLowerCase() === '_blank') {
323
+ return { target: '_blank', rel: 'noopener noreferrer' };
324
+ }
325
+ return { target: t || undefined, rel: undefined };
326
+ }
327
+ if (/^https?:\/\//i.test(href) || /^mailto:/i.test(href)) {
328
+ return { target: '_blank', rel: 'noopener noreferrer' };
329
+ }
330
+ return { target: undefined, rel: undefined };
331
+ }
332
+
284
333
  type Injector = (
285
334
  content: string,
286
335
  ) => { elem: React.ReactNode; index: number; length: number } | null;
@@ -325,6 +374,100 @@ const runFormattingPipeline = (
325
374
  return result;
326
375
  };
327
376
 
377
+ const linkLabelInjectors: Injector[] = [
378
+ injectHTMLTags,
379
+ injectBold,
380
+ injectItalic,
381
+ injectNewlines,
382
+ ];
383
+
384
+ const injectMarkdownLink: Injector = (content: string) => {
385
+ const matches = content.match(/\[([^\]]+)\]\(([^)]+)\)/);
386
+
387
+ if (!matches || matches.index === undefined) return null;
388
+
389
+ if (isInsideHtmlListOrTable(content, matches.index)) {
390
+ return null;
391
+ }
392
+
393
+ const href = normalizeWwwToHttps(matches[2].trim());
394
+ if (!isAllowedHref(href)) return null;
395
+
396
+ const { target, rel } = linkTargetRelForHref(href, undefined);
397
+ const label = matches[1];
398
+
399
+ return {
400
+ elem: (
401
+ <a href={href} target={target} rel={rel}>
402
+ {runFormattingPipeline(label, linkLabelInjectors)}
403
+ </a>
404
+ ),
405
+ index: matches.index,
406
+ length: matches[0].length,
407
+ };
408
+ };
409
+
410
+ const injectAutolinkUrl: Injector = (content: string) => {
411
+ type Cand = { index: number; length: number; href: string; display: string };
412
+
413
+ const candidates: Cand[] = [];
414
+
415
+ const reHttp = /https?:\/\/[^\s<>"']+/gi;
416
+ let hm: RegExpExecArray | null;
417
+ while ((hm = reHttp.exec(content)) !== null) {
418
+ const href = stripTrailingUrlPunctuation(hm[0]);
419
+ if (
420
+ href.length >= (href.startsWith('https://') ? 8 : 7) &&
421
+ isAllowedHref(href) &&
422
+ /^https?:\/\//i.test(href)
423
+ ) {
424
+ candidates.push({
425
+ index: hm.index,
426
+ length: href.length,
427
+ href,
428
+ display: href,
429
+ });
430
+ }
431
+ }
432
+
433
+ const reWww = /(^|[^A-Za-z0-9/])(www\.[^\s<>"']+)/gi;
434
+ while ((hm = reWww.exec(content)) !== null) {
435
+ const prefix = hm[1] ?? '';
436
+ const rawWww = hm[2];
437
+ const body = stripTrailingUrlPunctuation(rawWww);
438
+ const href = `https://${body}`;
439
+ if (body.length > 4 && isAllowedHref(href)) {
440
+ candidates.push({
441
+ index: hm.index + prefix.length,
442
+ length: body.length,
443
+ href,
444
+ display: body,
445
+ });
446
+ }
447
+ }
448
+
449
+ if (candidates.length === 0) return null;
450
+
451
+ candidates.sort((a, b) => a.index - b.index);
452
+ const c = candidates[0]!;
453
+
454
+ if (isInsideHtmlListOrTable(content, c.index)) {
455
+ return null;
456
+ }
457
+
458
+ const { target, rel } = linkTargetRelForHref(c.href, undefined);
459
+
460
+ return {
461
+ elem: (
462
+ <a href={c.href} target={target} rel={rel}>
463
+ {c.display}
464
+ </a>
465
+ ),
466
+ index: c.index,
467
+ length: c.length,
468
+ };
469
+ };
470
+
328
471
  const injectAnchor: Injector = (content: string) => {
329
472
  const regex = /<a\s+([^>]+)>([\s\S]*?)<\/a\s*>/i;
330
473
  const matches = content.match(regex);
@@ -356,18 +499,12 @@ const injectAnchor: Injector = (content: string) => {
356
499
  return null;
357
500
  }
358
501
 
359
- const opensNewTab = targetRaw.toLowerCase() === '_blank';
360
- const target = opensNewTab ? '_blank' : targetRaw || undefined;
361
- const rel = target === '_blank' ? 'noopener noreferrer' : undefined;
502
+ const { target, rel } = linkTargetRelForHref(href, targetRaw || undefined);
362
503
 
363
504
  return {
364
505
  elem: (
365
506
  <a href={href} target={target} rel={rel}>
366
- {runFormattingPipeline(inner, [
367
- injectHTMLTags,
368
- injectBold,
369
- injectNewlines,
370
- ])}
507
+ {runFormattingPipeline(inner, linkLabelInjectors)}
371
508
  </a>
372
509
  ),
373
510
  index: matches.index!,
@@ -379,17 +516,23 @@ const applyFormatting = (text: string): React.ReactNode[] =>
379
516
  runFormattingPipeline(text, [
380
517
  injectHeaders,
381
518
  injectAnchor,
519
+ injectMarkdownLink,
382
520
  injectHTMLTags,
383
521
  injectBullet,
384
522
  injectBold,
523
+ injectItalic,
524
+ injectAutolinkUrl,
385
525
  injectNewlines,
386
526
  ]);
387
527
 
388
528
  const applyFormattingInline = (text: string): React.ReactNode[] =>
389
529
  runFormattingPipeline(text, [
390
530
  injectAnchor,
531
+ injectMarkdownLink,
391
532
  injectHTMLTags,
392
533
  injectBold,
534
+ injectItalic,
535
+ injectAutolinkUrl,
393
536
  injectNewlines,
394
537
  ]);
395
538
 
@@ -397,6 +540,7 @@ export {
397
540
  injectHeaders,
398
541
  injectAnchor,
399
542
  injectBold,
543
+ injectItalic,
400
544
  injectBullet,
401
545
  injectNewlines,
402
546
  convertMarkdownTableToHTML,
@@ -1,5 +1,7 @@
1
1
  import cn from 'classnames';
2
2
 
3
+ import { InteractiveContent } from '#uilib/components/ui/InteractiveContent';
4
+
3
5
  import { TextShimmer } from '../../TextShimmer';
4
6
  import {
5
7
  type ChatMessageProps,
@@ -48,7 +50,9 @@ export function ChatMessage({
48
50
  />
49
51
  ) : (
50
52
  <div className={S.userColumn}>
51
- <div className={S.text}>{text}</div>
53
+ <div className={S.text}>
54
+ <InteractiveContent text={text} />
55
+ </div>
52
56
  {userCsvAttachment ? (
53
57
  <UserCsvAttachmentBubble attachment={userCsvAttachment} />
54
58
  ) : null}
@@ -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
  })();
@@ -1,3 +1,20 @@
1
1
  .root
2
2
  strong
3
3
  font-weight 600
4
+
5
+ em
6
+ font-style italic
7
+
8
+ a
9
+ color var(--sb-green-600)
10
+ text-decoration underline
11
+ text-underline-offset 2px
12
+
13
+ &:hover
14
+ color var(--sb-green-700)
15
+
16
+ :global(.dark) &
17
+ color var(--sb-green-400)
18
+
19
+ &:hover
20
+ color var(--sb-green-300)
@@ -12,8 +12,8 @@ import {
12
12
  type Chat,
13
13
  type ChatSendMessagePayload,
14
14
  type Message,
15
- type UserCsvAttachment,
16
15
  MessageRole,
16
+ type UserCsvAttachment,
17
17
  } from '#uilib/components/ui/Chat/Chat.types';
18
18
  import { stripJsonDashboardFences } from '#uilib/lib/dashboard-spec/stripJsonDashboardFences';
19
19
  import type { ChatResponse } from '#uilib/types/chat-api.types';
@@ -24,7 +24,10 @@ export type SendChatMessageFn = (
24
24
  targetChatId: string,
25
25
  ) => Promise<ChatResponse>;
26
26
 
27
- export type { ChatSendMessagePayload, UserCsvAttachment } from '#uilib/components/ui/Chat/Chat.types';
27
+ export type {
28
+ ChatSendMessagePayload,
29
+ UserCsvAttachment,
30
+ } from '#uilib/components/ui/Chat/Chat.types';
28
31
 
29
32
  const CHATS_PREFIX = 'chats-';
30
33
  const CHAT_SCOPE_IDS_REGISTRY_KEY = 'chat-scope-ids';
@@ -57,10 +60,20 @@ export interface ChatContextType {
57
60
  getChatsForScopeId: (scopeId: string) => Chat[];
58
61
  getCurrentChatId: (scopeId: string) => string | null;
59
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>>;
60
68
  }
61
69
 
62
70
  const ChatContext = createContext<ChatContextType | undefined>(undefined);
63
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
+
64
77
  function getCurrentChatIdKey(scopeId: string) {
65
78
  return `chat-current-id-${scopeId}`;
66
79
  }
@@ -158,6 +171,35 @@ export function ChatProvider({
158
171
  return loadChatsFromLS(userSwitchKey).currentChatId;
159
172
  });
160
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
+
161
203
  const getChatsForScopeId = useCallback(
162
204
  (scopeId: string): Chat[] => chats[scopeId] ?? [],
163
205
  [chats],
@@ -324,19 +366,27 @@ export function ChatProvider({
324
366
  if (typeof message === 'string') {
325
367
  addMessage(scopeId, targetChatId, MessageRole.USER, message);
326
368
  } else {
327
- addMessage(scopeId, targetChatId, MessageRole.USER, message.displayText, {
328
- userCsvAttachment: message.userCsvAttachment,
329
- });
369
+ addMessage(
370
+ scopeId,
371
+ targetChatId,
372
+ MessageRole.USER,
373
+ message.displayText,
374
+ {
375
+ userCsvAttachment: message.userCsvAttachment,
376
+ },
377
+ );
330
378
  }
331
379
 
380
+ const pendingChatSessionId = targetChatId;
381
+ beginOutboundPending(scopeId, pendingChatSessionId);
332
382
  try {
333
- const data = await sendChatMessageFn(apiPayload, targetChatId);
383
+ const data = await sendChatMessageFn(apiPayload, pendingChatSessionId);
334
384
 
335
- if (data.session_id && data.session_id !== targetChatId) {
385
+ if (data.session_id && data.session_id !== pendingChatSessionId) {
336
386
  setChats(prev => {
337
387
  const scopeChats = prev[scopeId] ?? [];
338
388
  const updatedChats = scopeChats.map(chat =>
339
- chat.session_id === targetChatId
389
+ chat.session_id === pendingChatSessionId
340
390
  ? { ...chat, session_id: data.session_id! }
341
391
  : chat,
342
392
  );
@@ -351,7 +401,7 @@ export function ChatProvider({
351
401
 
352
402
  addMessage(
353
403
  scopeId,
354
- data.session_id ? data.session_id : targetChatId,
404
+ data.session_id ? data.session_id : pendingChatSessionId,
355
405
  MessageRole.ASSISTANT,
356
406
  data.response,
357
407
  );
@@ -363,17 +413,32 @@ export function ChatProvider({
363
413
  ? error.message
364
414
  : 'Sorry, I encountered an error processing your message. Please try again.';
365
415
 
366
- addMessage(scopeId, targetChatId, MessageRole.ASSISTANT, errorMessage);
416
+ addMessage(
417
+ scopeId,
418
+ pendingChatSessionId,
419
+ MessageRole.ASSISTANT,
420
+ errorMessage,
421
+ );
367
422
  throw error;
423
+ } finally {
424
+ endOutboundPending(scopeId, pendingChatSessionId);
368
425
  }
369
426
  },
370
- [addMessage, getCurrentChatId, sendChatMessageFn, setCurrentChatId],
427
+ [
428
+ addMessage,
429
+ beginOutboundPending,
430
+ endOutboundPending,
431
+ getCurrentChatId,
432
+ sendChatMessageFn,
433
+ setCurrentChatId,
434
+ ],
371
435
  );
372
436
 
373
437
  useEffect(() => {
374
438
  if (userSwitchKey === null) {
375
439
  setChats({});
376
440
  setCurrentChatIdState({});
441
+ setOutboundPendingByKey({});
377
442
  return;
378
443
  }
379
444
 
@@ -404,6 +469,7 @@ export function ChatProvider({
404
469
  getChatsForScopeId,
405
470
  getCurrentChatId,
406
471
  deleteChat,
472
+ outboundPendingByKey,
407
473
  }}
408
474
  >
409
475
  {children}
@@ -436,6 +502,18 @@ export function useChat(
436
502
  }, [scopeId, chatId, getChatsForScopeId]);
437
503
  }
438
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
+
439
517
  export function useChatsForScopeId(scopeId: string) {
440
518
  const {
441
519
  getChatsForScopeId,
@@ -450,11 +528,13 @@ export function useChatsForScopeId(scopeId: string) {
450
528
  const chats = getChatsForScopeId(scopeId);
451
529
  const currentChatId = getCurrentChatId(scopeId);
452
530
  const currentChat = useChat(scopeId, currentChatId ?? undefined);
531
+ const isOutboundPending = useChatOutboundPending(scopeId, currentChatId);
453
532
 
454
533
  return {
455
534
  chats,
456
535
  currentChat,
457
536
  currentChatId,
537
+ isOutboundPending,
458
538
  setCurrentChatId: (targetId: string) => setCurrentChatId(scopeId, targetId),
459
539
  newChat: () => newChat(scopeId),
460
540
  addMessage: (