🐛 fix: fix thread portal not open correctly (#11475)

fix thread portal
This commit is contained in:
Arvin Xu
2026-01-14 00:14:48 +08:00
committed by GitHub
parent c5df7d1f2d
commit e6ff90b108
15 changed files with 44 additions and 224 deletions

View File

@@ -6,6 +6,7 @@ import { createStoreUpdater } from 'zustand-utils';
import { useFetchThreads } from '@/hooks/useFetchThreads';
import { useQueryState } from '@/hooks/useQueryParam';
import { useChatStore } from '@/store/chat';
import { PortalViewType } from '@/store/chat/slices/portal/initialState';
// sync outside state to useChatStore
const ThreadHydration = memo(() => {
@@ -31,7 +32,7 @@ const ThreadHydration = memo(() => {
// should open portal automatically when portalThread is set
useEffect(() => {
if (!!portalThread && !useChatStore.getState().showPortal) {
useChatStore.getState().togglePortal(true);
useChatStore.getState().pushPortalView({ threadId: portalThread, type: PortalViewType.Thread });
}
}, [portalThread]);

View File

@@ -16,10 +16,10 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
}));
const Layout = () => {
const [showMobilePortal, isPortalThread, togglePortal] = useChatStore((s) => [
const [showMobilePortal, isPortalThread, clearPortalStack] = useChatStore((s) => [
s.showPortal,
portalThreadSelectors.showThread(s),
s.togglePortal,
s.clearPortalStack,
]);
const { t } = useTranslation('portal');
@@ -42,7 +42,7 @@ const Layout = () => {
destroyOnHidden
footer={null}
height={'95%'}
onCancel={() => togglePortal(false)}
onCancel={() => clearPortalStack()}
open={showMobilePortal}
styles={{
body: { padding: 0 },

View File

@@ -11,6 +11,7 @@ import UserAvatar from '@/features/User/UserAvatar';
import { useAgentGroupStore } from '@/store/agentGroup';
import { agentGroupSelectors } from '@/store/agentGroup/selectors';
import { useChatStore } from '@/store/chat';
import { PortalViewType } from '@/store/chat/slices/portal/initialState';
import { useUserStore } from '@/store/user';
import { userProfileSelectors } from '@/store/user/slices/auth/selectors';
@@ -36,7 +37,7 @@ const GroupMember = memo<GroupMemberProps>(({ addModalOpen, onAddModalOpenChange
const addAgentsToGroup = useAgentGroupStore((s) => s.addAgentsToGroup);
const removeAgentFromGroup = useAgentGroupStore((s) => s.removeAgentFromGroup);
const toggleThread = useAgentGroupStore((s) => s.toggleThread);
const togglePortal = useChatStore((s) => s.togglePortal);
const pushPortalView = useChatStore((s) => s.pushPortalView);
// Get members from store (excluding supervisor)
const groupMembers = useAgentGroupStore(agentGroupSelectors.getGroupMembers(groupId || ''));
@@ -76,7 +77,7 @@ const GroupMember = memo<GroupMemberProps>(({ addModalOpen, onAddModalOpenChange
const handleMemberClick = (agentId: string) => {
toggleThread(agentId);
togglePortal(true);
pushPortalView({ agentId, type: PortalViewType.GroupThread });
};
return (

View File

@@ -6,6 +6,7 @@ import { createStoreUpdater } from 'zustand-utils';
import { useFetchThreads } from '@/hooks/useFetchThreads';
import { useQueryState } from '@/hooks/useQueryParam';
import { useChatStore } from '@/store/chat';
import { PortalViewType } from '@/store/chat/slices/portal/initialState';
// sync outside state to useChatStore
const ThreadHydration = memo(() => {
@@ -31,7 +32,7 @@ const ThreadHydration = memo(() => {
// should open portal automatically when portalThread is set
useEffect(() => {
if (!!portalThread && !useChatStore.getState().showPortal) {
useChatStore.getState().togglePortal(true);
useChatStore.getState().pushPortalView({ threadId: portalThread, type: PortalViewType.Thread });
}
}, [portalThread]);

View File

@@ -16,10 +16,10 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
}));
const Layout = () => {
const [showMobilePortal, isPortalThread, togglePortal] = useChatStore((s) => [
const [showMobilePortal, isPortalThread, clearPortalStack] = useChatStore((s) => [
s.showPortal,
portalThreadSelectors.showThread(s),
s.togglePortal,
s.clearPortalStack,
]);
const { t } = useTranslation('portal');
@@ -42,7 +42,7 @@ const Layout = () => {
destroyOnHidden
footer={null}
height={'95%'}
onCancel={() => togglePortal(false)}
onCancel={() => clearPortalStack()}
open={showMobilePortal}
styles={{
body: { padding: 0 },

View File

@@ -1,105 +0,0 @@
'use client';
import { DraggablePanel, DraggablePanelContainer, type DraggablePanelProps } from '@lobehub/ui';
import { Flexbox } from '@lobehub/ui';
import { createStaticStyles, useResponsive } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { Activity, type PropsWithChildren, memo, useState } from 'react';
import {
CHAT_PORTAL_MAX_WIDTH,
CHAT_PORTAL_TOOL_UI_WIDTH,
CHAT_PORTAL_WIDTH,
} from '@/const/layoutTokens';
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors, portalThreadSelectors } from '@/store/chat/selectors';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
const styles = createStaticStyles(({ css, cssVar }) => ({
content: css`
display: flex;
flex-direction: column;
height: 100% !important;
`,
drawer: css`
z-index: 10;
height: 100%;
background: ${cssVar.colorBgContainer};
`,
panel: css`
overflow: hidden;
height: 100%;
background: ${cssVar.colorBgContainer};
`,
}));
const PortalPanel = memo(({ children }: PropsWithChildren) => {
const { md = true } = useResponsive();
const [showPortal, showToolUI, showArtifactUI, showThread] = useChatStore((s) => [
chatPortalSelectors.showPortal(s),
chatPortalSelectors.showPluginUI(s),
chatPortalSelectors.showArtifactUI(s),
portalThreadSelectors.showThread(s),
]);
const [portalWidth, updateSystemStatus] = useGlobalStore((s) => [
systemStatusSelectors.portalWidth(s),
s.updateSystemStatus,
]);
const [tmpWidth, setWidth] = useState(portalWidth);
if (tmpWidth !== portalWidth) setWidth(portalWidth);
const handleSizeChange: DraggablePanelProps['onSizeChange'] = (_, size) => {
if (!size) return;
const nextWidth = typeof size.width === 'string' ? Number.parseInt(size.width) : size.width;
if (!nextWidth) return;
if (isEqual(nextWidth, portalWidth)) return;
setWidth(nextWidth);
updateSystemStatus({ portalWidth: nextWidth });
};
return (
<DraggablePanel
className={styles.drawer}
classNames={{
content: styles.content,
}}
defaultSize={{ width: tmpWidth }}
expand={showPortal}
maxWidth={CHAT_PORTAL_MAX_WIDTH}
minWidth={
(showArtifactUI || showToolUI || showThread) && md
? CHAT_PORTAL_TOOL_UI_WIDTH
: CHAT_PORTAL_WIDTH
}
mode={md ? 'fixed' : 'float'}
onSizeChange={handleSizeChange}
placement={'right'}
showHandleWhenCollapsed={false}
showHandleWideArea={false}
size={{ height: '100%', width: portalWidth }}
styles={{
handle: { display: 'none' },
}}
>
<DraggablePanelContainer
style={{
flex: 'none',
height: '100%',
maxHeight: '100vh',
minWidth: CHAT_PORTAL_WIDTH,
}}
>
<Activity mode={showPortal ? 'visible' : 'hidden'} name="GroupPortal">
<Flexbox className={styles.panel}>{children}</Flexbox>
</Activity>
</DraggablePanelContainer>
</DraggablePanel>
);
});
export default PortalPanel;

View File

@@ -1,23 +0,0 @@
import { Suspense, memo } from 'react';
import DesktopLayout from '@/app/[variants]/(main)/group/features/Portal/_layout/Desktop';
import MobileLayout from '@/app/[variants]/(main)/group/features/Portal/_layout/Mobile';
import Loading from '@/components/Loading/BrandTextLoading';
interface PortalPanelProps {
mobile?: boolean;
}
const PortalPanel = memo<PortalPanelProps>(({ mobile }) => {
const Layout = mobile ? MobileLayout : DesktopLayout;
return (
<Suspense fallback={<Loading debugId="PortalPanel" />}>
<Layout />
</Suspense>
);
});
PortalPanel.displayName = 'PortalPanel';
export default PortalPanel;

View File

@@ -12,10 +12,10 @@ import { useSessionStore } from '@/store/session';
import { sessionSelectors } from '@/store/session/selectors';
const Header = memo(() => {
const togglePortal = useChatStore((s) => s.togglePortal);
const clearPortalStack = useChatStore((s) => s.clearPortalStack);
const close = () => {
useAgentGroupStore.setState({ activeThreadAgentId: '' });
togglePortal(false);
clearPortalStack();
};
const activeThreadAgentId = useAgentGroupStore((s) => s.activeThreadAgentId);

View File

@@ -15,9 +15,9 @@ const md = css`
`;
const MessageDetailBody = () => {
const [messageDetailId, togglePortal] = useChatStore((s) => [
const [messageDetailId, clearPortalStack] = useChatStore((s) => [
chatPortalSelectors.messageDetailId(s),
s.togglePortal,
s.clearPortalStack,
]);
const message = useChatStore(dbMessageSelectors.getDbMessageById(messageDetailId || ''), isEqual);
@@ -26,7 +26,7 @@ const MessageDetailBody = () => {
useEffect(() => {
if (!message) {
togglePortal(false);
clearPortalStack();
}
}, [message]);

View File

@@ -10,10 +10,10 @@ import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
const Header = memo<{ title: ReactNode }>(({ title }) => {
const [canGoBack, goBack, togglePortal] = useChatStore((s) => [
const [canGoBack, goBack, clearPortalStack] = useChatStore((s) => [
chatPortalSelectors.canGoBack(s),
s.goBack,
s.togglePortal,
s.clearPortalStack,
]);
return (
@@ -30,7 +30,7 @@ const Header = memo<{ title: ReactNode }>(({ title }) => {
<ActionIcon
icon={PanelRightCloseIcon}
onClick={() => {
togglePortal(false);
clearPortalStack();
}}
size={DESKTOP_HEADER_ICON_SIZE}
/>

View File

@@ -5,15 +5,15 @@ import { useQueryRoute } from '@/hooks/useQueryRoute';
import { useChatStore } from '@/store/chat';
export const useNavigateToAgent = () => {
const togglePortal = useChatStore((s) => s.togglePortal);
const clearPortalStack = useChatStore((s) => s.clearPortalStack);
const router = useQueryRoute();
return useCallback(
(agentId: string) => {
togglePortal(false);
clearPortalStack();
router.push(SESSION_CHAT_URL(agentId, false));
},
[togglePortal, router],
[clearPortalStack, router],
);
};

View File

@@ -268,45 +268,4 @@ describe('chatDockSlice', () => {
});
});
describe('toggleDock', () => {
it('should toggle dock state when no argument is provided', () => {
const { result } = renderHook(() => useChatStore());
expect(result.current.showPortal).toBe(false);
act(() => {
result.current.togglePortal();
});
expect(result.current.showPortal).toBe(true);
act(() => {
result.current.togglePortal();
});
expect(result.current.showPortal).toBe(false);
});
it('should set dock state to the provided value', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
result.current.togglePortal(true);
});
expect(result.current.showPortal).toBe(true);
act(() => {
result.current.togglePortal(false);
});
expect(result.current.showPortal).toBe(false);
act(() => {
result.current.togglePortal(true);
});
expect(result.current.showPortal).toBe(true);
});
});
});

View File

@@ -28,7 +28,6 @@ export interface ChatPortalAction {
pushPortalView: (view: PortalViewData) => void;
replacePortalView: (view: PortalViewData) => void;
toggleNotebook: (open?: boolean) => void;
togglePortal: (open?: boolean) => void;
}
// Helper to get current view type from stack
@@ -222,28 +221,4 @@ pushPortalView: (view) => {
get().closeNotebook();
}
},
togglePortal: (open) => {
const nextOpen = open === undefined ? !get().showPortal : open;
if (!nextOpen) {
// When closing, clear the stack
set({ portalStack: [], showPortal: false }, false, 'togglePortal/close');
} else {
// When opening, if stack is empty, push Home view
const { portalStack } = get();
if (portalStack.length === 0) {
set(
{
portalStack: [{ type: PortalViewType.Home }],
showPortal: true,
},
false,
'togglePortal/openHome',
);
} else {
set({ showPortal: true }, false, 'togglePortal/open');
}
}
},
});

View File

@@ -141,7 +141,7 @@ describe('thread action', () => {
describe('openThreadCreator', () => {
it('should set thread creator state and open portal', () => {
const { result } = renderHook(() => useChatStore());
const togglePortalSpy = vi.spyOn(result.current, 'togglePortal');
const pushPortalViewSpy = vi.spyOn(result.current, 'pushPortalView');
act(() => {
result.current.openThreadCreator('message-id');
@@ -150,14 +150,14 @@ describe('thread action', () => {
expect(result.current.threadStartMessageId).toBe('message-id');
expect(result.current.portalThreadId).toBeUndefined();
expect(result.current.startToForkThread).toBe(true);
expect(togglePortalSpy).toHaveBeenCalledWith(true);
expect(pushPortalViewSpy).toHaveBeenCalledWith({ type: 'thread', startMessageId: 'message-id' });
});
});
describe('openThreadInPortal', () => {
it('should set portal thread state and open portal', () => {
const { result } = renderHook(() => useChatStore());
const togglePortalSpy = vi.spyOn(result.current, 'togglePortal');
const pushPortalViewSpy = vi.spyOn(result.current, 'pushPortalView');
act(() => {
result.current.openThreadInPortal('thread-id', 'source-message-id');
@@ -166,7 +166,11 @@ describe('thread action', () => {
expect(result.current.portalThreadId).toBe('thread-id');
expect(result.current.threadStartMessageId).toBe('source-message-id');
expect(result.current.startToForkThread).toBe(false);
expect(togglePortalSpy).toHaveBeenCalledWith(true);
expect(pushPortalViewSpy).toHaveBeenCalledWith({
type: 'thread',
threadId: 'thread-id',
startMessageId: 'source-message-id',
});
});
});
@@ -182,7 +186,7 @@ describe('thread action', () => {
});
});
const togglePortalSpy = vi.spyOn(result.current, 'togglePortal');
const clearPortalStackSpy = vi.spyOn(result.current, 'clearPortalStack');
act(() => {
result.current.closeThreadPortal();
@@ -191,7 +195,7 @@ describe('thread action', () => {
expect(result.current.portalThreadId).toBeUndefined();
expect(result.current.threadStartMessageId).toBeUndefined();
expect(result.current.startToForkThread).toBeUndefined();
expect(togglePortalSpy).toHaveBeenCalledWith(false);
expect(clearPortalStackSpy).toHaveBeenCalled();
});
});

View File

@@ -19,6 +19,7 @@ import { merge } from '@/utils/merge';
import { setNamespace } from '@/utils/storeDebug';
import { displayMessageSelectors } from '../message/selectors';
import { PortalViewType } from '../portal/initialState';
import { type ThreadDispatch, threadReducer } from './reducer';
import { genParentMessages } from './selectors';
@@ -88,7 +89,8 @@ export const chatThreadMessage: StateCreator<
false,
'openThreadCreator',
);
get().togglePortal(true);
// Push Thread view to portal stack instead of togglePortal
get().pushPortalView({ type: PortalViewType.Thread, startMessageId: messageId });
},
openThreadInPortal: (threadId, sourceMessageId) => {
set(
@@ -96,7 +98,12 @@ export const chatThreadMessage: StateCreator<
false,
'openThreadInPortal',
);
get().togglePortal(true);
// Push Thread view to portal stack with threadId
get().pushPortalView({
type: PortalViewType.Thread,
threadId,
startMessageId: sourceMessageId ?? undefined,
});
},
closeThreadPortal: () => {
@@ -105,7 +112,7 @@ export const chatThreadMessage: StateCreator<
false,
'closeThreadPortal',
);
get().togglePortal(false);
get().clearPortalStack();
},
createThread: async ({ message, sourceMessageId, topicId, type }) => {
set({ isCreatingThread: true }, false, n('creatingThread/start'));