@sybilion/uilib 1.3.58 → 1.3.60

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);
@@ -124,29 +123,27 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
124
123
  if (embedAsPage) {
125
124
  return;
126
125
  }
127
- const run = () => {
128
- setIsOpen(open);
129
- setChatPanelOpen(open);
130
- };
131
- if (!isMobile &&
132
- 'startViewTransition' in document &&
133
- document.startViewTransition) {
134
- document.startViewTransition(run);
135
- }
136
- else {
137
- run();
138
- }
139
126
  if (open) {
140
- // Avoid a second setSearchParams when `chat` is already set (e.g. Reports merges
141
- // reportId + chat in one update). A redundant add can merge from a stale `prev`
142
- // and drop other query keys.
127
+ setIsOpen(true);
128
+ setChatPanelOpen(true);
143
129
  if (!searchParams.has(CHAT_QUERY_PARAM)) {
144
130
  addSearchParams({ [CHAT_QUERY_PARAM]: CHAT_OPEN_VALUE });
145
131
  }
132
+ return;
133
+ }
134
+ const applyClose = () => {
135
+ setIsOpen(false);
136
+ setChatPanelOpen(false);
137
+ };
138
+ if (!isMobile &&
139
+ 'startViewTransition' in document &&
140
+ document.startViewTransition) {
141
+ document.startViewTransition(applyClose);
146
142
  }
147
143
  else {
148
- removeSearchParams(CHAT_QUERY_PARAM);
144
+ applyClose();
149
145
  }
146
+ removeSearchParams(CHAT_QUERY_PARAM);
150
147
  };
151
148
  const isEmpty = isChatEmpty(chat) && !isLoading;
152
149
  const openNewChatWithPrefill = useCallback((prompt) => {
@@ -631,9 +628,33 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
631
628
  if (embedAsPage) {
632
629
  return;
633
630
  }
634
- setIsOpen(chatOpen);
635
- setChatPanelOpen(chatOpen);
636
- }, [embedAsPage, chatOpen, setChatPanelOpen]);
631
+ if (!chatOpen) {
632
+ setIsOpen(false);
633
+ setChatPanelOpen(false);
634
+ return;
635
+ }
636
+ if (!shellChatPanelOpen && sidebarNavOpen) {
637
+ removeSearchParams(CHAT_QUERY_PARAM);
638
+ return;
639
+ }
640
+ setIsOpen(true);
641
+ setChatPanelOpen(true);
642
+ }, [
643
+ embedAsPage,
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. */
651
+ useEffect(() => {
652
+ if (embedAsPage || shellChatPanelOpen || !isOpen) {
653
+ return;
654
+ }
655
+ setIsOpen(false);
656
+ removeSearchParams(CHAT_QUERY_PARAM);
657
+ }, [embedAsPage, shellChatPanelOpen, isOpen, removeSearchParams]);
637
658
  /** When this instance unmounts (e.g. route hides ChatSheet), release shell layout — URL sync alone does not run. */
638
659
  useEffect(() => {
639
660
  return () => {
@@ -648,16 +669,30 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
648
669
  }
649
670
  if (!isOpen || !sidebarNavOpen)
650
671
  return;
651
- const collapseNavIfNarrow = () => {
652
- if (window.innerWidth < CHAT_NAV_COLLAPSE_BREAKPOINT_PX) {
672
+ const collapseNavIfNoSpace = () => {
673
+ const shellW = getShellWidth();
674
+ if (!shellFitsSidebarsLayout(shellW, {
675
+ mainSidebarOpen: true,
676
+ chatPanelOpen: true,
677
+ sidebarWidthPx,
678
+ chatWidthPx,
679
+ })) {
653
680
  // Chat open uses `startViewTransition`; nested transition here aborts first → AbortError overlay.
654
681
  setSidebarNavOpen(false, { viewTransition: false });
655
682
  }
656
683
  };
657
- collapseNavIfNarrow();
658
- window.addEventListener('resize', collapseNavIfNarrow);
659
- return () => window.removeEventListener('resize', collapseNavIfNarrow);
660
- }, [embedAsPage, isOpen, sidebarNavOpen, setSidebarNavOpen]);
684
+ collapseNavIfNoSpace();
685
+ window.addEventListener('resize', collapseNavIfNoSpace);
686
+ return () => window.removeEventListener('resize', collapseNavIfNoSpace);
687
+ }, [
688
+ embedAsPage,
689
+ isOpen,
690
+ sidebarNavOpen,
691
+ setSidebarNavOpen,
692
+ getShellWidth,
693
+ sidebarWidthPx,
694
+ chatWidthPx,
695
+ ]);
661
696
  const renderPresets = (layout = 'fixed') => {
662
697
  if (!presetsWithFreeform?.length)
663
698
  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,31 @@ 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
+ /**
50
+ * Stored width can fall below panel min when the shell was narrow (clamp cap < min).
51
+ * For dual-panel fit checks, treat open panels as at least their min width.
52
+ */
53
+ function effectiveOpenPanelWidthPx(px, minPx, bothPanelsOpen) {
54
+ if (bothPanelsOpen) {
55
+ return Math.max(px, minPx);
56
+ }
57
+ return px > 0 ? px : minPx;
58
+ }
59
+ /** Minimum shell width when nav and/or chat panels are open (main column at least `CENTRAL_AREA_MIN_PX`). */
60
+ function requiredShellWidthForSidebars(widths) {
61
+ const bothPanelsOpen = widths.mainSidebarOpen && widths.chatPanelOpen;
62
+ const sidebarW = widths.mainSidebarOpen
63
+ ? effectiveOpenPanelWidthPx(widths.sidebarWidthPx, SIDEBAR_WIDTH_MIN_PX, bothPanelsOpen)
64
+ : 0;
65
+ const chatW = widths.chatPanelOpen
66
+ ? effectiveOpenPanelWidthPx(widths.chatWidthPx, CHAT_WIDTH_MIN_PX, bothPanelsOpen)
67
+ : 0;
68
+ return sidebarW + CENTRAL_AREA_MIN_PX + chatW;
69
+ }
70
+ function shellFitsSidebarsLayout(shellWidth, widths) {
71
+ if (shellWidth <= 0)
72
+ return true;
73
+ return shellWidth >= requiredShellWidthForSidebars(widths);
74
+ }
49
75
 
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 };
76
+ 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.60",
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);
@@ -286,30 +287,29 @@ export function useChatPanelChromeModel({
286
287
  if (embedAsPage) {
287
288
  return;
288
289
  }
289
- const run = () => {
290
- setIsOpen(open);
291
- setChatPanelOpen(open);
290
+ if (open) {
291
+ setIsOpen(true);
292
+ setChatPanelOpen(true);
293
+ if (!searchParams.has(CHAT_QUERY_PARAM)) {
294
+ addSearchParams({ [CHAT_QUERY_PARAM]: CHAT_OPEN_VALUE });
295
+ }
296
+ return;
297
+ }
298
+
299
+ const applyClose = () => {
300
+ setIsOpen(false);
301
+ setChatPanelOpen(false);
292
302
  };
293
303
  if (
294
304
  !isMobile &&
295
305
  'startViewTransition' in document &&
296
306
  document.startViewTransition
297
307
  ) {
298
- document.startViewTransition(run);
308
+ document.startViewTransition(applyClose);
299
309
  } else {
300
- run();
301
- }
302
-
303
- if (open) {
304
- // Avoid a second setSearchParams when `chat` is already set (e.g. Reports merges
305
- // reportId + chat in one update). A redundant add can merge from a stale `prev`
306
- // and drop other query keys.
307
- if (!searchParams.has(CHAT_QUERY_PARAM)) {
308
- addSearchParams({ [CHAT_QUERY_PARAM]: CHAT_OPEN_VALUE });
309
- }
310
- } else {
311
- removeSearchParams(CHAT_QUERY_PARAM);
310
+ applyClose();
312
311
  }
312
+ removeSearchParams(CHAT_QUERY_PARAM);
313
313
  };
314
314
 
315
315
  const isEmpty = isChatEmpty(chat) && !isLoading;
@@ -855,9 +855,34 @@ export function useChatPanelChromeModel({
855
855
  if (embedAsPage) {
856
856
  return;
857
857
  }
858
- setIsOpen(chatOpen);
859
- setChatPanelOpen(chatOpen);
860
- }, [embedAsPage, chatOpen, setChatPanelOpen]);
858
+ if (!chatOpen) {
859
+ setIsOpen(false);
860
+ setChatPanelOpen(false);
861
+ return;
862
+ }
863
+ if (!shellChatPanelOpen && sidebarNavOpen) {
864
+ removeSearchParams(CHAT_QUERY_PARAM);
865
+ return;
866
+ }
867
+ setIsOpen(true);
868
+ setChatPanelOpen(true);
869
+ }, [
870
+ embedAsPage,
871
+ chatOpen,
872
+ setChatPanelOpen,
873
+ sidebarNavOpen,
874
+ shellChatPanelOpen,
875
+ removeSearchParams,
876
+ ]);
877
+
878
+ /** Shell closed chat for space (e.g. nav opened); keep UI and URL in sync. */
879
+ useEffect(() => {
880
+ if (embedAsPage || shellChatPanelOpen || !isOpen) {
881
+ return;
882
+ }
883
+ setIsOpen(false);
884
+ removeSearchParams(CHAT_QUERY_PARAM);
885
+ }, [embedAsPage, shellChatPanelOpen, isOpen, removeSearchParams]);
861
886
 
862
887
  /** When this instance unmounts (e.g. route hides ChatSheet), release shell layout — URL sync alone does not run. */
863
888
  useEffect(() => {
@@ -874,17 +899,33 @@ export function useChatPanelChromeModel({
874
899
  }
875
900
  if (!isOpen || !sidebarNavOpen) return;
876
901
 
877
- const collapseNavIfNarrow = () => {
878
- if (window.innerWidth < CHAT_NAV_COLLAPSE_BREAKPOINT_PX) {
902
+ const collapseNavIfNoSpace = () => {
903
+ const shellW = getShellWidth();
904
+ if (
905
+ !shellFitsSidebarsLayout(shellW, {
906
+ mainSidebarOpen: true,
907
+ chatPanelOpen: true,
908
+ sidebarWidthPx,
909
+ chatWidthPx,
910
+ })
911
+ ) {
879
912
  // Chat open uses `startViewTransition`; nested transition here aborts first → AbortError overlay.
880
913
  setSidebarNavOpen(false, { viewTransition: false });
881
914
  }
882
915
  };
883
916
 
884
- collapseNavIfNarrow();
885
- window.addEventListener('resize', collapseNavIfNarrow);
886
- return () => window.removeEventListener('resize', collapseNavIfNarrow);
887
- }, [embedAsPage, isOpen, sidebarNavOpen, setSidebarNavOpen]);
917
+ collapseNavIfNoSpace();
918
+ window.addEventListener('resize', collapseNavIfNoSpace);
919
+ return () => window.removeEventListener('resize', collapseNavIfNoSpace);
920
+ }, [
921
+ embedAsPage,
922
+ isOpen,
923
+ sidebarNavOpen,
924
+ setSidebarNavOpen,
925
+ getShellWidth,
926
+ sidebarWidthPx,
927
+ chatWidthPx,
928
+ ]);
888
929
 
889
930
  const renderPresets = (layout: ChatPresetsLayout = 'fixed') => {
890
931
  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,108 @@
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
+ it('uses panel mins for sub-min stored widths when both panels are open', () => {
42
+ expect(
43
+ requiredShellWidthForSidebars({
44
+ mainSidebarOpen: true,
45
+ chatPanelOpen: true,
46
+ sidebarWidthPx: 300,
47
+ chatWidthPx: 37,
48
+ }),
49
+ ).toBe(300 + CENTRAL_AREA_MIN_PX + CHAT_WIDTH_MIN_PX);
50
+ });
51
+
52
+ it('keeps actual chat width when only chat is open', () => {
53
+ expect(
54
+ requiredShellWidthForSidebars({
55
+ mainSidebarOpen: false,
56
+ chatPanelOpen: true,
57
+ sidebarWidthPx: 300,
58
+ chatWidthPx: 337,
59
+ }),
60
+ ).toBe(CENTRAL_AREA_MIN_PX + 337);
61
+ });
62
+ });
63
+
64
+ describe('shellFitsSidebarsLayout', () => {
65
+ it('returns true when shell meets required width', () => {
66
+ expect(
67
+ shellFitsSidebarsLayout(1600, {
68
+ mainSidebarOpen: true,
69
+ chatPanelOpen: true,
70
+ sidebarWidthPx: 300,
71
+ chatWidthPx: 500,
72
+ }),
73
+ ).toBe(true);
74
+ });
75
+
76
+ it('returns false when shell is too narrow for both panels', () => {
77
+ expect(
78
+ shellFitsSidebarsLayout(1500, {
79
+ mainSidebarOpen: true,
80
+ chatPanelOpen: true,
81
+ sidebarWidthPx: 300,
82
+ chatWidthPx: 500,
83
+ }),
84
+ ).toBe(false);
85
+ });
86
+
87
+ it('returns false when sub-min chat width would falsely fit at shell edge', () => {
88
+ expect(
89
+ shellFitsSidebarsLayout(1137, {
90
+ mainSidebarOpen: true,
91
+ chatPanelOpen: true,
92
+ sidebarWidthPx: 300,
93
+ chatWidthPx: 37,
94
+ }),
95
+ ).toBe(false);
96
+ });
97
+
98
+ it('returns true for unknown shell width', () => {
99
+ expect(
100
+ shellFitsSidebarsLayout(0, {
101
+ mainSidebarOpen: true,
102
+ chatPanelOpen: true,
103
+ sidebarWidthPx: 300,
104
+ chatWidthPx: 500,
105
+ }),
106
+ ).toBe(true);
107
+ });
108
+ });
@@ -78,3 +78,56 @@ 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
+ /**
90
+ * Stored width can fall below panel min when the shell was narrow (clamp cap < min).
91
+ * For dual-panel fit checks, treat open panels as at least their min width.
92
+ */
93
+ function effectiveOpenPanelWidthPx(
94
+ px: number,
95
+ minPx: number,
96
+ bothPanelsOpen: boolean,
97
+ ): number {
98
+ if (bothPanelsOpen) {
99
+ return Math.max(px, minPx);
100
+ }
101
+ return px > 0 ? px : minPx;
102
+ }
103
+
104
+ /** Minimum shell width when nav and/or chat panels are open (main column at least `CENTRAL_AREA_MIN_PX`). */
105
+ export function requiredShellWidthForSidebars(
106
+ widths: SidebarsLayoutWidths,
107
+ ): number {
108
+ const bothPanelsOpen =
109
+ widths.mainSidebarOpen && widths.chatPanelOpen;
110
+ const sidebarW = widths.mainSidebarOpen
111
+ ? effectiveOpenPanelWidthPx(
112
+ widths.sidebarWidthPx,
113
+ SIDEBAR_WIDTH_MIN_PX,
114
+ bothPanelsOpen,
115
+ )
116
+ : 0;
117
+ const chatW = widths.chatPanelOpen
118
+ ? effectiveOpenPanelWidthPx(
119
+ widths.chatWidthPx,
120
+ CHAT_WIDTH_MIN_PX,
121
+ bothPanelsOpen,
122
+ )
123
+ : 0;
124
+ return sidebarW + CENTRAL_AREA_MIN_PX + chatW;
125
+ }
126
+
127
+ export function shellFitsSidebarsLayout(
128
+ shellWidth: number,
129
+ widths: SidebarsLayoutWidths,
130
+ ): boolean {
131
+ if (shellWidth <= 0) return true;
132
+ return shellWidth >= requiredShellWidthForSidebars(widths);
133
+ }