chore: tidy ups...

This commit is contained in:
Lluis Agusti
2026-01-20 00:00:10 +07:00
parent 82ae0303cf
commit e7c5294b24
18 changed files with 948 additions and 553 deletions

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}}
/>
);
}

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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">

View File

@@ -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,
)}
>

View File

@@ -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}
/>
);
});

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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,
};
}

View File

@@ -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"
);
}