Merge branch 'dev' into sur-90-feat-comments-in-chats

This commit is contained in:
CREDO23 2026-01-19 14:49:10 +02:00
commit 47fbc83d48
116 changed files with 11410 additions and 5189 deletions

View file

@ -13,6 +13,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cleanupElectric } from "@/lib/electric/client";
import { resetUser, trackLogout } from "@/lib/posthog/events";
export function UserDropdown({
@ -26,12 +27,20 @@ export function UserDropdown({
}) {
const router = useRouter();
const handleLogout = () => {
const handleLogout = async () => {
try {
// Track logout event and reset PostHog identity
trackLogout();
resetUser();
// Best-effort cleanup of Electric SQL / PGlite
// Even if this fails, login-time cleanup will handle it
try {
await cleanupElectric();
} catch (err) {
console.warn("[Logout] Electric cleanup failed (will be handled on next login):", err);
}
if (typeof window !== "undefined") {
localStorage.removeItem("surfsense_bearer_token");
window.location.href = "/";
@ -40,7 +49,7 @@ export function UserDropdown({
console.error("Error during logout:", error);
// Optionally, provide user feedback
if (typeof window !== "undefined") {
alert("Logout failed. Please try again.");
localStorage.removeItem("surfsense_bearer_token");
window.location.href = "/";
}
}

View file

@ -357,7 +357,7 @@ export const ComposerAddAttachment: FC = () => {
</DropdownMenuItem>
<DropdownMenuItem onClick={handleFileUpload} className="cursor-pointer">
<Upload className="size-4" />
<span>Upload Files</span>
<span>Upload Documents</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -1,300 +0,0 @@
import { AssistantIf, ComposerPrimitive, useAssistantState } from "@assistant-ui/react";
import { useAtomValue } from "jotai";
import {
AlertCircle,
ArrowUpIcon,
ChevronRightIcon,
Loader2,
Plug2,
Plus,
SquareIcon,
} from "lucide-react";
import type { FC } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import {
globalNewLLMConfigsAtom,
llmPreferencesAtom,
newLLMConfigsAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { ComposerAddAttachment } from "@/components/assistant-ui/attachment";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
import { cn } from "@/lib/utils";
const ConnectorIndicator: FC = () => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const { connectors, isLoading: connectorsLoading } = useSearchSourceConnectors(
false,
searchSpaceId ? Number(searchSpaceId) : undefined
);
const { data: documentTypeCounts, isLoading: documentTypesLoading } =
useAtomValue(documentTypeCountsAtom);
const [isOpen, setIsOpen] = useState(false);
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isLoading = connectorsLoading || documentTypesLoading;
const activeDocumentTypes = documentTypeCounts
? Object.entries(documentTypeCounts).filter(([_, count]) => count > 0)
: [];
// Count only active connectors (matching what's shown in the Active tab)
const activeConnectorsCount = connectors.length;
const hasConnectors = activeConnectorsCount > 0;
const hasSources = hasConnectors || activeDocumentTypes.length > 0;
const handleMouseEnter = useCallback(() => {
// Clear any pending close timeout
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
setIsOpen(true);
}, []);
const handleMouseLeave = useCallback(() => {
// Delay closing by 150ms for better UX
closeTimeoutRef.current = setTimeout(() => {
setIsOpen(false);
}, 150);
}, []);
if (!searchSpaceId) return null;
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
"hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30",
"outline-none focus:outline-none focus-visible:outline-none",
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none",
"data-[state=open]:bg-transparent data-[state=open]:shadow-none data-[state=open]:ring-0",
"text-muted-foreground"
)}
aria-label={
hasConnectors
? `View ${activeConnectorsCount} active connectors`
: "Add your first connector"
}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<>
<Plug2 className="size-4" />
{activeConnectorsCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm">
{activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
</span>
)}
</>
)}
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
className="w-64 p-3"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{hasSources ? (
<div className="space-y-3">
{activeConnectorsCount > 0 && (
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">Active Connectors</p>
<span className="text-xs font-medium bg-muted px-1.5 py-0.5 rounded">
{activeConnectorsCount}
</span>
</div>
)}
{activeConnectorsCount > 0 && (
<div className="flex flex-wrap gap-2">
{connectors.map((connector) => (
<div
key={`connector-${connector.id}`}
className="flex items-center gap-1.5 rounded-md bg-muted/80 px-2.5 py-1.5 text-xs border border-border/50"
>
{getConnectorIcon(connector.connector_type, "size-3.5")}
<span className="truncate max-w-[100px]">{connector.name}</span>
</div>
))}
</div>
)}
{activeDocumentTypes.length > 0 && (
<>
{activeConnectorsCount > 0 && (
<div className="pt-2 border-t border-border/50">
<p className="text-xs font-medium text-muted-foreground mb-2">Documents</p>
</div>
)}
<div className="flex flex-wrap gap-2">
{activeDocumentTypes.map(([docType, count]) => (
<div
key={docType}
className="flex items-center gap-1.5 rounded-md bg-muted/80 px-2.5 py-1.5 text-xs border border-border/50"
>
{getConnectorIcon(docType, "size-3.5")}
<span className="truncate max-w-[100px]">
{getDocumentTypeLabel(docType)}
</span>
<span className="flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-medium rounded-full bg-primary/10 text-primary">
{count > 999 ? "999+" : count}
</span>
</div>
))}
</div>
</>
)}
<div className="pt-1 border-t border-border/50">
<button
type="button"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => {
/* Connector popup should be opened via the connector indicator button */
}}
>
<Plus className="size-3" />
Add more sources
<ChevronRightIcon className="size-3" />
</button>
</div>
</div>
) : (
<div className="space-y-2">
<p className="text-sm font-medium">No sources yet</p>
<p className="text-xs text-muted-foreground">
Add documents or connect data sources to enhance search results.
</p>
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors mt-1"
onClick={() => {
/* Connector popup should be opened via the connector indicator button */
}}
>
<Plus className="size-3" />
Add Connector
</button>
</div>
)}
</PopoverContent>
</Popover>
);
};
export const ComposerAction: FC = () => {
// Check if any attachments are still being processed (running AND progress < 100)
// When progress is 100, processing is done but waiting for send()
const hasProcessingAttachments = useAssistantState(({ composer }) =>
composer.attachments?.some((att) => {
const status = att.status;
if (status?.type !== "running") return false;
const progress = (status as { type: "running"; progress?: number }).progress;
return progress === undefined || progress < 100;
})
);
// Check if composer text is empty
const isComposerEmpty = useAssistantState(({ composer }) => {
const text = composer.text?.trim() || "";
return text.length === 0;
});
// Check if a model is configured
const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
const { data: preferences } = useAtomValue(llmPreferencesAtom);
const hasModelConfigured = useMemo(() => {
if (!preferences) return false;
const agentLlmId = preferences.agent_llm_id;
if (agentLlmId === null || agentLlmId === undefined) return false;
// Check if the configured model actually exists
if (agentLlmId < 0) {
return globalConfigs?.some((c) => c.id === agentLlmId) ?? false;
}
return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
}, [preferences, globalConfigs, userConfigs]);
const isSendDisabled = hasProcessingAttachments || isComposerEmpty || !hasModelConfigured;
return (
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
<div className="flex items-center gap-1">
<ComposerAddAttachment />
<ConnectorIndicator />
</div>
{/* Show processing indicator when attachments are being processed */}
{hasProcessingAttachments && (
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
<Loader2 className="size-3 animate-spin" />
<span>Processing...</span>
</div>
)}
{/* Show warning when no model is configured */}
{!hasModelConfigured && !hasProcessingAttachments && (
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs">
<AlertCircle className="size-3" />
<span>Select a model</span>
</div>
)}
<AssistantIf condition={({ thread }) => !thread.isRunning}>
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
<TooltipIconButton
tooltip={
!hasModelConfigured
? "Please select a model from the header to start chatting"
: hasProcessingAttachments
? "Wait for attachments to process"
: isComposerEmpty
? "Enter a message to send"
: "Send message"
}
side="bottom"
type="submit"
variant="default"
size="icon"
className={cn(
"aui-composer-send size-8 rounded-full",
isSendDisabled && "cursor-not-allowed opacity-50"
)}
aria-label="Send message"
disabled={isSendDisabled}
>
<ArrowUpIcon className="aui-composer-send-icon size-4" />
</TooltipIconButton>
</ComposerPrimitive.Send>
</AssistantIf>
<AssistantIf condition={({ thread }) => thread.isRunning}>
<ComposerPrimitive.Cancel asChild>
<Button
type="button"
variant="default"
size="icon"
className="aui-composer-cancel size-8 rounded-full"
aria-label="Stop generating"
>
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
</Button>
</ComposerPrimitive.Cancel>
</AssistantIf>
</div>
);
};

View file

@ -1,257 +0,0 @@
import { ComposerPrimitive, useAssistantState, useComposerRuntime } from "@assistant-ui/react";
import { useAtom, useSetAtom } from "jotai";
import { useParams } from "next/navigation";
import type { FC } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
mentionedDocumentIdsAtom,
mentionedDocumentsAtom,
} from "@/atoms/chat/mentioned-documents.atom";
import { ComposerAddAttachment, ComposerAttachments } from "@/components/assistant-ui/attachment";
import { ComposerAction } from "@/components/assistant-ui/composer-action";
import {
InlineMentionEditor,
type InlineMentionEditorRef,
} from "@/components/assistant-ui/inline-mention-editor";
import {
DocumentMentionPicker,
type DocumentMentionPickerRef,
} from "@/components/new-chat/document-mention-picker";
import type { Document } from "@/contracts/types/document.types";
export const Composer: FC = () => {
// ---- State for document mentions (using atoms to persist across remounts) ----
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
const editorRef = useRef<InlineMentionEditorRef>(null);
const editorContainerRef = useRef<HTMLDivElement>(null);
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
const { search_space_id } = useParams();
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
const composerRuntime = useComposerRuntime();
const hasAutoFocusedRef = useRef(false);
// Check if thread is empty (new chat)
const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty);
// Check if thread is currently running (streaming response)
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
// Auto-focus editor when on new chat page
useEffect(() => {
if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) {
// Small delay to ensure the editor is fully mounted
const timeoutId = setTimeout(() => {
editorRef.current?.focus();
hasAutoFocusedRef.current = true;
}, 100);
return () => clearTimeout(timeoutId);
}
}, [isThreadEmpty]);
// Sync mentioned document IDs to atom for use in chat request
useEffect(() => {
setMentionedDocumentIds({
surfsense_doc_ids: mentionedDocuments
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: mentionedDocuments
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
}, [mentionedDocuments, setMentionedDocumentIds]);
// Handle text change from inline editor - sync with assistant-ui composer
const handleEditorChange = useCallback(
(text: string) => {
composerRuntime.setText(text);
},
[composerRuntime]
);
// Handle @ mention trigger from inline editor
const handleMentionTrigger = useCallback((query: string) => {
setShowDocumentPopover(true);
setMentionQuery(query);
}, []);
// Handle mention close
const handleMentionClose = useCallback(() => {
if (showDocumentPopover) {
setShowDocumentPopover(false);
setMentionQuery("");
}
}, [showDocumentPopover]);
// Handle keyboard navigation when popover is open
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (showDocumentPopover) {
if (e.key === "ArrowDown") {
e.preventDefault();
documentPickerRef.current?.moveDown();
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
documentPickerRef.current?.moveUp();
return;
}
if (e.key === "Enter") {
e.preventDefault();
documentPickerRef.current?.selectHighlighted();
return;
}
if (e.key === "Escape") {
e.preventDefault();
setShowDocumentPopover(false);
setMentionQuery("");
return;
}
}
},
[showDocumentPopover]
);
// Handle submit from inline editor (Enter key)
const handleSubmit = useCallback(() => {
// Prevent sending while a response is still streaming
if (isThreadRunning) {
return;
}
if (!showDocumentPopover) {
composerRuntime.send();
// Clear the editor after sending
editorRef.current?.clear();
setMentionedDocuments([]);
setMentionedDocumentIds({
surfsense_doc_ids: [],
document_ids: [],
});
}
}, [
showDocumentPopover,
isThreadRunning,
composerRuntime,
setMentionedDocuments,
setMentionedDocumentIds,
]);
const handleDocumentRemove = useCallback(
(docId: number, docType?: string) => {
setMentionedDocuments((prev) => {
const updated = prev.filter((doc) => !(doc.id === docId && doc.document_type === docType));
setMentionedDocumentIds({
surfsense_doc_ids: updated
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: updated
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
return updated;
});
},
[setMentionedDocuments, setMentionedDocumentIds]
);
const handleDocumentsMention = useCallback(
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
const newDocs = documents.filter(
(doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`)
);
for (const doc of newDocs) {
editorRef.current?.insertDocumentChip(doc);
}
setMentionedDocuments((prev) => {
const existingKeySet = new Set(prev.map((d) => `${d.document_type}:${d.id}`));
const uniqueNewDocs = documents.filter(
(doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`)
);
const updated = [...prev, ...uniqueNewDocs];
setMentionedDocumentIds({
surfsense_doc_ids: updated
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: updated
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
return updated;
});
setMentionQuery("");
},
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
);
return (
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
<ComposerAttachments />
{/* -------- Inline Mention Editor -------- */}
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-3 pt-3 pb-6">
<InlineMentionEditor
ref={editorRef}
placeholder="Ask SurfSense or @mention docs"
onMentionTrigger={handleMentionTrigger}
onMentionClose={handleMentionClose}
onChange={handleEditorChange}
onDocumentRemove={handleDocumentRemove}
onSubmit={handleSubmit}
onKeyDown={handleKeyDown}
className="min-h-[24px]"
/>
</div>
{/* -------- Document mention popover (rendered via portal) -------- */}
{showDocumentPopover &&
typeof document !== "undefined" &&
createPortal(
<>
{/* Backdrop */}
<button
type="button"
className="fixed inset-0 cursor-default"
style={{ zIndex: 9998 }}
onClick={() => setShowDocumentPopover(false)}
aria-label="Close document picker"
/>
{/* Popover positioned above input */}
<div
className="fixed shadow-2xl rounded-lg border border-border overflow-hidden bg-popover"
style={{
zIndex: 9999,
bottom: editorContainerRef.current
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px`
: "200px",
left: editorContainerRef.current
? `${editorContainerRef.current.getBoundingClientRect().left}px`
: "50%",
}}
>
<DocumentMentionPicker
ref={documentPickerRef}
searchSpaceId={Number(search_space_id)}
onSelectionChange={handleDocumentsMention}
onDone={() => {
setShowDocumentPopover(false);
setMentionQuery("");
}}
initialSelectedDocuments={mentionedDocuments}
externalSearch={mentionQuery}
/>
</div>
</>,
document.body
)}
<ComposerAction />
</ComposerPrimitive.AttachmentDropzone>
</ComposerPrimitive.Root>
);
};

View file

@ -1,19 +1,16 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { Cable, Loader2 } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { type FC, useEffect, useMemo } from "react";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import type { FC } from "react";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { useLogsSummary } from "@/hooks/use-logs";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { useConnectorsElectric } from "@/hooks/use-connectors-electric";
import { useDocumentsElectric } from "@/hooks/use-documents-electric";
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";
@ -21,26 +18,23 @@ import { ConnectorEditView } from "./connector-popup/connector-configs/views/con
import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view";
import { OAUTH_CONNECTORS } from "./connector-popup/constants/connector-constants";
import { useConnectorDialog } from "./connector-popup/hooks/use-connector-dialog";
import { useIndexingConnectors } from "./connector-popup/hooks/use-indexing-connectors";
import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab";
import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
import { MCPConnectorListView } from "./connector-popup/views/mcp-connector-list-view";
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
export const ConnectorIndicator: FC = () => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const searchParams = useSearchParams();
const { data: documentTypeCounts, isLoading: documentTypesLoading } =
useAtomValue(documentTypeCountsAtom);
// Fetch document type counts using Electric SQL + PGlite for real-time updates
const { documentTypeCounts, loading: documentTypesLoading } = useDocumentsElectric(searchSpaceId);
// Check if YouTube view is active
const isYouTubeView = searchParams.get("view") === "youtube";
// Track active indexing tasks
const { summary: logsSummary } = useLogsSummary(searchSpaceId ? Number(searchSpaceId) : 0, 24, {
enablePolling: true,
refetchInterval: 5000,
});
// Use the custom hook for dialog state management
const {
isOpen,
@ -63,6 +57,7 @@ export const ConnectorIndicator: FC = () => {
frequencyMinutes,
allConnectors,
viewingAccountsType,
viewingMCPList,
setSearchQuery,
setStartDate,
setEndDate,
@ -86,6 +81,8 @@ export const ConnectorIndicator: FC = () => {
handleBackFromYouTube,
handleViewAccountsList,
handleBackFromAccountsList,
handleBackFromMCPList,
handleAddNewMCPFromList,
handleQuickIndexConnector,
connectorConfig,
setConnectorConfig,
@ -93,57 +90,35 @@ export const ConnectorIndicator: FC = () => {
setConnectorName,
} = useConnectorDialog();
// Fetch connectors using React Query with conditional refetchInterval
// This automatically refetches when mutations invalidate the cache (event-driven)
// and also polls when dialog is open to catch external changes
// Fetch connectors using Electric SQL + PGlite for real-time updates
// This provides instant updates when connectors change, without polling
const {
data: connectors = [],
isLoading: connectorsLoading,
refetch: refreshConnectors,
} = useQuery({
queryKey: cacheKeys.connectors.all(searchSpaceId || ""),
queryFn: () =>
connectorsApiService.getConnectors({
queryParams: {
search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined,
},
}),
enabled: !!searchSpaceId,
staleTime: 5 * 60 * 1000, // 5 minutes (same as connectorsAtom)
// Poll when dialog is open to catch external changes
refetchInterval: isOpen ? 5000 : false, // 5 seconds when open, no polling when closed
});
connectors: connectorsFromElectric = [],
loading: connectorsLoading,
error: connectorsError,
refreshConnectors: refreshConnectorsElectric,
} = useConnectorsElectric(searchSpaceId);
const queryClient = useQueryClient();
// Fallback to API if Electric is not available or fails
// Use Electric data if: 1) we have data, or 2) still loading without error
// Use API data if: Electric failed (has error) or finished loading with no data
const useElectricData = connectorsFromElectric.length > 0 || (connectorsLoading && !connectorsError);
const connectors = useElectricData ? connectorsFromElectric : allConnectors || [];
// Also refresh document type counts when dialog is open
useEffect(() => {
if (!isOpen || !searchSpaceId) return;
// Manual refresh function that works with both Electric and API
const refreshConnectors = async () => {
if (useElectricData) {
await refreshConnectorsElectric();
} else {
// Fallback: use allConnectors from useConnectorDialog (which uses connectorsAtom)
// The connectorsAtom will handle refetching if needed
}
};
const POLL_INTERVAL = 5000; // 5 seconds, same as connectors
const intervalId = setInterval(() => {
// Invalidate document type counts to refresh active document types
queryClient.invalidateQueries({
queryKey: cacheKeys.documents.typeCounts(searchSpaceId),
});
}, POLL_INTERVAL);
// Cleanup interval on unmount or when dialog closes
return () => {
clearInterval(intervalId);
};
}, [isOpen, searchSpaceId, queryClient]);
// Get connector IDs that are currently being indexed
const indexingConnectorIds = useMemo(() => {
if (!logsSummary?.active_tasks) return new Set<number>();
return new Set(
logsSummary.active_tasks
.filter((task) => task.source?.includes("connector_indexing") && task.connector_id != null)
.map((task) => task.connector_id as number)
);
}, [logsSummary?.active_tasks]);
// Track indexing state locally - clears automatically when Electric SQL detects last_indexed_at changed
const { indexingConnectorIds, startIndexing } = useIndexingConnectors(
connectors as SearchSourceConnector[]
);
const isLoading = connectorsLoading || documentTypesLoading;
@ -155,11 +130,13 @@ export const ConnectorIndicator: FC = () => {
const hasConnectors = connectors.length > 0;
const hasSources = hasConnectors || activeDocumentTypes.length > 0;
const totalSourceCount = connectors.length + activeDocumentTypes.length;
const activeConnectorsCount = connectors.length; // Only actual connectors, not document types
const activeConnectorsCount = connectors.length;
// Check which connectors are already connected
// Using Electric SQL + PGlite for real-time connector updates
const connectedTypes = new Set(
(allConnectors || []).map((c: SearchSourceConnector) => c.connector_type)
(connectors || []).map((c: SearchSourceConnector) => c.connector_type)
);
if (!searchSpaceId) return null;
@ -199,13 +176,25 @@ export const ConnectorIndicator: FC = () => {
{/* YouTube Crawler View - shown when adding YouTube videos */}
{isYouTubeView && searchSpaceId ? (
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
) : viewingMCPList ? (
<div className="p-6 sm:p-12 h-full overflow-hidden">
<MCPConnectorListView
mcpConnectors={
(allConnectors || []).filter(
(c: SearchSourceConnector) => c.connector_type === "MCP_CONNECTOR"
) as SearchSourceConnector[]
}
onAddNew={handleAddNewMCPFromList}
onManageConnector={handleStartEdit}
onBack={handleBackFromMCPList}
/>
</div>
) : viewingAccountsType ? (
<ConnectorAccountsListView
connectorType={viewingAccountsType.connectorType}
connectorTitle={viewingAccountsType.connectorTitle}
connectors={(allConnectors || []) as SearchSourceConnector[]}
connectors={(connectors || []) as SearchSourceConnector[]} // Using Electric SQL + PGlite for real-time connector updates (all connector types)
indexingConnectorIds={indexingConnectorIds}
logsSummary={logsSummary}
onBack={handleBackFromAccountsList}
onManage={handleStartEdit}
onAddAccount={() => {
@ -221,7 +210,7 @@ export const ConnectorIndicator: FC = () => {
) : connectingConnectorType ? (
<ConnectorConnectView
connectorType={connectingConnectorType}
onSubmit={handleSubmitConnectForm}
onSubmit={(formData) => handleSubmitConnectForm(formData, startIndexing)}
onBack={handleBackFromConnect}
isSubmitting={isCreatingConnector}
/>
@ -239,17 +228,24 @@ export const ConnectorIndicator: FC = () => {
isSaving={isSaving}
isDisconnecting={isDisconnecting}
isIndexing={indexingConnectorIds.has(editingConnector.id)}
searchSpaceId={searchSpaceId?.toString()}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onSave={() => handleSaveConnector(() => refreshConnectors())}
onSave={() => {
startIndexing(editingConnector.id);
handleSaveConnector(() => refreshConnectors());
}}
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
onBack={handleBackFromEdit}
onQuickIndex={
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR"
? () =>
handleQuickIndexConnector(editingConnector.id, editingConnector.connector_type)
? () => {
startIndexing(editingConnector.id);
handleQuickIndexConnector(editingConnector.id, editingConnector.connector_type);
}
: undefined
}
onConfigChange={setConnectorConfig}
@ -276,7 +272,12 @@ export const ConnectorIndicator: FC = () => {
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onConfigChange={setIndexingConnectorConfig}
onStartIndexing={() => handleStartIndexing(() => refreshConnectors())}
onStartIndexing={() => {
if (indexingConfig.connectorId) {
startIndexing(indexingConfig.connectorId);
}
handleStartIndexing(() => refreshConnectors());
}}
onSkip={handleSkipIndexing}
/>
) : (
@ -305,10 +306,9 @@ export const ConnectorIndicator: FC = () => {
searchSpaceId={searchSpaceId}
connectedTypes={connectedTypes}
connectingId={connectingId}
allConnectors={allConnectors}
allConnectors={connectors}
documentTypeCounts={documentTypeCounts}
indexingConnectorIds={indexingConnectorIds}
logsSummary={logsSummary}
onConnectOAuth={handleConnectOAuth}
onConnectNonOAuth={handleConnectNonOAuth}
onCreateWebcrawler={handleCreateWebcrawler}
@ -325,7 +325,6 @@ export const ConnectorIndicator: FC = () => {
activeDocumentTypes={activeDocumentTypes}
connectors={connectors as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
logsSummary={logsSummary}
searchSpaceId={searchSpaceId}
onTabChange={handleTabChange}
onManage={handleStartEdit}

View file

@ -1,12 +1,10 @@
"use client";
import { IconBrandYoutube } from "@tabler/icons-react";
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
import { FileText, Loader2 } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { LogActiveTask } from "@/contracts/types/log.types";
import { cn } from "@/lib/utils";
import { useConnectorStatus } from "../hooks/use-connector-status";
import { ConnectorStatusBadge } from "./connector-status-badge";
@ -20,24 +18,11 @@ interface ConnectorCardProps {
isConnecting?: boolean;
documentCount?: number;
accountCount?: number;
lastIndexedAt?: string | null;
isIndexing?: boolean;
activeTask?: LogActiveTask;
onConnect?: () => void;
onManage?: () => void;
}
/**
* Extract a number from the active task message for display
* Looks for patterns like "45 indexed", "Processing 123", etc.
*/
function extractIndexedCount(message: string | undefined): number | null {
if (!message) return null;
// Try to find a number in the message
const match = message.match(/(\d+)/);
return match ? parseInt(match[1], 10) : null;
}
/**
* Format document count (e.g., "1.2k docs", "500 docs", "1.5M docs")
*/
@ -52,45 +37,6 @@ function formatDocumentCount(count: number | undefined): string {
return `${m.replace(/\.0$/, "")}M docs`;
}
/**
* Format last indexed date with contextual messages
* Examples: "Just now", "10 minutes ago", "Today at 2:30 PM", "Yesterday at 3:45 PM", "3 days ago", "Jan 15, 2026"
*/
function formatLastIndexedDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const minutesAgo = differenceInMinutes(now, date);
const daysAgo = differenceInDays(now, date);
// Just now (within last minute)
if (minutesAgo < 1) {
return "Just now";
}
// X minutes ago (less than 1 hour)
if (minutesAgo < 60) {
return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`;
}
// Today at [time]
if (isToday(date)) {
return `Today at ${format(date, "h:mm a")}`;
}
// Yesterday at [time]
if (isYesterday(date)) {
return `Yesterday at ${format(date, "h:mm a")}`;
}
// X days ago (less than 7 days)
if (daysAgo < 7) {
return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`;
}
// Full date for older entries
return format(date, "MMM d, yyyy");
}
export const ConnectorCard: FC<ConnectorCardProps> = ({
id,
title,
@ -100,9 +46,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
isConnecting = false,
documentCount,
accountCount,
lastIndexedAt,
isIndexing = false,
activeTask,
onConnect,
onManage,
}) => {
@ -115,36 +59,11 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
const statusMessage = getConnectorStatusMessage(connectorType);
const showWarnings = shouldShowWarnings();
// Extract count from active task message during indexing
const indexingCount = extractIndexedCount(activeTask?.message);
// Determine the status content to display
const getStatusContent = () => {
if (isIndexing) {
return (
<div className="flex items-center gap-2 w-full max-w-[200px]">
<span className="text-[11px] text-primary font-medium whitespace-nowrap">
{indexingCount !== null ? <>{indexingCount.toLocaleString()} indexed</> : "Syncing..."}
</span>
{/* Indeterminate progress bar with animation */}
<div className="relative flex-1 h-1 overflow-hidden rounded-full bg-primary/20">
<div className="absolute h-full bg-primary rounded-full animate-progress-indeterminate" />
</div>
</div>
);
}
if (isConnected) {
// Show last indexed date for connected connectors
if (lastIndexedAt) {
return (
<span className="whitespace-nowrap text-[10px]">
Last indexed: {formatLastIndexedDate(lastIndexedAt)}
</span>
);
}
// Fallback for connected but never indexed
return <span className="whitespace-nowrap text-[10px]">Never indexed</span>;
// Don't show last indexed in overview tabs - only show in accounts list view
return null;
}
return description;
@ -186,9 +105,13 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
/>
)}
</div>
<div className="text-[10px] text-muted-foreground mt-1">{getStatusContent()}</div>
{isConnected && documentCount !== undefined && (
<p className="text-[10px] text-muted-foreground mt-0.5 flex items-center gap-1.5">
{isIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
Syncing
</p>
) : isConnected ? (
<p className="text-[10px] text-muted-foreground mt-1 flex items-center gap-1.5">
<span>{formatDocumentCount(documentCount)}</span>
{accountCount !== undefined && accountCount > 0 && (
<>
@ -199,6 +122,8 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
</>
)}
</p>
) : (
<div className="text-[10px] text-muted-foreground mt-1">{getStatusContent()}</div>
)}
</div>
<Button

View file

@ -44,7 +44,7 @@ export const ConnectorStatusBadge: FC<ConnectorStatusBadgeProps> = ({
case "deprecated":
return {
icon: AlertTriangle,
className: "ext-slate-500 dark:text-slate-400",
className: "text-slate-500 dark:text-slate-400",
defaultTitle: "Deprecated",
};
default:

View file

@ -1,6 +1,7 @@
"use client";
import type { FC } from "react";
import { AlertCircle } from "lucide-react";
import { Label } from "@/components/ui/label";
import {
Select,
@ -16,6 +17,8 @@ interface PeriodicSyncConfigProps {
frequencyMinutes: string;
onEnabledChange: (enabled: boolean) => void;
onFrequencyChange: (frequency: string) => void;
disabled?: boolean;
disabledMessage?: string;
}
export const PeriodicSyncConfig: FC<PeriodicSyncConfigProps> = ({
@ -23,6 +26,8 @@ export const PeriodicSyncConfig: FC<PeriodicSyncConfigProps> = ({
frequencyMinutes,
onEnabledChange,
onFrequencyChange,
disabled = false,
disabledMessage,
}) => {
return (
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
@ -33,9 +38,17 @@ export const PeriodicSyncConfig: FC<PeriodicSyncConfigProps> = ({
Automatically re-index at regular intervals
</p>
</div>
<Switch checked={enabled} onCheckedChange={onEnabledChange} />
<Switch checked={enabled} onCheckedChange={onEnabledChange} disabled={disabled} />
</div>
{/* Show disabled message when periodic sync can't be enabled */}
{disabled && disabledMessage && (
<div className="mt-3 flex items-start gap-2 text-amber-600 dark:text-amber-400">
<AlertCircle className="size-4 mt-0.5 shrink-0" />
<p className="text-xs sm:text-sm">{disabledMessage}</p>
</div>
)}
{enabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">

View file

@ -0,0 +1,229 @@
"use client";
import { CheckCircle2, ChevronDown, ChevronUp, Server, XCircle } from "lucide-react";
import { type FC, useRef, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { EnumConnectorName } from "@/contracts/enums/connector";
import type { MCPToolDefinition } from "@/contracts/types/mcp.types";
import type { ConnectFormProps } from "..";
import {
extractServerName,
parseMCPConfig,
testMCPConnection,
type MCPConnectionTestResult,
} from "../../utils/mcp-config-validator";
export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const [configJson, setConfigJson] = useState("");
const [jsonError, setJsonError] = useState<string | null>(null);
const [isTesting, setIsTesting] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [testResult, setTestResult] = useState<MCPConnectionTestResult | null>(null);
const DEFAULT_CONFIG = JSON.stringify(
{
name: "My MCP Server",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/directory"],
env: {
API_KEY: "your_api_key_here",
},
transport: "stdio",
},
null,
2
);
const parseConfig = () => {
const result = parseMCPConfig(configJson);
if (result.error) {
setJsonError(result.error);
} else {
setJsonError(null);
}
return result.config;
};
const handleConfigChange = (value: string) => {
setConfigJson(value);
// Clear previous error
if (jsonError) {
setJsonError(null);
}
// Validate immediately to show errors as user types (with debouncing via parseMCPConfig cache)
if (value.trim()) {
const result = parseMCPConfig(value);
if (result.error) {
setJsonError(result.error);
}
}
};
const handleTestConnection = async () => {
const serverConfig = parseConfig();
if (!serverConfig) {
setTestResult({
status: "error",
message: jsonError || "Invalid configuration",
tools: [],
});
return;
}
setIsTesting(true);
setTestResult(null);
const result = await testMCPConnection(serverConfig);
setTestResult(result);
setIsTesting(false);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
const serverConfig = parseConfig();
if (!serverConfig) {
return;
}
// Extract server name from config if provided
const serverName = extractServerName(configJson);
isSubmittingRef.current = true;
try {
await onSubmit({
name: serverName,
connector_type: EnumConnectorName.MCP_CONNECTOR,
config: { server_config: serverConfig },
is_indexable: false,
is_active: true,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
});
} finally {
isSubmittingRef.current = false;
}
};
return (
<div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 [&>svg]:top-2 sm:[&>svg]:top-3">
<Server className="h-4 w-4 shrink-0" />
<AlertDescription className="text-[10px] sm:text-xs">
Connect to an MCP (Model Context Protocol) server. Each MCP server is added as a separate connector.
</AlertDescription>
</Alert>
<form id="mcp-connect-form" onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-4 sm:p-6 space-y-4">
<div className="space-y-2">
<Label htmlFor="config">MCP Server Configuration (JSON)</Label>
<Textarea
id="config"
value={configJson}
onChange={(e) => handleConfigChange(e.target.value)}
placeholder={DEFAULT_CONFIG}
rows={16}
className={`font-mono text-xs ${jsonError ? "border-red-500" : ""}`}
/>
{jsonError && (
<p className="text-xs text-red-500">JSON Error: {jsonError}</p>
)}
<p className="text-[10px] sm:text-xs text-muted-foreground">
Paste a single MCP server configuration. Must include: name, command, args (optional), env (optional), transport (optional).
</p>
</div>
<div className="pt-4">
<Button
type="button"
onClick={handleTestConnection}
disabled={isTesting}
variant="outline"
className="w-full"
>
{isTesting ? "Testing Connection..." : "Test Connection"}
</Button>
</div>
{testResult && (
<Alert
className={
testResult.status === "success"
? "border-green-500/50 bg-green-500/10"
: "border-red-500/50 bg-red-500/10"
}
>
{testResult.status === "success" ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-red-600" />
)}
<div className="flex-1">
<div className="flex items-center justify-between">
<AlertTitle className="text-sm">
{testResult.status === "success" ? "Connection Successful" : "Connection Failed"}
</AlertTitle>
{testResult.tools.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowDetails(!showDetails);
}}
>
{showDetails ? (
<>
<ChevronUp className="h-3 w-3 mr-1" />
Hide Details
</>
) : (
<>
<ChevronDown className="h-3 w-3 mr-1" />
Show Details
</>
)}
</Button>
)}
</div>
<AlertDescription className="text-xs mt-1">
{testResult.message}
{showDetails && testResult.tools.length > 0 && (
<div className="mt-3 pt-3 border-t border-green-500/20">
<p className="font-semibold mb-2">
Available tools:
</p>
<ul className="list-disc list-inside text-xs space-y-0.5">
{testResult.tools.map((tool, i) => (
<li key={i}>{tool.name}</li>
))}
</ul>
</div>
)}
</AlertDescription>
</div>
</Alert>
)}
</div>
</form>
</div>
);
};

View file

@ -6,6 +6,7 @@ import { ElasticsearchConnectForm } from "./components/elasticsearch-connect-for
import { GithubConnectForm } from "./components/github-connect-form";
import { LinkupApiConnectForm } from "./components/linkup-api-connect-form";
import { LumaConnectForm } from "./components/luma-connect-form";
import { MCPConnectForm } from "./components/mcp-connect-form";
import { SearxngConnectForm } from "./components/searxng-connect-form";
import { TavilyApiConnectForm } from "./components/tavily-api-connect-form";
@ -15,6 +16,7 @@ export interface ConnectFormProps {
connector_type: string;
config: Record<string, unknown>;
is_indexable: boolean;
is_active: boolean;
last_indexed_at: null;
periodic_indexing_enabled: boolean;
indexing_frequency_minutes: number | null;
@ -54,6 +56,8 @@ export function getConnectFormComponent(connectorType: string): ConnectFormCompo
return LumaConnectForm;
case "CIRCLEBACK_CONNECTOR":
return CirclebackConnectForm;
case "MCP_CONNECTOR":
return MCPConnectForm;
// Add other connector types here as needed
default:
return null;

View file

@ -1,11 +1,19 @@
"use client";
import { Info } from "lucide-react";
import { File, FileText, FileSpreadsheet, FolderClosed, Image, Presentation } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { GoogleDriveFolderTree } from "@/components/connectors/google-drive-folder-tree";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import type { ConnectorConfigProps } from "../index";
interface SelectedFolder {
@ -13,128 +21,292 @@ interface SelectedFolder {
name: string;
}
interface IndexingOptions {
max_files_per_folder: number;
incremental_sync: boolean;
include_subfolders: boolean;
}
const DEFAULT_INDEXING_OPTIONS: IndexingOptions = {
max_files_per_folder: 100,
incremental_sync: true,
include_subfolders: true,
};
// Helper to get appropriate icon for file type based on file name
function getFileIconFromName(fileName: string, className: string = "size-3.5 shrink-0") {
const lowerName = fileName.toLowerCase();
// Spreadsheets
if (
lowerName.endsWith(".xlsx") ||
lowerName.endsWith(".xls") ||
lowerName.endsWith(".csv") ||
lowerName.includes("spreadsheet")
) {
return <FileSpreadsheet className={`${className} text-green-500`} />;
}
// Presentations
if (
lowerName.endsWith(".pptx") ||
lowerName.endsWith(".ppt") ||
lowerName.includes("presentation")
) {
return <Presentation className={`${className} text-orange-500`} />;
}
// Documents (word, text only - not PDF)
if (
lowerName.endsWith(".docx") ||
lowerName.endsWith(".doc") ||
lowerName.endsWith(".txt") ||
lowerName.includes("document") ||
lowerName.includes("word") ||
lowerName.includes("text")
) {
return <FileText className={`${className} text-gray-500`} />;
}
// Images
if (
lowerName.endsWith(".png") ||
lowerName.endsWith(".jpg") ||
lowerName.endsWith(".jpeg") ||
lowerName.endsWith(".gif") ||
lowerName.endsWith(".webp") ||
lowerName.endsWith(".svg")
) {
return <Image className={`${className} text-purple-500`} />;
}
// Default (including PDF)
return <File className={`${className} text-gray-500`} />;
}
export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigChange }) => {
// Initialize with existing selected folders and files from connector config
const existingFolders =
(connector.config?.selected_folders as SelectedFolder[] | undefined) || [];
const existingFiles = (connector.config?.selected_files as SelectedFolder[] | undefined) || [];
const existingIndexingOptions =
(connector.config?.indexing_options as IndexingOptions | undefined) || DEFAULT_INDEXING_OPTIONS;
const [selectedFolders, setSelectedFolders] = useState<SelectedFolder[]>(existingFolders);
const [selectedFiles, setSelectedFiles] = useState<SelectedFolder[]>(existingFiles);
const [showFolderSelector, setShowFolderSelector] = useState(false);
const [indexingOptions, setIndexingOptions] = useState<IndexingOptions>(existingIndexingOptions);
// Update selected folders and files when connector config changes
useEffect(() => {
const folders = (connector.config?.selected_folders as SelectedFolder[] | undefined) || [];
const files = (connector.config?.selected_files as SelectedFolder[] | undefined) || [];
const options =
(connector.config?.indexing_options as IndexingOptions | undefined) ||
DEFAULT_INDEXING_OPTIONS;
setSelectedFolders(folders);
setSelectedFiles(files);
setIndexingOptions(options);
}, [connector.config]);
const handleSelectFolders = (folders: SelectedFolder[]) => {
setSelectedFolders(folders);
const updateConfig = (
folders: SelectedFolder[],
files: SelectedFolder[],
options: IndexingOptions
) => {
if (onConfigChange) {
// Store folder IDs and names in config for indexing
onConfigChange({
...connector.config,
selected_folders: folders,
selected_files: selectedFiles, // Preserve existing files
selected_files: files,
indexing_options: options,
});
}
};
const handleSelectFolders = (folders: SelectedFolder[]) => {
setSelectedFolders(folders);
updateConfig(folders, selectedFiles, indexingOptions);
};
const handleSelectFiles = (files: SelectedFolder[]) => {
setSelectedFiles(files);
if (onConfigChange) {
// Store file IDs and names in config for indexing
onConfigChange({
...connector.config,
selected_folders: selectedFolders, // Preserve existing folders
selected_files: files,
});
}
updateConfig(selectedFolders, files, indexingOptions);
};
const handleIndexingOptionChange = (key: keyof IndexingOptions, value: number | boolean) => {
const newOptions = { ...indexingOptions, [key]: value };
setIndexingOptions(newOptions);
updateConfig(selectedFolders, selectedFiles, newOptions);
};
const totalSelected = selectedFolders.length + selectedFiles.length;
return (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-1 sm:space-y-2">
<h3 className="font-medium text-sm sm:text-base">Folder & File Selection</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Select specific folders and/or individual files to index. Only files directly in each
folder will be processedsubfolders must be selected separately.
</p>
</div>
{totalSelected > 0 && (
<div className="p-2 sm:p-3 bg-muted rounded-lg text-xs sm:text-sm space-y-1 sm:space-y-2">
<p className="font-medium">
Selected {totalSelected} item{totalSelected > 1 ? "s" : ""}:
{selectedFolders.length > 0 &&
` ${selectedFolders.length} folder${selectedFolders.length > 1 ? "s" : ""}`}
{selectedFiles.length > 0 &&
` ${selectedFiles.length} file${selectedFiles.length > 1 ? "s" : ""}`}
<div className="space-y-4">
{/* Folder & File Selection */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-1 sm:space-y-2">
<h3 className="font-medium text-sm sm:text-base">Folder & File Selection</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Select specific folders and/or individual files to index.
</p>
<div className="max-h-20 sm:max-h-24 overflow-y-auto space-y-1">
{selectedFolders.map((folder) => (
<p
key={folder.id}
className="text-xs sm:text-sm text-muted-foreground truncate"
title={folder.name}
>
📁 {folder.name}
</p>
))}
{selectedFiles.map((file) => (
<p
key={file.id}
className="text-xs sm:text-sm text-muted-foreground truncate"
title={file.name}
>
📄 {file.name}
</p>
))}
</div>
</div>
)}
{showFolderSelector ? (
<div className="space-y-2 sm:space-y-3">
<GoogleDriveFolderTree
connectorId={connector.id}
selectedFolders={selectedFolders}
onSelectFolders={handleSelectFolders}
selectedFiles={selectedFiles}
onSelectFiles={handleSelectFiles}
/>
{totalSelected > 0 && (
<div className="p-2 sm:p-3 bg-muted rounded-lg text-xs sm:text-sm space-y-1 sm:space-y-2">
<p className="font-medium">
Selected {totalSelected} item{totalSelected > 1 ? "s" : ""}: {(() => {
const parts: string[] = [];
if (selectedFolders.length > 0) {
parts.push(
`${selectedFolders.length} folder${selectedFolders.length > 1 ? "s" : ""}`
);
}
if (selectedFiles.length > 0) {
parts.push(`${selectedFiles.length} file${selectedFiles.length > 1 ? "s" : ""}`);
}
return parts.length > 0 ? `(${parts.join(" ")})` : "";
})()}
</p>
<div className="max-h-20 sm:max-h-24 overflow-y-auto space-y-1">
{selectedFolders.map((folder) => (
<p
key={folder.id}
className="text-xs sm:text-sm text-muted-foreground truncate flex items-center gap-1.5"
title={folder.name}
>
<FolderClosed className="size-3.5 shrink-0 text-gray-500" />
{folder.name}
</p>
))}
{selectedFiles.map((file) => (
<p
key={file.id}
className="text-xs sm:text-sm text-muted-foreground truncate flex items-center gap-1.5"
title={file.name}
>
{getFileIconFromName(file.name)}
{file.name}
</p>
))}
</div>
</div>
)}
{showFolderSelector ? (
<div className="space-y-2 sm:space-y-3">
<GoogleDriveFolderTree
connectorId={connector.id}
selectedFolders={selectedFolders}
onSelectFolders={handleSelectFolders}
selectedFiles={selectedFiles}
onSelectFiles={handleSelectFiles}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowFolderSelector(false)}
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-white/10 text-xs sm:text-sm h-8 sm:h-9"
>
Done Selecting
</Button>
</div>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowFolderSelector(false)}
onClick={() => setShowFolderSelector(true)}
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-white/10 text-xs sm:text-sm h-8 sm:h-9"
>
Done Selecting
{totalSelected > 0 ? "Change Selection" : "Select Folders & Files"}
</Button>
</div>
) : (
<Button
type="button"
variant="outline"
onClick={() => setShowFolderSelector(true)}
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-white/10 text-xs sm:text-sm h-8 sm:h-9"
>
{totalSelected > 0 ? "Change Selection" : "Select Folders & Files"}
</Button>
)}
)}
</div>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center gap-2 [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0" />
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
Folder and file selection is used when indexing. You can change this selection when you
start indexing.
</AlertDescription>
</Alert>
{/* Indexing Options */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-4">
<div className="space-y-1 sm:space-y-2">
<h3 className="font-medium text-sm sm:text-base">Indexing Options</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Configure how files are indexed from your Google Drive.
</p>
</div>
{/* Max files per folder */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="max-files" className="text-sm font-medium">
Max files per folder
</Label>
<p className="text-xs text-muted-foreground">
Maximum number of files to index from each folder
</p>
</div>
<Select
value={indexingOptions.max_files_per_folder.toString()}
onValueChange={(value) =>
handleIndexingOptionChange("max_files_per_folder", parseInt(value, 10))
}
>
<SelectTrigger
id="max-files"
className="w-[140px] bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select limit" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="50" className="text-xs sm:text-sm">
50 files
</SelectItem>
<SelectItem value="100" className="text-xs sm:text-sm">
100 files
</SelectItem>
<SelectItem value="250" className="text-xs sm:text-sm">
250 files
</SelectItem>
<SelectItem value="500" className="text-xs sm:text-sm">
500 files
</SelectItem>
<SelectItem value="1000" className="text-xs sm:text-sm">
1000 files
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Incremental sync toggle */}
<div className="flex items-center justify-between pt-2 border-t border-slate-400/20">
<div className="space-y-0.5">
<Label htmlFor="incremental-sync" className="text-sm font-medium">
Incremental sync
</Label>
<p className="text-xs text-muted-foreground">
Only sync changes since last index (faster). Disable for a full re-index.
</p>
</div>
<Switch
id="incremental-sync"
checked={indexingOptions.incremental_sync}
onCheckedChange={(checked) => handleIndexingOptionChange("incremental_sync", checked)}
/>
</div>
{/* Include subfolders toggle */}
<div className="flex items-center justify-between pt-2 border-t border-slate-400/20">
<div className="space-y-0.5">
<Label htmlFor="include-subfolders" className="text-sm font-medium">
Include subfolders
</Label>
<p className="text-xs text-muted-foreground">
Recursively index files in subfolders of selected folders
</p>
</div>
<Switch
id="include-subfolders"
checked={indexingOptions.include_subfolders}
onCheckedChange={(checked) => handleIndexingOptionChange("include_subfolders", checked)}
/>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,245 @@
"use client";
import { CheckCircle2, ChevronDown, ChevronUp, Server, XCircle } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { EnumConnectorName } from "@/contracts/enums/connector";
import type { MCPServerConfig, MCPToolDefinition } from "@/contracts/types/mcp.types";
import type { ConnectorConfigProps } from "../index";
import {
parseMCPConfig,
testMCPConnection,
type MCPConnectionTestResult,
} from "../../utils/mcp-config-validator";
interface MCPConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void;
}
export const MCPConfig: FC<MCPConfigProps> = ({ connector, onConfigChange, onNameChange }) => {
// Validate that this is an MCP connector
if (connector.connector_type !== EnumConnectorName.MCP_CONNECTOR) {
console.error(
"MCPConfig received non-MCP connector:",
connector.connector_type
);
return (
<Alert className="border-red-500/50 bg-red-500/10">
<XCircle className="h-4 w-4 text-red-600" />
<AlertTitle>Invalid Connector Type</AlertTitle>
<AlertDescription>
This component can only be used with MCP connectors.
</AlertDescription>
</Alert>
);
}
const [name, setName] = useState<string>("");
const [configJson, setConfigJson] = useState("");
const [jsonError, setJsonError] = useState<string | null>(null);
const [isTesting, setIsTesting] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [testResult, setTestResult] = useState<MCPConnectionTestResult | null>(null);
// Initialize form from connector config (only on mount)
useEffect(() => {
if (connector.name) {
setName(connector.name);
}
const serverConfig = connector.config?.server_config as MCPServerConfig | undefined;
if (serverConfig) {
// Convert server config to JSON string for editing (name is in separate field)
const configObj = {
command: serverConfig.command || "",
args: serverConfig.args || [],
env: serverConfig.env || {},
transport: serverConfig.transport || "stdio",
};
setConfigJson(JSON.stringify(configObj, null, 2));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Only run on mount to preserve user edits
const handleNameChange = (value: string) => {
setName(value);
if (onNameChange) {
onNameChange(value);
}
};
const parseConfig = () => {
const result = parseMCPConfig(configJson);
if (result.error) {
setJsonError(result.error);
} else {
setJsonError(null);
}
return result.config;
};
const handleConfigChange = (value: string) => {
setConfigJson(value);
if (jsonError) {
setJsonError(null);
}
// Use shared utility for validation and parsing (with caching)
const result = parseMCPConfig(value);
if (result.config && onConfigChange) {
// Valid config - update parent immediately
onConfigChange({ server_config: result.config });
}
// Ignore errors while typing - only show errors when user tests or saves
};
const handleTestConnection = async () => {
const serverConfig = parseConfig();
if (!serverConfig) {
setTestResult({
status: "error",
message: jsonError || "Invalid configuration",
tools: [],
});
return;
}
// Update parent with the config
if (onConfigChange) {
onConfigChange({ server_config: serverConfig });
}
setIsTesting(true);
setTestResult(null);
const result = await testMCPConnection(serverConfig);
setTestResult(result);
setIsTesting(false);
};
return (
<div className="space-y-6">
{/* Server Name */}
<div className="space-y-2">
<Label htmlFor="name">Server Name *</Label>
<Input
id="name"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="e.g., Filesystem Server"
required
/>
</div>
{/* Server Configuration */}
<div className="space-y-4">
<h3 className="font-medium text-sm sm:text-base flex items-center gap-2">
<Server className="h-4 w-4" />
Server Configuration
</h3>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-4">
<div className="space-y-2">
<Label htmlFor="config">MCP Server Configuration (JSON)</Label>
<Textarea
id="config"
value={configJson}
onChange={(e) => handleConfigChange(e.target.value)}
rows={16}
className={`font-mono text-xs ${jsonError ? "border-red-500" : ""}`}
/>
{jsonError && (
<p className="text-xs text-red-500">JSON Error: {jsonError}</p>
)}
<p className="text-[10px] sm:text-xs text-muted-foreground">
Edit your MCP server configuration. Must include: name, command, args (optional), env (optional), transport (optional).
</p>
</div>
{/* Test Connection */}
<div className="pt-4">
<Button
type="button"
onClick={handleTestConnection}
disabled={isTesting}
variant="outline"
className="w-full"
>
{isTesting ? "Testing Connection..." : "Test Connection"}
</Button>
</div>
{/* Test Result */}
{testResult && (
<Alert
className={
testResult.status === "success"
? "border-green-500/50 bg-green-500/10"
: "border-red-500/50 bg-red-500/10"
}
>
{testResult.status === "success" ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-red-600" />
)}
<div className="flex-1">
<div className="flex items-center justify-between">
<AlertTitle className="text-sm">
{testResult.status === "success" ? "Connection Successful" : "Connection Failed"}
</AlertTitle>
{testResult.tools.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowDetails(!showDetails);
}}
>
{showDetails ? (
<>
<ChevronUp className="h-3 w-3 mr-1" />
Hide Details
</>
) : (
<>
<ChevronDown className="h-3 w-3 mr-1" />
Show Details
</>
)}
</Button>
)}
</div>
<AlertDescription className="text-xs mt-1">
{testResult.message}
{showDetails && testResult.tools.length > 0 && (
<div className="mt-3 pt-3 border-t border-green-500/20">
<p className="font-semibold mb-2">
Available tools:
</p>
<ul className="list-disc list-inside text-xs space-y-0.5">
{testResult.tools.map((tool, i) => (
<li key={i}>{tool.name}</li>
))}
</ul>
</div>
)}
</AlertDescription>
</div>
</Alert>
)}
</div>
</div>
</div>
);
};

View file

@ -14,6 +14,7 @@ import { GoogleDriveConfig } from "./components/google-drive-config";
import { JiraConfig } from "./components/jira-config";
import { LinkupApiConfig } from "./components/linkup-api-config";
import { LumaConfig } from "./components/luma-config";
import { MCPConfig } from "./components/mcp-config";
import { SearxngConfig } from "./components/searxng-config";
import { SlackConfig } from "./components/slack-config";
import { TavilyApiConfig } from "./components/tavily-api-config";
@ -24,6 +25,7 @@ export interface ConnectorConfigProps {
connector: SearchSourceConnector;
onConfigChange?: (config: Record<string, unknown>) => void;
onNameChange?: (name: string) => void;
searchSpaceId?: string;
}
export type ConnectorConfigComponent = FC<ConnectorConfigProps>;
@ -69,6 +71,8 @@ export function getConnectorConfigComponent(
return LumaConfig;
case "CIRCLEBACK_CONNECTOR":
return CirclebackConfig;
case "MCP_CONNECTOR":
return MCPConfig;
// OAuth connectors (Gmail, Calendar, Airtable, Notion) and others don't need special config UI
default:
return null;

View file

@ -56,6 +56,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
GITHUB_CONNECTOR: "github-connect-form",
LUMA_CONNECTOR: "luma-connect-form",
CIRCLEBACK_CONNECTOR: "circleback-connect-form",
MCP_CONNECTOR: "mcp-connect-form",
};
const formId = formIdMap[connectorType];
if (formId) {
@ -98,7 +99,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
</div>
<div>
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">
Connect {getConnectorTypeDisplay(connectorType)}
Connect {connectorType === "MCP_CONNECTOR" ? "MCP Server" : getConnectorTypeDisplay(connectorType)}
</h2>
<p className="text-xs sm:text-base text-muted-foreground mt-1">
Enter your connection details
@ -135,10 +136,10 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
Connecting
</>
) : (
<>Connect {getConnectorTypeDisplay(connectorType)}</>
<>{connectorType === "MCP_CONNECTOR" ? "Connect" : `Connect ${getConnectorTypeDisplay(connectorType)}`}</>
)}
</Button>
</div>

View file

@ -19,6 +19,7 @@ interface ConnectorEditViewProps {
isSaving: boolean;
isDisconnecting: boolean;
isIndexing?: boolean;
searchSpaceId?: string;
onStartDateChange: (date: Date | undefined) => void;
onEndDateChange: (date: Date | undefined) => void;
onPeriodicEnabledChange: (enabled: boolean) => void;
@ -40,6 +41,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
isSaving,
isDisconnecting,
isIndexing = false,
searchSpaceId,
onStartDateChange,
onEndDateChange,
onPeriodicEnabledChange,
@ -149,7 +151,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
</div>
<div className="flex-1 min-w-0">
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight text-wrap whitespace-normal wrap-break-word">
{connector.name}
{connector.connector_type === "MCP_CONNECTOR" ? "MCP Server" : connector.name}
</h2>
<p className="text-xs sm:text-base text-muted-foreground mt-1">
Manage your connector settings and sync configuration
@ -170,7 +172,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
{isQuickIndexing || isIndexing ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Indexing...
Syncing
</>
) : (
<>
@ -197,6 +199,8 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
connector={connector}
onConfigChange={onConfigChange}
onNameChange={onNameChange}
searchSpaceId={searchSpaceId}
/>
)}
@ -218,15 +222,36 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
/>
)}
{/* Periodic sync - not shown for Google Drive */}
{connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" && (
<PeriodicSyncConfig
enabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
onEnabledChange={onPeriodicEnabledChange}
onFrequencyChange={onFrequencyChange}
/>
)}
{/* Periodic sync - shown for all indexable connectors */}
{(() => {
// Check if Google Drive has folders/files selected
const isGoogleDrive = connector.connector_type === "GOOGLE_DRIVE_CONNECTOR";
const selectedFolders =
(connector.config?.selected_folders as
| Array<{ id: string; name: string }>
| undefined) || [];
const selectedFiles =
(connector.config?.selected_files as
| Array<{ id: string; name: string }>
| undefined) || [];
const hasItemsSelected = selectedFolders.length > 0 || selectedFiles.length > 0;
const isDisabled = isGoogleDrive && !hasItemsSelected;
return (
<PeriodicSyncConfig
enabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
onEnabledChange={onPeriodicEnabledChange}
onFrequencyChange={onFrequencyChange}
disabled={isDisabled}
disabledMessage={
isDisabled
? "Select at least one folder or file above to enable periodic sync"
: undefined
}
/>
);
})()}
</>
)}
@ -277,7 +302,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
{isDisconnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Disconnecting...
Disconnecting
</>
) : (
"Confirm Disconnect"

View file

@ -160,6 +160,12 @@ export const OTHER_CONNECTORS = [
description: "Receive meeting notes, transcripts",
connectorType: EnumConnectorName.CIRCLEBACK_CONNECTOR,
},
{
id: "mcp-connector",
title: "MCPs",
description: "Connect to MCP servers for AI tools",
connectorType: EnumConnectorName.MCP_CONNECTOR,
},
] as const;
// Re-export IndexingConfigState from schemas for backward compatibility

View file

@ -7,7 +7,7 @@ import { searchSourceConnectorTypeEnum } from "@/contracts/types/connector.types
export const connectorPopupQueryParamsSchema = z.object({
modal: z.enum(["connectors"]).optional(),
tab: z.enum(["all", "active"]).optional(),
view: z.enum(["configure", "edit", "connect", "youtube", "accounts"]).optional(),
view: z.enum(["configure", "edit", "connect", "youtube", "accounts", "mcp-list"]).optional(),
connector: z.string().optional(),
connectorId: z.string().optional(),
connectorType: z.string().optional(),

View file

@ -80,12 +80,18 @@ export const useConnectorDialog = () => {
connectorTitle: string;
} | null>(null);
// MCP list view state (for managing multiple MCP connectors)
const [viewingMCPList, setViewingMCPList] = useState(false);
// Track if we came from accounts list when entering edit mode
const [cameFromAccountsList, setCameFromAccountsList] = useState<{
connectorType: string;
connectorTitle: string;
} | null>(null);
// Track if we came from MCP list view when entering edit mode
const [cameFromMCPList, setCameFromMCPList] = useState(false);
// Helper function to get frequency label
const getFrequencyLabel = useCallback((minutes: string): string => {
switch (minutes) {
@ -139,6 +145,16 @@ export const useConnectorDialog = () => {
setViewingAccountsType(null);
}
// Clear MCP list view if view is not "mcp-list" anymore
if (params.view !== "mcp-list" && viewingMCPList) {
setViewingMCPList(false);
}
// Handle MCP list view
if (params.view === "mcp-list" && !viewingMCPList) {
setViewingMCPList(true);
}
// Handle connect view
if (params.view === "connect" && params.connectorType && !connectingConnectorType) {
setConnectingConnectorType(params.connectorType);
@ -203,11 +219,9 @@ export const useConnectorDialog = () => {
setEditingConnector(connector);
setConnectorConfig(connector.config);
setConnectorName(connector.name);
// Load existing periodic sync settings (disabled for Google Drive and non-indexable connectors)
// Load existing periodic sync settings (disabled for non-indexable connectors)
setPeriodicEnabled(
connector.connector_type === "GOOGLE_DRIVE_CONNECTOR" || !connector.is_indexable
? false
: connector.periodic_indexing_enabled
!connector.is_indexable ? false : connector.periodic_indexing_enabled
);
setFrequencyMinutes(connector.indexing_frequency_minutes?.toString() || "1440");
// Reset dates - user can set new ones for re-indexing
@ -421,6 +435,7 @@ export const useConnectorDialog = () => {
connector_type: EnumConnectorName.WEBCRAWLER_CONNECTOR,
config: {},
is_indexable: true,
is_active: true,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
@ -491,20 +506,23 @@ export const useConnectorDialog = () => {
// Handle submitting connect form
const handleSubmitConnectForm = useCallback(
async (formData: {
name: string;
connector_type: string;
config: Record<string, unknown>;
is_indexable: boolean;
last_indexed_at: null;
periodic_indexing_enabled: boolean;
indexing_frequency_minutes: number | null;
next_scheduled_at: null;
startDate?: Date;
endDate?: Date;
periodicEnabled?: boolean;
frequencyMinutes?: string;
}) => {
async (
formData: {
name: string;
connector_type: string;
config: Record<string, unknown>;
is_indexable: boolean;
last_indexed_at: null;
periodic_indexing_enabled: boolean;
indexing_frequency_minutes: number | null;
next_scheduled_at: null;
startDate?: Date;
endDate?: Date;
periodicEnabled?: boolean;
frequencyMinutes?: string;
},
onIndexingStart?: (connectorId: number) => void
) => {
if (!searchSpaceId || !connectingConnectorType) return;
// Prevent multiple submissions using ref for immediate check
@ -522,17 +540,18 @@ export const useConnectorDialog = () => {
data: {
...connectorData,
connector_type: connectorData.connector_type as EnumConnectorName,
next_scheduled_at: connectorData.next_scheduled_at as string | null,
},
queryParams: {
search_space_id: searchSpaceId,
},
});
is_active: true,
next_scheduled_at: connectorData.next_scheduled_at as string | null,
},
queryParams: {
search_space_id: searchSpaceId,
},
});
// Refetch connectors to get the new one
const result = await refetchAllConnectors();
if (result.data) {
const connector = result.data.find(
// Refetch connectors to get the new one
const result = await refetchAllConnectors();
if (result.data) {
const connector = result.data.find(
(c: SearchSourceConnector) => c.id === newConnector.id
);
if (connector) {
@ -603,6 +622,11 @@ export const useConnectorDialog = () => {
});
}
// Notify caller that indexing is starting (for UI syncing state)
if (onIndexingStart) {
onIndexingStart(connector.id);
}
// Start indexing (backend will use defaults if dates are undefined)
const startDateStr = startDateForIndexing
? format(startDateForIndexing, "yyyy-MM-dd")
@ -620,32 +644,34 @@ export const useConnectorDialog = () => {
},
});
toast.success(`${connectorTitle} connected and indexing started!`, {
description: periodicEnabledForIndexing
? `Periodic sync enabled every ${getFrequencyLabel(frequencyMinutesForIndexing)}.`
: "You can continue working while we sync your data.",
});
const successMessage = currentConnectorType === "MCP_CONNECTOR"
? `${connector.name} MCP server added successfully`
: `${connectorTitle} connected and indexing started!`;
toast.success(successMessage, {
description: periodicEnabledForIndexing
? `Periodic sync enabled every ${getFrequencyLabel(frequencyMinutesForIndexing)}.`
: "You can continue working while we sync your data.",
});
// Close modal and return to main view
const url = new URL(window.location.href);
url.searchParams.delete("modal");
url.searchParams.delete("tab");
url.searchParams.delete("view");
url.searchParams.delete("connectorType");
router.replace(url.pathname + url.search, { scroll: false });
const url = new URL(window.location.href);
url.searchParams.delete("modal");
url.searchParams.delete("tab");
url.searchParams.delete("view");
url.searchParams.delete("connectorType");
router.replace(url.pathname + url.search, { scroll: false });
// Clear indexing config state since we're not showing the view
setIndexingConfig(null);
setIndexingConnector(null);
setIndexingConnectorConfig(null);
// Clear indexing config state since we're not showing the view
setIndexingConfig(null);
setIndexingConnector(null);
setIndexingConnectorConfig(null);
// Invalidate queries to refresh data
queryClient.invalidateQueries({
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
});
// Invalidate queries to refresh data
queryClient.invalidateQueries({
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
});
// Refresh connectors list
await refetchAllConnectors();
// Refresh connectors list
await refetchAllConnectors();
} else {
// Non-indexable connector
// For Circleback, transition to edit view to show webhook URL
@ -682,7 +708,13 @@ export const useConnectorDialog = () => {
await refetchAllConnectors();
} else {
// Other non-indexable connectors - just show success message and close
toast.success(`${connectorTitle} connected successfully!`);
const successMessage = currentConnectorType === "MCP_CONNECTOR"
? `${connector.name} MCP server added successfully`
: `${connectorTitle} connected successfully!`;
toast.success(successMessage);
// Refresh connectors list before closing modal
await refetchAllConnectors();
// Close modal and return to main view
const url = new URL(window.location.href);
@ -726,11 +758,18 @@ export const useConnectorDialog = () => {
const handleBackFromConnect = useCallback(() => {
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("tab", "all");
url.searchParams.delete("view");
// If we're connecting an MCP and came from list view, go back to list
if (connectingConnectorType === "MCP_CONNECTOR" && viewingMCPList) {
url.searchParams.set("view", "mcp-list");
} else {
url.searchParams.set("tab", "all");
url.searchParams.delete("view");
}
url.searchParams.delete("connectorType");
router.replace(url.pathname + url.search, { scroll: false });
}, [router]);
}, [router, connectingConnectorType, viewingMCPList]);
// Handle going back from YouTube view
const handleBackFromYouTube = useCallback(() => {
@ -773,6 +812,38 @@ export const useConnectorDialog = () => {
router.replace(url.pathname + url.search, { scroll: false });
}, [router]);
// Handle viewing MCP list
const handleViewMCPList = useCallback(() => {
if (!searchSpaceId) return;
setViewingMCPList(true);
// Update URL to show MCP list view
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "mcp-list");
window.history.pushState({ modal: true }, "", url.toString());
}, [searchSpaceId]);
// Handle going back from MCP list view
const handleBackFromMCPList = useCallback(() => {
setViewingMCPList(false);
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.delete("view");
router.replace(url.pathname + url.search, { scroll: false });
}, [router]);
// Handle adding new MCP from list view
const handleAddNewMCPFromList = useCallback(() => {
setConnectingConnectorType("MCP_CONNECTOR");
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "connect");
url.searchParams.set("connectorType", "MCP_CONNECTOR");
router.replace(url.pathname + url.search, { scroll: false });
}, [router]);
// Handle starting indexing
const handleStartIndexing = useCallback(
async (refreshConnectors: () => void) => {
@ -809,20 +880,14 @@ export const useConnectorDialog = () => {
const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined;
// Update connector with periodic sync settings and config changes
// Note: Periodic sync is disabled for Google Drive connectors
if (periodicEnabled || indexingConnectorConfig) {
const frequency = periodicEnabled ? parseInt(frequencyMinutes, 10) : undefined;
await updateConnector({
id: indexingConfig.connectorId,
data: {
...(periodicEnabled &&
indexingConfig.connectorType !== "GOOGLE_DRIVE_CONNECTOR" && {
periodic_indexing_enabled: true,
indexing_frequency_minutes: frequency,
}),
...(indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" && {
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
...(periodicEnabled && {
periodic_indexing_enabled: true,
indexing_frequency_minutes: frequency,
}),
...(indexingConnectorConfig && {
config: indexingConnectorConfig,
@ -839,11 +904,18 @@ export const useConnectorDialog = () => {
const selectedFiles = indexingConnectorConfig.selected_files as
| Array<{ id: string; name: string }>
| undefined;
const indexingOptions = indexingConnectorConfig.indexing_options as
| {
max_files_per_folder: number;
incremental_sync: boolean;
include_subfolders: boolean;
}
| undefined;
if (
(selectedFolders && selectedFolders.length > 0) ||
(selectedFiles && selectedFiles.length > 0)
) {
// Index with folder/file selection
// Index with folder/file selection and indexing options
await indexConnector({
connector_id: indexingConfig.connectorId,
queryParams: {
@ -852,6 +924,11 @@ export const useConnectorDialog = () => {
body: {
folders: selectedFolders || [],
files: selectedFiles || [],
indexing_options: indexingOptions || {
max_files_per_folder: 100,
incremental_sync: true,
include_subfolders: true,
},
},
});
} else {
@ -891,7 +968,7 @@ export const useConnectorDialog = () => {
);
// Track periodic indexing started if enabled
if (periodicEnabled && indexingConfig.connectorType !== "GOOGLE_DRIVE_CONNECTOR") {
if (periodicEnabled) {
trackPeriodicIndexingStarted(
Number(searchSpaceId),
indexingConfig.connectorType,
@ -958,6 +1035,13 @@ export const useConnectorDialog = () => {
(connector: SearchSourceConnector) => {
if (!searchSpaceId) return;
// For MCP connectors from "All Connectors" tab, show the list view instead of directly editing
// (unless we're already in the MCP list view or on the Active tab where individual MCPs are shown)
if (connector.connector_type === "MCP_CONNECTOR" && !viewingMCPList && activeTab === "all") {
handleViewMCPList();
return;
}
// All connector types should be handled in the popup edit view
// Validate connector data
const connectorValidation = searchSourceConnector.safeParse(connector);
@ -974,6 +1058,13 @@ export const useConnectorDialog = () => {
setCameFromAccountsList(null);
}
// Track if we came from MCP list view
if (viewingMCPList && connector.connector_type === "MCP_CONNECTOR") {
setCameFromMCPList(true);
} else {
setCameFromMCPList(false);
}
// Track index with date range opened event
if (connector.is_indexable) {
trackIndexWithDateRangeOpened(
@ -985,12 +1076,8 @@ export const useConnectorDialog = () => {
setEditingConnector(connector);
setConnectorName(connector.name);
// Load existing periodic sync settings (disabled for Google Drive and non-indexable connectors)
setPeriodicEnabled(
connector.connector_type === "GOOGLE_DRIVE_CONNECTOR" || !connector.is_indexable
? false
: connector.periodic_indexing_enabled
);
// Load existing periodic sync settings (disabled for non-indexable connectors)
setPeriodicEnabled(!connector.is_indexable ? false : connector.periodic_indexing_enabled);
setFrequencyMinutes(connector.indexing_frequency_minutes?.toString() || "1440");
// Reset dates - user can set new ones for re-indexing
setStartDate(undefined);
@ -1003,13 +1090,13 @@ export const useConnectorDialog = () => {
url.searchParams.set("connectorId", connector.id.toString());
window.history.pushState({ modal: true }, "", url.toString());
},
[searchSpaceId, viewingAccountsType]
[searchSpaceId, viewingAccountsType, viewingMCPList, handleViewMCPList, activeTab]
);
// Handle saving connector changes
const handleSaveConnector = useCallback(
async (refreshConnectors: () => void) => {
if (!editingConnector || !searchSpaceId) return;
if (!editingConnector || !searchSpaceId || isSaving) return;
// Validate date range (skip for Google Drive which uses folder selection, Webcrawler which uses config, and non-indexable connectors)
if (
@ -1030,6 +1117,24 @@ export const useConnectorDialog = () => {
return;
}
// Prevent periodic indexing for Google Drive without folders/files selected
if (periodicEnabled && editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR") {
const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as
| Array<{ id: string; name: string }>
| undefined;
const selectedFiles = (connectorConfig || editingConnector.config)?.selected_files as
| Array<{ id: string; name: string }>
| undefined;
const hasItemsSelected =
(selectedFolders && selectedFolders.length > 0) ||
(selectedFiles && selectedFiles.length > 0);
if (!hasItemsSelected) {
toast.error("Select at least one folder or file to enable periodic sync");
return;
}
}
// Validate frequency minutes if periodic is enabled (only for indexable connectors)
if (periodicEnabled && editingConnector.is_indexable) {
const frequencyValidation = frequencyMinutesSchema.safeParse(frequencyMinutes);
@ -1045,23 +1150,14 @@ export const useConnectorDialog = () => {
const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined;
// Update connector with periodic sync settings, config changes, and name
// Note: Periodic sync is disabled for Google Drive connectors and non-indexable connectors
const frequency =
periodicEnabled && editingConnector.is_indexable ? parseInt(frequencyMinutes, 10) : null;
await updateConnector({
id: editingConnector.id,
data: {
name: connectorName || editingConnector.name,
periodic_indexing_enabled:
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
!editingConnector.is_indexable
? false
: periodicEnabled,
indexing_frequency_minutes:
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
!editingConnector.is_indexable
? null
: frequency,
periodic_indexing_enabled: !editingConnector.is_indexable ? false : periodicEnabled,
indexing_frequency_minutes: !editingConnector.is_indexable ? null : frequency,
config: connectorConfig || editingConnector.config,
},
});
@ -1079,6 +1175,13 @@ export const useConnectorDialog = () => {
const selectedFiles = (connectorConfig || editingConnector.config)?.selected_files as
| Array<{ id: string; name: string }>
| undefined;
const indexingOptions = (connectorConfig || editingConnector.config)?.indexing_options as
| {
max_files_per_folder: number;
incremental_sync: boolean;
include_subfolders: boolean;
}
| undefined;
if (
(selectedFolders && selectedFolders.length > 0) ||
(selectedFiles && selectedFiles.length > 0)
@ -1091,6 +1194,11 @@ export const useConnectorDialog = () => {
body: {
folders: selectedFolders || [],
files: selectedFiles || [],
indexing_options: indexingOptions || {
max_files_per_folder: 100,
incremental_sync: true,
include_subfolders: true,
},
},
});
const totalItems = (selectedFolders?.length || 0) + (selectedFiles?.length || 0);
@ -1134,12 +1242,8 @@ export const useConnectorDialog = () => {
);
}
// Track periodic indexing if enabled (for non-Google Drive connectors)
if (
periodicEnabled &&
editingConnector.is_indexable &&
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR"
) {
// Track periodic indexing if enabled
if (periodicEnabled && editingConnector.is_indexable) {
trackPeriodicIndexingStarted(
Number(searchSpaceId),
editingConnector.connector_type,
@ -1148,34 +1252,38 @@ export const useConnectorDialog = () => {
);
}
toast.success(`${editingConnector.name} updated successfully`, {
description: periodicEnabled
? `Periodic sync ${frequency ? `enabled every ${getFrequencyLabel(frequencyMinutes)}` : "enabled"}. ${indexingDescription}`
: indexingDescription,
});
// Generate toast message based on connector type
const toastTitle = `${editingConnector.name} updated successfully`;
// Update URL - the effect will handle closing the modal and clearing state
const url = new URL(window.location.href);
url.searchParams.delete("modal");
url.searchParams.delete("tab");
url.searchParams.delete("view");
url.searchParams.delete("connectorId");
router.replace(url.pathname + url.search, { scroll: false });
toast.success(toastTitle, {
description: periodicEnabled
? `Periodic sync ${frequency ? `enabled every ${getFrequencyLabel(frequencyMinutes)}` : "enabled"}. ${indexingDescription}`
: indexingDescription,
});
refreshConnectors();
queryClient.invalidateQueries({
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
});
} catch (error) {
console.error("Error saving connector:", error);
toast.error("Failed to save connector changes");
} finally {
setIsSaving(false);
}
// Update URL - the effect will handle closing the modal and clearing state
const url = new URL(window.location.href);
url.searchParams.delete("modal");
url.searchParams.delete("tab");
url.searchParams.delete("view");
url.searchParams.delete("connectorId");
router.replace(url.pathname + url.search, { scroll: false });
refreshConnectors();
queryClient.invalidateQueries({
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
});
} catch (error) {
console.error("Error saving connector:", error);
toast.error("Failed to save connector changes");
} finally {
setIsSaving(false);
}
},
[
editingConnector,
searchSpaceId,
isSaving,
startDate,
endDate,
indexConnector,
@ -1207,14 +1315,27 @@ export const useConnectorDialog = () => {
editingConnector.id
);
toast.success(`${editingConnector.name} disconnected successfully`);
toast.success(
editingConnector.connector_type === "MCP_CONNECTOR"
? `${editingConnector.name} MCP server removed successfully`
: `${editingConnector.name} disconnected successfully`
);
// Update URL - the effect will handle closing the modal and clearing state
// Update URL - for MCP from list view, go back to list; otherwise close modal
const url = new URL(window.location.href);
url.searchParams.delete("modal");
url.searchParams.delete("tab");
url.searchParams.delete("view");
url.searchParams.delete("connectorId");
if (editingConnector.connector_type === "MCP_CONNECTOR" && cameFromMCPList) {
// Go back to MCP list view only if we came from there
setViewingMCPList(true);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "mcp-list");
url.searchParams.delete("connectorId");
} else {
// Close modal for all other cases
url.searchParams.delete("modal");
url.searchParams.delete("tab");
url.searchParams.delete("view");
url.searchParams.delete("connectorId");
}
router.replace(url.pathname + url.search, { scroll: false });
refreshConnectors();
@ -1266,6 +1387,21 @@ export const useConnectorDialog = () => {
// Handle going back from edit view
const handleBackFromEdit = useCallback(() => {
// If editing an MCP connector and came from MCP list, go back to MCP list view
if (editingConnector?.connector_type === "MCP_CONNECTOR" && cameFromMCPList) {
setViewingMCPList(true);
setCameFromMCPList(false);
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "mcp-list");
url.searchParams.delete("connectorId");
router.replace(url.pathname + url.search, { scroll: false });
setEditingConnector(null);
setConnectorName(null);
setConnectorConfig(null);
return;
}
// If we came from accounts list view, go back there
if (cameFromAccountsList && editingConnector) {
// Restore accounts list view
@ -1278,10 +1414,10 @@ export const useConnectorDialog = () => {
url.searchParams.delete("connectorId");
router.replace(url.pathname + url.search, { scroll: false });
} else {
// Otherwise, go back to main connector popup
// Otherwise, go back to main connector popup (preserve the tab the user was on)
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("tab", "all");
url.searchParams.set("tab", activeTab); // Use current tab instead of always "all"
url.searchParams.delete("view");
url.searchParams.delete("connectorId");
router.replace(url.pathname + url.search, { scroll: false });
@ -1289,7 +1425,7 @@ export const useConnectorDialog = () => {
setEditingConnector(null);
setConnectorName(null);
setConnectorConfig(null);
}, [router, cameFromAccountsList, editingConnector]);
}, [router, cameFromAccountsList, editingConnector, cameFromMCPList, activeTab]);
// Handle dialog open/close
const handleOpenChange = useCallback(
@ -1367,6 +1503,7 @@ export const useConnectorDialog = () => {
searchSpaceId,
allConnectors,
viewingAccountsType,
viewingMCPList,
// Setters
setSearchQuery,
@ -1395,6 +1532,9 @@ export const useConnectorDialog = () => {
handleBackFromYouTube,
handleViewAccountsList,
handleBackFromAccountsList,
handleViewMCPList,
handleBackFromMCPList,
handleAddNewMCPFromList,
handleQuickIndexConnector,
connectorConfig,
setConnectorConfig,

View file

@ -1,6 +1,6 @@
"use client";
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import {
type ConnectorStatusConfig,
connectorStatusConfig,
@ -14,34 +14,43 @@ export function useConnectorStatus() {
/**
* Get status configuration for a specific connector type
*/
const getConnectorStatus = (connectorType: string | undefined): ConnectorStatusConfig => {
if (!connectorType) {
return getDefaultConnectorStatus();
}
const getConnectorStatus = useCallback(
(connectorType: string | undefined): ConnectorStatusConfig => {
if (!connectorType) {
return getDefaultConnectorStatus();
}
return connectorStatusConfig.connectorStatuses[connectorType] || getDefaultConnectorStatus();
};
return connectorStatusConfig.connectorStatuses[connectorType] || getDefaultConnectorStatus();
},
[]
);
/**
* Check if a connector is enabled
*/
const isConnectorEnabled = (connectorType: string | undefined): boolean => {
return getConnectorStatus(connectorType).enabled;
};
const isConnectorEnabled = useCallback(
(connectorType: string | undefined): boolean => {
return getConnectorStatus(connectorType).enabled;
},
[getConnectorStatus]
);
/**
* Get status message for a connector
*/
const getConnectorStatusMessage = (connectorType: string | undefined): string | null => {
return getConnectorStatus(connectorType).statusMessage || null;
};
const getConnectorStatusMessage = useCallback(
(connectorType: string | undefined): string | null => {
return getConnectorStatus(connectorType).statusMessage || null;
},
[getConnectorStatus]
);
/**
* Check if warnings should be shown globally
*/
const shouldShowWarnings = (): boolean => {
const shouldShowWarnings = useCallback((): boolean => {
return connectorStatusConfig.globalSettings.showWarnings;
};
}, []);
return useMemo(
() => ({
@ -50,6 +59,6 @@ export function useConnectorStatus() {
getConnectorStatusMessage,
shouldShowWarnings,
}),
[]
[getConnectorStatus, isConnectorEnabled, getConnectorStatusMessage, shouldShowWarnings]
);
}

View file

@ -0,0 +1,81 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
/**
* Hook to track which connectors are currently indexing using local state.
*
* 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
*
* The actual `last_indexed_at` value comes from Electric SQL/PGlite, not local state.
*/
export function useIndexingConnectors(connectors: SearchSourceConnector[]) {
// Set of connector IDs that are currently indexing
const [indexingConnectorIds, setIndexingConnectorIds] = useState<Set<number>>(new Set());
// Track previous last_indexed_at values to detect changes
const previousLastIndexedAtRef = useRef<Map<number, string | null>>(new Map());
// 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 (
previousValue !== undefined && // We've seen this connector before
previousValue !== currentValue && // Value changed
indexingConnectorIds.has(connector.id) // It was marked as indexing
) {
newIndexingIds.delete(connector.id);
hasChanges = true;
}
// Update previous value tracking
previousValues.set(connector.id, currentValue);
}
if (hasChanges) {
setIndexingConnectorIds(newIndexingIds);
}
}, [connectors, indexingConnectorIds]);
// Add a connector to the indexing set (called when indexing starts)
const startIndexing = useCallback((connectorId: number) => {
setIndexingConnectorIds((prev) => {
const next = new Set(prev);
next.add(connectorId);
return next;
});
}, []);
// Remove a connector from the indexing set (called manually if needed)
const stopIndexing = useCallback((connectorId: number) => {
setIndexingConnectorIds((prev) => {
const next = new Set(prev);
next.delete(connectorId);
return next;
});
}, []);
// Check if a connector is currently indexing
const isIndexing = useCallback(
(connectorId: number) => indexingConnectorIds.has(connectorId),
[indexingConnectorIds]
);
return {
indexingConnectorIds,
startIndexing,
stopIndexing,
isIndexing,
};
}

View file

@ -1,15 +1,17 @@
"use client";
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
import { ArrowRight, Cable, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import type { FC } from "react";
import { useState } from "react";
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { TabsContent } from "@/components/ui/tabs";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import { cn } from "@/lib/utils";
import { OAUTH_CONNECTORS } from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
@ -21,20 +23,26 @@ interface ActiveConnectorsTabProps {
activeDocumentTypes: Array<[string, number]>;
connectors: SearchSourceConnector[];
indexingConnectorIds: Set<number>;
logsSummary: LogSummary | undefined;
searchSpaceId: string;
onTabChange: (value: string) => void;
onManage?: (connector: SearchSourceConnector) => void;
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
}
/**
* Check if a connector type is indexable
*/
function isIndexableConnector(connectorType: string): boolean {
const nonIndexableTypes = ["MCP_CONNECTOR"];
return !nonIndexableTypes.includes(connectorType);
}
export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
searchQuery,
hasSources,
activeDocumentTypes,
connectors,
indexingConnectorIds,
logsSummary,
searchSpaceId,
onTabChange,
onManage,
@ -67,32 +75,6 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
return `${m.replace(/\.0$/, "")}M docs`;
};
// Format last indexed date with contextual messages
const formatLastIndexedDate = (dateString: string): string => {
const date = new Date(dateString);
const now = new Date();
const minutesAgo = differenceInMinutes(now, date);
const daysAgo = differenceInDays(now, date);
if (minutesAgo < 1) return "Just now";
if (minutesAgo < 60) return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`;
if (isToday(date)) return `Today at ${format(date, "h:mm a")}`;
if (isYesterday(date)) return `Yesterday at ${format(date, "h:mm a")}`;
if (daysAgo < 7) return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`;
return format(date, "MMM d, yyyy");
};
// Get most recent last indexed date from a list of connectors
const getMostRecentLastIndexed = (
connectorsList: SearchSourceConnector[]
): string | undefined => {
return connectorsList.reduce<string | undefined>((latest, c) => {
if (!c.last_indexed_at) return latest;
if (!latest) return c.last_indexed_at;
return new Date(c.last_indexed_at) > new Date(latest) ? c.last_indexed_at : latest;
}, undefined);
};
// Document types that should be shown as standalone cards (not from connectors)
const standaloneDocumentTypes = ["EXTENSION", "FILE", "NOTE", "YOUTUBE_VIDEO", "CRAWLED_URL"];
@ -114,7 +96,9 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
// Separate OAuth and non-OAuth connectors
const oauthConnectors = connectors.filter((c) => oauthConnectorTypes.has(c.connector_type));
const nonOauthConnectors = connectors.filter((c) => !oauthConnectorTypes.has(c.connector_type));
const nonOauthConnectors = connectors.filter(
(c) => !oauthConnectorTypes.has(c.connector_type)
);
// Group OAuth connectors by type
const oauthConnectorsByType = oauthConnectors.reduce(
@ -166,7 +150,8 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
});
const hasActiveConnectors =
filteredOAuthConnectorTypes.length > 0 || filteredNonOAuthConnectors.length > 0;
filteredOAuthConnectorTypes.length > 0 ||
filteredNonOAuthConnectors.length > 0;
return (
<TabsContent value="active" className="m-0">
@ -190,7 +175,6 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
documentTypeCounts
);
const accountCount = typeConnectors.length;
const mostRecentLastIndexed = getMostRecentLastIndexed(typeConnectors);
const handleManageClick = () => {
if (onViewAccountsList) {
@ -204,10 +188,10 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
<div
key={`oauth-type-${connectorType}`}
className={cn(
"relative flex items-center gap-4 p-4 rounded-xl border border-border transition-all",
"relative flex items-center gap-4 p-4 rounded-xl transition-all",
isAnyIndexing
? "bg-primary/5 border-primary/20"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
? "bg-primary/5 border-0"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10 border border-border"
)}
>
<div
@ -225,22 +209,17 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
{isAnyIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
Indexing...
Syncing
</p>
) : (
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap">
{mostRecentLastIndexed
? `Last indexed: ${formatLastIndexedDate(mostRecentLastIndexed)}`
: "Never indexed"}
<p className="text-[10px] text-muted-foreground mt-1 flex items-center gap-1.5">
<span>{formatDocumentCount(documentCount)}</span>
<span className="text-muted-foreground/50"></span>
<span>
{accountCount} {accountCount === 1 ? "Account" : "Accounts"}
</span>
</p>
)}
<p className="text-[10px] text-muted-foreground mt-0.5 flex items-center gap-1.5">
<span>{formatDocumentCount(documentCount)}</span>
<span className="text-muted-foreground/50"></span>
<span>
{accountCount} {accountCount === 1 ? "Account" : "Accounts"}
</span>
</p>
</div>
<Button
variant="secondary"
@ -257,22 +236,19 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
{/* Non-OAuth Connectors - Individual Cards */}
{filteredNonOAuthConnectors.map((connector) => {
const isIndexing = indexingConnectorIds.has(connector.id);
const activeTask = logsSummary?.active_tasks?.find(
(task: LogActiveTask) => task.connector_id === connector.id
);
const documentCount = getDocumentCountForConnector(
connector.connector_type,
documentTypeCounts
);
const isMCPConnector = connector.connector_type === "MCP_CONNECTOR";
return (
<div
key={`connector-${connector.id}`}
className={cn(
"flex items-center gap-4 p-4 rounded-xl border border-border transition-all",
"flex items-center gap-4 p-4 rounded-xl transition-all",
isIndexing
? "bg-primary/5 border-primary/20"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
? "bg-primary/5 border-0"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10 border border-border"
)}
>
<div
@ -286,29 +262,21 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
{getConnectorIcon(connector.connector_type, "size-6")}
</div>
<div className="flex-1 min-w-0">
<p className="text-[14px] font-semibold leading-tight truncate">
{connector.name}
</p>
<div className="flex items-center gap-2">
<p className="text-[14px] font-semibold leading-tight">
{connector.name}
</p>
</div>
{isIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
Indexing...
{activeTask?.message && (
<span className="text-muted-foreground truncate max-w-[150px]">
{activeTask.message}
</span>
)}
Syncing
</p>
) : (
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap">
{connector.last_indexed_at
? `Last indexed: ${formatLastIndexedDate(connector.last_indexed_at)}`
: "Never indexed"}
) : !isMCPConnector ? (
<p className="text-[10px] text-muted-foreground mt-1">
{formatDocumentCount(documentCount)}
</p>
)}
<p className="text-[10px] text-muted-foreground mt-0.5">
{formatDocumentCount(documentCount)}
</p>
) : null}
</div>
<Button
variant="secondary"
@ -362,19 +330,12 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
) : (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
<Cable className="size-8 text-muted-foreground/50" />
<Cable className="size-8 text-muted-foreground" />
</div>
<h4 className="text-lg font-semibold">No active sources</h4>
<p className="text-sm text-muted-foreground mt-1 max-w-[280px]">
Connect your first service to start searching across all your data.
</p>
<Button
variant="link"
className="mt-6 text-primary hover:underline"
onClick={() => onTabChange("all")}
>
Browse available connectors
</Button>
</div>
)}
</TabsContent>

View file

@ -1,10 +1,7 @@
"use client";
import { Plus } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { ConnectorCard } from "../components/connector-card";
import { CRAWLERS, OAUTH_CONNECTORS, OTHER_CONNECTORS } from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
@ -30,7 +27,6 @@ interface AllConnectorsTabProps {
allConnectors: SearchSourceConnector[] | undefined;
documentTypeCounts?: Record<string, number>;
indexingConnectorIds?: Set<number>;
logsSummary?: LogSummary;
onConnectOAuth: (connector: (typeof OAUTH_CONNECTORS)[number]) => void;
onConnectNonOAuth?: (connectorType: string) => void;
onCreateWebcrawler?: () => void;
@ -41,13 +37,11 @@ interface AllConnectorsTabProps {
export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
searchQuery,
searchSpaceId,
connectedTypes,
connectingId,
allConnectors,
documentTypeCounts,
indexingConnectorIds,
logsSummary,
onConnectOAuth,
onConnectNonOAuth,
onCreateWebcrawler,
@ -55,14 +49,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
onManage,
onViewAccountsList,
}) => {
// Helper to find active task for a connector
const getActiveTaskForConnector = (connectorId: number): LogActiveTask | undefined => {
if (!logsSummary?.active_tasks) return undefined;
return logsSummary.active_tasks.find(
(task: LogActiveTask) => task.connector_id === connectorId
);
};
// Filter connectors based on search
const filteredOAuth = OAUTH_CONNECTORS.filter(
(c) =>
@ -103,6 +89,8 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
)
: [];
const accountCount = typeConnectors.length;
// Get the most recent last_indexed_at across all accounts
const mostRecentLastIndexed = typeConnectors.reduce<string | undefined>(
(latest, c) => {
@ -123,11 +111,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
// Check if any account is currently indexing
const isIndexing = typeConnectors.some((c) => indexingConnectorIds?.has(c.id));
// Get active task from any indexing account
const activeTask = typeConnectors
.map((c) => getActiveTaskForConnector(c.id))
.find((task) => task !== undefined);
return (
<ConnectorCard
key={connector.id}
@ -138,10 +121,9 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
isConnected={isConnected}
isConnecting={isConnecting}
documentCount={documentCount}
accountCount={typeConnectors.length}
lastIndexedAt={mostRecentLastIndexed}
accountCount={accountCount}
isIndexing={isIndexing}
activeTask={activeTask}
onConnect={() => onConnectOAuth(connector)}
onManage={
isConnected && onViewAccountsList
@ -179,9 +161,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
documentTypeCounts
);
const isIndexing = actualConnector && indexingConnectorIds?.has(actualConnector.id);
const activeTask = actualConnector
? getActiveTaskForConnector(actualConnector.id)
: undefined;
const handleConnect = onConnectNonOAuth
? () => onConnectNonOAuth(connector.connectorType)
@ -197,9 +176,8 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
isConnected={isConnected}
isConnecting={isConnecting}
documentCount={documentCount}
lastIndexedAt={actualConnector?.last_indexed_at}
isIndexing={isIndexing}
activeTask={activeTask}
onConnect={handleConnect}
onManage={
actualConnector && onManage ? () => onManage(actualConnector) : undefined
@ -240,9 +218,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
? getDocumentCountForConnector(crawler.connectorType, documentTypeCounts)
: undefined;
const isIndexing = actualConnector && indexingConnectorIds?.has(actualConnector.id);
const activeTask = actualConnector
? getActiveTaskForConnector(actualConnector.id)
: undefined;
const handleConnect =
isYouTube && onCreateYouTubeCrawler
@ -267,9 +242,7 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
isConnected={isConnected}
isConnecting={isConnecting}
documentCount={documentCount}
lastIndexedAt={actualConnector?.last_indexed_at}
isIndexing={isIndexing}
activeTask={activeTask}
onConnect={handleConnect}
onManage={
actualConnector && onManage ? () => onManage(actualConnector) : undefined

View file

@ -0,0 +1,254 @@
/**
* MCP Configuration Validator Utility
*
* Shared validation and parsing logic for MCP (Model Context Protocol) server configurations.
*
* Features:
* - Zod schema validation for runtime type safety
* - Configuration caching to avoid repeated parsing (5-minute TTL)
* - Standardized error messages
* - Connection testing utilities
*
* Usage:
* ```typescript
* // Parse and validate config
* const result = parseMCPConfig(jsonString);
* if (result.config) {
* // Valid config
* } else {
* // Show result.error to user
* }
*
* // Test connection
* const testResult = await testMCPConnection(config);
* if (testResult.status === "success") {
* console.log(`Found ${testResult.tools.length} tools`);
* }
* ```
*
* @module mcp-config-validator
*/
import { z } from "zod";
import type { MCPServerConfig, MCPToolDefinition } from "@/contracts/types/mcp.types";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
/**
* Zod schema for MCP server configuration
* Provides compile-time and runtime type safety
*
* Exported for advanced use cases (e.g., form builders)
*/
export const MCPServerConfigSchema = z.object({
name: z.string().optional(),
command: z
.string({ required_error: "Command field is required" })
.min(1, "Command cannot be empty"),
args: z.array(z.string()).optional().default([]),
env: z.record(z.string(), z.string()).optional().default({}),
transport: z.enum(["stdio", "sse"]).optional().default("stdio"),
});
/**
* Shared MCP configuration validation result
*/
export interface MCPConfigValidationResult {
config: MCPServerConfig | null;
error: string | null;
}
/**
* Shared MCP connection test result
*/
export interface MCPConnectionTestResult {
status: "success" | "error";
message: string;
tools: MCPToolDefinition[];
}
/**
* Cache for parsed configurations to avoid re-parsing
* Key: JSON string, Value: { config, timestamp }
*/
const configCache = new Map<string, { config: MCPServerConfig; timestamp: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
/**
* Clear expired entries from config cache
*/
const clearExpiredCache = () => {
const now = Date.now();
for (const [key, value] of configCache.entries()) {
if (now - value.timestamp > CACHE_TTL) {
configCache.delete(key);
}
}
};
/**
* Parse and validate MCP server configuration from JSON string
* Uses Zod for schema validation and caching to avoid re-parsing
* @param configJson - JSON string containing MCP server configuration
* @returns Validation result with parsed config or error message
*/
export const parseMCPConfig = (configJson: string): MCPConfigValidationResult => {
// Check cache first
const cached = configCache.get(configJson);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
console.log('[MCP Validator] ✅ Using cached config');
return { config: cached.config, error: null };
}
console.log('[MCP Validator] 🔍 Parsing new config...');
// Clean up expired cache entries periodically
if (configCache.size > 100) {
clearExpiredCache();
}
try {
const parsed = JSON.parse(configJson);
// Validate that it's an object, not an array
if (Array.isArray(parsed)) {
console.error('[MCP Validator] ❌ Error: Config is an array, expected object');
return {
config: null,
error: "Please provide a single server configuration object, not an array",
};
}
// Use Zod schema validation for robust type checking
const result = MCPServerConfigSchema.safeParse(parsed);
if (!result.success) {
// Format Zod validation errors for user-friendly display
const firstError = result.error.issues[0];
const fieldPath = firstError.path.join(".");
// Clean up error message - remove technical Zod jargon
let errorMsg = firstError.message;
// Replace technical error messages with user-friendly ones
if (errorMsg.includes("expected string, received undefined")) {
errorMsg = "This field is required";
} else if (errorMsg.includes("Invalid input")) {
errorMsg = "Invalid value";
}
const formattedError = fieldPath ? `${fieldPath}: ${errorMsg}` : errorMsg;
console.error('[MCP Validator] ❌ Validation error:', formattedError);
console.error('[MCP Validator] Full Zod errors:', result.error.issues);
return {
config: null,
error: formattedError,
};
}
const config: MCPServerConfig = {
command: result.data.command,
args: result.data.args,
env: result.data.env,
transport: result.data.transport,
};
// Cache the successfully parsed config
configCache.set(configJson, {
config,
timestamp: Date.now(),
});
console.log('[MCP Validator] ✅ Config parsed successfully:', config);
return {
config,
error: null,
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : "Invalid JSON";
console.error('[MCP Validator] ❌ JSON parse error:', errorMsg);
return {
config: null,
error: errorMsg,
};
}
};
/**
* Test connection to MCP server
* @param serverConfig - MCP server configuration to test
* @returns Connection test result with status, message, and available tools
*/
export const testMCPConnection = async (
serverConfig: MCPServerConfig
): Promise<MCPConnectionTestResult> => {
try {
const result = await connectorsApiService.testMCPConnection(serverConfig);
if (result.status === "success") {
return {
status: "success",
message: `Successfully connected. Found ${result.tools.length} tool${result.tools.length !== 1 ? "s" : ""}.`,
tools: result.tools,
};
}
return {
status: "error",
message: result.message || "Failed to connect",
tools: [],
};
} catch (error) {
return {
status: "error",
message: error instanceof Error ? error.message : "Failed to connect",
tools: [],
};
}
};
/**
* Extract server name from MCP config JSON with caching
* @param configJson - JSON string containing MCP server configuration
* @returns Server name if found, otherwise default name
*/
export const extractServerName = (configJson: string): string => {
try {
const parsed = JSON.parse(configJson);
// Use Zod to validate and extract name field safely
const nameSchema = z.object({ name: z.string().optional() });
const result = nameSchema.safeParse(parsed);
if (result.success && result.data.name) {
return result.data.name;
}
} catch {
// Return default if parsing fails
}
return "MCP Server";
};
/**
* Clear the configuration cache
* Useful for testing or when memory management is needed
*/
export const clearConfigCache = () => {
configCache.clear();
};
/**
* Get cache statistics for monitoring/debugging
*/
export const getConfigCacheStats = () => {
return {
size: configCache.size,
entries: Array.from(configCache.entries()).map(([key, value]) => ({
configPreview: key.substring(0, 50) + (key.length > 50 ? "..." : ""),
timestamp: new Date(value.timestamp).toISOString(),
age: Date.now() - value.timestamp,
})),
};
};

View file

@ -6,7 +6,6 @@ import type { FC } from "react";
import { Button } from "@/components/ui/button";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { cn } from "@/lib/utils";
import { useConnectorStatus } from "../hooks/use-connector-status";
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
@ -16,13 +15,20 @@ interface ConnectorAccountsListViewProps {
connectorTitle: string;
connectors: SearchSourceConnector[];
indexingConnectorIds: Set<number>;
logsSummary: LogSummary | undefined;
onBack: () => void;
onManage: (connector: SearchSourceConnector) => void;
onAddAccount: () => void;
isConnecting?: boolean;
}
/**
* Check if a connector type is indexable
*/
function isIndexableConnector(connectorType: string): boolean {
const nonIndexableTypes = ["MCP_CONNECTOR"];
return !nonIndexableTypes.includes(connectorType);
}
/**
* Format last indexed date with contextual messages
*/
@ -60,7 +66,6 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
connectorTitle,
connectors,
indexingConnectorIds,
logsSummary,
onBack,
onManage,
onAddAccount,
@ -125,7 +130,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
)}
</div>
<span className="text-[11px] sm:text-[12px] font-medium">
{isConnecting ? "Connecting..." : "Add Account"}
{isConnecting ? "Connecting" : "Add Account"}
</span>
</button>
</div>
@ -137,18 +142,15 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{typeConnectors.map((connector) => {
const isIndexing = indexingConnectorIds.has(connector.id);
const activeTask = logsSummary?.active_tasks?.find(
(task: LogActiveTask) => task.connector_id === connector.id
);
return (
<div
key={connector.id}
className={cn(
"flex items-center gap-4 p-4 rounded-xl border border-border transition-all",
"flex items-center gap-4 p-4 rounded-xl transition-all",
isIndexing
? "bg-primary/5 border-primary/20"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
? "bg-primary/5 border-0"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10 border border-border"
)}
>
<div
@ -168,18 +170,15 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
{isIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
Indexing...
{activeTask?.message && (
<span className="text-muted-foreground truncate max-w-[100px]">
{activeTask.message}
</span>
)}
Syncing
</p>
) : (
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap truncate">
{connector.last_indexed_at
? `Last indexed: ${formatLastIndexedDate(connector.last_indexed_at)}`
: "Never indexed"}
{isIndexableConnector(connector.connector_type)
? connector.last_indexed_at
? `Last indexed: ${formatLastIndexedDate(connector.last_indexed_at)}`
: "Never indexed"
: "Active"}
</p>
)}
</div>

View file

@ -0,0 +1,145 @@
"use client";
import { Plus, Server, XCircle } from "lucide-react";
import type { FC } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { cn } from "@/lib/utils";
interface MCPConnectorListViewProps {
mcpConnectors: SearchSourceConnector[];
onAddNew: () => void;
onManageConnector: (connector: SearchSourceConnector) => void;
onBack: () => void;
}
export const MCPConnectorListView: FC<MCPConnectorListViewProps> = ({
mcpConnectors,
onAddNew,
onManageConnector,
onBack,
}) => {
// Validate that all connectors are MCP connectors
const invalidConnectors = mcpConnectors.filter(
(c) => c.connector_type !== EnumConnectorName.MCP_CONNECTOR
);
if (invalidConnectors.length > 0) {
console.error(
"MCPConnectorListView received non-MCP connectors:",
invalidConnectors.map((c) => c.connector_type)
);
return (
<Alert className="border-red-500/50 bg-red-500/10">
<XCircle className="h-4 w-4 text-red-600" />
<AlertTitle>Invalid Connector Type</AlertTitle>
<AlertDescription>
This view can only display MCP connectors. Found {invalidConnectors.length} invalid
connector(s).
</AlertDescription>
</Alert>
);
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between mb-6 shrink-0">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="h-8 w-8"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m15 18-6-6 6-6" />
</svg>
</Button>
<div>
<h2 className="text-lg sm:text-xl font-semibold">MCP Connectors</h2>
<p className="text-xs sm:text-sm text-muted-foreground">
Manage your Model Context Protocol servers
</p>
</div>
</div>
</div>
{/* Add New Button */}
<div className="mb-4 shrink-0">
<Button
onClick={onAddNew}
className="w-full"
variant="outline"
>
<Plus className="h-4 w-4 mr-2" />
Add New MCP Server
</Button>
</div>
{/* MCP Connectors List */}
<div className="space-y-3 flex-1 overflow-y-auto">
{mcpConnectors.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="h-16 w-16 rounded-full bg-slate-400/5 dark:bg-white/5 flex items-center justify-center mb-4">
<Server className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-sm font-medium mb-1">No MCP Servers</h3>
<p className="text-xs text-muted-foreground max-w-[280px]">
Get started by adding your first Model Context Protocol server
</p>
</div>
) : (
mcpConnectors.map((connector) => {
// Extract server name from config
const serverName = connector.config?.server_config?.name || connector.name;
return (
<div
key={connector.id}
className={cn(
"flex items-center gap-4 p-4 rounded-xl border border-border transition-all",
"bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
)}
>
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-lg border shrink-0",
"bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
)}
>
{getConnectorIcon("MCP_CONNECTOR", "size-6")}
</div>
<div className="flex-1 min-w-0">
<p className="text-[14px] font-semibold leading-tight truncate">
{serverName}
</p>
</div>
<Button
variant="secondary"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80 shrink-0"
onClick={() => onManageConnector(connector)}
>
Manage
</Button>
</div>
);
})
)}
</div>
</div>
);
};

View file

@ -2,7 +2,6 @@
import { useAtomValue } from "jotai";
import { Upload } from "lucide-react";
import { useRouter } from "next/navigation";
import {
createContext,
type FC,
@ -85,13 +84,11 @@ const DocumentUploadPopupContent: FC<{
onOpenChange: (open: boolean) => void;
}> = ({ isOpen, onOpenChange }) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const router = useRouter();
if (!searchSpaceId) return null;
const handleSuccess = () => {
onOpenChange(false);
router.push(`/dashboard/${searchSpaceId}/documents`);
};
return (

View file

@ -12,6 +12,7 @@ import {
} from "react";
import ReactDOMServer from "react-dom/server";
import type { Document } from "@/contracts/types/document.types";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { cn } from "@/lib/utils";
export interface MentionedDocument {
@ -166,12 +167,19 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
chip.setAttribute(CHIP_DOCTYPE_ATTR, doc.document_type ?? "UNKNOWN");
chip.contentEditable = "false";
chip.className =
"inline-flex items-center gap-0.5 mx-0.5 pl-1 pr-0.5 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary border border-primary/10 select-none";
"inline-flex items-center gap-1 mx-0.5 pl-1 pr-0.5 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary/60 select-none";
chip.style.userSelect = "none";
chip.style.verticalAlign = "baseline";
// Add document type icon
const iconSpan = document.createElement("span");
iconSpan.className = "shrink-0 flex items-center text-muted-foreground";
iconSpan.innerHTML = ReactDOMServer.renderToString(
getConnectorIcon(doc.document_type ?? "UNKNOWN", "h-3 w-3")
);
const titleSpan = document.createElement("span");
titleSpan.className = "max-w-[80px] truncate";
titleSpan.className = "max-w-[120px] truncate";
titleSpan.textContent = doc.title;
titleSpan.title = doc.title;
@ -197,6 +205,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
focusAtEnd();
};
chip.appendChild(iconSpan);
chip.appendChild(titleSpan);
chip.appendChild(removeBtn);

View file

@ -1,71 +0,0 @@
import { useAtomValue } from "jotai";
import type { FC } from "react";
import { useMemo } from "react";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Composer } from "@/components/assistant-ui/composer";
const getTimeBasedGreeting = (userEmail?: string): string => {
const hour = new Date().getHours();
// Extract first name from email if available
const firstName = userEmail
? userEmail.split("@")[0].split(".")[0].charAt(0).toUpperCase() +
userEmail.split("@")[0].split(".")[0].slice(1)
: null;
// Array of greeting variations for each time period
const morningGreetings = ["Good morning", "Fresh start today", "Morning", "Hey there"];
const afternoonGreetings = ["Good afternoon", "Afternoon", "Hey there", "Hi there"];
const eveningGreetings = ["Good evening", "Evening", "Hey there", "Hi there"];
const nightGreetings = ["Good night", "Evening", "Hey there", "Winding down"];
const lateNightGreetings = ["Still up", "Night owl mode", "Up past bedtime", "Hi there"];
// Select a random greeting based on time
let greeting: string;
if (hour < 5) {
// Late night: midnight to 5 AM
greeting = lateNightGreetings[Math.floor(Math.random() * lateNightGreetings.length)];
} else if (hour < 12) {
greeting = morningGreetings[Math.floor(Math.random() * morningGreetings.length)];
} else if (hour < 18) {
greeting = afternoonGreetings[Math.floor(Math.random() * afternoonGreetings.length)];
} else if (hour < 22) {
greeting = eveningGreetings[Math.floor(Math.random() * eveningGreetings.length)];
} else {
// Night: 10 PM to midnight
greeting = nightGreetings[Math.floor(Math.random() * nightGreetings.length)];
}
// Add personalization with first name if available
if (firstName) {
return `${greeting}, ${firstName}!`;
}
return `${greeting}!`;
};
export const ThreadWelcome: FC = () => {
const { data: user } = useAtomValue(currentUserAtom);
// Memoize greeting so it doesn't change on re-renders (only on user change)
const greeting = useMemo(() => getTimeBasedGreeting(user?.email), [user?.email]);
return (
<div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative">
{/* Greeting positioned above the composer - fixed position */}
<div className="aui-thread-welcome-message absolute bottom-[calc(50%+5rem)] left-0 right-0 flex flex-col items-center text-center">
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-2 animate-in text-3xl md:text-5xl delay-100 duration-500 ease-out fill-mode-both">
{greeting}
</h1>
</div>
{/* Composer - top edge fixed, expands downward only */}
<div className="fade-in slide-in-from-bottom-3 animate-in delay-200 duration-500 ease-out fill-mode-both w-full flex items-start justify-center absolute top-[calc(50%-3.5rem)] left-0 right-0">
<Composer />
</div>
</div>
);
};

View file

@ -132,14 +132,23 @@ const ThreadScrollToBottom: FC = () => {
);
};
const getTimeBasedGreeting = (userEmail?: string): string => {
const getTimeBasedGreeting = (user?: { display_name?: string | null; email?: string }): string => {
const hour = new Date().getHours();
// Extract first name from email if available
const firstName = userEmail
? userEmail.split("@")[0].split(".")[0].charAt(0).toUpperCase() +
userEmail.split("@")[0].split(".")[0].slice(1)
: null;
// Extract first name: prefer display_name, fall back to email extraction
let firstName: string | null = null;
if (user?.display_name?.trim()) {
// Use display_name if available and not empty
// Extract first name from display_name (take first word)
const nameParts = user.display_name.trim().split(/\s+/);
firstName = nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1).toLowerCase();
} else if (user?.email) {
// Fall back to email extraction if display_name is not available
firstName =
user.email.split("@")[0].split(".")[0].charAt(0).toUpperCase() +
user.email.split("@")[0].split(".")[0].slice(1);
}
// Array of greeting variations for each time period
const morningGreetings = ["Good morning", "Fresh start today", "Morning", "Hey there"];
@ -180,7 +189,7 @@ const ThreadWelcome: FC = () => {
const { data: user } = useAtomValue(currentUserAtom);
// Memoize greeting so it doesn't change on re-renders (only on user change)
const greeting = useMemo(() => getTimeBasedGreeting(user?.email), [user?.email]);
const greeting = useMemo(() => getTimeBasedGreeting(user), [user]);
return (
<div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative">
@ -199,7 +208,7 @@ const ThreadWelcome: FC = () => {
};
const Composer: FC = () => {
// ---- State for document mentions (using atoms to persist across remounts) ----
// Document mention state (atoms persist across component remounts)
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
@ -211,16 +220,12 @@ const Composer: FC = () => {
const composerRuntime = useComposerRuntime();
const hasAutoFocusedRef = useRef(false);
// Check if thread is empty (new chat)
const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty);
// Check if thread is currently running (streaming response)
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
// Auto-focus editor when on new chat page
// Auto-focus editor on new chat page after mount
useEffect(() => {
if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) {
// Small delay to ensure the editor is fully mounted
const timeoutId = setTimeout(() => {
editorRef.current?.focus();
hasAutoFocusedRef.current = true;
@ -229,7 +234,7 @@ const Composer: FC = () => {
}
}, [isThreadEmpty]);
// Sync mentioned document IDs to atom for use in chat request
// Sync mentioned document IDs to atom for inclusion in chat request payload
useEffect(() => {
setMentionedDocumentIds({
surfsense_doc_ids: mentionedDocuments
@ -241,7 +246,7 @@ const Composer: FC = () => {
});
}, [mentionedDocuments, setMentionedDocumentIds]);
// Handle text change from inline editor - sync with assistant-ui composer
// Sync editor text with assistant-ui composer runtime
const handleEditorChange = useCallback(
(text: string) => {
composerRuntime.setText(text);
@ -249,13 +254,13 @@ const Composer: FC = () => {
[composerRuntime]
);
// Handle @ mention trigger from inline editor
// Open document picker when @ mention is triggered
const handleMentionTrigger = useCallback((query: string) => {
setShowDocumentPopover(true);
setMentionQuery(query);
}, []);
// Handle mention close
// Close document picker and reset query
const handleMentionClose = useCallback(() => {
if (showDocumentPopover) {
setShowDocumentPopover(false);
@ -263,7 +268,7 @@ const Composer: FC = () => {
}
}, [showDocumentPopover]);
// Handle keyboard navigation when popover is open
// Keyboard navigation for document picker (arrow keys, Enter, Escape)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (showDocumentPopover) {
@ -293,15 +298,13 @@ const Composer: FC = () => {
[showDocumentPopover]
);
// Handle submit from inline editor (Enter key)
// Submit message (blocked during streaming or when document picker is open)
const handleSubmit = useCallback(() => {
// Prevent sending while a response is still streaming
if (isThreadRunning) {
return;
}
if (!showDocumentPopover) {
composerRuntime.send();
// Clear the editor after sending
editorRef.current?.clear();
setMentionedDocuments([]);
setMentionedDocumentIds({
@ -317,6 +320,7 @@ const Composer: FC = () => {
setMentionedDocumentIds,
]);
// Remove document from mentions and sync IDs to atom
const handleDocumentRemove = useCallback(
(docId: number, docType?: string) => {
setMentionedDocuments((prev) => {
@ -335,6 +339,7 @@ const Composer: FC = () => {
[setMentionedDocuments, setMentionedDocumentIds]
);
// Add selected documents from picker, insert chips, and sync IDs to atom
const handleDocumentsMention = useCallback(
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
@ -372,7 +377,7 @@ const Composer: FC = () => {
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
<ComposerAttachments />
{/* -------- Inline Mention Editor -------- */}
{/* Inline editor with @mention support */}
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-3 pt-3 pb-6">
<InlineMentionEditor
ref={editorRef}
@ -387,45 +392,29 @@ const Composer: FC = () => {
/>
</div>
{/* -------- Document mention popover (rendered via portal) -------- */}
{/* Document picker popover (portal to body for proper z-index stacking) */}
{showDocumentPopover &&
typeof document !== "undefined" &&
createPortal(
<>
{/* Backdrop */}
<button
type="button"
className="fixed inset-0 cursor-default"
style={{ zIndex: 9998 }}
onClick={() => setShowDocumentPopover(false)}
aria-label="Close document picker"
/>
{/* Popover positioned above input */}
<div
className="fixed shadow-2xl rounded-lg border border-border overflow-hidden bg-popover"
style={{
zIndex: 9999,
bottom: editorContainerRef.current
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px`
: "200px",
left: editorContainerRef.current
? `${editorContainerRef.current.getBoundingClientRect().left}px`
: "50%",
}}
>
<DocumentMentionPicker
ref={documentPickerRef}
searchSpaceId={Number(search_space_id)}
onSelectionChange={handleDocumentsMention}
onDone={() => {
setShowDocumentPopover(false);
setMentionQuery("");
}}
initialSelectedDocuments={mentionedDocuments}
externalSearch={mentionQuery}
/>
</div>
</>,
<DocumentMentionPicker
ref={documentPickerRef}
searchSpaceId={Number(search_space_id)}
onSelectionChange={handleDocumentsMention}
onDone={() => {
setShowDocumentPopover(false);
setMentionQuery("");
}}
initialSelectedDocuments={mentionedDocuments}
externalSearch={mentionQuery}
containerStyle={{
bottom: editorContainerRef.current
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px`
: "200px",
left: editorContainerRef.current
? `${editorContainerRef.current.getBoundingClientRect().left}px`
: "50%",
}}
/>,
document.body
)}
<ComposerAction />

View file

@ -27,12 +27,7 @@ export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButton
<span className="aui-sr-only sr-only">{tooltip}</span>
</Button>
</TooltipTrigger>
<TooltipContent
side={side}
className="bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none"
>
{tooltip}
</TooltipContent>
<TooltipContent side={side}>{tooltip}</TooltipContent>
</Tooltip>
);
}

View file

@ -5,13 +5,13 @@ import {
ChevronRight,
File,
FileText,
Folder,
FolderClosed,
FolderOpen,
HardDrive,
Image,
Loader2,
Presentation,
Sheet,
FileSpreadsheet,
} from "lucide-react";
import { useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
@ -53,16 +53,16 @@ interface GoogleDriveFolderTreeProps {
// Helper to get appropriate icon for file type
function getFileIcon(mimeType: string, className: string = "h-4 w-4") {
if (mimeType.includes("spreadsheet") || mimeType.includes("excel")) {
return <Sheet className={`${className} text-green-600`} />;
return <FileSpreadsheet className={`${className} text-green-500`} />;
}
if (mimeType.includes("presentation") || mimeType.includes("powerpoint")) {
return <Presentation className={`${className} text-orange-600`} />;
return <Presentation className={`${className} text-orange-500`} />;
}
if (mimeType.includes("document") || mimeType.includes("word") || mimeType.includes("text")) {
return <FileText className={`${className} text-blue-600`} />;
return <FileText className={`${className} text-gray-500`} />;
}
if (mimeType.includes("image")) {
return <Image className={`${className} text-purple-600`} />;
return <Image className={`${className} text-purple-500`} />;
}
return <File className={`${className} text-gray-500`} />;
}
@ -280,9 +280,9 @@ export function GoogleDriveFolderTree({
<div className="shrink-0">
{isFolder ? (
isExpanded ? (
<FolderOpen className="h-3 w-3 sm:h-4 sm:w-4 text-blue-500" />
<FolderOpen className="h-3 w-3 sm:h-4 sm:w-4 text-gray-500" />
) : (
<Folder className="h-3 w-3 sm:h-4 sm:w-4 text-gray-500" />
<FolderClosed className="h-3 w-3 sm:h-4 sm:w-4 text-gray-500" />
)
) : (
getFileIcon(item.mimeType, "h-3 w-3 sm:h-4 sm:w-4")

View file

@ -184,7 +184,7 @@ function GetStartedButton() {
return (
<motion.div whileHover={{ scale: 1.02, y: -2 }} whileTap={{ scale: 0.98 }}>
<Link
href="/login"
href="/register"
className="group relative z-20 flex h-11 w-full cursor-pointer items-center justify-center gap-2 rounded-xl bg-black px-6 py-2.5 text-sm font-semibold text-white shadow-lg transition-shadow duration-300 hover:shadow-xl sm:w-56 dark:bg-white dark:text-black"
>
Get Started

View file

@ -21,6 +21,7 @@ import {
} from "@/components/ui/dialog";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
import { cleanupElectric } from "@/lib/electric/client";
import { resetUser, trackLogout } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types";
@ -278,10 +279,19 @@ export function LayoutDataProvider({
router.push(`/dashboard/${searchSpaceId}/team`);
}, [router, searchSpaceId]);
const handleLogout = useCallback(() => {
const handleLogout = useCallback(async () => {
try {
trackLogout();
resetUser();
// Best-effort cleanup of Electric SQL / PGlite
// Even if this fails, login-time cleanup will handle it
try {
await cleanupElectric();
} catch (err) {
console.warn("[Logout] Electric cleanup failed (will be handled on next login):", err);
}
if (typeof window !== "undefined") {
localStorage.removeItem("surfsense_bearer_token");
router.push("/");

View file

@ -3,6 +3,7 @@
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { NotificationButton } from "@/components/notifications/NotificationButton";
interface HeaderProps {
breadcrumb?: React.ReactNode;
@ -29,6 +30,9 @@ export function Header({
{/* Right side - Actions */}
<div className="flex items-center gap-2">
{/* Notifications */}
<NotificationButton />
{/* Theme toggle */}
{onToggleTheme && (
<Tooltip>

View file

@ -34,7 +34,6 @@ import {
deleteThread,
fetchThreads,
searchThreads,
type ThreadListItem,
updateThread,
} from "@/lib/chat/thread-persistence";
import { cn } from "@/lib/utils";
@ -410,7 +409,7 @@ export function AllPrivateChatsSidebar({
</div>
) : isSearchMode ? (
<div className="text-center py-8">
<Search className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
{t("no_chats_found") || "No chats found"}
</p>
@ -420,7 +419,7 @@ export function AllPrivateChatsSidebar({
</div>
) : (
<div className="text-center py-8">
<Lock className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
<Lock className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
{showArchived
? t("no_archived_chats") || "No archived chats"

View file

@ -34,7 +34,6 @@ import {
deleteThread,
fetchThreads,
searchThreads,
type ThreadListItem,
updateThread,
} from "@/lib/chat/thread-persistence";
import { cn } from "@/lib/utils";
@ -410,7 +409,7 @@ export function AllSharedChatsSidebar({
</div>
) : isSearchMode ? (
<div className="text-center py-8">
<Search className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
{t("no_chats_found") || "No chats found"}
</p>
@ -420,7 +419,7 @@ export function AllSharedChatsSidebar({
</div>
) : (
<div className="text-center py-8">
<Users className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
<Users className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
{showArchived
? t("no_archived_chats") || "No archived chats"

View file

@ -156,10 +156,10 @@ export function Sidebar({
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
onClick={onViewAllSharedChats}
>
<FolderOpen className="h-3.5 w-3.5" />
<FolderOpen className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
@ -197,10 +197,10 @@ export function Sidebar({
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
onClick={onViewAllPrivateChats}
>
<FolderOpen className="h-3.5 w-3.5" />
<FolderOpen className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">

View file

@ -37,14 +37,14 @@ export function SidebarSection({
{/* Action button - visible on hover (always visible on mobile) */}
{action && (
<div className="shrink-0 opacity-0 group-hover/section:opacity-100 transition-opacity pr-1 flex items-center gap-0.5">
<div className="shrink-0 opacity-0 group-hover/section:opacity-100 transition-opacity pr-1 flex items-center">
{action}
</div>
)}
{/* Persistent action - always visible */}
{persistentAction && (
<div className="shrink-0 pr-1 flex items-center gap-0.5">{persistentAction}</div>
<div className="shrink-0 pr-1 flex items-center">{persistentAction}</div>
)}
</div>

View file

@ -1,7 +1,7 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { Globe, Loader2, Lock, Share2, Users } from "lucide-react";
import { Loader2, Lock, Users } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@ -92,8 +92,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
variant="ghost"
size="sm"
className={cn(
"h-7 md:h-9 gap-1.5 md:gap-2 px-2 md:px-3 rounded-lg md:rounded-xl",
"border border-border/80 bg-background/50 backdrop-blur-sm",
"h-7 md:h-9 gap-1 md:gap-2 px-2 md:px-3 rounded-lg md:rounded-xl border border-border/80 bg-background/50 backdrop-blur-sm",
"hover:bg-muted/80 hover:border-border/30 transition-all duration-200",
"text-xs md:text-sm font-medium text-foreground",
"focus-visible:ring-0 focus-visible:ring-offset-0",
@ -104,7 +103,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
<span className="hidden md:inline">
{currentVisibility === "PRIVATE" ? "Private" : "Shared"}
</span>
<Share2 className="size-3 md:size-3.5 text-muted-foreground" />
</Button>
</PopoverTrigger>
@ -113,25 +111,13 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
align="end"
sideOffset={8}
>
<div className="p-3 md:p-4 border-b border-border/30">
<div className="flex items-center gap-2">
<Share2 className="size-4 md:size-5 text-primary" />
<div>
<h4 className="text-sm font-semibold">Share Chat</h4>
<p className="text-xs text-muted-foreground">
Control who can access this conversation
</p>
</div>
</div>
</div>
<div className="p-1.5 space-y-1">
{/* Updating overlay */}
{isUpdating && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-xl">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Updating...</span>
<span>Updating</span>
</div>
</div>
)}
@ -149,8 +135,8 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
className={cn(
"w-full flex items-start gap-2.5 px-2.5 py-2 rounded-md transition-all",
"hover:bg-accent/50 cursor-pointer",
"focus:outline-none focus:ring-2 focus:ring-primary/20",
isSelected && "bg-accent/80 ring-1 ring-primary/20"
"focus:outline-none",
isSelected && "bg-accent/80"
)}
>
<div
@ -185,18 +171,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
);
})}
</div>
{/* Info footer */}
<div className="p-3 bg-muted/30 border-t border-border/30 rounded-b-xl">
<div className="flex items-start gap-2">
<Globe className="size-3.5 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-[11px] text-muted-foreground leading-relaxed">
{currentVisibility === "PRIVATE"
? "This chat is private. Only you can view and interact with it."
: "This chat is shared. All search space members can view, continue the conversation, and delete it."}
</p>
</div>
</div>
</PopoverContent>
</Popover>
);

View file

@ -1,7 +1,6 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { FileText } from "lucide-react";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import {
forwardRef,
useCallback,
@ -12,9 +11,8 @@ import {
useState,
} from "react";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { Document, GetDocumentsResponse } from "@/contracts/types/document.types";
import type { Document, SearchDocumentTitlesResponse } from "@/contracts/types/document.types";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
export interface DocumentMentionPickerRef {
@ -29,16 +27,39 @@ interface DocumentMentionPickerProps {
onDone: () => void;
initialSelectedDocuments?: Pick<Document, "id" | "title" | "document_type">[];
externalSearch?: string;
/** Positioning styles for the container */
containerStyle?: React.CSSProperties;
}
const PAGE_SIZE = 20;
const MIN_SEARCH_LENGTH = 2;
const DEBOUNCE_MS = 100;
function useDebounced<T>(value: T, delay = 300) {
/**
* Custom debounce hook that delays value updates until user input stabilizes.
* Preferred over throttling for search inputs as it reduces API request frequency
* and prevents race conditions from stale responses overtaking recent ones.
*/
function useDebounced<T>(value: T, delay = DEBOUNCE_MS) {
const [debounced, setDebounced] = useState(value);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(t);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setDebounced(value);
}, delay);
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [value, delay]);
return debounced;
}
@ -46,17 +67,27 @@ export const DocumentMentionPicker = forwardRef<
DocumentMentionPickerRef,
DocumentMentionPickerProps
>(function DocumentMentionPicker(
{ searchSpaceId, onSelectionChange, onDone, initialSelectedDocuments = [], externalSearch = "" },
{
searchSpaceId,
onSelectionChange,
onDone,
initialSelectedDocuments = [],
externalSearch = "",
containerStyle,
},
ref
) {
// Use external search
const queryClient = useQueryClient();
// Debounced search value to minimize API calls and prevent race conditions
const search = externalSearch;
const debouncedSearch = useDebounced(search, 150);
const debouncedSearch = useDebounced(search, DEBOUNCE_MS);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
const scrollContainerRef = useRef<HTMLDivElement>(null);
const shouldScrollRef = useRef(false); // Keyboard navigation scroll flag
// State for pagination
// Pagination state for infinite scroll
const [accumulatedDocuments, setAccumulatedDocuments] = useState<
Pick<Document, "id" | "title" | "document_type">[]
>([]);
@ -64,74 +95,119 @@ export const DocumentMentionPicker = forwardRef<
const [hasMore, setHasMore] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
// Reset pagination when search or search space changes
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally reset pagination when search/space changes
/**
* Search Strategy:
* - Single character (length === 1): Client-side filtering for instant results
* - Two or more characters (length >= 2): Server-side search with pg_trgm index
* This hybrid approach optimizes UX by providing immediate feedback for short queries
* while leveraging efficient database indexing for longer, more specific searches.
*/
const isSearchValid = debouncedSearch.trim().length >= MIN_SEARCH_LENGTH;
const shouldSearch = debouncedSearch.trim().length > 0;
const isSingleCharSearch = debouncedSearch.trim().length === 1;
// Prefetch initial data on mount for instant display when picker opens
useEffect(() => {
if (!searchSpaceId) return;
const prefetchParams = {
search_space_id: searchSpaceId,
page: 0,
page_size: PAGE_SIZE,
};
queryClient.prefetchQuery({
queryKey: ["document-titles", prefetchParams],
queryFn: () => documentsApiService.searchDocumentTitles({ queryParams: prefetchParams }),
staleTime: 60 * 1000,
});
queryClient.prefetchQuery({
queryKey: ["surfsense-docs-mention", "", false],
queryFn: () =>
documentsApiService.getSurfsenseDocs({
queryParams: { page: 0, page_size: PAGE_SIZE },
}),
staleTime: 3 * 60 * 1000,
});
}, [searchSpaceId, queryClient]);
// Reset pagination state when search query or search space changes.
// Documents are not cleared to maintain visual continuity during fetches.
// biome-ignore lint/correctness/useExhaustiveDependencies: Intentional reset on search/space change
useEffect(() => {
setAccumulatedDocuments([]);
setCurrentPage(0);
setHasMore(false);
setHighlightedIndex(0);
}, [debouncedSearch, searchSpaceId]);
// Query params for initial fetch (page 0)
const fetchQueryParams = useMemo(
// Query parameters for lightweight title search endpoint
const titleSearchParams = useMemo(
() => ({
search_space_id: searchSpaceId,
page: 0,
page_size: PAGE_SIZE,
...(isSearchValid ? { title: debouncedSearch.trim() } : {}),
}),
[searchSpaceId]
[searchSpaceId, debouncedSearch, isSearchValid]
);
const searchQueryParams = useMemo(() => {
return {
search_space_id: searchSpaceId,
page: 0,
page_size: PAGE_SIZE,
title: debouncedSearch,
};
}, [debouncedSearch, searchSpaceId]);
const surfsenseDocsQueryParams = useMemo(() => {
const params: { page: number; page_size: number; title?: string } = {
page: 0,
page_size: PAGE_SIZE,
};
if (debouncedSearch.trim()) {
params.title = debouncedSearch;
if (isSearchValid) {
params.title = debouncedSearch.trim();
}
return params;
}, [debouncedSearch]);
}, [debouncedSearch, isSearchValid]);
// Use query for fetching first page of documents
const { data: documents, isLoading: isDocumentsLoading } = useQuery({
queryKey: cacheKeys.documents.withQueryParams(fetchQueryParams),
queryFn: () => documentsApiService.getDocuments({ queryParams: fetchQueryParams }),
staleTime: 3 * 60 * 1000,
enabled: !!searchSpaceId && !debouncedSearch.trim() && currentPage === 0,
/**
* TanStack Query for document title search.
* - Uses AbortSignal for automatic request cancellation on query key changes
* - placeholderData: keepPreviousData maintains UI stability during fetches
* - Only triggers server-side search when isSearchValid (2+ characters)
*/
const { data: titleSearchResults, isLoading: isTitleSearchLoading } = useQuery({
queryKey: ["document-titles", titleSearchParams],
queryFn: ({ signal }) =>
documentsApiService.searchDocumentTitles({ queryParams: titleSearchParams }, signal),
staleTime: 60 * 1000,
enabled: !!searchSpaceId && currentPage === 0 && (!shouldSearch || isSearchValid),
placeholderData: keepPreviousData,
});
// Searching - first page
const { data: searchedDocuments, isLoading: isSearchedDocumentsLoading } = useQuery({
queryKey: cacheKeys.documents.withQueryParams(searchQueryParams),
queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }),
staleTime: 3 * 60 * 1000,
enabled: !!searchSpaceId && !!debouncedSearch.trim() && currentPage === 0,
});
// Use query for fetching first page of SurfSense docs
/**
* TanStack Query for SurfSense documentation.
* - Uses AbortSignal for automatic request cancellation
* - placeholderData: keepPreviousData prevents UI flicker during refetches
*/
const { data: surfsenseDocs, isLoading: isSurfsenseDocsLoading } = useQuery({
queryKey: ["surfsense-docs-mention", debouncedSearch],
queryFn: () => documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }),
queryKey: ["surfsense-docs-mention", debouncedSearch, isSearchValid],
queryFn: ({ signal }) =>
documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }, signal),
staleTime: 3 * 60 * 1000,
enabled: !shouldSearch || isSearchValid,
placeholderData: keepPreviousData,
});
// Update accumulated documents when first page loads - combine both sources
// Post-fetch filter to eliminate false positives from backend fuzzy matching
const filterBySearchTerm = useCallback(
(docs: Pick<Document, "id" | "title" | "document_type">[]) => {
if (!isSearchValid) return docs; // No filtering when not searching
const searchLower = debouncedSearch.trim().toLowerCase();
return docs.filter((doc) => doc.title.toLowerCase().includes(searchLower));
},
[debouncedSearch, isSearchValid]
);
// Combine and update document list when first page data arrives
useEffect(() => {
if (currentPage === 0) {
const combinedDocs: Pick<Document, "id" | "title" | "document_type">[] = [];
// Add SurfSense docs first (they appear at top)
// SurfSense docs displayed first in the list
if (surfsenseDocs?.items) {
for (const doc of surfsenseDocs.items) {
combinedDocs.push({
@ -142,24 +218,16 @@ export const DocumentMentionPicker = forwardRef<
}
}
// Add regular documents
if (debouncedSearch.trim()) {
if (searchedDocuments?.items) {
combinedDocs.push(...searchedDocuments.items);
setHasMore(searchedDocuments.has_more);
}
} else {
if (documents?.items) {
combinedDocs.push(...documents.items);
setHasMore(documents.has_more);
}
if (titleSearchResults?.items) {
combinedDocs.push(...titleSearchResults.items);
setHasMore(titleSearchResults.has_more);
}
setAccumulatedDocuments(combinedDocs);
setAccumulatedDocuments(filterBySearchTerm(combinedDocs));
}
}, [documents, searchedDocuments, surfsenseDocs, debouncedSearch, currentPage]);
}, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm]);
// Function to load next page
// Load next page for infinite scroll pagination
const loadNextPage = useCallback(async () => {
if (isLoadingMore || !hasMore) return;
@ -167,23 +235,15 @@ export const DocumentMentionPicker = forwardRef<
setIsLoadingMore(true);
try {
let response: GetDocumentsResponse;
if (debouncedSearch.trim()) {
const queryParams = {
search_space_id: searchSpaceId,
page: nextPage,
page_size: PAGE_SIZE,
title: debouncedSearch,
};
response = await documentsApiService.searchDocuments({ queryParams });
} else {
const queryParams = {
search_space_id: searchSpaceId,
page: nextPage,
page_size: PAGE_SIZE,
};
response = await documentsApiService.getDocuments({ queryParams });
}
const queryParams = {
search_space_id: searchSpaceId,
page: nextPage,
page_size: PAGE_SIZE,
...(isSearchValid ? { title: debouncedSearch.trim() } : {}),
};
const response: SearchDocumentTitlesResponse = await documentsApiService.searchDocumentTitles(
{ queryParams }
);
setAccumulatedDocuments((prev) => [...prev, ...response.items]);
setHasMore(response.has_more);
@ -193,15 +253,14 @@ export const DocumentMentionPicker = forwardRef<
} finally {
setIsLoadingMore(false);
}
}, [currentPage, hasMore, isLoadingMore, debouncedSearch, searchSpaceId]);
}, [currentPage, hasMore, isLoadingMore, debouncedSearch, searchSpaceId, isSearchValid]);
// Infinite scroll handler
// Trigger pagination when user scrolls near the bottom (50px threshold)
const handleScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
// Load more when within 50px of bottom
if (scrollBottom < 50 && hasMore && !isLoadingMore) {
loadNextPage();
}
@ -209,13 +268,26 @@ export const DocumentMentionPicker = forwardRef<
[hasMore, isLoadingMore, loadNextPage]
);
const actualDocuments = accumulatedDocuments;
const actualLoading =
((debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) ||
isSurfsenseDocsLoading) &&
currentPage === 0;
/**
* Client-side filtering for single character searches.
* Filters cached documents locally for instant feedback without additional API calls.
* Server-side search is reserved for 2+ character queries to leverage database indexing.
*/
const clientFilteredDocs = useMemo(() => {
if (!isSingleCharSearch) return null;
const searchLower = debouncedSearch.trim().toLowerCase();
return accumulatedDocuments.filter((doc) => doc.title.toLowerCase().includes(searchLower));
}, [isSingleCharSearch, debouncedSearch, accumulatedDocuments]);
// Split documents into SurfSense docs and user docs for grouped rendering
// Select data source based on search length: client-filtered for single char, server results for 2+
const actualDocuments = isSingleCharSearch ? (clientFilteredDocs ?? []) : accumulatedDocuments;
// Only show loading spinner on initial load (no documents yet), not during subsequent searches
const actualLoading =
(isTitleSearchLoading || isSurfsenseDocsLoading) &&
currentPage === 0 &&
!isSingleCharSearch &&
accumulatedDocuments.length === 0;
// Partition documents by type for grouped UI rendering
const surfsenseDocsList = useMemo(
() => actualDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS"),
[actualDocuments]
@ -225,13 +297,13 @@ export const DocumentMentionPicker = forwardRef<
[actualDocuments]
);
// Track already selected documents using unique key (document_type:id) to avoid ID collisions
// Track selected documents with composite key (document_type:id) to prevent cross-type ID collisions
const selectedKeys = useMemo(
() => new Set(initialSelectedDocuments.map((d) => `${d.document_type}:${d.id}`)),
[initialSelectedDocuments]
);
// Filter out already selected documents for navigation
// Exclude already-selected documents from keyboard navigation
const selectableDocuments = useMemo(
() => actualDocuments.filter((doc) => !selectedKeys.has(`${doc.document_type}:${doc.id}`)),
[actualDocuments, selectedKeys]
@ -245,15 +317,44 @@ export const DocumentMentionPicker = forwardRef<
[initialSelectedDocuments, onSelectionChange, onDone]
);
// Scroll highlighted item into view
// Auto-scroll highlighted item into view (keyboard navigation only, not mouse hover)
useEffect(() => {
const item = itemRefs.current.get(highlightedIndex);
if (item) {
item.scrollIntoView({ block: "nearest", behavior: "smooth" });
if (!shouldScrollRef.current) {
return;
}
shouldScrollRef.current = false;
const rafId = requestAnimationFrame(() => {
const item = itemRefs.current.get(highlightedIndex);
const container = scrollContainerRef.current;
if (item && container) {
const itemRect = item.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const padding = 8;
const isAboveViewport = itemRect.top < containerRect.top + padding;
const isBelowViewport = itemRect.bottom > containerRect.bottom - padding;
if (isAboveViewport || isBelowViewport) {
const itemOffsetTop = item.offsetTop;
const containerHeight = container.clientHeight;
const itemHeight = item.offsetHeight;
const targetScrollTop = itemOffsetTop - containerHeight / 2 + itemHeight / 2;
const maxScrollTop = container.scrollHeight - containerHeight;
const clampedScrollTop = Math.max(0, Math.min(targetScrollTop, maxScrollTop));
container.scrollTo({
top: clampedScrollTop,
behavior: "smooth",
});
}
}
});
return () => cancelAnimationFrame(rafId);
}, [highlightedIndex]);
// Reset highlighted index when external search changes
// Reset highlight position when search query changes
const prevSearchRef = useRef(search);
if (prevSearchRef.current !== search) {
prevSearchRef.current = search;
@ -262,7 +363,7 @@ export const DocumentMentionPicker = forwardRef<
}
}
// Expose methods to parent via ref
// Expose navigation and selection methods to parent component via ref
useImperativeHandle(
ref,
() => ({
@ -272,16 +373,18 @@ export const DocumentMentionPicker = forwardRef<
}
},
moveUp: () => {
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : selectableDocuments.length - 1));
},
moveDown: () => {
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev < selectableDocuments.length - 1 ? prev + 1 : 0));
},
}),
[selectableDocuments, highlightedIndex, handleSelectDocument]
);
// Handle keyboard navigation
// Keyboard navigation handler for arrow keys, Enter, and Escape
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (selectableDocuments.length === 0) return;
@ -289,10 +392,12 @@ export const DocumentMentionPicker = forwardRef<
switch (e.key) {
case "ArrowDown":
e.preventDefault();
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev < selectableDocuments.length - 1 ? prev + 1 : 0));
break;
case "ArrowUp":
e.preventDefault();
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : selectableDocuments.length - 1));
break;
case "Enter":
@ -310,14 +415,24 @@ export const DocumentMentionPicker = forwardRef<
[selectableDocuments, highlightedIndex, handleSelectDocument, onDone]
);
// Hide popup when there are no documents to display (regardless of fetch state)
// Search continues in background; popup reappears when results arrive
if (!actualLoading && actualDocuments.length === 0) {
return null;
}
return (
<div
className="flex flex-col w-[280px] sm:w-[320px] bg-popover rounded-lg"
className="fixed shadow-2xl rounded-lg border border-border overflow-hidden bg-popover flex flex-col w-[280px] sm:w-[320px]"
style={{
zIndex: 9999,
...containerStyle,
}}
onKeyDown={handleKeyDown}
role="listbox"
tabIndex={-1}
>
{/* Document List - Shows max 5 items on mobile, 7-8 items on desktop */}
{/* Scrollable document list with responsive height */}
<div
ref={scrollContainerRef}
className="max-h-[180px] sm:max-h-[280px] overflow-y-auto"
@ -327,17 +442,12 @@ export const DocumentMentionPicker = forwardRef<
<div className="flex items-center justify-center py-4">
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full" />
</div>
) : actualDocuments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-4 text-center px-4">
<FileText className="h-5 w-5 text-muted-foreground/50 mb-1" />
<p className="text-sm text-muted-foreground">No documents found</p>
</div>
) : (
<div className="py-1">
{/* SurfSense Documentation Section */}
) : actualDocuments.length > 0 ? (
<div className="py-1 px-2">
{/* SurfSense Documentation */}
{surfsenseDocsList.length > 0 && (
<>
<div className="sticky top-0 z-10 px-3 py-2 text-xs font-bold uppercase tracking-wider bg-muted text-foreground/80 border-b border-border">
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">
SurfSense Docs
</div>
{surfsenseDocsList.map((doc) => {
@ -365,7 +475,7 @@ export const DocumentMentionPicker = forwardRef<
}}
disabled={isAlreadySelected}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 text-left transition-colors",
"w-full flex items-center gap-2 px-3 py-2 text-left transition-colors rounded-md",
isAlreadySelected ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
isHighlighted && "bg-accent"
)}
@ -382,10 +492,10 @@ export const DocumentMentionPicker = forwardRef<
</>
)}
{/* User Documents Section */}
{/* User Documents */}
{userDocsList.length > 0 && (
<>
<div className="sticky top-0 z-10 px-3 py-2 text-xs font-bold uppercase tracking-wider bg-muted text-foreground/80 border-b border-border">
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">
Your Documents
</div>
{userDocsList.map((doc) => {
@ -413,7 +523,7 @@ export const DocumentMentionPicker = forwardRef<
}}
disabled={isAlreadySelected}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 text-left transition-colors",
"w-full flex items-center gap-2 px-3 py-2 text-left transition-colors rounded-md",
isAlreadySelected ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
isHighlighted && "bg-accent"
)}
@ -430,14 +540,14 @@ export const DocumentMentionPicker = forwardRef<
</>
)}
{/* Loading indicator for additional pages */}
{/* Pagination loading indicator */}
{isLoadingMore && (
<div className="flex items-center justify-center py-2">
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full" />
</div>
)}
</div>
)}
) : null}
</div>
</div>
);

View file

@ -0,0 +1,61 @@
"use client";
import { Bell } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useNotifications } from "@/hooks/use-notifications";
import { useAtomValue } from "jotai";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { NotificationPopup } from "./NotificationPopup";
import { cn } from "@/lib/utils";
import { useParams } from "next/navigation";
export function NotificationButton() {
const { data: user } = useAtomValue(currentUserAtom);
const params = useParams();
const userId = user?.id ? String(user.id) : null;
// Get searchSpaceId from URL params - the component is rendered within /dashboard/[search_space_id]/
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
const { notifications, unreadCount, loading, markAsRead, markAllAsRead } = useNotifications(
userId,
searchSpaceId
);
return (
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8 relative">
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<span
className={cn(
"absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-black text-[10px] font-medium text-white dark:bg-zinc-800 dark:text-zinc-50",
unreadCount > 9 && "px-1"
)}
>
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
<span className="sr-only">Notifications</span>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Notifications</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-80 p-0">
<NotificationPopup
notifications={notifications}
unreadCount={unreadCount}
loading={loading}
markAsRead={markAsRead}
markAllAsRead={markAllAsRead}
/>
</PopoverContent>
</Popover>
);
}

View file

@ -0,0 +1,127 @@
"use client";
import { Bell, CheckCheck, Loader2, AlertCircle, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import type { Notification } from "@/hooks/use-notifications";
import { formatDistanceToNow } from "date-fns";
import { cn } from "@/lib/utils";
interface NotificationPopupProps {
notifications: Notification[];
unreadCount: number;
loading: boolean;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
}
export function NotificationPopup({
notifications,
unreadCount,
loading,
markAsRead,
markAllAsRead,
}: NotificationPopupProps) {
const handleMarkAsRead = async (id: number) => {
await markAsRead(id);
};
const handleMarkAllAsRead = async () => {
await markAllAsRead();
};
const formatTime = (dateString: string) => {
try {
return formatDistanceToNow(new Date(dateString), { addSuffix: true });
} catch {
return "Recently";
}
};
const getStatusIcon = (notification: Notification) => {
const status = notification.metadata?.status as string | undefined;
switch (status) {
case "in_progress":
return <Loader2 className="h-4 w-4 text-foreground animate-spin" />;
case "completed":
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case "failed":
return <AlertCircle className="h-4 w-4 text-red-500" />;
default:
return <Bell className="h-4 w-4 text-muted-foreground" />;
}
};
return (
<div className="flex flex-col w-80 max-w-[calc(100vw-2rem)]">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm">Notifications</h3>
</div>
{unreadCount > 0 && (
<Button variant="ghost" size="sm" onClick={handleMarkAllAsRead} className="h-7 text-xs">
<CheckCheck className="h-3.5 w-3.5 mr-0" />
Mark all read
</Button>
)}
</div>
{/* Notifications List */}
<ScrollArea className="h-[400px]">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-foreground" />
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 px-4 text-center">
<Bell className="h-8 w-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">No notifications</p>
</div>
) : (
<div className="pt-0 pb-2">
{notifications.map((notification, index) => (
<div key={notification.id}>
<button
type="button"
onClick={() => !notification.read && handleMarkAsRead(notification.id)}
className={cn(
"w-full px-4 py-3 text-left hover:bg-accent transition-colors",
!notification.read && "bg-accent/50"
)}
>
<div className="flex items-start gap-3 overflow-hidden">
<div className="flex-shrink-0 mt-0.5">{getStatusIcon(notification)}</div>
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-start justify-between gap-2 mb-1">
<p
className={cn(
"text-xs font-medium break-all",
!notification.read && "font-semibold"
)}
>
{notification.title}
</p>
</div>
<p className="text-[11px] text-muted-foreground break-all line-clamp-2">
{notification.message}
</p>
<div className="flex items-center justify-between mt-2">
<span className="text-[10px] text-muted-foreground">
{formatTime(notification.created_at)}
</span>
</div>
</div>
</div>
</button>
{index < notifications.length - 1 && <Separator />}
</div>
))}
</div>
)}
</ScrollArea>
</div>
);
}

View file

@ -0,0 +1,132 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { useAtomValue } from "jotai";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import {
initElectric,
cleanupElectric,
isElectricInitialized,
type ElectricClient,
} from "@/lib/electric/client";
import { ElectricContext } from "@/lib/electric/context";
interface ElectricProviderProps {
children: React.ReactNode;
}
/**
* ElectricProvider initializes the Electric SQL client with user-specific PGlite database
* and provides it to children via context.
*
* KEY BEHAVIORS:
* 1. Single initialization point - only this provider creates the Electric client
* 2. Creates user-specific database (isolated per user)
* 3. Cleans up other users' databases on login
* 4. Re-initializes when user changes
* 5. Provides client via context - hooks should use useElectricClient()
*/
export function ElectricProvider({ children }: ElectricProviderProps) {
const [electricClient, setElectricClient] = useState<ElectricClient | null>(null);
const [error, setError] = useState<Error | null>(null);
const {
data: user,
isSuccess: isUserLoaded,
isError: isUserError,
} = useAtomValue(currentUserAtom);
const previousUserIdRef = useRef<string | null>(null);
const initializingRef = useRef(false);
useEffect(() => {
// Skip on server side
if (typeof window === "undefined") return;
// If no user is logged in, don't initialize Electric
// The app can still function without real-time sync for non-authenticated pages
if (!isUserLoaded || !user?.id) {
// If we had a previous user and now logged out, cleanup
if (previousUserIdRef.current && isElectricInitialized()) {
console.log("[ElectricProvider] User logged out, cleaning up...");
cleanupElectric().then(() => {
previousUserIdRef.current = null;
setElectricClient(null);
});
}
return;
}
const userId = String(user.id);
// If already initialized for THIS user, skip
if (electricClient && previousUserIdRef.current === userId) {
return;
}
// Prevent concurrent initialization attempts
if (initializingRef.current) {
return;
}
// User changed or first initialization
initializingRef.current = true;
let mounted = true;
async function init() {
try {
console.log(`[ElectricProvider] Initializing for user: ${userId}`);
// If different user was previously initialized, cleanup will happen inside initElectric
const client = await initElectric(userId);
if (mounted) {
previousUserIdRef.current = userId;
setElectricClient(client);
setError(null);
console.log(`[ElectricProvider] ✅ Ready for user: ${userId}`);
}
} catch (err) {
console.error("[ElectricProvider] Failed to initialize:", err);
if (mounted) {
setError(err instanceof Error ? err : new Error("Failed to initialize Electric SQL"));
// Set client to null so hooks know initialization failed
setElectricClient(null);
}
} finally {
if (mounted) {
initializingRef.current = false;
}
}
}
init();
return () => {
mounted = false;
};
}, [user?.id, isUserLoaded, electricClient]);
// For non-authenticated pages (like landing page), render immediately with null context
// Also render immediately if user query failed (e.g., token expired)
if (!isUserLoaded || !user?.id || isUserError) {
return <ElectricContext.Provider value={null}>{children}</ElectricContext.Provider>;
}
// Show loading state while initializing for authenticated users
if (!electricClient && !error) {
return (
<ElectricContext.Provider value={null}>
<div className="flex items-center justify-center min-h-screen">
<div className="text-muted-foreground">Initializing...</div>
</div>
</ElectricContext.Provider>
);
}
// If there's an error, still render but warn
if (error) {
console.warn("[ElectricProvider] Initialization failed, sync may not work:", error.message);
}
// Provide the Electric client to children
return <ElectricContext.Provider value={electricClient}>{children}</ElectricContext.Provider>;
}

View file

@ -560,7 +560,7 @@ export function LLMConfigForm({
{isSubmitting ? (
<>
<Loader2 className="h-3.5 w-3.5 sm:h-4 sm:w-4 animate-spin" />
{mode === "edit" ? "Updating..." : "Creating..."}
{mode === "edit" ? "Updating..." : "Creating"}
</>
) : (
<>

View file

@ -3,7 +3,6 @@
import { useAtom } from "jotai";
import { CheckCircle2, FileType, Info, Loader2, Tag, Upload, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useMemo, useRef, useState } from "react";
import { useDropzone } from "react-dropzone";
@ -121,7 +120,6 @@ export function DocumentUploadTab({
onAccordionStateChange,
}: DocumentUploadTabProps) {
const t = useTranslations("upload_documents");
const router = useRouter();
const [files, setFiles] = useState<File[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [accordionValue, setAccordionValue] = useState<string>("");
@ -224,7 +222,7 @@ export function DocumentUploadTab({
setUploadProgress(100);
trackDocumentUploadSuccess(Number(searchSpaceId), files.length);
toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
onSuccess?.() || router.push(`/dashboard/${searchSpaceId}/documents`);
onSuccess?.();
},
onError: (error: unknown) => {
clearInterval(progressInterval);

View file

@ -98,7 +98,7 @@ function PodcastGeneratingState({ title }: { title: string }) {
<h3 className="font-semibold text-foreground text-lg">{title}</h3>
<div className="mt-2 flex items-center gap-2 text-muted-foreground">
<Loader2Icon className="size-4 animate-spin" />
<span className="text-sm">Generating podcast... This may take a few minutes</span>
<span className="text-sm">Generating podcast. This may take a few minutes</span>
</div>
<div className="mt-3">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-primary/10">

View file

@ -42,7 +42,7 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground fill-popover shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance",
"bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md text-xs text-balance",
className
)}
{...props}