chore: more fixes

This commit is contained in:
Lluis Agusti
2026-02-09 22:47:25 +08:00
parent 8fe88046fd
commit 0c82af2a3d
8 changed files with 169 additions and 51 deletions

View File

@@ -15,6 +15,7 @@ export interface ChatContainerProps {
isCreatingSession: boolean;
onCreateSession: () => void | Promise<string>;
onSend: (message: string) => void | Promise<void>;
onStop: () => void;
}
export const ChatContainer = ({
messages,
@@ -25,6 +26,7 @@ export const ChatContainer = ({
isCreatingSession,
onCreateSession,
onSend,
onStop,
}: ChatContainerProps) => {
const inputLayoutId = "copilot-2-chat-input";
@@ -52,7 +54,7 @@ export const ChatContainer = ({
onSend={onSend}
disabled={status === "streaming"}
isStreaming={status === "streaming"}
onStop={() => {}}
onStop={onStop}
placeholder="What else can I help with?"
/>
</motion.div>

View File

@@ -0,0 +1,57 @@
.loader {
position: relative;
display: inline-block;
flex-shrink: 0;
transform: rotateZ(45deg);
perspective: 1000px;
border-radius: 50%;
color: currentColor;
}
.loader::before,
.loader::after {
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
width: inherit;
height: inherit;
border-radius: 50%;
transform: rotateX(70deg);
animation: spin 1s linear infinite;
}
.loader::after {
color: var(--spinner-accent, #a855f7);
transform: rotateY(70deg);
animation-delay: 0.4s;
}
@keyframes spin {
0%,
100% {
box-shadow: 0.2em 0 0 0 currentColor;
}
12% {
box-shadow: 0.2em 0.2em 0 0 currentColor;
}
25% {
box-shadow: 0 0.2em 0 0 currentColor;
}
37% {
box-shadow: -0.2em 0.2em 0 0 currentColor;
}
50% {
box-shadow: -0.2em 0 0 0 currentColor;
}
62% {
box-shadow: -0.2em -0.2em 0 0 currentColor;
}
75% {
box-shadow: 0 -0.2em 0 0 currentColor;
}
87% {
box-shadow: 0.2em -0.2em 0 0 currentColor;
}
}

View File

@@ -0,0 +1,16 @@
import { cn } from "@/lib/utils";
import styles from "./SpinnerLoader.module.css";
interface Props {
size?: number;
className?: string;
}
export function SpinnerLoader({ size = 24, className }: Props) {
return (
<div
className={cn(styles.loader, className)}
style={{ width: size, height: size }}
/>
);
}

View File

@@ -13,6 +13,7 @@ export default function Page() {
messages,
status,
error,
stop,
isLoadingSession,
isCreatingSession,
createSession,
@@ -47,6 +48,7 @@ export default function Page() {
isCreatingSession={isCreatingSession}
onCreateSession={createSession}
onSend={onSend}
onStop={stop}
/>
</div>
</div>

View File

@@ -10,7 +10,7 @@ import {
WarningDiamondIcon,
} from "@phosphor-icons/react";
import type { ToolUIPart } from "ai";
import { PulseLoader } from "../../components/PulseLoader/PulseLoader";
import { SpinnerLoader } from "../../components/SpinnerLoader/SpinnerLoader";
export interface RunAgentInput {
username_agent_slug?: string;
@@ -171,7 +171,7 @@ export function ToolIcon({
);
}
if (isStreaming) {
return <PulseLoader size={40} className="text-neutral-700" />;
return <SpinnerLoader size={40} className="text-neutral-700" />;
}
return <PlayIcon size={14} weight="regular" className="text-neutral-400" />;
}
@@ -203,7 +203,7 @@ export function getAccordionMeta(output: RunAgentToolOutput): {
? output.status.trim()
: "started";
return {
icon: <PulseLoader size={28} className="text-neutral-700" />,
icon: <SpinnerLoader size={28} className="text-neutral-700" />,
title: output.graph_name,
description: `Status: ${statusText}`,
};

View File

@@ -8,7 +8,7 @@ import {
WarningDiamondIcon,
} from "@phosphor-icons/react";
import type { ToolUIPart } from "ai";
import { PulseLoader } from "../../components/PulseLoader/PulseLoader";
import { SpinnerLoader } from "../../components/SpinnerLoader/SpinnerLoader";
export interface RunBlockInput {
block_id?: string;
@@ -120,7 +120,7 @@ export function ToolIcon({
);
}
if (isStreaming) {
return <PulseLoader size={40} className="text-neutral-700" />;
return <SpinnerLoader size={40} className="text-neutral-700" />;
}
return <PlayIcon size={14} weight="regular" className="text-neutral-400" />;
}
@@ -149,7 +149,7 @@ export function getAccordionMeta(output: RunBlockToolOutput): {
if (isRunBlockBlockOutput(output)) {
const keys = Object.keys(output.outputs ?? {});
return {
icon: <PulseLoader size={32} className="text-neutral-700" />,
icon: <SpinnerLoader size={32} className="text-neutral-700" />,
title: output.block_name,
description:
keys.length > 0

View File

@@ -1,11 +1,14 @@
import {
getGetV2GetSessionQueryKey,
getGetV2ListSessionsQueryKey,
useGetV2GetSession,
usePostV2CreateSession,
} from "@/app/api/__generated__/endpoints/chat/chat";
import { toast } from "@/components/molecules/Toast/use-toast";
import { useQueryClient } from "@tanstack/react-query";
import * as Sentry from "@sentry/nextjs";
import { parseAsString, useQueryState } from "nuqs";
import { useMemo } from "react";
import { useEffect, useMemo, useRef } from "react";
import { convertChatSessionMessagesToUiMessages } from "./helpers/convertChatSessionToUiMessages";
export function useChatSession() {
@@ -14,12 +17,28 @@ export function useChatSession() {
const sessionQuery = useGetV2GetSession(sessionId ?? "", {
query: {
enabled: !!sessionId,
staleTime: Infinity,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
});
// When the user navigates away from a session, invalidate its query cache.
// useChat destroys its Chat instance on id change, so messages are lost.
// Invalidating ensures the next visit fetches fresh data from the API
// instead of hydrating from stale cache that's missing recent messages.
const prevSessionIdRef = useRef(sessionId);
useEffect(() => {
const prev = prevSessionIdRef.current;
prevSessionIdRef.current = sessionId;
if (prev && prev !== sessionId) {
queryClient.invalidateQueries({
queryKey: getGetV2GetSessionQueryKey(prev),
});
}
}, [sessionId, queryClient]);
// 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(() => {
@@ -46,11 +65,36 @@ export function useChatSession() {
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");
try {
const response = await createSessionMutation();
if (response.status !== 200 || !response.data?.id) {
const error = new Error("Failed to create session");
Sentry.captureException(error, {
extra: { status: response.status },
});
toast({
variant: "destructive",
title: "Could not start a new chat session",
description: "Please try again.",
});
throw error;
}
return response.data.id;
} catch (error) {
if (
error instanceof Error &&
error.message === "Failed to create session"
) {
throw error; // already handled above
}
Sentry.captureException(error);
toast({
variant: "destructive",
title: "Could not start a new chat session",
description: "Please try again.",
});
throw error;
}
return response.data.id;
}
return {

View File

@@ -2,7 +2,7 @@ import { useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/cha
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useCallback, useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useChatSession } from "./useChatSession";
export function useCopilotPage() {
@@ -22,38 +22,37 @@ export function useCopilotPage() {
const isMobile =
breakpoint === "base" || breakpoint === "sm" || breakpoint === "md";
const transport = sessionId
? new DefaultChatTransport({
api: `/api/chat/sessions/${sessionId}/stream`,
prepareSendMessagesRequest: ({ messages }) => {
const last = messages[messages.length - 1];
return {
body: {
message: last.parts
?.map((p) => (p.type === "text" ? p.text : ""))
.join(""),
is_user_message: last.role === "user",
context: null,
const transport = useMemo(
() =>
sessionId
? new DefaultChatTransport({
api: `/api/chat/sessions/${sessionId}/stream`,
prepareSendMessagesRequest: ({ messages }) => {
const last = messages[messages.length - 1];
return {
body: {
message: last.parts
?.map((p) => (p.type === "text" ? p.text : ""))
.join(""),
is_user_message: last.role === "user",
context: null,
},
};
},
};
},
// Resume uses GET on the same endpoint (no message param → backend resumes)
prepareReconnectToStreamRequest: () => ({
api: `/api/chat/sessions/${sessionId}/stream`,
}),
})
: null;
})
: null,
[sessionId],
);
const { messages, sendMessage, status, error, setMessages } = useChat({
const { messages, sendMessage, stop, status, error, setMessages } = useChat({
id: sessionId ?? undefined,
transport: transport ?? undefined,
resume: !!sessionId,
});
useEffect(() => {
if (!hydratedMessages || hydratedMessages.length === 0) return;
setMessages((prev) => {
if (prev.length > hydratedMessages.length) return prev;
if (prev.length >= hydratedMessages.length) return prev;
return hydratedMessages;
});
}, [hydratedMessages, setMessages]);
@@ -89,36 +88,34 @@ export function useCopilotPage() {
const sessions =
sessionsResponse?.status === 200 ? sessionsResponse.data.sessions : [];
const handleOpenDrawer = useCallback(() => {
function handleOpenDrawer() {
setIsDrawerOpen(true);
}, []);
}
const handleCloseDrawer = useCallback(() => {
function handleCloseDrawer() {
setIsDrawerOpen(false);
}, []);
}
const handleDrawerOpenChange = useCallback((open: boolean) => {
function handleDrawerOpenChange(open: boolean) {
setIsDrawerOpen(open);
}, []);
}
const handleSelectSession = useCallback(
(id: string) => {
setSessionId(id);
if (isMobile) setIsDrawerOpen(false);
},
[setSessionId, isMobile],
);
function handleSelectSession(id: string) {
setSessionId(id);
if (isMobile) setIsDrawerOpen(false);
}
const handleNewChat = useCallback(() => {
function handleNewChat() {
setSessionId(null);
if (isMobile) setIsDrawerOpen(false);
}, [setSessionId, isMobile]);
}
return {
sessionId,
messages,
status,
error,
stop,
isLoadingSession,
isCreatingSession,
createSession,