diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index f954670c42..439b6088c7 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -460,13 +460,21 @@ export function ChatSettingsPanel({ const updateThreadSettings = useRagStore((s) => s.updateThreadSettings); const ragDefaults = useRagStore((s) => s.defaults); - // Load this thread's RAG settings once when the sheet sees a thread - // for the first time. Updates re-render automatically via the store. + // Load this thread's RAG settings once per threadId. Ref-guarded so + // `threadSettings` isn't a dep — if it were, the post-load + // store-mutation re-triggers the effect and any failure mode where + // the selector flickers undefined produces an update loop. + const threadSettingsLoadedRef = useRef(null); useEffect(() => { - if (ragSource.kind === "thread" && activeThreadId && !threadSettings) { + if ( + ragSource.kind === "thread" + && activeThreadId + && threadSettingsLoadedRef.current !== activeThreadId + ) { + threadSettingsLoadedRef.current = activeThreadId; void loadThreadSettings(activeThreadId); } - }, [ragSource.kind, activeThreadId, threadSettings, loadThreadSettings]); + }, [ragSource.kind, activeThreadId, loadThreadSettings]); const effectiveThreadChunking: RagChunkingStrategy = threadSettings?.chunking_strategy ?? diff --git a/studio/frontend/src/features/rag/components/ingestion-toast-stack.tsx b/studio/frontend/src/features/rag/components/ingestion-toast-stack.tsx index ad824c7111..7e35180280 100644 --- a/studio/frontend/src/features/rag/components/ingestion-toast-stack.tsx +++ b/studio/frontend/src/features/rag/components/ingestion-toast-stack.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button"; import { Cancel01Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRagStore } from "../stores/rag-store"; import { IngestionProgress } from "./ingestion-progress"; @@ -33,16 +33,27 @@ export function IngestionToastStack() { ); // Schedule auto-dismiss for jobs that have reached a terminal state. + // Ref-tracked so `dismissedJobs` isn't a useEffect dep — the setter + // fires *inside* the effect, and depending on its output here is a + // recipe for update-depth loops if the scheduler ever runs faster + // than the cleanup. We snapshot the latest dismissed set into a ref + // and read from it inside the scheduling loop instead. + const scheduledJobsRef = useRef>(new Set()); + const dismissedJobsRef = useRef>(dismissedJobs); + dismissedJobsRef.current = dismissedJobs; useEffect(() => { const timers: ReturnType[] = []; for (const [jobId, event] of Object.entries(jobs)) { if ( - (event.type === "complete" || event.type === "error") && - !dismissedJobs.has(jobId) + (event.type === "complete" || event.type === "error") + && !dismissedJobsRef.current.has(jobId) + && !scheduledJobsRef.current.has(jobId) ) { + scheduledJobsRef.current.add(jobId); timers.push( setTimeout(() => { setDismissedJobs((prev) => { + if (prev.has(jobId)) return prev; const next = new Set(prev); next.add(jobId); return next; @@ -52,7 +63,7 @@ export function IngestionToastStack() { } } return () => timers.forEach(clearTimeout); - }, [jobs, dismissedJobs]); + }, [jobs]); const visible = Object.entries(jobs).filter( ([jobId]) => !dismissedJobs.has(jobId),