@sybilion/uilib 1.3.60 → 1.3.62

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.
@@ -13,7 +13,7 @@ function ChatPresets({ chatId, items, usedItemIds = [], layout = 'fixed', onSele
13
13
  const availableItems = items.filter(item => !usedItems.includes(item.id));
14
14
  if (availableItems.length === 0)
15
15
  return null;
16
- return (jsx("div", { className: layout === 'inline' ? S.inlineRoot : S.root, children: jsx("div", { className: cn(S.inner, layout === 'inline' && S.innerInline), children: availableItems.map(preset => (jsx(Button, { type: "button", className: cn(S.item), onClick: () => {
16
+ return (jsx("div", { className: layout === 'inline' ? S.inlineRoot : S.root, children: jsx("div", { className: cn(S.inner, layout === 'inline' && S.innerInline), children: availableItems.map(preset => (jsx(Button, { type: "button", variant: "outline", size: "sm", className: cn(S.item), onClick: () => {
17
17
  onSelect?.(preset);
18
18
  onItemUsed?.(preset.id);
19
19
  setLocalUsedItems(prev => [...prev, preset.id]);
@@ -1,6 +1,6 @@
1
1
  import styleInject from 'style-inject';
2
2
 
3
- var css_248z = ".ChatPresets_root__Cj42o{bottom:160px;width:100%}.ChatPresets_inlineRoot__WXVnu{margin-top:var(--p-6);position:relative;width:100%}.ChatPresets_inner__h14-q{background-color:var(--background);display:flex;flex-wrap:wrap;gap:8px;padding:var(--p-2) var(--p-6) var(--p-3)}.ChatPresets_innerInline__iPM2b{background-color:transparent}.ChatPresets_item__LfX5b{background:none;border:1px solid var(--border);border-radius:var(--p-4);color:var(--foreground);cursor:pointer;flex-shrink:0;font-size:var(--text-xs);line-height:1.4;max-width:300px;padding:var(--p-3);text-align:left;transition:all .2s ease;white-space:break-spaces}.ChatPresets_item__LfX5b:hover{background:var(--border)!important}.dark .ChatPresets_item__LfX5b{background-color:var(--muted)}";
3
+ var css_248z = ".ChatPresets_root__Cj42o{bottom:160px;width:100%}.ChatPresets_inlineRoot__WXVnu{margin-top:var(--p-6);position:relative;width:100%}.ChatPresets_inner__h14-q{background-color:var(--background);display:flex;flex-wrap:wrap;gap:8px;padding:var(--p-2) var(--p-6) var(--p-3)}.ChatPresets_innerInline__iPM2b{background-color:transparent}.ChatPresets_item__LfX5b{flex-shrink:0;font-size:var(--text-xs);height:auto;line-height:1.4;max-width:300px;min-height:auto;min-width:0;overflow-wrap:anywhere;padding:var(--p-3);text-align:left;white-space:break-spaces!important}";
4
4
  var S = {"root":"ChatPresets_root__Cj42o","inlineRoot":"ChatPresets_inlineRoot__WXVnu","inner":"ChatPresets_inner__h14-q","innerInline":"ChatPresets_innerInline__iPM2b","item":"ChatPresets_item__LfX5b"};
5
5
  styleInject(css_248z);
6
6
 
@@ -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(false);
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
- setIsOpen(true);
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
- setIsOpen(false);
633
- setChatPanelOpen(false);
656
+ layoutDismissedRef.current = false;
634
657
  return;
635
658
  }
636
- if (!shellChatPanelOpen && sidebarNavOpen) {
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
- 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. */
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 || shellChatPanelOpen || !isOpen) {
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
- removeSearchParams(CHAT_QUERY_PARAM);
657
- }, [embedAsPage, shellChatPanelOpen, isOpen, removeSearchParams]);
658
- /** When this instance unmounts (e.g. route hides ChatSheet), release shell layout — URL sync alone does not run. */
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 (!isOpen || !sidebarNavOpen)
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
- isOpen,
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 = () => onOpenChange(!isOpen);
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 };
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;
@@ -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
  };
@@ -0,0 +1,5 @@
1
+ import type { ChatPreset } from '#uilib/components/ui/Chat/Chat.types';
2
+ /** Sample dataset name — mirrors app dataset page chat presets. */
3
+ export declare const DOCS_SAMPLE_DATASET_NAME = "HDPE Spot Price";
4
+ export declare function getDocsDatasetPresets(datasetName?: string): ChatPreset[];
5
+ export declare const DOCS_DATASET_PRESETS: ChatPreset[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sybilion/uilib",
3
- "version": "1.3.60",
3
+ "version": "1.3.62",
4
4
  "description": "Sybilion Design System — React UI components (Webpack + Stylus)",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -20,22 +20,13 @@
20
20
 
21
21
  .item
22
22
  flex-shrink 0
23
+ min-width 0
23
24
  max-width 300px
25
+ height auto
26
+ min-height auto
24
27
  padding var(--p-3)
25
-
26
- background none
27
- border 1px solid var(--border)
28
- border-radius var(--p-4)
29
- cursor pointer
30
- transition all 0.2s ease
31
28
  text-align left
32
29
  font-size var(--text-xs)
33
30
  line-height 1.4
34
- white-space break-spaces
35
- color var(--foreground)
36
-
37
- &:hover
38
- background var(--border) !important
39
-
40
- :global(.dark) &
41
- background-color var(--muted)
31
+ white-space break-spaces !important
32
+ overflow-wrap anywhere
@@ -43,6 +43,8 @@ export function ChatPresets({
43
43
  <Button
44
44
  key={preset.id}
45
45
  type="button"
46
+ variant="outline"
47
+ size="sm"
46
48
  className={cn(S.item)}
47
49
  onClick={() => {
48
50
  onSelect?.(preset);
@@ -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 { useSidebar } from '../../Sidebar/Sidebar';
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(false);
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
- setIsOpen(true);
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
- setIsOpen(false);
860
- setChatPanelOpen(false);
894
+ layoutDismissedRef.current = false;
861
895
  return;
862
896
  }
863
- if (!shellChatPanelOpen && sidebarNavOpen) {
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
- /** Shell closed chat for space (e.g. nav opened); keep UI and URL in sync. */
905
+ /** Heal desync: `?chat=open` but shell collapsed while this instance still wants panel open. */
879
906
  useEffect(() => {
880
- if (embedAsPage || shellChatPanelOpen || !isOpen) {
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
- removeSearchParams(CHAT_QUERY_PARAM);
885
- }, [embedAsPage, shellChatPanelOpen, isOpen, removeSearchParams]);
937
+ }, [embedAsPage, shellChatPanelOpen, chatOpen, isOpen]);
886
938
 
887
- /** When this instance unmounts (e.g. route hides ChatSheet), release shell layout URL sync alone does not run. */
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 (!isOpen || !sidebarNavOpen) return;
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
- isOpen,
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 = () => onOpenChange(!isOpen);
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) {
@@ -0,0 +1,25 @@
1
+ import type { ChatPreset } from '#uilib/components/ui/Chat/Chat.types';
2
+
3
+ /** Sample dataset name — mirrors app dataset page chat presets. */
4
+ export const DOCS_SAMPLE_DATASET_NAME = 'HDPE Spot Price';
5
+
6
+ export function getDocsDatasetPresets(
7
+ datasetName: string = DOCS_SAMPLE_DATASET_NAME,
8
+ ): ChatPreset[] {
9
+ return [
10
+ {
11
+ id: '1',
12
+ text: `What should my planning decisions be based on the "${datasetName}" forecasts?`,
13
+ },
14
+ {
15
+ id: '2',
16
+ text: `Explain the "${datasetName}" forecast.`,
17
+ },
18
+ {
19
+ id: '3',
20
+ text: `What is the "${datasetName}" forecast performance?`,
21
+ },
22
+ ];
23
+ }
24
+
25
+ export const DOCS_DATASET_PRESETS = getDocsDatasetPresets();
@@ -2,11 +2,15 @@ import type { SlashCommandItem } from '#uilib/components/ui/Chat';
2
2
  import { PageContentSection } from '#uilib/components/ui/Page';
3
3
  import { MessageSquare } from 'lucide-react';
4
4
 
5
+ import {
6
+ DOCS_DATASET_PRESETS,
7
+ DOCS_SAMPLE_DATASET_NAME,
8
+ } from '../chatPresets.sample';
5
9
  import { AppPageHeader } from '../components/AppPageHeader/AppPageHeader';
6
10
  import { DOCS_CHAT_USER_KEY } from '../docsConstants';
7
11
  import { DocsHeaderActions } from '../docsHeaderActions';
8
12
 
9
- const DOCS_CHAT_SHEET_SCOPE_ID = `${DOCS_CHAT_USER_KEY}-docs-chat-sheet-portal`;
13
+ const DOCS_CHAT_SHEET_SCOPE_ID = `${DOCS_CHAT_USER_KEY}-docs-dataset-chat`;
10
14
 
11
15
  const DOCS_SAMPLE_SLASH_ITEMS: SlashCommandItem[] = [
12
16
  {
@@ -22,24 +26,29 @@ export default function ChatSheetPage() {
22
26
  <AppPageHeader
23
27
  breadcrumbs={[{ label: 'Chat' }, { label: 'Chat sheet' }]}
24
28
  title="Chat sheet (portal)"
25
- subheader="Same integration as design-demo: ChatSheet in page header actions, chat panel portaled into the shell sidebar slot. No inline ChatChrome on this page."
29
+ subheader={`Same integration as the app dataset page: ChatSheet in header actions with dataset-scoped presets for "${DOCS_SAMPLE_DATASET_NAME}". Panel portaled into the shell chat slot.`}
26
30
  actions={
27
31
  <DocsHeaderActions
28
32
  scopeId={DOCS_CHAT_SHEET_SCOPE_ID}
29
33
  slashCommandItems={DOCS_SAMPLE_SLASH_ITEMS}
30
- triggerLabel={
31
- <>
32
- <MessageSquare size={20} />
33
- &nbsp;AI Assistant
34
- </>
35
- }
34
+ presets={DOCS_DATASET_PRESETS}
35
+ triggerLabel={<MessageSquare size={20} />}
36
+ triggerAriaLabel="AI Assistant"
37
+ emptyState={{
38
+ title: 'Start a conversation',
39
+ }}
36
40
  />
37
41
  }
38
42
  />
39
43
  <PageContentSection>
40
44
  <p style={{ marginBottom: 16, fontSize: 14, lineHeight: 1.5 }}>
41
- Open <strong>AI Assistant</strong> in the header. The composer runs
42
- inside the portaled chat panel (not inline on this page).
45
+ Open <strong>AI Assistant</strong> in the header. Presets render below
46
+ the empty state (fixed layout) and inline after the assistant reply,
47
+ same as{' '}
48
+ <code style={{ fontSize: 13 }}>
49
+ presets=&#123;datasetPresets&#125;
50
+ </code>{' '}
51
+ on the app dataset page.
43
52
  </p>
44
53
  <h3 style={{ marginBottom: 8, fontSize: 14, fontWeight: 600 }}>
45
54
  Regression checklist
@@ -47,6 +56,10 @@ export default function ChatSheetPage() {
47
56
  <ul
48
57
  style={{ margin: 0, paddingLeft: 20, fontSize: 14, lineHeight: 1.6 }}
49
58
  >
59
+ <li>
60
+ Empty chat → three outline preset chips with dataset-scoped labels
61
+ </li>
62
+ <li>Preset click → sends preset text, chip hides for that session</li>
50
63
  <li>Plain Enter with text → submit and clear composer</li>
51
64
  <li>
52
65
  Plain Enter must not create empty lines with duplicated placeholders