update frontend

This commit is contained in:
Swifty
2025-11-04 16:30:14 +01:00
parent 6c4d528fda
commit a6941cad40
15 changed files with 382 additions and 326 deletions

View File

@@ -182,13 +182,14 @@ export function parseToolResponse(
if (parsedResult && typeof parsedResult === "object") {
const responseType = parsedResult.type as string | undefined;
// Handle no_results response
// Handle no_results response - treat as a successful tool response
if (responseType === "no_results") {
return {
type: "no_results",
message: (parsedResult.message as string) || "No results found",
suggestions: (parsedResult.suggestions as string[]) || [],
sessionId: parsedResult.session_id as string | undefined,
type: "tool_response",
toolId,
toolName,
result: parsedResult.message || "No results found",
success: true,
timestamp: timestamp || new Date(),
};
}
@@ -221,6 +222,25 @@ export function parseToolResponse(
};
}
// Handle need_login response
if (responseType === "need_login") {
return {
type: "login_needed",
message:
(parsedResult.message as string) ||
"Please sign in to use chat and agent features",
sessionId: (parsedResult.session_id as string) || "",
agentInfo: parsedResult.agent_info as
| {
graph_id: string;
name: string;
trigger_type: string;
}
| undefined,
timestamp: timestamp || new Date(),
};
}
// Handle setup_requirements - return null so caller can handle it specially
if (responseType === "setup_requirements") {
return null;

View File

@@ -21,7 +21,7 @@ interface UseChatContainerResult {
streamingChunks: string[];
isStreaming: boolean;
error: Error | null;
sendMessage: (content: string) => Promise<void>;
sendMessage: (content: string, isUserMessage?: boolean) => Promise<void>;
}
export function useChatContainer({
@@ -139,13 +139,13 @@ export function useChatContainer({
* - On stream_end, local messages cleared and replaced by refreshed initialMessages
*/
const sendMessage = useCallback(
async function sendMessage(content: string) {
async function sendMessage(content: string, isUserMessage: boolean = true) {
if (!sessionId) {
console.error("Cannot send message: no session ID");
return;
}
// Add user message immediately
// Add user message immediately (only if it's a user message)
const userMessage: ChatMessageData = {
type: "message",
role: "user",
@@ -155,13 +155,24 @@ export function useChatContainer({
// Remove any pending credentials_needed or login_needed messages when user sends a new message
// This prevents them from persisting after the user has taken action
setMessages((prev) => {
const filtered = prev.filter(
(msg) =>
msg.type !== "credentials_needed" && msg.type !== "login_needed",
// Only add user message to UI if isUserMessage is true
if (isUserMessage) {
setMessages((prev) => {
const filtered = prev.filter(
(msg) =>
msg.type !== "credentials_needed" && msg.type !== "login_needed",
);
return [...filtered, userMessage];
});
} else {
// For system messages, just remove the login/credentials prompts
setMessages((prev) =>
prev.filter(
(msg) =>
msg.type !== "credentials_needed" && msg.type !== "login_needed",
),
);
return [...filtered, userMessage];
});
}
// Clear streaming chunks and reset text flag
setStreamingChunks([]);
@@ -182,8 +193,21 @@ export function useChatContainer({
return updated;
});
} else if (chunk.type === "text_ended") {
// Close the streaming text box
console.log("[Text Ended] Closing streaming text box");
// Save the completed text as an assistant message before clearing
console.log("[Text Ended] Saving streamed text as assistant message");
const completedText = streamingChunksRef.current.join("");
if (completedText.trim()) {
const assistantMessage: ChatMessageData = {
type: "message",
role: "assistant",
content: completedText,
timestamp: new Date(),
};
setMessages((prev) => [...prev, assistantMessage]);
}
// Clear streaming state
setStreamingChunks([]);
streamingChunksRef.current = [];
setHasTextChunks(false);
@@ -211,11 +235,32 @@ export function useChatContainer({
timestamp: new Date().toISOString(),
});
// Find the matching tool_call to get the tool name if missing
let toolName = chunk.tool_name || "unknown";
if (!chunk.tool_name || chunk.tool_name === "unknown") {
setMessages((prev) => {
const matchingToolCall = [...prev]
.reverse()
.find(
(msg) =>
msg.type === "tool_call" &&
msg.toolId === chunk.tool_id,
);
if (
matchingToolCall &&
matchingToolCall.type === "tool_call"
) {
toolName = matchingToolCall.toolName;
}
return prev;
});
}
// Use helper function to parse tool response
const responseMessage = parseToolResponse(
chunk.result!,
chunk.tool_id!,
chunk.tool_name!,
toolName,
new Date(),
);
@@ -277,12 +322,18 @@ export function useChatContainer({
);
return [...prev, responseMessage];
});
} else if (chunk.type === "login_needed") {
} else if (
chunk.type === "login_needed" ||
chunk.type === "need_login"
) {
// Add login needed message
const loginNeededMessage: ChatMessageData = {
type: "login_needed",
message: chunk.message || "Authentication required to continue",
message:
chunk.message ||
"Please sign in to use chat and agent features",
sessionId: chunk.session_id || sessionId,
agentInfo: chunk.agent_info,
timestamp: new Date(),
};
setMessages((prev) => [...prev, loginNeededMessage]);
@@ -351,6 +402,7 @@ export function useChatContainer({
}
// TODO: Handle usage for display
},
isUserMessage,
);
} catch (err) {
console.error("Failed to send message:", err);

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { Card } from "@/components/atoms/Card/Card";
import { Text } from "@/components/atoms/Text/Text";
import { Key, Check, Warning } from "@phosphor-icons/react";
@@ -50,10 +50,22 @@ export function ChatCredentialsSetup({
const { selectedCredentials, isAllComplete, handleCredentialSelect } =
useChatCredentialsSetup(credentials);
// Track if we've already called completion to prevent double calls
const hasCalledCompleteRef = useRef(false);
// Reset the completion flag when credentials change (new credential setup flow)
useEffect(
function resetCompletionFlag() {
hasCalledCompleteRef.current = false;
},
[credentials],
);
// Auto-call completion when all credentials are configured
useEffect(
function autoCompleteWhenReady() {
if (isAllComplete) {
if (isAllComplete && !hasCalledCompleteRef.current) {
hasCalledCompleteRef.current = true;
onAllCredentialsComplete();
}
},

View File

@@ -2,6 +2,8 @@ import { useEffect, useState, useRef } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { toast } from "sonner";
import { useChatSession } from "@/hooks/useChatSession";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { useChatStream } from "@/hooks/useChatStream";
interface UseChatPageResult {
session: ReturnType<typeof useChatSession>["session"];
@@ -18,9 +20,14 @@ interface UseChatPageResult {
export function useChatPage(): UseChatPageResult {
const router = useRouter();
const searchParams = useSearchParams();
const urlSessionId = searchParams.get("session");
// Support both 'session' and 'session_id' query parameters
const urlSessionId =
searchParams.get("session_id") || searchParams.get("session");
const [isOnline, setIsOnline] = useState(true);
const hasCreatedSessionRef = useRef(false);
const hasClaimedSessionRef = useRef(false);
const { user } = useSupabase();
const { sendMessage: sendStreamMessage } = useChatStream();
const {
session,
@@ -31,6 +38,7 @@ export function useChatPage(): UseChatPageResult {
error,
createSession,
refreshSession,
claimSession,
clearSession: clearSessionBase,
} = useChatSession({
urlSessionId,
@@ -68,9 +76,54 @@ export function useChatPage(): UseChatPageResult {
[urlSessionId, isCreating, sessionIdFromHook, createSession],
);
// Note: Session claiming is handled explicitly by UI components when needed
// - Locally created sessions: backend sets user_id from JWT automatically
// - URL sessions: claiming happens in specific user flows, not automatically
// Auto-claim session if user is logged in and session has no user_id
useEffect(
function autoClaimSession() {
// Only claim if:
// 1. We have a session loaded
// 2. Session has no user_id (anonymous session)
// 3. User is logged in
// 4. Haven't already claimed this session
// 5. Not currently loading
if (
session &&
!session.user_id &&
user &&
!hasClaimedSessionRef.current &&
!isLoading &&
sessionIdFromHook
) {
console.log("[autoClaimSession] Claiming anonymous session for user");
hasClaimedSessionRef.current = true;
claimSession(sessionIdFromHook)
.then(() => {
console.log(
"[autoClaimSession] Session claimed successfully, sending login notification",
);
// Send login notification message to backend after successful claim
// This notifies the agent that the user has logged in
sendStreamMessage(
sessionIdFromHook,
"User has successfully logged in.",
() => {
// Empty chunk handler - we don't need to process responses for this system message
},
false, // isUserMessage = false
).catch((err) => {
console.error(
"[autoClaimSession] Failed to send login notification:",
err,
);
});
})
.catch((err) => {
console.error("[autoClaimSession] Failed to claim session:", err);
hasClaimedSessionRef.current = false; // Reset on error to allow retry
});
}
},
[session, user, isLoading, sessionIdFromHook, claimSession, sendStreamMessage],
);
// Monitor online/offline status
useEffect(function monitorNetworkStatus() {
@@ -102,7 +155,10 @@ export function useChatPage(): UseChatPageResult {
function clearSession() {
clearSessionBase();
// Remove session from URL
// Reset the created session flag so a new session can be created
hasCreatedSessionRef.current = false;
hasClaimedSessionRef.current = false;
// Remove session from URL and trigger new session creation
router.push("/chat");
}

View File

@@ -4,7 +4,7 @@ import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { environment } from "@/services/environment";
import { loginFormSchema, LoginProvider } from "@/types/auth";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import z from "zod";
@@ -14,6 +14,8 @@ export function useLoginPage() {
const [feedback, setFeedback] = useState<string | null>(null);
const [captchaKey, setCaptchaKey] = useState(0);
const router = useRouter();
const searchParams = useSearchParams();
const returnUrl = searchParams.get("returnUrl");
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
@@ -140,8 +142,11 @@ export function useLoginPage() {
setIsLoading(false);
setFeedback(null);
const next =
(result?.next as string) || (result?.onboarding ? "/onboarding" : "/");
// Prioritize returnUrl from query params over backend's onboarding logic
const next = returnUrl
? returnUrl
: (result?.next as string) ||
(result?.onboarding ? "/onboarding" : "/");
if (next) router.push(next);
} catch (error) {
toast({

View File

@@ -14,6 +14,7 @@ export async function GET(
const { sessionId } = await params;
const searchParams = request.nextUrl.searchParams;
const message = searchParams.get("message");
const isUserMessage = searchParams.get("is_user_message");
if (!message) {
return new Response("Missing message parameter", { status: 400 });
@@ -31,6 +32,11 @@ export async function GET(
);
streamUrl.searchParams.set("message", message);
// Pass is_user_message parameter if provided
if (isUserMessage !== null) {
streamUrl.searchParams.set("is_user_message", isUserMessage);
}
// Forward request to backend with auth header
const headers: Record<string, string> = {
Accept: "text/event-stream",

View File

@@ -0,0 +1,129 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/atoms/Button/Button";
import { SignIn, UserPlus, Shield } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
export interface AuthPromptWidgetProps {
message: string;
sessionId: string;
agentInfo?: {
graph_id: string;
name: string;
trigger_type: string;
};
returnUrl?: string;
className?: string;
}
export function AuthPromptWidget({
message,
sessionId,
agentInfo,
returnUrl = "/chat",
className,
}: AuthPromptWidgetProps) {
const router = useRouter();
function handleSignIn() {
// Store session info to return after auth
if (typeof window !== "undefined") {
localStorage.setItem("pending_chat_session", sessionId);
if (agentInfo) {
localStorage.setItem("pending_agent_setup", JSON.stringify(agentInfo));
}
}
// Build return URL with session ID (using session_id to match chat page parameter)
const returnUrlWithSession = `${returnUrl}?session_id=${sessionId}`;
const encodedReturnUrl = encodeURIComponent(returnUrlWithSession);
router.push(`/login?returnUrl=${encodedReturnUrl}`);
}
function handleSignUp() {
// Store session info to return after auth
if (typeof window !== "undefined") {
localStorage.setItem("pending_chat_session", sessionId);
if (agentInfo) {
localStorage.setItem("pending_agent_setup", JSON.stringify(agentInfo));
}
}
// Build return URL with session ID (using session_id to match chat page parameter)
const returnUrlWithSession = `${returnUrl}?session_id=${sessionId}`;
const encodedReturnUrl = encodeURIComponent(returnUrlWithSession);
router.push(`/signup?returnUrl=${encodedReturnUrl}`);
}
return (
<div
className={cn(
"my-4 overflow-hidden rounded-lg border border-violet-200 dark:border-violet-800",
"bg-gradient-to-br from-violet-50 to-purple-50 dark:from-violet-950/30 dark:to-purple-950/30",
"duration-500 animate-in fade-in-50 slide-in-from-bottom-2",
className,
)}
>
<div className="px-6 py-5">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-violet-600">
<Shield size={20} weight="fill" className="text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Authentication Required
</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Sign in to set up and manage agents
</p>
</div>
</div>
<div className="mb-5 rounded-md bg-white/50 p-4 dark:bg-neutral-900/50">
<p className="text-sm text-neutral-700 dark:text-neutral-300">
{message}
</p>
{agentInfo && (
<div className="mt-3 text-xs text-neutral-600 dark:text-neutral-400">
<p>
Ready to set up:{" "}
<span className="font-medium">{agentInfo.name}</span>
</p>
<p>
Type:{" "}
<span className="font-medium">{agentInfo.trigger_type}</span>
</p>
</div>
)}
</div>
<div className="flex gap-3">
<Button
onClick={handleSignIn}
variant="primary"
size="small"
className="flex-1"
>
<SignIn size={16} weight="bold" className="mr-2" />
Sign In
</Button>
<Button
onClick={handleSignUp}
variant="secondary"
size="small"
className="flex-1"
>
<UserPlus size={16} weight="bold" className="mr-2" />
Create Account
</Button>
</div>
<div className="mt-4 text-center text-xs text-neutral-500 dark:text-neutral-500">
Your chat session will be preserved after signing in
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { AuthPromptWidget } from "./AuthPromptWidget";
export type { AuthPromptWidgetProps } from "./AuthPromptWidget";

View File

@@ -1,15 +1,17 @@
import { cn } from "@/lib/utils";
import { Robot, User } from "@phosphor-icons/react";
import { Robot, User, CheckCircle } from "@phosphor-icons/react";
import { useRouter } from "next/navigation";
import { useCallback } from "react";
import { MessageBubble } from "@/components/atoms/MessageBubble/MessageBubble";
import { MarkdownContent } from "@/components/atoms/MarkdownContent/MarkdownContent";
import { ToolCallMessage } from "@/components/molecules/ToolCallMessage/ToolCallMessage";
import { ToolResponseMessage } from "@/components/molecules/ToolResponseMessage/ToolResponseMessage";
import { LoginPrompt } from "@/components/molecules/LoginPrompt/LoginPrompt";
import { AuthPromptWidget } from "@/components/molecules/AuthPromptWidget/AuthPromptWidget";
import { ChatCredentialsSetup } from "@/app/(platform)/chat/components/ChatCredentialsSetup/ChatCredentialsSetup";
import { NoResultsMessage } from "@/components/molecules/NoResultsMessage/NoResultsMessage";
import { AgentCarouselMessage } from "@/components/molecules/AgentCarouselMessage/AgentCarouselMessage";
import { ExecutionStartedMessage } from "@/components/molecules/ExecutionStartedMessage/ExecutionStartedMessage";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { useChatMessage, type ChatMessageData } from "./useChatMessage";
export interface ChatMessageProps {
@@ -17,7 +19,7 @@ export interface ChatMessageProps {
className?: string;
onDismissLogin?: () => void;
onDismissCredentials?: () => void;
onSendMessage?: (content: string) => void;
onSendMessage?: (content: string, isUserMessage?: boolean) => void;
}
export function ChatMessage({
@@ -28,6 +30,7 @@ export function ChatMessage({
onSendMessage,
}: ChatMessageProps) {
const router = useRouter();
const { user } = useSupabase();
const {
formattedTimestamp,
isUser,
@@ -55,19 +58,22 @@ export function ChatMessage({
}
}
function handleAllCredentialsComplete() {
// Send a user message that explicitly asks to retry the setup
// This ensures the LLM calls get_required_setup_info again and proceeds with execution
if (onSendMessage) {
onSendMessage(
"I've configured the required credentials. Please check if everything is ready and proceed with setting up the agent.",
);
}
// Optionally dismiss the credentials prompt
if (onDismissCredentials) {
onDismissCredentials();
}
}
const handleAllCredentialsComplete = useCallback(
function handleAllCredentialsComplete() {
// Send a user message that explicitly asks to retry the setup
// This ensures the LLM calls get_required_setup_info again and proceeds with execution
if (onSendMessage) {
onSendMessage(
"I've configured the required credentials. Please check if everything is ready and proceed with setting up the agent.",
);
}
// Optionally dismiss the credentials prompt
if (onDismissCredentials) {
onDismissCredentials();
}
},
[onSendMessage, onDismissCredentials],
);
function handleCancelCredentials() {
// Dismiss the credentials prompt
@@ -92,13 +98,41 @@ export function ChatMessage({
// Render login needed messages
if (isLoginNeeded && message.type === "login_needed") {
// If user is already logged in, show success message instead of auth prompt
if (user) {
return (
<div className={cn("px-4 py-2", className)}>
<div className="my-4 overflow-hidden rounded-lg border border-green-200 bg-gradient-to-br from-green-50 to-emerald-50 dark:border-green-800 dark:from-green-950/30 dark:to-emerald-950/30">
<div className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-600">
<CheckCircle size={20} weight="fill" className="text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Successfully Authenticated
</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
You're now signed in and ready to continue
</p>
</div>
</div>
</div>
</div>
</div>
);
}
// Show auth prompt if not logged in
return (
<LoginPrompt
message={message.message}
onLogin={handleLogin}
onContinueAsGuest={handleContinueAsGuest}
className={className}
/>
<div className={cn("px-4 py-2", className)}>
<AuthPromptWidget
message={message.message}
sessionId={message.sessionId}
agentInfo={message.agentInfo}
returnUrl="/chat"
/>
</div>
);
}

View File

@@ -27,6 +27,11 @@ export type ChatMessageData =
type: "login_needed";
message: string;
sessionId: string;
agentInfo?: {
graph_id: string;
name: string;
trigger_type: string;
};
timestamp?: string | Date;
}
| {

View File

@@ -1,37 +0,0 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { LoginPrompt } from "./LoginPrompt";
const meta = {
title: "Molecules/LoginPrompt",
component: LoginPrompt,
parameters: {
layout: "padded",
},
tags: ["autodocs"],
args: {
onLogin: () => console.log("Login clicked"),
onContinueAsGuest: () => console.log("Continue as guest clicked"),
},
} satisfies Meta<typeof LoginPrompt>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
message: "Please log in to save your chat history and access your account",
},
};
export const CustomMessage: Story = {
args: {
message:
"To continue with this agent and save your progress, please sign in to your account",
},
};
export const ShortMessage: Story = {
args: {
message: "Sign in to continue",
},
};

View File

@@ -1,62 +0,0 @@
import React from "react";
import { Text } from "@/components/atoms/Text/Text";
import { SignIn, UserCircle } from "@phosphor-icons/react";
import { Button } from "@/components/atoms/Button/Button";
import { cn } from "@/lib/utils";
export interface LoginPromptProps {
message: string;
onLogin: () => void;
onContinueAsGuest: () => void;
className?: string;
}
export function LoginPrompt({
message,
onLogin,
onContinueAsGuest,
className,
}: LoginPromptProps) {
return (
<div
className={cn(
"mx-4 my-2 flex flex-col items-center gap-4 rounded-lg border border-blue-200 bg-blue-50 p-6 dark:border-blue-900 dark:bg-blue-950",
className,
)}
>
{/* Icon */}
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-blue-500">
<UserCircle size={32} weight="fill" className="text-white" />
</div>
{/* Content */}
<div className="text-center">
<Text variant="h3" className="mb-2 text-blue-900 dark:text-blue-100">
Login Required
</Text>
<Text variant="body" className="text-blue-700 dark:text-blue-300">
{message}
</Text>
</div>
{/* Action Buttons */}
<div className="flex gap-3">
<Button
onClick={onLogin}
variant="primary"
className="flex items-center gap-2"
>
<SignIn size={20} weight="bold" />
Login
</Button>
<Button onClick={onContinueAsGuest} variant="secondary">
Continue as Guest
</Button>
</div>
<Text variant="small" className="text-blue-600 dark:text-blue-400">
Logging in will save your chat history to your account
</Text>
</div>
);
}

View File

@@ -208,26 +208,6 @@ export const WithToolResponses: Story = {
},
};
export const WithLoginPrompt: Story = {
args: {
messages: [
{
type: "message" as const,
role: "user" as const,
content: "Run this agent for me",
timestamp: new Date(Date.now() - 3 * 60 * 1000),
},
{
type: "login_needed" as const,
message:
"To run agents and save your chat history, please log in to your account.",
sessionId: "session-123",
timestamp: new Date(Date.now() - 2 * 60 * 1000),
},
],
},
};
export const WithCredentialsPrompt: Story = {
args: {
messages: [

View File

@@ -13,6 +13,7 @@ export interface StreamChunk {
| "tool_call_start"
| "tool_response"
| "login_needed"
| "need_login"
| "credentials_needed"
| "error"
| "usage"
@@ -29,6 +30,11 @@ export interface StreamChunk {
idx?: number; // Index for tool_call_start
// Login needed fields
session_id?: string;
agent_info?: {
graph_id: string;
name: string;
trigger_type: string;
};
// Credentials needed fields
provider?: string;
provider_name?: string;
@@ -45,6 +51,7 @@ interface UseChatStreamResult {
sessionId: string,
message: string,
onChunk: (chunk: StreamChunk) => void,
isUserMessage?: boolean,
) => Promise<void>;
stopStreaming: () => void;
}
@@ -88,6 +95,7 @@ export function useChatStream(): UseChatStreamResult {
sessionId: string,
message: string,
onChunk: (chunk: StreamChunk) => void,
isUserMessage: boolean = true,
) {
// Stop any existing stream
stopStreaming();
@@ -111,7 +119,7 @@ export function useChatStream(): UseChatStreamResult {
// EventSource doesn't support custom headers, so we use a Next.js API route
// that acts as a proxy and adds authentication headers server-side
// This matches the pattern from PR #10905 where SSE went through the same server
const url = `/api/chat/sessions/${sessionId}/stream?message=${encodeURIComponent(message)}`;
const url = `/api/chat/sessions/${sessionId}/stream?message=${encodeURIComponent(message)}&is_user_message=${isUserMessage}`;
// Create EventSource for SSE (connects to our Next.js proxy)
const eventSource = new EventSource(url);
@@ -171,7 +179,7 @@ export function useChatStream(): UseChatStreamResult {
retryTimeoutRef.current = setTimeout(() => {
// Retry by recursively calling sendMessage
sendMessage(sessionId, message, onChunk).catch((err) => {
sendMessage(sessionId, message, onChunk, isUserMessage).catch((err) => {
console.error("Retry failed:", err);
});
}, retryDelay);

View File

@@ -1,154 +0,0 @@
import { Page, Locator } from "@playwright/test";
export class ChatPage {
constructor(private page: Page) {}
async goto(sessionId?: string) {
await this.page.goto(sessionId ? `/chat?session=${sessionId}` : "/chat");
}
// Selectors
getChatInput(): Locator {
return this.page.locator('textarea[placeholder*="Type a message"]');
}
getSendButton(): Locator {
return this.page.getByRole("button", { name: /send/i });
}
getMessages(): Locator {
return this.page.locator('[data-testid="chat-message"]');
}
getMessageByIndex(index: number): Locator {
return this.getMessages().nth(index);
}
getStreamingMessage(): Locator {
return this.page.locator('[data-testid="streaming-message"]');
}
getNewChatButton(): Locator {
return this.page.getByRole("button", { name: /new chat/i });
}
getQuickActionButton(text: string): Locator {
return this.page.getByRole("button", { name: new RegExp(text, "i") });
}
// Tool-specific message selectors
getToolCallMessage(): Locator {
return this.page.locator('[data-testid="tool-call-message"]').first();
}
getToolResponseMessage(): Locator {
return this.page.locator('[data-testid="tool-response-message"]').first();
}
getLoginPrompt(): Locator {
return this.page.getByText("Login Required").first();
}
getCredentialsPrompt(): Locator {
return this.page.getByText("Credentials Required").first();
}
getNoResultsMessage(): Locator {
return this.page.getByText("No Results Found").first();
}
getAgentCarouselMessage(): Locator {
return this.page.getByText(/Found \d+ Agents?/).first();
}
getExecutionStartedMessage(): Locator {
return this.page.getByText("Execution Started").first();
}
// Actions
async sendMessage(text: string): Promise<void> {
const input = this.getChatInput();
await input.waitFor({ state: "visible" });
await input.fill(text);
const sendButton = this.getSendButton();
await sendButton.waitFor({ state: "visible" });
await sendButton.click();
}
async clickQuickAction(text: string): Promise<void> {
const button = this.getQuickActionButton(text);
await button.waitFor({ state: "visible" });
await button.click();
}
async startNewChat(): Promise<void> {
const button = this.getNewChatButton();
await button.waitFor({ state: "visible" });
await button.click();
}
async waitForResponse(): Promise<void> {
// Wait for a new assistant message to appear
await this.page.waitForSelector('[data-testid="chat-message"]', {
state: "visible",
timeout: 10000,
});
}
async waitForStreaming(): Promise<void> {
await this.getStreamingMessage().waitFor({
state: "visible",
timeout: 5000,
});
}
async waitForToolCall(): Promise<void> {
await this.getToolCallMessage().waitFor({
state: "visible",
timeout: 10000,
});
}
async waitForToolResponse(): Promise<void> {
await this.getToolResponseMessage().waitFor({
state: "visible",
timeout: 10000,
});
}
async waitForLoginPrompt(): Promise<void> {
await this.getLoginPrompt().waitFor({
state: "visible",
timeout: 5000,
});
}
async waitForCredentialsPrompt(): Promise<void> {
await this.getCredentialsPrompt().waitFor({
state: "visible",
timeout: 5000,
});
}
async waitForNoResults(): Promise<void> {
await this.getNoResultsMessage().waitFor({
state: "visible",
timeout: 5000,
});
}
async waitForAgentCarousel(): Promise<void> {
await this.getAgentCarouselMessage().waitFor({
state: "visible",
timeout: 5000,
});
}
async waitForExecutionStarted(): Promise<void> {
await this.getExecutionStartedMessage().waitFor({
state: "visible",
timeout: 5000,
});
}
}