From 277b0537e90d368ba4a18c8d334a1d3ac59f861b Mon Sep 17 00:00:00 2001 From: Lluis Agusti Date: Wed, 28 Jan 2026 01:48:09 +0700 Subject: [PATCH] hotfix(frontend): copilot simplication... --- .../components/CopilotShell/CopilotShell.tsx | 80 +---- .../components/LoadingState/LoadingState.tsx | 15 - .../MobileDrawer/useMobileDrawer.ts | 12 +- .../SessionsList/useSessionsPagination.ts | 64 ++-- .../components/CopilotShell/helpers.ts | 73 ----- .../CopilotShell/useCopilotShell.ts | 290 +++++------------- .../CopilotShell/useShellSessionList.ts | 115 +++++++ .../(platform)/copilot/copilot-page-store.ts | 66 +--- .../src/app/(platform)/copilot/helpers.ts | 23 -- .../src/app/(platform)/copilot/page.tsx | 21 +- .../app/(platform)/copilot/useCopilotPage.ts | 145 ++------- .../(platform)/copilot/useCopilotSessionId.ts | 10 + .../(platform)/copilot/useCopilotURLState.ts | 80 ----- .../src/components/contextual/Chat/Chat.tsx | 29 +- 14 files changed, 291 insertions(+), 732 deletions(-) delete mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/LoadingState/LoadingState.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useShellSessionList.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotSessionId.ts delete mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotURLState.ts diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/CopilotShell.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/CopilotShell.tsx index 8c9f9d528c..fe2f4f3625 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/CopilotShell.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/CopilotShell.tsx @@ -3,10 +3,7 @@ import { ChatLoader } from "@/components/contextual/Chat/components/ChatLoader/ChatLoader"; import { NAVBAR_HEIGHT_PX } from "@/lib/constants"; import type { ReactNode } from "react"; -import { useCallback, useEffect } from "react"; -import { useCopilotStore } from "../../copilot-page-store"; import { DesktopSidebar } from "./components/DesktopSidebar/DesktopSidebar"; -import { LoadingState } from "./components/LoadingState/LoadingState"; import { MobileDrawer } from "./components/MobileDrawer/MobileDrawer"; import { MobileHeader } from "./components/MobileHeader/MobileHeader"; import { useCopilotShell } from "./useCopilotShell"; @@ -24,85 +21,16 @@ export function CopilotShell({ children }: Props) { hasActiveSession, sessions, currentSessionId, - handleSelectSession, - performSelectSession, handleOpenDrawer, handleCloseDrawer, handleDrawerOpenChange, - handleNewChat, - performNewChat, + handleNewChatClick, + handleSessionClick, hasNextPage, isFetchingNextPage, fetchNextPage, - isReadyToShowContent, } = useCopilotShell(); - const setNewChatHandler = useCopilotStore((s) => s.setNewChatHandler); - const setNewChatWithInterruptHandler = useCopilotStore( - (s) => s.setNewChatWithInterruptHandler, - ); - const setSelectSessionHandler = useCopilotStore( - (s) => s.setSelectSessionHandler, - ); - const setSelectSessionWithInterruptHandler = useCopilotStore( - (s) => s.setSelectSessionWithInterruptHandler, - ); - const requestNewChat = useCopilotStore((s) => s.requestNewChat); - const requestSelectSession = useCopilotStore((s) => s.requestSelectSession); - - const stableHandleNewChat = useCallback(handleNewChat, [handleNewChat]); - const stablePerformNewChat = useCallback(performNewChat, [performNewChat]); - - useEffect( - function registerNewChatHandlers() { - setNewChatHandler(stableHandleNewChat); - setNewChatWithInterruptHandler(stablePerformNewChat); - return function cleanup() { - setNewChatHandler(null); - setNewChatWithInterruptHandler(null); - }; - }, - [ - stableHandleNewChat, - stablePerformNewChat, - setNewChatHandler, - setNewChatWithInterruptHandler, - ], - ); - - const stableHandleSelectSession = useCallback(handleSelectSession, [ - handleSelectSession, - ]); - - const stablePerformSelectSession = useCallback(performSelectSession, [ - performSelectSession, - ]); - - useEffect( - function registerSelectSessionHandlers() { - setSelectSessionHandler(stableHandleSelectSession); - setSelectSessionWithInterruptHandler(stablePerformSelectSession); - return function cleanup() { - setSelectSessionHandler(null); - setSelectSessionWithInterruptHandler(null); - }; - }, - [ - stableHandleSelectSession, - stablePerformSelectSession, - setSelectSessionHandler, - setSelectSessionWithInterruptHandler, - ], - ); - - function handleNewChatClick() { - requestNewChat(); - } - - function handleSessionClick(sessionId: string) { - requestSelectSession(sessionId); - } - if (!isLoggedIn) { return (
@@ -132,9 +60,7 @@ export function CopilotShell({ children }: Props) {
{isMobile && } -
- {isReadyToShowContent ? children : } -
+
{children}
{isMobile && ( diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/LoadingState/LoadingState.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/LoadingState/LoadingState.tsx deleted file mode 100644 index 21b1663916..0000000000 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/LoadingState/LoadingState.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Text } from "@/components/atoms/Text/Text"; -import { ChatLoader } from "@/components/contextual/Chat/components/ChatLoader/ChatLoader"; - -export function LoadingState() { - return ( -
-
- - - Loading your chats... - -
-
- ); -} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/MobileDrawer/useMobileDrawer.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/MobileDrawer/useMobileDrawer.ts index c9504e49a9..2ef63a4422 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/MobileDrawer/useMobileDrawer.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/MobileDrawer/useMobileDrawer.ts @@ -3,17 +3,17 @@ import { useState } from "react"; export function useMobileDrawer() { const [isDrawerOpen, setIsDrawerOpen] = useState(false); - function handleOpenDrawer() { + const handleOpenDrawer = () => { setIsDrawerOpen(true); - } + }; - function handleCloseDrawer() { + const handleCloseDrawer = () => { setIsDrawerOpen(false); - } + }; - function handleDrawerOpenChange(open: boolean) { + const handleDrawerOpenChange = (open: boolean) => { setIsDrawerOpen(open); - } + }; return { isDrawerOpen, diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/SessionsList/useSessionsPagination.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/SessionsList/useSessionsPagination.ts index 1f241f992a..11ddd937af 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/SessionsList/useSessionsPagination.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/SessionsList/useSessionsPagination.ts @@ -1,11 +1,6 @@ -import { - getGetV2ListSessionsQueryKey, - useGetV2ListSessions, -} from "@/app/api/__generated__/endpoints/chat/chat"; +import { useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/chat"; import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse"; import { okData } from "@/app/api/helpers"; -import { useChatStore } from "@/components/contextual/Chat/chat-store"; -import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; const PAGE_SIZE = 50; @@ -16,12 +11,12 @@ export interface UseSessionsPaginationArgs { export function useSessionsPagination({ enabled }: UseSessionsPaginationArgs) { const [offset, setOffset] = useState(0); + const [accumulatedSessions, setAccumulatedSessions] = useState< SessionSummaryResponse[] >([]); + const [totalCount, setTotalCount] = useState(null); - const queryClient = useQueryClient(); - const onStreamComplete = useChatStore((state) => state.onStreamComplete); const { data, isLoading, isFetching, isError } = useGetV2ListSessions( { limit: PAGE_SIZE, offset }, @@ -32,38 +27,23 @@ export function useSessionsPagination({ enabled }: UseSessionsPaginationArgs) { }, ); - useEffect(function refreshOnStreamComplete() { - const unsubscribe = onStreamComplete(function handleStreamComplete() { - setOffset(0); + useEffect(() => { + const responseData = okData(data); + if (responseData) { + const newSessions = responseData.sessions; + const total = responseData.total; + setTotalCount(total); + + if (offset === 0) { + setAccumulatedSessions(newSessions); + } else { + setAccumulatedSessions((prev) => [...prev, ...newSessions]); + } + } else if (!enabled) { setAccumulatedSessions([]); setTotalCount(null); - queryClient.invalidateQueries({ - queryKey: getGetV2ListSessionsQueryKey(), - }); - }); - return unsubscribe; - }, []); - - useEffect( - function updateSessionsFromResponse() { - const responseData = okData(data); - if (responseData) { - const newSessions = responseData.sessions; - const total = responseData.total; - setTotalCount(total); - - if (offset === 0) { - setAccumulatedSessions(newSessions); - } else { - setAccumulatedSessions((prev) => [...prev, ...newSessions]); - } - } else if (!enabled) { - setAccumulatedSessions([]); - setTotalCount(null); - } - }, - [data, offset, enabled], - ); + } + }, [data, offset, enabled]); const hasNextPage = totalCount !== null && accumulatedSessions.length < totalCount; @@ -86,17 +66,17 @@ export function useSessionsPagination({ enabled }: UseSessionsPaginationArgs) { } }, [hasNextPage, isFetching, isLoading, isError, totalCount]); - function fetchNextPage() { + const fetchNextPage = () => { if (hasNextPage && !isFetching) { setOffset((prev) => prev + PAGE_SIZE); } - } + }; - function reset() { + const reset = () => { setOffset(0); setAccumulatedSessions([]); setTotalCount(null); - } + }; return { sessions: accumulatedSessions, diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/helpers.ts index 3e932848a0..ef0d414edf 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/helpers.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/helpers.ts @@ -104,76 +104,3 @@ export function mergeCurrentSessionIntoList( export function getCurrentSessionId(searchParams: URLSearchParams) { return searchParams.get("sessionId"); } - -export function shouldAutoSelectSession( - areAllSessionsLoaded: boolean, - hasAutoSelectedSession: boolean, - paramSessionId: string | null, - visibleSessions: SessionSummaryResponse[], - accumulatedSessions: SessionSummaryResponse[], - isLoading: boolean, - totalCount: number | null, -) { - if (!areAllSessionsLoaded || hasAutoSelectedSession) { - return { - shouldSelect: false, - sessionIdToSelect: null, - shouldCreate: false, - }; - } - - if (paramSessionId) { - return { - shouldSelect: false, - sessionIdToSelect: null, - shouldCreate: false, - }; - } - - if (visibleSessions.length > 0) { - return { - shouldSelect: true, - sessionIdToSelect: visibleSessions[0].id, - shouldCreate: false, - }; - } - - if (accumulatedSessions.length === 0 && !isLoading && totalCount === 0) { - return { shouldSelect: false, sessionIdToSelect: null, shouldCreate: true }; - } - - if (totalCount === 0) { - return { - shouldSelect: false, - sessionIdToSelect: null, - shouldCreate: false, - }; - } - - return { shouldSelect: false, sessionIdToSelect: null, shouldCreate: false }; -} - -export function checkReadyToShowContent( - areAllSessionsLoaded: boolean, - paramSessionId: string | null, - accumulatedSessions: SessionSummaryResponse[], - isCurrentSessionLoading: boolean, - currentSessionData: SessionDetailResponse | null | undefined, - hasAutoSelectedSession: boolean, -) { - if (!areAllSessionsLoaded) return false; - - if (paramSessionId) { - const sessionFound = accumulatedSessions.some( - (s) => s.id === paramSessionId, - ); - return ( - sessionFound || - (!isCurrentSessionLoading && - currentSessionData !== undefined && - currentSessionData !== null) - ); - } - - return hasAutoSelectedSession; -} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useCopilotShell.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useCopilotShell.ts index 3154df2975..8429b75b2b 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useCopilotShell.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useCopilotShell.ts @@ -5,25 +5,18 @@ import { getGetV2ListSessionsQueryKey, useGetV2GetSession, } from "@/app/api/__generated__/endpoints/chat/chat"; -import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse"; import { okData } from "@/app/api/helpers"; import { useChatStore } from "@/components/contextual/Chat/chat-store"; import { useBreakpoint } from "@/lib/hooks/useBreakpoint"; import { useSupabase } from "@/lib/supabase/hooks/useSupabase"; import { useQueryClient } from "@tanstack/react-query"; -import { parseAsString, useQueryState } from "nuqs"; import { usePathname, useSearchParams } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; +import { useRef } from "react"; import { useCopilotStore } from "../../copilot-page-store"; +import { useCopilotSessionId } from "../../useCopilotSessionId"; import { useMobileDrawer } from "./components/MobileDrawer/useMobileDrawer"; -import { useSessionsPagination } from "./components/SessionsList/useSessionsPagination"; -import { - checkReadyToShowContent, - convertSessionDetailToSummary, - filterVisibleSessions, - getCurrentSessionId, - mergeCurrentSessionIntoList, -} from "./helpers"; +import { getCurrentSessionId } from "./helpers"; +import { useShellSessionList } from "./useShellSessionList"; export function useCopilotShell() { const pathname = usePathname(); @@ -34,7 +27,7 @@ export function useCopilotShell() { const isMobile = breakpoint === "base" || breakpoint === "sm" || breakpoint === "md"; - const [, setUrlSessionId] = useQueryState("sessionId", parseAsString); + const { urlSessionId, setUrlSessionId } = useCopilotSessionId(); const isOnHomepage = pathname === "/copilot"; const paramSessionId = searchParams.get("sessionId"); @@ -48,235 +41,113 @@ export function useCopilotShell() { const paginationEnabled = !isMobile || isDrawerOpen || !!paramSessionId; - const { - sessions: accumulatedSessions, - isLoading: isSessionsLoading, - isFetching: isSessionsFetching, - hasNextPage, - areAllSessionsLoaded, - fetchNextPage, - reset: resetPagination, - } = useSessionsPagination({ - enabled: paginationEnabled, - }); - const currentSessionId = getCurrentSessionId(searchParams); - const { data: currentSessionData, isLoading: isCurrentSessionLoading } = - useGetV2GetSession(currentSessionId || "", { + const { data: currentSessionData } = useGetV2GetSession( + currentSessionId || "", + { query: { enabled: !!currentSessionId, select: okData, }, - }); - - const [hasAutoSelectedSession, setHasAutoSelectedSession] = useState(false); - const hasAutoSelectedRef = useRef(false); - const recentlyCreatedSessionsRef = useRef< - Map - >(new Map()); - - const [optimisticSessionId, setOptimisticSessionId] = useState( - null, - ); - - useEffect( - function clearOptimisticWhenUrlMatches() { - if (optimisticSessionId && currentSessionId === optimisticSessionId) { - setOptimisticSessionId(null); - } }, - [currentSessionId, optimisticSessionId], ); - // Mark as auto-selected when sessionId is in URL - useEffect(() => { - if (paramSessionId && !hasAutoSelectedRef.current) { - hasAutoSelectedRef.current = true; - setHasAutoSelectedSession(true); - } - }, [paramSessionId]); - - // On homepage without sessionId, mark as ready immediately - useEffect(() => { - if (isOnHomepage && !paramSessionId && !hasAutoSelectedRef.current) { - hasAutoSelectedRef.current = true; - setHasAutoSelectedSession(true); - } - }, [isOnHomepage, paramSessionId]); - - // Invalidate sessions list when navigating to homepage (to show newly created sessions) - useEffect(() => { - if (isOnHomepage && !paramSessionId) { - queryClient.invalidateQueries({ - queryKey: getGetV2ListSessionsQueryKey(), - }); - } - }, [isOnHomepage, paramSessionId, queryClient]); - - // Track newly created sessions to ensure they stay visible even when switching away - useEffect(() => { - if (currentSessionId && currentSessionData) { - const isNewSession = - currentSessionData.updated_at === currentSessionData.created_at; - const isNotInAccumulated = !accumulatedSessions.some( - (s) => s.id === currentSessionId, - ); - if (isNewSession || isNotInAccumulated) { - const summary = convertSessionDetailToSummary(currentSessionData); - recentlyCreatedSessionsRef.current.set(currentSessionId, summary); - } - } - }, [currentSessionId, currentSessionData, accumulatedSessions]); - - // Clean up recently created sessions that are now in the accumulated list - useEffect(() => { - for (const sessionId of recentlyCreatedSessionsRef.current.keys()) { - if (accumulatedSessions.some((s) => s.id === sessionId)) { - recentlyCreatedSessionsRef.current.delete(sessionId); - } - } - }, [accumulatedSessions]); - - // Reset pagination when query becomes disabled - const prevPaginationEnabledRef = useRef(paginationEnabled); - useEffect(() => { - if (prevPaginationEnabledRef.current && !paginationEnabled) { - resetPagination(); - resetAutoSelect(); - } - prevPaginationEnabledRef.current = paginationEnabled; - }, [paginationEnabled, resetPagination]); - - const sessions = mergeCurrentSessionIntoList( - accumulatedSessions, + const { + sessions, + isLoading, + isSessionsFetching, + hasNextPage, + fetchNextPage, + resetPagination, + recentlyCreatedSessionsRef, + } = useShellSessionList({ + paginationEnabled, currentSessionId, currentSessionData, - recentlyCreatedSessionsRef.current, - ); - - const visibleSessions = filterVisibleSessions(sessions); - - const sidebarSelectedSessionId = - isOnHomepage && !paramSessionId && !optimisticSessionId - ? null - : optimisticSessionId || currentSessionId; - - const isReadyToShowContent = isOnHomepage - ? true - : checkReadyToShowContent( - areAllSessionsLoaded, - paramSessionId, - accumulatedSessions, - isCurrentSessionLoading, - currentSessionData, - hasAutoSelectedSession, - ); + isOnHomepage, + paramSessionId, + }); const stopStream = useChatStore((s) => s.stopStream); const onStreamComplete = useChatStore((s) => s.onStreamComplete); + const isStreaming = useCopilotStore((s) => s.isStreaming); const setIsSwitchingSession = useCopilotStore((s) => s.setIsSwitchingSession); + const openInterruptModal = useCopilotStore((s) => s.openInterruptModal); - async function performSelectSession(sessionId: string) { - if (sessionId === currentSessionId) return; + const pendingActionRef = useRef<(() => void) | null>(null); - const sourceSessionId = currentSessionId; + async function stopCurrentStream() { + if (!currentSessionId) return; - if (sourceSessionId) { - setIsSwitchingSession(true); - - await new Promise(function waitForStreamComplete(resolve) { - const unsubscribe = onStreamComplete( - function handleComplete(completedId) { - if (completedId === sourceSessionId) { - clearTimeout(timeout); - unsubscribe(); - resolve(); - } - }, - ); - const timeout = setTimeout(function handleTimeout() { + setIsSwitchingSession(true); + await new Promise((resolve) => { + const unsubscribe = onStreamComplete((completedId) => { + if (completedId === currentSessionId) { + clearTimeout(timeout); unsubscribe(); resolve(); - }, 3000); - stopStream(sourceSessionId); + } }); + const timeout = setTimeout(() => { + unsubscribe(); + resolve(); + }, 3000); + stopStream(currentSessionId); + }); - queryClient.invalidateQueries({ - queryKey: getGetV2GetSessionQueryKey(sourceSessionId), - }); - } - - setOptimisticSessionId(sessionId); - setUrlSessionId(sessionId, { shallow: false }); + queryClient.invalidateQueries({ + queryKey: getGetV2GetSessionQueryKey(currentSessionId), + }); setIsSwitchingSession(false); - if (isMobile) handleCloseDrawer(); } - function handleSelectSession(sessionId: string) { + function selectSession(sessionId: string) { if (sessionId === currentSessionId) return; - setOptimisticSessionId(sessionId); + if (recentlyCreatedSessionsRef.current.has(sessionId)) { + queryClient.invalidateQueries({ + queryKey: getGetV2GetSessionQueryKey(sessionId), + }); + } setUrlSessionId(sessionId, { shallow: false }); if (isMobile) handleCloseDrawer(); } - async function performNewChat() { - const sourceSessionId = currentSessionId; + function startNewChat() { + resetPagination(); + queryClient.invalidateQueries({ + queryKey: getGetV2ListSessionsQueryKey(), + }); + setUrlSessionId(null, { shallow: false }); + if (isMobile) handleCloseDrawer(); + } - if (sourceSessionId) { - setIsSwitchingSession(true); + function handleSessionClick(sessionId: string) { + if (sessionId === currentSessionId) return; - await new Promise(function waitForStreamComplete(resolve) { - const unsubscribe = onStreamComplete( - function handleComplete(completedId) { - if (completedId === sourceSessionId) { - clearTimeout(timeout); - unsubscribe(); - resolve(); - } - }, - ); - const timeout = setTimeout(function handleTimeout() { - unsubscribe(); - resolve(); - }, 3000); - stopStream(sourceSessionId); - }); - - queryClient.invalidateQueries({ - queryKey: getGetV2GetSessionQueryKey(sourceSessionId), - }); - setIsSwitchingSession(false); + if (isStreaming) { + pendingActionRef.current = async () => { + await stopCurrentStream(); + selectSession(sessionId); + }; + openInterruptModal(pendingActionRef.current); + } else { + selectSession(sessionId); } - - resetAutoSelect(); - resetPagination(); - queryClient.invalidateQueries({ - queryKey: getGetV2ListSessionsQueryKey(), - }); - setUrlSessionId(null, { shallow: false }); - setOptimisticSessionId(null); - if (isMobile) handleCloseDrawer(); } - function handleNewChat() { - resetAutoSelect(); - resetPagination(); - queryClient.invalidateQueries({ - queryKey: getGetV2ListSessionsQueryKey(), - }); - setUrlSessionId(null, { shallow: false }); - setOptimisticSessionId(null); - if (isMobile) handleCloseDrawer(); + function handleNewChatClick() { + if (isStreaming) { + pendingActionRef.current = async () => { + await stopCurrentStream(); + startNewChat(); + }; + openInterruptModal(pendingActionRef.current); + } else { + startNewChat(); + } } - function resetAutoSelect() { - hasAutoSelectedRef.current = false; - setHasAutoSelectedSession(false); - } - - const isLoading = isSessionsLoading && accumulatedSessions.length === 0; - return { isMobile, isDrawerOpen, @@ -284,18 +155,15 @@ export function useCopilotShell() { hasActiveSession: Boolean(currentSessionId) && (!isOnHomepage || Boolean(paramSessionId)), isLoading, - sessions: visibleSessions, - currentSessionId: sidebarSelectedSessionId, - handleSelectSession, - performSelectSession, + sessions, + currentSessionId: urlSessionId, handleOpenDrawer, handleCloseDrawer, handleDrawerOpenChange, - handleNewChat, - performNewChat, + handleNewChatClick, + handleSessionClick, hasNextPage, isFetchingNextPage: isSessionsFetching, fetchNextPage, - isReadyToShowContent, }; } diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useShellSessionList.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useShellSessionList.ts new file mode 100644 index 0000000000..30e2b6aba1 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useShellSessionList.ts @@ -0,0 +1,115 @@ +import { getGetV2ListSessionsQueryKey } from "@/app/api/__generated__/endpoints/chat/chat"; +import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse"; +import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse"; +import { useChatStore } from "@/components/contextual/Chat/chat-store"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef } from "react"; +import { useSessionsPagination } from "./components/SessionsList/useSessionsPagination"; +import { + convertSessionDetailToSummary, + filterVisibleSessions, + mergeCurrentSessionIntoList, +} from "./helpers"; + +interface UseShellSessionListArgs { + paginationEnabled: boolean; + currentSessionId: string | null; + currentSessionData: SessionDetailResponse | null | undefined; + isOnHomepage: boolean; + paramSessionId: string | null; +} + +export function useShellSessionList({ + paginationEnabled, + currentSessionId, + currentSessionData, + isOnHomepage, + paramSessionId, +}: UseShellSessionListArgs) { + const queryClient = useQueryClient(); + const onStreamComplete = useChatStore((s) => s.onStreamComplete); + + const { + sessions: accumulatedSessions, + isLoading: isSessionsLoading, + isFetching: isSessionsFetching, + hasNextPage, + fetchNextPage, + reset: resetPagination, + } = useSessionsPagination({ + enabled: paginationEnabled, + }); + + const recentlyCreatedSessionsRef = useRef< + Map + >(new Map()); + + useEffect(() => { + if (isOnHomepage && !paramSessionId) { + queryClient.invalidateQueries({ + queryKey: getGetV2ListSessionsQueryKey(), + }); + } + }, [isOnHomepage, paramSessionId, queryClient]); + + useEffect(() => { + if (currentSessionId && currentSessionData) { + const isNewSession = + currentSessionData.updated_at === currentSessionData.created_at; + const isNotInAccumulated = !accumulatedSessions.some( + (s) => s.id === currentSessionId, + ); + if (isNewSession || isNotInAccumulated) { + const summary = convertSessionDetailToSummary(currentSessionData); + recentlyCreatedSessionsRef.current.set(currentSessionId, summary); + } + } + }, [currentSessionId, currentSessionData, accumulatedSessions]); + + useEffect(() => { + for (const sessionId of recentlyCreatedSessionsRef.current.keys()) { + if (accumulatedSessions.some((s) => s.id === sessionId)) { + recentlyCreatedSessionsRef.current.delete(sessionId); + } + } + }, [accumulatedSessions]); + + useEffect(() => { + const unsubscribe = onStreamComplete((completedSessionId) => { + if (recentlyCreatedSessionsRef.current.has(completedSessionId)) { + queryClient.invalidateQueries({ + queryKey: getGetV2ListSessionsQueryKey(), + }); + } + }); + return unsubscribe; + }, [onStreamComplete, queryClient]); + + const sessions = useMemo( + () => + mergeCurrentSessionIntoList( + accumulatedSessions, + currentSessionId, + currentSessionData, + recentlyCreatedSessionsRef.current, + ), + [accumulatedSessions, currentSessionId, currentSessionData], + ); + + const visibleSessions = useMemo( + () => filterVisibleSessions(sessions), + [sessions], + ); + + const isLoading = isSessionsLoading && accumulatedSessions.length === 0; + + return { + sessions: visibleSessions, + isLoading, + isSessionsFetching, + hasNextPage, + fetchNextPage, + resetPagination, + recentlyCreatedSessionsRef, + }; +} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/copilot-page-store.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/copilot-page-store.ts index 486d31865b..201c8824c1 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/copilot-page-store.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/copilot-page-store.ts @@ -7,25 +7,12 @@ interface CopilotStoreState { isSwitchingSession: boolean; isInterruptModalOpen: boolean; pendingAction: (() => void) | null; - newChatHandler: (() => void) | null; - newChatWithInterruptHandler: (() => void) | null; - selectSessionHandler: ((sessionId: string) => void) | null; - selectSessionWithInterruptHandler: ((sessionId: string) => void) | null; } interface CopilotStoreActions { setIsStreaming: (isStreaming: boolean) => void; setIsSwitchingSession: (isSwitchingSession: boolean) => void; - setNewChatHandler: (handler: (() => void) | null) => void; - setNewChatWithInterruptHandler: (handler: (() => void) | null) => void; - setSelectSessionHandler: ( - handler: ((sessionId: string) => void) | null, - ) => void; - setSelectSessionWithInterruptHandler: ( - handler: ((sessionId: string) => void) | null, - ) => void; - requestNewChat: () => void; - requestSelectSession: (sessionId: string) => void; + openInterruptModal: (onConfirm: () => void) => void; confirmInterrupt: () => void; cancelInterrupt: () => void; } @@ -37,10 +24,6 @@ export const useCopilotStore = create((set, get) => ({ isSwitchingSession: false, isInterruptModalOpen: false, pendingAction: null, - newChatHandler: null, - newChatWithInterruptHandler: null, - selectSessionHandler: null, - selectSessionWithInterruptHandler: null, setIsStreaming(isStreaming) { set({ isStreaming }); @@ -50,51 +33,8 @@ export const useCopilotStore = create((set, get) => ({ set({ isSwitchingSession }); }, - setNewChatHandler(handler) { - set({ newChatHandler: handler }); - }, - - setNewChatWithInterruptHandler(handler) { - set({ newChatWithInterruptHandler: handler }); - }, - - setSelectSessionHandler(handler) { - set({ selectSessionHandler: handler }); - }, - - setSelectSessionWithInterruptHandler(handler) { - set({ selectSessionWithInterruptHandler: handler }); - }, - - requestNewChat() { - const { isStreaming, newChatHandler, newChatWithInterruptHandler } = get(); - if (isStreaming) { - if (!newChatWithInterruptHandler) return; - set({ - isInterruptModalOpen: true, - pendingAction: newChatWithInterruptHandler, - }); - } else if (newChatHandler) { - newChatHandler(); - } - }, - - requestSelectSession(sessionId) { - const { - isStreaming, - selectSessionHandler, - selectSessionWithInterruptHandler, - } = get(); - if (isStreaming) { - if (!selectSessionWithInterruptHandler) return; - set({ - isInterruptModalOpen: true, - pendingAction: () => selectSessionWithInterruptHandler(sessionId), - }); - } else { - if (!selectSessionHandler) return; - selectSessionHandler(sessionId); - } + openInterruptModal(onConfirm) { + set({ isInterruptModalOpen: true, pendingAction: onConfirm }); }, confirmInterrupt() { diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts index a5818f0a9f..692a5741f4 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts @@ -1,28 +1,5 @@ import type { User } from "@supabase/supabase-js"; -export type PageState = - | { type: "welcome" } - | { type: "newChat" } - | { type: "creating"; prompt: string } - | { type: "chat"; sessionId: string; initialPrompt?: string }; - -export function getInitialPromptFromState( - pageState: PageState, - storedInitialPrompt: string | undefined, -) { - if (storedInitialPrompt) return storedInitialPrompt; - if (pageState.type === "creating") return pageState.prompt; - if (pageState.type === "chat") return pageState.initialPrompt; -} - -export function shouldResetToWelcome(pageState: PageState) { - return ( - pageState.type !== "newChat" && - pageState.type !== "creating" && - pageState.type !== "welcome" - ); -} - export function getGreetingName(user?: User | null): string { if (!user) return "there"; const metadata = user.user_metadata as Record | undefined; diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/page.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/page.tsx index 008b06fcda..dfc531557f 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/page.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/page.tsx @@ -1,7 +1,6 @@ "use client"; import { Button } from "@/components/atoms/Button/Button"; - import { Skeleton } from "@/components/atoms/Skeleton/Skeleton"; import { Text } from "@/components/atoms/Text/Text"; import { Chat } from "@/components/contextual/Chat/Chat"; @@ -16,7 +15,15 @@ export default function CopilotPage() { const isInterruptModalOpen = useCopilotStore((s) => s.isInterruptModalOpen); const confirmInterrupt = useCopilotStore((s) => s.confirmInterrupt); const cancelInterrupt = useCopilotStore((s) => s.cancelInterrupt); - const { greetingName, quickActions, isLoading, pageState, isReady } = state; + const { + greetingName, + quickActions, + isLoading, + isCreating, + hasSession, + initialPrompt, + isReady, + } = state; const { handleQuickAction, startChatWithPrompt, @@ -26,14 +33,12 @@ export default function CopilotPage() { if (!isReady) return null; - if (pageState.type === "chat") { + if (hasSession) { return (
@@ -77,13 +82,13 @@ export default function CopilotPage() { ); } - if (pageState.type === "newChat" || pageState.type === "creating") { + if (isCreating) { return (
- Loading your chats... + Creating your chat...
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts index 8cf4599a12..35401d4415 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts @@ -14,60 +14,10 @@ import * as Sentry from "@sentry/nextjs"; import { useQueryClient } from "@tanstack/react-query"; import { useFlags } from "launchdarkly-react-client-sdk"; import { useRouter } from "next/navigation"; -import { useEffect, useReducer } from "react"; +import { useEffect, useRef, useState } from "react"; import { useCopilotStore } from "./copilot-page-store"; -import { getGreetingName, getQuickActions, type PageState } from "./helpers"; -import { useCopilotURLState } from "./useCopilotURLState"; - -type CopilotState = { - pageState: PageState; - initialPrompts: Record; - previousSessionId: string | null; -}; - -type CopilotAction = - | { type: "setPageState"; pageState: PageState } - | { type: "setInitialPrompt"; sessionId: string; prompt: string } - | { type: "setPreviousSessionId"; sessionId: string | null }; - -function isSamePageState(next: PageState, current: PageState) { - if (next.type !== current.type) return false; - if (next.type === "creating" && current.type === "creating") { - return next.prompt === current.prompt; - } - if (next.type === "chat" && current.type === "chat") { - return ( - next.sessionId === current.sessionId && - next.initialPrompt === current.initialPrompt - ); - } - return true; -} - -function copilotReducer( - state: CopilotState, - action: CopilotAction, -): CopilotState { - if (action.type === "setPageState") { - if (isSamePageState(action.pageState, state.pageState)) return state; - return { ...state, pageState: action.pageState }; - } - if (action.type === "setInitialPrompt") { - if (state.initialPrompts[action.sessionId] === action.prompt) return state; - return { - ...state, - initialPrompts: { - ...state.initialPrompts, - [action.sessionId]: action.prompt, - }, - }; - } - if (action.type === "setPreviousSessionId") { - if (state.previousSessionId === action.sessionId) return state; - return { ...state, previousSessionId: action.sessionId }; - } - return state; -} +import { getGreetingName, getQuickActions } from "./helpers"; +import { useCopilotSessionId } from "./useCopilotSessionId"; export function useCopilotPage() { const router = useRouter(); @@ -75,6 +25,7 @@ export function useCopilotPage() { const { user, isLoggedIn, isUserLoading } = useSupabase(); const { toast } = useToast(); + const { urlSessionId, setUrlSessionId } = useCopilotSessionId(); const setIsStreaming = useCopilotStore((s) => s.setIsStreaming); const isChatEnabled = useGetFlag(Flag.CHAT); @@ -86,72 +37,30 @@ export function useCopilotPage() { const isFlagReady = !isLaunchDarklyConfigured || flags[Flag.CHAT] !== undefined; - const [state, dispatch] = useReducer(copilotReducer, { - pageState: { type: "welcome" }, - initialPrompts: {}, - previousSessionId: null, - }); + const [isCreating, setIsCreating] = useState(false); + const initialPromptsRef = useRef>({}); const greetingName = getGreetingName(user); const quickActions = getQuickActions(); - function setPageState(pageState: PageState) { - dispatch({ type: "setPageState", pageState }); - } + const hasSession = Boolean(urlSessionId); + const initialPrompt = urlSessionId + ? initialPromptsRef.current[urlSessionId] + : undefined; - function setInitialPrompt(sessionId: string, prompt: string) { - dispatch({ type: "setInitialPrompt", sessionId, prompt }); - } - - function setPreviousSessionId(sessionId: string | null) { - dispatch({ type: "setPreviousSessionId", sessionId }); - } - - const { setUrlSessionId } = useCopilotURLState({ - pageState: state.pageState, - initialPrompts: state.initialPrompts, - previousSessionId: state.previousSessionId, - setPageState, - setInitialPrompt, - setPreviousSessionId, - }); - - useEffect( - function transitionNewChatToWelcome() { - if (state.pageState.type === "newChat") { - function setWelcomeState() { - dispatch({ type: "setPageState", pageState: { type: "welcome" } }); - } - - const timer = setTimeout(setWelcomeState, 300); - - return function cleanup() { - clearTimeout(timer); - }; - } - }, - [state.pageState.type], - ); - - useEffect( - function ensureAccess() { - if (!isFlagReady) return; - if (isChatEnabled === false) { - router.replace(homepageRoute); - } - }, - [homepageRoute, isChatEnabled, isFlagReady, router], - ); + useEffect(() => { + if (!isFlagReady) return; + if (isChatEnabled === false) { + router.replace(homepageRoute); + } + }, [homepageRoute, isChatEnabled, isFlagReady, router]); async function startChatWithPrompt(prompt: string) { if (!prompt?.trim()) return; - if (state.pageState.type === "creating") return; + if (isCreating) return; const trimmedPrompt = prompt.trim(); - dispatch({ - type: "setPageState", - pageState: { type: "creating", prompt: trimmedPrompt }, - }); + setIsCreating(true); try { const sessionResponse = await postV2CreateSession({ @@ -163,27 +72,19 @@ export function useCopilotPage() { } const sessionId = sessionResponse.data.id; - - dispatch({ - type: "setInitialPrompt", - sessionId, - prompt: trimmedPrompt, - }); + initialPromptsRef.current[sessionId] = trimmedPrompt; await queryClient.invalidateQueries({ queryKey: getGetV2ListSessionsQueryKey(), }); await setUrlSessionId(sessionId, { shallow: false }); - dispatch({ - type: "setPageState", - pageState: { type: "chat", sessionId, initialPrompt: trimmedPrompt }, - }); } catch (error) { console.error("[CopilotPage] Failed to start chat:", error); toast({ title: "Failed to start chat", variant: "destructive" }); Sentry.captureException(error); - dispatch({ type: "setPageState", pageState: { type: "welcome" } }); + } finally { + setIsCreating(false); } } @@ -204,7 +105,9 @@ export function useCopilotPage() { greetingName, quickActions, isLoading: isUserLoading, - pageState: state.pageState, + isCreating, + hasSession, + initialPrompt, isReady: isFlagReady && isChatEnabled !== false && isLoggedIn, }, handlers: { diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotSessionId.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotSessionId.ts new file mode 100644 index 0000000000..87f9b7d3ae --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotSessionId.ts @@ -0,0 +1,10 @@ +import { parseAsString, useQueryState } from "nuqs"; + +export function useCopilotSessionId() { + const [urlSessionId, setUrlSessionId] = useQueryState( + "sessionId", + parseAsString, + ); + + return { urlSessionId, setUrlSessionId }; +} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotURLState.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotURLState.ts deleted file mode 100644 index 5e37e29a15..0000000000 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotURLState.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { parseAsString, useQueryState } from "nuqs"; -import { useLayoutEffect } from "react"; -import { - getInitialPromptFromState, - type PageState, - shouldResetToWelcome, -} from "./helpers"; - -interface UseCopilotUrlStateArgs { - pageState: PageState; - initialPrompts: Record; - previousSessionId: string | null; - setPageState: (pageState: PageState) => void; - setInitialPrompt: (sessionId: string, prompt: string) => void; - setPreviousSessionId: (sessionId: string | null) => void; -} - -export function useCopilotURLState({ - pageState, - initialPrompts, - previousSessionId, - setPageState, - setInitialPrompt, - setPreviousSessionId, -}: UseCopilotUrlStateArgs) { - const [urlSessionId, setUrlSessionId] = useQueryState( - "sessionId", - parseAsString, - ); - - function syncSessionFromUrl() { - if (urlSessionId) { - if (pageState.type === "chat" && pageState.sessionId === urlSessionId) { - setPreviousSessionId(urlSessionId); - return; - } - - const storedInitialPrompt = initialPrompts[urlSessionId]; - const currentInitialPrompt = getInitialPromptFromState( - pageState, - storedInitialPrompt, - ); - - if (currentInitialPrompt) { - setInitialPrompt(urlSessionId, currentInitialPrompt); - } - - setPageState({ - type: "chat", - sessionId: urlSessionId, - initialPrompt: currentInitialPrompt, - }); - setPreviousSessionId(urlSessionId); - return; - } - - const wasInChat = previousSessionId !== null && pageState.type === "chat"; - setPreviousSessionId(null); - if (wasInChat) { - setPageState({ type: "newChat" }); - return; - } - - if (shouldResetToWelcome(pageState)) { - setPageState({ type: "welcome" }); - } - } - - useLayoutEffect(syncSessionFromUrl, [ - urlSessionId, - pageState.type, - previousSessionId, - initialPrompts, - ]); - - return { - urlSessionId, - setUrlSessionId, - }; -} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/Chat.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/Chat.tsx index a7a5f61674..d16fd60b98 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/Chat.tsx +++ b/autogpt_platform/frontend/src/components/contextual/Chat/Chat.tsx @@ -1,5 +1,6 @@ "use client"; +import { useCopilotSessionId } from "@/app/(platform)/copilot/useCopilotSessionId"; import { useCopilotStore } from "@/app/(platform)/copilot/copilot-page-store"; import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner"; import { Text } from "@/components/atoms/Text/Text"; @@ -11,7 +12,6 @@ import { useChat } from "./useChat"; export interface ChatProps { className?: string; - urlSessionId?: string | null; initialPrompt?: string; onSessionNotFound?: () => void; onStreamingChange?: (isStreaming: boolean) => void; @@ -19,11 +19,11 @@ export interface ChatProps { export function Chat({ className, - urlSessionId, initialPrompt, onSessionNotFound, onStreamingChange, }: ChatProps) { + const { urlSessionId } = useCopilotSessionId(); const hasHandledNotFoundRef = useRef(false); const isSwitchingSession = useCopilotStore((s) => s.isSwitchingSession); const { @@ -37,17 +37,20 @@ export function Chat({ showLoader, } = useChat({ urlSessionId }); - useEffect( - function handleMissingSession() { - if (!onSessionNotFound) return; - if (!urlSessionId) return; - if (!isSessionNotFound || isLoading || isCreating) return; - if (hasHandledNotFoundRef.current) return; - hasHandledNotFoundRef.current = true; - onSessionNotFound(); - }, - [onSessionNotFound, urlSessionId, isSessionNotFound, isLoading, isCreating], - ); + useEffect(() => { + if (!onSessionNotFound) return; + if (!urlSessionId) return; + if (!isSessionNotFound || isLoading || isCreating) return; + if (hasHandledNotFoundRef.current) return; + hasHandledNotFoundRef.current = true; + onSessionNotFound(); + }, [ + onSessionNotFound, + urlSessionId, + isSessionNotFound, + isLoading, + isCreating, + ]); const shouldShowLoader = (showLoader && (isLoading || isCreating)) || isSwitchingSession;