Studio: global RAG ingestion toast stack (Phase 2C)

Adds a floating progress-card stack mounted at the app root that
watches useRagStore.jobs and renders one card per in-flight ingestion
job, regardless of which page the user is on. Wraps the existing
IngestionProgress component so the progress UI stays consistent with
the per-doc chips in the KB detail panel and chat sidebar.

- Terminal cards (complete/error) linger for 4s then auto-dismiss.
- A manual dismiss button is always available.
- Reduced-motion preference is respected (no slide animation).
- Positioned bottom-right, z-50, pointer-events-none container so
  clicks pass through to the page underneath.

Mounted in app/routes/__root.tsx next to the existing SettingsDialog
so it's visible across every authenticated route. Closes the last
deferred item from Phase 2.
This commit is contained in:
Roland Tannous 2026-05-24 12:45:00 +04:00
commit 2b85a165e6
2 changed files with 116 additions and 0 deletions

View file

@ -5,6 +5,7 @@ import { AppSidebar } from "@/components/app-sidebar";
import { Navbar } from "@/components/navbar";
import { fetchDeviceType, usePlatformStore } from "@/config/env";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { IngestionToastStack } from "@/features/rag/components/ingestion-toast-stack";
import { SettingsDialog, useSettingsDialogStore } from "@/features/settings";
import { useTrainingUnloadGuard } from "@/features/training/hooks/use-training-unload-guard";
import { useSidebarPin } from "@/hooks/use-sidebar-pin";
@ -114,6 +115,7 @@ function RootLayout() {
return (
<AppProvider>
<SettingsDialog />
<IngestionToastStack />
{hideNavbar ? (
<main className="flex-1">
<Suspense fallback={RouteFallback}>

View file

@ -0,0 +1,114 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
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 { useRagStore } from "../stores/rag-store";
import { IngestionProgress } from "./ingestion-progress";
/**
* Floating progress stack for in-flight RAG ingestion jobs.
*
* Watches the rag-store `jobs` map (populated by `subscribeJob`) and
* renders one card per job that's neither completed nor errored. On
* completion the card stays for a few seconds with a success state
* before fading out, so users notice the run finished even if they
* weren't watching the per-doc progress chips.
*
* Mounted once at the app root (`__root.tsx`) so it follows users
* across pages.
*/
const DISMISS_DELAY_MS = 4000;
export function IngestionToastStack() {
const jobs = useRagStore((s) => s.jobs);
const reduced = useReducedMotion();
// Per-job timeout handles so terminal toasts auto-dismiss.
const [dismissedJobs, setDismissedJobs] = useState<Set<string>>(
() => new Set(),
);
// Schedule auto-dismiss for jobs that have reached a terminal state.
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = [];
for (const [jobId, event] of Object.entries(jobs)) {
if (
(event.type === "complete" || event.type === "error") &&
!dismissedJobs.has(jobId)
) {
timers.push(
setTimeout(() => {
setDismissedJobs((prev) => {
const next = new Set(prev);
next.add(jobId);
return next;
});
}, DISMISS_DELAY_MS),
);
}
}
return () => timers.forEach(clearTimeout);
}, [jobs, dismissedJobs]);
const visible = Object.entries(jobs).filter(
([jobId]) => !dismissedJobs.has(jobId),
);
if (visible.length === 0) return null;
return (
<div className="pointer-events-none fixed right-4 bottom-4 z-50 flex w-72 flex-col gap-2">
<AnimatePresence initial={false}>
{visible.map(([jobId, event]) => {
const isTerminal =
event.type === "complete" || event.type === "error";
const isError = event.type === "error";
return (
<motion.div
key={jobId}
layout
initial={reduced ? false : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={
reduced ? { opacity: 0 } : { opacity: 0, y: 12 }
}
transition={{ duration: reduced ? 0 : 0.15 }}
className="pointer-events-auto rounded-md border border-border bg-popover px-3 py-2 shadow-md"
>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-xs font-medium">
{isError
? "Ingestion failed"
: event.type === "complete"
? "Indexed"
: "Indexing document"}
</span>
<IngestionProgress jobId={jobId} className="mt-0.5" />
</div>
<Button
variant="ghost"
size="icon"
aria-label="Dismiss"
className="h-5 w-5 shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => {
setDismissedJobs((prev) => {
const next = new Set(prev);
next.add(jobId);
return next;
});
}}
>
<HugeiconsIcon icon={Cancel01Icon} size={12} />
</Button>
</div>
</motion.div>
);
})}
</AnimatePresence>
</div>
);
}