@sybilion/uilib 1.3.61 → 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.
- package/dist/esm/components/ui/Chat/ChatSheet/chatPanelOpenSync.js +28 -0
- package/dist/esm/components/ui/Chat/ChatSheet/useChatPanelChromeModel.js +84 -26
- package/dist/esm/components/ui/Sidebar/Sidebar.js +6 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/types/src/components/ui/Chat/ChatSheet/chatPanelOpenSync.d.ts +12 -0
- package/dist/esm/types/src/components/ui/Chat/ChatSheet/chatPanelOpenSync.test.d.ts +1 -0
- package/dist/esm/types/src/components/ui/Sidebar/Sidebar.d.ts +2 -0
- package/package.json +1 -1
- package/src/components/ui/Chat/ChatSheet/chatPanelOpenSync.test.ts +83 -0
- package/src/components/ui/Chat/ChatSheet/chatPanelOpenSync.ts +58 -0
- package/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.tsx +106 -26
- package/src/components/ui/Sidebar/Sidebar.tsx +6 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Pure guards for chat panel URL ↔ shell sync (unit-tested). */
|
|
2
|
+
function shouldOpenChatFromUrl(chatOpen, layoutDismissed) {
|
|
3
|
+
return chatOpen && !layoutDismissed;
|
|
4
|
+
}
|
|
5
|
+
function shouldHealChatShellDesync(chatOpen, shellChatPanelOpen, layoutDismissed, isOpen) {
|
|
6
|
+
if (!chatOpen || shellChatPanelOpen) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
if (layoutDismissed && !isOpen) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
function shouldCloseStaleLocalChatOpen(shellChatPanelOpen, chatOpen, isOpen) {
|
|
15
|
+
return !shellChatPanelOpen && !chatOpen && isOpen;
|
|
16
|
+
}
|
|
17
|
+
function shouldDismissChatAfterShellClosed(params) {
|
|
18
|
+
const { shellChatPanelOpen, wasShellChatPanelOpen, chatOpen, isOpen, openedShellChat, } = params;
|
|
19
|
+
if (shellChatPanelOpen || !wasShellChatPanelOpen || !chatOpen) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
return isOpen || openedShellChat;
|
|
23
|
+
}
|
|
24
|
+
function isChatPanelVisible(isOpen, shellChatPanelOpen) {
|
|
25
|
+
return isOpen && shellChatPanelOpen;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export { isChatPanelVisible, shouldCloseStaleLocalChatOpen, shouldDismissChatAfterShellClosed, shouldHealChatShellDesync, shouldOpenChatFromUrl };
|
|
@@ -11,8 +11,9 @@ import { useIsMobile } from '../../../../hooks/useIsMobile.js';
|
|
|
11
11
|
import { useQueryParams } from '../../../../hooks/useQueryParams.js';
|
|
12
12
|
import logger from '../../../../lib/logger.js';
|
|
13
13
|
import { mergePresetFreeformDefaults } from '../../../../utils/chatPresetMerge.js';
|
|
14
|
-
import { useSidebar } from '../../Sidebar/Sidebar.js';
|
|
14
|
+
import { useSidebar, DISMISS_CHAT_FOR_LAYOUT_EVENT } from '../../Sidebar/Sidebar.js';
|
|
15
15
|
import { Chat } from '../Chat.js';
|
|
16
|
+
import { shouldOpenChatFromUrl, shouldHealChatShellDesync, shouldCloseStaleLocalChatOpen, shouldDismissChatAfterShellClosed, isChatPanelVisible } from './chatPanelOpenSync.js';
|
|
16
17
|
|
|
17
18
|
/** Fallback when `scopeId` prop omitted; apps should pass an explicit composite scope (e.g. `${userId}-dashboard`). */
|
|
18
19
|
const NO_SCOPE_FALLBACK = '__uilib_chat_scope_unset__';
|
|
@@ -33,11 +34,16 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
33
34
|
useSyncChatPanelBusy(isLoading);
|
|
34
35
|
const { searchParams, addSearchParams, removeSearchParams, mutateSearchParams, } = useQueryParams();
|
|
35
36
|
const chatOpen = searchParams.has(CHAT_QUERY_PARAM);
|
|
36
|
-
const [isOpen, setIsOpen] = useState(
|
|
37
|
+
const [isOpen, setIsOpen] = useState(() => !embedAsPage && chatOpen);
|
|
37
38
|
/** `?prompt=` deep link text for one-shot composer pre-fill (see ChatPrompt). */
|
|
38
39
|
const [promptLinkPrefill, setPromptLinkPrefill] = useState(null);
|
|
39
40
|
/** Deduplicate Strict Mode double `useEffect` on the same mount. */
|
|
40
41
|
const promptParamHandledInEffectRef = useRef(false);
|
|
42
|
+
const prevShellChatPanelOpenRef = useRef(shellChatPanelOpen);
|
|
43
|
+
/** This instance requested shell chat width (ignore other ChatSheet instances on the page). */
|
|
44
|
+
const openedShellChatRef = useRef(false);
|
|
45
|
+
/** Nav-forced chat close; blocks URL sync from reopening chat in the same tick. */
|
|
46
|
+
const layoutDismissedRef = useRef(false);
|
|
41
47
|
const panelActive = embedAsPage || isOpen;
|
|
42
48
|
// Ensure valid currentChatId when chat opens
|
|
43
49
|
useEffect(() => {
|
|
@@ -124,13 +130,17 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
124
130
|
return;
|
|
125
131
|
}
|
|
126
132
|
if (open) {
|
|
127
|
-
|
|
133
|
+
layoutDismissedRef.current = false;
|
|
134
|
+
openedShellChatRef.current = true;
|
|
128
135
|
setChatPanelOpen(true);
|
|
136
|
+
setIsOpen(true);
|
|
129
137
|
if (!searchParams.has(CHAT_QUERY_PARAM)) {
|
|
130
138
|
addSearchParams({ [CHAT_QUERY_PARAM]: CHAT_OPEN_VALUE });
|
|
131
139
|
}
|
|
132
140
|
return;
|
|
133
141
|
}
|
|
142
|
+
openedShellChatRef.current = false;
|
|
143
|
+
removeSearchParams(CHAT_QUERY_PARAM);
|
|
134
144
|
const applyClose = () => {
|
|
135
145
|
setIsOpen(false);
|
|
136
146
|
setChatPanelOpen(false);
|
|
@@ -143,7 +153,6 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
143
153
|
else {
|
|
144
154
|
applyClose();
|
|
145
155
|
}
|
|
146
|
-
removeSearchParams(CHAT_QUERY_PARAM);
|
|
147
156
|
};
|
|
148
157
|
const isEmpty = isChatEmpty(chat) && !isLoading;
|
|
149
158
|
const openNewChatWithPrefill = useCallback((prompt) => {
|
|
@@ -624,50 +633,93 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
624
633
|
}
|
|
625
634
|
})();
|
|
626
635
|
}, [currentChatId, scriptByChatId, addMessage, onScriptComplete]);
|
|
636
|
+
const dismissChatForLayout = useCallback(() => {
|
|
637
|
+
if (embedAsPage) {
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
layoutDismissedRef.current = true;
|
|
641
|
+
openedShellChatRef.current = false;
|
|
642
|
+
setIsOpen(false);
|
|
643
|
+
setChatPanelOpen(false);
|
|
644
|
+
removeSearchParams(CHAT_QUERY_PARAM);
|
|
645
|
+
}, [embedAsPage, removeSearchParams, setChatPanelOpen]);
|
|
646
|
+
useEvent({
|
|
647
|
+
event: DISMISS_CHAT_FOR_LAYOUT_EVENT,
|
|
648
|
+
callback: dismissChatForLayout,
|
|
649
|
+
});
|
|
650
|
+
/** `?chat=open` in URL — open local UI + shell (skip right after nav evicted chat). */
|
|
627
651
|
useEffect(() => {
|
|
628
652
|
if (embedAsPage) {
|
|
629
653
|
return;
|
|
630
654
|
}
|
|
631
655
|
if (!chatOpen) {
|
|
632
|
-
|
|
633
|
-
setChatPanelOpen(false);
|
|
656
|
+
layoutDismissedRef.current = false;
|
|
634
657
|
return;
|
|
635
658
|
}
|
|
636
|
-
if (!
|
|
637
|
-
removeSearchParams(CHAT_QUERY_PARAM);
|
|
659
|
+
if (!shouldOpenChatFromUrl(chatOpen, layoutDismissedRef.current)) {
|
|
638
660
|
return;
|
|
639
661
|
}
|
|
640
662
|
setIsOpen(true);
|
|
663
|
+
openedShellChatRef.current = true;
|
|
641
664
|
setChatPanelOpen(true);
|
|
642
|
-
}, [
|
|
643
|
-
|
|
644
|
-
chatOpen,
|
|
645
|
-
setChatPanelOpen,
|
|
646
|
-
sidebarNavOpen,
|
|
647
|
-
shellChatPanelOpen,
|
|
648
|
-
removeSearchParams,
|
|
649
|
-
]);
|
|
650
|
-
/** Shell closed chat for space (e.g. nav opened); keep UI and URL in sync. */
|
|
665
|
+
}, [embedAsPage, chatOpen, setChatPanelOpen]);
|
|
666
|
+
/** Heal desync: `?chat=open` but shell collapsed while this instance still wants panel open. */
|
|
651
667
|
useEffect(() => {
|
|
652
|
-
if (embedAsPage
|
|
668
|
+
if (embedAsPage) {
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (!shouldHealChatShellDesync(chatOpen, shellChatPanelOpen, layoutDismissedRef.current, isOpen)) {
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
layoutDismissedRef.current = false;
|
|
675
|
+
if (!isOpen) {
|
|
676
|
+
setIsOpen(true);
|
|
677
|
+
}
|
|
678
|
+
openedShellChatRef.current = true;
|
|
679
|
+
setChatPanelOpen(true);
|
|
680
|
+
}, [embedAsPage, chatOpen, isOpen, shellChatPanelOpen, setChatPanelOpen]);
|
|
681
|
+
/** Stale local open after shell closed without `?chat=` (e.g. another instance or aborted open). */
|
|
682
|
+
useEffect(() => {
|
|
683
|
+
if (embedAsPage) {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
if (!shouldCloseStaleLocalChatOpen(shellChatPanelOpen, chatOpen, isOpen)) {
|
|
653
687
|
return;
|
|
654
688
|
}
|
|
655
689
|
setIsOpen(false);
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
690
|
+
}, [embedAsPage, shellChatPanelOpen, chatOpen, isOpen]);
|
|
691
|
+
/** Shell chat closed while `?chat=` still set (fallback if layout event was missed). */
|
|
692
|
+
useEffect(() => {
|
|
693
|
+
if (embedAsPage) {
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const wasShellChatOpen = prevShellChatPanelOpenRef.current;
|
|
697
|
+
prevShellChatPanelOpenRef.current = shellChatPanelOpen;
|
|
698
|
+
if (!shouldDismissChatAfterShellClosed({
|
|
699
|
+
shellChatPanelOpen,
|
|
700
|
+
wasShellChatPanelOpen: wasShellChatOpen,
|
|
701
|
+
chatOpen,
|
|
702
|
+
isOpen,
|
|
703
|
+
openedShellChat: openedShellChatRef.current,
|
|
704
|
+
})) {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
dismissChatForLayout();
|
|
708
|
+
}, [embedAsPage, shellChatPanelOpen, chatOpen, dismissChatForLayout, isOpen]);
|
|
709
|
+
/** Route change: release shell slot only if this instance opened it (not Strict Mode remount). */
|
|
659
710
|
useEffect(() => {
|
|
660
711
|
return () => {
|
|
661
|
-
if (!embedAsPage) {
|
|
712
|
+
if (!embedAsPage && openedShellChatRef.current && !chatOpen) {
|
|
713
|
+
openedShellChatRef.current = false;
|
|
662
714
|
setChatPanelOpen(false);
|
|
663
715
|
}
|
|
664
716
|
};
|
|
665
|
-
}, [embedAsPage, setChatPanelOpen]);
|
|
717
|
+
}, [embedAsPage, chatOpen, setChatPanelOpen]);
|
|
666
718
|
useEffect(() => {
|
|
667
719
|
if (embedAsPage) {
|
|
668
720
|
return;
|
|
669
721
|
}
|
|
670
|
-
if (!
|
|
722
|
+
if (!shellChatPanelOpen || !sidebarNavOpen)
|
|
671
723
|
return;
|
|
672
724
|
const collapseNavIfNoSpace = () => {
|
|
673
725
|
const shellW = getShellWidth();
|
|
@@ -686,7 +738,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
686
738
|
return () => window.removeEventListener('resize', collapseNavIfNoSpace);
|
|
687
739
|
}, [
|
|
688
740
|
embedAsPage,
|
|
689
|
-
|
|
741
|
+
shellChatPanelOpen,
|
|
690
742
|
sidebarNavOpen,
|
|
691
743
|
setSidebarNavOpen,
|
|
692
744
|
getShellWidth,
|
|
@@ -808,7 +860,13 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
808
860
|
slashCommandItems,
|
|
809
861
|
onSlashItemCommand,
|
|
810
862
|
};
|
|
811
|
-
const toggleOpen = () =>
|
|
863
|
+
const toggleOpen = () => {
|
|
864
|
+
if (!isChatPanelVisible(isOpen, shellChatPanelOpen)) {
|
|
865
|
+
onOpenChange(true);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
onOpenChange(false);
|
|
869
|
+
};
|
|
812
870
|
return {
|
|
813
871
|
chromeProps,
|
|
814
872
|
isOpen,
|
|
@@ -20,6 +20,8 @@ import SidebarStem from './Sidebar.styl.js';
|
|
|
20
20
|
const SIDEBAR_WIDTH_MOBILE = '18rem';
|
|
21
21
|
const SIDEBAR_WIDTH_ICON = '3rem';
|
|
22
22
|
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
|
|
23
|
+
/** Fired when nav open closes chat for layout; ChatSheet clears `?chat=` so URL sync does not reopen chat. */
|
|
24
|
+
const DISMISS_CHAT_FOR_LAYOUT_EVENT = 'sybilion:dismiss-chat-for-layout';
|
|
23
25
|
function isEditableKeyboardTarget(el) {
|
|
24
26
|
if (!el)
|
|
25
27
|
return false;
|
|
@@ -219,6 +221,9 @@ function SidebarProvider({ defaultOpen = getCookie('isSidebarOpen') ?? true, cla
|
|
|
219
221
|
return;
|
|
220
222
|
if (openingMain && chatPanelOpen) {
|
|
221
223
|
_setChatPanelOpen(false);
|
|
224
|
+
if (typeof window !== 'undefined') {
|
|
225
|
+
window.dispatchEvent(new CustomEvent(DISMISS_CHAT_FOR_LAYOUT_EVENT));
|
|
226
|
+
}
|
|
222
227
|
}
|
|
223
228
|
else if (openingChat && isOpen) {
|
|
224
229
|
setIsOpen(false);
|
|
@@ -482,4 +487,4 @@ function SidebarMenuSubButton({ asChild = false, size = 'md', isActive = false,
|
|
|
482
487
|
return (jsxs(Tooltip, { children: [jsx(TooltipTrigger, { asChild: true, children: button }), jsx(TooltipContent, { side: "right", align: "center", hidden: !isOpen || isSidebarSheetLayout, ...tooltip })] }));
|
|
483
488
|
}
|
|
484
489
|
|
|
485
|
-
export { PanelResizeHandle, Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupAction, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarRail, SidebarResizeHandle, SidebarSeparator, SidebarTrigger, useSidebar };
|
|
490
|
+
export { DISMISS_CHAT_FOR_LAYOUT_EVENT, PanelResizeHandle, Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupAction, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarRail, SidebarResizeHandle, SidebarSeparator, SidebarTrigger, useSidebar };
|
package/dist/esm/index.js
CHANGED
|
@@ -77,7 +77,7 @@ export { Renamer } from './components/ui/Renamer/Renamer.js';
|
|
|
77
77
|
export { Select, SelectContent, SelectGroup, SelectItem, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue } from './components/ui/Select/Select.js';
|
|
78
78
|
export { Separator } from './components/ui/Separator/Separator.js';
|
|
79
79
|
export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from './components/ui/Sheet/Sheet.js';
|
|
80
|
-
export { PanelResizeHandle, Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupAction, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarRail, SidebarResizeHandle, SidebarSeparator, SidebarTrigger, useSidebar } from './components/ui/Sidebar/Sidebar.js';
|
|
80
|
+
export { DISMISS_CHAT_FOR_LAYOUT_EVENT, PanelResizeHandle, Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupAction, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarRail, SidebarResizeHandle, SidebarSeparator, SidebarTrigger, useSidebar } from './components/ui/Sidebar/Sidebar.js';
|
|
81
81
|
export { Skeleton } from './components/ui/Skeleton/Skeleton.js';
|
|
82
82
|
export { Slider } from './components/ui/Slider/Slider.js';
|
|
83
83
|
export { SmartTextTruncate } from './components/ui/SmartTextTruncate/SmartTextTruncate.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Pure guards for chat panel URL ↔ shell sync (unit-tested). */
|
|
2
|
+
export declare function shouldOpenChatFromUrl(chatOpen: boolean, layoutDismissed: boolean): boolean;
|
|
3
|
+
export declare function shouldHealChatShellDesync(chatOpen: boolean, shellChatPanelOpen: boolean, layoutDismissed: boolean, isOpen: boolean): boolean;
|
|
4
|
+
export declare function shouldCloseStaleLocalChatOpen(shellChatPanelOpen: boolean, chatOpen: boolean, isOpen: boolean): boolean;
|
|
5
|
+
export declare function shouldDismissChatAfterShellClosed(params: {
|
|
6
|
+
shellChatPanelOpen: boolean;
|
|
7
|
+
wasShellChatPanelOpen: boolean;
|
|
8
|
+
chatOpen: boolean;
|
|
9
|
+
isOpen: boolean;
|
|
10
|
+
openedShellChat: boolean;
|
|
11
|
+
}): boolean;
|
|
12
|
+
export declare function isChatPanelVisible(isOpen: boolean, shellChatPanelOpen: boolean): boolean;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -4,6 +4,8 @@ import type { ButtonProps } from '#uilib/components/ui/Button/Button.types';
|
|
|
4
4
|
import { Separator } from '#uilib/components/ui/Separator';
|
|
5
5
|
import { TooltipContent } from '#uilib/components/ui/Tooltip';
|
|
6
6
|
import { Scroll } from '@homecode/ui';
|
|
7
|
+
/** Fired when nav open closes chat for layout; ChatSheet clears `?chat=` so URL sync does not reopen chat. */
|
|
8
|
+
export declare const DISMISS_CHAT_FOR_LAYOUT_EVENT = "sybilion:dismiss-chat-for-layout";
|
|
7
9
|
type SetPanelWidthOptions = {
|
|
8
10
|
persist?: boolean;
|
|
9
11
|
};
|
package/package.json
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isChatPanelVisible,
|
|
3
|
+
shouldCloseStaleLocalChatOpen,
|
|
4
|
+
shouldDismissChatAfterShellClosed,
|
|
5
|
+
shouldHealChatShellDesync,
|
|
6
|
+
shouldOpenChatFromUrl,
|
|
7
|
+
} from './chatPanelOpenSync';
|
|
8
|
+
|
|
9
|
+
describe('shouldOpenChatFromUrl', () => {
|
|
10
|
+
it('opens when ?chat= is set and layout has not dismissed chat', () => {
|
|
11
|
+
expect(shouldOpenChatFromUrl(true, false)).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('blocks reopen in the same tick after nav layout dismiss', () => {
|
|
15
|
+
expect(shouldOpenChatFromUrl(true, true)).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('shouldHealChatShellDesync', () => {
|
|
20
|
+
it('heals when URL requests chat but shell slot is closed', () => {
|
|
21
|
+
expect(shouldHealChatShellDesync(true, false, false, false)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('skips heal after layout dismiss cleared local open', () => {
|
|
25
|
+
expect(shouldHealChatShellDesync(true, false, true, false)).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('shouldCloseStaleLocalChatOpen', () => {
|
|
30
|
+
it('closes local open when shell and URL are both closed', () => {
|
|
31
|
+
expect(shouldCloseStaleLocalChatOpen(false, false, true)).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('keeps local open while ?chat= remains', () => {
|
|
35
|
+
expect(shouldCloseStaleLocalChatOpen(false, true, true)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('shouldDismissChatAfterShellClosed', () => {
|
|
40
|
+
it('dismisses when shell closes with ?chat= and this instance opened chat', () => {
|
|
41
|
+
expect(
|
|
42
|
+
shouldDismissChatAfterShellClosed({
|
|
43
|
+
shellChatPanelOpen: false,
|
|
44
|
+
wasShellChatPanelOpen: true,
|
|
45
|
+
chatOpen: true,
|
|
46
|
+
isOpen: true,
|
|
47
|
+
openedShellChat: true,
|
|
48
|
+
}),
|
|
49
|
+
).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('ignores shell close when URL has no chat param', () => {
|
|
53
|
+
expect(
|
|
54
|
+
shouldDismissChatAfterShellClosed({
|
|
55
|
+
shellChatPanelOpen: false,
|
|
56
|
+
wasShellChatPanelOpen: true,
|
|
57
|
+
chatOpen: false,
|
|
58
|
+
isOpen: true,
|
|
59
|
+
openedShellChat: true,
|
|
60
|
+
}),
|
|
61
|
+
).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('ignores unrelated shell close without local or instance ownership', () => {
|
|
65
|
+
expect(
|
|
66
|
+
shouldDismissChatAfterShellClosed({
|
|
67
|
+
shellChatPanelOpen: false,
|
|
68
|
+
wasShellChatPanelOpen: true,
|
|
69
|
+
chatOpen: true,
|
|
70
|
+
isOpen: false,
|
|
71
|
+
openedShellChat: false,
|
|
72
|
+
}),
|
|
73
|
+
).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('isChatPanelVisible', () => {
|
|
78
|
+
it('is visible only when local and shell are both open', () => {
|
|
79
|
+
expect(isChatPanelVisible(true, true)).toBe(true);
|
|
80
|
+
expect(isChatPanelVisible(true, false)).toBe(false);
|
|
81
|
+
expect(isChatPanelVisible(false, true)).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Pure guards for chat panel URL ↔ shell sync (unit-tested). */
|
|
2
|
+
|
|
3
|
+
export function shouldOpenChatFromUrl(
|
|
4
|
+
chatOpen: boolean,
|
|
5
|
+
layoutDismissed: boolean,
|
|
6
|
+
): boolean {
|
|
7
|
+
return chatOpen && !layoutDismissed;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function shouldHealChatShellDesync(
|
|
11
|
+
chatOpen: boolean,
|
|
12
|
+
shellChatPanelOpen: boolean,
|
|
13
|
+
layoutDismissed: boolean,
|
|
14
|
+
isOpen: boolean,
|
|
15
|
+
): boolean {
|
|
16
|
+
if (!chatOpen || shellChatPanelOpen) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
if (layoutDismissed && !isOpen) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function shouldCloseStaleLocalChatOpen(
|
|
26
|
+
shellChatPanelOpen: boolean,
|
|
27
|
+
chatOpen: boolean,
|
|
28
|
+
isOpen: boolean,
|
|
29
|
+
): boolean {
|
|
30
|
+
return !shellChatPanelOpen && !chatOpen && isOpen;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function shouldDismissChatAfterShellClosed(params: {
|
|
34
|
+
shellChatPanelOpen: boolean;
|
|
35
|
+
wasShellChatPanelOpen: boolean;
|
|
36
|
+
chatOpen: boolean;
|
|
37
|
+
isOpen: boolean;
|
|
38
|
+
openedShellChat: boolean;
|
|
39
|
+
}): boolean {
|
|
40
|
+
const {
|
|
41
|
+
shellChatPanelOpen,
|
|
42
|
+
wasShellChatPanelOpen,
|
|
43
|
+
chatOpen,
|
|
44
|
+
isOpen,
|
|
45
|
+
openedShellChat,
|
|
46
|
+
} = params;
|
|
47
|
+
if (shellChatPanelOpen || !wasShellChatPanelOpen || !chatOpen) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return isOpen || openedShellChat;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isChatPanelVisible(
|
|
54
|
+
isOpen: boolean,
|
|
55
|
+
shellChatPanelOpen: boolean,
|
|
56
|
+
): boolean {
|
|
57
|
+
return isOpen && shellChatPanelOpen;
|
|
58
|
+
}
|
|
@@ -43,12 +43,22 @@ import type {
|
|
|
43
43
|
import { mergePresetFreeformDefaults } from '#uilib/utils/chatPresetMerge';
|
|
44
44
|
import { ScrollRef } from '@homecode/ui';
|
|
45
45
|
|
|
46
|
-
import {
|
|
46
|
+
import {
|
|
47
|
+
DISMISS_CHAT_FOR_LAYOUT_EVENT,
|
|
48
|
+
useSidebar,
|
|
49
|
+
} from '../../Sidebar/Sidebar';
|
|
47
50
|
import { Chat } from '../Chat';
|
|
48
51
|
import type { ChatChromeProps } from '../ChatChrome';
|
|
49
52
|
import type { ChatAttachmentDropItem } from '../ChatChrome/ChatChrome.types';
|
|
50
53
|
import type { ChatEmptyStateConfig } from '../ChatEmptyState/ChatEmptyState.types';
|
|
51
54
|
import type { ChatEmptyStateProps } from '../ChatEmptyState/ChatEmptyState.types';
|
|
55
|
+
import {
|
|
56
|
+
isChatPanelVisible,
|
|
57
|
+
shouldCloseStaleLocalChatOpen,
|
|
58
|
+
shouldDismissChatAfterShellClosed,
|
|
59
|
+
shouldHealChatShellDesync,
|
|
60
|
+
shouldOpenChatFromUrl,
|
|
61
|
+
} from './chatPanelOpenSync';
|
|
52
62
|
|
|
53
63
|
export type UseChatPanelChromeModelInput = {
|
|
54
64
|
/** When true, skip sidebar chat slot, URL `?chat=`, and portal behavior (e.g. page main content). */
|
|
@@ -173,13 +183,18 @@ export function useChatPanelChromeModel({
|
|
|
173
183
|
mutateSearchParams,
|
|
174
184
|
} = useQueryParams();
|
|
175
185
|
const chatOpen = searchParams.has(CHAT_QUERY_PARAM);
|
|
176
|
-
const [isOpen, setIsOpen] = useState(
|
|
186
|
+
const [isOpen, setIsOpen] = useState(() => !embedAsPage && chatOpen);
|
|
177
187
|
/** `?prompt=` deep link text for one-shot composer pre-fill (see ChatPrompt). */
|
|
178
188
|
const [promptLinkPrefill, setPromptLinkPrefill] = useState<string | null>(
|
|
179
189
|
null,
|
|
180
190
|
);
|
|
181
191
|
/** Deduplicate Strict Mode double `useEffect` on the same mount. */
|
|
182
192
|
const promptParamHandledInEffectRef = useRef(false);
|
|
193
|
+
const prevShellChatPanelOpenRef = useRef(shellChatPanelOpen);
|
|
194
|
+
/** This instance requested shell chat width (ignore other ChatSheet instances on the page). */
|
|
195
|
+
const openedShellChatRef = useRef(false);
|
|
196
|
+
/** Nav-forced chat close; blocks URL sync from reopening chat in the same tick. */
|
|
197
|
+
const layoutDismissedRef = useRef(false);
|
|
183
198
|
const panelActive = embedAsPage || isOpen;
|
|
184
199
|
|
|
185
200
|
// Ensure valid currentChatId when chat opens
|
|
@@ -288,14 +303,18 @@ export function useChatPanelChromeModel({
|
|
|
288
303
|
return;
|
|
289
304
|
}
|
|
290
305
|
if (open) {
|
|
291
|
-
|
|
306
|
+
layoutDismissedRef.current = false;
|
|
307
|
+
openedShellChatRef.current = true;
|
|
292
308
|
setChatPanelOpen(true);
|
|
309
|
+
setIsOpen(true);
|
|
293
310
|
if (!searchParams.has(CHAT_QUERY_PARAM)) {
|
|
294
311
|
addSearchParams({ [CHAT_QUERY_PARAM]: CHAT_OPEN_VALUE });
|
|
295
312
|
}
|
|
296
313
|
return;
|
|
297
314
|
}
|
|
298
315
|
|
|
316
|
+
openedShellChatRef.current = false;
|
|
317
|
+
removeSearchParams(CHAT_QUERY_PARAM);
|
|
299
318
|
const applyClose = () => {
|
|
300
319
|
setIsOpen(false);
|
|
301
320
|
setChatPanelOpen(false);
|
|
@@ -309,7 +328,6 @@ export function useChatPanelChromeModel({
|
|
|
309
328
|
} else {
|
|
310
329
|
applyClose();
|
|
311
330
|
}
|
|
312
|
-
removeSearchParams(CHAT_QUERY_PARAM);
|
|
313
331
|
};
|
|
314
332
|
|
|
315
333
|
const isEmpty = isChatEmpty(chat) && !isLoading;
|
|
@@ -851,53 +869,109 @@ export function useChatPanelChromeModel({
|
|
|
851
869
|
})();
|
|
852
870
|
}, [currentChatId, scriptByChatId, addMessage, onScriptComplete]);
|
|
853
871
|
|
|
872
|
+
const dismissChatForLayout = useCallback(() => {
|
|
873
|
+
if (embedAsPage) {
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
layoutDismissedRef.current = true;
|
|
877
|
+
openedShellChatRef.current = false;
|
|
878
|
+
setIsOpen(false);
|
|
879
|
+
setChatPanelOpen(false);
|
|
880
|
+
removeSearchParams(CHAT_QUERY_PARAM);
|
|
881
|
+
}, [embedAsPage, removeSearchParams, setChatPanelOpen]);
|
|
882
|
+
|
|
883
|
+
useEvent({
|
|
884
|
+
event: DISMISS_CHAT_FOR_LAYOUT_EVENT,
|
|
885
|
+
callback: dismissChatForLayout,
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
/** `?chat=open` in URL — open local UI + shell (skip right after nav evicted chat). */
|
|
854
889
|
useEffect(() => {
|
|
855
890
|
if (embedAsPage) {
|
|
856
891
|
return;
|
|
857
892
|
}
|
|
858
893
|
if (!chatOpen) {
|
|
859
|
-
|
|
860
|
-
setChatPanelOpen(false);
|
|
894
|
+
layoutDismissedRef.current = false;
|
|
861
895
|
return;
|
|
862
896
|
}
|
|
863
|
-
if (!
|
|
864
|
-
removeSearchParams(CHAT_QUERY_PARAM);
|
|
897
|
+
if (!shouldOpenChatFromUrl(chatOpen, layoutDismissedRef.current)) {
|
|
865
898
|
return;
|
|
866
899
|
}
|
|
867
900
|
setIsOpen(true);
|
|
901
|
+
openedShellChatRef.current = true;
|
|
868
902
|
setChatPanelOpen(true);
|
|
869
|
-
}, [
|
|
870
|
-
embedAsPage,
|
|
871
|
-
chatOpen,
|
|
872
|
-
setChatPanelOpen,
|
|
873
|
-
sidebarNavOpen,
|
|
874
|
-
shellChatPanelOpen,
|
|
875
|
-
removeSearchParams,
|
|
876
|
-
]);
|
|
903
|
+
}, [embedAsPage, chatOpen, setChatPanelOpen]);
|
|
877
904
|
|
|
878
|
-
/**
|
|
905
|
+
/** Heal desync: `?chat=open` but shell collapsed while this instance still wants panel open. */
|
|
879
906
|
useEffect(() => {
|
|
880
|
-
if (embedAsPage
|
|
907
|
+
if (embedAsPage) {
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
if (
|
|
911
|
+
!shouldHealChatShellDesync(
|
|
912
|
+
chatOpen,
|
|
913
|
+
shellChatPanelOpen,
|
|
914
|
+
layoutDismissedRef.current,
|
|
915
|
+
isOpen,
|
|
916
|
+
)
|
|
917
|
+
) {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
layoutDismissedRef.current = false;
|
|
921
|
+
if (!isOpen) {
|
|
922
|
+
setIsOpen(true);
|
|
923
|
+
}
|
|
924
|
+
openedShellChatRef.current = true;
|
|
925
|
+
setChatPanelOpen(true);
|
|
926
|
+
}, [embedAsPage, chatOpen, isOpen, shellChatPanelOpen, setChatPanelOpen]);
|
|
927
|
+
|
|
928
|
+
/** Stale local open after shell closed without `?chat=` (e.g. another instance or aborted open). */
|
|
929
|
+
useEffect(() => {
|
|
930
|
+
if (embedAsPage) {
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
if (!shouldCloseStaleLocalChatOpen(shellChatPanelOpen, chatOpen, isOpen)) {
|
|
881
934
|
return;
|
|
882
935
|
}
|
|
883
936
|
setIsOpen(false);
|
|
884
|
-
|
|
885
|
-
}, [embedAsPage, shellChatPanelOpen, isOpen, removeSearchParams]);
|
|
937
|
+
}, [embedAsPage, shellChatPanelOpen, chatOpen, isOpen]);
|
|
886
938
|
|
|
887
|
-
/**
|
|
939
|
+
/** Shell chat closed while `?chat=` still set (fallback if layout event was missed). */
|
|
940
|
+
useEffect(() => {
|
|
941
|
+
if (embedAsPage) {
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
const wasShellChatOpen = prevShellChatPanelOpenRef.current;
|
|
945
|
+
prevShellChatPanelOpenRef.current = shellChatPanelOpen;
|
|
946
|
+
if (
|
|
947
|
+
!shouldDismissChatAfterShellClosed({
|
|
948
|
+
shellChatPanelOpen,
|
|
949
|
+
wasShellChatPanelOpen: wasShellChatOpen,
|
|
950
|
+
chatOpen,
|
|
951
|
+
isOpen,
|
|
952
|
+
openedShellChat: openedShellChatRef.current,
|
|
953
|
+
})
|
|
954
|
+
) {
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
dismissChatForLayout();
|
|
958
|
+
}, [embedAsPage, shellChatPanelOpen, chatOpen, dismissChatForLayout, isOpen]);
|
|
959
|
+
|
|
960
|
+
/** Route change: release shell slot only if this instance opened it (not Strict Mode remount). */
|
|
888
961
|
useEffect(() => {
|
|
889
962
|
return () => {
|
|
890
|
-
if (!embedAsPage) {
|
|
963
|
+
if (!embedAsPage && openedShellChatRef.current && !chatOpen) {
|
|
964
|
+
openedShellChatRef.current = false;
|
|
891
965
|
setChatPanelOpen(false);
|
|
892
966
|
}
|
|
893
967
|
};
|
|
894
|
-
}, [embedAsPage, setChatPanelOpen]);
|
|
968
|
+
}, [embedAsPage, chatOpen, setChatPanelOpen]);
|
|
895
969
|
|
|
896
970
|
useEffect(() => {
|
|
897
971
|
if (embedAsPage) {
|
|
898
972
|
return;
|
|
899
973
|
}
|
|
900
|
-
if (!
|
|
974
|
+
if (!shellChatPanelOpen || !sidebarNavOpen) return;
|
|
901
975
|
|
|
902
976
|
const collapseNavIfNoSpace = () => {
|
|
903
977
|
const shellW = getShellWidth();
|
|
@@ -919,7 +993,7 @@ export function useChatPanelChromeModel({
|
|
|
919
993
|
return () => window.removeEventListener('resize', collapseNavIfNoSpace);
|
|
920
994
|
}, [
|
|
921
995
|
embedAsPage,
|
|
922
|
-
|
|
996
|
+
shellChatPanelOpen,
|
|
923
997
|
sidebarNavOpen,
|
|
924
998
|
setSidebarNavOpen,
|
|
925
999
|
getShellWidth,
|
|
@@ -1096,7 +1170,13 @@ export function useChatPanelChromeModel({
|
|
|
1096
1170
|
onSlashItemCommand,
|
|
1097
1171
|
};
|
|
1098
1172
|
|
|
1099
|
-
const toggleOpen = () =>
|
|
1173
|
+
const toggleOpen = () => {
|
|
1174
|
+
if (!isChatPanelVisible(isOpen, shellChatPanelOpen)) {
|
|
1175
|
+
onOpenChange(true);
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
onOpenChange(false);
|
|
1179
|
+
};
|
|
1100
1180
|
|
|
1101
1181
|
return {
|
|
1102
1182
|
chromeProps,
|
|
@@ -52,6 +52,9 @@ const SIDEBAR_WIDTH_MOBILE = '18rem';
|
|
|
52
52
|
const SIDEBAR_WIDTH_ICON = '3rem';
|
|
53
53
|
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
|
|
54
54
|
|
|
55
|
+
/** Fired when nav open closes chat for layout; ChatSheet clears `?chat=` so URL sync does not reopen chat. */
|
|
56
|
+
export const DISMISS_CHAT_FOR_LAYOUT_EVENT = 'sybilion:dismiss-chat-for-layout';
|
|
57
|
+
|
|
55
58
|
function isEditableKeyboardTarget(el: Element | null): boolean {
|
|
56
59
|
if (!el) return false;
|
|
57
60
|
if (el instanceof HTMLElement && el.isContentEditable) return true;
|
|
@@ -325,6 +328,9 @@ function SidebarProvider({
|
|
|
325
328
|
if (shellFitsSidebarsLayout(shellW, layoutAfterOpen)) return;
|
|
326
329
|
if (openingMain && chatPanelOpen) {
|
|
327
330
|
_setChatPanelOpen(false);
|
|
331
|
+
if (typeof window !== 'undefined') {
|
|
332
|
+
window.dispatchEvent(new CustomEvent(DISMISS_CHAT_FOR_LAYOUT_EVENT));
|
|
333
|
+
}
|
|
328
334
|
} else if (openingChat && isOpen) {
|
|
329
335
|
setIsOpen(false);
|
|
330
336
|
if (getCookiePreferences(userId)?.functional) {
|