mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-08 20:25:19 +02:00
refactor: update document processing status handling and improve sidebar components
This commit is contained in:
parent
d8f403efba
commit
0201fd319d
7 changed files with 141 additions and 49 deletions
|
|
@ -153,7 +153,7 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
<Clock className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Pending - waiting to be synced</TooltipContent>
|
||||
<TooltipContent side="top">Pending — waiting to be synced</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ import {
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||
import { useDocumentsProcessing } from "@/hooks/use-documents-processing";
|
||||
import { useInbox } from "@/hooks/use-inbox";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
||||
|
|
@ -194,9 +193,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
setStatusInboxItems(statusInbox.inboxItems);
|
||||
}, [statusInbox.inboxItems, setStatusInboxItems]);
|
||||
|
||||
// Document processing status — drives sidebar status indicator (spinner / check / error)
|
||||
const documentsProcessingStatus = useDocumentsProcessing(numericSpaceId);
|
||||
|
||||
// Track seen notification IDs to detect new page_limit_exceeded notifications
|
||||
const seenPageLimitNotifications = useRef<Set<number>>(new Set());
|
||||
const isInitialLoad = useRef(true);
|
||||
|
|
@ -366,7 +362,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
isActive: isMobile
|
||||
? isDocumentsSidebarOpen
|
||||
: isDocumentsSidebarOpen && !isRightPanelCollapsed,
|
||||
statusIndicator: documentsProcessingStatus,
|
||||
},
|
||||
{
|
||||
title: "Announcements",
|
||||
|
|
@ -384,7 +379,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
totalUnreadCount,
|
||||
isAnnouncementsSidebarOpen,
|
||||
announcementUnreadCount,
|
||||
documentsProcessingStatus,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,18 +3,13 @@
|
|||
import { useAtomValue } from "jotai";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
|
||||
import { reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { activeTabAtom, tabsAtom } from "@/atoms/tabs/tabs.atom";
|
||||
import { ChatHeader } from "@/components/new-chat/chat-header";
|
||||
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RightPanelExpandButton } from "../right-panel/RightPanel";
|
||||
|
||||
interface HeaderProps {
|
||||
mobileMenuTrigger?: React.ReactNode;
|
||||
|
|
@ -26,19 +21,9 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
|
|||
const isMobile = useIsMobile();
|
||||
const activeTab = useAtomValue(activeTabAtom);
|
||||
const tabs = useAtomValue(tabsAtom);
|
||||
const collapsed = useAtomValue(rightPanelCollapsedAtom);
|
||||
const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
|
||||
const reportState = useAtomValue(reportPanelAtom);
|
||||
const editorState = useAtomValue(editorPanelAtom);
|
||||
const hitlEditState = useAtomValue(hitlEditPanelAtom);
|
||||
|
||||
const isChatPage = pathname?.includes("/new-chat") ?? false;
|
||||
const isDocumentTab = activeTab?.type === "document";
|
||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||
const editorOpen = editorState.isOpen && !!editorState.documentId;
|
||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||
const showExpandButton =
|
||||
!isMobile && collapsed && (documentsOpen || reportOpen || editorOpen || hitlEditOpen);
|
||||
const hasTabBar = tabs.length > 1;
|
||||
|
||||
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||
|
|
@ -72,12 +57,11 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
|
|||
</div>
|
||||
|
||||
{/* Right side - Actions */}
|
||||
<div
|
||||
className={cn("ml-auto flex items-center gap-2", showExpandButton && !hasTabBar && "mr-10")}
|
||||
>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{hasThread && (
|
||||
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
|
||||
)}
|
||||
{!isMobile && !hasTabBar && <RightPanelExpandButton />}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
|
|||
import type { FolderDisplay } from "@/components/documents/FolderNode";
|
||||
import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog";
|
||||
import { FolderTreeView } from "@/components/documents/FolderTreeView";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
|
||||
import {
|
||||
AlertDialog,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { CheckCircle2, CircleAlert } from "lucide-react";
|
||||
import { CheckCircle2, CircleAlert, RefreshCw } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { NavItem } from "../../types/layout.types";
|
||||
|
|
@ -12,6 +12,46 @@ interface NavSectionProps {
|
|||
isCollapsed?: boolean;
|
||||
}
|
||||
|
||||
function getStatusInfo(status: NavItem["statusIndicator"]) {
|
||||
switch (status) {
|
||||
case "processing":
|
||||
return {
|
||||
tooltip: "New or updated documents are still being prepared for search.",
|
||||
};
|
||||
case "background_sync":
|
||||
return {
|
||||
pillLabel: "Background sync",
|
||||
tooltip:
|
||||
"Periodic sync is checking for updates in the background. Existing documents stay searchable while this runs.",
|
||||
};
|
||||
case "success":
|
||||
return {
|
||||
tooltip: "All document updates are fully synced.",
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
pillLabel: "Needs attention",
|
||||
tooltip: "Some documents failed to sync. Open Documents or Inbox for details.",
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: NavItem["statusIndicator"] }) {
|
||||
const { pillLabel } = getStatusInfo(status);
|
||||
|
||||
if (!pillLabel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full border border-border/60 bg-background/60 px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{pillLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: NavItem["statusIndicator"] }) {
|
||||
if (status === "processing") {
|
||||
return (
|
||||
|
|
@ -20,6 +60,13 @@ function StatusBadge({ status }: { status: NavItem["statusIndicator"] }) {
|
|||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "background_sync") {
|
||||
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">
|
||||
<RefreshCw className="h-[9px] w-[9px] text-primary animate-[spin_3s_linear_infinite]" />
|
||||
</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">
|
||||
|
|
@ -49,6 +96,16 @@ function StatusIcon({
|
|||
if (status === "processing") {
|
||||
return <Spinner size="sm" className={cn("shrink-0 text-primary", className)} />;
|
||||
}
|
||||
if (status === "background_sync") {
|
||||
return (
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
"shrink-0 text-primary animate-[spin_3s_linear_infinite]",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (status === "success") {
|
||||
return (
|
||||
<CheckCircle2
|
||||
|
|
@ -89,6 +146,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
|||
item.title === "Inbox" || item.title.toLowerCase().includes("inbox")
|
||||
? { "data-joyride": "inbox-sidebar" as const }
|
||||
: {};
|
||||
const { tooltip } = getStatusInfo(item.statusIndicator);
|
||||
|
||||
return (
|
||||
<SidebarButton
|
||||
|
|
@ -107,6 +165,8 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
|||
className="h-4 w-4"
|
||||
/>
|
||||
}
|
||||
trailingContent={<StatusPill status={item.statusIndicator} />}
|
||||
tooltipContent={tooltip}
|
||||
buttonProps={joyrideAttr}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ interface SidebarButtonProps {
|
|||
collapsedOverlay?: React.ReactNode;
|
||||
/** Custom icon node for expanded mode — overrides the default <Icon> rendering */
|
||||
expandedIconNode?: React.ReactNode;
|
||||
/** Optional inline trailing content shown in expanded mode */
|
||||
trailingContent?: React.ReactNode;
|
||||
/** Optional tooltip content that replaces the default label tooltip */
|
||||
tooltipContent?: React.ReactNode;
|
||||
className?: string;
|
||||
/** Extra attributes spread onto the inner <button> (e.g. data-joyride) */
|
||||
buttonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
|
@ -42,6 +46,8 @@ export function SidebarButton({
|
|||
badge,
|
||||
collapsedOverlay,
|
||||
expandedIconNode,
|
||||
trailingContent,
|
||||
tooltipContent,
|
||||
className,
|
||||
buttonProps,
|
||||
}: SidebarButtonProps) {
|
||||
|
|
@ -62,15 +68,19 @@ export function SidebarButton({
|
|||
<span className="sr-only">{label}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{label}
|
||||
{typeof badge === "string" && ` (${badge})`}
|
||||
<TooltipContent side="right" className="max-w-xs">
|
||||
{tooltipContent ?? (
|
||||
<>
|
||||
{label}
|
||||
{typeof badge === "string" && ` (${badge})`}
|
||||
</>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
|
|
@ -79,6 +89,7 @@ export function SidebarButton({
|
|||
>
|
||||
{expandedIconNode ?? <Icon className="h-4 w-4 shrink-0" />}
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
{trailingContent}
|
||||
{badge && typeof badge !== "string" ? badge : null}
|
||||
{badge && typeof badge === "string" ? (
|
||||
<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">
|
||||
|
|
@ -87,4 +98,17 @@ export function SidebarButton({
|
|||
) : null}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (!tooltipContent) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs">
|
||||
{tooltipContent}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,18 +4,31 @@ import { useQuery } from "@rocicorp/zero/react";
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { queries } from "@/zero/queries";
|
||||
|
||||
export type DocumentsProcessingStatus = "idle" | "processing" | "success" | "error";
|
||||
export type DocumentsProcessingStatus =
|
||||
| "idle"
|
||||
| "processing"
|
||||
| "background_sync"
|
||||
| "success"
|
||||
| "error";
|
||||
|
||||
const SUCCESS_LINGER_MS = 5000;
|
||||
|
||||
interface UseDocumentsProcessingOptions {
|
||||
hasPeriodicSyncEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the processing status of documents in the search space:
|
||||
* - "processing" — at least one doc is pending/processing (show spinner)
|
||||
* - "processing" — docs are queued or actively being prepared for search
|
||||
* - "background_sync" — existing docs are being refreshed in the background
|
||||
* - "error" — nothing processing, but failed docs exist (show red icon)
|
||||
* - "success" — just transitioned from processing → all clear (green check, auto-dismisses)
|
||||
* - "idle" — nothing noteworthy (show normal icon)
|
||||
*/
|
||||
export function useDocumentsProcessing(searchSpaceId: number | null): DocumentsProcessingStatus {
|
||||
export function useDocumentsProcessing(
|
||||
searchSpaceId: number | null,
|
||||
{ hasPeriodicSyncEnabled = false }: UseDocumentsProcessingOptions = {}
|
||||
): DocumentsProcessingStatus {
|
||||
const [status, setStatus] = useState<DocumentsProcessingStatus>("idle");
|
||||
const wasProcessingRef = useRef(false);
|
||||
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
|
@ -25,38 +38,56 @@ export function useDocumentsProcessing(searchSpaceId: number | null): DocumentsP
|
|||
useEffect(() => {
|
||||
if (!searchSpaceId || !documents) return;
|
||||
|
||||
const clearSuccessTimer = () => {
|
||||
if (successTimerRef.current) {
|
||||
clearTimeout(successTimerRef.current);
|
||||
successTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
let pendingCount = 0;
|
||||
let processingCount = 0;
|
||||
let failedCount = 0;
|
||||
let readyCount = 0;
|
||||
|
||||
for (const doc of documents) {
|
||||
// Keep the nav indicator aligned with what the Documents sidebar actually renders.
|
||||
// Some connectors can create temporary untitled placeholder rows that remain hidden
|
||||
// from the sidebar, and those should not keep the whole section looking "stuck".
|
||||
if (!doc.title || doc.title.trim() === "") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const state = (doc.status as { state?: string } | null)?.state;
|
||||
if (state === "pending" || state === "processing") {
|
||||
if (state === "pending") {
|
||||
pendingCount++;
|
||||
} else if (state === "processing") {
|
||||
processingCount++;
|
||||
} else if (state === "failed") {
|
||||
failedCount++;
|
||||
} else {
|
||||
readyCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (processingCount > 0) {
|
||||
if (pendingCount > 0) {
|
||||
wasProcessingRef.current = true;
|
||||
if (successTimerRef.current) {
|
||||
clearTimeout(successTimerRef.current);
|
||||
successTimerRef.current = null;
|
||||
}
|
||||
clearSuccessTimer();
|
||||
setStatus("processing");
|
||||
} else if (processingCount > 0) {
|
||||
wasProcessingRef.current = true;
|
||||
clearSuccessTimer();
|
||||
|
||||
const isBackgroundSync = hasPeriodicSyncEnabled && readyCount > 0;
|
||||
setStatus(isBackgroundSync ? "background_sync" : "processing");
|
||||
} else if (failedCount > 0) {
|
||||
wasProcessingRef.current = false;
|
||||
if (successTimerRef.current) {
|
||||
clearTimeout(successTimerRef.current);
|
||||
successTimerRef.current = null;
|
||||
}
|
||||
clearSuccessTimer();
|
||||
setStatus("error");
|
||||
} else if (wasProcessingRef.current) {
|
||||
wasProcessingRef.current = false;
|
||||
setStatus("success");
|
||||
if (successTimerRef.current) {
|
||||
clearTimeout(successTimerRef.current);
|
||||
}
|
||||
clearSuccessTimer();
|
||||
successTimerRef.current = setTimeout(() => {
|
||||
setStatus("idle");
|
||||
successTimerRef.current = null;
|
||||
|
|
@ -64,7 +95,7 @@ export function useDocumentsProcessing(searchSpaceId: number | null): DocumentsP
|
|||
} else {
|
||||
setStatus("idle");
|
||||
}
|
||||
}, [searchSpaceId, documents]);
|
||||
}, [searchSpaceId, documents, hasPeriodicSyncEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue