Merge remote-tracking branch 'upstream/dev' into fix/docker-dev

This commit is contained in:
Anish Sarkar 2026-03-10 14:23:57 +05:30
commit 4cca366e11
18 changed files with 450 additions and 51 deletions

View file

@ -0,0 +1,8 @@
import { atom } from "jotai";
import type { InboxItem } from "@/contracts/types/inbox.types";
/**
* Shared atom for status inbox items populated by LayoutDataProvider.
* Avoids duplicate useInbox("status") calls in child components like ConnectorPopup.
*/
export const statusInboxItemsAtom = atom<InboxItem[]>([]);

View file

@ -19,8 +19,8 @@ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
import { useConnectorsElectric } from "@/hooks/use-connectors-electric";
import { useInbox } from "@/hooks/use-inbox";
import { cn } from "@/lib/utils";
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
@ -75,12 +75,9 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
const { data: documentTypeCounts, isFetching: documentTypesLoading } =
useAtomValue(documentTypeCountsAtom);
// Fetch status notifications to detect indexing failures
const { inboxItems: statusInboxItems = [] } = useInbox(
currentUser?.id ?? null,
searchSpaceId ? Number(searchSpaceId) : null,
"status"
);
// Read status inbox items from shared atom (populated by LayoutDataProvider)
// instead of creating a duplicate useInbox("status") hook.
const statusInboxItems = useAtomValue(statusInboxItemsAtom);
const inboxItems = useMemo(
() => statusInboxItems.filter((item) => item.type === "connector_indexing"),
[statusInboxItems]

View file

@ -10,6 +10,7 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "rea
import { toast } from "sonner";
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
@ -37,6 +38,7 @@ import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
import { useAnnouncements } from "@/hooks/use-announcements";
import { useDocumentsProcessing } from "@/hooks/use-documents-processing";
import { useInbox } from "@/hooks/use-inbox";
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { logout } from "@/lib/auth-utils";
import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
@ -144,11 +146,39 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
const userId = user?.id ? String(user.id) : null;
const numericSpaceId = Number(searchSpaceId) || null;
const commentsInbox = useInbox(userId, numericSpaceId, "comments");
const statusInbox = useInbox(userId, numericSpaceId, "status");
// Batch-fetch unread counts for all categories in a single request
// instead of 2 separate /unread-count calls.
const { data: batchUnread, isLoading: isBatchUnreadLoading } = useQuery({
queryKey: cacheKeys.notifications.batchUnreadCounts(numericSpaceId),
queryFn: () => notificationsApiService.getBatchUnreadCounts(numericSpaceId ?? undefined),
enabled: !!userId && !!numericSpaceId,
staleTime: 30_000,
});
const commentsInbox = useInbox(
userId,
numericSpaceId,
"comments",
batchUnread?.comments,
!isBatchUnreadLoading
);
const statusInbox = useInbox(
userId,
numericSpaceId,
"status",
batchUnread?.status,
!isBatchUnreadLoading
);
const totalUnreadCount = commentsInbox.unreadCount + statusInbox.unreadCount;
// Sync status inbox items to a shared atom so child components
// (e.g. ConnectorPopup) can read them without creating duplicate useInbox hooks.
const setStatusInboxItems = useSetAtom(statusInboxItemsAtom);
useEffect(() => {
setStatusInboxItems(statusInbox.inboxItems);
}, [statusInbox.inboxItems, setStatusInboxItems]);
// Document processing status — drives sidebar status indicator (spinner / check / error)
const documentsProcessingStatus = useDocumentsProcessing(numericSpaceId);

View file

@ -284,6 +284,20 @@ export const getSourceTypesResponse = z.object({
sources: z.array(sourceTypeItem),
});
/**
* Batched unread counts for all categories in a single response.
* Replaces 2 separate /unread-count calls (comments + status).
*/
export const categoryUnreadCount = z.object({
total_unread: z.number(),
recent_unread: z.number(),
});
export const getBatchUnreadCountResponse = z.object({
comments: categoryUnreadCount,
status: categoryUnreadCount,
});
// =============================================================================
// Type Guards for Metadata
// =============================================================================
@ -412,3 +426,4 @@ export type GetUnreadCountRequest = z.infer<typeof getUnreadCountRequest>;
export type GetUnreadCountResponse = z.infer<typeof getUnreadCountResponse>;
export type SourceTypeItem = z.infer<typeof sourceTypeItem>;
export type GetSourceTypesResponse = z.infer<typeof getSourceTypesResponse>;
export type GetBatchUnreadCountResponse = z.infer<typeof getBatchUnreadCountResponse>;

View file

@ -57,7 +57,9 @@ function getSyncCutoffDate(): string {
export function useInbox(
userId: string | null,
searchSpaceId: number | null,
category: NotificationCategory
category: NotificationCategory,
prefetchedUnread?: { total_unread: number; recent_unread: number } | null,
prefetchedUnreadReady = true,
) {
const electricClient = useElectricClient();
@ -77,9 +79,12 @@ export function useInbox(
const olderUnreadOffsetRef = useRef<number | null>(null);
const apiUnreadTotalRef = useRef(0);
// EFFECT 1: Fetch first page + unread count from API with category filter
// EFFECT 1: Fetch first page + unread count from API with category filter.
// When prefetchedUnreadReady=false, we wait for the batch query to settle
// before deciding whether we need an individual unread-count fallback call.
useEffect(() => {
if (!userId || !searchSpaceId) return;
if (!prefetchedUnreadReady) return;
let cancelled = false;
@ -94,15 +99,22 @@ export function useInbox(
const fetchInitialData = async () => {
try {
const notificationsPromise = notificationsApiService.getNotifications({
queryParams: {
search_space_id: searchSpaceId,
category,
limit: INITIAL_PAGE_SIZE,
},
});
// Use prefetched counts when available, otherwise fetch individually.
const unreadPromise = prefetchedUnread
? Promise.resolve(prefetchedUnread)
: notificationsApiService.getUnreadCount(searchSpaceId, undefined, category);
const [notificationsResponse, unreadResponse] = await Promise.all([
notificationsApiService.getNotifications({
queryParams: {
search_space_id: searchSpaceId,
category,
limit: INITIAL_PAGE_SIZE,
},
}),
notificationsApiService.getUnreadCount(searchSpaceId, undefined, category),
notificationsPromise,
unreadPromise,
]);
if (cancelled) return;
@ -127,7 +139,7 @@ export function useInbox(
return () => {
cancelled = true;
};
}, [userId, searchSpaceId, category]);
}, [userId, searchSpaceId, category, prefetchedUnread, prefetchedUnreadReady]);
// EFFECT 2: Electric sync (shared shape) + per-instance type-filtered live queries
useEffect(() => {

View file

@ -1,8 +1,10 @@
import {
type GetBatchUnreadCountResponse,
type GetNotificationsRequest,
type GetNotificationsResponse,
type GetSourceTypesResponse,
type GetUnreadCountResponse,
getBatchUnreadCountResponse,
getNotificationsRequest,
getNotificationsResponse,
getSourceTypesResponse,
@ -149,6 +151,25 @@ class NotificationsApiService {
getUnreadCountResponse
);
};
/**
* Get unread counts for all categories in a single request.
* Replaces 2 separate getUnreadCount calls (comments + status).
*/
getBatchUnreadCounts = async (
searchSpaceId?: number
): Promise<GetBatchUnreadCountResponse> => {
const params = new URLSearchParams();
if (searchSpaceId !== undefined) {
params.append("search_space_id", String(searchSpaceId));
}
const queryString = params.toString();
return baseApiService.get(
`/api/v1/notifications/unread-counts-batch${queryString ? `?${queryString}` : ""}`,
getBatchUnreadCountResponse
);
};
}
export const notificationsApiService = new NotificationsApiService();

View file

@ -98,5 +98,7 @@ export const cacheKeys = {
["notifications", "search", searchSpaceId, search, tab] as const,
sourceTypes: (searchSpaceId: number | null) =>
["notifications", "source-types", searchSpaceId] as const,
batchUnreadCounts: (searchSpaceId: number | null) =>
["notifications", "unread-counts-batch", searchSpaceId] as const,
},
};