@sybilion/uilib 1.3.61 → 1.3.63
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/chatPanelOpenSync.js +28 -0
- package/dist/esm/components/ui/Chat/ChatSheet/useChatPanelChromeModel.js +84 -26
- package/dist/esm/components/ui/Sidebar/Sidebar.js +6 -1
- package/dist/esm/components/widgets/DriversComparisonChart/DriversComparisonChart.js +2 -2
- package/dist/esm/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.js +21 -5
- package/dist/esm/index.js +1 -1
- package/dist/esm/types/src/components/ui/Chat/ChatSheet/chatPanelOpenSync.d.ts +12 -0
- package/dist/esm/types/src/components/ui/Chat/ChatSheet/chatPanelOpenSync.test.d.ts +1 -0
- package/dist/esm/types/src/components/ui/Sidebar/Sidebar.d.ts +2 -0
- package/dist/esm/types/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.d.ts +3 -1
- package/package.json +1 -1
- package/src/components/ui/Chat/ChatSheet/chatPanelOpenSync.test.ts +83 -0
- package/src/components/ui/Chat/ChatSheet/chatPanelOpenSync.ts +58 -0
- package/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.tsx +106 -26
- package/src/components/ui/Sidebar/Sidebar.tsx +6 -0
- package/src/components/widgets/DriversComparisonChart/AGENT.md +3 -3
- package/src/components/widgets/DriversComparisonChart/DriversComparisonChart.tsx +2 -2
- package/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.test.ts +65 -45
- package/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.ts +22 -4
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Pure guards for chat panel URL ↔ shell sync (unit-tested). */
|
|
2
|
+
function shouldOpenChatFromUrl(chatOpen, layoutDismissed) {
|
|
3
|
+
return chatOpen && !layoutDismissed;
|
|
4
|
+
}
|
|
5
|
+
function shouldHealChatShellDesync(chatOpen, shellChatPanelOpen, layoutDismissed, isOpen) {
|
|
6
|
+
if (!chatOpen || shellChatPanelOpen) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
if (layoutDismissed && !isOpen) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
function shouldCloseStaleLocalChatOpen(shellChatPanelOpen, chatOpen, isOpen) {
|
|
15
|
+
return !shellChatPanelOpen && !chatOpen && isOpen;
|
|
16
|
+
}
|
|
17
|
+
function shouldDismissChatAfterShellClosed(params) {
|
|
18
|
+
const { shellChatPanelOpen, wasShellChatPanelOpen, chatOpen, isOpen, openedShellChat, } = params;
|
|
19
|
+
if (shellChatPanelOpen || !wasShellChatPanelOpen || !chatOpen) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
return isOpen || openedShellChat;
|
|
23
|
+
}
|
|
24
|
+
function isChatPanelVisible(isOpen, shellChatPanelOpen) {
|
|
25
|
+
return isOpen && shellChatPanelOpen;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export { isChatPanelVisible, shouldCloseStaleLocalChatOpen, shouldDismissChatAfterShellClosed, shouldHealChatShellDesync, shouldOpenChatFromUrl };
|
|
@@ -11,8 +11,9 @@ import { useIsMobile } from '../../../../hooks/useIsMobile.js';
|
|
|
11
11
|
import { useQueryParams } from '../../../../hooks/useQueryParams.js';
|
|
12
12
|
import logger from '../../../../lib/logger.js';
|
|
13
13
|
import { mergePresetFreeformDefaults } from '../../../../utils/chatPresetMerge.js';
|
|
14
|
-
import { useSidebar } from '../../Sidebar/Sidebar.js';
|
|
14
|
+
import { useSidebar, DISMISS_CHAT_FOR_LAYOUT_EVENT } from '../../Sidebar/Sidebar.js';
|
|
15
15
|
import { Chat } from '../Chat.js';
|
|
16
|
+
import { shouldOpenChatFromUrl, shouldHealChatShellDesync, shouldCloseStaleLocalChatOpen, shouldDismissChatAfterShellClosed, isChatPanelVisible } from './chatPanelOpenSync.js';
|
|
16
17
|
|
|
17
18
|
/** Fallback when `scopeId` prop omitted; apps should pass an explicit composite scope (e.g. `${userId}-dashboard`). */
|
|
18
19
|
const NO_SCOPE_FALLBACK = '__uilib_chat_scope_unset__';
|
|
@@ -33,11 +34,16 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
33
34
|
useSyncChatPanelBusy(isLoading);
|
|
34
35
|
const { searchParams, addSearchParams, removeSearchParams, mutateSearchParams, } = useQueryParams();
|
|
35
36
|
const chatOpen = searchParams.has(CHAT_QUERY_PARAM);
|
|
36
|
-
const [isOpen, setIsOpen] = useState(
|
|
37
|
+
const [isOpen, setIsOpen] = useState(() => !embedAsPage && chatOpen);
|
|
37
38
|
/** `?prompt=` deep link text for one-shot composer pre-fill (see ChatPrompt). */
|
|
38
39
|
const [promptLinkPrefill, setPromptLinkPrefill] = useState(null);
|
|
39
40
|
/** Deduplicate Strict Mode double `useEffect` on the same mount. */
|
|
40
41
|
const promptParamHandledInEffectRef = useRef(false);
|
|
42
|
+
const prevShellChatPanelOpenRef = useRef(shellChatPanelOpen);
|
|
43
|
+
/** This instance requested shell chat width (ignore other ChatSheet instances on the page). */
|
|
44
|
+
const openedShellChatRef = useRef(false);
|
|
45
|
+
/** Nav-forced chat close; blocks URL sync from reopening chat in the same tick. */
|
|
46
|
+
const layoutDismissedRef = useRef(false);
|
|
41
47
|
const panelActive = embedAsPage || isOpen;
|
|
42
48
|
// Ensure valid currentChatId when chat opens
|
|
43
49
|
useEffect(() => {
|
|
@@ -124,13 +130,17 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
124
130
|
return;
|
|
125
131
|
}
|
|
126
132
|
if (open) {
|
|
127
|
-
|
|
133
|
+
layoutDismissedRef.current = false;
|
|
134
|
+
openedShellChatRef.current = true;
|
|
128
135
|
setChatPanelOpen(true);
|
|
136
|
+
setIsOpen(true);
|
|
129
137
|
if (!searchParams.has(CHAT_QUERY_PARAM)) {
|
|
130
138
|
addSearchParams({ [CHAT_QUERY_PARAM]: CHAT_OPEN_VALUE });
|
|
131
139
|
}
|
|
132
140
|
return;
|
|
133
141
|
}
|
|
142
|
+
openedShellChatRef.current = false;
|
|
143
|
+
removeSearchParams(CHAT_QUERY_PARAM);
|
|
134
144
|
const applyClose = () => {
|
|
135
145
|
setIsOpen(false);
|
|
136
146
|
setChatPanelOpen(false);
|
|
@@ -143,7 +153,6 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
143
153
|
else {
|
|
144
154
|
applyClose();
|
|
145
155
|
}
|
|
146
|
-
removeSearchParams(CHAT_QUERY_PARAM);
|
|
147
156
|
};
|
|
148
157
|
const isEmpty = isChatEmpty(chat) && !isLoading;
|
|
149
158
|
const openNewChatWithPrefill = useCallback((prompt) => {
|
|
@@ -624,50 +633,93 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
624
633
|
}
|
|
625
634
|
})();
|
|
626
635
|
}, [currentChatId, scriptByChatId, addMessage, onScriptComplete]);
|
|
636
|
+
const dismissChatForLayout = useCallback(() => {
|
|
637
|
+
if (embedAsPage) {
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
layoutDismissedRef.current = true;
|
|
641
|
+
openedShellChatRef.current = false;
|
|
642
|
+
setIsOpen(false);
|
|
643
|
+
setChatPanelOpen(false);
|
|
644
|
+
removeSearchParams(CHAT_QUERY_PARAM);
|
|
645
|
+
}, [embedAsPage, removeSearchParams, setChatPanelOpen]);
|
|
646
|
+
useEvent({
|
|
647
|
+
event: DISMISS_CHAT_FOR_LAYOUT_EVENT,
|
|
648
|
+
callback: dismissChatForLayout,
|
|
649
|
+
});
|
|
650
|
+
/** `?chat=open` in URL — open local UI + shell (skip right after nav evicted chat). */
|
|
627
651
|
useEffect(() => {
|
|
628
652
|
if (embedAsPage) {
|
|
629
653
|
return;
|
|
630
654
|
}
|
|
631
655
|
if (!chatOpen) {
|
|
632
|
-
|
|
633
|
-
setChatPanelOpen(false);
|
|
656
|
+
layoutDismissedRef.current = false;
|
|
634
657
|
return;
|
|
635
658
|
}
|
|
636
|
-
if (!
|
|
637
|
-
removeSearchParams(CHAT_QUERY_PARAM);
|
|
659
|
+
if (!shouldOpenChatFromUrl(chatOpen, layoutDismissedRef.current)) {
|
|
638
660
|
return;
|
|
639
661
|
}
|
|
640
662
|
setIsOpen(true);
|
|
663
|
+
openedShellChatRef.current = true;
|
|
641
664
|
setChatPanelOpen(true);
|
|
642
|
-
}, [
|
|
643
|
-
|
|
644
|
-
chatOpen,
|
|
645
|
-
setChatPanelOpen,
|
|
646
|
-
sidebarNavOpen,
|
|
647
|
-
shellChatPanelOpen,
|
|
648
|
-
removeSearchParams,
|
|
649
|
-
]);
|
|
650
|
-
/** Shell closed chat for space (e.g. nav opened); keep UI and URL in sync. */
|
|
665
|
+
}, [embedAsPage, chatOpen, setChatPanelOpen]);
|
|
666
|
+
/** Heal desync: `?chat=open` but shell collapsed while this instance still wants panel open. */
|
|
651
667
|
useEffect(() => {
|
|
652
|
-
if (embedAsPage
|
|
668
|
+
if (embedAsPage) {
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (!shouldHealChatShellDesync(chatOpen, shellChatPanelOpen, layoutDismissedRef.current, isOpen)) {
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
layoutDismissedRef.current = false;
|
|
675
|
+
if (!isOpen) {
|
|
676
|
+
setIsOpen(true);
|
|
677
|
+
}
|
|
678
|
+
openedShellChatRef.current = true;
|
|
679
|
+
setChatPanelOpen(true);
|
|
680
|
+
}, [embedAsPage, chatOpen, isOpen, shellChatPanelOpen, setChatPanelOpen]);
|
|
681
|
+
/** Stale local open after shell closed without `?chat=` (e.g. another instance or aborted open). */
|
|
682
|
+
useEffect(() => {
|
|
683
|
+
if (embedAsPage) {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
if (!shouldCloseStaleLocalChatOpen(shellChatPanelOpen, chatOpen, isOpen)) {
|
|
653
687
|
return;
|
|
654
688
|
}
|
|
655
689
|
setIsOpen(false);
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
690
|
+
}, [embedAsPage, shellChatPanelOpen, chatOpen, isOpen]);
|
|
691
|
+
/** Shell chat closed while `?chat=` still set (fallback if layout event was missed). */
|
|
692
|
+
useEffect(() => {
|
|
693
|
+
if (embedAsPage) {
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const wasShellChatOpen = prevShellChatPanelOpenRef.current;
|
|
697
|
+
prevShellChatPanelOpenRef.current = shellChatPanelOpen;
|
|
698
|
+
if (!shouldDismissChatAfterShellClosed({
|
|
699
|
+
shellChatPanelOpen,
|
|
700
|
+
wasShellChatPanelOpen: wasShellChatOpen,
|
|
701
|
+
chatOpen,
|
|
702
|
+
isOpen,
|
|
703
|
+
openedShellChat: openedShellChatRef.current,
|
|
704
|
+
})) {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
dismissChatForLayout();
|
|
708
|
+
}, [embedAsPage, shellChatPanelOpen, chatOpen, dismissChatForLayout, isOpen]);
|
|
709
|
+
/** Route change: release shell slot only if this instance opened it (not Strict Mode remount). */
|
|
659
710
|
useEffect(() => {
|
|
660
711
|
return () => {
|
|
661
|
-
if (!embedAsPage) {
|
|
712
|
+
if (!embedAsPage && openedShellChatRef.current && !chatOpen) {
|
|
713
|
+
openedShellChatRef.current = false;
|
|
662
714
|
setChatPanelOpen(false);
|
|
663
715
|
}
|
|
664
716
|
};
|
|
665
|
-
}, [embedAsPage, setChatPanelOpen]);
|
|
717
|
+
}, [embedAsPage, chatOpen, setChatPanelOpen]);
|
|
666
718
|
useEffect(() => {
|
|
667
719
|
if (embedAsPage) {
|
|
668
720
|
return;
|
|
669
721
|
}
|
|
670
|
-
if (!
|
|
722
|
+
if (!shellChatPanelOpen || !sidebarNavOpen)
|
|
671
723
|
return;
|
|
672
724
|
const collapseNavIfNoSpace = () => {
|
|
673
725
|
const shellW = getShellWidth();
|
|
@@ -686,7 +738,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
686
738
|
return () => window.removeEventListener('resize', collapseNavIfNoSpace);
|
|
687
739
|
}, [
|
|
688
740
|
embedAsPage,
|
|
689
|
-
|
|
741
|
+
shellChatPanelOpen,
|
|
690
742
|
sidebarNavOpen,
|
|
691
743
|
setSidebarNavOpen,
|
|
692
744
|
getShellWidth,
|
|
@@ -808,7 +860,13 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
808
860
|
slashCommandItems,
|
|
809
861
|
onSlashItemCommand,
|
|
810
862
|
};
|
|
811
|
-
const toggleOpen = () =>
|
|
863
|
+
const toggleOpen = () => {
|
|
864
|
+
if (!isChatPanelVisible(isOpen, shellChatPanelOpen)) {
|
|
865
|
+
onOpenChange(true);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
onOpenChange(false);
|
|
869
|
+
};
|
|
812
870
|
return {
|
|
813
871
|
chromeProps,
|
|
814
872
|
isOpen,
|
|
@@ -20,6 +20,8 @@ import SidebarStem from './Sidebar.styl.js';
|
|
|
20
20
|
const SIDEBAR_WIDTH_MOBILE = '18rem';
|
|
21
21
|
const SIDEBAR_WIDTH_ICON = '3rem';
|
|
22
22
|
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
|
|
23
|
+
/** Fired when nav open closes chat for layout; ChatSheet clears `?chat=` so URL sync does not reopen chat. */
|
|
24
|
+
const DISMISS_CHAT_FOR_LAYOUT_EVENT = 'sybilion:dismiss-chat-for-layout';
|
|
23
25
|
function isEditableKeyboardTarget(el) {
|
|
24
26
|
if (!el)
|
|
25
27
|
return false;
|
|
@@ -219,6 +221,9 @@ function SidebarProvider({ defaultOpen = getCookie('isSidebarOpen') ?? true, cla
|
|
|
219
221
|
return;
|
|
220
222
|
if (openingMain && chatPanelOpen) {
|
|
221
223
|
_setChatPanelOpen(false);
|
|
224
|
+
if (typeof window !== 'undefined') {
|
|
225
|
+
window.dispatchEvent(new CustomEvent(DISMISS_CHAT_FOR_LAYOUT_EVENT));
|
|
226
|
+
}
|
|
222
227
|
}
|
|
223
228
|
else if (openingChat && isOpen) {
|
|
224
229
|
setIsOpen(false);
|
|
@@ -482,4 +487,4 @@ function SidebarMenuSubButton({ asChild = false, size = 'md', isActive = false,
|
|
|
482
487
|
return (jsxs(Tooltip, { children: [jsx(TooltipTrigger, { asChild: true, children: button }), jsx(TooltipContent, { side: "right", align: "center", hidden: !isOpen || isSidebarSheetLayout, ...tooltip })] }));
|
|
483
488
|
}
|
|
484
489
|
|
|
485
|
-
export { PanelResizeHandle, Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupAction, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarRail, SidebarResizeHandle, SidebarSeparator, SidebarTrigger, useSidebar };
|
|
490
|
+
export { DISMISS_CHAT_FOR_LAYOUT_EVENT, PanelResizeHandle, Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupAction, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarRail, SidebarResizeHandle, SidebarSeparator, SidebarTrigger, useSidebar };
|
|
@@ -83,8 +83,8 @@ function DriversComparisonChart({ payload, datasetHistorical = [], loading = fal
|
|
|
83
83
|
const mergedChartData = useMemo(() => mergeBacktestsChartData(payloadForView), [payloadForView]);
|
|
84
84
|
const mergedWithHistorical = useMemo(() => mergeDatasetHistoricalWithBacktestsChartData(datasetHistorical, mergedChartData), [datasetHistorical, mergedChartData]);
|
|
85
85
|
const historicalWindowFloor = useMemo(() => {
|
|
86
|
-
const
|
|
87
|
-
const merged = mergeDatasetHistoricalWithBacktestsChartData(datasetHistorical,
|
|
86
|
+
const laggedMerged = mergeBacktestsChartData(payload);
|
|
87
|
+
const merged = mergeDatasetHistoricalWithBacktestsChartData(datasetHistorical, laggedMerged);
|
|
88
88
|
const sortedDrivers = [...(payload?.drivers ?? [])]
|
|
89
89
|
.filter(d => d.normalized_series &&
|
|
90
90
|
Object.keys(d.normalized_series).some(key => d.normalized_series[key] != null))
|
package/dist/esm/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.js
CHANGED
|
@@ -57,7 +57,7 @@ function formatLagMonthsLabel(months) {
|
|
|
57
57
|
return `${months} month(s)`;
|
|
58
58
|
}
|
|
59
59
|
function getLagDisplayForView(lag, tab) {
|
|
60
|
-
if (tab === '
|
|
60
|
+
if (tab === 'lagged') {
|
|
61
61
|
return lag?.trim() ? lag : '—';
|
|
62
62
|
}
|
|
63
63
|
const months = parseLagMonthsFromLabel(lag);
|
|
@@ -78,8 +78,24 @@ function shiftNormalizedSeriesForward(series, lagMonths) {
|
|
|
78
78
|
}
|
|
79
79
|
return out;
|
|
80
80
|
}
|
|
81
|
+
/** Shift driver points left (earlier months) by lag — overlapped alignment vs target. */
|
|
82
|
+
function shiftNormalizedSeriesBackward(series, lagMonths) {
|
|
83
|
+
if (lagMonths <= 0)
|
|
84
|
+
return { ...series };
|
|
85
|
+
const out = {};
|
|
86
|
+
for (const [dateStr, val] of Object.entries(series)) {
|
|
87
|
+
if (val === null || val === undefined)
|
|
88
|
+
continue;
|
|
89
|
+
let shifted = normalizeToMonthStart(dateStr);
|
|
90
|
+
for (let i = 0; i < lagMonths; i++) {
|
|
91
|
+
shifted = getPreviousMonth(shifted);
|
|
92
|
+
}
|
|
93
|
+
out[shifted] = val;
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
81
97
|
function applyDriversComparisonViewToPayload(payload, tab) {
|
|
82
|
-
if (!payload || tab === '
|
|
98
|
+
if (!payload || tab === 'lagged')
|
|
83
99
|
return payload;
|
|
84
100
|
return {
|
|
85
101
|
target: {
|
|
@@ -90,7 +106,7 @@ function applyDriversComparisonViewToPayload(payload, tab) {
|
|
|
90
106
|
const lagMonths = parseLagMonthsFromLabel(resolveDriverLagLabel(driver));
|
|
91
107
|
const series = driver.normalized_series ?? {};
|
|
92
108
|
const normalized_series = lagMonths != null && lagMonths > 0
|
|
93
|
-
?
|
|
109
|
+
? shiftNormalizedSeriesBackward(series, lagMonths)
|
|
94
110
|
: { ...series };
|
|
95
111
|
return {
|
|
96
112
|
...driver,
|
|
@@ -231,7 +247,7 @@ function prependHistoricalLeadFromDataset(points, datasetHistorical, xMin) {
|
|
|
231
247
|
}
|
|
232
248
|
return [...lead, ...points];
|
|
233
249
|
}
|
|
234
|
-
/**
|
|
250
|
+
/** Lagged (unshifted) anchor minus lead months — stable xMin across lagged/overlapped tabs. */
|
|
235
251
|
function computeDriversComparisonHistoricalWindowFloor(mergedWithHistorical, forecastIds) {
|
|
236
252
|
if (mergedWithHistorical.length === 0 || forecastIds.length === 0)
|
|
237
253
|
return null;
|
|
@@ -253,4 +269,4 @@ function buildDriversComparisonChartData(mergedWithHistorical, datasetHistorical
|
|
|
253
269
|
return withLead.filter(p => normalizeToMonthStart(p.date) >= xMin);
|
|
254
270
|
}
|
|
255
271
|
|
|
256
|
-
export { DRIVER_COMPARISON_CHART_LEAD_MONTHS, DRIVER_FORECAST_ID_BASE, INITIAL_VISIBLE_SERIES_COUNT, applyDriversComparisonViewToPayload, buildDriversComparisonChartData, computeDriversComparisonHistoricalWindowFloor, formatLagMonthsLabel, formatSeriesImportance, getLagDisplayForView, getZoomAnchorMonthFromDriverMaterialStarts, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, parseLagMonthsFromLabel, prependHistoricalLeadFromDataset, resolveDriverLagLabel, shiftNormalizedSeriesForward, subtractMonthsFromMonthStart };
|
|
272
|
+
export { DRIVER_COMPARISON_CHART_LEAD_MONTHS, DRIVER_FORECAST_ID_BASE, INITIAL_VISIBLE_SERIES_COUNT, applyDriversComparisonViewToPayload, buildDriversComparisonChartData, computeDriversComparisonHistoricalWindowFloor, formatLagMonthsLabel, formatSeriesImportance, getLagDisplayForView, getZoomAnchorMonthFromDriverMaterialStarts, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, parseLagMonthsFromLabel, prependHistoricalLeadFromDataset, resolveDriverLagLabel, shiftNormalizedSeriesBackward, shiftNormalizedSeriesForward, subtractMonthsFromMonthStart };
|
package/dist/esm/index.js
CHANGED
|
@@ -77,7 +77,7 @@ export { Renamer } from './components/ui/Renamer/Renamer.js';
|
|
|
77
77
|
export { Select, SelectContent, SelectGroup, SelectItem, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue } from './components/ui/Select/Select.js';
|
|
78
78
|
export { Separator } from './components/ui/Separator/Separator.js';
|
|
79
79
|
export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from './components/ui/Sheet/Sheet.js';
|
|
80
|
-
export { PanelResizeHandle, Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupAction, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarRail, SidebarResizeHandle, SidebarSeparator, SidebarTrigger, useSidebar } from './components/ui/Sidebar/Sidebar.js';
|
|
80
|
+
export { DISMISS_CHAT_FOR_LAYOUT_EVENT, PanelResizeHandle, Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupAction, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarRail, SidebarResizeHandle, SidebarSeparator, SidebarTrigger, useSidebar } from './components/ui/Sidebar/Sidebar.js';
|
|
81
81
|
export { Skeleton } from './components/ui/Skeleton/Skeleton.js';
|
|
82
82
|
export { Slider } from './components/ui/Slider/Slider.js';
|
|
83
83
|
export { SmartTextTruncate } from './components/ui/SmartTextTruncate/SmartTextTruncate.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Pure guards for chat panel URL ↔ shell sync (unit-tested). */
|
|
2
|
+
export declare function shouldOpenChatFromUrl(chatOpen: boolean, layoutDismissed: boolean): boolean;
|
|
3
|
+
export declare function shouldHealChatShellDesync(chatOpen: boolean, shellChatPanelOpen: boolean, layoutDismissed: boolean, isOpen: boolean): boolean;
|
|
4
|
+
export declare function shouldCloseStaleLocalChatOpen(shellChatPanelOpen: boolean, chatOpen: boolean, isOpen: boolean): boolean;
|
|
5
|
+
export declare function shouldDismissChatAfterShellClosed(params: {
|
|
6
|
+
shellChatPanelOpen: boolean;
|
|
7
|
+
wasShellChatPanelOpen: boolean;
|
|
8
|
+
chatOpen: boolean;
|
|
9
|
+
isOpen: boolean;
|
|
10
|
+
openedShellChat: boolean;
|
|
11
|
+
}): boolean;
|
|
12
|
+
export declare function isChatPanelVisible(isOpen: boolean, shellChatPanelOpen: boolean): boolean;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -4,6 +4,8 @@ import type { ButtonProps } from '#uilib/components/ui/Button/Button.types';
|
|
|
4
4
|
import { Separator } from '#uilib/components/ui/Separator';
|
|
5
5
|
import { TooltipContent } from '#uilib/components/ui/Tooltip';
|
|
6
6
|
import { Scroll } from '@homecode/ui';
|
|
7
|
+
/** Fired when nav open closes chat for layout; ChatSheet clears `?chat=` so URL sync does not reopen chat. */
|
|
8
|
+
export declare const DISMISS_CHAT_FOR_LAYOUT_EVENT = "sybilion:dismiss-chat-for-layout";
|
|
7
9
|
type SetPanelWidthOptions = {
|
|
8
10
|
persist?: boolean;
|
|
9
11
|
};
|
|
@@ -16,6 +16,8 @@ export declare function parseLagMonthsFromLabel(lag: string | null | undefined):
|
|
|
16
16
|
export declare function formatLagMonthsLabel(months: number): string;
|
|
17
17
|
export declare function getLagDisplayForView(lag: string | null | undefined, tab: DriversComparisonViewTab): string;
|
|
18
18
|
export declare function shiftNormalizedSeriesForward(series: Record<string, number | null>, lagMonths: number): Record<string, number | null>;
|
|
19
|
+
/** Shift driver points left (earlier months) by lag — overlapped alignment vs target. */
|
|
20
|
+
export declare function shiftNormalizedSeriesBackward(series: Record<string, number | null>, lagMonths: number): Record<string, number | null>;
|
|
19
21
|
export declare function applyDriversComparisonViewToPayload(payload: BacktestsComponentPayload | null, tab: DriversComparisonViewTab): BacktestsComponentPayload | null;
|
|
20
22
|
export declare function mergeBacktestsChartData(payload: BacktestsComponentPayload | null): ChartDataPoint[];
|
|
21
23
|
/** While drivers comparison is not loaded: dataset historical only. After load: normalized historical from target.normalized_series (see mergeBacktestsChartData) replaces it. */
|
|
@@ -35,6 +37,6 @@ export declare function subtractMonthsFromMonthStart(date: string, count: number
|
|
|
35
37
|
* (scaled to the first normalized point) so the lead-in shows the target line alone.
|
|
36
38
|
*/
|
|
37
39
|
export declare function prependHistoricalLeadFromDataset(points: ChartDataPoint[], datasetHistorical: ChartDataPoint[], xMin: string): ChartDataPoint[];
|
|
38
|
-
/**
|
|
40
|
+
/** Lagged (unshifted) anchor minus lead months — stable xMin across lagged/overlapped tabs. */
|
|
39
41
|
export declare function computeDriversComparisonHistoricalWindowFloor(mergedWithHistorical: ChartDataPoint[], forecastIds: number[]): string | null;
|
|
40
42
|
export declare function buildDriversComparisonChartData(mergedWithHistorical: ChartDataPoint[], datasetHistorical: ChartDataPoint[], forecastIds: number[], historicalWindowFloor?: string | null): ChartDataPoint[];
|
package/package.json
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isChatPanelVisible,
|
|
3
|
+
shouldCloseStaleLocalChatOpen,
|
|
4
|
+
shouldDismissChatAfterShellClosed,
|
|
5
|
+
shouldHealChatShellDesync,
|
|
6
|
+
shouldOpenChatFromUrl,
|
|
7
|
+
} from './chatPanelOpenSync';
|
|
8
|
+
|
|
9
|
+
describe('shouldOpenChatFromUrl', () => {
|
|
10
|
+
it('opens when ?chat= is set and layout has not dismissed chat', () => {
|
|
11
|
+
expect(shouldOpenChatFromUrl(true, false)).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('blocks reopen in the same tick after nav layout dismiss', () => {
|
|
15
|
+
expect(shouldOpenChatFromUrl(true, true)).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('shouldHealChatShellDesync', () => {
|
|
20
|
+
it('heals when URL requests chat but shell slot is closed', () => {
|
|
21
|
+
expect(shouldHealChatShellDesync(true, false, false, false)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('skips heal after layout dismiss cleared local open', () => {
|
|
25
|
+
expect(shouldHealChatShellDesync(true, false, true, false)).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('shouldCloseStaleLocalChatOpen', () => {
|
|
30
|
+
it('closes local open when shell and URL are both closed', () => {
|
|
31
|
+
expect(shouldCloseStaleLocalChatOpen(false, false, true)).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('keeps local open while ?chat= remains', () => {
|
|
35
|
+
expect(shouldCloseStaleLocalChatOpen(false, true, true)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('shouldDismissChatAfterShellClosed', () => {
|
|
40
|
+
it('dismisses when shell closes with ?chat= and this instance opened chat', () => {
|
|
41
|
+
expect(
|
|
42
|
+
shouldDismissChatAfterShellClosed({
|
|
43
|
+
shellChatPanelOpen: false,
|
|
44
|
+
wasShellChatPanelOpen: true,
|
|
45
|
+
chatOpen: true,
|
|
46
|
+
isOpen: true,
|
|
47
|
+
openedShellChat: true,
|
|
48
|
+
}),
|
|
49
|
+
).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('ignores shell close when URL has no chat param', () => {
|
|
53
|
+
expect(
|
|
54
|
+
shouldDismissChatAfterShellClosed({
|
|
55
|
+
shellChatPanelOpen: false,
|
|
56
|
+
wasShellChatPanelOpen: true,
|
|
57
|
+
chatOpen: false,
|
|
58
|
+
isOpen: true,
|
|
59
|
+
openedShellChat: true,
|
|
60
|
+
}),
|
|
61
|
+
).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('ignores unrelated shell close without local or instance ownership', () => {
|
|
65
|
+
expect(
|
|
66
|
+
shouldDismissChatAfterShellClosed({
|
|
67
|
+
shellChatPanelOpen: false,
|
|
68
|
+
wasShellChatPanelOpen: true,
|
|
69
|
+
chatOpen: true,
|
|
70
|
+
isOpen: false,
|
|
71
|
+
openedShellChat: false,
|
|
72
|
+
}),
|
|
73
|
+
).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('isChatPanelVisible', () => {
|
|
78
|
+
it('is visible only when local and shell are both open', () => {
|
|
79
|
+
expect(isChatPanelVisible(true, true)).toBe(true);
|
|
80
|
+
expect(isChatPanelVisible(true, false)).toBe(false);
|
|
81
|
+
expect(isChatPanelVisible(false, true)).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Pure guards for chat panel URL ↔ shell sync (unit-tested). */
|
|
2
|
+
|
|
3
|
+
export function shouldOpenChatFromUrl(
|
|
4
|
+
chatOpen: boolean,
|
|
5
|
+
layoutDismissed: boolean,
|
|
6
|
+
): boolean {
|
|
7
|
+
return chatOpen && !layoutDismissed;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function shouldHealChatShellDesync(
|
|
11
|
+
chatOpen: boolean,
|
|
12
|
+
shellChatPanelOpen: boolean,
|
|
13
|
+
layoutDismissed: boolean,
|
|
14
|
+
isOpen: boolean,
|
|
15
|
+
): boolean {
|
|
16
|
+
if (!chatOpen || shellChatPanelOpen) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
if (layoutDismissed && !isOpen) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function shouldCloseStaleLocalChatOpen(
|
|
26
|
+
shellChatPanelOpen: boolean,
|
|
27
|
+
chatOpen: boolean,
|
|
28
|
+
isOpen: boolean,
|
|
29
|
+
): boolean {
|
|
30
|
+
return !shellChatPanelOpen && !chatOpen && isOpen;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function shouldDismissChatAfterShellClosed(params: {
|
|
34
|
+
shellChatPanelOpen: boolean;
|
|
35
|
+
wasShellChatPanelOpen: boolean;
|
|
36
|
+
chatOpen: boolean;
|
|
37
|
+
isOpen: boolean;
|
|
38
|
+
openedShellChat: boolean;
|
|
39
|
+
}): boolean {
|
|
40
|
+
const {
|
|
41
|
+
shellChatPanelOpen,
|
|
42
|
+
wasShellChatPanelOpen,
|
|
43
|
+
chatOpen,
|
|
44
|
+
isOpen,
|
|
45
|
+
openedShellChat,
|
|
46
|
+
} = params;
|
|
47
|
+
if (shellChatPanelOpen || !wasShellChatPanelOpen || !chatOpen) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return isOpen || openedShellChat;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isChatPanelVisible(
|
|
54
|
+
isOpen: boolean,
|
|
55
|
+
shellChatPanelOpen: boolean,
|
|
56
|
+
): boolean {
|
|
57
|
+
return isOpen && shellChatPanelOpen;
|
|
58
|
+
}
|
|
@@ -43,12 +43,22 @@ import type {
|
|
|
43
43
|
import { mergePresetFreeformDefaults } from '#uilib/utils/chatPresetMerge';
|
|
44
44
|
import { ScrollRef } from '@homecode/ui';
|
|
45
45
|
|
|
46
|
-
import {
|
|
46
|
+
import {
|
|
47
|
+
DISMISS_CHAT_FOR_LAYOUT_EVENT,
|
|
48
|
+
useSidebar,
|
|
49
|
+
} from '../../Sidebar/Sidebar';
|
|
47
50
|
import { Chat } from '../Chat';
|
|
48
51
|
import type { ChatChromeProps } from '../ChatChrome';
|
|
49
52
|
import type { ChatAttachmentDropItem } from '../ChatChrome/ChatChrome.types';
|
|
50
53
|
import type { ChatEmptyStateConfig } from '../ChatEmptyState/ChatEmptyState.types';
|
|
51
54
|
import type { ChatEmptyStateProps } from '../ChatEmptyState/ChatEmptyState.types';
|
|
55
|
+
import {
|
|
56
|
+
isChatPanelVisible,
|
|
57
|
+
shouldCloseStaleLocalChatOpen,
|
|
58
|
+
shouldDismissChatAfterShellClosed,
|
|
59
|
+
shouldHealChatShellDesync,
|
|
60
|
+
shouldOpenChatFromUrl,
|
|
61
|
+
} from './chatPanelOpenSync';
|
|
52
62
|
|
|
53
63
|
export type UseChatPanelChromeModelInput = {
|
|
54
64
|
/** When true, skip sidebar chat slot, URL `?chat=`, and portal behavior (e.g. page main content). */
|
|
@@ -173,13 +183,18 @@ export function useChatPanelChromeModel({
|
|
|
173
183
|
mutateSearchParams,
|
|
174
184
|
} = useQueryParams();
|
|
175
185
|
const chatOpen = searchParams.has(CHAT_QUERY_PARAM);
|
|
176
|
-
const [isOpen, setIsOpen] = useState(
|
|
186
|
+
const [isOpen, setIsOpen] = useState(() => !embedAsPage && chatOpen);
|
|
177
187
|
/** `?prompt=` deep link text for one-shot composer pre-fill (see ChatPrompt). */
|
|
178
188
|
const [promptLinkPrefill, setPromptLinkPrefill] = useState<string | null>(
|
|
179
189
|
null,
|
|
180
190
|
);
|
|
181
191
|
/** Deduplicate Strict Mode double `useEffect` on the same mount. */
|
|
182
192
|
const promptParamHandledInEffectRef = useRef(false);
|
|
193
|
+
const prevShellChatPanelOpenRef = useRef(shellChatPanelOpen);
|
|
194
|
+
/** This instance requested shell chat width (ignore other ChatSheet instances on the page). */
|
|
195
|
+
const openedShellChatRef = useRef(false);
|
|
196
|
+
/** Nav-forced chat close; blocks URL sync from reopening chat in the same tick. */
|
|
197
|
+
const layoutDismissedRef = useRef(false);
|
|
183
198
|
const panelActive = embedAsPage || isOpen;
|
|
184
199
|
|
|
185
200
|
// Ensure valid currentChatId when chat opens
|
|
@@ -288,14 +303,18 @@ export function useChatPanelChromeModel({
|
|
|
288
303
|
return;
|
|
289
304
|
}
|
|
290
305
|
if (open) {
|
|
291
|
-
|
|
306
|
+
layoutDismissedRef.current = false;
|
|
307
|
+
openedShellChatRef.current = true;
|
|
292
308
|
setChatPanelOpen(true);
|
|
309
|
+
setIsOpen(true);
|
|
293
310
|
if (!searchParams.has(CHAT_QUERY_PARAM)) {
|
|
294
311
|
addSearchParams({ [CHAT_QUERY_PARAM]: CHAT_OPEN_VALUE });
|
|
295
312
|
}
|
|
296
313
|
return;
|
|
297
314
|
}
|
|
298
315
|
|
|
316
|
+
openedShellChatRef.current = false;
|
|
317
|
+
removeSearchParams(CHAT_QUERY_PARAM);
|
|
299
318
|
const applyClose = () => {
|
|
300
319
|
setIsOpen(false);
|
|
301
320
|
setChatPanelOpen(false);
|
|
@@ -309,7 +328,6 @@ export function useChatPanelChromeModel({
|
|
|
309
328
|
} else {
|
|
310
329
|
applyClose();
|
|
311
330
|
}
|
|
312
|
-
removeSearchParams(CHAT_QUERY_PARAM);
|
|
313
331
|
};
|
|
314
332
|
|
|
315
333
|
const isEmpty = isChatEmpty(chat) && !isLoading;
|
|
@@ -851,53 +869,109 @@ export function useChatPanelChromeModel({
|
|
|
851
869
|
})();
|
|
852
870
|
}, [currentChatId, scriptByChatId, addMessage, onScriptComplete]);
|
|
853
871
|
|
|
872
|
+
const dismissChatForLayout = useCallback(() => {
|
|
873
|
+
if (embedAsPage) {
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
layoutDismissedRef.current = true;
|
|
877
|
+
openedShellChatRef.current = false;
|
|
878
|
+
setIsOpen(false);
|
|
879
|
+
setChatPanelOpen(false);
|
|
880
|
+
removeSearchParams(CHAT_QUERY_PARAM);
|
|
881
|
+
}, [embedAsPage, removeSearchParams, setChatPanelOpen]);
|
|
882
|
+
|
|
883
|
+
useEvent({
|
|
884
|
+
event: DISMISS_CHAT_FOR_LAYOUT_EVENT,
|
|
885
|
+
callback: dismissChatForLayout,
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
/** `?chat=open` in URL — open local UI + shell (skip right after nav evicted chat). */
|
|
854
889
|
useEffect(() => {
|
|
855
890
|
if (embedAsPage) {
|
|
856
891
|
return;
|
|
857
892
|
}
|
|
858
893
|
if (!chatOpen) {
|
|
859
|
-
|
|
860
|
-
setChatPanelOpen(false);
|
|
894
|
+
layoutDismissedRef.current = false;
|
|
861
895
|
return;
|
|
862
896
|
}
|
|
863
|
-
if (!
|
|
864
|
-
removeSearchParams(CHAT_QUERY_PARAM);
|
|
897
|
+
if (!shouldOpenChatFromUrl(chatOpen, layoutDismissedRef.current)) {
|
|
865
898
|
return;
|
|
866
899
|
}
|
|
867
900
|
setIsOpen(true);
|
|
901
|
+
openedShellChatRef.current = true;
|
|
868
902
|
setChatPanelOpen(true);
|
|
869
|
-
}, [
|
|
870
|
-
embedAsPage,
|
|
871
|
-
chatOpen,
|
|
872
|
-
setChatPanelOpen,
|
|
873
|
-
sidebarNavOpen,
|
|
874
|
-
shellChatPanelOpen,
|
|
875
|
-
removeSearchParams,
|
|
876
|
-
]);
|
|
903
|
+
}, [embedAsPage, chatOpen, setChatPanelOpen]);
|
|
877
904
|
|
|
878
|
-
/**
|
|
905
|
+
/** Heal desync: `?chat=open` but shell collapsed while this instance still wants panel open. */
|
|
879
906
|
useEffect(() => {
|
|
880
|
-
if (embedAsPage
|
|
907
|
+
if (embedAsPage) {
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
if (
|
|
911
|
+
!shouldHealChatShellDesync(
|
|
912
|
+
chatOpen,
|
|
913
|
+
shellChatPanelOpen,
|
|
914
|
+
layoutDismissedRef.current,
|
|
915
|
+
isOpen,
|
|
916
|
+
)
|
|
917
|
+
) {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
layoutDismissedRef.current = false;
|
|
921
|
+
if (!isOpen) {
|
|
922
|
+
setIsOpen(true);
|
|
923
|
+
}
|
|
924
|
+
openedShellChatRef.current = true;
|
|
925
|
+
setChatPanelOpen(true);
|
|
926
|
+
}, [embedAsPage, chatOpen, isOpen, shellChatPanelOpen, setChatPanelOpen]);
|
|
927
|
+
|
|
928
|
+
/** Stale local open after shell closed without `?chat=` (e.g. another instance or aborted open). */
|
|
929
|
+
useEffect(() => {
|
|
930
|
+
if (embedAsPage) {
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
if (!shouldCloseStaleLocalChatOpen(shellChatPanelOpen, chatOpen, isOpen)) {
|
|
881
934
|
return;
|
|
882
935
|
}
|
|
883
936
|
setIsOpen(false);
|
|
884
|
-
|
|
885
|
-
}, [embedAsPage, shellChatPanelOpen, isOpen, removeSearchParams]);
|
|
937
|
+
}, [embedAsPage, shellChatPanelOpen, chatOpen, isOpen]);
|
|
886
938
|
|
|
887
|
-
/**
|
|
939
|
+
/** Shell chat closed while `?chat=` still set (fallback if layout event was missed). */
|
|
940
|
+
useEffect(() => {
|
|
941
|
+
if (embedAsPage) {
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
const wasShellChatOpen = prevShellChatPanelOpenRef.current;
|
|
945
|
+
prevShellChatPanelOpenRef.current = shellChatPanelOpen;
|
|
946
|
+
if (
|
|
947
|
+
!shouldDismissChatAfterShellClosed({
|
|
948
|
+
shellChatPanelOpen,
|
|
949
|
+
wasShellChatPanelOpen: wasShellChatOpen,
|
|
950
|
+
chatOpen,
|
|
951
|
+
isOpen,
|
|
952
|
+
openedShellChat: openedShellChatRef.current,
|
|
953
|
+
})
|
|
954
|
+
) {
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
dismissChatForLayout();
|
|
958
|
+
}, [embedAsPage, shellChatPanelOpen, chatOpen, dismissChatForLayout, isOpen]);
|
|
959
|
+
|
|
960
|
+
/** Route change: release shell slot only if this instance opened it (not Strict Mode remount). */
|
|
888
961
|
useEffect(() => {
|
|
889
962
|
return () => {
|
|
890
|
-
if (!embedAsPage) {
|
|
963
|
+
if (!embedAsPage && openedShellChatRef.current && !chatOpen) {
|
|
964
|
+
openedShellChatRef.current = false;
|
|
891
965
|
setChatPanelOpen(false);
|
|
892
966
|
}
|
|
893
967
|
};
|
|
894
|
-
}, [embedAsPage, setChatPanelOpen]);
|
|
968
|
+
}, [embedAsPage, chatOpen, setChatPanelOpen]);
|
|
895
969
|
|
|
896
970
|
useEffect(() => {
|
|
897
971
|
if (embedAsPage) {
|
|
898
972
|
return;
|
|
899
973
|
}
|
|
900
|
-
if (!
|
|
974
|
+
if (!shellChatPanelOpen || !sidebarNavOpen) return;
|
|
901
975
|
|
|
902
976
|
const collapseNavIfNoSpace = () => {
|
|
903
977
|
const shellW = getShellWidth();
|
|
@@ -919,7 +993,7 @@ export function useChatPanelChromeModel({
|
|
|
919
993
|
return () => window.removeEventListener('resize', collapseNavIfNoSpace);
|
|
920
994
|
}, [
|
|
921
995
|
embedAsPage,
|
|
922
|
-
|
|
996
|
+
shellChatPanelOpen,
|
|
923
997
|
sidebarNavOpen,
|
|
924
998
|
setSidebarNavOpen,
|
|
925
999
|
getShellWidth,
|
|
@@ -1096,7 +1170,13 @@ export function useChatPanelChromeModel({
|
|
|
1096
1170
|
onSlashItemCommand,
|
|
1097
1171
|
};
|
|
1098
1172
|
|
|
1099
|
-
const toggleOpen = () =>
|
|
1173
|
+
const toggleOpen = () => {
|
|
1174
|
+
if (!isChatPanelVisible(isOpen, shellChatPanelOpen)) {
|
|
1175
|
+
onOpenChange(true);
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
onOpenChange(false);
|
|
1179
|
+
};
|
|
1100
1180
|
|
|
1101
1181
|
return {
|
|
1102
1182
|
chromeProps,
|
|
@@ -52,6 +52,9 @@ const SIDEBAR_WIDTH_MOBILE = '18rem';
|
|
|
52
52
|
const SIDEBAR_WIDTH_ICON = '3rem';
|
|
53
53
|
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
|
|
54
54
|
|
|
55
|
+
/** Fired when nav open closes chat for layout; ChatSheet clears `?chat=` so URL sync does not reopen chat. */
|
|
56
|
+
export const DISMISS_CHAT_FOR_LAYOUT_EVENT = 'sybilion:dismiss-chat-for-layout';
|
|
57
|
+
|
|
55
58
|
function isEditableKeyboardTarget(el: Element | null): boolean {
|
|
56
59
|
if (!el) return false;
|
|
57
60
|
if (el instanceof HTMLElement && el.isContentEditable) return true;
|
|
@@ -325,6 +328,9 @@ function SidebarProvider({
|
|
|
325
328
|
if (shellFitsSidebarsLayout(shellW, layoutAfterOpen)) return;
|
|
326
329
|
if (openingMain && chatPanelOpen) {
|
|
327
330
|
_setChatPanelOpen(false);
|
|
331
|
+
if (typeof window !== 'undefined') {
|
|
332
|
+
window.dispatchEvent(new CustomEvent(DISMISS_CHAT_FOR_LAYOUT_EVENT));
|
|
333
|
+
}
|
|
328
334
|
} else if (openingChat && isOpen) {
|
|
329
335
|
setIsOpen(false);
|
|
330
336
|
if (getCookiePreferences(userId)?.functional) {
|
|
@@ -10,7 +10,7 @@ Host provides:
|
|
|
10
10
|
- `payload`: BacktestsComponentPayload from platform SDK (host fetch per analysis)
|
|
11
11
|
- Optional `datasetHistorical` overlay
|
|
12
12
|
- `seriesInitKey` when selected analysis changes
|
|
13
|
-
- `viewTab` / `onViewTabChange`: `
|
|
13
|
+
- `viewTab` / `onViewTabChange`: `lagged` (calendar-aligned, raw dates) or `overlapped` (driver series shifted backward by parsed lag months)
|
|
14
14
|
|
|
15
15
|
View tabs: host should render uilib `Tabs variant="button"` with **Lagged** / **Overlapped** in the toolbar (analysis selector left, tabs right). Chart applies `applyDriversComparisonViewToPayload` internally.
|
|
16
16
|
|
|
@@ -18,8 +18,8 @@ Report tile: `drivers_comparison_chart` — host loads normalized backtests payl
|
|
|
18
18
|
|
|
19
19
|
Requires: `payload` — target + driver normalized_series; `loading` / `chartLoading` — spinners; `seriesInitKey` — reset visible series on analysis or view tab change; `runAnalysisHint` / `statusHint` — empty/error text.
|
|
20
20
|
|
|
21
|
-
Lag column: **
|
|
21
|
+
Lag column: **Lagged** shows API `lag` string (may be a range). **Overlapped** shows single `N month(s)` from `parseLagMonthsFromLabel` (range uses max month).
|
|
22
22
|
|
|
23
|
-
Historical window: lead-in is anchored to the **
|
|
23
|
+
Historical window: lead-in is anchored to the **lagged** (unshifted) view (`computeDriversComparisonHistoricalWindowFloor`); overlapped tab shifts driver lines backward only — switching tabs does not change historical extent when floor is pinned.
|
|
24
24
|
|
|
25
25
|
Empty/loading: loading props shimmer chart; null `payload` with `runAnalysisHint` prompts to run analysis.
|
|
@@ -160,10 +160,10 @@ export function DriversComparisonChart({
|
|
|
160
160
|
);
|
|
161
161
|
|
|
162
162
|
const historicalWindowFloor = useMemo(() => {
|
|
163
|
-
const
|
|
163
|
+
const laggedMerged = mergeBacktestsChartData(payload);
|
|
164
164
|
const merged = mergeDatasetHistoricalWithBacktestsChartData(
|
|
165
165
|
datasetHistorical,
|
|
166
|
-
|
|
166
|
+
laggedMerged,
|
|
167
167
|
);
|
|
168
168
|
const sortedDrivers = [...(payload?.drivers ?? [])]
|
|
169
169
|
.filter(
|
package/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.test.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
mergeDatasetHistoricalWithBacktestsChartData,
|
|
13
13
|
parseLagMonthsFromLabel,
|
|
14
14
|
resolveDriverLagLabel,
|
|
15
|
+
shiftNormalizedSeriesBackward,
|
|
15
16
|
shiftNormalizedSeriesForward,
|
|
16
17
|
subtractMonthsFromMonthStart,
|
|
17
18
|
} from './driversComparisonChart.helpers';
|
|
@@ -58,17 +59,17 @@ describe('formatLagMonthsLabel', () => {
|
|
|
58
59
|
});
|
|
59
60
|
|
|
60
61
|
describe('getLagDisplayForView', () => {
|
|
61
|
-
it('shows API lag on
|
|
62
|
-
expect(getLagDisplayForView('9 to 12 month(s)', '
|
|
62
|
+
it('shows API lag on lagged tab', () => {
|
|
63
|
+
expect(getLagDisplayForView('9 to 12 month(s)', 'lagged')).toBe(
|
|
63
64
|
'9 to 12 month(s)',
|
|
64
65
|
);
|
|
65
66
|
});
|
|
66
67
|
|
|
67
|
-
it('shows parsed month on
|
|
68
|
-
expect(getLagDisplayForView('9 to 12 month(s)', '
|
|
68
|
+
it('shows parsed month on overlapped tab', () => {
|
|
69
|
+
expect(getLagDisplayForView('9 to 12 month(s)', 'overlapped')).toBe(
|
|
69
70
|
'12 month(s)',
|
|
70
71
|
);
|
|
71
|
-
expect(getLagDisplayForView('Unknown', '
|
|
72
|
+
expect(getLagDisplayForView('Unknown', 'overlapped')).toBe('—');
|
|
72
73
|
});
|
|
73
74
|
});
|
|
74
75
|
|
|
@@ -90,6 +91,24 @@ describe('shiftNormalizedSeriesForward', () => {
|
|
|
90
91
|
});
|
|
91
92
|
});
|
|
92
93
|
|
|
94
|
+
describe('shiftNormalizedSeriesBackward', () => {
|
|
95
|
+
it('moves points backward by lag months', () => {
|
|
96
|
+
const shifted = shiftNormalizedSeriesBackward(
|
|
97
|
+
{ '2015-03-01': 1.2, '2015-04-01': 1.4 },
|
|
98
|
+
2,
|
|
99
|
+
);
|
|
100
|
+
expect(shifted['2015-01-01']).toBe(1.2);
|
|
101
|
+
expect(shifted['2015-02-01']).toBe(1.4);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('returns a shallow copy when lag is zero', () => {
|
|
105
|
+
const series = { '2015-01-01': 1 };
|
|
106
|
+
const shifted = shiftNormalizedSeriesBackward(series, 0);
|
|
107
|
+
expect(shifted).toEqual(series);
|
|
108
|
+
expect(shifted).not.toBe(series);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
93
112
|
describe('applyDriversComparisonViewToPayload', () => {
|
|
94
113
|
const payload = {
|
|
95
114
|
target: {
|
|
@@ -107,18 +126,19 @@ describe('applyDriversComparisonViewToPayload', () => {
|
|
|
107
126
|
],
|
|
108
127
|
};
|
|
109
128
|
|
|
110
|
-
it('returns original payload for
|
|
111
|
-
expect(applyDriversComparisonViewToPayload(payload, '
|
|
112
|
-
payload,
|
|
113
|
-
);
|
|
129
|
+
it('returns original payload for lagged tab', () => {
|
|
130
|
+
expect(applyDriversComparisonViewToPayload(payload, 'lagged')).toBe(payload);
|
|
114
131
|
});
|
|
115
132
|
|
|
116
|
-
it('shifts driver series for
|
|
117
|
-
const
|
|
118
|
-
|
|
133
|
+
it('shifts driver series backward for overlapped tab without mutating source', () => {
|
|
134
|
+
const overlapped = applyDriversComparisonViewToPayload(
|
|
135
|
+
payload,
|
|
136
|
+
'overlapped',
|
|
137
|
+
);
|
|
138
|
+
expect(overlapped).not.toBe(payload);
|
|
119
139
|
expect(payload.drivers[0].normalized_series['2015-01-01']).toBe(0.5);
|
|
120
|
-
expect(
|
|
121
|
-
expect(
|
|
140
|
+
expect(overlapped?.drivers[0].normalized_series['2014-11-01']).toBe(0.5);
|
|
141
|
+
expect(overlapped?.target.normalized_series).toEqual(
|
|
122
142
|
payload.target.normalized_series,
|
|
123
143
|
);
|
|
124
144
|
});
|
|
@@ -135,11 +155,11 @@ describe('applyDriversComparisonViewToPayload', () => {
|
|
|
135
155
|
},
|
|
136
156
|
],
|
|
137
157
|
};
|
|
138
|
-
const
|
|
158
|
+
const overlapped = applyDriversComparisonViewToPayload(
|
|
139
159
|
withOverallLag,
|
|
140
|
-
'
|
|
160
|
+
'overlapped',
|
|
141
161
|
);
|
|
142
|
-
expect(
|
|
162
|
+
expect(overlapped?.drivers[0].normalized_series['2015-05-01']).toBe(0.8);
|
|
143
163
|
});
|
|
144
164
|
});
|
|
145
165
|
|
|
@@ -184,7 +204,7 @@ describe('computeDriversComparisonHistoricalWindowFloor', () => {
|
|
|
184
204
|
|
|
185
205
|
const forecastIds = [DRIVER_FORECAST_ID_BASE];
|
|
186
206
|
|
|
187
|
-
it('returns anchor minus lead months from
|
|
207
|
+
it('returns anchor minus lead months from lagged (unshifted) merged data', () => {
|
|
188
208
|
const merged = mergeDatasetHistoricalWithBacktestsChartData(
|
|
189
209
|
datasetHistorical,
|
|
190
210
|
mergeBacktestsChartData(laggedDriverPayload),
|
|
@@ -245,70 +265,70 @@ describe('buildDriversComparisonChartData historical window floor', () => {
|
|
|
245
265
|
const forecastIds = [DRIVER_FORECAST_ID_BASE];
|
|
246
266
|
|
|
247
267
|
it('keeps same xMin for lagged and overlapped when floor is pinned', () => {
|
|
248
|
-
const
|
|
268
|
+
const laggedMerged = mergeDatasetHistoricalWithBacktestsChartData(
|
|
249
269
|
datasetHistorical,
|
|
250
270
|
mergeBacktestsChartData(laggedDriverPayload),
|
|
251
271
|
);
|
|
252
|
-
const
|
|
272
|
+
const overlappedPayload = applyDriversComparisonViewToPayload(
|
|
253
273
|
laggedDriverPayload,
|
|
254
|
-
'
|
|
274
|
+
'overlapped',
|
|
255
275
|
);
|
|
256
|
-
const
|
|
276
|
+
const overlappedMerged = mergeDatasetHistoricalWithBacktestsChartData(
|
|
257
277
|
datasetHistorical,
|
|
258
|
-
mergeBacktestsChartData(
|
|
278
|
+
mergeBacktestsChartData(overlappedPayload),
|
|
259
279
|
);
|
|
260
280
|
const floor = computeDriversComparisonHistoricalWindowFloor(
|
|
261
|
-
|
|
281
|
+
laggedMerged,
|
|
262
282
|
forecastIds,
|
|
263
283
|
)!;
|
|
264
284
|
|
|
265
|
-
const
|
|
266
|
-
|
|
285
|
+
const laggedChart = buildDriversComparisonChartData(
|
|
286
|
+
laggedMerged,
|
|
267
287
|
datasetHistorical,
|
|
268
288
|
forecastIds,
|
|
269
289
|
floor,
|
|
270
290
|
);
|
|
271
|
-
const
|
|
272
|
-
|
|
291
|
+
const overlappedChart = buildDriversComparisonChartData(
|
|
292
|
+
overlappedMerged,
|
|
273
293
|
datasetHistorical,
|
|
274
294
|
forecastIds,
|
|
275
295
|
floor,
|
|
276
296
|
);
|
|
277
297
|
|
|
278
|
-
expect(overlappedChart[0]?.date).toBe('2014-10-01');
|
|
279
298
|
expect(laggedChart[0]?.date).toBe('2014-10-01');
|
|
280
|
-
expect(
|
|
299
|
+
expect(overlappedChart[0]?.date).toBe('2014-10-01');
|
|
300
|
+
expect(overlappedChart[0]?.date).toBe(laggedChart[0]?.date);
|
|
281
301
|
});
|
|
282
302
|
|
|
283
|
-
it('without floor override
|
|
284
|
-
const
|
|
303
|
+
it('without floor override overlapped chart starts earlier than lagged', () => {
|
|
304
|
+
const laggedMerged = mergeDatasetHistoricalWithBacktestsChartData(
|
|
285
305
|
datasetHistorical,
|
|
286
306
|
mergeBacktestsChartData(laggedDriverPayload),
|
|
287
307
|
);
|
|
288
|
-
const
|
|
308
|
+
const overlappedPayload = applyDriversComparisonViewToPayload(
|
|
289
309
|
laggedDriverPayload,
|
|
290
|
-
'
|
|
310
|
+
'overlapped',
|
|
291
311
|
);
|
|
292
|
-
const
|
|
312
|
+
const overlappedMerged = mergeDatasetHistoricalWithBacktestsChartData(
|
|
293
313
|
datasetHistorical,
|
|
294
|
-
mergeBacktestsChartData(
|
|
314
|
+
mergeBacktestsChartData(overlappedPayload),
|
|
295
315
|
);
|
|
296
316
|
|
|
297
|
-
const
|
|
298
|
-
|
|
317
|
+
const laggedChart = buildDriversComparisonChartData(
|
|
318
|
+
laggedMerged,
|
|
299
319
|
datasetHistorical,
|
|
300
320
|
forecastIds,
|
|
301
321
|
);
|
|
302
|
-
const
|
|
303
|
-
|
|
322
|
+
const overlappedChart = buildDriversComparisonChartData(
|
|
323
|
+
overlappedMerged,
|
|
304
324
|
datasetHistorical,
|
|
305
325
|
forecastIds,
|
|
306
326
|
);
|
|
307
327
|
|
|
308
|
-
expect(
|
|
309
|
-
expect(
|
|
310
|
-
expect(
|
|
311
|
-
|
|
312
|
-
);
|
|
328
|
+
expect(laggedChart[0]?.date).toBe('2014-10-01');
|
|
329
|
+
expect(overlappedChart[0]?.date).toBe('2014-07-01');
|
|
330
|
+
expect(
|
|
331
|
+
overlappedChart[0]?.date!.localeCompare(laggedChart[0]?.date!),
|
|
332
|
+
).toBe(-1);
|
|
313
333
|
});
|
|
314
334
|
});
|
|
@@ -78,7 +78,7 @@ export function getLagDisplayForView(
|
|
|
78
78
|
lag: string | null | undefined,
|
|
79
79
|
tab: DriversComparisonViewTab,
|
|
80
80
|
): string {
|
|
81
|
-
if (tab === '
|
|
81
|
+
if (tab === 'lagged') {
|
|
82
82
|
return lag?.trim() ? lag : '—';
|
|
83
83
|
}
|
|
84
84
|
const months = parseLagMonthsFromLabel(lag);
|
|
@@ -102,11 +102,29 @@ export function shiftNormalizedSeriesForward(
|
|
|
102
102
|
return out;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/** Shift driver points left (earlier months) by lag — overlapped alignment vs target. */
|
|
106
|
+
export function shiftNormalizedSeriesBackward(
|
|
107
|
+
series: Record<string, number | null>,
|
|
108
|
+
lagMonths: number,
|
|
109
|
+
): Record<string, number | null> {
|
|
110
|
+
if (lagMonths <= 0) return { ...series };
|
|
111
|
+
const out: Record<string, number | null> = {};
|
|
112
|
+
for (const [dateStr, val] of Object.entries(series)) {
|
|
113
|
+
if (val === null || val === undefined) continue;
|
|
114
|
+
let shifted = normalizeToMonthStart(dateStr);
|
|
115
|
+
for (let i = 0; i < lagMonths; i++) {
|
|
116
|
+
shifted = getPreviousMonth(shifted);
|
|
117
|
+
}
|
|
118
|
+
out[shifted] = val;
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
105
123
|
export function applyDriversComparisonViewToPayload(
|
|
106
124
|
payload: BacktestsComponentPayload | null,
|
|
107
125
|
tab: DriversComparisonViewTab,
|
|
108
126
|
): BacktestsComponentPayload | null {
|
|
109
|
-
if (!payload || tab === '
|
|
127
|
+
if (!payload || tab === 'lagged') return payload;
|
|
110
128
|
|
|
111
129
|
return {
|
|
112
130
|
target: {
|
|
@@ -118,7 +136,7 @@ export function applyDriversComparisonViewToPayload(
|
|
|
118
136
|
const series = driver.normalized_series ?? {};
|
|
119
137
|
const normalized_series =
|
|
120
138
|
lagMonths != null && lagMonths > 0
|
|
121
|
-
?
|
|
139
|
+
? shiftNormalizedSeriesBackward(series, lagMonths)
|
|
122
140
|
: { ...series };
|
|
123
141
|
return {
|
|
124
142
|
...driver,
|
|
@@ -290,7 +308,7 @@ export function prependHistoricalLeadFromDataset(
|
|
|
290
308
|
return [...lead, ...points];
|
|
291
309
|
}
|
|
292
310
|
|
|
293
|
-
/**
|
|
311
|
+
/** Lagged (unshifted) anchor minus lead months — stable xMin across lagged/overlapped tabs. */
|
|
294
312
|
export function computeDriversComparisonHistoricalWindowFloor(
|
|
295
313
|
mergedWithHistorical: ChartDataPoint[],
|
|
296
314
|
forecastIds: number[],
|