refactor: update LayoutDataProvider and NavSection components to use DocumentsProcessingStatus for improved document processing status handling

This commit is contained in:
Anish Sarkar 2026-03-08 21:16:52 +05:30
parent 0a1d0035e6
commit 2adf5750df
4 changed files with 122 additions and 34 deletions

View file

@ -132,8 +132,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
const totalUnreadCount = commentsInbox.unreadCount + statusInbox.unreadCount; const totalUnreadCount = commentsInbox.unreadCount + statusInbox.unreadCount;
// Whether any documents are currently being uploaded/indexed — drives sidebar spinner // Document processing status — drives sidebar status indicator (spinner / check / error)
const isDocumentsProcessing = useDocumentsProcessing(numericSpaceId); const documentsProcessingStatus = useDocumentsProcessing(numericSpaceId);
// Track seen notification IDs to detect new page_limit_exceeded notifications // Track seen notification IDs to detect new page_limit_exceeded notifications
const seenPageLimitNotifications = useRef<Set<number>>(new Set()); const seenPageLimitNotifications = useRef<Set<number>>(new Set());
@ -271,7 +271,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
url: "#documents", url: "#documents",
icon: SquareLibrary, icon: SquareLibrary,
isActive: isDocumentsSidebarOpen, isActive: isDocumentsSidebarOpen,
showSpinner: isDocumentsProcessing, statusIndicator: documentsProcessingStatus,
}, },
{ {
title: "Announcements", title: "Announcements",
@ -287,7 +287,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
totalUnreadCount, totalUnreadCount,
isAnnouncementsSidebarOpen, isAnnouncementsSidebarOpen,
announcementUnreadCount, announcementUnreadCount,
isDocumentsProcessing, documentsProcessingStatus,
] ]
); );

View file

@ -1,4 +1,5 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import type { DocumentsProcessingStatus } from "@/hooks/use-documents-processing";
export interface SearchSpace { export interface SearchSpace {
id: number; id: number;
@ -21,7 +22,7 @@ export interface NavItem {
icon: LucideIcon; icon: LucideIcon;
isActive?: boolean; isActive?: boolean;
badge?: string | number; badge?: string | number;
showSpinner?: boolean; statusIndicator?: DocumentsProcessingStatus;
} }
export interface ChatItem { export interface ChatItem {

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { CheckCircle2, CircleAlert } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -11,13 +12,55 @@ interface NavSectionProps {
isCollapsed?: boolean; isCollapsed?: boolean;
} }
function StatusBadge({ status }: { status: NavItem["statusIndicator"] }) {
if (status === "processing") {
return (
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center h-[14px] w-[14px] rounded-full bg-primary/15">
<Spinner size="xs" className="text-primary" />
</span>
);
}
if (status === "success") {
return (
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center h-[14px] w-[14px] rounded-full bg-emerald-500/15 animate-in fade-in duration-300">
<CheckCircle2 className="h-[10px] w-[10px] text-emerald-500" />
</span>
);
}
if (status === "error") {
return (
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center h-[14px] w-[14px] rounded-full bg-destructive/15 animate-in fade-in duration-300">
<CircleAlert className="h-[10px] w-[10px] text-destructive" />
</span>
);
}
return null;
}
function StatusIcon({ status, FallbackIcon, className }: {
status: NavItem["statusIndicator"];
FallbackIcon: NavItem["icon"];
className?: string;
}) {
if (status === "processing") {
return <Spinner size="sm" className={cn("shrink-0 text-primary", className)} />;
}
if (status === "success") {
return <CheckCircle2 className={cn("shrink-0 text-emerald-500 animate-in fade-in duration-300", className)} />;
}
if (status === "error") {
return <CircleAlert className={cn("shrink-0 text-destructive animate-in fade-in duration-300", className)} />;
}
return <FallbackIcon className={cn("shrink-0", className)} />;
}
export function NavSection({ items, onItemClick, isCollapsed = false }: NavSectionProps) { export function NavSection({ items, onItemClick, isCollapsed = false }: NavSectionProps) {
return ( return (
<div className={cn("flex flex-col gap-0.5 py-2", isCollapsed && "items-center")}> <div className={cn("flex flex-col gap-0.5 py-2", isCollapsed && "items-center")}>
{items.map((item) => { {items.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const indicator = item.statusIndicator;
// Add data-joyride for onboarding tour
const joyrideAttr = const joyrideAttr =
item.title === "Documents" || item.title.toLowerCase().includes("documents") item.title === "Documents" || item.title.toLowerCase().includes("documents")
? { "data-joyride": "documents-sidebar" } ? { "data-joyride": "documents-sidebar" }
@ -40,10 +83,8 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
{...joyrideAttr} {...joyrideAttr}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
{item.showSpinner ? ( {indicator && indicator !== "idle" ? (
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center h-[14px] w-[14px] rounded-full bg-primary/15"> <StatusBadge status={indicator} />
<Spinner size="xs" className="text-primary" />
</span>
) : item.badge ? ( ) : item.badge ? (
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center min-w-[14px] h-[14px] px-0.5 rounded-full bg-red-500 text-white text-[9px] font-medium"> <span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center min-w-[14px] h-[14px] px-0.5 rounded-full bg-red-500 text-white text-[9px] font-medium">
{item.badge} {item.badge}
@ -72,11 +113,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
)} )}
{...joyrideAttr} {...joyrideAttr}
> >
{item.showSpinner ? ( <StatusIcon
<Spinner size="sm" className="shrink-0 text-primary" /> status={indicator}
) : ( FallbackIcon={Icon}
<Icon className="h-4 w-4 shrink-0" /> className="h-4 w-4"
)} />
<span className="flex-1 truncate">{item.title}</span> <span className="flex-1 truncate">{item.title}</span>
{item.badge && ( {item.badge && (
<span className="inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium"> <span className="inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">

View file

@ -3,20 +3,23 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useElectricClient } from "@/lib/electric/context"; import { useElectricClient } from "@/lib/electric/context";
export type DocumentsProcessingStatus = "idle" | "processing" | "success" | "error";
const SUCCESS_LINGER_MS = 5000;
/** /**
* Returns whether any documents in the search space are currently being * Returns the processing status of documents in the search space:
* uploaded or indexed (status = "pending" | "processing"). * - "processing" at least one doc is pending/processing (show spinner)
* * - "error" nothing processing, but failed docs exist (show red icon)
* Covers both manual file uploads (2-phase pattern) and all connector indexers, * - "success" just transitioned from processing all clear (green check, auto-dismisses)
* since both create documents with status = pending before processing. * - "idle" nothing noteworthy (show normal icon)
*
* The sync shape uses the same columns as useDocuments so Electric can share
* the subscription when both hooks are active simultaneously.
*/ */
export function useDocumentsProcessing(searchSpaceId: number | null): boolean { export function useDocumentsProcessing(searchSpaceId: number | null): DocumentsProcessingStatus {
const electricClient = useElectricClient(); const electricClient = useElectricClient();
const [isProcessing, setIsProcessing] = useState(false); const [status, setStatus] = useState<DocumentsProcessingStatus>("idle");
const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null); const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null);
const wasProcessingRef = useRef(false);
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
if (!searchSpaceId || !electricClient) return; if (!searchSpaceId || !electricClient) return;
@ -76,10 +79,15 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean {
if (!db.live?.query) return; if (!db.live?.query) return;
const liveQuery = await db.live.query<{ count: number | string }>( const liveQuery = await db.live.query<{
`SELECT COUNT(*) as count FROM documents processing_count: number | string;
WHERE search_space_id = $1 failed_count: number | string;
AND (status->>'state' = 'pending' OR status->>'state' = 'processing')`, }>(
`SELECT
SUM(CASE WHEN status->>'state' IN ('pending', 'processing') THEN 1 ELSE 0 END) AS processing_count,
SUM(CASE WHEN status->>'state' = 'failed' THEN 1 ELSE 0 END) AS failed_count
FROM documents
WHERE search_space_id = $1`,
[spaceId] [spaceId]
); );
@ -88,10 +96,44 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean {
return; return;
} }
liveQuery.subscribe((result: { rows: Array<{ count: number | string }> }) => { liveQuery.subscribe(
if (!mounted || !result.rows?.[0]) return; (result: { rows: Array<{ processing_count: number | string; failed_count: number | string }> }) => {
setIsProcessing((Number(result.rows[0].count) || 0) > 0); if (!mounted || !result.rows?.[0]) return;
});
const processingCount = Number(result.rows[0].processing_count) || 0;
const failedCount = Number(result.rows[0].failed_count) || 0;
if (processingCount > 0) {
wasProcessingRef.current = true;
if (successTimerRef.current) {
clearTimeout(successTimerRef.current);
successTimerRef.current = null;
}
setStatus("processing");
} else if (failedCount > 0) {
wasProcessingRef.current = false;
if (successTimerRef.current) {
clearTimeout(successTimerRef.current);
successTimerRef.current = null;
}
setStatus("error");
} else if (wasProcessingRef.current) {
wasProcessingRef.current = false;
setStatus("success");
if (successTimerRef.current) {
clearTimeout(successTimerRef.current);
}
successTimerRef.current = setTimeout(() => {
if (mounted) {
setStatus("idle");
successTimerRef.current = null;
}
}, SUCCESS_LINGER_MS);
} else {
setStatus("idle");
}
}
);
liveQueryRef.current = liveQuery; liveQueryRef.current = liveQuery;
} catch (err) { } catch (err) {
@ -103,6 +145,10 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean {
return () => { return () => {
mounted = false; mounted = false;
if (successTimerRef.current) {
clearTimeout(successTimerRef.current);
successTimerRef.current = null;
}
if (liveQueryRef.current) { if (liveQueryRef.current) {
try { try {
liveQueryRef.current.unsubscribe?.(); liveQueryRef.current.unsubscribe?.();
@ -114,5 +160,5 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean {
}; };
}, [searchSpaceId, electricClient]); }, [searchSpaceId, electricClient]);
return isProcessing; return status;
} }