@sybilion/uilib 1.3.18 → 1.3.19

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.
@@ -111,7 +111,7 @@ function ChartAreaInteractive({ className, chartContainerClassName, legendClassN
111
111
  return (jsx(BaseChartWrapper, { ...baseChartProps, chartClassName: cn(S.chartContainer, chartContainerClassName) }));
112
112
  }
113
113
  };
114
- return (jsxs(InteractionOverlay, { className: cn(className, loading && S.loading), children: [(!disableTimeRangeSelector || headerActions) && (jsxs("div", { className: S.chartHeaderContainer, children: [!disableTimeRangeSelector && (jsx(TimeRangeControls, { timeRange: timeRange, onTimeRangeChange: onTimeRangeChange, loading: loading })), headerActions] })), jsx(TimeRangeBrushHost, { chartData: bridgedChartData, onTimeRangeChange: onTimeRangeChange, enabled: brushEnabled, layoutKey: chartRenderId ?? null, children: renderChart() })] }));
114
+ return (jsxs(InteractionOverlay, { className: cn(className, loading && S.loading), disabled: loading, children: [(!disableTimeRangeSelector || headerActions) && (jsxs("div", { className: S.chartHeaderContainer, children: [!disableTimeRangeSelector && (jsx(TimeRangeControls, { timeRange: timeRange, onTimeRangeChange: onTimeRangeChange, loading: loading })), headerActions] })), jsx(TimeRangeBrushHost, { chartData: bridgedChartData, onTimeRangeChange: onTimeRangeChange, enabled: brushEnabled, layoutKey: chartRenderId ?? null, children: renderChart() })] }));
115
115
  }
116
116
 
117
117
  export { ChartAreaInteractive, chartConfig };
@@ -4,7 +4,7 @@ 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 { buildChatSendMessagePayload, displayTextFromSendPayload } from '../buildChatSendMessagePayload.js';
6
6
  import { usedPresetIdsFromMessages, formatChatTranscript } from '../chat-preset-utils.js';
7
- import { useChatsForScopeId, useChat, useChatOutboundPending, isChatEmpty } from '../../../../contexts/chat-context.js';
7
+ import { useChatsForScopeId, useChat, useChatOutboundPending, useSyncChatPanelBusy, isChatEmpty } from '../../../../contexts/chat-context.js';
8
8
  import useEvent from '../../../../hooks/useEvent.js';
9
9
  import { useIsMobile } from '../../../../hooks/useIsMobile.js';
10
10
  import { useQueryParams } from '../../../../hooks/useQueryParams.js';
@@ -31,6 +31,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
31
31
  const chat = useChat(effectiveScopeId, currentChatId);
32
32
  const isOutboundPending = useChatOutboundPending(effectiveScopeId, currentChatId);
33
33
  const isLoading = isOutboundPending || localUiBusy;
34
+ useSyncChatPanelBusy(isLoading);
34
35
  const { searchParams, addSearchParams, removeSearchParams, mutateSearchParams, } = useQueryParams();
35
36
  const chatOpen = searchParams.has(CHAT_QUERY_PARAM);
36
37
  const [isOpen, setIsOpen] = useState(false);
@@ -1,22 +1,25 @@
1
1
  import { jsx, Fragment, jsxs } from 'react/jsx-runtime';
2
2
  import cn from 'classnames';
3
3
  import { useState, useCallback, useEffect } from 'react';
4
+ import { useIsChatLoading } from '../../../contexts/chat-context.js';
4
5
  import { useIsTouchDevice } from '../../../hooks/useIsTouchDevice.js';
5
6
  import S from './InteractionOverlay.styl.js';
6
7
 
7
- function InteractionOverlay({ className, children, message = 'Touch to interact', }) {
8
+ function InteractionOverlay({ className, children, message = 'Touch to interact', disabled = false, }) {
8
9
  const isTouchDevice = useIsTouchDevice();
10
+ const isChatLoading = useIsChatLoading();
11
+ const suppressOverlay = disabled || isChatLoading;
9
12
  const [isVisible, setIsVisible] = useState(true);
10
13
  const handleClick = useCallback(() => {
11
14
  setIsVisible(false);
12
15
  }, []);
13
16
  useEffect(() => {
14
- if (isTouchDevice) {
17
+ if (isTouchDevice && !suppressOverlay) {
15
18
  setIsVisible(true);
16
19
  }
17
- }, [isTouchDevice]);
20
+ }, [isTouchDevice, suppressOverlay]);
18
21
  useEffect(() => {
19
- if (!isTouchDevice)
22
+ if (!isTouchDevice || suppressOverlay)
20
23
  return;
21
24
  const handleScroll = () => {
22
25
  setIsVisible(true);
@@ -25,11 +28,11 @@ function InteractionOverlay({ className, children, message = 'Touch to interact'
25
28
  return () => {
26
29
  document.removeEventListener('scroll', handleScroll, { capture: true });
27
30
  };
28
- }, [isTouchDevice]);
31
+ }, [isTouchDevice, suppressOverlay]);
29
32
  if (!isTouchDevice) {
30
33
  return jsx(Fragment, { children: children });
31
34
  }
32
- return (jsxs("div", { className: cn(S.root, !isVisible && S.hidden, className), children: [children, jsx("button", { type: "button", className: S.overlay, onClick: handleClick, "aria-label": message, children: jsx("span", { className: S.message, children: message }) })] }));
35
+ return (jsxs("div", { className: cn(S.root, (!isVisible || suppressOverlay) && S.hidden, className), children: [children, jsx("button", { type: "button", className: S.overlay, onClick: handleClick, "aria-label": message, children: jsx("span", { className: S.message, children: message }) })] }));
33
36
  }
34
37
 
35
38
  export { InteractionOverlay };
@@ -1,6 +1,6 @@
1
1
  import styleInject from 'style-inject';
2
2
 
3
- var css_248z = ".InteractionOverlay_root__9FV58{position:relative}.InteractionOverlay_overlay__Nwawr{background:transparent;border:none;box-shadow:inset 0 0 80px 40px var(--overlay-background-color);cursor:pointer;inset:0;margin:0;padding:0;position:absolute;touch-action:manipulation;transition:box-shadow .2s ease-out;z-index:4}.InteractionOverlay_hidden__NRlgG .InteractionOverlay_overlay__Nwawr{box-shadow:inset 0 0 0 0 var(--overlay-background-color);pointer-events:none}.InteractionOverlay_message__oCuPR{backdrop-filter:blur(10px);background-color:color-mix(in srgb,var(--overlay-background-color) 80%,transparent);border:1px solid color-mix(in srgb,var(--foreground) 14%,transparent);border-radius:9999px;box-shadow:0 0 24px 10px var(--overlay-background-color),0 0 0 1px color-mix(in srgb,var(--overlay-background-color) 40%,transparent);color:var(--foreground);font-size:.875rem;font-weight:500;left:50%;line-height:1.25;padding:var(--p-3) var(--p-5);pointer-events:none;position:absolute;top:50%;transform:translate(-50%,-50%);transition:.2s ease-out;transition-property:transform,opacity;white-space:nowrap;z-index:1}.InteractionOverlay_hidden__NRlgG .InteractionOverlay_message__oCuPR{opacity:0;transform:translate(-50%,-50%) scale(.3)}:root{--overlay-background-color:var(--page-color)}";
3
+ var css_248z = ".InteractionOverlay_root__9FV58{position:relative}.InteractionOverlay_overlay__Nwawr{background:transparent;border:none;cursor:pointer;inset:0;margin:0;padding:0;position:absolute;touch-action:manipulation;z-index:4}.InteractionOverlay_overlay__Nwawr:before{background-color:color-mix(in srgb,var(--overlay-background-color) 70%,transparent);bottom:calc(var(--p-2)*-1);box-shadow:inset 0 0 80px 40px var(--overlay-background-color);content:\"\";left:calc(var(--p-2)*-1);pointer-events:none;position:absolute;right:calc(var(--p-2)*-1);top:calc(var(--p-2)*-1);transition:.2s ease-out;transition-property:box-shadow,background-color;z-index:-1}.InteractionOverlay_hidden__NRlgG .InteractionOverlay_overlay__Nwawr{pointer-events:none}.InteractionOverlay_hidden__NRlgG .InteractionOverlay_overlay__Nwawr:before{background-color:transparent;box-shadow:inset 0 0 0 0 var(--overlay-background-color)}.InteractionOverlay_message__oCuPR{backdrop-filter:blur(10px);background-color:color-mix(in srgb,var(--overlay-background-color) 80%,transparent);border:1px solid color-mix(in srgb,var(--foreground) 14%,transparent);border-radius:9999px;box-shadow:0 0 24px 10px var(--overlay-background-color),0 0 0 1px color-mix(in srgb,var(--overlay-background-color) 40%,transparent);color:var(--foreground);font-size:.875rem;font-weight:500;left:50%;line-height:1.25;padding:var(--p-3) var(--p-5);pointer-events:none;position:absolute;top:50%;transform:translate(-50%,-50%);transition:.2s ease-out;transition-property:transform,opacity;white-space:nowrap;z-index:1}.InteractionOverlay_hidden__NRlgG .InteractionOverlay_message__oCuPR{opacity:0;transform:translate(-50%,-50%) scale(.3)}:root{--overlay-background-color:var(--page-color)}";
4
4
  var S = {"root":"InteractionOverlay_root__9FV58","overlay":"InteractionOverlay_overlay__Nwawr","hidden":"InteractionOverlay_hidden__NRlgG","message":"InteractionOverlay_message__oCuPR"};
5
5
  styleInject(css_248z);
6
6
 
@@ -92,6 +92,13 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
92
92
  return loadChatsFromLS(userSwitchKey).currentChatId;
93
93
  });
94
94
  const [outboundPendingByKey, setOutboundPendingByKey] = useState({});
95
+ const [panelBusyCount, setPanelBusyCount] = useState(0);
96
+ const acquirePanelBusy = useCallback(() => {
97
+ setPanelBusyCount(count => count + 1);
98
+ }, []);
99
+ const releasePanelBusy = useCallback(() => {
100
+ setPanelBusyCount(count => Math.max(0, count - 1));
101
+ }, []);
95
102
  const beginOutboundPending = useCallback((scopeId, chatSessionId) => {
96
103
  const key = outboundPendingKey(scopeId, chatSessionId);
97
104
  setOutboundPendingByKey(prev => ({
@@ -282,6 +289,7 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
282
289
  setChats({});
283
290
  setCurrentChatIdState({});
284
291
  setOutboundPendingByKey({});
292
+ setPanelBusyCount(0);
285
293
  return;
286
294
  }
287
295
  const loaded = loadChatsFromLS(userSwitchKey);
@@ -306,6 +314,9 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
306
314
  getCurrentChatId,
307
315
  deleteChat,
308
316
  outboundPendingByKey,
317
+ panelBusyCount,
318
+ acquirePanelBusy,
319
+ releasePanelBusy,
309
320
  }, children: children }));
310
321
  }
311
322
  const isChatEmpty = (chat) => chat?.messages.length === 0;
@@ -334,6 +345,29 @@ function useChatOutboundPending(scopeId, chatSessionId) {
334
345
  return (outboundPendingByKey[key] ?? 0) > 0;
335
346
  }, [scopeId, chatSessionId, outboundPendingByKey]);
336
347
  }
348
+ /** True when chat is waiting on API or panel UI busy (safe outside ChatProvider). */
349
+ function useIsChatLoading() {
350
+ const context = useContext(ChatContext);
351
+ return useMemo(() => {
352
+ if (!context)
353
+ return false;
354
+ const outboundPending = Object.values(context.outboundPendingByKey).some(count => count > 0);
355
+ return outboundPending || context.panelBusyCount > 0;
356
+ }, [context?.outboundPendingByKey, context?.panelBusyCount]);
357
+ }
358
+ /** Syncs chat panel `isLoading` into context for touch-overlay suppression. */
359
+ function useSyncChatPanelBusy(isLoading) {
360
+ const acquirePanelBusy = useContext(ChatContext)?.acquirePanelBusy;
361
+ const releasePanelBusy = useContext(ChatContext)?.releasePanelBusy;
362
+ useEffect(() => {
363
+ if (!isLoading || !acquirePanelBusy || !releasePanelBusy)
364
+ return;
365
+ acquirePanelBusy();
366
+ return () => {
367
+ releasePanelBusy();
368
+ };
369
+ }, [isLoading, acquirePanelBusy, releasePanelBusy]);
370
+ }
337
371
  function useChatsForScopeId(scopeId) {
338
372
  const { getChatsForScopeId, getCurrentChatId, setCurrentChatId, newChat, addMessage, removeMessageById, sendMessage, deleteChat, } = useChats();
339
373
  const chats = getChatsForScopeId(scopeId);
@@ -363,4 +397,4 @@ function useCurrentChat(scopeId) {
363
397
  return useChat(scopeId, chatId ?? undefined);
364
398
  }
365
399
 
366
- export { ChatContext, ChatProvider, isChatEmpty, outboundPendingKey, useChat, useChatOutboundPending, useChats, useChatsForDataset, useChatsForScopeId, useCurrentChat };
400
+ export { ChatContext, ChatProvider, isChatEmpty, outboundPendingKey, useChat, useChatOutboundPending, useChats, useChatsForDataset, useChatsForScopeId, useCurrentChat, useIsChatLoading, useSyncChatPanelBusy };
package/dist/esm/index.js CHANGED
@@ -6,7 +6,7 @@ export { DEFAULT_THEME_ACTIVE_COLOR } from './docs/lib/theme.js';
6
6
  export { SybilionAuthProvider, createSybilionApiFetch, getSybilionApiOriginFromSdk, sybilionApiFetch, useSybilionApiFetch, useSybilionAuth } from './sybilion-auth/SybilionAuthProvider.js';
7
7
  export { SYBILION_AUTH_LOGIN_PATH, normalizeApiBaseUrl } from './sybilion-auth/authPaths.js';
8
8
  export { exchangeAuth0AccessTokenForSybilionJwt } from './sybilion-auth/exchangeSybilionToken.js';
9
- export { ChatContext, ChatProvider, isChatEmpty, outboundPendingKey, useChat, useChatOutboundPending, useChats, useChatsForDataset, useChatsForScopeId, useCurrentChat } from './contexts/chat-context.js';
9
+ export { ChatContext, ChatProvider, isChatEmpty, outboundPendingKey, useChat, useChatOutboundPending, useChats, useChatsForDataset, useChatsForScopeId, useCurrentChat, useIsChatLoading, useSyncChatPanelBusy } from './contexts/chat-context.js';
10
10
  export { AnalysesSelector } from './components/ui/AnalysesSelector/AnalysesSelector.js';
11
11
  export { AnalysisLineIcon } from './components/ui/AnalysisLineIcon/AnalysisLineIcon.js';
12
12
  export { AppHeaderHost, AppHeaderPortal } from './components/ui/AppHeader/AppHeader.js';
@@ -2,6 +2,8 @@ interface InteractionOverlayProps {
2
2
  className?: string;
3
3
  children?: React.ReactNode;
4
4
  message?: string;
5
+ /** When true, hides the touch overlay (e.g. chart data loading). */
6
+ disabled?: boolean;
5
7
  }
6
- export declare function InteractionOverlay({ className, children, message, }: InteractionOverlayProps): import("react/jsx-runtime").JSX.Element;
8
+ export declare function InteractionOverlay({ className, children, message, disabled, }: InteractionOverlayProps): import("react/jsx-runtime").JSX.Element;
7
9
  export {};
@@ -21,6 +21,10 @@ export interface ChatContextType {
21
21
  * `outboundPendingKey(scopeId, chatSessionId)` (session id at send start).
22
22
  */
23
23
  outboundPendingByKey: Readonly<Record<string, number>>;
24
+ /** Ref-count of chat panels reporting UI busy (outbound wait, preset scripts, etc.). */
25
+ panelBusyCount: number;
26
+ acquirePanelBusy: () => void;
27
+ releasePanelBusy: () => void;
24
28
  }
25
29
  declare const ChatContext: import("react").Context<ChatContextType>;
26
30
  /** Stable composite key; avoids collisions if `scopeId` contains `:`. */
@@ -36,6 +40,10 @@ export declare const isChatEmpty: (chat: Chat | null) => boolean;
36
40
  export declare function useChats(): ChatContextType;
37
41
  export declare function useChat(scopeId: string | undefined, chatId: string | undefined): Chat | null;
38
42
  export declare function useChatOutboundPending(scopeId: string | undefined | null, chatSessionId: string | null | undefined): boolean;
43
+ /** True when chat is waiting on API or panel UI busy (safe outside ChatProvider). */
44
+ export declare function useIsChatLoading(): boolean;
45
+ /** Syncs chat panel `isLoading` into context for touch-overlay suppression. */
46
+ export declare function useSyncChatPanelBusy(isLoading: boolean): void;
39
47
  export declare function useChatsForScopeId(scopeId: string): {
40
48
  chats: Chat[];
41
49
  currentChat: Chat;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sybilion/uilib",
3
- "version": "1.3.18",
3
+ "version": "1.3.19",
4
4
  "description": "Sybilion Design System — React UI components (Webpack + Stylus)",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -231,7 +231,10 @@ export function ChartAreaInteractive({
231
231
  };
232
232
 
233
233
  return (
234
- <InteractionOverlay className={cn(className, loading && S.loading)}>
234
+ <InteractionOverlay
235
+ className={cn(className, loading && S.loading)}
236
+ disabled={loading}
237
+ >
235
238
  {(!disableTimeRangeSelector || headerActions) && (
236
239
  <div className={S.chartHeaderContainer}>
237
240
  {!disableTimeRangeSelector && (
@@ -32,6 +32,7 @@ import {
32
32
  useChat,
33
33
  useChatOutboundPending,
34
34
  useChatsForScopeId,
35
+ useSyncChatPanelBusy,
35
36
  } from '#uilib/contexts/chat-context';
36
37
  import useEvent from '#uilib/hooks/useEvent';
37
38
  import { useIsMobile } from '#uilib/hooks/useIsMobile';
@@ -144,6 +145,7 @@ export function useChatPanelChromeModel({
144
145
  currentChatId,
145
146
  );
146
147
  const isLoading = isOutboundPending || localUiBusy;
148
+ useSyncChatPanelBusy(isLoading);
147
149
 
148
150
  const {
149
151
  searchParams,
@@ -11,13 +11,28 @@
11
11
  cursor pointer
12
12
  touch-action manipulation
13
13
  background transparent
14
- box-shadow inset 0 0 80px 40px var(--overlay-background-color)
15
- transition box-shadow 200ms ease-out
14
+
15
+ &::before
16
+ content ''
17
+ position absolute
18
+ top calc(-1 * var(--p-2))
19
+ right calc(-1 * var(--p-2))
20
+ bottom calc(-1 * var(--p-2))
21
+ left calc(-1 * var(--p-2))
22
+ background-color unquote('color-mix(in srgb, var(--overlay-background-color) 70%, transparent)')
23
+ box-shadow inset 0 0 80px 40px var(--overlay-background-color)
24
+ pointer-events none
25
+ z-index -1
26
+ transition 200ms ease-out
27
+ transition-property box-shadow, background-color
16
28
 
17
29
  .hidden &
18
- box-shadow inset 0 0 0 0 var(--overlay-background-color)
19
30
  pointer-events none
20
31
 
32
+ &::before
33
+ box-shadow inset 0 0 0 0 var(--overlay-background-color)
34
+ background-color transparent
35
+
21
36
  .message
22
37
  position absolute
23
38
  top 50%
@@ -1,6 +1,7 @@
1
1
  import cn from 'classnames';
2
2
  import { useCallback, useEffect, useState } from 'react';
3
3
 
4
+ import { useIsChatLoading } from '#uilib/contexts/chat-context';
4
5
  import { useIsTouchDevice } from '#uilib/hooks/useIsTouchDevice';
5
6
 
6
7
  import S from './InteractionOverlay.styl';
@@ -9,14 +10,19 @@ interface InteractionOverlayProps {
9
10
  className?: string;
10
11
  children?: React.ReactNode;
11
12
  message?: string;
13
+ /** When true, hides the touch overlay (e.g. chart data loading). */
14
+ disabled?: boolean;
12
15
  }
13
16
 
14
17
  export function InteractionOverlay({
15
18
  className,
16
19
  children,
17
20
  message = 'Touch to interact',
21
+ disabled = false,
18
22
  }: InteractionOverlayProps) {
19
23
  const isTouchDevice = useIsTouchDevice();
24
+ const isChatLoading = useIsChatLoading();
25
+ const suppressOverlay = disabled || isChatLoading;
20
26
  const [isVisible, setIsVisible] = useState(true);
21
27
 
22
28
  const handleClick = useCallback(() => {
@@ -24,13 +30,13 @@ export function InteractionOverlay({
24
30
  }, []);
25
31
 
26
32
  useEffect(() => {
27
- if (isTouchDevice) {
33
+ if (isTouchDevice && !suppressOverlay) {
28
34
  setIsVisible(true);
29
35
  }
30
- }, [isTouchDevice]);
36
+ }, [isTouchDevice, suppressOverlay]);
31
37
 
32
38
  useEffect(() => {
33
- if (!isTouchDevice) return;
39
+ if (!isTouchDevice || suppressOverlay) return;
34
40
 
35
41
  const handleScroll = () => {
36
42
  setIsVisible(true);
@@ -40,14 +46,20 @@ export function InteractionOverlay({
40
46
  return () => {
41
47
  document.removeEventListener('scroll', handleScroll, { capture: true });
42
48
  };
43
- }, [isTouchDevice]);
49
+ }, [isTouchDevice, suppressOverlay]);
44
50
 
45
51
  if (!isTouchDevice) {
46
52
  return <>{children}</>;
47
53
  }
48
54
 
49
55
  return (
50
- <div className={cn(S.root, !isVisible && S.hidden, className)}>
56
+ <div
57
+ className={cn(
58
+ S.root,
59
+ (!isVisible || suppressOverlay) && S.hidden,
60
+ className,
61
+ )}
62
+ >
51
63
  {children}
52
64
  <button
53
65
  type="button"
@@ -66,6 +66,10 @@ export interface ChatContextType {
66
66
  * `outboundPendingKey(scopeId, chatSessionId)` (session id at send start).
67
67
  */
68
68
  outboundPendingByKey: Readonly<Record<string, number>>;
69
+ /** Ref-count of chat panels reporting UI busy (outbound wait, preset scripts, etc.). */
70
+ panelBusyCount: number;
71
+ acquirePanelBusy: () => void;
72
+ releasePanelBusy: () => void;
69
73
  }
70
74
 
71
75
  const ChatContext = createContext<ChatContextType | undefined>(undefined);
@@ -175,6 +179,15 @@ export function ChatProvider({
175
179
  const [outboundPendingByKey, setOutboundPendingByKey] = useState<
176
180
  Record<string, number>
177
181
  >({});
182
+ const [panelBusyCount, setPanelBusyCount] = useState(0);
183
+
184
+ const acquirePanelBusy = useCallback(() => {
185
+ setPanelBusyCount(count => count + 1);
186
+ }, []);
187
+
188
+ const releasePanelBusy = useCallback(() => {
189
+ setPanelBusyCount(count => Math.max(0, count - 1));
190
+ }, []);
178
191
 
179
192
  const beginOutboundPending = useCallback(
180
193
  (scopeId: string, chatSessionId: string) => {
@@ -444,6 +457,7 @@ export function ChatProvider({
444
457
  setChats({});
445
458
  setCurrentChatIdState({});
446
459
  setOutboundPendingByKey({});
460
+ setPanelBusyCount(0);
447
461
  return;
448
462
  }
449
463
 
@@ -475,6 +489,9 @@ export function ChatProvider({
475
489
  getCurrentChatId,
476
490
  deleteChat,
477
491
  outboundPendingByKey,
492
+ panelBusyCount,
493
+ acquirePanelBusy,
494
+ releasePanelBusy,
478
495
  }}
479
496
  >
480
497
  {children}
@@ -519,6 +536,31 @@ export function useChatOutboundPending(
519
536
  }, [scopeId, chatSessionId, outboundPendingByKey]);
520
537
  }
521
538
 
539
+ /** True when chat is waiting on API or panel UI busy (safe outside ChatProvider). */
540
+ export function useIsChatLoading(): boolean {
541
+ const context = useContext(ChatContext);
542
+ return useMemo(() => {
543
+ if (!context) return false;
544
+ const outboundPending = Object.values(context.outboundPendingByKey).some(
545
+ count => count > 0,
546
+ );
547
+ return outboundPending || context.panelBusyCount > 0;
548
+ }, [context?.outboundPendingByKey, context?.panelBusyCount]);
549
+ }
550
+
551
+ /** Syncs chat panel `isLoading` into context for touch-overlay suppression. */
552
+ export function useSyncChatPanelBusy(isLoading: boolean): void {
553
+ const acquirePanelBusy = useContext(ChatContext)?.acquirePanelBusy;
554
+ const releasePanelBusy = useContext(ChatContext)?.releasePanelBusy;
555
+ useEffect(() => {
556
+ if (!isLoading || !acquirePanelBusy || !releasePanelBusy) return;
557
+ acquirePanelBusy();
558
+ return () => {
559
+ releasePanelBusy();
560
+ };
561
+ }, [isLoading, acquirePanelBusy, releasePanelBusy]);
562
+ }
563
+
522
564
  export function useChatsForScopeId(scopeId: string) {
523
565
  const {
524
566
  getChatsForScopeId,
@@ -6,7 +6,9 @@ import type {
6
6
  OverlayMode,
7
7
  } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
8
8
  import { ForecastItemData } from '#uilib/components/ui/ChartAreaInteractive/ChartLines';
9
+ import { Label } from '#uilib/components/ui/Label';
9
10
  import { PageContentSection } from '#uilib/components/ui/Page';
11
+ import { Switch } from '#uilib/components/ui/Switch';
10
12
  import { Tabs, TabsList, TabsTrigger } from '#uilib/components/ui/Tabs';
11
13
  import { useTheme } from '#uilib/contexts/theme-context';
12
14
  import type { ForecastData } from '#uilib/types/forecast-data';
@@ -102,6 +104,7 @@ export default function ChartAreaInteractivePage() {
102
104
  string | null
103
105
  >('0.9');
104
106
  const [upperThreshold, setUpperThreshold] = useState(15);
107
+ const [loading, setLoading] = useState(false);
105
108
 
106
109
  const toggleLegendSeries = useCallback((key: string) => {
107
110
  setHidden(prev => {
@@ -155,6 +158,21 @@ export default function ChartAreaInteractivePage() {
155
158
  <TabsTrigger value="thresholds">Thresholds</TabsTrigger>
156
159
  </TabsList>
157
160
  </Tabs>
161
+ <div
162
+ style={{
163
+ display: 'flex',
164
+ alignItems: 'center',
165
+ gap: 8,
166
+ marginBottom: 16,
167
+ }}
168
+ >
169
+ <Switch
170
+ id="chart-loading"
171
+ checked={loading}
172
+ onCheckedChange={setLoading}
173
+ />
174
+ <Label htmlFor="chart-loading">Loading</Label>
175
+ </div>
158
176
  <ChartAreaInteractive
159
177
  timeRange={timeRange}
160
178
  onTimeRangeChange={setTimeRange}
@@ -163,7 +181,7 @@ export default function ChartAreaInteractivePage() {
163
181
  mode={mode}
164
182
  chartData={chartData}
165
183
  forecastData={DEMO_FORECAST_ITEMS}
166
- loading={false}
184
+ loading={loading}
167
185
  isDarkTheme={isDarkMode}
168
186
  toggleLegendSeries={toggleLegendSeries}
169
187
  ensureAnalysisSeriesVisible={ensureAnalysisSeriesVisible}