feat: enhance indexing state management and inbox count formatting

- Improved indexing state management by refining the logic for handling notifications, ensuring accurate updates for in-progress, completed, and failed states.
- Introduced a new utility function to format inbox counts, displaying numbers up to 999 and using "k+" for larger counts, enhancing user interface clarity.
- Updated sidebar components to utilize the new inbox count formatting, improving the overall user experience.
This commit is contained in:
Anish Sarkar 2026-01-24 01:20:51 +05:30
parent c48ba36fa4
commit 6d14b49d3f
4 changed files with 75 additions and 44 deletions

View file

@ -10,8 +10,9 @@ import { isConnectorIndexingMetadata } from "@/contracts/types/inbox.types";
*
* This provides a better UX than polling by:
* 1. Setting indexing state immediately when user triggers indexing (optimistic)
* 2. Clearing indexing state when Electric SQL detects last_indexed_at changed
* 3. Clearing indexing state when a failed notification is detected
* 2. Detecting in_progress notifications from Electric SQL to restore state after remounts
* 3. Clearing indexing state when notifications become completed or failed
* 4. Clearing indexing state when Electric SQL detects last_indexed_at changed
*
* The actual `last_indexed_at` value comes from Electric SQL/PGlite, not local state.
*/
@ -28,65 +29,73 @@ export function useIndexingConnectors(
// Detect when last_indexed_at changes (indexing completed) via Electric SQL
useEffect(() => {
const previousValues = previousLastIndexedAtRef.current;
const newIndexingIds = new Set(indexingConnectorIds);
let hasChanges = false;
for (const connector of connectors) {
const previousValue = previousValues.get(connector.id);
const currentValue = connector.last_indexed_at;
// If last_indexed_at changed and connector was in indexing state, clear it
// If last_indexed_at changed, clear it from indexing state
if (
previousValue !== undefined && // We've seen this connector before
previousValue !== currentValue && // Value changed
indexingConnectorIds.has(connector.id) // It was marked as indexing
previousValue !== currentValue // Value changed
) {
newIndexingIds.delete(connector.id);
hasChanges = true;
// Use functional update to access current state
setIndexingConnectorIds((prev) => {
if (prev.has(connector.id)) {
const next = new Set(prev);
next.delete(connector.id);
return next;
}
return prev;
});
}
// Update previous value tracking
previousValues.set(connector.id, currentValue);
}
}, [connectors]);
if (hasChanges) {
setIndexingConnectorIds(newIndexingIds);
}
}, [connectors, indexingConnectorIds]);
// Detect failed notifications and stop indexing state
// Detect notification status changes and update indexing state accordingly
// This restores spinner state after component remounts and handles all status transitions
useEffect(() => {
if (!inboxItems || inboxItems.length === 0) return;
const newIndexingIds = new Set(indexingConnectorIds);
let hasChanges = false;
setIndexingConnectorIds((prev) => {
const newIndexingIds = new Set(prev);
let hasChanges = false;
for (const item of inboxItems) {
// Only check connector_indexing notifications
if (item.type !== "connector_indexing") continue;
for (const item of inboxItems) {
// Only check connector_indexing notifications
if (item.type !== "connector_indexing") continue;
// Check if this notification indicates a failure
const metadata = isConnectorIndexingMetadata(item.metadata)
? item.metadata
: null;
if (!metadata) continue;
const metadata = isConnectorIndexingMetadata(item.metadata)
? item.metadata
: null;
if (!metadata) continue;
// Check if status is "failed" or if there's an error_message
const isFailed =
metadata.status === "failed" ||
(metadata.error_message && metadata.error_message.trim().length > 0);
// If failed and connector is in indexing state, clear it
if (isFailed && indexingConnectorIds.has(metadata.connector_id)) {
newIndexingIds.delete(metadata.connector_id);
hasChanges = true;
// If status is "in_progress", add connector to indexing set
if (metadata.status === "in_progress") {
if (!newIndexingIds.has(metadata.connector_id)) {
newIndexingIds.add(metadata.connector_id);
hasChanges = true;
}
}
// If status is "completed" or "failed", remove connector from indexing set
else if (
metadata.status === "completed" ||
metadata.status === "failed" ||
(metadata.error_message && metadata.error_message.trim().length > 0)
) {
if (newIndexingIds.has(metadata.connector_id)) {
newIndexingIds.delete(metadata.connector_id);
hasChanges = true;
}
}
}
}
if (hasChanges) {
setIndexingConnectorIds(newIndexingIds);
}
}, [inboxItems, indexingConnectorIds]);
return hasChanges ? newIndexingIds : prev;
});
}, [inboxItems]);
// Add a connector to the indexing set (called when indexing starts)
const startIndexing = useCallback((connectorId: number) => {

View file

@ -38,6 +38,17 @@ interface LayoutDataProviderProps {
breadcrumb?: React.ReactNode;
}
/**
* Format count for display: shows numbers up to 999, then "1k+", "2k+", etc.
*/
function formatInboxCount(count: number): string {
if (count <= 999) {
return count.toString();
}
const thousands = Math.floor(count / 1000);
return `${thousands}k+`;
}
export function LayoutDataProvider({
searchSpaceId,
children,
@ -172,7 +183,7 @@ export function LayoutDataProvider({
url: "#inbox", // Special URL to indicate this is handled differently
icon: Inbox,
isActive: isInboxSidebarOpen,
badge: unreadCount > 0 ? (unreadCount > 99 ? "99+" : unreadCount) : undefined,
badge: unreadCount > 0 ? formatInboxCount(unreadCount) : undefined,
},
],
[searchSpaceId, pathname, isInboxSidebarOpen, unreadCount]

View file

@ -70,6 +70,17 @@ function getInitials(name: string | null | undefined, email: string | null | und
return "U";
}
/**
* Format count for display: shows numbers up to 999, then "1k+", "2k+", etc.
*/
function formatInboxCount(count: number): string {
if (count <= 999) {
return count.toString();
}
const thousands = Math.floor(count / 1000);
return `${thousands}k+`;
}
/**
* Get display name for connector type
*/
@ -732,7 +743,7 @@ export function InboxSidebar({
<AtSign className="h-4 w-4" />
<span>{t("mentions") || "Mentions"}</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
{unreadMentionsCount}
{formatInboxCount(unreadMentionsCount)}
</span>
</span>
</TabsTrigger>
@ -744,7 +755,7 @@ export function InboxSidebar({
<History className="h-4 w-4" />
<span>{t("status") || "Status"}</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
{unreadStatusCount}
{formatInboxCount(unreadStatusCount)}
</span>
</span>
</TabsTrigger>

View file

@ -39,7 +39,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
>
<Icon className="h-4 w-4" />
{item.badge && (
<span className="absolute top-0.5 right-0.5 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="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}
</span>
)}
@ -70,7 +70,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate">{item.title}</span>
{item.badge && (
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-red-500 text-white text-xs 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">
{item.badge}
</span>
)}