@sybilion/uilib 1.3.58 → 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;
@@ -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 };
@@ -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.58",
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]);
@@ -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
+ }