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()}
-
-
- }
- >
- New Chat
-
-
-
-
-
- ) : 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
+
+
+
+
+
+
+
+
+ }
+ >
+ New Chat
+
+
+
+
+
+ );
+}
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"
+ );
+}