@sybilion/uilib 1.3.57 → 1.3.59

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.
@@ -5,6 +5,7 @@ import { isGraphIntakeAssistantStepComplete, matchUserTextToQuickReply, isPreset
5
5
  import { buildChatSendMessagePayload, displayTextFromSendPayload } from '../buildChatSendMessagePayload.js';
6
6
  import { usedPresetIdsFromMessages } from '../chat-preset-utils.js';
7
7
  import { useChatsForScopeId, useChat, useChatOutboundPending, useSyncChatPanelBusy, isChatEmpty } from '../../../../contexts/chat-context.js';
8
+ import { shellFitsSidebarsLayout } from '../../../../hooks/panelWidth.js';
8
9
  import useEvent from '../../../../hooks/useEvent.js';
9
10
  import { useIsMobile } from '../../../../hooks/useIsMobile.js';
10
11
  import { useQueryParams } from '../../../../hooks/useQueryParams.js';
@@ -17,15 +18,13 @@ import { Chat } from '../Chat.js';
17
18
  const NO_SCOPE_FALLBACK = '__uilib_chat_scope_unset__';
18
19
  const SCRIPT_STEP_DELAY_MS = 1200;
19
20
  const CHAT_NEW_SHORTCUT_KEY = 'o';
20
- /** When chat panel is open, collapse left nav below this width so both don't crowd the viewport. */
21
- const CHAT_NAV_COLLAPSE_BREAKPOINT_PX = 1400;
22
21
  const CHAT_QUERY_PARAM = 'chat';
23
22
  const CHAT_OPEN_VALUE = 'open';
24
23
  const PROMPT_QUERY_PARAM = 'prompt';
25
24
  function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onScriptComplete, renderMessageChart, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, slashCommandItems, onSlashItemCommand, transformSendPayload, }) {
26
25
  const effectiveScopeId = scopeId ?? NO_SCOPE_FALLBACK;
27
26
  const isMobile = useIsMobile();
28
- const { chatPanelContainer, isOpen: sidebarNavOpen, setOpen: setSidebarNavOpen, chatWidthPx, setChatWidthPx, getShellWidth, setChatPanelOpen, } = useSidebar();
27
+ const { chatPanelContainer, isOpen: sidebarNavOpen, setOpen: setSidebarNavOpen, sidebarWidthPx, chatWidthPx, setChatWidthPx, getShellWidth, chatPanelOpen: shellChatPanelOpen, setChatPanelOpen, } = useSidebar();
29
28
  const [localUiBusy, setLocalUiBusy] = useState(false);
30
29
  const { chats, currentChatId, setCurrentChatId, newChat, sendMessage, addMessage, removeMessageById, } = useChatsForScopeId(effectiveScopeId);
31
30
  const chat = useChat(effectiveScopeId, currentChatId);
@@ -634,6 +633,14 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
634
633
  setIsOpen(chatOpen);
635
634
  setChatPanelOpen(chatOpen);
636
635
  }, [embedAsPage, chatOpen, setChatPanelOpen]);
636
+ /** Shell closed chat for space (e.g. nav opened); keep UI and URL in sync. */
637
+ useEffect(() => {
638
+ if (embedAsPage || shellChatPanelOpen || !isOpen) {
639
+ return;
640
+ }
641
+ setIsOpen(false);
642
+ removeSearchParams(CHAT_QUERY_PARAM);
643
+ }, [embedAsPage, shellChatPanelOpen, isOpen, removeSearchParams]);
637
644
  /** When this instance unmounts (e.g. route hides ChatSheet), release shell layout — URL sync alone does not run. */
638
645
  useEffect(() => {
639
646
  return () => {
@@ -648,16 +655,30 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
648
655
  }
649
656
  if (!isOpen || !sidebarNavOpen)
650
657
  return;
651
- const collapseNavIfNarrow = () => {
652
- if (window.innerWidth < CHAT_NAV_COLLAPSE_BREAKPOINT_PX) {
658
+ const collapseNavIfNoSpace = () => {
659
+ const shellW = getShellWidth();
660
+ if (!shellFitsSidebarsLayout(shellW, {
661
+ mainSidebarOpen: true,
662
+ chatPanelOpen: true,
663
+ sidebarWidthPx,
664
+ chatWidthPx,
665
+ })) {
653
666
  // Chat open uses `startViewTransition`; nested transition here aborts first → AbortError overlay.
654
667
  setSidebarNavOpen(false, { viewTransition: false });
655
668
  }
656
669
  };
657
- collapseNavIfNarrow();
658
- window.addEventListener('resize', collapseNavIfNarrow);
659
- return () => window.removeEventListener('resize', collapseNavIfNarrow);
660
- }, [embedAsPage, isOpen, sidebarNavOpen, setSidebarNavOpen]);
670
+ collapseNavIfNoSpace();
671
+ window.addEventListener('resize', collapseNavIfNoSpace);
672
+ return () => window.removeEventListener('resize', collapseNavIfNoSpace);
673
+ }, [
674
+ embedAsPage,
675
+ isOpen,
676
+ sidebarNavOpen,
677
+ setSidebarNavOpen,
678
+ getShellWidth,
679
+ sidebarWidthPx,
680
+ chatWidthPx,
681
+ ]);
661
682
  const renderPresets = (layout = 'fixed') => {
662
683
  if (!presetsWithFreeform?.length)
663
684
  return null;
@@ -5,7 +5,7 @@ import { Button } from '../Button/Button.js';
5
5
  import { Separator } from '../Separator/Separator.js';
6
6
  import { Sheet, SheetContent, SheetTitle } from '../Sheet/Sheet.js';
7
7
  import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '../Tooltip/Tooltip.js';
8
- import { clampSidebarWidthPx, CHAT_WIDTH_STORAGE_KEY, SIDEBAR_WIDTH_DEFAULT_PX, SIDEBAR_WIDTH_MIN_PX, SIDEBAR_WIDTH_STORAGE_KEY_PX, SIDEBAR_WIDTH_STORAGE_KEY_PCT, CHAT_WIDTH_DEFAULT_PX, CHAT_WIDTH_MIN_PX, defaultChatWidthPx, clampChatWidthPx } from '../../../hooks/panelWidth.js';
8
+ import { clampSidebarWidthPx, CHAT_WIDTH_STORAGE_KEY, shellFitsSidebarsLayout, SIDEBAR_WIDTH_DEFAULT_PX, SIDEBAR_WIDTH_MIN_PX, SIDEBAR_WIDTH_STORAGE_KEY_PX, SIDEBAR_WIDTH_STORAGE_KEY_PCT, CHAT_WIDTH_DEFAULT_PX, CHAT_WIDTH_MIN_PX, defaultChatWidthPx, clampChatWidthPx } from '../../../hooks/panelWidth.js';
9
9
  import useElemDrag from '../../../hooks/useDragElem.js';
10
10
  import useEvent from '../../../hooks/useEvent.js';
11
11
  import { useIsSidebarSheetLayout } from '../../../hooks/useIsSidebarSheetLayout.js';
@@ -80,7 +80,7 @@ function SidebarProvider({ defaultOpen = getCookie('isSidebarOpen') ?? true, cla
80
80
  }, []);
81
81
  const [sidebarWidthPx, _setSidebarWidthPx] = useState(() => readInitialSidebarWidthPx(sidebarLsKey));
82
82
  const [chatWidthPx, _setChatWidthPx] = useState(readInitialChatWidthPx);
83
- const [chatPanelOpen, setChatPanelOpen] = useState(false);
83
+ const [chatPanelOpen, _setChatPanelOpen] = useState(false);
84
84
  const sidebarWidthRef = useRef(sidebarWidthPx);
85
85
  const chatWidthRef = useRef(chatWidthPx);
86
86
  sidebarWidthRef.current = sidebarWidthPx;
@@ -205,7 +205,46 @@ function SidebarProvider({ defaultOpen = getCookie('isSidebarOpen') ?? true, cla
205
205
  fitPanelWidthsToShell,
206
206
  ]);
207
207
  useShellWidthObserver(shellEl, handleShellResize);
208
+ const closeOppositeSidebarIfNoSpace = useCallback((openingMain, openingChat) => {
209
+ if (isSidebarSheetLayout)
210
+ return;
211
+ const shellW = getShellWidth();
212
+ const layoutAfterOpen = {
213
+ mainSidebarOpen: openingMain || isOpen,
214
+ chatPanelOpen: openingChat || chatPanelOpen,
215
+ sidebarWidthPx,
216
+ chatWidthPx,
217
+ };
218
+ if (shellFitsSidebarsLayout(shellW, layoutAfterOpen))
219
+ return;
220
+ if (openingMain && chatPanelOpen) {
221
+ _setChatPanelOpen(false);
222
+ }
223
+ else if (openingChat && isOpen) {
224
+ setIsOpen(false);
225
+ if (getCookiePreferences(userId)?.functional) {
226
+ setCookie('isSidebarOpen', 'false', 60 * 60 * 24 * 7);
227
+ }
228
+ }
229
+ }, [
230
+ isSidebarSheetLayout,
231
+ getShellWidth,
232
+ isOpen,
233
+ chatPanelOpen,
234
+ sidebarWidthPx,
235
+ chatWidthPx,
236
+ userId,
237
+ ]);
238
+ const setChatPanelOpen = useCallback((open) => {
239
+ if (open) {
240
+ closeOppositeSidebarIfNoSpace(false, true);
241
+ }
242
+ _setChatPanelOpen(open);
243
+ }, [closeOppositeSidebarIfNoSpace]);
208
244
  const setOpen = useCallback((value, options) => {
245
+ if (value) {
246
+ closeOppositeSidebarIfNoSpace(true, false);
247
+ }
209
248
  const useViewTransition = options?.viewTransition !== false &&
210
249
  !isSidebarSheetLayout &&
211
250
  'startViewTransition' in document &&
@@ -221,7 +260,7 @@ function SidebarProvider({ defaultOpen = getCookie('isSidebarOpen') ?? true, cla
221
260
  if (getCookiePreferences(userId)?.functional) {
222
261
  setCookie('isSidebarOpen', value.toString(), 60 * 60 * 24 * 7);
223
262
  }
224
- }, [isSidebarSheetLayout, userId]);
263
+ }, [isSidebarSheetLayout, userId, closeOppositeSidebarIfNoSpace]);
225
264
  const toggleSidebar = useCallback(() => setOpen(!isOpen), [isOpen, setOpen]);
226
265
  useEffect(() => {
227
266
  const shell = wrapperRef.current;
@@ -28,7 +28,7 @@ import { Table, TableHeader, TableRow, TableHead, TableCellValue, TableBody, Tab
28
28
  import { TextShimmer } from '../../ui/TextShimmer/TextShimmer.js';
29
29
  import { TIME_RANGES } from '../../ui/TimeRangeControls/TimeRangeControls.constants.js';
30
30
  import S from './DriversComparisonChart.styl.js';
31
- import { applyDriversComparisonViewToPayload, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, DRIVER_FORECAST_ID_BASE, getLagDisplayForView, resolveDriverLagLabel, INITIAL_VISIBLE_SERIES_COUNT, buildDriversComparisonChartData, formatSeriesImportance } from './driversComparisonChart.helpers.js';
31
+ import { applyDriversComparisonViewToPayload, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, computeDriversComparisonHistoricalWindowFloor, DRIVER_FORECAST_ID_BASE, getLagDisplayForView, resolveDriverLagLabel, INITIAL_VISIBLE_SERIES_COUNT, buildDriversComparisonChartData, formatSeriesImportance } from './driversComparisonChart.helpers.js';
32
32
 
33
33
  const ALL_TIME_RANGE = TIME_RANGES[TIME_RANGES.length - 1];
34
34
  function toForecastDataKey(id) {
@@ -82,6 +82,16 @@ function DriversComparisonChart({ payload, datasetHistorical = [], loading = fal
82
82
  }, [payloadForView?.drivers]);
83
83
  const mergedChartData = useMemo(() => mergeBacktestsChartData(payloadForView), [payloadForView]);
84
84
  const mergedWithHistorical = useMemo(() => mergeDatasetHistoricalWithBacktestsChartData(datasetHistorical, mergedChartData), [datasetHistorical, mergedChartData]);
85
+ const historicalWindowFloor = useMemo(() => {
86
+ const overlappedMerged = mergeBacktestsChartData(payload);
87
+ const merged = mergeDatasetHistoricalWithBacktestsChartData(datasetHistorical, overlappedMerged);
88
+ const sortedDrivers = [...(payload?.drivers ?? [])]
89
+ .filter(d => d.normalized_series &&
90
+ Object.keys(d.normalized_series).some(key => d.normalized_series[key] != null))
91
+ .sort((a, b) => String(a.id).localeCompare(String(b.id)));
92
+ const forecastIds = sortedDrivers.map((_, idx) => DRIVER_FORECAST_ID_BASE + idx);
93
+ return computeDriversComparisonHistoricalWindowFloor(merged, forecastIds);
94
+ }, [payload, datasetHistorical]);
85
95
  const chartForecastData = useMemo(() => {
86
96
  if (!payloadForView?.target?.normalized_series)
87
97
  return [];
@@ -114,8 +124,7 @@ function DriversComparisonChart({ payload, datasetHistorical = [], loading = fal
114
124
  };
115
125
  });
116
126
  }, [originalDriversById, sortedDriversWithData, viewTab]);
117
- const seriesInitKeyResolved = seriesInitKey ??
118
- `${tableSeriesRows.map(r => r.id).join(',') || 'none'}`;
127
+ const seriesInitKeyResolved = seriesInitKey ?? `${tableSeriesRows.map(r => r.id).join(',') || 'none'}`;
119
128
  const backtestsSeriesInitKeyRef = useRef('');
120
129
  useEffect(() => {
121
130
  if (tableSeriesRows.length === 0)
@@ -135,7 +144,12 @@ function DriversComparisonChart({ payload, datasetHistorical = [], loading = fal
135
144
  return next;
136
145
  });
137
146
  }, [seriesInitKeyResolved, tableSeriesRows]);
138
- const driversComparisonChartData = useMemo(() => buildDriversComparisonChartData(mergedWithHistorical, datasetHistorical, chartForecastData.map(f => f.id)), [chartForecastData, datasetHistorical, mergedWithHistorical]);
147
+ const driversComparisonChartData = useMemo(() => buildDriversComparisonChartData(mergedWithHistorical, datasetHistorical, chartForecastData.map(f => f.id), historicalWindowFloor), [
148
+ chartForecastData,
149
+ datasetHistorical,
150
+ historicalWindowFloor,
151
+ mergedWithHistorical,
152
+ ]);
139
153
  const showEmptyOverlay = (runAnalysisHint || Boolean(statusHint)) && !loading;
140
154
  return (jsxs("div", { className: cn(S.root, className), children: [jsxs("div", { className: cn(S.chartShell, loading && S.chartShellLoading), children: [jsx("div", { className: S.chartSlot, children: jsxs("div", { className: S.chartWithOverlay, children: [jsx("div", { className: cn(S.chartInteractiveLayer, showEmptyOverlay && S.chartInteractiveDimmed), children: jsx(ChartAreaInteractive, { chartRenderId: "drivers-comparison", disableLineDrawAnimation: true, disableHistoricalAnimation: true, disableTimeRangeSelector: true, enableTimeRangeBrush: true, chartContainerClassName: S.chartContainer, chartData: driversComparisonChartData, forecastData: chartForecastData, timeRange: timeRange, onTimeRangeChange: handleTimeRangeChange, pinMonth: undefined, onPinMonthChange: () => { }, isDarkTheme: isDarkTheme, loading: chartLoading, hasCombinedData: mergedWithHistorical.length > 0, forecastLineStyle: "solid", showLegend: false, hiddenSeries: hiddenSeries, toggleLegendSeries: toggleSeries, ensureAnalysisSeriesVisible: showSeries }) }), showEmptyOverlay && (jsx("div", { className: S.chartEmptyOverlay, role: "status", "aria-live": "polite", children: jsx("div", { className: S.chartEmptyBlurb, children: jsx(ChartEmptyState, { variant: "inline", hint: runAnalysisHint
141
155
  ? 'Run a completed analysis to load the drivers comparison chart for an analysis.'
@@ -231,17 +231,26 @@ function prependHistoricalLeadFromDataset(points, datasetHistorical, xMin) {
231
231
  }
232
232
  return [...lead, ...points];
233
233
  }
234
- function buildDriversComparisonChartData(mergedWithHistorical, datasetHistorical, forecastIds) {
234
+ /** Overlapped (unshifted) anchor minus lead months — stable xMin across lagged/overlapped tabs. */
235
+ function computeDriversComparisonHistoricalWindowFloor(mergedWithHistorical, forecastIds) {
236
+ if (mergedWithHistorical.length === 0 || forecastIds.length === 0)
237
+ return null;
238
+ const { anchor: anchorMonth } = getZoomAnchorMonthFromDriverMaterialStarts(mergedWithHistorical, forecastIds);
239
+ if (anchorMonth === null)
240
+ return null;
241
+ return subtractMonthsFromMonthStart(anchorMonth, DRIVER_COMPARISON_CHART_LEAD_MONTHS);
242
+ }
243
+ function buildDriversComparisonChartData(mergedWithHistorical, datasetHistorical, forecastIds, historicalWindowFloor) {
235
244
  if (mergedWithHistorical.length === 0)
236
245
  return mergedWithHistorical;
237
246
  if (forecastIds.length === 0)
238
247
  return mergedWithHistorical;
239
- const { anchor: anchorMonth } = getZoomAnchorMonthFromDriverMaterialStarts(mergedWithHistorical, forecastIds);
240
- if (anchorMonth === null)
248
+ const xMin = historicalWindowFloor ??
249
+ computeDriversComparisonHistoricalWindowFloor(mergedWithHistorical, forecastIds);
250
+ if (xMin === null)
241
251
  return mergedWithHistorical;
242
- const xMin = subtractMonthsFromMonthStart(anchorMonth, DRIVER_COMPARISON_CHART_LEAD_MONTHS);
243
252
  const withLead = prependHistoricalLeadFromDataset(mergedWithHistorical, datasetHistorical, xMin);
244
253
  return withLead.filter(p => normalizeToMonthStart(p.date) >= xMin);
245
254
  }
246
255
 
247
- export { DRIVER_COMPARISON_CHART_LEAD_MONTHS, DRIVER_FORECAST_ID_BASE, INITIAL_VISIBLE_SERIES_COUNT, applyDriversComparisonViewToPayload, buildDriversComparisonChartData, formatLagMonthsLabel, formatSeriesImportance, getLagDisplayForView, getZoomAnchorMonthFromDriverMaterialStarts, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, parseLagMonthsFromLabel, prependHistoricalLeadFromDataset, resolveDriverLagLabel, shiftNormalizedSeriesForward, subtractMonthsFromMonthStart };
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 };
@@ -46,5 +46,20 @@ function clampChatWidthPx(px, shellWidth, effectiveSidebarWidthPx) {
46
46
  function defaultChatWidthPx(shellWidth) {
47
47
  return clampChatWidthPx(CHAT_WIDTH_DEFAULT_PX, shellWidth, SIDEBAR_WIDTH_DEFAULT_PX);
48
48
  }
49
+ /** Minimum shell width when nav and/or chat panels are open (main column at least `CENTRAL_AREA_MIN_PX`). */
50
+ function requiredShellWidthForSidebars(widths) {
51
+ const sidebarW = widths.mainSidebarOpen
52
+ ? widths.sidebarWidthPx || SIDEBAR_WIDTH_MIN_PX
53
+ : 0;
54
+ const chatW = widths.chatPanelOpen
55
+ ? widths.chatWidthPx || CHAT_WIDTH_MIN_PX
56
+ : 0;
57
+ return sidebarW + CENTRAL_AREA_MIN_PX + chatW;
58
+ }
59
+ function shellFitsSidebarsLayout(shellWidth, widths) {
60
+ if (shellWidth <= 0)
61
+ return true;
62
+ return shellWidth >= requiredShellWidthForSidebars(widths);
63
+ }
49
64
 
50
- export { CENTRAL_AREA_MIN_PX, CHAT_WIDTH_ABS_MAX_PX, CHAT_WIDTH_DEFAULT_PX, CHAT_WIDTH_MIN_PX, CHAT_WIDTH_STORAGE_KEY, SHELL_MAX_FRACTION, SIDEBAR_SHEET_LAYOUT_MAX_WIDTH_PX, SIDEBAR_SHEET_SPLIT_MIN_VIEWPORT_PX, SIDEBAR_WIDTH_DEFAULT_PX, SIDEBAR_WIDTH_MIN_PX, SIDEBAR_WIDTH_STORAGE_KEY_PCT, SIDEBAR_WIDTH_STORAGE_KEY_PX, clampChatWidthPx, clampSidebarWidthPx, defaultChatWidthPx, maxChatWidthPx, maxSidebarWidthPx };
65
+ export { CENTRAL_AREA_MIN_PX, CHAT_WIDTH_ABS_MAX_PX, CHAT_WIDTH_DEFAULT_PX, CHAT_WIDTH_MIN_PX, CHAT_WIDTH_STORAGE_KEY, SHELL_MAX_FRACTION, SIDEBAR_SHEET_LAYOUT_MAX_WIDTH_PX, SIDEBAR_SHEET_SPLIT_MIN_VIEWPORT_PX, SIDEBAR_WIDTH_DEFAULT_PX, SIDEBAR_WIDTH_MIN_PX, SIDEBAR_WIDTH_STORAGE_KEY_PCT, SIDEBAR_WIDTH_STORAGE_KEY_PX, clampChatWidthPx, clampSidebarWidthPx, defaultChatWidthPx, maxChatWidthPx, maxSidebarWidthPx, requiredShellWidthForSidebars, shellFitsSidebarsLayout };
@@ -35,4 +35,6 @@ export declare function subtractMonthsFromMonthStart(date: string, count: number
35
35
  * (scaled to the first normalized point) so the lead-in shows the target line alone.
36
36
  */
37
37
  export declare function prependHistoricalLeadFromDataset(points: ChartDataPoint[], datasetHistorical: ChartDataPoint[], xMin: string): ChartDataPoint[];
38
- export declare function buildDriversComparisonChartData(mergedWithHistorical: ChartDataPoint[], datasetHistorical: ChartDataPoint[], forecastIds: number[]): ChartDataPoint[];
38
+ /** Overlapped (unshifted) anchor minus lead months stable xMin across lagged/overlapped tabs. */
39
+ export declare function computeDriversComparisonHistoricalWindowFloor(mergedWithHistorical: ChartDataPoint[], forecastIds: number[]): string | null;
40
+ export declare function buildDriversComparisonChartData(mergedWithHistorical: ChartDataPoint[], datasetHistorical: ChartDataPoint[], forecastIds: number[], historicalWindowFloor?: string | null): ChartDataPoint[];
@@ -22,3 +22,12 @@ export declare function clampSidebarWidthPx(px: number, shellWidth: number, opts
22
22
  /** Width that reduces the main column when the nav sidebar is expanded; use 0 when collapsed. */
23
23
  export declare function clampChatWidthPx(px: number, shellWidth: number, effectiveSidebarWidthPx: number): number;
24
24
  export declare function defaultChatWidthPx(shellWidth: number): number;
25
+ export type SidebarsLayoutWidths = {
26
+ mainSidebarOpen: boolean;
27
+ chatPanelOpen: boolean;
28
+ sidebarWidthPx: number;
29
+ chatWidthPx: number;
30
+ };
31
+ /** Minimum shell width when nav and/or chat panels are open (main column at least `CENTRAL_AREA_MIN_PX`). */
32
+ export declare function requiredShellWidthForSidebars(widths: SidebarsLayoutWidths): number;
33
+ export declare function shellFitsSidebarsLayout(shellWidth: number, widths: SidebarsLayoutWidths): boolean;
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sybilion/uilib",
3
- "version": "1.3.57",
3
+ "version": "1.3.59",
4
4
  "description": "Sybilion Design System — React UI components (Webpack + Stylus)",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -31,6 +31,7 @@ import {
31
31
  useChatsForScopeId,
32
32
  useSyncChatPanelBusy,
33
33
  } from '#uilib/contexts/chat-context';
34
+ import { shellFitsSidebarsLayout } from '#uilib/hooks/panelWidth';
34
35
  import useEvent from '#uilib/hooks/useEvent';
35
36
  import { useIsMobile } from '#uilib/hooks/useIsMobile';
36
37
  import { useQueryParams } from '#uilib/hooks/useQueryParams';
@@ -114,8 +115,6 @@ type PresetScriptState = {
114
115
 
115
116
  const SCRIPT_STEP_DELAY_MS = 1200;
116
117
  const CHAT_NEW_SHORTCUT_KEY = 'o';
117
- /** When chat panel is open, collapse left nav below this width so both don't crowd the viewport. */
118
- const CHAT_NAV_COLLAPSE_BREAKPOINT_PX = 1400;
119
118
 
120
119
  const CHAT_QUERY_PARAM = 'chat';
121
120
  const CHAT_OPEN_VALUE = 'open';
@@ -142,9 +141,11 @@ export function useChatPanelChromeModel({
142
141
  chatPanelContainer,
143
142
  isOpen: sidebarNavOpen,
144
143
  setOpen: setSidebarNavOpen,
144
+ sidebarWidthPx,
145
145
  chatWidthPx,
146
146
  setChatWidthPx,
147
147
  getShellWidth,
148
+ chatPanelOpen: shellChatPanelOpen,
148
149
  setChatPanelOpen,
149
150
  } = useSidebar();
150
151
  const [localUiBusy, setLocalUiBusy] = useState(false);
@@ -859,6 +860,15 @@ export function useChatPanelChromeModel({
859
860
  setChatPanelOpen(chatOpen);
860
861
  }, [embedAsPage, chatOpen, setChatPanelOpen]);
861
862
 
863
+ /** Shell closed chat for space (e.g. nav opened); keep UI and URL in sync. */
864
+ useEffect(() => {
865
+ if (embedAsPage || shellChatPanelOpen || !isOpen) {
866
+ return;
867
+ }
868
+ setIsOpen(false);
869
+ removeSearchParams(CHAT_QUERY_PARAM);
870
+ }, [embedAsPage, shellChatPanelOpen, isOpen, removeSearchParams]);
871
+
862
872
  /** When this instance unmounts (e.g. route hides ChatSheet), release shell layout — URL sync alone does not run. */
863
873
  useEffect(() => {
864
874
  return () => {
@@ -874,17 +884,33 @@ export function useChatPanelChromeModel({
874
884
  }
875
885
  if (!isOpen || !sidebarNavOpen) return;
876
886
 
877
- const collapseNavIfNarrow = () => {
878
- if (window.innerWidth < CHAT_NAV_COLLAPSE_BREAKPOINT_PX) {
887
+ const collapseNavIfNoSpace = () => {
888
+ const shellW = getShellWidth();
889
+ if (
890
+ !shellFitsSidebarsLayout(shellW, {
891
+ mainSidebarOpen: true,
892
+ chatPanelOpen: true,
893
+ sidebarWidthPx,
894
+ chatWidthPx,
895
+ })
896
+ ) {
879
897
  // Chat open uses `startViewTransition`; nested transition here aborts first → AbortError overlay.
880
898
  setSidebarNavOpen(false, { viewTransition: false });
881
899
  }
882
900
  };
883
901
 
884
- collapseNavIfNarrow();
885
- window.addEventListener('resize', collapseNavIfNarrow);
886
- return () => window.removeEventListener('resize', collapseNavIfNarrow);
887
- }, [embedAsPage, isOpen, sidebarNavOpen, setSidebarNavOpen]);
902
+ collapseNavIfNoSpace();
903
+ window.addEventListener('resize', collapseNavIfNoSpace);
904
+ return () => window.removeEventListener('resize', collapseNavIfNoSpace);
905
+ }, [
906
+ embedAsPage,
907
+ isOpen,
908
+ sidebarNavOpen,
909
+ setSidebarNavOpen,
910
+ getShellWidth,
911
+ sidebarWidthPx,
912
+ chatWidthPx,
913
+ ]);
888
914
 
889
915
  const renderPresets = (layout: ChatPresetsLayout = 'fixed') => {
890
916
  if (!presetsWithFreeform?.length) return null;
@@ -34,6 +34,7 @@ import {
34
34
  clampChatWidthPx,
35
35
  clampSidebarWidthPx,
36
36
  defaultChatWidthPx,
37
+ shellFitsSidebarsLayout,
37
38
  } from '#uilib/hooks/panelWidth';
38
39
  import useElemDrag, { Delta } from '#uilib/hooks/useDragElem';
39
40
  import useEvent from '#uilib/hooks/useEvent';
@@ -171,7 +172,7 @@ function SidebarProvider({
171
172
  readInitialSidebarWidthPx(sidebarLsKey),
172
173
  );
173
174
  const [chatWidthPx, _setChatWidthPx] = useState(readInitialChatWidthPx);
174
- const [chatPanelOpen, setChatPanelOpen] = useState(false);
175
+ const [chatPanelOpen, _setChatPanelOpen] = useState(false);
175
176
  const sidebarWidthRef = useRef(sidebarWidthPx);
176
177
  const chatWidthRef = useRef(chatWidthPx);
177
178
  sidebarWidthRef.current = sidebarWidthPx;
@@ -311,8 +312,53 @@ function SidebarProvider({
311
312
 
312
313
  useShellWidthObserver(shellEl, handleShellResize);
313
314
 
315
+ const closeOppositeSidebarIfNoSpace = useCallback(
316
+ (openingMain: boolean, openingChat: boolean) => {
317
+ if (isSidebarSheetLayout) return;
318
+ const shellW = getShellWidth();
319
+ const layoutAfterOpen = {
320
+ mainSidebarOpen: openingMain || isOpen,
321
+ chatPanelOpen: openingChat || chatPanelOpen,
322
+ sidebarWidthPx,
323
+ chatWidthPx,
324
+ };
325
+ if (shellFitsSidebarsLayout(shellW, layoutAfterOpen)) return;
326
+ if (openingMain && chatPanelOpen) {
327
+ _setChatPanelOpen(false);
328
+ } else if (openingChat && isOpen) {
329
+ setIsOpen(false);
330
+ if (getCookiePreferences(userId)?.functional) {
331
+ setCookie('isSidebarOpen', 'false', 60 * 60 * 24 * 7);
332
+ }
333
+ }
334
+ },
335
+ [
336
+ isSidebarSheetLayout,
337
+ getShellWidth,
338
+ isOpen,
339
+ chatPanelOpen,
340
+ sidebarWidthPx,
341
+ chatWidthPx,
342
+ userId,
343
+ ],
344
+ );
345
+
346
+ const setChatPanelOpen = useCallback(
347
+ (open: boolean) => {
348
+ if (open) {
349
+ closeOppositeSidebarIfNoSpace(false, true);
350
+ }
351
+ _setChatPanelOpen(open);
352
+ },
353
+ [closeOppositeSidebarIfNoSpace],
354
+ );
355
+
314
356
  const setOpen = useCallback(
315
357
  (value: boolean, options?: SetSidebarOpenOptions) => {
358
+ if (value) {
359
+ closeOppositeSidebarIfNoSpace(true, false);
360
+ }
361
+
316
362
  const useViewTransition =
317
363
  options?.viewTransition !== false &&
318
364
  !isSidebarSheetLayout &&
@@ -331,7 +377,7 @@ function SidebarProvider({
331
377
  setCookie('isSidebarOpen', value.toString(), 60 * 60 * 24 * 7);
332
378
  }
333
379
  },
334
- [isSidebarSheetLayout, userId],
380
+ [isSidebarSheetLayout, userId, closeOppositeSidebarIfNoSpace],
335
381
  );
336
382
 
337
383
  const toggleSidebar = useCallback(() => setOpen(!isOpen), [isOpen, setOpen]);
@@ -20,4 +20,6 @@ Requires: `payload` — target + driver normalized_series; `loading` / `chartLoa
20
20
 
21
21
  Lag column: **Overlapped** shows API `lag` string (may be a range). **Lagged** shows single `N month(s)` from `parseLagMonthsFromLabel` (range uses max month).
22
22
 
23
+ Historical window: lead-in is anchored to the **overlapped** view (`computeDriversComparisonHistoricalWindowFloor`); lagged tab shifts driver lines only — switching tabs does not change historical extent.
24
+
23
25
  Empty/loading: loading props shimmer chart; null `payload` with `runAnalysisHint` prompts to run analysis.
@@ -35,6 +35,7 @@ import {
35
35
  INITIAL_VISIBLE_SERIES_COUNT,
36
36
  applyDriversComparisonViewToPayload,
37
37
  buildDriversComparisonChartData,
38
+ computeDriversComparisonHistoricalWindowFloor,
38
39
  formatSeriesImportance,
39
40
  getLagDisplayForView,
40
41
  mergeBacktestsChartData,
@@ -158,6 +159,27 @@ export function DriversComparisonChart({
158
159
  [datasetHistorical, mergedChartData],
159
160
  );
160
161
 
162
+ const historicalWindowFloor = useMemo(() => {
163
+ const overlappedMerged = mergeBacktestsChartData(payload);
164
+ const merged = mergeDatasetHistoricalWithBacktestsChartData(
165
+ datasetHistorical,
166
+ overlappedMerged,
167
+ );
168
+ const sortedDrivers = [...(payload?.drivers ?? [])]
169
+ .filter(
170
+ d =>
171
+ d.normalized_series &&
172
+ Object.keys(d.normalized_series).some(
173
+ key => d.normalized_series![key] != null,
174
+ ),
175
+ )
176
+ .sort((a, b) => String(a.id).localeCompare(String(b.id)));
177
+ const forecastIds = sortedDrivers.map(
178
+ (_, idx) => DRIVER_FORECAST_ID_BASE + idx,
179
+ );
180
+ return computeDriversComparisonHistoricalWindowFloor(merged, forecastIds);
181
+ }, [payload, datasetHistorical]);
182
+
161
183
  const chartForecastData = useMemo((): ForecastItemData[] => {
162
184
  if (!payloadForView?.target?.normalized_series) return [];
163
185
  return sortedDriversWithData.map((driver, idx) => ({
@@ -193,8 +215,7 @@ export function DriversComparisonChart({
193
215
  }, [originalDriversById, sortedDriversWithData, viewTab]);
194
216
 
195
217
  const seriesInitKeyResolved =
196
- seriesInitKey ??
197
- `${tableSeriesRows.map(r => r.id).join(',') || 'none'}`;
218
+ seriesInitKey ?? `${tableSeriesRows.map(r => r.id).join(',') || 'none'}`;
198
219
 
199
220
  const backtestsSeriesInitKeyRef = useRef<string>('');
200
221
 
@@ -222,8 +243,14 @@ export function DriversComparisonChart({
222
243
  mergedWithHistorical,
223
244
  datasetHistorical,
224
245
  chartForecastData.map(f => f.id),
246
+ historicalWindowFloor,
225
247
  ),
226
- [chartForecastData, datasetHistorical, mergedWithHistorical],
248
+ [
249
+ chartForecastData,
250
+ datasetHistorical,
251
+ historicalWindowFloor,
252
+ mergedWithHistorical,
253
+ ],
227
254
  );
228
255
 
229
256
  const showEmptyOverlay = (runAnalysisHint || Boolean(statusHint)) && !loading;
@@ -1,10 +1,19 @@
1
+ import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
2
+
1
3
  import {
4
+ DRIVER_COMPARISON_CHART_LEAD_MONTHS,
5
+ DRIVER_FORECAST_ID_BASE,
2
6
  applyDriversComparisonViewToPayload,
7
+ buildDriversComparisonChartData,
8
+ computeDriversComparisonHistoricalWindowFloor,
3
9
  formatLagMonthsLabel,
4
10
  getLagDisplayForView,
11
+ mergeBacktestsChartData,
12
+ mergeDatasetHistoricalWithBacktestsChartData,
5
13
  parseLagMonthsFromLabel,
6
14
  resolveDriverLagLabel,
7
15
  shiftNormalizedSeriesForward,
16
+ subtractMonthsFromMonthStart,
8
17
  } from './driversComparisonChart.helpers';
9
18
 
10
19
  describe('parseLagMonthsFromLabel', () => {
@@ -133,3 +142,173 @@ describe('applyDriversComparisonViewToPayload', () => {
133
142
  expect(lagged?.drivers[0].normalized_series['2015-07-01']).toBe(0.8);
134
143
  });
135
144
  });
145
+
146
+ describe('computeDriversComparisonHistoricalWindowFloor', () => {
147
+ const laggedDriverPayload = {
148
+ target: {
149
+ id: 'target',
150
+ name: 'Target',
151
+ normalized_series: {
152
+ '2014-10-01': 1.0,
153
+ '2014-11-01': 1.01,
154
+ '2014-12-01': 1.02,
155
+ '2015-01-01': 1.03,
156
+ '2015-02-01': 1.04,
157
+ '2015-03-01': 1.05,
158
+ '2015-04-01': 1.06,
159
+ '2015-05-01': 1.07,
160
+ },
161
+ },
162
+ drivers: [
163
+ {
164
+ id: 'd1',
165
+ name: 'Driver',
166
+ lag: '3 month(s)',
167
+ normalized_series: {
168
+ '2015-04-01': 0.5,
169
+ '2015-05-01': 0.6,
170
+ },
171
+ },
172
+ ],
173
+ };
174
+
175
+ const datasetHistorical: ChartDataPoint[] = [
176
+ { date: '2014-10-01', historical: 100 },
177
+ { date: '2014-11-01', historical: 101 },
178
+ { date: '2014-12-01', historical: 102 },
179
+ { date: '2015-01-01', historical: 103 },
180
+ { date: '2015-02-01', historical: 104 },
181
+ { date: '2015-03-01', historical: 105 },
182
+ { date: '2015-04-01', historical: 106 },
183
+ ];
184
+
185
+ const forecastIds = [DRIVER_FORECAST_ID_BASE];
186
+
187
+ it('returns anchor minus lead months from overlapped merged data', () => {
188
+ const merged = mergeDatasetHistoricalWithBacktestsChartData(
189
+ datasetHistorical,
190
+ mergeBacktestsChartData(laggedDriverPayload),
191
+ );
192
+ const floor = computeDriversComparisonHistoricalWindowFloor(
193
+ merged,
194
+ forecastIds,
195
+ );
196
+ expect(floor).toBe(
197
+ subtractMonthsFromMonthStart(
198
+ '2015-04-01',
199
+ DRIVER_COMPARISON_CHART_LEAD_MONTHS,
200
+ ),
201
+ );
202
+ expect(floor).toBe('2014-10-01');
203
+ });
204
+ });
205
+
206
+ describe('buildDriversComparisonChartData historical window floor', () => {
207
+ const laggedDriverPayload = {
208
+ target: {
209
+ id: 'target',
210
+ name: 'Target',
211
+ normalized_series: {
212
+ '2014-10-01': 1.0,
213
+ '2014-11-01': 1.01,
214
+ '2014-12-01': 1.02,
215
+ '2015-01-01': 1.03,
216
+ '2015-02-01': 1.04,
217
+ '2015-03-01': 1.05,
218
+ '2015-04-01': 1.06,
219
+ '2015-05-01': 1.07,
220
+ },
221
+ },
222
+ drivers: [
223
+ {
224
+ id: 'd1',
225
+ name: 'Driver',
226
+ lag: '3 month(s)',
227
+ normalized_series: {
228
+ '2015-04-01': 0.5,
229
+ '2015-05-01': 0.6,
230
+ },
231
+ },
232
+ ],
233
+ };
234
+
235
+ const datasetHistorical: ChartDataPoint[] = [
236
+ { date: '2014-10-01', historical: 100 },
237
+ { date: '2014-11-01', historical: 101 },
238
+ { date: '2014-12-01', historical: 102 },
239
+ { date: '2015-01-01', historical: 103 },
240
+ { date: '2015-02-01', historical: 104 },
241
+ { date: '2015-03-01', historical: 105 },
242
+ { date: '2015-04-01', historical: 106 },
243
+ ];
244
+
245
+ const forecastIds = [DRIVER_FORECAST_ID_BASE];
246
+
247
+ it('keeps same xMin for lagged and overlapped when floor is pinned', () => {
248
+ const overlappedMerged = mergeDatasetHistoricalWithBacktestsChartData(
249
+ datasetHistorical,
250
+ mergeBacktestsChartData(laggedDriverPayload),
251
+ );
252
+ const laggedPayload = applyDriversComparisonViewToPayload(
253
+ laggedDriverPayload,
254
+ 'lagged',
255
+ );
256
+ const laggedMerged = mergeDatasetHistoricalWithBacktestsChartData(
257
+ datasetHistorical,
258
+ mergeBacktestsChartData(laggedPayload),
259
+ );
260
+ const floor = computeDriversComparisonHistoricalWindowFloor(
261
+ overlappedMerged,
262
+ forecastIds,
263
+ )!;
264
+
265
+ const overlappedChart = buildDriversComparisonChartData(
266
+ overlappedMerged,
267
+ datasetHistorical,
268
+ forecastIds,
269
+ floor,
270
+ );
271
+ const laggedChart = buildDriversComparisonChartData(
272
+ laggedMerged,
273
+ datasetHistorical,
274
+ forecastIds,
275
+ floor,
276
+ );
277
+
278
+ expect(overlappedChart[0]?.date).toBe('2014-10-01');
279
+ expect(laggedChart[0]?.date).toBe('2014-10-01');
280
+ expect(laggedChart[0]?.date).toBe(overlappedChart[0]?.date);
281
+ });
282
+
283
+ it('without floor override lagged chart starts later than overlapped', () => {
284
+ const overlappedMerged = mergeDatasetHistoricalWithBacktestsChartData(
285
+ datasetHistorical,
286
+ mergeBacktestsChartData(laggedDriverPayload),
287
+ );
288
+ const laggedPayload = applyDriversComparisonViewToPayload(
289
+ laggedDriverPayload,
290
+ 'lagged',
291
+ );
292
+ const laggedMerged = mergeDatasetHistoricalWithBacktestsChartData(
293
+ datasetHistorical,
294
+ mergeBacktestsChartData(laggedPayload),
295
+ );
296
+
297
+ const overlappedChart = buildDriversComparisonChartData(
298
+ overlappedMerged,
299
+ datasetHistorical,
300
+ forecastIds,
301
+ );
302
+ const laggedChart = buildDriversComparisonChartData(
303
+ laggedMerged,
304
+ datasetHistorical,
305
+ forecastIds,
306
+ );
307
+
308
+ expect(overlappedChart[0]?.date).toBe('2014-10-01');
309
+ expect(laggedChart[0]?.date).toBe('2015-01-01');
310
+ expect(laggedChart[0]?.date!.localeCompare(overlappedChart[0]?.date!)).toBe(
311
+ 1,
312
+ );
313
+ });
314
+ });
@@ -290,22 +290,41 @@ export function prependHistoricalLeadFromDataset(
290
290
  return [...lead, ...points];
291
291
  }
292
292
 
293
- export function buildDriversComparisonChartData(
293
+ /** Overlapped (unshifted) anchor minus lead months — stable xMin across lagged/overlapped tabs. */
294
+ export function computeDriversComparisonHistoricalWindowFloor(
294
295
  mergedWithHistorical: ChartDataPoint[],
295
- datasetHistorical: ChartDataPoint[],
296
296
  forecastIds: number[],
297
- ): ChartDataPoint[] {
298
- if (mergedWithHistorical.length === 0) return mergedWithHistorical;
299
- if (forecastIds.length === 0) return mergedWithHistorical;
297
+ ): string | null {
298
+ if (mergedWithHistorical.length === 0 || forecastIds.length === 0)
299
+ return null;
300
300
  const { anchor: anchorMonth } = getZoomAnchorMonthFromDriverMaterialStarts(
301
301
  mergedWithHistorical,
302
302
  forecastIds,
303
303
  );
304
- if (anchorMonth === null) return mergedWithHistorical;
305
- const xMin = subtractMonthsFromMonthStart(
304
+ if (anchorMonth === null) return null;
305
+ return subtractMonthsFromMonthStart(
306
306
  anchorMonth,
307
307
  DRIVER_COMPARISON_CHART_LEAD_MONTHS,
308
308
  );
309
+ }
310
+
311
+ export function buildDriversComparisonChartData(
312
+ mergedWithHistorical: ChartDataPoint[],
313
+ datasetHistorical: ChartDataPoint[],
314
+ forecastIds: number[],
315
+ historicalWindowFloor?: string | null,
316
+ ): ChartDataPoint[] {
317
+ if (mergedWithHistorical.length === 0) return mergedWithHistorical;
318
+ if (forecastIds.length === 0) return mergedWithHistorical;
319
+
320
+ const xMin =
321
+ historicalWindowFloor ??
322
+ computeDriversComparisonHistoricalWindowFloor(
323
+ mergedWithHistorical,
324
+ forecastIds,
325
+ );
326
+ if (xMin === null) return mergedWithHistorical;
327
+
309
328
  const withLead = prependHistoricalLeadFromDataset(
310
329
  mergedWithHistorical,
311
330
  datasetHistorical,
@@ -0,0 +1,75 @@
1
+ import {
2
+ CENTRAL_AREA_MIN_PX,
3
+ CHAT_WIDTH_MIN_PX,
4
+ SIDEBAR_WIDTH_MIN_PX,
5
+ requiredShellWidthForSidebars,
6
+ shellFitsSidebarsLayout,
7
+ } from './panelWidth';
8
+
9
+ describe('requiredShellWidthForSidebars', () => {
10
+ it('sums only open panels plus central min', () => {
11
+ expect(
12
+ requiredShellWidthForSidebars({
13
+ mainSidebarOpen: true,
14
+ chatPanelOpen: true,
15
+ sidebarWidthPx: 320,
16
+ chatWidthPx: 600,
17
+ }),
18
+ ).toBe(320 + CENTRAL_AREA_MIN_PX + 600);
19
+
20
+ expect(
21
+ requiredShellWidthForSidebars({
22
+ mainSidebarOpen: false,
23
+ chatPanelOpen: true,
24
+ sidebarWidthPx: 320,
25
+ chatWidthPx: 600,
26
+ }),
27
+ ).toBe(CENTRAL_AREA_MIN_PX + 600);
28
+ });
29
+
30
+ it('falls back to panel mins when width is zero', () => {
31
+ expect(
32
+ requiredShellWidthForSidebars({
33
+ mainSidebarOpen: true,
34
+ chatPanelOpen: true,
35
+ sidebarWidthPx: 0,
36
+ chatWidthPx: 0,
37
+ }),
38
+ ).toBe(SIDEBAR_WIDTH_MIN_PX + CENTRAL_AREA_MIN_PX + CHAT_WIDTH_MIN_PX);
39
+ });
40
+ });
41
+
42
+ describe('shellFitsSidebarsLayout', () => {
43
+ it('returns true when shell meets required width', () => {
44
+ expect(
45
+ shellFitsSidebarsLayout(1600, {
46
+ mainSidebarOpen: true,
47
+ chatPanelOpen: true,
48
+ sidebarWidthPx: 300,
49
+ chatWidthPx: 500,
50
+ }),
51
+ ).toBe(true);
52
+ });
53
+
54
+ it('returns false when shell is too narrow for both panels', () => {
55
+ expect(
56
+ shellFitsSidebarsLayout(1500, {
57
+ mainSidebarOpen: true,
58
+ chatPanelOpen: true,
59
+ sidebarWidthPx: 300,
60
+ chatWidthPx: 500,
61
+ }),
62
+ ).toBe(false);
63
+ });
64
+
65
+ it('returns true for unknown shell width', () => {
66
+ expect(
67
+ shellFitsSidebarsLayout(0, {
68
+ mainSidebarOpen: true,
69
+ chatPanelOpen: true,
70
+ sidebarWidthPx: 300,
71
+ chatWidthPx: 500,
72
+ }),
73
+ ).toBe(true);
74
+ });
75
+ });
@@ -78,3 +78,31 @@ export function defaultChatWidthPx(shellWidth: number): number {
78
78
  SIDEBAR_WIDTH_DEFAULT_PX,
79
79
  );
80
80
  }
81
+
82
+ export type SidebarsLayoutWidths = {
83
+ mainSidebarOpen: boolean;
84
+ chatPanelOpen: boolean;
85
+ sidebarWidthPx: number;
86
+ chatWidthPx: number;
87
+ };
88
+
89
+ /** Minimum shell width when nav and/or chat panels are open (main column at least `CENTRAL_AREA_MIN_PX`). */
90
+ export function requiredShellWidthForSidebars(
91
+ widths: SidebarsLayoutWidths,
92
+ ): number {
93
+ const sidebarW = widths.mainSidebarOpen
94
+ ? widths.sidebarWidthPx || SIDEBAR_WIDTH_MIN_PX
95
+ : 0;
96
+ const chatW = widths.chatPanelOpen
97
+ ? widths.chatWidthPx || CHAT_WIDTH_MIN_PX
98
+ : 0;
99
+ return sidebarW + CENTRAL_AREA_MIN_PX + chatW;
100
+ }
101
+
102
+ export function shellFitsSidebarsLayout(
103
+ shellWidth: number,
104
+ widths: SidebarsLayoutWidths,
105
+ ): boolean {
106
+ if (shellWidth <= 0) return true;
107
+ return shellWidth >= requiredShellWidthForSidebars(widths);
108
+ }