diff --git a/autogpt_platform/frontend/CONTRIBUTING.md b/autogpt_platform/frontend/CONTRIBUTING.md index 1b2b810986..7bda2ad02a 100644 --- a/autogpt_platform/frontend/CONTRIBUTING.md +++ b/autogpt_platform/frontend/CONTRIBUTING.md @@ -549,9 +549,48 @@ Files: Types: - Prefer `interface` for object shapes -- Component props should be `interface Props { ... }` +- Component props should be `interface Props { ... }` (not exported) +- Only use specific exported names (e.g., `export interface MyComponentProps`) when the interface needs to be used outside the component +- Keep type definitions inline with the component - do not create separate `types.ts` files unless types are shared across multiple files - Use precise types; avoid `any` and unsafe casts +**Props naming examples:** + +```tsx +// ✅ Good - internal props, not exported +interface Props { + title: string; + onClose: () => void; +} + +export function Modal({ title, onClose }: Props) { + // ... +} + +// ✅ Good - exported when needed externally +export interface ModalProps { + title: string; + onClose: () => void; +} + +export function Modal({ title, onClose }: ModalProps) { + // ... +} + +// ❌ Bad - unnecessarily specific name for internal use +interface ModalComponentProps { + title: string; + onClose: () => void; +} + +// ❌ Bad - separate types.ts file for single component +// types.ts +export interface ModalProps { ... } + +// Modal.tsx +import type { ModalProps } from './types'; +``` + Parameters: - If more than one parameter is needed, pass a single `Args` object for clarity 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 5d947e1b92..b11e15c3ed 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 @@ -1,24 +1,18 @@ "use client"; -import { Skeleton } from "@/components/__legacy__/ui/skeleton"; -import { Button } from "@/components/atoms/Button/Button"; -import { Text } from "@/components/atoms/Text/Text"; -import { ChatLoader } from "@/components/contextual/Chat/components/ChatLoader/ChatLoader"; -import { InfiniteList } from "@/components/molecules/InfiniteList/InfiniteList"; -import { scrollbarStyles } from "@/components/styles/scrollbars"; import { NAVBAR_HEIGHT_PX } from "@/lib/constants"; -import { cn } from "@/lib/utils"; -import { List, Plus, X } from "@phosphor-icons/react"; import type { ReactNode } from "react"; -import { Drawer } from "vaul"; -import { getSessionTitle } from "./helpers"; +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"; -interface CopilotShellProps { +interface Props { children: ReactNode; } -export function CopilotShell({ children }: CopilotShellProps) { +export function CopilotShell({ children }: Props) { const { isMobile, isDrawerOpen, @@ -36,172 +30,46 @@ export function CopilotShell({ children }: CopilotShellProps) { isReadyToShowContent, } = useCopilotShell(); - function renderSessionsList() { - if (isLoading) { - return ( -
- {Array.from({ length: 5 }).map((_, i) => ( -
- -
- ))} -
- ); - } - - if (sessions.length === 0) { - return ( -
- - No sessions found - -
- ); - } - - return ( - { - const isActive = session.id === currentSessionId; - return ( - - ); - }} - /> - ); - } - return (
- {!isMobile ? ( - - ) : null} + {!isMobile && ( + + )}
- {isMobile ? ( -
- -
- ) : null} + {isMobile && }
- {isReadyToShowContent ? ( - children - ) : ( -
-
- - - Loading your chats... - -
-
- )} + {isReadyToShowContent ? children : }
- {isMobile ? ( - - - - -
-
- - Your tasks - - -
-
-
- {renderSessionsList()} -
-
- -
-
-
-
- ) : null} + /> + )}
); } diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/DesktopSidebar/DesktopSidebar.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/DesktopSidebar/DesktopSidebar.tsx new file mode 100644 index 0000000000..d1abd2cfb0 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/DesktopSidebar/DesktopSidebar.tsx @@ -0,0 +1,66 @@ +import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse"; +import { Button } from "@/components/atoms/Button/Button"; +import { Text } from "@/components/atoms/Text/Text"; +import { scrollbarStyles } from "@/components/styles/scrollbars"; +import { cn } from "@/lib/utils"; +import { Plus } from "@phosphor-icons/react"; +import { SessionsList } from "../SessionsList/SessionsList"; + +interface Props { + sessions: SessionSummaryResponse[]; + currentSessionId: string | null; + isLoading: boolean; + hasNextPage: boolean; + isFetchingNextPage: boolean; + onSelectSession: (sessionId: string) => void; + onFetchNextPage: () => void; + onNewChat: () => void; +} + +export function DesktopSidebar({ + sessions, + currentSessionId, + isLoading, + hasNextPage, + isFetchingNextPage, + onSelectSession, + onFetchNextPage, + onNewChat, +}: Props) { + return ( + + ); +} 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 new file mode 100644 index 0000000000..21b1663916 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/LoadingState/LoadingState.tsx @@ -0,0 +1,15 @@ +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/MobileDrawer.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/MobileDrawer/MobileDrawer.tsx new file mode 100644 index 0000000000..c6637d34d7 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/MobileDrawer/MobileDrawer.tsx @@ -0,0 +1,87 @@ +import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse"; +import { Button } from "@/components/atoms/Button/Button"; +import { scrollbarStyles } from "@/components/styles/scrollbars"; +import { cn } from "@/lib/utils"; +import { Plus, X } from "@phosphor-icons/react"; +import { Drawer } from "vaul"; +import { SessionsList } from "../SessionsList/SessionsList"; + +interface Props { + isOpen: boolean; + sessions: SessionSummaryResponse[]; + currentSessionId: string | null; + isLoading: boolean; + hasNextPage: boolean; + isFetchingNextPage: boolean; + onSelectSession: (sessionId: string) => void; + onFetchNextPage: () => void; + onNewChat: () => void; + onClose: () => void; + onOpenChange: (open: boolean) => void; +} + +export function MobileDrawer({ + isOpen, + sessions, + currentSessionId, + isLoading, + hasNextPage, + isFetchingNextPage, + onSelectSession, + onFetchNextPage, + onNewChat, + onClose, + onOpenChange, +}: Props) { + return ( + + + + +
+
+ + Your tasks + + +
+
+
+ +
+
+ +
+
+
+
+ ); +} 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 new file mode 100644 index 0000000000..c9504e49a9 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/MobileDrawer/useMobileDrawer.ts @@ -0,0 +1,24 @@ +import { useState } from "react"; + +export function useMobileDrawer() { + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + + function handleOpenDrawer() { + setIsDrawerOpen(true); + } + + function handleCloseDrawer() { + setIsDrawerOpen(false); + } + + function handleDrawerOpenChange(open: boolean) { + setIsDrawerOpen(open); + } + + return { + isDrawerOpen, + handleOpenDrawer, + handleCloseDrawer, + handleDrawerOpenChange, + }; +} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/MobileHeader/MobileHeader.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/MobileHeader/MobileHeader.tsx new file mode 100644 index 0000000000..b4fa401992 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/MobileHeader/MobileHeader.tsx @@ -0,0 +1,21 @@ +import { Button } from "@/components/atoms/Button/Button"; +import { List } from "@phosphor-icons/react"; + +interface Props { + onOpenDrawer: () => void; +} + +export function MobileHeader({ onOpenDrawer }: Props) { + return ( +
+ +
+ ); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/SessionsList/SessionsList.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/SessionsList/SessionsList.tsx new file mode 100644 index 0000000000..497170c141 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/SessionsList/SessionsList.tsx @@ -0,0 +1,80 @@ +import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse"; +import { Skeleton } from "@/components/__legacy__/ui/skeleton"; +import { Text } from "@/components/atoms/Text/Text"; +import { InfiniteList } from "@/components/molecules/InfiniteList/InfiniteList"; +import { cn } from "@/lib/utils"; +import { getSessionTitle } from "../../helpers"; + +interface Props { + sessions: SessionSummaryResponse[]; + currentSessionId: string | null; + isLoading: boolean; + hasNextPage: boolean; + isFetchingNextPage: boolean; + onSelectSession: (sessionId: string) => void; + onFetchNextPage: () => void; +} + +export function SessionsList({ + sessions, + currentSessionId, + isLoading, + hasNextPage, + isFetchingNextPage, + onSelectSession, + onFetchNextPage, +}: Props) { + if (isLoading) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ +
+ ))} +
+ ); + } + + if (sessions.length === 0) { + return ( +
+ + No sessions found + +
+ ); + } + + return ( + { + const isActive = session.id === currentSessionId; + return ( + + ); + }} + /> + ); +} 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 new file mode 100644 index 0000000000..0c306a47c0 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/SessionsList/useSessionsPagination.ts @@ -0,0 +1,83 @@ +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 { useEffect, useMemo, useState } from "react"; + +const PAGE_SIZE = 50; + +export interface UseSessionsPaginationArgs { + enabled: boolean; +} + +export function useSessionsPagination({ enabled }: UseSessionsPaginationArgs) { + const [offset, setOffset] = useState(0); + const [accumulatedSessions, setAccumulatedSessions] = useState< + SessionSummaryResponse[] + >([]); + const [totalCount, setTotalCount] = useState(null); + + const { data, isLoading, isFetching } = useGetV2ListSessions( + { limit: PAGE_SIZE, offset }, + { + query: { + enabled: enabled && offset >= 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]); + } + } + }, [data, offset]); + + const hasNextPage = useMemo(() => { + if (totalCount === null) return false; + return accumulatedSessions.length < totalCount; + }, [accumulatedSessions.length, totalCount]); + + const areAllSessionsLoaded = useMemo(() => { + if (totalCount === null) return false; + return ( + accumulatedSessions.length >= totalCount && !isFetching && !isLoading + ); + }, [accumulatedSessions.length, totalCount, isFetching, isLoading]); + + useEffect(() => { + if (hasNextPage && !isFetching && !isLoading && totalCount !== null) { + setOffset((prev) => prev + PAGE_SIZE); + } + }, [hasNextPage, isFetching, isLoading, totalCount]); + + function fetchNextPage() { + if (hasNextPage && !isFetching) { + setOffset((prev) => prev + PAGE_SIZE); + } + } + + function reset() { + setOffset(0); + setAccumulatedSessions([]); + setTotalCount(null); + } + + return { + sessions: accumulatedSessions, + isLoading, + isFetching, + hasNextPage, + areAllSessionsLoaded, + totalCount, + fetchNextPage, + reset, + }; +} 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 f7d8106b49..49624c7d2a 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 @@ -1,6 +1,18 @@ +import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse"; import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse"; import { format, formatDistanceToNow, isToday } from "date-fns"; +export function convertSessionDetailToSummary( + session: SessionDetailResponse, +): SessionSummaryResponse { + return { + id: session.id, + created_at: session.created_at, + updated_at: session.updated_at, + title: undefined, + }; +} + export function filterVisibleSessions( sessions: SessionSummaryResponse[], ): SessionSummaryResponse[] { @@ -28,3 +40,119 @@ export function getSessionUpdatedLabel( if (!session.updated_at) return ""; return formatDistanceToNow(new Date(session.updated_at), { addSuffix: true }); } + +export function mergeCurrentSessionIntoList( + accumulatedSessions: SessionSummaryResponse[], + currentSessionId: string | null, + currentSessionData: SessionDetailResponse | undefined, +): SessionSummaryResponse[] { + const filteredSessions: SessionSummaryResponse[] = []; + + if (accumulatedSessions.length > 0) { + const visibleSessions = filterVisibleSessions(accumulatedSessions); + + if (currentSessionId) { + const currentInAll = accumulatedSessions.find( + (s) => s.id === currentSessionId, + ); + if (currentInAll) { + const isInVisible = visibleSessions.some( + (s) => s.id === currentSessionId, + ); + if (!isInVisible) { + filteredSessions.push(currentInAll); + } + } + } + + filteredSessions.push(...visibleSessions); + } + + if (currentSessionId && currentSessionData) { + const isCurrentInList = filteredSessions.some( + (s) => s.id === currentSessionId, + ); + if (!isCurrentInList) { + const summarySession = convertSessionDetailToSummary(currentSessionData); + filteredSessions.unshift(summarySession); + } + } + + return filteredSessions; +} + +export function getCurrentSessionId( + searchParams: URLSearchParams, + storedSessionId: string | null, +): string | null { + const paramSessionId = searchParams.get("sessionId"); + if (paramSessionId) return paramSessionId; + if (storedSessionId) return storedSessionId; + return null; +} + +export function shouldAutoSelectSession( + areAllSessionsLoaded: boolean, + hasAutoSelectedSession: boolean, + paramSessionId: string | null, + visibleSessions: SessionSummaryResponse[], + accumulatedSessions: SessionSummaryResponse[], + isLoading: boolean, + totalCount: number | null, +): { + shouldSelect: boolean; + sessionIdToSelect: string | null; + shouldCreate: boolean; +} { + 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 | undefined, + hasAutoSelectedSession: boolean, +): boolean { + if (!areAllSessionsLoaded) return false; + + if (paramSessionId) { + const sessionFound = accumulatedSessions.some( + (s) => s.id === paramSessionId, + ); + return ( + sessionFound || (!isCurrentSessionLoading && currentSessionData !== undefined) + ); + } + + 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 98b99163fe..b64153b96e 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 @@ -3,222 +3,71 @@ import { postV2CreateSession, useGetV2GetSession, - useGetV2ListSessions, } 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 { okData } from "@/app/api/helpers"; import { useBreakpoint } from "@/lib/hooks/useBreakpoint"; import { Key, storage } from "@/services/storage/local-storage"; import { useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { filterVisibleSessions } from "./helpers"; - -function convertSessionDetailToSummary( - session: SessionDetailResponse, -): SessionSummaryResponse { - return { - id: session.id, - created_at: session.created_at, - updated_at: session.updated_at, - title: undefined, - }; -} +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useMobileDrawer } from "./components/MobileDrawer/useMobileDrawer"; +import { useSessionsPagination } from "./components/SessionsList/useSessionsPagination"; +import { + checkReadyToShowContent, + filterVisibleSessions, + getCurrentSessionId, + mergeCurrentSessionIntoList, + shouldAutoSelectSession, +} from "./helpers"; export function useCopilotShell() { const router = useRouter(); const searchParams = useSearchParams(); const breakpoint = useBreakpoint(); - const [isDrawerOpen, setIsDrawerOpen] = useState(false); const isMobile = breakpoint === "base" || breakpoint === "sm" || breakpoint === "md"; - const [offset, setOffset] = useState(0); - const [accumulatedSessions, setAccumulatedSessions] = useState< - SessionSummaryResponse[] - >([]); - const [totalCount, setTotalCount] = useState(null); - const [hasAutoSelectedSession, setHasAutoSelectedSession] = useState(false); - const hasCreatedSessionRef = useRef(false); - const PAGE_SIZE = 50; + const { + isDrawerOpen, + handleOpenDrawer, + handleCloseDrawer, + handleDrawerOpenChange, + } = useMobileDrawer(); - const { data, isLoading, isFetching } = useGetV2ListSessions( - { limit: PAGE_SIZE, offset }, - { - query: { - enabled: (!isMobile || isDrawerOpen) && offset >= 0, - }, - }, - ); + const paginationEnabled = !isMobile || isDrawerOpen; - 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]); - } - } - }, [data, offset]); - - const hasNextPage = useMemo(() => { - if (totalCount === null) return false; - return accumulatedSessions.length < totalCount; - }, [accumulatedSessions.length, totalCount]); - - const areAllSessionsLoaded = useMemo(() => { - if (totalCount === null) return false; - return ( - accumulatedSessions.length >= totalCount && !isFetching && !isLoading - ); - }, [accumulatedSessions.length, totalCount, isFetching, isLoading]); - - useEffect(() => { - if (hasNextPage && !isFetching && !isLoading && totalCount !== null) { - setOffset((prev) => prev + PAGE_SIZE); - } - }, [hasNextPage, isFetching, isLoading, totalCount]); - - const fetchNextPage = () => { - if (hasNextPage && !isFetching) { - setOffset((prev) => prev + PAGE_SIZE); - } - }; - - // Reset when query becomes disabled (mobile with drawer closed) - useEffect(() => { - const isQueryEnabled = !isMobile || isDrawerOpen; - if (!isQueryEnabled) { - setOffset(0); - setAccumulatedSessions([]); - setTotalCount(null); - setHasAutoSelectedSession(false); - hasCreatedSessionRef.current = false; - } - }, [isMobile, isDrawerOpen]); + const { + sessions: accumulatedSessions, + isLoading: isSessionsLoading, + isFetching: isSessionsFetching, + hasNextPage, + areAllSessionsLoaded, + totalCount, + fetchNextPage, + reset: resetPagination, + } = useSessionsPagination({ + enabled: paginationEnabled, + }); + const storedSessionId = storage.get(Key.CHAT_SESSION_ID) ?? null; const currentSessionId = useMemo( - function getCurrentSessionId() { - const paramSessionId = searchParams.get("sessionId"); - if (paramSessionId) return paramSessionId; - const storedSessionId = storage.get(Key.CHAT_SESSION_ID); - if (storedSessionId) return storedSessionId; - return null; - }, - [searchParams], + () => getCurrentSessionId(searchParams, storedSessionId), + [searchParams, storedSessionId], ); const { data: currentSessionData, isLoading: isCurrentSessionLoading } = useGetV2GetSession(currentSessionId || "", { query: { - enabled: !!currentSessionId && (!isMobile || isDrawerOpen), + enabled: !!currentSessionId && paginationEnabled, select: okData, }, }); - const sessions = useMemo( - function getSessions() { - const filteredSessions: SessionSummaryResponse[] = []; - - if (accumulatedSessions.length > 0) { - const visibleSessions = filterVisibleSessions(accumulatedSessions); - - if (currentSessionId) { - const currentInAll = accumulatedSessions.find( - (s) => s.id === currentSessionId, - ); - if (currentInAll) { - const isInVisible = visibleSessions.some( - (s) => s.id === currentSessionId, - ); - if (!isInVisible) { - filteredSessions.push(currentInAll); - } - } - } - - filteredSessions.push(...visibleSessions); - } - - if (currentSessionId && currentSessionData) { - const isCurrentInList = filteredSessions.some( - (s) => s.id === currentSessionId, - ); - if (!isCurrentInList) { - const summarySession = - convertSessionDetailToSummary(currentSessionData); - // Add new session at the beginning to match API order (most recent first) - filteredSessions.unshift(summarySession); - } - } - - return filteredSessions; - }, - [accumulatedSessions, currentSessionId, currentSessionData], - ); - - function handleSelectSession(sessionId: string) { - router.push(`/copilot/chat?sessionId=${sessionId}`); - if (isMobile) setIsDrawerOpen(false); - } - - function handleOpenDrawer() { - setIsDrawerOpen(true); - } - - function handleCloseDrawer() { - setIsDrawerOpen(false); - } - - function handleDrawerOpenChange(open: boolean) { - setIsDrawerOpen(open); - } - - function handleNewChat() { - storage.clean(Key.CHAT_SESSION_ID); - setHasAutoSelectedSession(false); - hasCreatedSessionRef.current = false; - postV2CreateSession({ body: JSON.stringify({}) }) - .then((response) => { - if (response.status === 200 && response.data) { - router.push(`/copilot/chat?sessionId=${response.data.id}`); - setHasAutoSelectedSession(true); - } - }) - .catch(() => { - hasCreatedSessionRef.current = false; - }); - if (isMobile) setIsDrawerOpen(false); - } - + const [hasAutoSelectedSession, setHasAutoSelectedSession] = useState(false); + const hasCreatedSessionRef = useRef(false); const paramSessionId = searchParams.get("sessionId"); - useEffect(() => { - if (!areAllSessionsLoaded || hasAutoSelectedSession) return; - - const visibleSessions = filterVisibleSessions(accumulatedSessions); - - if (paramSessionId) { - setHasAutoSelectedSession(true); - return; - } - - if (visibleSessions.length > 0) { - const lastSession = visibleSessions[0]; - setHasAutoSelectedSession(true); - router.push(`/copilot/chat?sessionId=${lastSession.id}`); - } else if ( - accumulatedSessions.length === 0 && - !isLoading && - totalCount === 0 && - !hasCreatedSessionRef.current - ) { - hasCreatedSessionRef.current = true; + const createSessionAndNavigate = useCallback( + function createSessionAndNavigate() { postV2CreateSession({ body: JSON.stringify({}) }) .then((response) => { if (response.status === 200 && response.data) { @@ -229,6 +78,35 @@ export function useCopilotShell() { .catch(() => { hasCreatedSessionRef.current = false; }); + }, + [router], + ); + + useEffect(() => { + if (!areAllSessionsLoaded || hasAutoSelectedSession) return; + + const visibleSessions = filterVisibleSessions(accumulatedSessions); + const autoSelect = shouldAutoSelectSession( + areAllSessionsLoaded, + hasAutoSelectedSession, + paramSessionId, + visibleSessions, + accumulatedSessions, + isSessionsLoading, + totalCount, + ); + + if (paramSessionId) { + setHasAutoSelectedSession(true); + return; + } + + if (autoSelect.shouldSelect && autoSelect.sessionIdToSelect) { + setHasAutoSelectedSession(true); + router.push(`/copilot/chat?sessionId=${autoSelect.sessionIdToSelect}`); + } else if (autoSelect.shouldCreate && !hasCreatedSessionRef.current) { + hasCreatedSessionRef.current = true; + createSessionAndNavigate(); } else if (totalCount === 0) { setHasAutoSelectedSession(true); } @@ -238,8 +116,9 @@ export function useCopilotShell() { paramSessionId, hasAutoSelectedSession, router, - isLoading, + isSessionsLoading, totalCount, + createSessionAndNavigate, ]); useEffect(() => { @@ -248,33 +127,66 @@ export function useCopilotShell() { } }, [paramSessionId]); - const isReadyToShowContent = useMemo(() => { - if (!areAllSessionsLoaded) return false; + function resetAutoSelect() { + setHasAutoSelectedSession(false); + hasCreatedSessionRef.current = false; + } - if (paramSessionId) { - const sessionFound = accumulatedSessions.some( - (s) => s.id === paramSessionId, - ); - const sessionLoading = isCurrentSessionLoading; - return ( - sessionFound || (!sessionLoading && currentSessionData !== undefined) - ); + // Reset pagination and auto-selection when query becomes disabled + useEffect(() => { + if (!paginationEnabled) { + resetPagination(); + resetAutoSelect(); } + }, [paginationEnabled, resetPagination]); - return hasAutoSelectedSession; - }, [ - areAllSessionsLoaded, - accumulatedSessions, - paramSessionId, - isCurrentSessionLoading, - currentSessionData, - hasAutoSelectedSession, - ]); + const sessions = useMemo( + function getSessions() { + return mergeCurrentSessionIntoList( + accumulatedSessions, + currentSessionId, + currentSessionData, + ); + }, + [accumulatedSessions, currentSessionId, currentSessionData], + ); + + function handleSelectSession(sessionId: string) { + router.push(`/copilot/chat?sessionId=${sessionId}`); + if (isMobile) handleCloseDrawer(); + } + + function handleNewChat() { + storage.clean(Key.CHAT_SESSION_ID); + resetAutoSelect(); + createSessionAndNavigate(); + if (isMobile) handleCloseDrawer(); + } + + const isReadyToShowContent = useMemo( + () => + checkReadyToShowContent( + areAllSessionsLoaded, + paramSessionId, + accumulatedSessions, + isCurrentSessionLoading, + currentSessionData, + hasAutoSelectedSession, + ), + [ + areAllSessionsLoaded, + paramSessionId, + accumulatedSessions, + isCurrentSessionLoading, + currentSessionData, + hasAutoSelectedSession, + ], + ); return { isMobile, isDrawerOpen, - isLoading: isLoading || !areAllSessionsLoaded, + isLoading: isSessionsLoading || !areAllSessionsLoaded, sessions, currentSessionId, handleSelectSession, @@ -282,10 +194,9 @@ export function useCopilotShell() { handleCloseDrawer, handleDrawerOpenChange, handleNewChat, - hasNextPage: hasNextPage ?? false, - isFetchingNextPage: isFetching, + hasNextPage, + isFetchingNextPage: isSessionsFetching, fetchNextPage, isReadyToShowContent, - areAllSessionsLoaded, }; } diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/Chat.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/Chat.tsx index 3d28b2c466..8a5025444d 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/Chat.tsx +++ b/autogpt_platform/frontend/src/components/contextual/Chat/Chat.tsx @@ -73,7 +73,7 @@ export function Chat({ )} {/* Main Content */} -
+
{/* Loading State - show loader when loading or creating a session (with 300ms delay) */} {showLoader && (isLoading || isCreating) && (
diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/ChatContainer.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/ChatContainer.tsx index 2f6c5c71db..9928a16c3e 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/ChatContainer.tsx +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/ChatContainer.tsx @@ -51,7 +51,7 @@ export function ChatContainer({ return (
diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/MessageList.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/MessageList.tsx index 0e8eb0ebaf..5cfa9bfdcd 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/MessageList.tsx +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/MessageList.tsx @@ -1,12 +1,12 @@ "use client"; import { cn } from "@/lib/utils"; -import { AIChatBubble } from "../AIChatBubble/AIChatBubble"; -import { ChatMessage } from "../ChatMessage/ChatMessage"; import type { ChatMessageData } from "../ChatMessage/useChatMessage"; -import { MarkdownContent } from "../MarkdownContent/MarkdownContent"; import { StreamingMessage } from "../StreamingMessage/StreamingMessage"; import { ThinkingMessage } from "../ThinkingMessage/ThinkingMessage"; +import { LastToolResponse } from "./components/LastToolResponse/LastToolResponse"; +import { MessageItem } from "./components/MessageItem/MessageItem"; +import { findLastMessageIndex, shouldSkipAgentOutput } from "./helpers"; import { useMessageList } from "./useMessageList"; export interface MessageListProps { @@ -43,192 +43,44 @@ export function MessageList({
{/* Render all persisted messages */} {(() => { - let lastAssistantMessageIndex = -1; - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (msg.type === "message" && msg.role === "assistant") { - lastAssistantMessageIndex = i; - break; - } - } + const lastAssistantMessageIndex = findLastMessageIndex( + messages, + (msg) => msg.type === "message" && msg.role === "assistant", + ); - let lastToolResponseIndex = -1; - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (msg.type === "tool_response") { - lastToolResponseIndex = i; - break; - } - } + const lastToolResponseIndex = findLastMessageIndex( + messages, + (msg) => msg.type === "tool_response", + ); return messages.map((message, index) => { - // Log message for debugging - if (message.type === "message" && message.role === "assistant") { - const prevMessage = messages[index - 1]; - const prevMessageToolName = - prevMessage?.type === "tool_call" - ? prevMessage.toolName - : undefined; - console.log("[MessageList] Assistant message:", { - index, - content: message.content.substring(0, 200), - fullContent: message.content, - prevMessageType: prevMessage?.type, - prevMessageToolName, - }); + // Skip agent_output tool_responses that should be rendered inside assistant messages + if (shouldSkipAgentOutput(message, messages[index - 1])) { + return null; } - // Check if current message is an agent_output tool_response - // and if previous message is an assistant message - let agentOutput: ChatMessageData | undefined; - let messageToRender: ChatMessageData = message; - - if (message.type === "tool_response" && message.result) { - let parsedResult: Record | null = null; - try { - parsedResult = - typeof message.result === "string" - ? JSON.parse(message.result) - : (message.result as Record); - } catch { - parsedResult = null; - } - if (parsedResult?.type === "agent_output") { - const prevMessage = messages[index - 1]; - if ( - prevMessage && - prevMessage.type === "message" && - prevMessage.role === "assistant" - ) { - // This agent output will be rendered inside the previous assistant message - // Skip rendering this message separately - return null; - } - } - } - - // Check if assistant message follows a tool_call and looks like a tool output - if (message.type === "message" && message.role === "assistant") { - const prevMessage = messages[index - 1]; - - // Check if next message is an agent_output tool_response to include in current assistant message - const nextMessage = messages[index + 1]; - if ( - nextMessage && - nextMessage.type === "tool_response" && - nextMessage.result - ) { - let parsedResult: Record | null = null; - try { - parsedResult = - typeof nextMessage.result === "string" - ? JSON.parse(nextMessage.result) - : (nextMessage.result as Record); - } catch { - parsedResult = null; - } - if (parsedResult?.type === "agent_output") { - agentOutput = nextMessage; - } - } - - // Only convert to tool_response if it follows a tool_call AND looks like a tool output - if (prevMessage && prevMessage.type === "tool_call") { - const content = message.content.toLowerCase().trim(); - // Patterns that indicate this is a tool output result, not an agent response - const isToolOutputPattern = - content.startsWith("no agents found") || - content.startsWith("no results found") || - content.includes("no agents found matching") || - content.match(/^no \w+ found/i) || - (content.length < 150 && content.includes("try different")) || - (content.length < 200 && - !content.includes("i'll") && - !content.includes("let me") && - !content.includes("i can") && - !content.includes("i will")); - - console.log( - "[MessageList] Checking if assistant message is tool output:", - { - content: message.content.substring(0, 100), - isToolOutputPattern, - prevToolName: prevMessage.toolName, - }, - ); - - if (isToolOutputPattern) { - // Convert this message to a tool_response format for rendering - messageToRender = { - type: "tool_response", - toolId: prevMessage.toolId, - toolName: prevMessage.toolName, - result: message.content, - success: true, - timestamp: message.timestamp, - } as ChatMessageData; - } - } - } - - const isFinalMessage = - messageToRender.type !== "message" || - messageToRender.role !== "assistant" || - index === lastAssistantMessageIndex; - - // Render last tool_response as AIChatBubble (but skip agent_output that's rendered inside assistant message) + // Render last tool_response as AIChatBubble if ( - messageToRender.type === "tool_response" && message.type === "tool_response" && index === lastToolResponseIndex ) { - // Check if this is an agent_output that should be rendered inside assistant message - let parsedResult: Record | null = null; - try { - parsedResult = - typeof messageToRender.result === "string" - ? JSON.parse(messageToRender.result) - : (messageToRender.result as Record); - } catch { - parsedResult = null; - } - - const isAgentOutput = parsedResult?.type === "agent_output"; - const prevMessage = messages[index - 1]; - const shouldSkip = - isAgentOutput && - prevMessage && - prevMessage.type === "message" && - prevMessage.role === "assistant"; - - if (shouldSkip) return null; - - const resultValue = - typeof messageToRender.result === "string" - ? messageToRender.result - : messageToRender.result - ? JSON.stringify(messageToRender.result, null, 2) - : ""; - return ( -
- - - -
+ message={message} + prevMessage={messages[index - 1]} + /> ); } return ( - ); }); diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/components/LastToolResponse/LastToolResponse.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/components/LastToolResponse/LastToolResponse.tsx new file mode 100644 index 0000000000..b12d307da0 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/components/LastToolResponse/LastToolResponse.tsx @@ -0,0 +1,32 @@ +import { AIChatBubble } from "../../../AIChatBubble/AIChatBubble"; +import type { ChatMessageData } from "../../../ChatMessage/useChatMessage"; +import { MarkdownContent } from "../../../MarkdownContent/MarkdownContent"; +import { + formatToolResultValue, + shouldSkipAgentOutput +} from "../../helpers"; + +export interface LastToolResponseProps { + message: ChatMessageData; + prevMessage: ChatMessageData | undefined; +} + +export function LastToolResponse({ + message, + prevMessage, +}: LastToolResponseProps) { + if (message.type !== "tool_response") return null; + + // Skip if this is an agent_output that should be rendered inside assistant message + if (shouldSkipAgentOutput(message, prevMessage)) return null; + + const resultValue = formatToolResultValue(message.result); + + return ( +
+ + + +
+ ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/components/MessageItem/MessageItem.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/components/MessageItem/MessageItem.tsx new file mode 100644 index 0000000000..f3c11a55d7 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/components/MessageItem/MessageItem.tsx @@ -0,0 +1,35 @@ +import { ChatMessage } from "../../../ChatMessage/ChatMessage"; +import type { ChatMessageData } from "../../../ChatMessage/useChatMessage"; +import { useMessageItem } from "./useMessageItem"; + +export interface MessageItemProps { + message: ChatMessageData; + messages: ChatMessageData[]; + index: number; + lastAssistantMessageIndex: number; + onSendMessage?: (content: string) => void; +} + +export function MessageItem({ + message, + messages, + index, + lastAssistantMessageIndex, + onSendMessage, +}: MessageItemProps) { + const { messageToRender, agentOutput, isFinalMessage } = useMessageItem({ + message, + messages, + index, + lastAssistantMessageIndex, + }); + + return ( + + ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/components/MessageItem/useMessageItem.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/components/MessageItem/useMessageItem.ts new file mode 100644 index 0000000000..61f58e90d1 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/components/MessageItem/useMessageItem.ts @@ -0,0 +1,86 @@ +import type { ChatMessageData } from "../../../ChatMessage/useChatMessage"; +import { + isAgentOutputResult, + isToolOutputPattern +} from "../../helpers"; + +export interface UseMessageItemArgs { + message: ChatMessageData; + messages: ChatMessageData[]; + index: number; + lastAssistantMessageIndex: number; +} + +export function useMessageItem({ + message, + messages, + index, + lastAssistantMessageIndex, +}: UseMessageItemArgs) { + let agentOutput: ChatMessageData | undefined; + let messageToRender: ChatMessageData = message; + + // Check if assistant message follows a tool_call and looks like a tool output + if (message.type === "message" && message.role === "assistant") { + const prevMessage = messages[index - 1]; + + // Check if next message is an agent_output tool_response to include in current assistant message + const nextMessage = messages[index + 1]; + if ( + nextMessage && + nextMessage.type === "tool_response" && + nextMessage.result + ) { + if (isAgentOutputResult(nextMessage.result)) { + agentOutput = nextMessage; + } + } + + // Only convert to tool_response if it follows a tool_call AND looks like a tool output + if (prevMessage && prevMessage.type === "tool_call") { + if (isToolOutputPattern(message.content)) { + // Convert this message to a tool_response format for rendering + messageToRender = { + type: "tool_response", + toolId: prevMessage.toolId, + toolName: prevMessage.toolName, + result: message.content, + success: true, + timestamp: message.timestamp, + } as ChatMessageData; + + console.log( + "[MessageItem] Converting assistant message to tool output:", + { + content: message.content.substring(0, 100), + prevToolName: prevMessage.toolName, + }, + ); + } + } + + // Log for debugging + if (message.type === "message" && message.role === "assistant") { + const prevMessageToolName = + prevMessage?.type === "tool_call" ? prevMessage.toolName : undefined; + console.log("[MessageItem] Assistant message:", { + index, + content: message.content.substring(0, 200), + fullContent: message.content, + prevMessageType: prevMessage?.type, + prevMessageToolName, + }); + } + } + + const isFinalMessage = + messageToRender.type !== "message" || + messageToRender.role !== "assistant" || + index === lastAssistantMessageIndex; + + return { + messageToRender, + agentOutput, + isFinalMessage, + }; +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/helpers.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/helpers.ts new file mode 100644 index 0000000000..f6731c66c7 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/helpers.ts @@ -0,0 +1,68 @@ +import type { ChatMessageData } from "../ChatMessage/useChatMessage"; + +export function parseToolResult( + result: unknown, +): Record | null { + try { + return typeof result === "string" + ? JSON.parse(result) + : (result as Record); + } catch { + return null; + } +} + +export function isAgentOutputResult(result: unknown): boolean { + const parsed = parseToolResult(result); + return parsed?.type === "agent_output"; +} + +export function isToolOutputPattern(content: string): boolean { + const normalizedContent = content.toLowerCase().trim(); + + return ( + normalizedContent.startsWith("no agents found") || + normalizedContent.startsWith("no results found") || + normalizedContent.includes("no agents found matching") || + !!normalizedContent.match(/^no \w+ found/i) || + (content.length < 150 && normalizedContent.includes("try different")) || + (content.length < 200 && + !normalizedContent.includes("i'll") && + !normalizedContent.includes("let me") && + !normalizedContent.includes("i can") && + !normalizedContent.includes("i will")) + ); +} + +export function formatToolResultValue(result: unknown): string { + return typeof result === "string" + ? result + : result + ? JSON.stringify(result, null, 2) + : ""; +} + +export function findLastMessageIndex( + messages: ChatMessageData[], + predicate: (msg: ChatMessageData) => boolean, +): number { + for (let i = messages.length - 1; i >= 0; i--) { + if (predicate(messages[i])) return i; + } + return -1; +} + +export function shouldSkipAgentOutput( + message: ChatMessageData, + prevMessage: ChatMessageData | undefined, +): boolean { + if (message.type !== "tool_response" || !message.result) return false; + + const isAgentOutput = isAgentOutputResult(message.result); + return ( + isAgentOutput && + !!prevMessage && + prevMessage.type === "message" && + prevMessage.role === "assistant" + ); +}