mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-22 13:38:10 -05:00
chore: tidy ups...
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-1">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg px-3 py-2.5">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Text variant="body" className="text-zinc-500">
|
||||
No sessions found
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InfiniteList
|
||||
items={sessions}
|
||||
hasMore={hasNextPage}
|
||||
isFetchingMore={isFetchingNextPage}
|
||||
onEndReached={fetchNextPage}
|
||||
className="space-y-1"
|
||||
renderItem={(session) => {
|
||||
const isActive = session.id === currentSessionId;
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
className={cn(
|
||||
"w-full rounded-lg px-3 py-2.5 text-left transition-colors",
|
||||
isActive ? "bg-zinc-100" : "hover:bg-zinc-50",
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
className={cn(
|
||||
"font-normal",
|
||||
isActive ? "text-zinc-600" : "text-zinc-800",
|
||||
)}
|
||||
>
|
||||
{getSessionTitle(session)}
|
||||
</Text>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex overflow-hidden bg-zinc-50"
|
||||
style={{ height: `calc(100vh - ${NAVBAR_HEIGHT_PX}px)` }}
|
||||
>
|
||||
{!isMobile ? (
|
||||
<aside className="flex h-full w-80 flex-col border-r border-zinc-100 bg-white">
|
||||
<div className="shrink-0 px-6 py-4">
|
||||
<Text variant="h3" size="body-medium">
|
||||
Your chats
|
||||
</Text>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col overflow-y-auto px-3 py-3",
|
||||
scrollbarStyles,
|
||||
)}
|
||||
>
|
||||
{renderSessionsList()}
|
||||
</div>
|
||||
<div className="shrink-0 bg-white p-3 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={handleNewChat}
|
||||
className="w-full"
|
||||
leftIcon={<Plus width="1rem" height="1rem" />}
|
||||
>
|
||||
New Chat
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
{!isMobile && (
|
||||
<DesktopSidebar
|
||||
sessions={sessions}
|
||||
currentSessionId={currentSessionId}
|
||||
isLoading={isLoading}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onSelectSession={handleSelectSession}
|
||||
onFetchNextPage={fetchNextPage}
|
||||
onNewChat={handleNewChat}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{isMobile ? (
|
||||
<header className="flex items-center justify-between px-4 py-3">
|
||||
<Button
|
||||
variant="icon"
|
||||
size="icon"
|
||||
aria-label="Open sessions"
|
||||
onClick={handleOpenDrawer}
|
||||
>
|
||||
<List width="1.25rem" height="1.25rem" />
|
||||
</Button>
|
||||
</header>
|
||||
) : null}
|
||||
{isMobile && <MobileHeader onOpenDrawer={handleOpenDrawer} />}
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{isReadyToShowContent ? (
|
||||
children
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<ChatLoader />
|
||||
<Text variant="body" className="text-zinc-500">
|
||||
Loading your chats...
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isReadyToShowContent ? children : <LoadingState />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMobile ? (
|
||||
<Drawer.Root
|
||||
open={isDrawerOpen}
|
||||
{isMobile && (
|
||||
<MobileDrawer
|
||||
isOpen={isDrawerOpen}
|
||||
sessions={sessions}
|
||||
currentSessionId={currentSessionId}
|
||||
isLoading={isLoading}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onSelectSession={handleSelectSession}
|
||||
onFetchNextPage={fetchNextPage}
|
||||
onNewChat={handleNewChat}
|
||||
onClose={handleCloseDrawer}
|
||||
onOpenChange={handleDrawerOpenChange}
|
||||
direction="left"
|
||||
>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 z-[60] bg-black/10 backdrop-blur-sm" />
|
||||
<Drawer.Content className="fixed left-0 top-0 z-[70] flex h-full w-80 flex-col border-r border-zinc-200 bg-white">
|
||||
<div className="shrink-0 border-b border-zinc-200 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Drawer.Title className="text-lg font-semibold text-zinc-800">
|
||||
Your tasks
|
||||
</Drawer.Title>
|
||||
<Button
|
||||
variant="icon"
|
||||
size="icon"
|
||||
aria-label="Close sessions"
|
||||
onClick={handleCloseDrawer}
|
||||
>
|
||||
<X width="1.25rem" height="1.25rem" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col overflow-y-auto px-3 py-3",
|
||||
scrollbarStyles,
|
||||
)}
|
||||
>
|
||||
{renderSessionsList()}
|
||||
</div>
|
||||
<div className="shrink-0 bg-white p-3 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={handleNewChat}
|
||||
className="w-full"
|
||||
leftIcon={<Plus width="1rem" height="1rem" />}
|
||||
>
|
||||
New Chat
|
||||
</Button>
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
) : null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<aside className="flex h-full w-80 flex-col border-r border-zinc-100 bg-white">
|
||||
<div className="shrink-0 px-6 py-4">
|
||||
<Text variant="h3" size="body-medium">
|
||||
Your chats
|
||||
</Text>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col overflow-y-auto px-3 py-3",
|
||||
scrollbarStyles,
|
||||
)}
|
||||
>
|
||||
<SessionsList
|
||||
sessions={sessions}
|
||||
currentSessionId={currentSessionId}
|
||||
isLoading={isLoading}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onSelectSession={onSelectSession}
|
||||
onFetchNextPage={onFetchNextPage}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 bg-white p-3 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={onNewChat}
|
||||
className="w-full"
|
||||
leftIcon={<Plus width="1rem" height="1rem" />}
|
||||
>
|
||||
New Chat
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { ChatLoader } from "@/components/contextual/Chat/components/ChatLoader/ChatLoader";
|
||||
|
||||
export function LoadingState() {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<ChatLoader />
|
||||
<Text variant="body" className="text-zinc-500">
|
||||
Loading your chats...
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Drawer.Root open={isOpen} onOpenChange={onOpenChange} direction="left">
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 z-[60] bg-black/10 backdrop-blur-sm" />
|
||||
<Drawer.Content className="fixed left-0 top-0 z-[70] flex h-full w-80 flex-col border-r border-zinc-200 bg-white">
|
||||
<div className="shrink-0 border-b border-zinc-200 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Drawer.Title className="text-lg font-semibold text-zinc-800">
|
||||
Your tasks
|
||||
</Drawer.Title>
|
||||
<Button
|
||||
variant="icon"
|
||||
size="icon"
|
||||
aria-label="Close sessions"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X width="1.25rem" height="1.25rem" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col overflow-y-auto px-3 py-3",
|
||||
scrollbarStyles,
|
||||
)}
|
||||
>
|
||||
<SessionsList
|
||||
sessions={sessions}
|
||||
currentSessionId={currentSessionId}
|
||||
isLoading={isLoading}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onSelectSession={onSelectSession}
|
||||
onFetchNextPage={onFetchNextPage}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 bg-white p-3 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={onNewChat}
|
||||
className="w-full"
|
||||
leftIcon={<Plus width="1rem" height="1rem" />}
|
||||
>
|
||||
New Chat
|
||||
</Button>
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
<header className="flex items-center justify-between px-4 py-3">
|
||||
<Button
|
||||
variant="icon"
|
||||
size="icon"
|
||||
aria-label="Open sessions"
|
||||
onClick={onOpenDrawer}
|
||||
>
|
||||
<List width="1.25rem" height="1.25rem" />
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-1">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg px-3 py-2.5">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Text variant="body" className="text-zinc-500">
|
||||
No sessions found
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InfiniteList
|
||||
items={sessions}
|
||||
hasMore={hasNextPage}
|
||||
isFetchingMore={isFetchingNextPage}
|
||||
onEndReached={onFetchNextPage}
|
||||
className="space-y-1"
|
||||
renderItem={(session) => {
|
||||
const isActive = session.id === currentSessionId;
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
className={cn(
|
||||
"w-full rounded-lg px-3 py-2.5 text-left transition-colors",
|
||||
isActive ? "bg-zinc-100" : "hover:bg-zinc-50",
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
className={cn(
|
||||
"font-normal",
|
||||
isActive ? "text-zinc-600" : "text-zinc-800",
|
||||
)}
|
||||
>
|
||||
{getSessionTitle(session)}
|
||||
</Text>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<number | null>(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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<number | null>(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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export function Chat({
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<main className="flex min-h-0 flex-1 flex-col overflow-hidden w-full">
|
||||
{/* Loading State - show loader when loading or creating a session (with 300ms delay) */}
|
||||
{showLoader && (isLoading || isCreating) && (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
|
||||
@@ -51,7 +51,7 @@ export function ChatContainer({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex h-full min-h-0 max-w-3xl flex-col bg-[#f8f8f9]",
|
||||
"mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col bg-[#f8f8f9]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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({
|
||||
<div className="mx-auto flex min-w-0 flex-col hyphens-auto break-words py-4">
|
||||
{/* 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<string, unknown> | null = null;
|
||||
try {
|
||||
parsedResult =
|
||||
typeof message.result === "string"
|
||||
? JSON.parse(message.result)
|
||||
: (message.result as Record<string, unknown>);
|
||||
} 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<string, unknown> | null = null;
|
||||
try {
|
||||
parsedResult =
|
||||
typeof nextMessage.result === "string"
|
||||
? JSON.parse(nextMessage.result)
|
||||
: (nextMessage.result as Record<string, unknown>);
|
||||
} 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<string, unknown> | null = null;
|
||||
try {
|
||||
parsedResult =
|
||||
typeof messageToRender.result === "string"
|
||||
? JSON.parse(messageToRender.result)
|
||||
: (messageToRender.result as Record<string, unknown>);
|
||||
} 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 (
|
||||
<div
|
||||
<LastToolResponse
|
||||
key={index}
|
||||
className="min-w-0 overflow-x-hidden hyphens-auto break-words px-4 py-2"
|
||||
>
|
||||
<AIChatBubble>
|
||||
<MarkdownContent content={resultValue} />
|
||||
</AIChatBubble>
|
||||
</div>
|
||||
message={message}
|
||||
prevMessage={messages[index - 1]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatMessage
|
||||
<MessageItem
|
||||
key={index}
|
||||
message={messageToRender}
|
||||
message={message}
|
||||
messages={messages}
|
||||
index={index}
|
||||
lastAssistantMessageIndex={lastAssistantMessageIndex}
|
||||
onSendMessage={onSendMessage}
|
||||
agentOutput={agentOutput}
|
||||
isFinalMessage={isFinalMessage}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-w-0 overflow-x-hidden hyphens-auto break-words px-4 py-2">
|
||||
<AIChatBubble>
|
||||
<MarkdownContent content={resultValue} />
|
||||
</AIChatBubble>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<ChatMessage
|
||||
message={messageToRender}
|
||||
onSendMessage={onSendMessage}
|
||||
agentOutput={agentOutput}
|
||||
isFinalMessage={isFinalMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
||||
|
||||
export function parseToolResult(
|
||||
result: unknown,
|
||||
): Record<string, unknown> | null {
|
||||
try {
|
||||
return typeof result === "string"
|
||||
? JSON.parse(result)
|
||||
: (result as Record<string, unknown>);
|
||||
} 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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user