chore: refactors

This commit is contained in:
Lluis Agusti
2026-01-19 22:29:29 +07:00
parent a37f7efbdf
commit 82ae0303cf
14 changed files with 249 additions and 210 deletions

View File

@@ -73,9 +73,7 @@ export function CopilotShell({ children }: CopilotShellProps) {
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",
isActive ? "bg-zinc-100" : "hover:bg-zinc-50",
)}
>
<Text

View File

@@ -1,6 +1,10 @@
"use client";
import { postV2CreateSession, useGetV2GetSession, useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/chat";
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";
@@ -30,7 +34,9 @@ export function useCopilotShell() {
breakpoint === "base" || breakpoint === "sm" || breakpoint === "md";
const [offset, setOffset] = useState(0);
const [accumulatedSessions, setAccumulatedSessions] = useState<SessionSummaryResponse[]>([]);
const [accumulatedSessions, setAccumulatedSessions] = useState<
SessionSummaryResponse[]
>([]);
const [totalCount, setTotalCount] = useState<number | null>(null);
const [hasAutoSelectedSession, setHasAutoSelectedSession] = useState(false);
const hasCreatedSessionRef = useRef(false);
@@ -51,7 +57,7 @@ export function useCopilotShell() {
const newSessions = responseData.sessions;
const total = responseData.total;
setTotalCount(total);
if (offset === 0) {
setAccumulatedSessions(newSessions);
} else {
@@ -67,7 +73,9 @@ export function useCopilotShell() {
const areAllSessionsLoaded = useMemo(() => {
if (totalCount === null) return false;
return accumulatedSessions.length >= totalCount && !isFetching && !isLoading;
return (
accumulatedSessions.length >= totalCount && !isFetching && !isLoading
);
}, [accumulatedSessions.length, totalCount, isFetching, isLoading]);
useEffect(() => {
@@ -105,33 +113,35 @@ export function useCopilotShell() {
[searchParams],
);
const { data: currentSessionData, isLoading: isCurrentSessionLoading } = useGetV2GetSession(
currentSessionId || "",
{
const { data: currentSessionData, isLoading: isCurrentSessionLoading } =
useGetV2GetSession(currentSessionId || "", {
query: {
enabled: !!currentSessionId && (!isMobile || isDrawerOpen),
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);
const currentInAll = accumulatedSessions.find(
(s) => s.id === currentSessionId,
);
if (currentInAll) {
const isInVisible = visibleSessions.some((s) => s.id === currentSessionId);
const isInVisible = visibleSessions.some(
(s) => s.id === currentSessionId,
);
if (!isInVisible) {
filteredSessions.push(currentInAll);
}
}
}
filteredSessions.push(...visibleSessions);
}
@@ -140,7 +150,8 @@ export function useCopilotShell() {
(s) => s.id === currentSessionId,
);
if (!isCurrentInList) {
const summarySession = convertSessionDetailToSummary(currentSessionData);
const summarySession =
convertSessionDetailToSummary(currentSessionData);
// Add new session at the beginning to match API order (most recent first)
filteredSessions.unshift(summarySession);
}
@@ -189,9 +200,9 @@ export function useCopilotShell() {
useEffect(() => {
if (!areAllSessionsLoaded || hasAutoSelectedSession) return;
const visibleSessions = filterVisibleSessions(accumulatedSessions);
if (paramSessionId) {
setHasAutoSelectedSession(true);
return;
@@ -201,7 +212,12 @@ export function useCopilotShell() {
const lastSession = visibleSessions[0];
setHasAutoSelectedSession(true);
router.push(`/copilot/chat?sessionId=${lastSession.id}`);
} else if (accumulatedSessions.length === 0 && !isLoading && totalCount === 0 && !hasCreatedSessionRef.current) {
} else if (
accumulatedSessions.length === 0 &&
!isLoading &&
totalCount === 0 &&
!hasCreatedSessionRef.current
) {
hasCreatedSessionRef.current = true;
postV2CreateSession({ body: JSON.stringify({}) })
.then((response) => {
@@ -216,7 +232,15 @@ export function useCopilotShell() {
} else if (totalCount === 0) {
setHasAutoSelectedSession(true);
}
}, [areAllSessionsLoaded, accumulatedSessions, paramSessionId, hasAutoSelectedSession, router, isLoading, totalCount]);
}, [
areAllSessionsLoaded,
accumulatedSessions,
paramSessionId,
hasAutoSelectedSession,
router,
isLoading,
totalCount,
]);
useEffect(() => {
if (paramSessionId) {
@@ -228,13 +252,24 @@ export function useCopilotShell() {
if (!areAllSessionsLoaded) return false;
if (paramSessionId) {
const sessionFound = accumulatedSessions.some((s) => s.id === paramSessionId);
const sessionFound = accumulatedSessions.some(
(s) => s.id === paramSessionId,
);
const sessionLoading = isCurrentSessionLoading;
return sessionFound || (!sessionLoading && currentSessionData !== undefined);
return (
sessionFound || (!sessionLoading && currentSessionData !== undefined)
);
}
return hasAutoSelectedSession;
}, [areAllSessionsLoaded, accumulatedSessions, paramSessionId, isCurrentSessionLoading, currentSessionData, hasAutoSelectedSession]);
}, [
areAllSessionsLoaded,
accumulatedSessions,
paramSessionId,
isCurrentSessionLoading,
currentSessionData,
hasAutoSelectedSession,
]);
return {
isMobile,

View File

@@ -50,7 +50,7 @@ export function Chat({
<div className={cn("flex h-full flex-col", className)}>
{/* Header */}
{showHeader && (
<header className="shrink-0 bg-[#f8f8f9] p-3">
<header className="shrink-0 bg-[#f8f8f9] p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{showSessionInfo && sessionId && (

View File

@@ -8,12 +8,7 @@ export interface AIChatBubbleProps {
export function AIChatBubble({ children, className }: AIChatBubbleProps) {
return (
<div
className={cn(
"text-left text-sm leading-relaxed",
className,
)}
>
<div className={cn("text-left text-sm leading-relaxed", className)}>
{children}
</div>
);

View File

@@ -50,24 +50,27 @@ export function ChatContainer({
return (
<div
className={cn("flex h-full min-h-0 flex-col max-w-3xl mx-auto bg-[#f8f8f9]", className)}
className={cn(
"mx-auto flex h-full min-h-0 max-w-3xl flex-col bg-[#f8f8f9]",
className,
)}
>
{/* Messages or Welcome Screen - Scrollable */}
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto relative">
<div className="relative flex min-h-0 flex-1 flex-col overflow-y-auto">
<div className="flex min-h-full flex-col justify-end">
<MessageList
messages={messages}
streamingChunks={streamingChunks}
isStreaming={isStreaming}
onSendMessage={sendMessageWithContext}
className="flex-1"
/>
<MessageList
messages={messages}
streamingChunks={streamingChunks}
isStreaming={isStreaming}
onSendMessage={sendMessageWithContext}
className="flex-1"
/>
</div>
</div>
{/* Input - Fixed at bottom */}
<div className="relative pb-4 pt-2">
<div className="absolute w-full top-[-18px] h-6 pointer-events-none bg-gradient-to-b from-transparent to-[#f8f8f9] z-10" />
<div className="pointer-events-none absolute top-[-18px] z-10 h-6 w-full bg-gradient-to-b from-transparent to-[#f8f8f9]" />
<ChatInput
onSend={sendMessageWithContext}
disabled={isStreaming || !sessionId}

View File

@@ -37,10 +37,11 @@ export function handleTextEnded(
const lastMessage = prev[prev.length - 1];
console.log("[Text Ended] Previous message:", {
type: lastMessage?.type,
toolName: lastMessage?.type === "tool_call" ? lastMessage.toolName : undefined,
toolName:
lastMessage?.type === "tool_call" ? lastMessage.toolName : undefined,
content: completedText.substring(0, 200),
});
const assistantMessage: ChatMessageData = {
type: "message",
role: "assistant",

View File

@@ -7,5 +7,7 @@
animation: l1 1s infinite;
}
@keyframes l1 {
100% {box-shadow: 0 0 0 30px #0000}
100% {
box-shadow: 0 0 0 30px #0000;
}
}

View File

@@ -245,16 +245,15 @@ export function ChatMessage({
) : (
<AIChatBubble>
<MarkdownContent content={message.content} />
{agentOutput &&
agentOutput.type === "tool_response" && (
<div className="mt-4">
<ToolResponseMessage
toolId={agentOutput.toolId}
toolName={agentOutput.toolName || "Agent Output"}
result={agentOutput.result}
/>
</div>
)}
{agentOutput && agentOutput.type === "tool_response" && (
<div className="mt-4">
<ToolResponseMessage
toolId={agentOutput.toolId}
toolName={agentOutput.toolName || "Agent Output"}
result={agentOutput.result}
/>
</div>
)}
</AIChatBubble>
)}
<div

View File

@@ -39,9 +39,7 @@ export function MessageBubble({
)}
>
{/* Gradient flare background */}
<div
className={cn("absolute inset-0 bg-gradient-to-br")}
/>
<div className={cn("absolute inset-0 bg-gradient-to-br")} />
<div
className={cn(
"relative z-10 transition-all duration-500 ease-in-out",

View File

@@ -40,7 +40,7 @@ export function MessageList({
className,
)}
>
<div className="mx-auto flex flex-col py-4 min-w-0 break-words hyphens-auto">
<div className="mx-auto flex min-w-0 flex-col hyphens-auto break-words py-4">
{/* Render all persisted messages */}
{(() => {
let lastAssistantMessageIndex = -1;
@@ -61,165 +61,177 @@ export function MessageList({
}
}
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,
});
}
// 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") {
return messages.map((message, index) => {
// Log message for debugging
if (message.type === "message" && message.role === "assistant") {
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;
}
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,
});
}
}
// 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
) {
// 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 nextMessage.result === "string"
? JSON.parse(nextMessage.result)
: (nextMessage.result as Record<string, unknown>);
typeof message.result === "string"
? JSON.parse(message.result)
: (message.result as Record<string, unknown>);
} catch {
parsedResult = null;
}
if (parsedResult?.type === "agent_output") {
agentOutput = nextMessage;
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;
}
}
}
// 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"));
// 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];
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)
// Check if next message is an agent_output tool_response to include in current assistant message
const nextMessage = messages[index + 1];
if (
messageToRender.type === "tool_response" &&
message.type === "tool_response" &&
index === lastToolResponseIndex
nextMessage &&
nextMessage.type === "tool_response" &&
nextMessage.result
) {
// 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 key={index} className="px-4 py-2 min-w-0 overflow-x-hidden break-words hyphens-auto">
<AIChatBubble>
<MarkdownContent content={resultValue} />
</AIChatBubble>
</div>
);
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)
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 (
<ChatMessage
<div
key={index}
message={messageToRender}
onSendMessage={onSendMessage}
agentOutput={agentOutput}
isFinalMessage={isFinalMessage}
/>
className="min-w-0 overflow-x-hidden hyphens-auto break-words px-4 py-2"
>
<AIChatBubble>
<MarkdownContent content={resultValue} />
</AIChatBubble>
</div>
);
});
}
return (
<ChatMessage
key={index}
message={messageToRender}
onSendMessage={onSendMessage}
agentOutput={agentOutput}
isFinalMessage={isFinalMessage}
/>
);
});
})()}
{/* Render thinking message when streaming but no chunks yet */}

View File

@@ -20,8 +20,8 @@ export function ToolCallMessage({
const displayData = toolArguments
? JSON.stringify(toolArguments)
: "No arguments";
const displayText = `${displayKey}: ${displayData}`;
const displayText = `${displayKey}: ${displayData}`;
return (
<AIChatBubble className={className}>

View File

@@ -6,10 +6,7 @@ export interface UserChatBubbleProps {
className?: string;
}
export function UserChatBubble({
children,
className,
}: UserChatBubbleProps) {
export function UserChatBubble({ children, className }: UserChatBubbleProps) {
return (
<div
className={cn(

View File

@@ -66,7 +66,6 @@ export function useChat({ urlSessionId }: UseChatArgs = {}) {
],
);
useEffect(() => {
if (isLoading || isCreating) {
const timer = setTimeout(() => {

View File

@@ -75,17 +75,17 @@ export function NavbarLink({ name, href }: Props) {
{href === "/library" &&
(isChatEnabled ? (
<ListChecksIcon
className={cn(
iconWidthClass,
isActive && "text-white dark:text-black",
)}
/>
className={cn(
iconWidthClass,
isActive && "text-white dark:text-black",
)}
/>
) : (
<HouseIcon
className={cn(
iconWidthClass,
isActive && "text-white dark:text-black",
)}
)}
/>
))}
<Text