mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-30 19:36:25 +02:00
refactor: update LayoutDataProvider and NavSection components to use DocumentsProcessingStatus for improved document processing status handling
This commit is contained in:
parent
0a1d0035e6
commit
2adf5750df
4 changed files with 122 additions and 34 deletions
|
|
@ -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,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
(result: { rows: Array<{ processing_count: number | string; failed_count: number | string }> }) => {
|
||||||
if (!mounted || !result.rows?.[0]) return;
|
if (!mounted || !result.rows?.[0]) return;
|
||||||
setIsProcessing((Number(result.rows[0].count) || 0) > 0);
|
|
||||||
});
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue