4 ? "pb-8" : ""}`}
@@ -206,7 +226,15 @@ export function Sidebar({
) : undefined
}
>
- {chats.length > 0 ? (
+ {isLoadingChats ? (
+
4 ? "pb-8" : ""}`}
diff --git a/surfsense_web/contracts/enums/connectorIcons.tsx b/surfsense_web/contracts/enums/connectorIcons.tsx
index aaf476215..18a872d94 100644
--- a/surfsense_web/contracts/enums/connectorIcons.tsx
+++ b/surfsense_web/contracts/enums/connectorIcons.tsx
@@ -92,7 +92,7 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
case "FILE":
return ;
case "GOOGLE_DRIVE_FILE":
- return ;
+ return ;
case "COMPOSIO_GOOGLE_DRIVE_CONNECTOR":
return ;
case "COMPOSIO_GMAIL_CONNECTOR":
diff --git a/surfsense_web/contracts/types/document.types.ts b/surfsense_web/contracts/types/document.types.ts
index 01a58173e..b7a2d2cf8 100644
--- a/surfsense_web/contracts/types/document.types.ts
+++ b/surfsense_web/contracts/types/document.types.ts
@@ -23,6 +23,7 @@ export const documentTypeEnum = z.enum([
"ELASTICSEARCH_CONNECTOR",
"BOOKSTACK_CONNECTOR",
"CIRCLEBACK",
+ "OBSIDIAN_CONNECTOR",
"SURFSENSE_DOCS",
"NOTE",
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
@@ -41,6 +42,8 @@ export const document = z.object({
created_at: z.string(),
updated_at: z.string().nullable(),
search_space_id: z.number(),
+ created_by_id: z.string().nullable().optional(),
+ created_by_name: z.string().nullable().optional(),
});
export const extensionDocumentContent = z.object({
diff --git a/surfsense_web/hooks/use-documents-electric.ts b/surfsense_web/hooks/use-documents-electric.ts
deleted file mode 100644
index 43809499e..000000000
--- a/surfsense_web/hooks/use-documents-electric.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-"use client";
-
-import { useEffect, useMemo, useRef, useState } from "react";
-import type { SyncHandle } from "@/lib/electric/client";
-import { useElectricClient } from "@/lib/electric/context";
-
-interface Document {
- id: number;
- search_space_id: number;
- document_type: string;
- created_at: string;
-}
-
-/**
- * Hook for managing documents with Electric SQL real-time sync
- *
- * Uses the Electric client from context (provided by ElectricProvider)
- * instead of initializing its own - prevents race conditions and memory leaks
- */
-export function useDocumentsElectric(searchSpaceId: number | string | null) {
- // Get Electric client from context - ElectricProvider handles initialization
- const electricClient = useElectricClient();
-
- const [documents, setDocuments] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const syncHandleRef = useRef(null);
- const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
- const syncKeyRef = useRef(null);
-
- // Calculate document type counts from synced documents
- const documentTypeCounts = useMemo(() => {
- if (!documents.length) return {};
-
- const counts: Record = {};
- for (const doc of documents) {
- counts[doc.document_type] = (counts[doc.document_type] || 0) + 1;
- }
- return counts;
- }, [documents]);
-
- // Start syncing when Electric client is available
- useEffect(() => {
- // Wait for both searchSpaceId and Electric client to be available
- if (!searchSpaceId || !electricClient) {
- setLoading(!electricClient); // Still loading if waiting for Electric
- if (!searchSpaceId) {
- setDocuments([]);
- }
- return;
- }
-
- // Create a unique key for this sync to prevent duplicate subscriptions
- const syncKey = `documents_${searchSpaceId}`;
- if (syncKeyRef.current === syncKey) {
- // Already syncing for this search space
- return;
- }
-
- let mounted = true;
- syncKeyRef.current = syncKey;
-
- async function startSync() {
- try {
- console.log("[useDocumentsElectric] Starting sync for search space:", searchSpaceId);
-
- const handle = await electricClient.syncShape({
- table: "documents",
- where: `search_space_id = ${searchSpaceId}`,
- columns: ["id", "document_type", "search_space_id", "created_at"],
- primaryKey: ["id"],
- });
-
- console.log("[useDocumentsElectric] Sync started:", {
- isUpToDate: handle.isUpToDate,
- });
-
- // Wait for initial sync with timeout
- if (!handle.isUpToDate && handle.initialSyncPromise) {
- try {
- await Promise.race([
- handle.initialSyncPromise,
- new Promise((resolve) => setTimeout(resolve, 2000)),
- ]);
- } catch (syncErr) {
- console.error("[useDocumentsElectric] Initial sync failed:", syncErr);
- }
- }
-
- if (!mounted) {
- handle.unsubscribe();
- return;
- }
-
- syncHandleRef.current = handle;
- setLoading(false);
- setError(null);
-
- // Fetch initial documents
- await fetchDocuments();
-
- // Set up live query for real-time updates
- await setupLiveQuery();
- } catch (err) {
- if (!mounted) return;
- console.error("[useDocumentsElectric] Failed to start sync:", err);
- setError(err instanceof Error ? err : new Error("Failed to sync documents"));
- setLoading(false);
- }
- }
-
- async function fetchDocuments() {
- try {
- const result = await electricClient.db.query(
- `SELECT id, document_type, search_space_id, created_at FROM documents WHERE search_space_id = $1 ORDER BY created_at DESC`,
- [searchSpaceId]
- );
- if (mounted) {
- setDocuments(result.rows || []);
- }
- } catch (err) {
- console.error("[useDocumentsElectric] Failed to fetch:", err);
- }
- }
-
- async function setupLiveQuery() {
- try {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const db = electricClient.db as any;
-
- if (db.live?.query && typeof db.live.query === "function") {
- const liveQuery = await db.live.query(
- `SELECT id, document_type, search_space_id, created_at FROM documents WHERE search_space_id = $1 ORDER BY created_at DESC`,
- [searchSpaceId]
- );
-
- if (!mounted) {
- liveQuery.unsubscribe?.();
- return;
- }
-
- // Set initial results
- if (liveQuery.initialResults?.rows) {
- setDocuments(liveQuery.initialResults.rows);
- } else if (liveQuery.rows) {
- setDocuments(liveQuery.rows);
- }
-
- // Subscribe to changes
- if (typeof liveQuery.subscribe === "function") {
- liveQuery.subscribe((result: { rows: Document[] }) => {
- if (mounted && result.rows) {
- setDocuments(result.rows);
- }
- });
- }
-
- if (typeof liveQuery.unsubscribe === "function") {
- liveQueryRef.current = liveQuery;
- }
- }
- } catch (liveErr) {
- console.error("[useDocumentsElectric] Failed to set up live query:", liveErr);
- }
- }
-
- startSync();
-
- return () => {
- mounted = false;
- syncKeyRef.current = null;
-
- if (syncHandleRef.current) {
- syncHandleRef.current.unsubscribe();
- syncHandleRef.current = null;
- }
- if (liveQueryRef.current) {
- liveQueryRef.current.unsubscribe();
- liveQueryRef.current = null;
- }
- };
- }, [searchSpaceId, electricClient]);
-
- return { documentTypeCounts, loading, error };
-}
diff --git a/surfsense_web/hooks/use-documents.ts b/surfsense_web/hooks/use-documents.ts
new file mode 100644
index 000000000..369cc7b41
--- /dev/null
+++ b/surfsense_web/hooks/use-documents.ts
@@ -0,0 +1,449 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import type { DocumentTypeEnum } from "@/contracts/types/document.types";
+import { documentsApiService } from "@/lib/apis/documents-api.service";
+import type { SyncHandle } from "@/lib/electric/client";
+import { useElectricClient } from "@/lib/electric/context";
+
+// Stable empty array to prevent infinite re-renders when no typeFilter is provided
+const EMPTY_TYPE_FILTER: DocumentTypeEnum[] = [];
+
+// Document status type (matches backend DocumentStatus JSONB)
+export interface DocumentStatusType {
+ state: "ready" | "pending" | "processing" | "failed";
+ reason?: string;
+}
+
+// Document from Electric sync (lightweight table columns - NO content/metadata)
+interface DocumentElectric {
+ id: number;
+ search_space_id: number;
+ document_type: string;
+ title: string;
+ created_by_id: string | null;
+ created_at: string;
+ status: DocumentStatusType | null;
+}
+
+// Document for display (with resolved user name)
+export interface DocumentDisplay {
+ id: number;
+ search_space_id: number;
+ document_type: string;
+ title: string;
+ created_by_id: string | null;
+ created_by_name: string | null;
+ created_at: string;
+ status: DocumentStatusType;
+}
+
+/**
+ * Deduplicate by ID and sort by created_at descending (newest first)
+ */
+function deduplicateAndSort(items: T[]): T[] {
+ const seen = new Map();
+ for (const item of items) {
+ // Keep the most recent version if duplicate
+ const existing = seen.get(item.id);
+ if (!existing || new Date(item.created_at) > new Date(existing.created_at)) {
+ seen.set(item.id, item);
+ }
+ }
+ return Array.from(seen.values()).sort(
+ (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
+ );
+}
+
+/**
+ * Check if a document has valid/complete data
+ */
+function isValidDocument(doc: DocumentElectric): boolean {
+ return doc.id != null && doc.title != null && doc.title !== "";
+}
+
+/**
+ * Real-time documents hook with Electric SQL
+ *
+ * Architecture (100% Reliable):
+ * 1. API is the PRIMARY source of truth - always loads first
+ * 2. Electric provides REAL-TIME updates for additions and deletions
+ * 3. Use syncHandle.isUpToDate to determine if deletions can be trusted
+ * 4. Handles bulk deletions correctly by checking sync state
+ *
+ * @param searchSpaceId - The search space ID to filter documents
+ * @param typeFilter - Optional document types to filter by
+ */
+export function useDocuments(
+ searchSpaceId: number | null,
+ typeFilter: DocumentTypeEnum[] = EMPTY_TYPE_FILTER
+) {
+ const electricClient = useElectricClient();
+
+ const [documents, setDocuments] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Track if initial API load is complete (source of truth)
+ const apiLoadedRef = useRef(false);
+
+ // User cache: userId → displayName
+ const userCacheRef = useRef