@sybilion/uilib 1.2.20 → 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.
- package/dist/esm/components/ui/Chat/ChatSheet/useChatPanelChromeModel.js +12 -18
- package/dist/esm/contexts/chat-context.js +56 -7
- package/dist/esm/index.js +1 -1
- package/dist/esm/types/src/contexts/chat-context.d.ts +12 -2
- package/package.json +1 -1
- package/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.tsx +15 -15
- package/src/contexts/chat-context.tsx +91 -11
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
590
|
+
setLocalUiBusy(false);
|
|
597
591
|
scriptAdvanceLockRef.current = false;
|
|
598
592
|
}
|
|
599
593
|
})();
|
|
@@ -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,
|
|
216
|
-
if (data.session_id && data.session_id !==
|
|
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 ===
|
|
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 :
|
|
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,
|
|
261
|
+
addMessage(scopeId, pendingChatSessionId, MessageRole.ASSISTANT, errorMessage);
|
|
236
262
|
throw error;
|
|
237
263
|
}
|
|
238
|
-
|
|
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';
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ReactNode } from 'react';
|
|
2
|
-
import { type Chat, type ChatSendMessagePayload, type UserCsvAttachment
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
764
|
+
setLocalUiBusy(false);
|
|
765
765
|
scriptAdvanceLockRef.current = false;
|
|
766
766
|
}
|
|
767
767
|
})();
|
|
@@ -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 {
|
|
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(
|
|
328
|
-
|
|
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,
|
|
383
|
+
const data = await sendChatMessageFn(apiPayload, pendingChatSessionId);
|
|
334
384
|
|
|
335
|
-
if (data.session_id && data.session_id !==
|
|
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 ===
|
|
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 :
|
|
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(
|
|
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
|
-
[
|
|
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: (
|