feat(copilot): add loading state to chat components

- Introduced `isLoadingSession` prop to manage loading states in `ChatContainer` and `ChatMessagesContainer`.
- Updated `useCopilotPage` to handle session loading state and improve user experience during session creation.
- Refactored session management logic to streamline message hydration and session handling.
- Enhanced UI feedback with loading indicators when messages are being fetched or sessions are being created.
This commit is contained in:
abhi1992002
2026-02-06 09:48:35 +05:30
parent 62edd73020
commit 4b036bfe22
13 changed files with 133 additions and 129 deletions

View File

@@ -11,6 +11,7 @@ export interface ChatContainerProps {
status: string;
error: Error | undefined;
sessionId: string | null;
isLoadingSession: boolean;
isCreatingSession: boolean;
onCreateSession: () => void | Promise<string>;
onSend: (message: string) => void | Promise<void>;
@@ -20,6 +21,7 @@ export const ChatContainer = ({
status,
error,
sessionId,
isLoadingSession,
isCreatingSession,
onCreateSession,
onSend,
@@ -36,6 +38,7 @@ export const ChatContainer = ({
messages={messages}
status={status}
error={error}
isLoading={isLoadingSession}
/>
<motion.div
layoutId={inputLayoutId}

View File

@@ -8,6 +8,7 @@ import {
MessageContent,
MessageResponse,
} from "@/components/ai-elements/message";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { UIDataTypes, UIMessage, UITools, ToolUIPart } from "ai";
import { FindBlocksTool } from "../../tools/FindBlocks/FindBlocks";
import { FindAgentsTool } from "../../tools/FindAgents/FindAgents";
@@ -22,16 +23,23 @@ interface ChatMessagesContainerProps {
messages: UIMessage<unknown, UIDataTypes, UITools>[];
status: string;
error: Error | undefined;
isLoading: boolean;
}
export const ChatMessagesContainer = ({
messages,
status,
error,
isLoading,
}: ChatMessagesContainerProps) => {
return (
<Conversation className="min-h-0 flex-1">
<ConversationContent className="gap-6 px-3 py-6">
{isLoading && messages.length === 0 && (
<div className="flex flex-1 items-center justify-center">
<LoadingSpinner size="large" className="text-neutral-400" />
</div>
)}
{messages.map((message) => (
<Message from={message.role} key={message.id}>
<MessageContent

View File

@@ -13,6 +13,7 @@ export default function Page() {
messages,
status,
error,
isLoadingSession,
isCreatingSession,
createSession,
onSend,
@@ -42,6 +43,7 @@ export default function Page() {
status={status}
error={error}
sessionId={sessionId}
isLoadingSession={isLoadingSession}
isCreatingSession={isCreatingSession}
onCreateSession={createSession}
onSend={onSend}

View File

@@ -64,7 +64,11 @@ export function FindAgentsTool({ part }: Props) {
return (
<div className="py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ToolIcon toolType={part.type} isStreaming={isStreaming} isError={isError} />
<ToolIcon
toolType={part.type}
isStreaming={isStreaming}
isError={isError}
/>
<MorphingTextAnimation
text={text}
className={isError ? "text-red-500" : undefined}

View File

@@ -1,8 +1,5 @@
import { ToolUIPart } from "ai";
import {
MagnifyingGlassIcon,
SquaresFourIcon,
} from "@phosphor-icons/react";
import { MagnifyingGlassIcon, SquaresFourIcon } from "@phosphor-icons/react";
import type { AgentInfo } from "@/app/api/__generated__/models/agentInfo";
import type { AgentsFoundResponse } from "@/app/api/__generated__/models/agentsFoundResponse";
import type { ErrorResponse } from "@/app/api/__generated__/models/errorResponse";

View File

@@ -210,7 +210,8 @@ export function RunAgentTool({ part }: Props) {
const output = getRunAgentToolOutput(part);
const isError =
part.state === "output-error" || (!!output && isRunAgentErrorOutput(output));
part.state === "output-error" ||
(!!output && isRunAgentErrorOutput(output));
const hasExpandableContent =
part.state === "output-available" &&
!!output &&

View File

@@ -195,7 +195,8 @@ export function RunBlockTool({ part }: Props) {
const output = getRunBlockToolOutput(part);
const isError =
part.state === "output-error" || (!!output && isRunBlockErrorOutput(output));
part.state === "output-error" ||
(!!output && isRunBlockErrorOutput(output));
const hasExpandableContent =
part.state === "output-available" &&
!!output &&

View File

@@ -95,8 +95,7 @@ export function getAnimationText(part: {
case "output-available": {
const output = parseOutput(part.output);
if (!output) return `Running the block${blockText}`;
if (isRunBlockBlockOutput(output))
return `Ran "${output.block_name}"`;
if (isRunBlockBlockOutput(output)) return `Ran "${output.block_name}"`;
if (isRunBlockSetupRequirementsOutput(output)) {
return `Setup needed for "${output.setup_info.agent_name}"`;
}

View File

@@ -84,7 +84,11 @@ export function SearchDocsTool({ part }: Props) {
return (
<div className="py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ToolIcon toolType={part.type} isStreaming={isStreaming} isError={isError} />
<ToolIcon
toolType={part.type}
isStreaming={isStreaming}
isError={isError}
/>
<MorphingTextAnimation
text={text}
className={isError ? "text-red-500" : undefined}

View File

@@ -0,0 +1,64 @@
import {
getGetV2ListSessionsQueryKey,
useGetV2GetSession,
usePostV2CreateSession,
} from "@/app/api/__generated__/endpoints/chat/chat";
import { useQueryClient } from "@tanstack/react-query";
import { parseAsString, useQueryState } from "nuqs";
import { useMemo } from "react";
import { convertChatSessionMessagesToUiMessages } from "./helpers/convertChatSessionToUiMessages";
export function useChatSession() {
const [sessionId, setSessionId] = useQueryState("sessionId", parseAsString);
const queryClient = useQueryClient();
const sessionQuery = useGetV2GetSession(sessionId ?? "", {
query: {
staleTime: Infinity,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
});
// Memoize so the effect in useCopilotPage doesn't infinite-loop on a new
// array reference every render. Re-derives only when query data changes.
const hydratedMessages = useMemo(() => {
if (sessionQuery.data?.status !== 200 || !sessionId) return undefined;
return convertChatSessionMessagesToUiMessages(
sessionId,
sessionQuery.data.data.messages ?? [],
);
}, [sessionQuery.data, sessionId]);
const { mutateAsync: createSessionMutation, isPending: isCreatingSession } =
usePostV2CreateSession({
mutation: {
onSuccess: (response) => {
if (response.status === 200 && response.data?.id) {
setSessionId(response.data.id);
queryClient.invalidateQueries({
queryKey: getGetV2ListSessionsQueryKey(),
});
}
},
},
});
async function createSession() {
if (sessionId) return sessionId;
const response = await createSessionMutation();
if (response.status !== 200 || !response.data?.id) {
throw new Error("Failed to create session");
}
return response.data.id;
}
return {
sessionId,
setSessionId,
hydratedMessages,
isLoadingSession: sessionQuery.isLoading,
createSession,
isCreatingSession,
};
}

View File

@@ -1,31 +1,26 @@
import {
getGetV2ListSessionsQueryKey,
getV2GetSession,
postV2CreateSession,
useGetV2ListSessions,
} from "@/app/api/__generated__/endpoints/chat/chat";
import { useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/chat";
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
import { useChat } from "@ai-sdk/react";
import { useQueryClient } from "@tanstack/react-query";
import { DefaultChatTransport } from "ai";
import { parseAsString, useQueryState } from "nuqs";
import { useCallback, useEffect, useRef, useState } from "react";
import { convertChatSessionMessagesToUiMessages } from "./helpers/convertChatSessionToUiMessages";
import { useCallback, useEffect, useState } from "react";
import { useChatSession } from "./useChatSession";
export function useCopilotPage() {
const [isCreatingSession, setIsCreatingSession] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [sessionId, setSessionId] = useQueryState("sessionId", parseAsString);
const [pendingMessage, setPendingMessage] = useState<string | null>(null);
const {
sessionId,
setSessionId,
hydratedMessages,
isLoadingSession,
createSession,
isCreatingSession,
} = useChatSession();
const breakpoint = useBreakpoint();
const isMobile =
breakpoint === "base" || breakpoint === "sm" || breakpoint === "md";
const hydrationSeq = useRef(0);
const lastHydratedSessionIdRef = useRef<string | null>(null);
const createSessionPromiseRef = useRef<Promise<string> | null>(null);
const queuedFirstMessageRef = useRef<string | null>(null);
const queuedFirstMessageResolverRef = useRef<(() => void) | null>(null);
const queryClient = useQueryClient();
const transport = sessionId
? new DefaultChatTransport({
@@ -50,101 +45,25 @@ export function useCopilotPage() {
transport: transport ?? undefined,
});
const messagesRef = useRef(messages);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
async function createSession() {
if (sessionId) return sessionId;
if (createSessionPromiseRef.current) return createSessionPromiseRef.current;
setIsCreatingSession(true);
const promise = (async () => {
const response = await postV2CreateSession({
body: JSON.stringify({}),
});
if (response.status !== 200 || !response.data?.id) {
throw new Error("Failed to create chat session");
}
setSessionId(response.data.id);
queryClient.invalidateQueries({
queryKey: getGetV2ListSessionsQueryKey(),
});
return response.data.id;
})();
createSessionPromiseRef.current = promise;
try {
return await promise;
} finally {
createSessionPromiseRef.current = null;
setIsCreatingSession(false);
}
}
if (!hydratedMessages || hydratedMessages.length === 0) return;
setMessages((prev) => {
if (prev.length > hydratedMessages.length) return prev;
return hydratedMessages;
});
}, [hydratedMessages, setMessages]);
// Clear messages when session is null
useEffect(() => {
hydrationSeq.current += 1;
const seq = hydrationSeq.current;
const controller = new AbortController();
if (!sessionId) {
setMessages([]);
lastHydratedSessionIdRef.current = null;
return;
}
const currentSessionId = sessionId;
if (lastHydratedSessionIdRef.current !== currentSessionId) {
setMessages([]);
lastHydratedSessionIdRef.current = currentSessionId;
}
async function hydrate() {
try {
const response = await getV2GetSession(currentSessionId, {
signal: controller.signal,
});
if (response.status !== 200) return;
const uiMessages = convertChatSessionMessagesToUiMessages(
currentSessionId,
response.data.messages ?? [],
);
if (controller.signal.aborted) return;
if (hydrationSeq.current !== seq) return;
const localMessagesCount = messagesRef.current.length;
const remoteMessagesCount = uiMessages.length;
if (remoteMessagesCount === 0) return;
if (localMessagesCount > remoteMessagesCount) return;
setMessages(uiMessages);
} catch (error) {
if ((error as { name?: string } | null)?.name === "AbortError") return;
console.warn("Failed to hydrate chat session:", error);
}
}
void hydrate();
return () => controller.abort();
if (!sessionId) setMessages([]);
}, [sessionId, setMessages]);
useEffect(() => {
if (!sessionId) return;
const firstMessage = queuedFirstMessageRef.current;
if (!firstMessage) return;
queuedFirstMessageRef.current = null;
sendMessage({ text: firstMessage });
queuedFirstMessageResolverRef.current?.();
queuedFirstMessageResolverRef.current = null;
}, [sendMessage, sessionId]);
if (!sessionId || !pendingMessage) return;
const msg = pendingMessage;
setPendingMessage(null);
sendMessage({ text: msg });
}, [sessionId, pendingMessage, sendMessage]);
async function onSend(message: string) {
const trimmed = message.trim();
@@ -155,23 +74,16 @@ export function useCopilotPage() {
return;
}
queuedFirstMessageRef.current = trimmed;
const sentPromise = new Promise<void>((resolve) => {
queuedFirstMessageResolverRef.current = resolve;
});
setPendingMessage(trimmed);
await createSession();
await sentPromise;
}
// Sessions list for mobile drawer
const { data: sessionsResponse, isLoading: isLoadingSessions } =
useGetV2ListSessions({ limit: 50 });
const sessions =
sessionsResponse?.status === 200 ? sessionsResponse.data.sessions : [];
// Drawer handlers
const handleOpenDrawer = useCallback(() => {
setIsDrawerOpen(true);
}, []);
@@ -202,6 +114,7 @@ export function useCopilotPage() {
messages,
status,
error,
isLoadingSession,
isCreatingSession,
createSession,
onSend,

View File

@@ -12,7 +12,11 @@ export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", scrollbarStyles, className)}
className={cn(
"relative flex-1 overflow-y-hidden",
scrollbarStyles,
className,
)}
initial="smooth"
resize="smooth"
role="log"

View File

@@ -38,9 +38,13 @@ export function useVoiceRecording({
const streamRef = useRef<MediaStream | null>(null);
const isRecordingRef = useRef(false);
const isSupported =
typeof window !== "undefined" &&
!!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
const [isSupported, setIsSupported] = useState(false);
useEffect(() => {
setIsSupported(
!!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
);
}, []);
const clearTimer = useCallback(() => {
if (timerRef.current) {