mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
feat: add processing document count hook and integrate spinner in sidebar navigation for improved user feedback during document processing
This commit is contained in:
parent
7f3c647328
commit
b98dbf8952
5 changed files with 138 additions and 4 deletions
|
|
@ -34,6 +34,7 @@ import { Input } from "@/components/ui/input";
|
||||||
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
|
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
|
||||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||||
import { useInbox } from "@/hooks/use-inbox";
|
import { useInbox } from "@/hooks/use-inbox";
|
||||||
|
import { useDocumentsProcessing } from "@/hooks/use-documents-processing";
|
||||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||||
import { logout } from "@/lib/auth-utils";
|
import { logout } from "@/lib/auth-utils";
|
||||||
import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
|
import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
|
||||||
|
|
@ -129,6 +130,9 @@ export function LayoutDataProvider({
|
||||||
|
|
||||||
const totalUnreadCount = commentsInbox.unreadCount + statusInbox.unreadCount;
|
const totalUnreadCount = commentsInbox.unreadCount + statusInbox.unreadCount;
|
||||||
|
|
||||||
|
// Whether any documents are currently being uploaded/indexed — drives sidebar spinner
|
||||||
|
const isDocumentsProcessing = 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());
|
||||||
const isInitialLoad = useRef(true);
|
const isInitialLoad = useRef(true);
|
||||||
|
|
@ -266,6 +270,7 @@ export function LayoutDataProvider({
|
||||||
url: "#documents",
|
url: "#documents",
|
||||||
icon: SquareLibrary,
|
icon: SquareLibrary,
|
||||||
isActive: isDocumentsSidebarOpen,
|
isActive: isDocumentsSidebarOpen,
|
||||||
|
showSpinner: isDocumentsProcessing,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Announcements",
|
title: "Announcements",
|
||||||
|
|
@ -281,6 +286,7 @@ export function LayoutDataProvider({
|
||||||
isDocumentsSidebarOpen,
|
isDocumentsSidebarOpen,
|
||||||
totalUnreadCount,
|
totalUnreadCount,
|
||||||
announcementUnreadCount,
|
announcementUnreadCount,
|
||||||
|
isDocumentsProcessing,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export interface NavItem {
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
badge?: string | number;
|
badge?: string | number;
|
||||||
|
showSpinner?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatItem {
|
export interface ChatItem {
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
|
||||||
const handleVisibilityChange = (_visibility: ChatVisibility) => {};
|
const handleVisibilityChange = (_visibility: ChatVisibility) => {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4">
|
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4 md:border-b md:border-border">
|
||||||
{/* Left side - Mobile menu trigger + Model selector */}
|
{/* Left side - Mobile menu trigger + Model selector */}
|
||||||
<div className="flex flex-1 items-center gap-2 min-w-0">
|
<div className="flex flex-1 items-center gap-2 min-w-0">
|
||||||
{mobileMenuTrigger}
|
{mobileMenuTrigger}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
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";
|
||||||
import type { NavItem } from "../../types/layout.types";
|
import type { NavItem } from "../../types/layout.types";
|
||||||
|
|
@ -39,11 +40,15 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
||||||
{...joyrideAttr}
|
{...joyrideAttr}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
{item.badge && (
|
{item.showSpinner ? (
|
||||||
|
<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>
|
||||||
|
) : 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}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
<span className="sr-only">{item.title}</span>
|
<span className="sr-only">{item.title}</span>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
@ -67,7 +72,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
||||||
)}
|
)}
|
||||||
{...joyrideAttr}
|
{...joyrideAttr}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 shrink-0" />
|
{item.showSpinner ? (
|
||||||
|
<Spinner size="sm" className="shrink-0 text-primary" />
|
||||||
|
) : (
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
|
)}
|
||||||
<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">
|
||||||
|
|
|
||||||
118
surfsense_web/hooks/use-documents-processing.ts
Normal file
118
surfsense_web/hooks/use-documents-processing.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useElectricClient } from "@/lib/electric/context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether any documents in the search space are currently being
|
||||||
|
* uploaded or indexed (status = "pending" | "processing").
|
||||||
|
*
|
||||||
|
* Covers both manual file uploads (2-phase pattern) and all connector indexers,
|
||||||
|
* since both create documents with status = pending before processing.
|
||||||
|
*
|
||||||
|
* 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 {
|
||||||
|
const electricClient = useElectricClient();
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchSpaceId || !electricClient) return;
|
||||||
|
|
||||||
|
const spaceId = searchSpaceId;
|
||||||
|
const client = electricClient;
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
if (liveQueryRef.current) {
|
||||||
|
try {
|
||||||
|
liveQueryRef.current.unsubscribe?.();
|
||||||
|
} catch {
|
||||||
|
/* PGlite may be closed */
|
||||||
|
}
|
||||||
|
liveQueryRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handle = await client.syncShape({
|
||||||
|
table: "documents",
|
||||||
|
where: `search_space_id = ${spaceId}`,
|
||||||
|
columns: [
|
||||||
|
"id",
|
||||||
|
"document_type",
|
||||||
|
"search_space_id",
|
||||||
|
"title",
|
||||||
|
"created_by_id",
|
||||||
|
"created_at",
|
||||||
|
"status",
|
||||||
|
],
|
||||||
|
primaryKey: ["id"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (!handle.isUpToDate && handle.initialSyncPromise) {
|
||||||
|
await Promise.race([
|
||||||
|
handle.initialSyncPromise,
|
||||||
|
new Promise((resolve) => setTimeout(resolve, 5000)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
const db = client.db as {
|
||||||
|
live?: {
|
||||||
|
query: <T>(
|
||||||
|
sql: string,
|
||||||
|
params?: (number | string)[],
|
||||||
|
) => Promise<{
|
||||||
|
subscribe: (cb: (result: { rows: T[] }) => void) => void;
|
||||||
|
unsubscribe?: () => void;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!db.live?.query) return;
|
||||||
|
|
||||||
|
const liveQuery = await db.live.query<{ count: number | string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM documents
|
||||||
|
WHERE search_space_id = $1
|
||||||
|
AND (status->>'state' = 'pending' OR status->>'state' = 'processing')`,
|
||||||
|
[spaceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
liveQuery.unsubscribe?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
liveQuery.subscribe((result: { rows: Array<{ count: number | string }> }) => {
|
||||||
|
if (!mounted || !result.rows?.[0]) return;
|
||||||
|
setIsProcessing((Number(result.rows[0].count) || 0) > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
liveQueryRef.current = liveQuery;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[useDocumentsProcessing] Electric setup failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setup();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
if (liveQueryRef.current) {
|
||||||
|
try {
|
||||||
|
liveQueryRef.current.unsubscribe?.();
|
||||||
|
} catch {
|
||||||
|
/* PGlite may be closed */
|
||||||
|
}
|
||||||
|
liveQueryRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [searchSpaceId, electricClient]);
|
||||||
|
|
||||||
|
return isProcessing;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue