From 49ea058cfefd4f47e73f96c4359ef22478995384 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira <62335616+lucaseduoli@users.noreply.github.com> Date: Mon, 27 Oct 2025 17:27:22 -0300 Subject: [PATCH] fix: added onboarding steps accordion, changed bg color (#313) * fixed accordion chevron side * Added steps history to animated provider steps * always reserve space for thinking, and pass required things for children * update onboarding card to reflect changes * added showCompleted * passed required props * Added background to other pages * Made tasks just appear when not on onboarding * changed task context to not have error * changed default to closed * fixed onboarding card * changed line to show up centered --- frontend/components/ui/accordion.tsx | 6 +- .../components/onboarding-content.tsx | 6 +- .../components/onboarding-step.tsx | 336 +++++---- .../components/onboarding-upload.tsx | 184 ++--- .../components/animated-provider-steps.tsx | 269 ++++++-- .../onboarding/components/onboarding-card.tsx | 56 +- frontend/src/components/chat-renderer.tsx | 3 +- frontend/src/contexts/task-context.tsx | 639 +++++++++--------- 8 files changed, 815 insertions(+), 684 deletions(-) diff --git a/frontend/components/ui/accordion.tsx b/frontend/components/ui/accordion.tsx index 37ec5c3c..16330e30 100644 --- a/frontend/components/ui/accordion.tsx +++ b/frontend/components/ui/accordion.tsx @@ -1,7 +1,7 @@ "use client"; import * as AccordionPrimitive from "@radix-ui/react-accordion"; -import { ChevronDown } from "lucide-react"; +import { ChevronRight } from "lucide-react"; import * as React from "react"; import { cn } from "@/lib/utils"; @@ -28,12 +28,12 @@ const AccordionTrigger = React.forwardRef< svg]:rotate-180", + "flex flex-1 items-center p-4 py-2.5 gap-2 font-medium !text-mmd text-muted-foreground transition-all [&[data-state=open]>svg]:rotate-90", className, )} {...props} > - + {children} diff --git a/frontend/src/app/new-onboarding/components/onboarding-content.tsx b/frontend/src/app/new-onboarding/components/onboarding-content.tsx index 6ca77e60..c328d1f3 100644 --- a/frontend/src/app/new-onboarding/components/onboarding-content.tsx +++ b/frontend/src/app/new-onboarding/components/onboarding-content.tsx @@ -26,7 +26,6 @@ export function OnboardingContent({ ); const [isLoadingModels, setIsLoadingModels] = useState(false); const [loadingStatus, setLoadingStatus] = useState([]); - const [hasStartedOnboarding, setHasStartedOnboarding] = useState(false); const { streamingMessage, isLoading, sendMessage } = useChatStreaming({ onComplete: (message, newResponseId) => { @@ -81,16 +80,17 @@ export function OnboardingContent({ = 0} isCompleted={currentStep > 0} + showCompleted={true} text="Let's get started by setting up your model provider." isLoadingModels={isLoadingModels} loadingStatus={loadingStatus} - reserveSpaceForThinking={!hasStartedOnboarding} + reserveSpaceForThinking={true} > { - setHasStartedOnboarding(true); handleStepComplete(); }} + isCompleted={currentStep > 0} setIsLoadingModels={setIsLoadingModels} setLoadingStatus={setLoadingStatus} /> diff --git a/frontend/src/app/new-onboarding/components/onboarding-step.tsx b/frontend/src/app/new-onboarding/components/onboarding-step.tsx index 93f76a74..9ce97c87 100644 --- a/frontend/src/app/new-onboarding/components/onboarding-step.tsx +++ b/frontend/src/app/new-onboarding/components/onboarding-step.tsx @@ -2,191 +2,189 @@ import { AnimatePresence, motion } from "motion/react"; import { type ReactNode, useEffect, useState } from "react"; import { Message } from "@/app/chat/components/message"; import DogIcon from "@/components/logo/dog-icon"; -import AnimatedProcessingIcon from "@/components/ui/animated-processing-icon"; import { MarkdownRenderer } from "@/components/markdown-renderer"; +import AnimatedProcessingIcon from "@/components/ui/animated-processing-icon"; import { cn } from "@/lib/utils"; interface OnboardingStepProps { - text: string; - children?: ReactNode; - isVisible: boolean; - isCompleted?: boolean; - icon?: ReactNode; - isMarkdown?: boolean; - hideIcon?: boolean; - isLoadingModels?: boolean; - loadingStatus?: string[]; - reserveSpaceForThinking?: boolean; + text: string; + children?: ReactNode; + isVisible: boolean; + isCompleted?: boolean; + showCompleted?: boolean; + icon?: ReactNode; + isMarkdown?: boolean; + hideIcon?: boolean; + isLoadingModels?: boolean; + loadingStatus?: string[]; + reserveSpaceForThinking?: boolean; } export function OnboardingStep({ - text, - children, - isVisible, - isCompleted = false, - icon, - isMarkdown = false, - hideIcon = false, - isLoadingModels = false, - loadingStatus = [], - reserveSpaceForThinking = false, + text, + children, + isVisible, + isCompleted = false, + showCompleted = false, + icon, + isMarkdown = false, + hideIcon = false, + isLoadingModels = false, + loadingStatus = [], + reserveSpaceForThinking = false, }: OnboardingStepProps) { - const [displayedText, setDisplayedText] = useState(""); - const [showChildren, setShowChildren] = useState(false); - const [currentStatusIndex, setCurrentStatusIndex] = useState(0); + const [displayedText, setDisplayedText] = useState(""); + const [showChildren, setShowChildren] = useState(false); + const [currentStatusIndex, setCurrentStatusIndex] = useState(0); - // Cycle through loading status messages once - useEffect(() => { - if (!isLoadingModels || loadingStatus.length === 0) { - setCurrentStatusIndex(0); - return; - } + // Cycle through loading status messages once + useEffect(() => { + if (!isLoadingModels || loadingStatus.length === 0) { + setCurrentStatusIndex(0); + return; + } - const interval = setInterval(() => { - setCurrentStatusIndex((prev) => { - const nextIndex = prev + 1; - // Stop at the last message - if (nextIndex >= loadingStatus.length - 1) { - clearInterval(interval); - return loadingStatus.length - 1; - } - return nextIndex; - }); - }, 1500); // Change status every 1.5 seconds + const interval = setInterval(() => { + setCurrentStatusIndex((prev) => { + const nextIndex = prev + 1; + // Stop at the last message + if (nextIndex >= loadingStatus.length - 1) { + clearInterval(interval); + return loadingStatus.length - 1; + } + return nextIndex; + }); + }, 1500); // Change status every 1.5 seconds - return () => clearInterval(interval); - }, [isLoadingModels, loadingStatus]); + return () => clearInterval(interval); + }, [isLoadingModels, loadingStatus]); - useEffect(() => { - if (!isVisible) { - setDisplayedText(""); - setShowChildren(false); - return; - } + useEffect(() => { + if (!isVisible) { + setDisplayedText(""); + setShowChildren(false); + return; + } - if (isCompleted) { - setDisplayedText(text); - setShowChildren(true); - return; - } + if (isCompleted) { + setDisplayedText(text); + setShowChildren(true); + return; + } - let currentIndex = 0; - setDisplayedText(""); - setShowChildren(false); + let currentIndex = 0; + setDisplayedText(""); + setShowChildren(false); - const interval = setInterval(() => { - if (currentIndex < text.length) { - setDisplayedText(text.slice(0, currentIndex + 1)); - currentIndex++; - } else { - clearInterval(interval); - setShowChildren(true); - } - }, 20); // 20ms per character + const interval = setInterval(() => { + if (currentIndex < text.length) { + setDisplayedText(text.slice(0, currentIndex + 1)); + currentIndex++; + } else { + clearInterval(interval); + setShowChildren(true); + } + }, 20); // 20ms per character - return () => clearInterval(interval); - }, [text, isVisible, isCompleted]); + return () => clearInterval(interval); + }, [text, isVisible, isCompleted]); - if (!isVisible) return null; + if (!isVisible) return null; - return ( - - - ) : ( - icon || ( -
- -
- ) - ) - } - > -
- {isLoadingModels && loadingStatus.length > 0 ? ( -
-
-
- -
- - Thinking - -
-
-
-
-
- - - {loadingStatus[currentStatusIndex]} - - -
-
-
-
- ) : isMarkdown ? ( - - ) : ( - <> -

- {displayedText} - {!showChildren && !isCompleted && ( - - )} -

- {reserveSpaceForThinking && ( -
- )} - - )} - {children && ( - - {((showChildren && !isCompleted) || isMarkdown) && ( - -
- {children}
-
- )} -
- )} -
- - - ); + return ( + + + ) : ( + icon || ( +
+ +
+ ) + ) + } + > +
+ {isLoadingModels && loadingStatus.length > 0 ? ( +
+
+
+ +
+ + Thinking + +
+
+
+
+
+ + + {loadingStatus[currentStatusIndex]} + + +
+
+
+
+ ) : isMarkdown ? ( + + ) : ( + <> +

+ {displayedText} + {!showChildren && !isCompleted && ( + + )} +

+ {reserveSpaceForThinking &&
} + + )} + {children && ( + + {((showChildren && (!isCompleted || showCompleted)) || + isMarkdown) && ( + +
{children}
+
+ )} +
+ )} +
+ + + ); } diff --git a/frontend/src/app/new-onboarding/components/onboarding-upload.tsx b/frontend/src/app/new-onboarding/components/onboarding-upload.tsx index dab12e53..366b420e 100644 --- a/frontend/src/app/new-onboarding/components/onboarding-upload.tsx +++ b/frontend/src/app/new-onboarding/components/onboarding-upload.tsx @@ -1,108 +1,108 @@ -import { ChangeEvent, useRef, useState } from "react"; +import { AnimatePresence, motion } from "motion/react"; +import { type ChangeEvent, useRef, useState } from "react"; +import { AnimatedProviderSteps } from "@/app/onboarding/components/animated-provider-steps"; import { Button } from "@/components/ui/button"; import { uploadFileForContext } from "@/lib/upload-utils"; -import { AnimatePresence, motion } from "motion/react"; -import { AnimatedProviderSteps } from "@/app/onboarding/components/animated-provider-steps"; interface OnboardingUploadProps { - onComplete: () => void; + onComplete: () => void; } const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => { - const fileInputRef = useRef(null); - const [isUploading, setIsUploading] = useState(false); - const [currentStep, setCurrentStep] = useState(null); + const fileInputRef = useRef(null); + const [isUploading, setIsUploading] = useState(false); + const [currentStep, setCurrentStep] = useState(null); - const STEP_LIST = [ - "Uploading your document", - "Processing your document", - ]; + const STEP_LIST = ["Uploading your document", "Processing your document"]; - const resetFileInput = () => { - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } - }; + const resetFileInput = () => { + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; - const handleUploadClick = () => { - fileInputRef.current?.click(); - }; + const handleUploadClick = () => { + fileInputRef.current?.click(); + }; - const performUpload = async (file: File) => { - setIsUploading(true); - try { - setCurrentStep(0); - await uploadFileForContext(file); - console.log("Document uploaded successfully"); - } catch (error) { - console.error("Upload failed", (error as Error).message); - } finally { - setIsUploading(false); - await new Promise(resolve => setTimeout(resolve, 1000)); - setCurrentStep(STEP_LIST.length); - await new Promise(resolve => setTimeout(resolve, 500)); - onComplete(); - } - }; + const performUpload = async (file: File) => { + setIsUploading(true); + try { + setCurrentStep(0); + await uploadFileForContext(file); + console.log("Document uploaded successfully"); + } catch (error) { + console.error("Upload failed", (error as Error).message); + } finally { + setIsUploading(false); + await new Promise((resolve) => setTimeout(resolve, 1000)); + setCurrentStep(STEP_LIST.length); + await new Promise((resolve) => setTimeout(resolve, 500)); + onComplete(); + } + }; - const handleFileChange = async (event: ChangeEvent) => { - const selectedFile = event.target.files?.[0]; - if (!selectedFile) { - resetFileInput(); - return; - } + const handleFileChange = async (event: ChangeEvent) => { + const selectedFile = event.target.files?.[0]; + if (!selectedFile) { + resetFileInput(); + return; + } - try { - await performUpload(selectedFile); - } catch (error) { - console.error("Unable to prepare file for upload", (error as Error).message); - } finally { - resetFileInput(); - } - }; + try { + await performUpload(selectedFile); + } catch (error) { + console.error( + "Unable to prepare file for upload", + (error as Error).message, + ); + } finally { + resetFileInput(); + } + }; - - return ( - - {currentStep === null ? ( - - - - - ) : ( - - - - )} - - ) -} + return ( + + {currentStep === null ? ( + + + + + ) : ( + + + + )} + + ); +}; export default OnboardingUpload; diff --git a/frontend/src/app/onboarding/components/animated-provider-steps.tsx b/frontend/src/app/onboarding/components/animated-provider-steps.tsx index 90708d4a..0dc334aa 100644 --- a/frontend/src/app/onboarding/components/animated-provider-steps.tsx +++ b/frontend/src/app/onboarding/components/animated-provider-steps.tsx @@ -2,86 +2,211 @@ import { AnimatePresence, motion } from "framer-motion"; import { CheckIcon } from "lucide-react"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; import AnimatedProcessingIcon from "@/components/ui/animated-processing-icon"; import { cn } from "@/lib/utils"; export function AnimatedProviderSteps({ - currentStep, - setCurrentStep, - steps, + currentStep, + isCompleted, + setCurrentStep, + steps, + storageKey = "provider-steps", }: { - currentStep: number; - setCurrentStep: (step: number) => void; - steps: string[]; + currentStep: number; + isCompleted: boolean; + setCurrentStep: (step: number) => void; + steps: string[]; + storageKey?: string; }) { + const [startTime, setStartTime] = useState(null); + const [elapsedTime, setElapsedTime] = useState(0); - useEffect(() => { - if (currentStep < steps.length - 1) { - const interval = setInterval(() => { - setCurrentStep(currentStep + 1); - }, 1500); - return () => clearInterval(interval); - } - }, [currentStep, setCurrentStep, steps]); + // Initialize start time from local storage or set new one + useEffect(() => { + const storedStartTime = localStorage.getItem(`${storageKey}-start`); + const storedElapsedTime = localStorage.getItem(`${storageKey}-elapsed`); - const isDone = currentStep >= steps.length; + if (isCompleted && storedElapsedTime) { + // If completed, use stored elapsed time + setElapsedTime(parseFloat(storedElapsedTime)); + } else if (storedStartTime) { + // If in progress, use stored start time + setStartTime(parseInt(storedStartTime)); + } else { + // First time, set new start time + const now = Date.now(); + setStartTime(now); + localStorage.setItem(`${storageKey}-start`, now.toString()); + } + }, [storageKey, isCompleted]); - return ( -
-
-
- - -
+ // Progress through steps + useEffect(() => { + if (currentStep < steps.length - 1 && !isCompleted) { + const interval = setInterval(() => { + setCurrentStep(currentStep + 1); + }, 1500); + return () => clearInterval(interval); + } + }, [currentStep, setCurrentStep, steps, isCompleted]); - - {isDone ? "Done" : "Thinking"} - -
-
- - {!isDone && ( - -
-
- - - {steps[currentStep]} - - -
- - )} - -
-
- ); + // Calculate and store elapsed time when completed + useEffect(() => { + if (isCompleted && startTime) { + const elapsed = Date.now() - startTime; + setElapsedTime(elapsed); + localStorage.setItem(`${storageKey}-elapsed`, elapsed.toString()); + localStorage.removeItem(`${storageKey}-start`); + } + }, [isCompleted, startTime, storageKey]); + + const isDone = currentStep >= steps.length && !isCompleted; + + return ( + + {!isCompleted ? ( + +
+
+ + +
+ + + {isDone ? "Done" : "Thinking"} + +
+
+ + {!isDone && ( + +
+
+ + + {steps[currentStep]} + + +
+ + )} + +
+
+ ) : ( + + + + +
+ + {`Initialized in ${(elapsedTime / 1000).toFixed(1)} seconds`} + +
+
+ +
+ {/* Connecting line on the left */} + + +
+ + {steps.map((step, index) => ( + + + + + + + + {step} + + + ))} + +
+
+
+
+
+
+ )} +
+ ); } diff --git a/frontend/src/app/onboarding/components/onboarding-card.tsx b/frontend/src/app/onboarding/components/onboarding-card.tsx index fb3ce3b9..e379976b 100644 --- a/frontend/src/app/onboarding/components/onboarding-card.tsx +++ b/frontend/src/app/onboarding/components/onboarding-card.tsx @@ -27,6 +27,7 @@ import { OpenAIOnboarding } from "./openai-onboarding"; interface OnboardingCardProps { onComplete: () => void; + isCompleted?: boolean; setIsLoadingModels?: (isLoading: boolean) => void; setLoadingStatus?: (status: string[]) => void; } @@ -43,6 +44,7 @@ const TOTAL_PROVIDER_STEPS = STEP_LIST.length; const OnboardingCard = ({ onComplete, + isCompleted = false, setIsLoadingModels: setIsLoadingModelsParent, setLoadingStatus: setLoadingStatusParent, }: OnboardingCardProps) => { @@ -104,7 +106,7 @@ const OnboardingCard = ({ llm_model: "", }); - const [currentStep, setCurrentStep] = useState(null); + const [currentStep, setCurrentStep] = useState(isCompleted ? TOTAL_PROVIDER_STEPS : null); // Query tasks to track completion const { data: tasks } = useGetTasksQuery({ @@ -130,6 +132,7 @@ const OnboardingCard = ({ if ( (!activeTasks || (activeTasks.processed_files ?? 0) > 0) && tasks.length > 0 + && !isCompleted ) { // Set to final step to show "Done" setCurrentStep(TOTAL_PROVIDER_STEPS); @@ -138,7 +141,7 @@ const OnboardingCard = ({ onComplete(); }, 1000); } - }, [tasks, currentStep, onComplete]); + }, [tasks, currentStep, onComplete, isCompleted]); // Mutations const onboardingMutation = useOnboardingMutation({ @@ -267,31 +270,29 @@ const OnboardingCard = ({ - {!isLoadingModels && ( - - -
- -
-
- {!isComplete && ( - - {!!settings.llm_model && - !!settings.embedding_model && - !isDoclingHealthy - ? "docling-serve must be running to continue" - : "Please fill in all required fields"} - - )} -
- )} + + +
+ +
+
+ {!isComplete && ( + + {isLoadingModels ? "Loading models..." : (!!settings.llm_model && + !!settings.embedding_model && + !isDoclingHealthy + ? "docling-serve must be running to continue" + : "Please fill in all required fields")} + + )} +
) : ( @@ -303,6 +304,7 @@ const OnboardingCard = ({ > diff --git a/frontend/src/components/chat-renderer.tsx b/frontend/src/components/chat-renderer.tsx index 925ddb76..9d6402e6 100644 --- a/frontend/src/components/chat-renderer.tsx +++ b/frontend/src/components/chat-renderer.tsx @@ -149,6 +149,7 @@ export function ChatRenderer({ className={cn( "flex h-full w-full max-w-full max-h-full items-center justify-center overflow-hidden", !showLayout && "absolute", + showLayout && !isOnChatPage && "bg-background", )} >
{children} diff --git a/frontend/src/contexts/task-context.tsx b/frontend/src/contexts/task-context.tsx index 8bb2bb24..a8e7747e 100644 --- a/frontend/src/contexts/task-context.tsx +++ b/frontend/src/contexts/task-context.tsx @@ -3,368 +3,373 @@ import { useQueryClient } from "@tanstack/react-query"; import type React from "react"; import { - createContext, - useCallback, - useContext, - useEffect, - useRef, - useState, + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, } from "react"; import { toast } from "sonner"; import { useCancelTaskMutation } from "@/app/api/mutations/useCancelTaskMutation"; import { - type Task, - type TaskFileEntry, - useGetTasksQuery, + type Task, + type TaskFileEntry, + useGetTasksQuery, } from "@/app/api/queries/useGetTasksQuery"; import { useAuth } from "@/contexts/auth-context"; +import { ONBOARDING_STEP_KEY } from "@/lib/constants"; // Task interface is now imported from useGetTasksQuery export type { Task }; export interface TaskFile { - filename: string; - mimetype: string; - source_url: string; - size: number; - connector_type: string; - status: "active" | "failed" | "processing"; - task_id: string; - created_at: string; - updated_at: string; - error?: string; - embedding_model?: string; - embedding_dimensions?: number; + filename: string; + mimetype: string; + source_url: string; + size: number; + connector_type: string; + status: "active" | "failed" | "processing"; + task_id: string; + created_at: string; + updated_at: string; + error?: string; + embedding_model?: string; + embedding_dimensions?: number; } interface TaskContextType { - tasks: Task[]; - files: TaskFile[]; - addTask: (taskId: string) => void; - addFiles: (files: Partial[], taskId: string) => void; - refreshTasks: () => Promise; - cancelTask: (taskId: string) => Promise; - isPolling: boolean; - isFetching: boolean; - isMenuOpen: boolean; - toggleMenu: () => void; - isRecentTasksExpanded: boolean; - setRecentTasksExpanded: (expanded: boolean) => void; - // React Query states - isLoading: boolean; - error: Error | null; + tasks: Task[]; + files: TaskFile[]; + addTask: (taskId: string) => void; + addFiles: (files: Partial[], taskId: string) => void; + refreshTasks: () => Promise; + cancelTask: (taskId: string) => Promise; + isPolling: boolean; + isFetching: boolean; + isMenuOpen: boolean; + toggleMenu: () => void; + isRecentTasksExpanded: boolean; + setRecentTasksExpanded: (expanded: boolean) => void; + // React Query states + isLoading: boolean; + error: Error | null; } const TaskContext = createContext(undefined); export function TaskProvider({ children }: { children: React.ReactNode }) { - const [files, setFiles] = useState([]); - const [isMenuOpen, setIsMenuOpen] = useState(false); - const [isRecentTasksExpanded, setIsRecentTasksExpanded] = useState(false); - const previousTasksRef = useRef([]); - const { isAuthenticated, isNoAuthMode } = useAuth(); + const [files, setFiles] = useState([]); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isRecentTasksExpanded, setIsRecentTasksExpanded] = useState(false); + const previousTasksRef = useRef([]); + const { isAuthenticated, isNoAuthMode } = useAuth(); - const queryClient = useQueryClient(); + const queryClient = useQueryClient(); - // Use React Query hooks - const { - data: tasks = [], - isLoading, - error, - refetch: refetchTasks, - isFetching, - } = useGetTasksQuery({ - enabled: isAuthenticated || isNoAuthMode, - }); + // Use React Query hooks + const { + data: tasks = [], + isLoading, + error, + refetch: refetchTasks, + isFetching, + } = useGetTasksQuery({ + enabled: isAuthenticated || isNoAuthMode, + }); - const cancelTaskMutation = useCancelTaskMutation({ - onSuccess: () => { - toast.success("Task cancelled", { - description: "Task has been cancelled successfully", - }); - }, - onError: (error) => { - toast.error("Failed to cancel task", { - description: error.message, - }); - }, - }); + const cancelTaskMutation = useCancelTaskMutation({ + onSuccess: () => { + toast.success("Task cancelled", { + description: "Task has been cancelled successfully", + }); + }, + onError: (error) => { + toast.error("Failed to cancel task", { + description: error.message, + }); + }, + }); - const refetchSearch = useCallback(() => { - queryClient.invalidateQueries({ - queryKey: ["search"], - exact: false, - }); - }, [queryClient]); + // Helper function to check if onboarding is active + const isOnboardingActive = useCallback(() => { + if (typeof window === "undefined") return false; + return localStorage.getItem(ONBOARDING_STEP_KEY) !== null; + }, []); - const addFiles = useCallback( - (newFiles: Partial[], taskId: string) => { - const now = new Date().toISOString(); - const filesToAdd: TaskFile[] = newFiles.map((file) => ({ - filename: file.filename || "", - mimetype: file.mimetype || "", - source_url: file.source_url || "", - size: file.size || 0, - connector_type: file.connector_type || "local", - status: "processing", - task_id: taskId, - created_at: now, - updated_at: now, - error: file.error, - embedding_model: file.embedding_model, - embedding_dimensions: file.embedding_dimensions, - })); + const refetchSearch = useCallback(() => { + queryClient.invalidateQueries({ + queryKey: ["search"], + exact: false, + }); + }, [queryClient]); - setFiles((prevFiles) => [...prevFiles, ...filesToAdd]); - }, - [], - ); + const addFiles = useCallback( + (newFiles: Partial[], taskId: string) => { + const now = new Date().toISOString(); + const filesToAdd: TaskFile[] = newFiles.map((file) => ({ + filename: file.filename || "", + mimetype: file.mimetype || "", + source_url: file.source_url || "", + size: file.size || 0, + connector_type: file.connector_type || "local", + status: "processing", + task_id: taskId, + created_at: now, + updated_at: now, + error: file.error, + embedding_model: file.embedding_model, + embedding_dimensions: file.embedding_dimensions, + })); - // Handle task status changes and file updates - useEffect(() => { - if (tasks.length === 0) { - // Store current tasks as previous for next comparison - previousTasksRef.current = tasks; - return; - } + setFiles((prevFiles) => [...prevFiles, ...filesToAdd]); + }, + [], + ); - // Check for task status changes by comparing with previous tasks - tasks.forEach((currentTask) => { - const previousTask = previousTasksRef.current.find( - (prev) => prev.task_id === currentTask.task_id, - ); + // Handle task status changes and file updates + useEffect(() => { + if (tasks.length === 0) { + // Store current tasks as previous for next comparison + previousTasksRef.current = tasks; + return; + } - // Only show toasts if we have previous data and status has changed - if ( - (previousTask && previousTask.status !== currentTask.status) || - (!previousTask && previousTasksRef.current.length !== 0) - ) { - // Process files from failed task and add them to files list - if (currentTask.files && typeof currentTask.files === "object") { - const taskFileEntries = Object.entries(currentTask.files); - const now = new Date().toISOString(); + // Check for task status changes by comparing with previous tasks + tasks.forEach((currentTask) => { + const previousTask = previousTasksRef.current.find( + (prev) => prev.task_id === currentTask.task_id, + ); - taskFileEntries.forEach(([filePath, fileInfo]) => { - if (typeof fileInfo === "object" && fileInfo) { - const fileInfoEntry = fileInfo as TaskFileEntry; - // Use the filename from backend if available, otherwise extract from path - const fileName = - fileInfoEntry.filename || - filePath.split("/").pop() || - filePath; - const fileStatus = fileInfoEntry.status ?? "processing"; + // Only show toasts if we have previous data and status has changed + if ( + (previousTask && previousTask.status !== currentTask.status) || + (!previousTask && previousTasksRef.current.length !== 0) + ) { + // Process files from failed task and add them to files list + if (currentTask.files && typeof currentTask.files === "object") { + const taskFileEntries = Object.entries(currentTask.files); + const now = new Date().toISOString(); - // Map backend file status to our TaskFile status - let mappedStatus: TaskFile["status"]; - switch (fileStatus) { - case "pending": - case "running": - mappedStatus = "processing"; - break; - case "completed": - mappedStatus = "active"; - break; - case "failed": - mappedStatus = "failed"; - break; - default: - mappedStatus = "processing"; - } + taskFileEntries.forEach(([filePath, fileInfo]) => { + if (typeof fileInfo === "object" && fileInfo) { + const fileInfoEntry = fileInfo as TaskFileEntry; + // Use the filename from backend if available, otherwise extract from path + const fileName = + fileInfoEntry.filename || filePath.split("/").pop() || filePath; + const fileStatus = fileInfoEntry.status ?? "processing"; - const fileError = (() => { - if ( - typeof fileInfoEntry.error === "string" && - fileInfoEntry.error.trim().length > 0 - ) { - return fileInfoEntry.error.trim(); - } - if ( - mappedStatus === "failed" && - typeof currentTask.error === "string" && - currentTask.error.trim().length > 0 - ) { - return currentTask.error.trim(); - } - return undefined; - })(); + // Map backend file status to our TaskFile status + let mappedStatus: TaskFile["status"]; + switch (fileStatus) { + case "pending": + case "running": + mappedStatus = "processing"; + break; + case "completed": + mappedStatus = "active"; + break; + case "failed": + mappedStatus = "failed"; + break; + default: + mappedStatus = "processing"; + } - setFiles((prevFiles) => { - const existingFileIndex = prevFiles.findIndex( - (f) => - f.source_url === filePath && - f.task_id === currentTask.task_id, - ); + const fileError = (() => { + if ( + typeof fileInfoEntry.error === "string" && + fileInfoEntry.error.trim().length > 0 + ) { + return fileInfoEntry.error.trim(); + } + if ( + mappedStatus === "failed" && + typeof currentTask.error === "string" && + currentTask.error.trim().length > 0 + ) { + return currentTask.error.trim(); + } + return undefined; + })(); - // Detect connector type based on file path or other indicators - let connectorType = "local"; - if (filePath.includes("/") && !filePath.startsWith("/")) { - // Likely S3 key format (bucket/path/file.ext) - connectorType = "s3"; - } + setFiles((prevFiles) => { + const existingFileIndex = prevFiles.findIndex( + (f) => + f.source_url === filePath && + f.task_id === currentTask.task_id, + ); - const fileEntry: TaskFile = { - filename: fileName, - mimetype: "", // We don't have this info from the task - source_url: filePath, - size: 0, // We don't have this info from the task - connector_type: connectorType, - status: mappedStatus, - task_id: currentTask.task_id, - created_at: - typeof fileInfoEntry.created_at === "string" - ? fileInfoEntry.created_at - : now, - updated_at: - typeof fileInfoEntry.updated_at === "string" - ? fileInfoEntry.updated_at - : now, - error: fileError, - embedding_model: - typeof fileInfoEntry.embedding_model === "string" - ? fileInfoEntry.embedding_model - : undefined, - embedding_dimensions: - typeof fileInfoEntry.embedding_dimensions === "number" - ? fileInfoEntry.embedding_dimensions - : undefined, - }; + // Detect connector type based on file path or other indicators + let connectorType = "local"; + if (filePath.includes("/") && !filePath.startsWith("/")) { + // Likely S3 key format (bucket/path/file.ext) + connectorType = "s3"; + } - if (existingFileIndex >= 0) { - // Update existing file - const updatedFiles = [...prevFiles]; - updatedFiles[existingFileIndex] = fileEntry; - return updatedFiles; - } else { - // Add new file - return [...prevFiles, fileEntry]; - } - }); - } - }); - } - if ( - previousTask && - previousTask.status !== "completed" && - currentTask.status === "completed" - ) { - // Task just completed - show success toast with file counts - const successfulFiles = currentTask.successful_files || 0; - const failedFiles = currentTask.failed_files || 0; + const fileEntry: TaskFile = { + filename: fileName, + mimetype: "", // We don't have this info from the task + source_url: filePath, + size: 0, // We don't have this info from the task + connector_type: connectorType, + status: mappedStatus, + task_id: currentTask.task_id, + created_at: + typeof fileInfoEntry.created_at === "string" + ? fileInfoEntry.created_at + : now, + updated_at: + typeof fileInfoEntry.updated_at === "string" + ? fileInfoEntry.updated_at + : now, + error: fileError, + embedding_model: + typeof fileInfoEntry.embedding_model === "string" + ? fileInfoEntry.embedding_model + : undefined, + embedding_dimensions: + typeof fileInfoEntry.embedding_dimensions === "number" + ? fileInfoEntry.embedding_dimensions + : undefined, + }; - let description = ""; - if (failedFiles > 0) { - description = `${successfulFiles} file${ - successfulFiles !== 1 ? "s" : "" - } uploaded successfully, ${failedFiles} file${ - failedFiles !== 1 ? "s" : "" - } failed`; - } else { - description = `${successfulFiles} file${ - successfulFiles !== 1 ? "s" : "" - } uploaded successfully`; - } + if (existingFileIndex >= 0) { + // Update existing file + const updatedFiles = [...prevFiles]; + updatedFiles[existingFileIndex] = fileEntry; + return updatedFiles; + } else { + // Add new file + return [...prevFiles, fileEntry]; + } + }); + } + }); + } + if ( + previousTask && + previousTask.status !== "completed" && + currentTask.status === "completed" + ) { + // Task just completed - show success toast with file counts + const successfulFiles = currentTask.successful_files || 0; + const failedFiles = currentTask.failed_files || 0; - toast.success("Task completed", { - description, - action: { - label: "View", - onClick: () => { - setIsMenuOpen(true); - setIsRecentTasksExpanded(true); - }, - }, - }); - setTimeout(() => { - setFiles((prevFiles) => - prevFiles.filter( - (file) => - file.task_id !== currentTask.task_id || - file.status === "failed", - ), - ); - refetchSearch(); - }, 500); - } else if ( - previousTask && - previousTask.status !== "failed" && - previousTask.status !== "error" && - (currentTask.status === "failed" || currentTask.status === "error") - ) { - // Task just failed - show error toast - toast.error("Task failed", { - description: `Task ${currentTask.task_id} failed: ${ - currentTask.error || "Unknown error" - }`, - }); - } - } - }); + let description = ""; + if (failedFiles > 0) { + description = `${successfulFiles} file${ + successfulFiles !== 1 ? "s" : "" + } uploaded successfully, ${failedFiles} file${ + failedFiles !== 1 ? "s" : "" + } failed`; + } else { + description = `${successfulFiles} file${ + successfulFiles !== 1 ? "s" : "" + } uploaded successfully`; + } + if (!isOnboardingActive()) { + toast.success("Task completed", { + description, + action: { + label: "View", + onClick: () => { + setIsMenuOpen(true); + setIsRecentTasksExpanded(true); + }, + }, + }); + } + setTimeout(() => { + setFiles((prevFiles) => + prevFiles.filter( + (file) => + file.task_id !== currentTask.task_id || + file.status === "failed", + ), + ); + refetchSearch(); + }, 500); + } else if ( + previousTask && + previousTask.status !== "failed" && + previousTask.status !== "error" && + (currentTask.status === "failed" || currentTask.status === "error") + ) { + // Task just failed - show error toast + toast.error("Task failed", { + description: `Task ${currentTask.task_id} failed: ${ + currentTask.error || "Unknown error" + }`, + }); + } + } + }); - // Store current tasks as previous for next comparison - previousTasksRef.current = tasks; - }, [tasks, refetchSearch]); + // Store current tasks as previous for next comparison + previousTasksRef.current = tasks; + }, [tasks, refetchSearch, isOnboardingActive]); - const addTask = useCallback( - (_taskId: string) => { - // React Query will automatically handle polling when tasks are active - // Just trigger a refetch to get the latest data - setTimeout(() => { - refetchTasks(); - }, 500); - }, - [refetchTasks], - ); + const addTask = useCallback( + (_taskId: string) => { + // React Query will automatically handle polling when tasks are active + // Just trigger a refetch to get the latest data + setTimeout(() => { + refetchTasks(); + }, 500); + }, + [refetchTasks], + ); - const refreshTasks = useCallback(async () => { - setFiles([]); - await refetchTasks(); - }, [refetchTasks]); + const refreshTasks = useCallback(async () => { + setFiles([]); + await refetchTasks(); + }, [refetchTasks]); + const cancelTask = useCallback( + async (taskId: string) => { + cancelTaskMutation.mutate({ taskId }); + }, + [cancelTaskMutation], + ); - const cancelTask = useCallback( - async (taskId: string) => { - cancelTaskMutation.mutate({ taskId }); - }, - [cancelTaskMutation], - ); + const toggleMenu = useCallback(() => { + setIsMenuOpen((prev) => !prev); + }, []); - const toggleMenu = useCallback(() => { - setIsMenuOpen((prev) => !prev); - }, []); + // Determine if we're polling based on React Query's refetch interval + const isPolling = + isFetching && + tasks.some( + (task) => + task.status === "pending" || + task.status === "running" || + task.status === "processing", + ); - // Determine if we're polling based on React Query's refetch interval - const isPolling = - isFetching && - tasks.some( - (task) => - task.status === "pending" || - task.status === "running" || - task.status === "processing", - ); + const value: TaskContextType = { + tasks, + files, + addTask, + addFiles, + refreshTasks, + cancelTask, + isPolling, + isFetching, + isMenuOpen, + toggleMenu, + isRecentTasksExpanded, + setRecentTasksExpanded: setIsRecentTasksExpanded, + isLoading, + error, + }; - const value: TaskContextType = { - tasks, - files, - addTask, - addFiles, - refreshTasks, - cancelTask, - isPolling, - isFetching, - isMenuOpen, - toggleMenu, - isRecentTasksExpanded, - setRecentTasksExpanded: setIsRecentTasksExpanded, - isLoading, - error, - }; - - return {children}; + return {children}; } export function useTask() { - const context = useContext(TaskContext); - if (context === undefined) { - throw new Error("useTask must be used within a TaskProvider"); - } - return context; + const context = useContext(TaskContext); + if (context === undefined) { + throw new Error("useTask must be used within a TaskProvider"); + } + return context; }