merge: upstream/dev with migration renumbering

This commit is contained in:
CREDO23 2026-01-27 11:22:26 +02:00
commit a7145b2c63
176 changed files with 8791 additions and 3608 deletions

View file

@ -1,7 +1,9 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils";
import { trackLoginSuccess } from "@/lib/posthog/events";
@ -25,8 +27,12 @@ const TokenHandler = ({
tokenParamName = "token",
storageKey = "surfsense_bearer_token",
}: TokenHandlerProps) => {
const t = useTranslations("auth");
const searchParams = useSearchParams();
// Always show loading for this component - spinner animation won't reset
useGlobalLoadingEffect(true, t("processing_authentication"), "default");
useEffect(() => {
// Only run on client-side
if (typeof window === "undefined") return;
@ -66,11 +72,8 @@ const TokenHandler = ({
}
}, [searchParams, tokenParamName, storageKey, redirectPath]);
return (
<div className="flex items-center justify-center min-h-[200px]">
<p className="text-gray-500">Processing authentication...</p>
</div>
);
// Return null - the global provider handles the loading UI
return null;
};
export default TokenHandler;

View file

@ -7,7 +7,7 @@ import {
useAssistantApi,
useAssistantState,
} from "@assistant-ui/react";
import { FileText, Loader2, Paperclip, PlusIcon, Upload, XIcon } from "lucide-react";
import { FileText, Paperclip, PlusIcon, Upload, XIcon } from "lucide-react";
import Image from "next/image";
import { type FC, type PropsWithChildren, useEffect, useRef, useState } from "react";
import { useShallow } from "zustand/shallow";
@ -20,6 +20,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useDocumentUploadDialog } from "./document-upload-popup";
@ -135,7 +136,7 @@ const AttachmentThumb: FC = () => {
if (isProcessing) {
return (
<div className="flex h-full w-full items-center justify-center bg-muted">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
<Spinner size="md" className="text-muted-foreground" />
</div>
);
}
@ -213,7 +214,7 @@ const AttachmentUI: FC = () => {
>
{isProcessing ? (
<span className="flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
<Spinner size="xs" />
Processing...
</span>
) : (

View file

@ -1,7 +1,7 @@
"use client";
import { Loader2 } from "lucide-react";
import type { FC } from "react";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
interface ChatSessionStatusProps {
@ -43,7 +43,7 @@ export const ChatSessionStatus: FC<ChatSessionStatusProps> = ({
className
)}
>
<Loader2 className="size-3.5 animate-spin" />
<Spinner size="xs" />
<span>Currently responding to {displayName}</span>
</div>
);

View file

@ -1,37 +1,50 @@
"use client";
import { useAtomValue } from "jotai";
import { Cable, Loader2 } from "lucide-react";
import { Cable } from "lucide-react";
import { useSearchParams } from "next/navigation";
import type { FC } from "react";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { useConnectorsElectric } from "@/hooks/use-connectors-electric";
import { useDocumentsElectric } from "@/hooks/use-documents-electric";
import { useInbox } from "@/hooks/use-inbox";
import { cn } from "@/lib/utils";
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view";
import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view";
import { OAUTH_CONNECTORS } from "./connector-popup/constants/connector-constants";
import {
COMPOSIO_CONNECTORS,
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 { ComposioToolkitView } from "./connector-popup/views/composio-toolkit-view";
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
export const ConnectorIndicator: FC = () => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const searchParams = useSearchParams();
const { data: currentUser } = useAtomValue(currentUserAtom);
// Fetch document type counts using Electric SQL + PGlite for real-time updates
const { documentTypeCounts, loading: documentTypesLoading } = useDocumentsElectric(searchSpaceId);
// Fetch notifications to detect indexing failures
const { inboxItems = [] } = useInbox(
currentUser?.id ?? null,
searchSpaceId ? Number(searchSpaceId) : null,
"connector_indexing"
);
// Check if YouTube view is active
const isYouTubeView = searchParams.get("view") === "youtube";
@ -88,12 +101,6 @@ export const ConnectorIndicator: FC = () => {
setConnectorConfig,
setIndexingConnectorConfig,
setConnectorName,
// Composio
viewingComposio,
connectingComposioToolkit,
handleOpenComposio,
handleBackFromComposio,
handleConnectComposioToolkit,
} = useConnectorDialog();
// Fetch connectors using Electric SQL + PGlite for real-time updates
@ -123,8 +130,10 @@ export const ConnectorIndicator: FC = () => {
};
// Track indexing state locally - clears automatically when Electric SQL detects last_indexed_at changed
const { indexingConnectorIds, startIndexing } = useIndexingConnectors(
connectors as SearchSourceConnector[]
// Also clears when failed notifications are detected
const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors(
connectors as SearchSourceConnector[],
inboxItems
);
const isLoading = connectorsLoading || documentTypesLoading;
@ -142,7 +151,7 @@ export const ConnectorIndicator: FC = () => {
// Check which connectors are already connected
// Using Electric SQL + PGlite for real-time connector updates
const connectedTypes = new Set(
const connectedTypes = new Set<string>(
(connectors || []).map((c: SearchSourceConnector) => c.connector_type)
);
@ -166,7 +175,7 @@ export const ConnectorIndicator: FC = () => {
onClick={() => handleOpenChange(true)}
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
<Spinner size="sm" />
) : (
<>
<Cable className="size-4 stroke-[1.5px]" />
@ -179,22 +188,11 @@ export const ConnectorIndicator: FC = () => {
)}
</TooltipIconButton>
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5">
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5">
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
{/* YouTube Crawler View - shown when adding YouTube videos */}
{isYouTubeView && searchSpaceId ? (
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
) : viewingComposio && searchSpaceId ? (
<ComposioToolkitView
searchSpaceId={searchSpaceId}
connectedToolkits={(connectors || [])
.filter((c: SearchSourceConnector) => c.connector_type === "COMPOSIO_CONNECTOR")
.map((c: SearchSourceConnector) => c.config?.toolkit_id as string)
.filter(Boolean)}
onBack={handleBackFromComposio}
onConnectToolkit={handleConnectComposioToolkit}
isConnecting={connectingComposioToolkit !== null}
connectingToolkitId={connectingComposioToolkit}
/>
) : viewingMCPList ? (
<ConnectorAccountsListView
connectorType="MCP_CONNECTOR"
@ -215,9 +213,14 @@ export const ConnectorIndicator: FC = () => {
onBack={handleBackFromAccountsList}
onManage={handleStartEdit}
onAddAccount={() => {
const oauthConnector = OAUTH_CONNECTORS.find(
(c) => c.connectorType === viewingAccountsType.connectorType
);
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
const oauthConnector =
OAUTH_CONNECTORS.find(
(c) => c.connectorType === viewingAccountsType.connectorType
) ||
COMPOSIO_CONNECTORS.find(
(c) => c.connectorType === viewingAccountsType.connectorType
);
if (oauthConnector) {
handleConnectOAuth(oauthConnector);
}
@ -260,7 +263,13 @@ export const ConnectorIndicator: FC = () => {
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR"
? () => {
startIndexing(editingConnector.id);
handleQuickIndexConnector(editingConnector.id, editingConnector.connector_type);
handleQuickIndexConnector(
editingConnector.id,
editingConnector.connector_type,
stopIndexing,
startDate,
endDate
);
}
: undefined
}
@ -331,7 +340,6 @@ export const ConnectorIndicator: FC = () => {
onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
onManage={handleStartEdit}
onViewAccountsList={handleViewAccountsList}
onOpenComposio={handleOpenComposio}
/>
</TabsContent>

View file

@ -1,78 +0,0 @@
"use client";
import { Zap } from "lucide-react";
import Image from "next/image";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface ComposioConnectorCardProps {
id: string;
title: string;
description: string;
connectorCount?: number;
onConnect: () => void;
}
export const ComposioConnectorCard: FC<ComposioConnectorCardProps> = ({
id,
title,
description,
connectorCount = 0,
onConnect,
}) => {
const hasConnections = connectorCount > 0;
return (
<div
className={cn(
"group relative flex items-center gap-4 p-4 rounded-xl text-left transition-all duration-200 w-full border",
"border-violet-500/20 bg-gradient-to-br from-violet-500/5 to-purple-500/5",
"hover:border-violet-500/40 hover:from-violet-500/10 hover:to-purple-500/10"
)}
>
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-lg transition-colors shrink-0 border",
"bg-gradient-to-br from-violet-500/10 to-purple-500/10 border-violet-500/20"
)}
>
<Image
src="/connectors/composio.svg"
alt="Composio"
width={24}
height={24}
className="size-6"
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-[14px] font-semibold leading-tight truncate">{title}</span>
<Zap className="size-3.5 text-violet-500" />
</div>
{hasConnections ? (
<p className="text-[10px] text-muted-foreground mt-1 flex items-center gap-1.5">
<span>
{connectorCount} {connectorCount === 1 ? "connection" : "connections"}
</span>
</p>
) : (
<p className="text-[10px] text-muted-foreground mt-1">{description}</p>
)}
</div>
<Button
size="sm"
variant={hasConnections ? "secondary" : "default"}
className={cn(
"h-8 text-[11px] px-3 rounded-lg shrink-0 font-medium shadow-xs",
!hasConnections && "bg-violet-600 hover:bg-violet-700 text-white",
hasConnections &&
"bg-white text-slate-700 hover:bg-slate-50 border-0 dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80"
)}
onClick={onConnect}
>
{hasConnections ? "Manage" : "Browse"}
</Button>
</div>
);
};

View file

@ -1,9 +1,10 @@
"use client";
import { IconBrandYoutube } from "@tabler/icons-react";
import { FileText, Loader2 } from "lucide-react";
import { FileText } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { cn } from "@/lib/utils";
@ -111,7 +112,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
</div>
{isIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
<Spinner size="xs" />
Syncing
</p>
) : isConnected ? (
@ -151,7 +152,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
disabled={isConnecting || !isEnabled}
>
{isConnecting ? (
<Loader2 className="size-3 animate-spin" />
<Spinner size="xs" />
) : !isEnabled ? (
"Unavailable"
) : isConnected ? (

View file

@ -24,6 +24,11 @@
"enabled": true,
"status": "warning",
"statusMessage": "Some requests may be blocked if not using Firecrawl."
},
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR": {
"enabled": false,
"status": "disabled",
"statusMessage": "Not available yet."
}
},
"globalSettings": {

View file

@ -6,12 +6,6 @@ import type { FC } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
@ -85,6 +79,7 @@ export const BookStackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
BOOKSTACK_TOKEN_SECRET: values.token_secret,
},
is_indexable: true,
is_active: true,
last_indexed_at: null,
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
@ -301,124 +296,6 @@ export const BookStackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The BookStack connector uses the BookStack REST API to fetch all pages from your
BookStack instance that your account has access to.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
For follow up indexing runs, the connector retrieves pages that have been updated
since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within minutes.
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">API Token Required</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
You need to create an API token from your BookStack instance. The token requires
"Access System API" permission.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Create an API Token
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>Log in to your BookStack instance</li>
<li>Click on your profile icon Edit Profile</li>
<li>Navigate to the "API Tokens" tab</li>
<li>Click "Create Token" and give it a name</li>
<li>Copy both the Token ID and Token Secret</li>
<li>Paste them in the form above</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Grant necessary access
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
Your user account must have "Access System API" permission. The connector will
only index content your account can view.
</p>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Rate Limiting</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
BookStack API has a rate limit of 180 requests per minute. The connector
automatically handles rate limiting to ensure reliable indexing.
</AlertDescription>
</Alert>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Navigate to the Connector Dashboard and select the <strong>BookStack</strong>{" "}
Connector.
</li>
<li>
Enter your <strong>BookStack Instance URL</strong> (e.g.,
https://docs.example.com)
</li>
<li>
Enter your <strong>Token ID</strong> and <strong>Token Secret</strong> from your
BookStack API token.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your BookStack pages will be indexed automatically.</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The BookStack connector indexes the following data:</p>
<ul className="list-disc pl-5 space-y-1">
<li>All pages from your BookStack instance</li>
<li>Page content in Markdown format</li>
<li>Page titles and metadata</li>
<li>Book and chapter hierarchy information</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -6,12 +6,6 @@ import type { FC } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
@ -253,131 +247,6 @@ export const LumaConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The Luma connector uses the Luma API to fetch all events that your API key has
access to.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
For follow up indexing runs, the connector retrieves events that have been updated
since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within minutes.
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">API Key Required</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
You need a Luma API key to use this connector. The key will be used to read your
Luma events with read-only permissions.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Get Your API Key
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>Log into your Luma account</li>
<li>Navigate to your account settings</li>
<li>Go to API settings or Developer settings</li>
<li>Generate a new API key</li>
<li>Copy the generated API key</li>
<li>
You can also visit{" "}
<a
href="https://lu.ma/api"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Luma API Settings
</a>{" "}
for more information.
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Grant necessary access
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
The API key will have access to all events that your user account can see.
Make sure your account has appropriate permissions for the events you want to
index.
</p>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Data Privacy</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
Only event details, descriptions, and attendee information will be indexed.
Event attachments and linked files are not indexed by this connector.
</AlertDescription>
</Alert>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Navigate to the Connector Dashboard and select the <strong>Luma</strong>{" "}
Connector.
</li>
<li>
Place your <strong>API Key</strong> in the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your Luma events will be indexed automatically.</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The Luma connector indexes the following data:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Event titles and descriptions</li>
<li>Event details and metadata</li>
<li>Attendee information</li>
<li>Event dates and locations</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -1,17 +1,11 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { FolderOpen, Info } from "lucide-react";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
@ -109,7 +103,7 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitti
return (
<div className="space-y-6 pb-6">
<Alert className="bg-purple-500/10 dark:bg-purple-500/10 border-purple-500/30 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<FolderOpen className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1 text-purple-500" />
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1 text-purple-500" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">Self-Hosted Only</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs pl-0!">
@ -320,145 +314,6 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitti
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The Obsidian connector scans your local Obsidian vault directory and indexes all
Markdown files. It preserves your note structure and extracts metadata from YAML
frontmatter.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
The connector parses frontmatter metadata (title, tags, aliases, dates, etc.)
</li>
<li>Wiki-style links ([[note]]) are extracted and preserved</li>
<li>Inline tags (#tag) are recognized and indexed</li>
<li>Content is chunked intelligently for optimal search results</li>
<li>
Subsequent indexing runs use content hashing to skip unchanged files for faster
sync
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Setup</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">
File System Access Required
</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
The SurfSense backend must have read access to your Obsidian vault directory.
For Docker deployments, mount your vault as a volume.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Locate your vault
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
<strong>macOS/Linux:</strong> Right-click any note in Obsidian "Reveal in
Finder" to see the vault folder
</li>
<li>
<strong>Windows:</strong> Right-click any note "Show in system explorer"
</li>
<li>
<strong>Or:</strong> Click the vault switcher (bottom-left icon) "Open
folder" next to your vault name
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Enter the path
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
<strong>Running locally (no Docker):</strong> Use the direct path to your
vault:
</p>
<pre className="bg-slate-800 text-slate-200 p-2 rounded text-[9px] sm:text-[10px] overflow-x-auto mb-2">
{`/Users/yourname/Documents/MyObsidianVault`}
</pre>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
<strong>Running in Docker:</strong> Mount your vault as a volume in
docker-compose.yml:
</p>
<pre className="bg-slate-800 text-slate-200 p-2 rounded text-[9px] sm:text-[10px] overflow-x-auto">
{`volumes:
- /path/to/your/vault:/app/obsidian_vaults/my-vault:ro`}
</pre>
<p className="text-[10px] sm:text-xs text-muted-foreground mt-2">
Then use <code>/app/obsidian_vaults/my-vault</code> as your vault path.
</p>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 3: Configure exclusions
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Common folders to exclude:
</p>
<ul className="list-disc pl-5 mt-1 space-y-1 text-[10px] sm:text-xs text-muted-foreground">
<li>
<code>.obsidian</code> - Obsidian config (always recommended)
</li>
<li>
<code>.trash</code> - Obsidian's trash folder
</li>
<li>
<code>templates</code> - If you have a templates folder
</li>
<li>
<code>daily-notes</code> - If you want to exclude daily notes
</li>
</ul>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">What Gets Indexed</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Indexed Content</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The Obsidian connector indexes:</p>
<ul className="list-disc pl-5 space-y-1">
<li>All Markdown files (.md) in your vault</li>
<li>YAML frontmatter metadata (title, tags, aliases, dates)</li>
<li>Wiki-style links between notes</li>
<li>Inline tags throughout your notes</li>
<li>Full note content with proper chunking</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -0,0 +1,14 @@
"use client";
import type { FC } from "react";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
interface ComposioCalendarConfigProps {
connector: SearchSourceConnector;
onConfigChange?: (config: Record<string, unknown>) => void;
onNameChange?: (name: string) => void;
}
export const ComposioCalendarConfig: FC<ComposioCalendarConfigProps> = () => {
return <div className="space-y-6" />;
};

View file

@ -1,160 +0,0 @@
"use client";
import { ExternalLink, Info, Zap } from "lucide-react";
import Image from "next/image";
import type { FC } from "react";
import { Badge } from "@/components/ui/badge";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { cn } from "@/lib/utils";
interface ComposioConfigProps {
connector: SearchSourceConnector;
onConfigChange?: (config: Record<string, unknown>) => void;
onNameChange?: (name: string) => void;
}
// Get toolkit display info
const getToolkitInfo = (toolkitId: string): { name: string; icon: string; description: string } => {
switch (toolkitId) {
case "googledrive":
return {
name: "Google Drive",
icon: "/connectors/google-drive.svg",
description: "Files and documents from Google Drive",
};
case "gmail":
return {
name: "Gmail",
icon: "/connectors/google-gmail.svg",
description: "Emails from Gmail",
};
case "googlecalendar":
return {
name: "Google Calendar",
icon: "/connectors/google-calendar.svg",
description: "Events from Google Calendar",
};
case "slack":
return {
name: "Slack",
icon: "/connectors/slack.svg",
description: "Messages from Slack",
};
case "notion":
return {
name: "Notion",
icon: "/connectors/notion.svg",
description: "Pages from Notion",
};
case "github":
return {
name: "GitHub",
icon: "/connectors/github.svg",
description: "Repositories from GitHub",
};
default:
return {
name: toolkitId,
icon: "/connectors/composio.svg",
description: "Connected via Composio",
};
}
};
export const ComposioConfig: FC<ComposioConfigProps> = ({ connector }) => {
const toolkitId = connector.config?.toolkit_id as string;
const toolkitName = connector.config?.toolkit_name as string;
const isIndexable = connector.config?.is_indexable as boolean;
const composioAccountId = connector.config?.composio_connected_account_id as string;
const toolkitInfo = getToolkitInfo(toolkitId);
return (
<div className="space-y-6">
{/* Toolkit Info Card */}
<div className="rounded-xl border border-violet-500/20 bg-gradient-to-br from-violet-500/5 to-purple-500/5 p-4">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500/10 to-purple-500/10 border border-violet-500/20 shrink-0">
<Image
src={toolkitInfo.icon}
alt={toolkitInfo.name}
width={24}
height={24}
className="size-6"
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-sm font-semibold">{toolkitName || toolkitInfo.name}</h3>
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 h-5 bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
>
<Zap className="size-3 mr-0.5" />
Composio
</Badge>
</div>
<p className="text-xs text-muted-foreground">{toolkitInfo.description}</p>
</div>
</div>
</div>
{/* Connection Details */}
<div className="space-y-3">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Connection Details
</h4>
<div className="space-y-2">
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-muted/50">
<span className="text-xs text-muted-foreground">Toolkit</span>
<span className="text-xs font-medium">{toolkitId}</span>
</div>
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-muted/50">
<span className="text-xs text-muted-foreground">Indexing Supported</span>
<Badge
variant={isIndexable ? "default" : "secondary"}
className={cn(
"text-[10px] px-1.5 py-0 h-5",
isIndexable
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
)}
>
{isIndexable ? "Yes" : "Coming Soon"}
</Badge>
</div>
{composioAccountId && (
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-muted/50">
<span className="text-xs text-muted-foreground">Account ID</span>
<span className="text-xs font-mono text-muted-foreground truncate max-w-[150px]">
{composioAccountId}
</span>
</div>
)}
</div>
</div>
{/* Info Banner */}
<div className="rounded-lg border border-border/50 bg-muted/30 p-3">
<div className="flex items-start gap-2.5">
<Info className="size-4 text-muted-foreground shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-xs text-muted-foreground leading-relaxed">
This connection uses Composio&apos;s managed OAuth, which means you don&apos;t need to
wait for app verification. Your data is securely accessed through Composio.
</p>
<a
href="https://composio.dev"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-violet-600 dark:text-violet-400 hover:underline"
>
Learn more about Composio
<ExternalLink className="size-3" />
</a>
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,347 @@
"use client";
import {
File,
FileSpreadsheet,
FileText,
FolderClosed,
Image,
Presentation,
X,
} from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { ComposioDriveFolderTree } from "@/components/connectors/composio-drive-folder-tree";
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 { SearchSourceConnector } from "@/contracts/types/connector.types";
interface ComposioDriveConfigProps {
connector: SearchSourceConnector;
onConfigChange?: (config: Record<string, unknown>) => void;
onNameChange?: (name: string) => void;
}
interface SelectedFolder {
id: string;
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 ComposioDriveConfig: FC<ComposioDriveConfigProps> = ({
connector,
onConfigChange,
}) => {
const isIndexable = connector.config?.is_indexable as boolean;
// 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 updateConfig = (
folders: SelectedFolder[],
files: SelectedFolder[],
options: IndexingOptions
) => {
if (onConfigChange) {
onConfigChange({
...connector.config,
selected_folders: folders,
selected_files: files,
indexing_options: options,
});
}
};
const handleSelectFolders = (folders: SelectedFolder[]) => {
setSelectedFolders(folders);
updateConfig(folders, selectedFiles, indexingOptions);
};
const handleSelectFiles = (files: SelectedFolder[]) => {
setSelectedFiles(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 handleRemoveFolder = (folderId: string) => {
const newFolders = selectedFolders.filter((folder) => folder.id !== folderId);
setSelectedFolders(newFolders);
updateConfig(newFolders, selectedFiles, indexingOptions);
};
const handleRemoveFile = (fileId: string) => {
const newFiles = selectedFiles.filter((file) => file.id !== fileId);
setSelectedFiles(newFiles);
updateConfig(selectedFolders, newFiles, indexingOptions);
};
const totalSelected = selectedFolders.length + selectedFiles.length;
// Only show configuration if the connector is indexable
if (!isIndexable) {
return <div className="space-y-6" />;
}
return (
<div className="space-y-6">
{/* 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 from your Google Drive.
</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" : ""}: {(() => {
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) => (
<div
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" />
<span className="flex-1 truncate">{folder.name}</span>
<button
type="button"
onClick={() => handleRemoveFolder(folder.id)}
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
aria-label={`Remove ${folder.name}`}
>
<X className="size-3.5" />
</button>
</div>
))}
{selectedFiles.map((file) => (
<div
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)}
<span className="flex-1 truncate">{file.name}</span>
<button
type="button"
onClick={() => handleRemoveFile(file.id)}
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
aria-label={`Remove ${file.name}`}
>
<X className="size-3.5" />
</button>
</div>
))}
</div>
</div>
)}
{showFolderSelector ? (
<div className="space-y-2 sm:space-y-3">
<ComposioDriveFolderTree
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"
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>
{/* 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>
{/* 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,14 @@
"use client";
import type { FC } from "react";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
interface ComposioGmailConfigProps {
connector: SearchSourceConnector;
onConfigChange?: (config: Record<string, unknown>) => void;
onNameChange?: (name: string) => void;
}
export const ComposioGmailConfig: FC<ComposioGmailConfigProps> = () => {
return <div className="space-y-6" />;
};

View file

@ -1,6 +1,14 @@
"use client";
import { File, FileSpreadsheet, FileText, FolderClosed, Image, Presentation } from "lucide-react";
import {
File,
FileSpreadsheet,
FileText,
FolderClosed,
Image,
Presentation,
X,
} from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { GoogleDriveFolderTree } from "@/components/connectors/google-drive-folder-tree";
@ -135,6 +143,18 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
updateConfig(selectedFolders, selectedFiles, newOptions);
};
const handleRemoveFolder = (folderId: string) => {
const newFolders = selectedFolders.filter((folder) => folder.id !== folderId);
setSelectedFolders(newFolders);
updateConfig(newFolders, selectedFiles, indexingOptions);
};
const handleRemoveFile = (fileId: string) => {
const newFiles = selectedFiles.filter((file) => file.id !== fileId);
setSelectedFiles(newFiles);
updateConfig(selectedFolders, newFiles, indexingOptions);
};
const totalSelected = selectedFolders.length + selectedFiles.length;
return (
@ -161,29 +181,45 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
if (selectedFiles.length > 0) {
parts.push(`${selectedFiles.length} file${selectedFiles.length > 1 ? "s" : ""}`);
}
return parts.length > 0 ? `(${parts.join(" ")})` : "";
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
<div
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>
<span className="flex-1 truncate">{folder.name}</span>
<button
type="button"
onClick={() => handleRemoveFolder(folder.id)}
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
aria-label={`Remove ${folder.name}`}
>
<X className="size-3.5" />
</button>
</div>
))}
{selectedFiles.map((file) => (
<p
<div
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>
<span className="flex-1 truncate">{file.name}</span>
<button
type="button"
onClick={() => handleRemoveFile(file.id)}
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
aria-label={`Remove ${file.name}`}
>
<X className="size-3.5" />
</button>
</div>
))}
</div>
</div>

View file

@ -6,7 +6,9 @@ import { BaiduSearchApiConfig } from "./components/baidu-search-api-config";
import { BookStackConfig } from "./components/bookstack-config";
import { CirclebackConfig } from "./components/circleback-config";
import { ClickUpConfig } from "./components/clickup-config";
import { ComposioConfig } from "./components/composio-config";
import { ComposioCalendarConfig } from "./components/composio-calendar-config";
import { ComposioDriveConfig } from "./components/composio-drive-config";
import { ComposioGmailConfig } from "./components/composio-gmail-config";
import { ConfluenceConfig } from "./components/confluence-config";
import { DiscordConfig } from "./components/discord-config";
import { ElasticsearchConfig } from "./components/elasticsearch-config";
@ -77,8 +79,12 @@ export function getConnectorConfigComponent(
return MCPConfig;
case "OBSIDIAN_CONNECTOR":
return ObsidianConfig;
case "COMPOSIO_CONNECTOR":
return ComposioConfig;
case "COMPOSIO_GOOGLE_DRIVE_CONNECTOR":
return ComposioDriveConfig;
case "COMPOSIO_GMAIL_CONNECTOR":
return ComposioGmailConfig;
case "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR":
return ComposioCalendarConfig;
// OAuth connectors (Gmail, Calendar, Airtable, Notion) and others don't need special config UI
default:
return null;

View file

@ -1,8 +1,9 @@
"use client";
import { ArrowLeft, Loader2 } from "lucide-react";
import { ArrowLeft } from "lucide-react";
import { type FC, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import type { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
@ -139,7 +140,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Spinner size="sm" className="mr-2" />
Connecting
</>
) : connectorType === "MCP_CONNECTOR" ? (

View file

@ -1,13 +1,15 @@
"use client";
import { ArrowLeft, Info, Loader2, RefreshCw, Trash2 } from "lucide-react";
import { ArrowLeft, Info, RefreshCw, Trash2 } from "lucide-react";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { cn } from "@/lib/utils";
import { DateRangeSelector } from "../../components/date-range-selector";
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
import { getConnectorConfigComponent } from "../index";
interface ConnectorEditViewProps {
@ -97,12 +99,16 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
};
}, [checkScrollState]);
// Reset local quick indexing state when indexing completes
// Reset local quick indexing state when indexing completes or fails
useEffect(() => {
if (!isIndexing) {
setIsQuickIndexing(false);
if (!isIndexing && isQuickIndexing) {
// Small delay to ensure smooth transition
const timer = setTimeout(() => {
setIsQuickIndexing(false);
}, 100);
return () => clearTimeout(timer);
}
}, [isIndexing]);
}, [isIndexing, isQuickIndexing]);
const handleDisconnectClick = () => {
setShowDisconnectConfirm(true);
@ -118,11 +124,11 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
};
const handleQuickIndex = useCallback(() => {
if (onQuickIndex) {
if (onQuickIndex && !isQuickIndexing && !isIndexing) {
setIsQuickIndexing(true);
onQuickIndex();
}
}, [onQuickIndex]);
}, [onQuickIndex, isQuickIndexing, isIndexing]);
return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
@ -151,7 +157,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}
{getConnectorDisplayName(connector.name)}
</h2>
<p className="text-xs sm:text-base text-muted-foreground mt-1">
Manage your connector settings and sync configuration
@ -206,8 +212,9 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
{/* Date range selector and periodic sync - only shown for indexable connectors */}
{connector.is_indexable && (
<>
{/* Date range selector - not shown for Google Drive, Webcrawler, or GitHub (indexes full repo snapshots) */}
{/* Date range selector - not shown for Google Drive (regular and Composio), Webcrawler, or GitHub (indexes full repo snapshots) */}
{connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" &&
connector.connector_type !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
connector.connector_type !== "WEBCRAWLER_CONNECTOR" &&
connector.connector_type !== "GITHUB_CONNECTOR" && (
<DateRangeSelector
@ -217,6 +224,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
onEndDateChange={onEndDateChange}
allowFutureDates={
connector.connector_type === "GOOGLE_CALENDAR_CONNECTOR" ||
connector.connector_type === "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR" ||
connector.connector_type === "LUMA_CONNECTOR"
}
/>
@ -224,8 +232,11 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
{/* Periodic sync - shown for all indexable connectors */}
{(() => {
// Check if Google Drive has folders/files selected
// Check if Google Drive (regular or Composio) has folders/files selected
const isGoogleDrive = connector.connector_type === "GOOGLE_DRIVE_CONNECTOR";
const isComposioGoogleDrive =
connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
const requiresFolderSelection = isGoogleDrive || isComposioGoogleDrive;
const selectedFolders =
(connector.config?.selected_folders as
| Array<{ id: string; name: string }>
@ -235,7 +246,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
| Array<{ id: string; name: string }>
| undefined) || [];
const hasItemsSelected = selectedFolders.length > 0 || selectedFiles.length > 0;
const isDisabled = isGoogleDrive && !hasItemsSelected;
const isDisabled = requiresFolderSelection && !hasItemsSelected;
return (
<PeriodicSyncConfig
@ -266,8 +277,8 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
Re-indexing runs in the background
</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
You can continue using SurfSense while we sync your data. Check the Active tab
to see progress.
You can continue using SurfSense while we sync your data. Check inbox for
updates.
</p>
</div>
</div>
@ -301,7 +312,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
>
{isDisconnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Spinner size="sm" className="mr-2" />
Disconnecting
</>
) : (
@ -337,8 +348,8 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
<Spinner size="sm" className="mr-2" />
Saving
</>
) : (
"Save Changes"

View file

@ -1,15 +1,16 @@
"use client";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { ArrowLeft, Check, Info } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
import { cn } from "@/lib/utils";
import { DateRangeSelector } from "../../components/date-range-selector";
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
import { type IndexingConfigState, OAUTH_CONNECTORS } from "../../constants/connector-constants";
import type { IndexingConfigState } from "../../constants/connector-constants";
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
import { getConnectorConfigComponent } from "../index";
@ -91,8 +92,6 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
};
}, [checkScrollState]);
const authConnector = OAUTH_CONNECTORS.find((c) => c.connectorType === connector?.connector_type);
return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Fixed Header */}
@ -151,8 +150,9 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
{/* Date range selector and periodic sync - only shown for indexable connectors */}
{connector?.is_indexable && (
<>
{/* Date range selector - not shown for Google Drive, Webcrawler, or GitHub (indexes full repo snapshots) */}
{/* Date range selector - not shown for Google Drive (regular and Composio), Webcrawler, or GitHub (indexes full repo snapshots) */}
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
config.connectorType !== "WEBCRAWLER_CONNECTOR" &&
config.connectorType !== "GITHUB_CONNECTOR" && (
<DateRangeSelector
@ -162,20 +162,22 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
onEndDateChange={onEndDateChange}
allowFutureDates={
config.connectorType === "GOOGLE_CALENDAR_CONNECTOR" ||
config.connectorType === "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR" ||
config.connectorType === "LUMA_CONNECTOR"
}
/>
)}
{/* Periodic sync - not shown for Google Drive */}
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" && (
<PeriodicSyncConfig
enabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
onEnabledChange={onPeriodicEnabledChange}
onFrequencyChange={onFrequencyChange}
/>
)}
{/* Periodic sync - not shown for Google Drive (regular and Composio) */}
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" && (
<PeriodicSyncConfig
enabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
onEnabledChange={onPeriodicEnabledChange}
onFrequencyChange={onFrequencyChange}
/>
)}
</>
)}
@ -188,8 +190,8 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
<div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Indexing runs in the background</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
You can continue using SurfSense while we sync your data. Check the Active tab
to see progress.
You can continue using SurfSense while we sync your data. Check inbox for
updates.
</p>
</div>
</div>
@ -215,7 +217,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
>
{isStartingIndexing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Spinner size="sm" className="mr-2" />
Starting...
</>
) : (

View file

@ -175,14 +175,28 @@ export const OTHER_CONNECTORS = [
},
] as const;
// Composio Connector (Single entry that opens toolkit selector)
// Composio Connectors - Individual entries for each supported toolkit
export const COMPOSIO_CONNECTORS = [
{
id: "composio-connector",
title: "Composio",
description: "Connect 100+ apps via Composio (Google, Slack, Notion, etc.)",
connectorType: EnumConnectorName.COMPOSIO_CONNECTOR,
// No authEndpoint - handled via toolkit selector view
id: "composio-googledrive",
title: "Google Drive",
description: "Search your Drive files via Composio",
connectorType: EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
authEndpoint: "/api/v1/auth/composio/connector/add/?toolkit_id=googledrive",
},
{
id: "composio-gmail",
title: "Gmail",
description: "Search through your emails via Composio",
connectorType: EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR,
authEndpoint: "/api/v1/auth/composio/connector/add/?toolkit_id=gmail",
},
{
id: "composio-googlecalendar",
title: "Google Calendar",
description: "Search through your events via Composio",
connectorType: EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR,
authEndpoint: "/api/v1/auth/composio/connector/add/?toolkit_id=googlecalendar",
},
] as const;

View file

@ -7,7 +7,9 @@ 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", "mcp-list"]).optional(),
view: z
.enum(["configure", "edit", "connect", "youtube", "accounts", "mcp-list", "composio"])
.optional(),
connector: z.string().optional(),
connectorId: z.string().optional(),
connectorType: z.string().optional(),

View file

@ -26,7 +26,11 @@ import {
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
import type { IndexingConfigState } from "../constants/connector-constants";
import { OAUTH_CONNECTORS, OTHER_CONNECTORS } from "../constants/connector-constants";
import {
COMPOSIO_CONNECTORS,
OAUTH_CONNECTORS,
OTHER_CONNECTORS,
} from "../constants/connector-constants";
import {
dateRangeSchema,
frequencyMinutesSchema,
@ -83,10 +87,6 @@ export const useConnectorDialog = () => {
// MCP list view state (for managing multiple MCP connectors)
const [viewingMCPList, setViewingMCPList] = useState(false);
// Composio toolkit view state
const [viewingComposio, setViewingComposio] = useState(false);
const [connectingComposioToolkit, setConnectingComposioToolkit] = useState<string | null>(null);
// Track if we came from accounts list when entering edit mode
const [cameFromAccountsList, setCameFromAccountsList] = useState<{
connectorType: string;
@ -159,32 +159,28 @@ export const useConnectorDialog = () => {
setViewingMCPList(true);
}
// Clear Composio view if view is not "composio" anymore
if (params.view !== "composio" && viewingComposio) {
setViewingComposio(false);
setConnectingComposioToolkit(null);
}
// Handle Composio view
if (params.view === "composio" && !viewingComposio) {
setViewingComposio(true);
}
// Handle connect view
if (params.view === "connect" && params.connectorType && !connectingConnectorType) {
setConnectingConnectorType(params.connectorType);
}
// Handle accounts view
if (params.view === "accounts" && params.connectorType && !viewingAccountsType) {
const oauthConnector = OAUTH_CONNECTORS.find(
(c) => c.connectorType === params.connectorType
);
if (oauthConnector) {
setViewingAccountsType({
connectorType: oauthConnector.connectorType,
connectorTitle: oauthConnector.title,
});
if (params.view === "accounts" && params.connectorType) {
// Update state if not set, or if connectorType has changed
const needsUpdate =
!viewingAccountsType || viewingAccountsType.connectorType !== params.connectorType;
if (needsUpdate) {
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
const oauthConnector =
OAUTH_CONNECTORS.find((c) => c.connectorType === params.connectorType) ||
COMPOSIO_CONNECTORS.find((c) => c.connectorType === params.connectorType);
if (oauthConnector) {
setViewingAccountsType({
connectorType: oauthConnector.connectorType,
connectorTitle: oauthConnector.title,
});
}
}
}
@ -195,7 +191,10 @@ export const useConnectorDialog = () => {
// Handle configure view (for page refresh support)
if (params.view === "configure" && params.connector && !indexingConfig && allConnectors) {
const oauthConnector = OAUTH_CONNECTORS.find((c) => c.id === params.connector);
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
const oauthConnector =
OAUTH_CONNECTORS.find((c) => c.id === params.connector) ||
COMPOSIO_CONNECTORS.find((c) => c.id === params.connector);
if (oauthConnector) {
let existingConnector: SearchSourceConnector | undefined;
if (params.connectorId) {
@ -293,6 +292,7 @@ export const useConnectorDialog = () => {
indexingConfig,
connectingConnectorType,
viewingAccountsType,
viewingMCPList,
]);
// Detect OAuth success / Failure and transition to config view
@ -328,58 +328,72 @@ export const useConnectorDialog = () => {
return;
}
if (
params.success === "true" &&
params.connector &&
searchSpaceId &&
params.modal === "connectors"
) {
const oauthConnector = OAUTH_CONNECTORS.find((c) => c.id === params.connector);
if (oauthConnector) {
refetchAllConnectors().then((result) => {
if (!result.data) return;
if (params.success === "true" && searchSpaceId && params.modal === "connectors") {
refetchAllConnectors().then((result) => {
if (!result.data) return;
let newConnector: SearchSourceConnector | undefined;
if (params.connectorId) {
const connectorId = parseInt(params.connectorId, 10);
newConnector = result.data.find((c: SearchSourceConnector) => c.id === connectorId);
} else {
let newConnector: SearchSourceConnector | undefined;
let oauthConnector:
| (typeof OAUTH_CONNECTORS)[number]
| (typeof COMPOSIO_CONNECTORS)[number]
| undefined;
// First, try to find connector by connectorId if provided
if (params.connectorId) {
const connectorId = parseInt(params.connectorId, 10);
newConnector = result.data.find((c: SearchSourceConnector) => c.id === connectorId);
// If we found the connector, find the matching OAuth/Composio connector by type
if (newConnector) {
oauthConnector =
OAUTH_CONNECTORS.find((c) => c.connectorType === newConnector!.connector_type) ||
COMPOSIO_CONNECTORS.find((c) => c.connectorType === newConnector!.connector_type);
}
}
// If we don't have a connector yet, try to find by connector param
if (!newConnector && params.connector) {
oauthConnector =
OAUTH_CONNECTORS.find((c) => c.id === params.connector) ||
COMPOSIO_CONNECTORS.find((c) => c.id === params.connector);
if (oauthConnector) {
newConnector = result.data.find(
(c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType
(c: SearchSourceConnector) => c.connector_type === oauthConnector!.connectorType
);
}
}
if (newConnector) {
const connectorValidation = searchSourceConnector.safeParse(newConnector);
if (connectorValidation.success) {
// Track connector connected event for OAuth connectors
trackConnectorConnected(
Number(searchSpaceId),
oauthConnector.connectorType,
newConnector.id
);
if (newConnector && oauthConnector) {
const connectorValidation = searchSourceConnector.safeParse(newConnector);
if (connectorValidation.success) {
// Track connector connected event for OAuth/Composio connectors
trackConnectorConnected(
Number(searchSpaceId),
oauthConnector.connectorType,
newConnector.id
);
const config = validateIndexingConfigState({
connectorType: oauthConnector.connectorType,
connectorId: newConnector.id,
connectorTitle: oauthConnector.title,
});
setIndexingConfig(config);
setIndexingConnector(newConnector);
setIndexingConnectorConfig(newConnector.config);
setIsOpen(true);
const url = new URL(window.location.href);
url.searchParams.delete("success");
url.searchParams.set("connectorId", newConnector.id.toString());
url.searchParams.set("view", "configure");
window.history.replaceState({}, "", url.toString());
} else {
console.warn("Invalid connector data after OAuth:", connectorValidation.error);
toast.error("Failed to validate connector data");
}
const config = validateIndexingConfigState({
connectorType: oauthConnector.connectorType,
connectorId: newConnector.id,
connectorTitle: oauthConnector.title,
});
setIndexingConfig(config);
setIndexingConnector(newConnector);
setIndexingConnectorConfig(newConnector.config);
setIsOpen(true);
const url = new URL(window.location.href);
url.searchParams.delete("success");
url.searchParams.set("connectorId", newConnector.id.toString());
url.searchParams.set("view", "configure");
window.history.replaceState({}, "", url.toString());
} else {
console.warn("Invalid connector data after OAuth:", connectorValidation.error);
toast.error("Failed to validate connector data");
}
});
}
}
});
}
} catch (error) {
// Invalid query params - log but don't crash
@ -389,17 +403,18 @@ export const useConnectorDialog = () => {
// Handle OAuth connection
const handleConnectOAuth = useCallback(
async (connector: (typeof OAUTH_CONNECTORS)[number]) => {
async (connector: (typeof OAUTH_CONNECTORS)[number] | (typeof COMPOSIO_CONNECTORS)[number]) => {
if (!searchSpaceId || !connector.authEndpoint) return;
// Set connecting state immediately to disable button and show spinner
setConnectingId(connector.id);
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}?space_id=${searchSpaceId}`,
{ method: "GET" }
);
// Check if authEndpoint already has query parameters
const separator = connector.authEndpoint.includes("?") ? "&" : "?";
const url = `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}${separator}space_id=${searchSpaceId}`;
const response = await authenticatedFetch(url, { method: "GET" });
if (!response.ok) {
throw new Error(`Failed to initiate ${connector.title} OAuth`);
@ -799,23 +814,19 @@ export const useConnectorDialog = () => {
// Handle viewing accounts list for OAuth connector type
const handleViewAccountsList = useCallback(
(connectorType: string, connectorTitle: string) => {
(connectorType: string, _connectorTitle?: string) => {
if (!searchSpaceId) return;
setViewingAccountsType({
connectorType,
connectorTitle,
});
// Update URL to show accounts view, preserving current tab
// The useEffect will handle setting viewingAccountsType based on URL params
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "accounts");
url.searchParams.set("connectorType", connectorType);
// Keep the current tab in URL so we can go back to it
window.history.pushState({ modal: true }, "", url.toString());
router.replace(url.pathname + url.search, { scroll: false });
},
[searchSpaceId]
[searchSpaceId, router]
);
// Handle going back from accounts list view
@ -839,8 +850,8 @@ export const useConnectorDialog = () => {
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]);
router.replace(url.pathname + url.search, { scroll: false });
}, [searchSpaceId, router]);
// Handle going back from MCP list view
const handleBackFromMCPList = useCallback(() => {
@ -861,71 +872,15 @@ export const useConnectorDialog = () => {
router.replace(url.pathname + url.search, { scroll: false });
}, [router]);
// Handle opening Composio toolkit view
const handleOpenComposio = useCallback(() => {
if (!searchSpaceId) return;
setViewingComposio(true);
// Update URL to show Composio view
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "composio");
window.history.pushState({ modal: true }, "", url.toString());
}, [searchSpaceId]);
// Handle going back from Composio view
const handleBackFromComposio = useCallback(() => {
setViewingComposio(false);
setConnectingComposioToolkit(null);
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 connecting a Composio toolkit
const handleConnectComposioToolkit = useCallback(
async (toolkitId: string) => {
if (!searchSpaceId) return;
setConnectingComposioToolkit(toolkitId);
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/auth/composio/connector/add?space_id=${searchSpaceId}&toolkit_id=${toolkitId}`,
{ method: "GET" }
);
if (!response.ok) {
throw new Error(`Failed to initiate Composio OAuth for ${toolkitId}`);
}
const data = await response.json();
if (data.auth_url) {
// Redirect to Composio OAuth
window.location.href = data.auth_url;
} else {
throw new Error("No authorization URL received from Composio");
}
} catch (error) {
console.error("Error connecting Composio toolkit:", error);
toast.error(`Failed to connect ${toolkitId}. Please try again.`);
setConnectingComposioToolkit(null);
}
},
[searchSpaceId]
);
// Handle starting indexing
const handleStartIndexing = useCallback(
async (refreshConnectors: () => void) => {
if (!indexingConfig || !searchSpaceId) return;
// Validate date range (skip for Google Drive and Webcrawler)
// Validate date range (skip for Google Drive, Composio Drive, and Webcrawler)
if (
indexingConfig.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
indexingConfig.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
indexingConfig.connectorType !== "WEBCRAWLER_CONNECTOR"
) {
const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate });
@ -970,8 +925,12 @@ export const useConnectorDialog = () => {
});
}
// Handle Google Drive folder selection
if (indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" && indexingConnectorConfig) {
// Handle Google Drive folder selection (regular and Composio)
if (
(indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" ||
indexingConfig.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR") &&
indexingConnectorConfig
) {
const selectedFolders = indexingConnectorConfig.selected_folders as
| Array<{ id: string; name: string }>
| undefined;
@ -1191,8 +1150,12 @@ export const useConnectorDialog = () => {
return;
}
// Prevent periodic indexing for Google Drive without folders/files selected
if (periodicEnabled && editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR") {
// Prevent periodic indexing for Google Drive (regular or Composio) without folders/files selected
if (
periodicEnabled &&
(editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR")
) {
const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as
| Array<{ id: string; name: string }>
| undefined;
@ -1241,8 +1204,11 @@ export const useConnectorDialog = () => {
if (!editingConnector.is_indexable) {
// Non-indexable connectors (like Tavily API) don't need re-indexing
indexingDescription = "Settings saved.";
} else if (editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR") {
// Google Drive uses folder selection from config, not date ranges
} else if (
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"
) {
// Google Drive (both regular and Composio) uses folder selection from config, not date ranges
const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as
| Array<{ id: string; name: string }>
| undefined;
@ -1423,13 +1389,24 @@ export const useConnectorDialog = () => {
setIsDisconnecting(false);
}
},
[editingConnector, searchSpaceId, deleteConnector, router]
[editingConnector, searchSpaceId, deleteConnector, router, cameFromMCPList]
);
// Handle quick index (index without date picker, uses backend defaults)
// Handle quick index (index with selected date range, or backend defaults if none selected)
const handleQuickIndexConnector = useCallback(
async (connectorId: number, connectorType?: string) => {
if (!searchSpaceId) return;
async (
connectorId: number,
connectorType?: string,
stopIndexing?: (id: number) => void,
startDate?: Date,
endDate?: Date
) => {
if (!searchSpaceId) {
if (stopIndexing) {
stopIndexing(connectorId);
}
return;
}
// Track quick index clicked event
if (connectorType) {
@ -1437,10 +1414,16 @@ export const useConnectorDialog = () => {
}
try {
// Format dates if provided, otherwise pass undefined (backend will use defaults)
const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined;
const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined;
await indexConnector({
connector_id: connectorId,
queryParams: {
search_space_id: searchSpaceId,
start_date: startDateStr,
end_date: endDateStr,
},
});
toast.success("Indexing started", {
@ -1451,12 +1434,18 @@ export const useConnectorDialog = () => {
queryClient.invalidateQueries({
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
});
// Note: Don't call stopIndexing here - let useIndexingConnectors hook
// detect when last_indexed_at changes via Electric SQL
} catch (error) {
console.error("Error indexing connector content:", error);
toast.error(error instanceof Error ? error.message : "Failed to start indexing");
// Stop indexing state on error
if (stopIndexing) {
stopIndexing(connectorId);
}
}
},
[searchSpaceId, indexConnector]
[searchSpaceId, indexConnector, queryClient]
);
// Handle going back from edit view
@ -1578,7 +1567,6 @@ export const useConnectorDialog = () => {
allConnectors,
viewingAccountsType,
viewingMCPList,
viewingComposio,
// Setters
setSearchQuery,
@ -1614,12 +1602,5 @@ export const useConnectorDialog = () => {
connectorConfig,
setConnectorConfig,
setIndexingConnectorConfig,
// Composio
viewingComposio,
connectingComposioToolkit,
handleOpenComposio,
handleBackFromComposio,
handleConnectComposioToolkit,
};
};

View file

@ -2,17 +2,24 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { InboxItem } from "@/contracts/types/inbox.types";
import { isConnectorIndexingMetadata } from "@/contracts/types/inbox.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
* 2. Detecting in_progress notifications from Electric SQL to restore state after remounts
* 3. Clearing indexing state when notifications become completed or failed
* 4. Clearing indexing state when Electric SQL detects last_indexed_at changed
*
* The actual `last_indexed_at` value comes from Electric SQL/PGlite, not local state.
*/
export function useIndexingConnectors(connectors: SearchSourceConnector[]) {
export function useIndexingConnectors(
connectors: SearchSourceConnector[],
inboxItems?: InboxItem[]
) {
// Set of connector IDs that are currently indexing
const [indexingConnectorIds, setIndexingConnectorIds] = useState<Set<number>>(new Set());
@ -22,31 +29,71 @@ export function useIndexingConnectors(connectors: SearchSourceConnector[]) {
// Detect when last_indexed_at changes (indexing completed) via Electric SQL
useEffect(() => {
const previousValues = previousLastIndexedAtRef.current;
const newIndexingIds = new Set(indexingConnectorIds);
let hasChanges = false;
for (const connector of connectors) {
const previousValue = previousValues.get(connector.id);
const currentValue = connector.last_indexed_at;
// If last_indexed_at changed and connector was in indexing state, clear it
// If last_indexed_at changed, clear it from indexing state
if (
previousValue !== undefined && // We've seen this connector before
previousValue !== currentValue && // Value changed
indexingConnectorIds.has(connector.id) // It was marked as indexing
previousValue !== currentValue // Value changed
) {
newIndexingIds.delete(connector.id);
hasChanges = true;
// Use functional update to access current state
setIndexingConnectorIds((prev) => {
if (prev.has(connector.id)) {
const next = new Set(prev);
next.delete(connector.id);
return next;
}
return prev;
});
}
// Update previous value tracking
previousValues.set(connector.id, currentValue);
}
}, [connectors]);
if (hasChanges) {
setIndexingConnectorIds(newIndexingIds);
}
}, [connectors, indexingConnectorIds]);
// Detect notification status changes and update indexing state accordingly
// This restores spinner state after component remounts and handles all status transitions
useEffect(() => {
if (!inboxItems || inboxItems.length === 0) return;
setIndexingConnectorIds((prev) => {
const newIndexingIds = new Set(prev);
let hasChanges = false;
for (const item of inboxItems) {
// Only check connector_indexing notifications
if (item.type !== "connector_indexing") continue;
const metadata = isConnectorIndexingMetadata(item.metadata) ? item.metadata : null;
if (!metadata) continue;
// If status is "in_progress", add connector to indexing set
if (metadata.status === "in_progress") {
if (!newIndexingIds.has(metadata.connector_id)) {
newIndexingIds.add(metadata.connector_id);
hasChanges = true;
}
}
// If status is "completed" or "failed", remove connector from indexing set
else if (
metadata.status === "completed" ||
metadata.status === "failed" ||
(metadata.error_message && metadata.error_message.trim().length > 0)
) {
if (newIndexingIds.has(metadata.connector_id)) {
newIndexingIds.delete(metadata.connector_id);
hasChanges = true;
}
}
}
return hasChanges ? newIndexingIds : prev;
});
}, [inboxItems]);
// Add a connector to the indexing set (called when indexing starts)
const startIndexing = useCallback((connectorId: number) => {

View file

@ -1,11 +1,12 @@
"use client";
import { ArrowRight, Cable, Loader2 } from "lucide-react";
import { ArrowRight, Cable } 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 { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { TabsContent } from "@/components/ui/tabs";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
@ -13,8 +14,9 @@ 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 { COMPOSIO_CONNECTORS, OAUTH_CONNECTORS } from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
import { getConnectorDisplayName } from "./all-connectors-tab";
interface ActiveConnectorsTabProps {
searchQuery: string;
@ -113,7 +115,10 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
// Get display info for OAuth connector type
const getOAuthConnectorTypeInfo = (connectorType: string) => {
const oauthConnector = OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType);
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
const oauthConnector =
OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType) ||
COMPOSIO_CONNECTORS.find((c) => c.connectorType === connectorType);
return {
title:
oauthConnector?.title ||
@ -205,7 +210,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
<p className="text-[14px] font-semibold leading-tight truncate">{title}</p>
{isAnyIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
<Spinner size="xs" />
Syncing
</p>
) : (
@ -260,13 +265,13 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-[14px] font-semibold leading-tight">
{connector.name}
<p className="text-[14px] font-semibold leading-tight truncate">
{getConnectorDisplayName(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" />
<Spinner size="xs" />
Syncing
</p>
) : !isMCPConnector ? (

View file

@ -4,7 +4,6 @@ import type { FC } from "react";
import { EnumConnectorName } from "@/contracts/enums/connector";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { isSelfHosted } from "@/lib/env-config";
import { ComposioConnectorCard } from "../components/composio-connector-card";
import { ConnectorCard } from "../components/connector-card";
import {
COMPOSIO_CONNECTORS,
@ -35,13 +34,14 @@ interface AllConnectorsTabProps {
allConnectors: SearchSourceConnector[] | undefined;
documentTypeCounts?: Record<string, number>;
indexingConnectorIds?: Set<number>;
onConnectOAuth: (connector: (typeof OAUTH_CONNECTORS)[number]) => void;
onConnectOAuth: (
connector: (typeof OAUTH_CONNECTORS)[number] | (typeof COMPOSIO_CONNECTORS)[number]
) => void;
onConnectNonOAuth?: (connectorType: string) => void;
onCreateWebcrawler?: () => void;
onCreateYouTubeCrawler?: () => void;
onManage?: (connector: SearchSourceConnector) => void;
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
onOpenComposio?: () => void;
}
export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
@ -57,7 +57,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
onCreateYouTubeCrawler,
onManage,
onViewAccountsList,
onOpenComposio,
}) => {
// Check if self-hosted mode (for showing self-hosted only connectors)
const selfHosted = isSelfHosted();
@ -93,23 +92,18 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
c.description.toLowerCase().includes(searchQuery.toLowerCase())
);
// Count Composio connectors
const composioConnectorCount = allConnectors
? allConnectors.filter(
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.COMPOSIO_CONNECTOR
).length
: 0;
return (
<div className="space-y-8">
{/* Quick Connect */}
{filteredOAuth.length > 0 && (
{/* Managed OAuth (Composio Integrations) */}
{filteredComposio.length > 0 && (
<section>
<div className="flex items-center gap-2 mb-4">
<h3 className="text-sm font-semibold text-muted-foreground">Quick Connect</h3>
<h3 className="text-sm font-semibold text-muted-foreground">
Managed OAuth (Composio)
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{filteredOAuth.map((connector) => {
{filteredComposio.map((connector) => {
const isConnected = connectedTypes.has(connector.connectorType);
const isConnecting = connectingId === connector.id;
@ -123,18 +117,6 @@ 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) => {
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
);
const documentCount = getDocumentCountForConnector(
connector.connectorType,
documentTypeCounts
@ -168,29 +150,59 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
</section>
)}
{/* Composio Integrations */}
{/* {filteredComposio.length > 0 && onOpenComposio && (
{/* Quick Connect */}
{filteredOAuth.length > 0 && (
<section>
<div className="flex items-center gap-2 mb-4">
<h3 className="text-sm font-semibold text-muted-foreground">Managed OAuth</h3>
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20 font-medium">
No verification needed
</span>
<h3 className="text-sm font-semibold text-muted-foreground">Quick Connect</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{filteredComposio.map((connector) => (
<ComposioConnectorCard
key={connector.id}
id={connector.id}
title={connector.title}
description={connector.description}
connectorCount={composioConnectorCount}
onConnect={onOpenComposio}
/>
))}
{filteredOAuth.map((connector) => {
const isConnected = connectedTypes.has(connector.connectorType);
const isConnecting = connectingId === connector.id;
// Find all connectors of this type
const typeConnectors =
isConnected && allConnectors
? allConnectors.filter(
(c: SearchSourceConnector) => c.connector_type === connector.connectorType
)
: [];
const accountCount = typeConnectors.length;
const documentCount = getDocumentCountForConnector(
connector.connectorType,
documentTypeCounts
);
// Check if any account is currently indexing
const isIndexing = typeConnectors.some((c) => indexingConnectorIds?.has(c.id));
return (
<ConnectorCard
key={connector.id}
id={connector.id}
title={connector.title}
description={connector.description}
connectorType={connector.connectorType}
isConnected={isConnected}
isConnecting={isConnecting}
documentCount={documentCount}
accountCount={accountCount}
isIndexing={isIndexing}
onConnect={() => onConnectOAuth(connector)}
onManage={
isConnected && onViewAccountsList
? () => onViewAccountsList(connector.connectorType, connector.title)
: undefined
}
/>
);
})}
</div>
</section>
)} */}
)}
{/* More Integrations */}
{filteredOther.length > 0 && (

View file

@ -31,7 +31,10 @@ export const CONNECTOR_TO_DOCUMENT_TYPE: Record<string, string> = {
// Special mappings (connector type differs from document type)
GOOGLE_DRIVE_CONNECTOR: "GOOGLE_DRIVE_FILE",
WEBCRAWLER_CONNECTOR: "CRAWLED_URL",
COMPOSIO_CONNECTOR: "COMPOSIO_CONNECTOR",
// Composio connectors map to their own document types
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
COMPOSIO_GMAIL_CONNECTOR: "COMPOSIO_GMAIL_CONNECTOR",
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
};
/**

View file

@ -1,355 +0,0 @@
"use client";
import {
ArrowLeft,
Calendar,
Check,
ExternalLink,
FileText,
Github,
HardDrive,
Loader2,
Mail,
MessageSquare,
Zap,
} from "lucide-react";
import Image from "next/image";
import type { FC } from "react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface ComposioToolkit {
id: string;
name: string;
description: string;
isIndexable: boolean;
}
interface ComposioToolkitViewProps {
searchSpaceId: string;
connectedToolkits: string[];
onBack: () => void;
onConnectToolkit: (toolkitId: string) => void;
isConnecting: boolean;
connectingToolkitId: string | null;
}
// Available Composio toolkits
const COMPOSIO_TOOLKITS: ComposioToolkit[] = [
{
id: "googledrive",
name: "Google Drive",
description: "Search your Drive files and documents",
isIndexable: true,
},
{
id: "gmail",
name: "Gmail",
description: "Search through your emails",
isIndexable: true,
},
{
id: "googlecalendar",
name: "Google Calendar",
description: "Search through your events",
isIndexable: true,
},
{
id: "slack",
name: "Slack",
description: "Search Slack messages",
isIndexable: false,
},
{
id: "notion",
name: "Notion",
description: "Search Notion pages",
isIndexable: false,
},
{
id: "github",
name: "GitHub",
description: "Search repositories and code",
isIndexable: false,
},
];
// Get icon for toolkit
const getToolkitIcon = (toolkitId: string, className?: string) => {
const iconClass = className || "size-5";
switch (toolkitId) {
case "googledrive":
return (
<Image
src="/connectors/google-drive.svg"
alt="Google Drive"
width={20}
height={20}
className={iconClass}
/>
);
case "gmail":
return (
<Image
src="/connectors/google-gmail.svg"
alt="Gmail"
width={20}
height={20}
className={iconClass}
/>
);
case "googlecalendar":
return (
<Image
src="/connectors/google-calendar.svg"
alt="Google Calendar"
width={20}
height={20}
className={iconClass}
/>
);
case "slack":
return (
<Image
src="/connectors/slack.svg"
alt="Slack"
width={20}
height={20}
className={iconClass}
/>
);
case "notion":
return (
<Image
src="/connectors/notion.svg"
alt="Notion"
width={20}
height={20}
className={iconClass}
/>
);
case "github":
return (
<Image
src="/connectors/github.svg"
alt="GitHub"
width={20}
height={20}
className={iconClass}
/>
);
default:
return <Zap className={iconClass} />;
}
};
export const ComposioToolkitView: FC<ComposioToolkitViewProps> = ({
searchSpaceId,
connectedToolkits,
onBack,
onConnectToolkit,
isConnecting,
connectingToolkitId,
}) => {
const [hoveredToolkit, setHoveredToolkit] = useState<string | null>(null);
// Separate indexable and non-indexable toolkits
const indexableToolkits = COMPOSIO_TOOLKITS.filter((t) => t.isIndexable);
const nonIndexableToolkits = COMPOSIO_TOOLKITS.filter((t) => !t.isIndexable);
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-6 sm:px-12 pt-8 sm:pt-10 pb-4 sm:pb-6 border-b border-border/50 bg-muted">
{/* Back button */}
<button
type="button"
onClick={onBack}
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground mb-6 w-fit transition-colors"
>
<ArrowLeft className="size-4" />
Back to connectors
</button>
{/* Header content */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
<div className="flex gap-4 flex-1 w-full sm:w-auto">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500/20 to-purple-500/20 border border-violet-500/30 shrink-0">
<Image
src="/connectors/composio.svg"
alt="Composio"
width={28}
height={28}
className="size-7"
/>
</div>
<div className="flex-1 min-w-0">
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">Composio</h2>
<p className="text-xs sm:text-sm text-muted-foreground mt-1">
Connect 100+ apps with managed OAuth - no verification needed
</p>
</div>
</div>
<a
href="https://composio.dev"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<span>Powered by Composio</span>
<ExternalLink className="size-3" />
</a>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 sm:px-12 py-6 sm:py-8">
{/* Indexable Toolkits (Google Services) */}
<section className="mb-8">
<div className="flex items-center gap-2 mb-4">
<h3 className="text-sm font-semibold text-foreground">Google Services</h3>
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 h-5 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
>
Indexable
</Badge>
</div>
<p className="text-xs text-muted-foreground mb-4">
Connect Google services via Composio&apos;s verified OAuth app. Your data will be
indexed and searchable.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{indexableToolkits.map((toolkit) => {
const isConnected = connectedToolkits.includes(toolkit.id);
const isThisConnecting = connectingToolkitId === toolkit.id;
return (
<div
key={toolkit.id}
onMouseEnter={() => setHoveredToolkit(toolkit.id)}
onMouseLeave={() => setHoveredToolkit(null)}
className={cn(
"group relative flex flex-col p-4 rounded-xl border transition-all duration-200",
isConnected
? "border-emerald-500/30 bg-emerald-500/5"
: "border-border bg-card hover:border-violet-500/30 hover:bg-violet-500/5"
)}
>
<div className="flex items-start justify-between mb-3">
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-lg border transition-colors",
isConnected
? "bg-emerald-500/10 border-emerald-500/20"
: "bg-muted border-border group-hover:border-violet-500/20 group-hover:bg-violet-500/10"
)}
>
{getToolkitIcon(toolkit.id, "size-5")}
</div>
{isConnected && (
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 h-5 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
>
<Check className="size-3 mr-0.5" />
Connected
</Badge>
)}
</div>
<h4 className="text-sm font-medium mb-1">{toolkit.name}</h4>
<p className="text-xs text-muted-foreground mb-4 flex-1">{toolkit.description}</p>
<Button
size="sm"
variant={isConnected ? "secondary" : "default"}
className={cn(
"w-full h-8 text-xs font-medium",
!isConnected && "bg-violet-600 hover:bg-violet-700 text-white"
)}
onClick={() => onConnectToolkit(toolkit.id)}
disabled={isConnecting || isConnected}
>
{isThisConnecting ? (
<>
<Loader2 className="size-3 mr-1.5 animate-spin" />
Connecting...
</>
) : isConnected ? (
"Connected"
) : (
"Connect"
)}
</Button>
</div>
);
})}
</div>
</section>
{/* Non-Indexable Toolkits (Coming Soon) */}
<section>
<div className="flex items-center gap-2 mb-4">
<h3 className="text-sm font-semibold text-foreground">More Integrations</h3>
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 h-5 bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
>
Coming Soon
</Badge>
</div>
<p className="text-xs text-muted-foreground mb-4">
Connect these services for future indexing support. Currently available for connection
only.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 opacity-60">
{nonIndexableToolkits.map((toolkit) => (
<div
key={toolkit.id}
className="group relative flex flex-col p-4 rounded-xl border border-border bg-card/50"
>
<div className="flex items-start justify-between mb-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg border bg-muted border-border">
{getToolkitIcon(toolkit.id, "size-5")}
</div>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-5">
Soon
</Badge>
</div>
<h4 className="text-sm font-medium mb-1">{toolkit.name}</h4>
<p className="text-xs text-muted-foreground mb-4 flex-1">{toolkit.description}</p>
<Button
size="sm"
variant="outline"
className="w-full h-8 text-xs font-medium"
disabled
>
Coming Soon
</Button>
</div>
))}
</div>
</section>
{/* Info footer */}
<div className="mt-8 p-4 rounded-xl bg-muted/50 border border-border/50">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500/10 border border-violet-500/20 shrink-0">
<Zap className="size-4 text-violet-500" />
</div>
<div>
<h4 className="text-sm font-medium mb-1">Why use Composio?</h4>
<p className="text-xs text-muted-foreground leading-relaxed">
Composio provides pre-verified OAuth apps, so you don&apos;t need to wait for Google
app verification. Your data is securely processed through Composio&apos;s managed
authentication.
</p>
</div>
</div>
</div>
</div>
</div>
);
};

View file

@ -1,9 +1,10 @@
"use client";
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
import { ArrowLeft, Loader2, Plus, Server } from "lucide-react";
import { ArrowLeft, Plus, Server } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
@ -143,7 +144,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
>
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-primary/10 shrink-0">
{isConnecting ? (
<Loader2 className="size-3 animate-spin text-primary" />
<Spinner size="xs" className="text-primary" />
) : (
<Plus className="size-3 text-primary" />
)}
@ -207,7 +208,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
</p>
{isIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
<Spinner size="xs" />
Syncing
</p>
) : (

View file

@ -2,7 +2,7 @@
import { TagInput, type Tag as TagType } from "emblor";
import { useAtom } from "jotai";
import { ArrowLeft, Loader2 } from "lucide-react";
import { ArrowLeft } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { type FC, useState } from "react";
@ -10,6 +10,7 @@ import { toast } from "sonner";
import { createDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
@ -222,7 +223,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Spinner size="sm" className="mr-2" />
{t("processing")}
</>
) : (

View file

@ -6,6 +6,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { CommentActionsProps } from "./types";
@ -23,7 +24,7 @@ export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: Comment
size="icon"
className="size-7 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity"
>
<MoreHorizontal className="size-4" />
<MoreHorizontal className="size-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@ -33,8 +34,9 @@ export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: Comment
Edit
</DropdownMenuItem>
)}
{canEdit && canDelete && <DropdownMenuSeparator />}
{canDelete && (
<DropdownMenuItem onClick={onDelete} className="text-destructive">
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>

View file

@ -1,7 +1,7 @@
"use client";
import { Loader2 } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { MemberMentionItem } from "./member-mention-item";
import type { MemberMentionPickerProps } from "./types";
@ -24,7 +24,7 @@ export function MemberMentionPicker({
if (isLoading) {
return (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
<Spinner size="md" className="text-muted-foreground" />
</div>
);
}

View file

@ -0,0 +1,364 @@
"use client";
import {
ChevronDown,
ChevronRight,
File,
FileSpreadsheet,
FileText,
FolderClosed,
FolderOpen,
HardDrive,
Image,
Presentation,
} from "lucide-react";
import { useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { useComposioDriveFolders } from "@/hooks/use-composio-drive-folders";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import { cn } from "@/lib/utils";
interface DriveItem {
id: string;
name: string;
mimeType: string;
isFolder: boolean;
parents?: string[];
size?: number;
iconLink?: string;
}
interface ItemTreeNode {
item: DriveItem;
children: DriveItem[] | null; // null = not loaded, [] = loaded but empty
isExpanded: boolean;
isLoading: boolean;
}
interface SelectedFolder {
id: string;
name: string;
}
interface ComposioDriveFolderTreeProps {
connectorId: number;
selectedFolders: SelectedFolder[];
onSelectFolders: (folders: SelectedFolder[]) => void;
selectedFiles?: SelectedFolder[];
onSelectFiles?: (files: SelectedFolder[]) => void;
}
// 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 <FileSpreadsheet className={`${className} text-green-500`} />;
}
if (mimeType.includes("presentation") || mimeType.includes("powerpoint")) {
return <Presentation className={`${className} text-orange-500`} />;
}
if (mimeType.includes("document") || mimeType.includes("word") || mimeType.includes("text")) {
return <FileText className={`${className} text-gray-500`} />;
}
if (mimeType.includes("image")) {
return <Image className={`${className} text-purple-500`} />;
}
return <File className={`${className} text-gray-500`} />;
}
export function ComposioDriveFolderTree({
connectorId,
selectedFolders,
onSelectFolders,
selectedFiles = [],
onSelectFiles = () => {},
}: ComposioDriveFolderTreeProps) {
const [itemStates, setItemStates] = useState<Map<string, ItemTreeNode>>(new Map());
const { data: rootData, isLoading: isLoadingRoot } = useComposioDriveFolders({
connectorId,
});
const rootItems = rootData?.items || [];
const isFolderSelected = (folderId: string): boolean => {
return selectedFolders.some((f) => f.id === folderId);
};
const isFileSelected = (fileId: string): boolean => {
return selectedFiles.some((f) => f.id === fileId);
};
const toggleFolderSelection = (folderId: string, folderName: string) => {
if (isFolderSelected(folderId)) {
onSelectFolders(selectedFolders.filter((f) => f.id !== folderId));
} else {
onSelectFolders([...selectedFolders, { id: folderId, name: folderName }]);
}
};
const toggleFileSelection = (fileId: string, fileName: string) => {
if (isFileSelected(fileId)) {
onSelectFiles(selectedFiles.filter((f) => f.id !== fileId));
} else {
onSelectFiles([...selectedFiles, { id: fileId, name: fileName }]);
}
};
/**
* Find an item by ID across all loaded items (root and nested).
*/
const findItem = (itemId: string): DriveItem | undefined => {
const state = itemStates.get(itemId);
if (state?.item) return state.item;
const rootItem = rootItems.find((item) => item.id === itemId);
if (rootItem) return rootItem;
for (const [, nodeState] of itemStates) {
if (nodeState.children) {
const found = nodeState.children.find((child) => child.id === itemId);
if (found) return found;
}
}
return undefined;
};
/**
* Load and display contents of a specific folder.
*/
const loadFolderContents = async (folderId: string) => {
try {
setItemStates((prev) => {
const newMap = new Map(prev);
const existing = newMap.get(folderId);
if (existing) {
newMap.set(folderId, { ...existing, isLoading: true });
} else {
const item = findItem(folderId);
if (item) {
newMap.set(folderId, {
item,
children: null,
isExpanded: false,
isLoading: true,
});
}
}
return newMap;
});
const data = await connectorsApiService.listComposioDriveFolders({
connector_id: connectorId,
parent_id: folderId,
});
const items = data.items || [];
setItemStates((prev) => {
const newMap = new Map(prev);
const existing = newMap.get(folderId);
const item = existing?.item || findItem(folderId);
if (item) {
newMap.set(folderId, {
item,
children: items,
isExpanded: true,
isLoading: false,
});
} else {
console.error(`Could not find item for folderId: ${folderId}`);
}
return newMap;
});
} catch (error) {
console.error("Error loading folder contents:", error);
setItemStates((prev) => {
const newMap = new Map(prev);
const existing = newMap.get(folderId);
if (existing) {
newMap.set(folderId, { ...existing, isLoading: false });
}
return newMap;
});
}
};
/**
* Toggle folder expand/collapse state.
*/
const toggleFolder = async (item: DriveItem) => {
if (!item.isFolder) return;
const state = itemStates.get(item.id);
if (!state || state.children === null) {
await loadFolderContents(item.id);
} else {
setItemStates((prev) => {
const newMap = new Map(prev);
newMap.set(item.id, {
...state,
isExpanded: !state.isExpanded,
});
return newMap;
});
}
};
/**
* Render a single item (folder or file) with its children.
*/
const renderItem = (item: DriveItem, level: number = 0) => {
const state = itemStates.get(item.id);
const isExpanded = state?.isExpanded || false;
const isLoading = state?.isLoading || false;
const children = state?.children;
const isFolder = item.isFolder;
const isSelected = isFolder ? isFolderSelected(item.id) : isFileSelected(item.id);
const childFolders = children?.filter((c) => c.isFolder) || [];
const childFiles = children?.filter((c) => !c.isFolder) || [];
const indentSize = 0.75; // Smaller indent for mobile
return (
<div
key={item.id}
className="w-full sm:ml-[calc(var(--level)*1.25rem)]"
style={
{ marginLeft: `${level * indentSize}rem`, "--level": level } as React.CSSProperties & {
"--level"?: number;
}
}
>
<div
className={cn(
"flex items-center group gap-1 sm:gap-2 h-auto py-1 sm:py-2 px-1 sm:px-2 rounded-md",
isFolder && "hover:bg-accent cursor-pointer",
!isFolder && "cursor-default opacity-60",
isSelected && "bg-accent/50"
)}
>
{isFolder ? (
<button
type="button"
className="flex items-center justify-center w-3 h-3 sm:w-4 sm:h-4 shrink-0 bg-transparent border-0 p-0 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
toggleFolder(item);
}}
aria-label={isExpanded ? `Collapse ${item.name}` : `Expand ${item.name}`}
>
{isLoading ? (
<Spinner size="xs" className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
) : isExpanded ? (
<ChevronDown className="h-3 w-3 sm:h-4 sm:w-4" />
) : (
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
)}
</button>
) : (
<span className="w-3 h-3 sm:w-4 sm:h-4 shrink-0" />
)}
<Checkbox
checked={isSelected}
onCheckedChange={() => {
if (isFolder) {
toggleFolderSelection(item.id, item.name);
} else {
toggleFileSelection(item.id, item.name);
}
}}
className="shrink-0 h-3.5 w-3.5 sm:h-4 sm:w-4 border-slate-400/20 dark:border-white/20"
onClick={(e) => e.stopPropagation()}
/>
<div className="shrink-0">
{isFolder ? (
isExpanded ? (
<FolderOpen 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")
)}
</div>
{isFolder ? (
<button
type="button"
className="truncate flex-1 text-left text-xs sm:text-sm min-w-0 bg-transparent border-0 p-0 cursor-pointer"
onClick={() => toggleFolder(item)}
>
{item.name}
</button>
) : (
<span className="truncate flex-1 text-left text-xs sm:text-sm min-w-0">
{item.name}
</span>
)}
</div>
{isExpanded && isFolder && children && (
<div className="w-full">
{childFolders.map((child) => renderItem(child, level + 1))}
{childFiles.map((child) => renderItem(child, level + 1))}
{children.length === 0 && (
<div className="text-[10px] sm:text-xs text-muted-foreground py-1 sm:py-2 pl-1 sm:pl-2">
Empty folder
</div>
)}
</div>
)}
</div>
);
};
return (
<div className="border border-slate-400/20 dark:border-white/20 rounded-md w-full overflow-hidden">
<ScrollArea className="h-[300px] sm:h-[450px] w-full">
<div className="p-1 sm:p-2 pr-2 sm:pr-4 w-full overflow-x-hidden">
<div className="mb-1 sm:mb-2 pb-1 sm:pb-2 border-b border-slate-400/20 dark:border-white/20">
<div className="flex items-center gap-1 sm:gap-2 h-auto py-1 sm:py-2 px-1 sm:px-2 rounded-md hover:bg-accent cursor-pointer">
<Checkbox
checked={isFolderSelected("root")}
onCheckedChange={() => toggleFolderSelection("root", "My Drive")}
className="shrink-0 h-3.5 w-3.5 sm:h-4 sm:w-4 border-slate-400/20 dark:border-white/20"
/>
<HardDrive className="h-3 w-3 sm:h-4 sm:w-4 text-primary shrink-0" />
<button
type="button"
className="font-semibold truncate text-xs sm:text-sm cursor-pointer bg-transparent border-0 p-0 text-left"
onClick={() => toggleFolderSelection("root", "My Drive")}
>
My Drive
</button>
</div>
</div>
{isLoadingRoot && (
<div className="flex items-center justify-center py-4 sm:py-8">
<Spinner size="sm" className="sm:h-6 sm:w-6 text-muted-foreground" />
</div>
)}
<div className="w-full overflow-x-hidden">
{!isLoadingRoot && rootItems.map((item) => renderItem(item, 0))}
</div>
{!isLoadingRoot && rootItems.length === 0 && (
<div className="text-center text-xs sm:text-sm text-muted-foreground py-4 sm:py-8">
No files or folders found in your Google Drive
</div>
)}
</div>
</ScrollArea>
</div>
);
}

View file

@ -10,12 +10,12 @@ import {
FolderOpen,
HardDrive,
Image,
Loader2,
Presentation,
} from "lucide-react";
import { useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { useGoogleDriveFolders } from "@/hooks/use-google-drive-folders";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import { cn } from "@/lib/utils";
@ -253,7 +253,7 @@ export function GoogleDriveFolderTree({
aria-label={isExpanded ? `Collapse ${item.name}` : `Expand ${item.name}`}
>
{isLoading ? (
<Loader2 className="h-2.5 w-2.5 sm:h-3 sm:w-3 animate-spin" />
<Spinner size="xs" className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
) : isExpanded ? (
<ChevronDown className="h-3 w-3 sm:h-4 sm:w-4" />
) : (
@ -344,7 +344,7 @@ export function GoogleDriveFolderTree({
{isLoadingRoot && (
<div className="flex items-center justify-center py-4 sm:py-8">
<Loader2 className="h-4 w-4 sm:h-6 sm:w-6 animate-spin text-muted-foreground" />
<Spinner size="sm" className="sm:h-6 sm:w-6 text-muted-foreground" />
</div>
)}

View file

@ -1,104 +1,55 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { IconMailFilled } from "@tabler/icons-react";
import { IconCalendar, IconMailFilled } from "@tabler/icons-react";
import { motion } from "motion/react";
import Image from "next/image";
import Link from "next/link";
import type React from "react";
import { useId, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { useId } from "react";
import { cn } from "@/lib/utils";
// Define validation schema matching the database schema
const contactFormSchema = z.object({
name: z.string().min(1, "Name is required").max(255, "Name is too long"),
email: z.email("Invalid email address").max(255, "Email is too long"),
company: z.string().min(1, "Company is required").max(255, "Company name is too long"),
message: z.string().optional().prefault(""),
});
type ContactFormData = z.infer<typeof contactFormSchema>;
export function ContactFormGridWithDetails() {
const [isSubmitting, setIsSubmitting] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm<ContactFormData>({
resolver: zodResolver(contactFormSchema),
});
const onSubmit = async (data: ContactFormData) => {
setIsSubmitting(true);
try {
const response = await fetch("/api/contact", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
const result = await response.json();
if (response.ok) {
toast.success("Message sent successfully!", {
description: "We will get back to you as soon as possible.",
});
reset();
} else {
toast.error("Failed to send message", {
description: result.message || "Please try again later.",
});
}
} catch (error) {
console.error("Error submitting form:", error);
toast.error("Something went wrong", {
description: "Please try again later.",
});
} finally {
setIsSubmitting(false);
}
};
return (
<div className="mx-auto grid w-full max-w-7xl grid-cols-1 gap-10 px-4 py-10 md:px-6 md:py-20 lg:grid-cols-2">
<div className="relative flex flex-col items-center overflow-hidden lg:items-start">
<div className="mx-auto flex w-full max-w-7xl flex-col items-center gap-10 px-4 py-10 md:px-6 md:py-20">
<div className="relative flex flex-col items-center overflow-hidden">
<div className="flex items-start justify-start">
<FeatureIconContainer className="flex items-center justify-center overflow-hidden">
<IconMailFilled className="h-6 w-6 text-blue-500" />
</FeatureIconContainer>
</div>
<h2 className="mt-9 bg-gradient-to-b from-neutral-800 to-neutral-900 bg-clip-text text-left text-xl font-bold text-transparent md:text-3xl lg:text-5xl dark:from-neutral-200 dark:to-neutral-300">
<h2 className="mt-9 bg-gradient-to-b from-neutral-800 to-neutral-900 bg-clip-text text-center text-xl font-bold text-transparent md:text-3xl lg:text-5xl dark:from-neutral-200 dark:to-neutral-300">
Contact
</h2>
<p className="mt-8 max-w-lg text-center text-base text-neutral-600 md:text-left dark:text-neutral-400">
We'd love to Hear From You.
<p className="mt-8 max-w-lg text-center text-base text-neutral-600 dark:text-neutral-400">
We'd love to hear from you. Schedule a meeting or send us an email.
</p>
<div className="mt-10 hidden flex-col items-center gap-4 md:flex-row lg:flex">
<div className="mt-10 flex flex-col items-center gap-6">
<Link
href="mailto:rohan@surfsense.com"
className="text-sm text-neutral-500 dark:text-neutral-400"
href="https://calendly.com/eric-surfsense/surfsense-meeting"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 rounded-xl bg-gradient-to-b from-blue-500 to-blue-600 px-6 py-3 text-base font-medium text-white shadow-lg transition duration-200 hover:from-blue-600 hover:to-blue-700"
>
rohan@surfsense.com
<IconCalendar className="h-5 w-5" />
Schedule a Meeting
</Link>
<div className="h-1 w-1 rounded-full bg-neutral-500 dark:bg-neutral-400" />
<div className="flex items-center gap-2 text-neutral-500 dark:text-neutral-400">
<span className="h-px w-8 bg-neutral-300 dark:bg-neutral-600" />
<span className="text-sm">or</span>
<span className="h-px w-8 bg-neutral-300 dark:bg-neutral-600" />
</div>
<Link
href="https://cal.com/mod-surfsense"
className="text-sm text-neutral-500 dark:text-neutral-400"
href="mailto:eric@surfsense.com"
className="flex items-center gap-2 text-base text-neutral-600 transition duration-200 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-200"
>
https://cal.com/mod-surfsense
<IconMailFilled className="h-5 w-5" />
eric@surfsense.com
</Link>
</div>
<div className="div relative mt-20 flex w-[600px] flex-shrink-0 -translate-x-10 items-center justify-center [perspective:800px] [transform-style:preserve-3d] sm:-translate-x-0 lg:-translate-x-32">
<div className="div relative mt-20 flex w-[600px] flex-shrink-0 items-center justify-center [perspective:800px] [transform-style:preserve-3d]">
<Pin className="h-30 w-85 top-0 left-0" />
<Image
@ -110,95 +61,6 @@ export function ContactFormGridWithDetails() {
/>
</div>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="relative mx-auto flex w-full max-w-2xl flex-col items-start gap-4 overflow-hidden rounded-3xl bg-gradient-to-b from-gray-100 to-gray-200 p-4 sm:p-10 dark:from-neutral-900 dark:to-neutral-950"
>
<Grid size={20} />
<div className="relative z-20 mb-4 w-full">
<label
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
htmlFor="name"
>
Full name
</label>
<input
id="name"
type="text"
placeholder="John Doe"
{...register("name")}
className={cn(
"shadow-input h-10 w-full rounded-md border bg-white pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
errors.name ? "border-red-500" : "border-transparent"
)}
/>
{errors.name && <p className="mt-1 text-xs text-red-500">{errors.name.message}</p>}
</div>
<div className="relative z-20 mb-4 w-full">
<label
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
htmlFor="email"
>
Email Address
</label>
<input
id="email"
type="email"
placeholder="john.doe@example.com"
{...register("email")}
className={cn(
"shadow-input h-10 w-full rounded-md border bg-white pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
errors.email ? "border-red-500" : "border-transparent"
)}
/>
{errors.email && <p className="mt-1 text-xs text-red-500">{errors.email.message}</p>}
</div>
<div className="relative z-20 mb-4 w-full">
<label
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
htmlFor="company"
>
Company
</label>
<input
id="company"
type="text"
placeholder="Example Inc."
{...register("company")}
className={cn(
"shadow-input h-10 w-full rounded-md border bg-white pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
errors.company ? "border-red-500" : "border-transparent"
)}
/>
{errors.company && <p className="mt-1 text-xs text-red-500">{errors.company.message}</p>}
</div>
<div className="relative z-20 mb-4 w-full">
<label
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
htmlFor="message"
>
Message <span className="text-neutral-400 text-xs font-normal">(optional)</span>
</label>
<textarea
id="message"
rows={5}
placeholder="Type your message here"
{...register("message")}
className={cn(
"shadow-input w-full rounded-md border bg-white pt-4 pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
errors.message ? "border-red-500" : "border-transparent"
)}
/>
{errors.message && <p className="mt-1 text-xs text-red-500">{errors.message.message}</p>}
</div>
<button
type="submit"
disabled={isSubmitting}
className="relative z-10 flex items-center justify-center rounded-md border border-transparent bg-neutral-800 px-4 py-2 text-sm font-medium text-white shadow-[0px_1px_0px_0px_#FFFFFF20_inset] transition duration-200 hover:bg-neutral-900 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
>
{isSubmitting ? "Submitting..." : "Submit"}
</button>
</form>
</div>
);
}

View file

@ -184,7 +184,7 @@ function GetStartedButton() {
return (
<motion.div whileHover={{ scale: 1.02, y: -2 }} whileTap={{ scale: 0.98 }}>
<Link
href="/register"
href="/login"
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

@ -19,7 +19,7 @@ export const Navbar = () => {
const [isScrolled, setIsScrolled] = useState(false);
const navItems = [
// { name: "Home", link: "/" },
{ name: "Contact Us", link: "/contact" },
{ name: "Pricing", link: "/pricing" },
{ name: "Changelog", link: "/changelog" },
// { name: "Sign In", link: "/login" },
@ -39,7 +39,7 @@ export const Navbar = () => {
}, []);
return (
<div className="fixed top-1 left-0 right-0 z-[60] w-full">
<div className="fixed top-1 left-0 right-0 z-60 w-full">
<DesktopNav navItems={navItems} isScrolled={isScrolled} />
<MobileNav navItems={navItems} isScrolled={isScrolled} />
</div>

View file

@ -1,12 +1,14 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { useAtomValue, useSetAtom } from "jotai";
import { Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
@ -21,7 +23,7 @@ import {
} from "@/components/ui/dialog";
import { useInbox } from "@/hooks/use-inbox";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
import { deleteThread, fetchThreads, updateThread } 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";
@ -38,6 +40,17 @@ interface LayoutDataProviderProps {
breadcrumb?: React.ReactNode;
}
/**
* Format count for display: shows numbers up to 999, then "1k+", "2k+", etc.
*/
function formatInboxCount(count: number): string {
if (count <= 999) {
return count.toString();
}
const thousands = Math.floor(count / 1000);
return `${thousands}k+`;
}
export function LayoutDataProvider({
searchSpaceId,
children,
@ -45,6 +58,7 @@ export function LayoutDataProvider({
}: LayoutDataProviderProps) {
const t = useTranslations("dashboard");
const tCommon = useTranslations("common");
const tSidebar = useTranslations("sidebar");
const router = useRouter();
const params = useParams();
const pathname = usePathname();
@ -55,11 +69,16 @@ export function LayoutDataProvider({
const { data: user } = useAtomValue(currentUserAtom);
const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom);
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
const currentThreadState = useAtomValue(currentThreadAtom);
const resetCurrentThread = useSetAtom(resetCurrentThreadAtom);
// Current IDs from URL
// State for handling new chat navigation when router is out of sync
const [pendingNewChat, setPendingNewChat] = useState(false);
// Current IDs from URL, with fallback to atom for replaceState updates
const currentChatId = params?.chat_id
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
: null;
: currentThreadState.id;
// Fetch current search space (for caching purposes)
useQuery({
@ -111,6 +130,17 @@ export function LayoutDataProvider({
const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false);
const [isLeavingSearchSpace, setIsLeavingSearchSpace] = useState(false);
// Effect to complete new chat navigation after router syncs
// This runs when handleNewChat detected an out-of-sync state and triggered a sync
useEffect(() => {
if (pendingNewChat && params?.chat_id) {
// Router is now synced (chat_id is in params), complete navigation to new-chat
resetCurrentThread();
router.push(`/dashboard/${searchSpaceId}/new-chat`);
setPendingNewChat(false);
}
}, [pendingNewChat, params?.chat_id, router, searchSpaceId, resetCurrentThread]);
const searchSpaces: SearchSpace[] = useMemo(() => {
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
return searchSpacesData.map((space) => ({
@ -143,6 +173,7 @@ export function LayoutDataProvider({
url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`,
visibility: thread.visibility,
isOwnThread: thread.is_own_thread,
archived: thread.archived,
};
// Split based on visibility, not ownership:
@ -161,18 +192,18 @@ export function LayoutDataProvider({
// Navigation items
const navItems: NavItem[] = useMemo(
() => [
{
title: "Documents",
url: `/dashboard/${searchSpaceId}/documents`,
icon: SquareLibrary,
isActive: pathname?.includes("/documents"),
},
{
title: "Inbox",
url: "#inbox", // Special URL to indicate this is handled differently
icon: Inbox,
isActive: isInboxSidebarOpen,
badge: unreadCount > 0 ? (unreadCount > 99 ? "99+" : unreadCount) : undefined,
badge: unreadCount > 0 ? formatInboxCount(unreadCount) : undefined,
},
{
title: "Documents",
url: `/dashboard/${searchSpaceId}/documents`,
icon: SquareLibrary,
isActive: pathname?.includes("/documents"),
},
],
[searchSpaceId, pathname, isInboxSidebarOpen, unreadCount]
@ -278,8 +309,20 @@ export function LayoutDataProvider({
);
const handleNewChat = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}, [router, searchSpaceId]);
// Check if router is out of sync (thread created via replaceState but params don't have chat_id)
const isOutOfSync = currentThreadState.id !== null && !params?.chat_id;
if (isOutOfSync) {
// First sync Next.js router by navigating to the current chat's actual URL
// This updates the router's internal state to match the browser URL
router.replace(`/dashboard/${searchSpaceId}/new-chat/${currentThreadState.id}`);
// Set flag to trigger navigation to new-chat after params update
setPendingNewChat(true);
} else {
// Normal navigation - router is in sync
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}
}, [router, searchSpaceId, currentThreadState.id, params?.chat_id]);
const handleChatSelect = useCallback(
(chat: ChatItem) => {
@ -293,6 +336,28 @@ export function LayoutDataProvider({
setShowDeleteChatDialog(true);
}, []);
const handleChatArchive = useCallback(
async (chat: ChatItem) => {
const newArchivedState = !chat.archived;
const successMessage = newArchivedState
? tSidebar("chat_archived") || "Chat archived"
: tSidebar("chat_unarchived") || "Chat restored";
try {
await updateThread(chat.id, { archived: newArchivedState });
toast.success(successMessage);
// Invalidate queries to refresh UI (React Query will only refetch active queries)
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
} catch (error) {
console.error("Error archiving thread:", error);
toast.error(tSidebar("error_archiving_chat") || "Failed to archive chat");
}
},
[queryClient, searchSpaceId, tSidebar]
);
const handleSettings = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/settings`);
}, [router, searchSpaceId]);
@ -380,6 +445,7 @@ export function LayoutDataProvider({
onNewChat={handleNewChat}
onChatSelect={handleChatSelect}
onChatDelete={handleChatDelete}
onChatArchive={handleChatArchive}
onViewAllSharedChats={handleViewAllSharedChats}
onViewAllPrivateChats={handleViewAllPrivateChats}
user={{

View file

@ -30,6 +30,7 @@ export interface ChatItem {
isActive?: boolean;
visibility?: "PRIVATE" | "SEARCH_SPACE";
isOwnThread?: boolean;
archived?: boolean;
}
export interface PageUsage {

View file

@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { Loader2, Plus, Search } from "lucide-react";
import { Plus, Search } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";
@ -26,6 +26,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { trackSearchSpaceCreated } from "@/lib/posthog/events";
const formSchema = z.object({
@ -82,29 +83,36 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Search className="h-5 w-5 text-primary" />
<DialogContent className="max-w-[90vw] sm:max-w-sm p-4 sm:p-5 data-[state=open]:animate-none data-[state=closed]:animate-none">
<DialogHeader className="space-y-2 pb-2">
<div className="flex items-center gap-2 sm:gap-3">
<div className="flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
<Search className="h-4 w-4 sm:h-5 sm:w-5 text-primary" />
</div>
<div>
<DialogTitle>{t("create_title")}</DialogTitle>
<DialogDescription>{t("create_description")}</DialogDescription>
<div className="flex-1 min-w-0">
<DialogTitle className="text-base sm:text-lg">{t("create_title")}</DialogTitle>
<DialogDescription className="text-xs sm:text-sm mt-0.5">
{t("create_description")}
</DialogDescription>
</div>
</div>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="flex flex-col gap-4">
<form onSubmit={form.handleSubmit(handleSubmit)} className="flex flex-col gap-3 sm:gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("name_label")}</FormLabel>
<FormLabel className="text-sm">{t("name_label")}</FormLabel>
<FormControl>
<Input placeholder={t("name_placeholder")} {...field} autoFocus />
<Input
placeholder={t("name_placeholder")}
{...field}
autoFocus
className="text-sm h-9 sm:h-10"
/>
</FormControl>
<FormMessage />
</FormItem>
@ -116,38 +124,47 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
<FormLabel className="text-sm">
{t("description_label")}{" "}
<span className="text-muted-foreground font-normal">
({tCommon("optional")})
</span>
</FormLabel>
<FormControl>
<Input placeholder={t("description_placeholder")} {...field} />
<Input
placeholder={t("description_placeholder")}
{...field}
className="text-sm h-9 sm:h-10"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="flex gap-2 pt-2">
<DialogFooter className="flex-col sm:flex-row gap-2 pt-2 sm:pt-3">
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
className="w-full sm:w-auto h-9 sm:h-10 text-sm"
>
{tCommon("cancel")}
</Button>
<Button type="submit" disabled={isSubmitting}>
<Button
type="submit"
disabled={isSubmitting}
className="w-full sm:w-auto h-9 sm:h-10 text-sm"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Spinner size="sm" className="mr-1.5" />
{t("creating")}
</>
) : (
<>
<Plus className="mr-2 h-4 w-4" />
<Plus className="-mr-1 h-4 w-4" />
{t("create_button")}
</>
)}

View file

@ -26,6 +26,7 @@ interface LayoutShellProps {
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void;
onViewAllPrivateChats?: () => void;
user: User;
@ -59,6 +60,7 @@ export function LayoutShell({
onNewChat,
onChatSelect,
onChatDelete,
onChatArchive,
onViewAllSharedChats,
onViewAllPrivateChats,
user,
@ -107,6 +109,7 @@ export function LayoutShell({
onNewChat={onNewChat}
onChatSelect={onChatSelect}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
onViewAllSharedChats={onViewAllSharedChats}
onViewAllPrivateChats={onViewAllPrivateChats}
user={user}
@ -155,6 +158,7 @@ export function LayoutShell({
onNewChat={onNewChat}
onChatSelect={onChatSelect}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
onViewAllSharedChats={onViewAllSharedChats}
onViewAllPrivateChats={onViewAllPrivateChats}
user={user}

View file

@ -4,7 +4,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import {
ArchiveIcon,
Loader2,
MessageCircleMore,
MoreHorizontal,
RotateCcwIcon,
@ -28,6 +27,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
@ -231,7 +231,7 @@ export function AllPrivateChatsSidebar({
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
transition={{ type: "tween", duration: 0.3, ease: "easeOut" }}
className="fixed inset-y-0 left-0 z-70 w-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
role="dialog"
aria-modal="true"
@ -304,7 +304,7 @@ export function AllPrivateChatsSidebar({
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<Spinner size="md" className="text-muted-foreground" />
</div>
) : error ? (
<div className="text-center py-8 text-sm text-destructive">
@ -365,7 +365,7 @@ export function AllPrivateChatsSidebar({
disabled={isBusy}
>
{isDeleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<Spinner size="xs" />
) : (
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
)}

View file

@ -4,7 +4,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import {
ArchiveIcon,
Loader2,
MessageCircleMore,
MoreHorizontal,
RotateCcwIcon,
@ -28,6 +27,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
@ -231,7 +231,7 @@ export function AllSharedChatsSidebar({
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
transition={{ type: "tween", duration: 0.3, ease: "easeOut" }}
className="fixed inset-y-0 left-0 z-70 w-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
role="dialog"
aria-modal="true"
@ -304,7 +304,7 @@ export function AllSharedChatsSidebar({
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<Spinner size="md" className="text-muted-foreground" />
</div>
) : error ? (
<div className="text-center py-8 text-sm text-destructive">
@ -365,7 +365,7 @@ export function AllSharedChatsSidebar({
disabled={isBusy}
>
{isDeleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<Spinner size="xs" />
) : (
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
)}

View file

@ -1,12 +1,13 @@
"use client";
import { MessageSquare, MoreHorizontal } from "lucide-react";
import { ArchiveIcon, MessageSquare, MoreHorizontal, RotateCcwIcon, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
@ -14,11 +15,20 @@ import { cn } from "@/lib/utils";
interface ChatListItemProps {
name: string;
isActive?: boolean;
archived?: boolean;
onClick?: () => void;
onArchive?: () => void;
onDelete?: () => void;
}
export function ChatListItem({ name, isActive, onClick, onDelete }: ChatListItemProps) {
export function ChatListItem({
name,
isActive,
archived,
onClick,
onArchive,
onDelete,
}: ChatListItemProps) {
const t = useTranslations("sidebar");
return (
@ -48,15 +58,39 @@ export function ChatListItem({ name, isActive, onClick, onDelete }: ChatListItem
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="right">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete?.();
}}
className="text-destructive focus:text-destructive"
>
{t("delete")}
</DropdownMenuItem>
{onArchive && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onArchive();
}}
>
{archived ? (
<>
<RotateCcwIcon className="mr-2 h-4 w-4" />
<span>{t("unarchive") || "Restore"}</span>
</>
) : (
<>
<ArchiveIcon className="mr-2 h-4 w-4" />
<span>{t("archive") || "Archive"}</span>
</>
)}
</DropdownMenuItem>
)}
{onArchive && onDelete && <DropdownMenuSeparator />}
{onDelete && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>{t("delete")}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>

View file

@ -73,6 +73,17 @@ function getInitials(name: string | null | undefined, email: string | null | und
return "U";
}
/**
* Format count for display: shows numbers up to 999, then "1k+", "2k+", etc.
*/
function formatInboxCount(count: number): string {
if (count <= 999) {
return count.toString();
}
const thousands = Math.floor(count / 1000);
return `${thousands}k+`;
}
/**
* Get display name for connector type
*/
@ -82,6 +93,9 @@ function getConnectorTypeDisplayName(connectorType: string): string {
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
GOOGLE_GMAIL_CONNECTOR: "Gmail",
GOOGLE_DRIVE_CONNECTOR: "Google Drive",
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Composio Google Drive",
COMPOSIO_GMAIL_CONNECTOR: "Composio Gmail",
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Composio Google Calendar",
LINEAR_CONNECTOR: "Linear",
NOTION_CONNECTOR: "Notion",
SLACK_CONNECTOR: "Slack",
@ -482,7 +496,7 @@ export function InboxSidebar({
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
transition={{ type: "tween", duration: 0.3, ease: "easeOut" }}
className="fixed inset-y-0 left-0 z-70 w-90 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
role="dialog"
aria-modal="true"
@ -765,7 +779,7 @@ export function InboxSidebar({
<AtSign className="h-4 w-4" />
<span>{t("mentions") || "Mentions"}</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
{unreadMentionsCount}
{formatInboxCount(unreadMentionsCount)}
</span>
</span>
</TabsTrigger>
@ -777,7 +791,7 @@ export function InboxSidebar({
<History className="h-4 w-4" />
<span>{t("status") || "Status"}</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
{unreadStatusCount}
{formatInboxCount(unreadStatusCount)}
</span>
</span>
</TabsTrigger>

View file

@ -25,6 +25,7 @@ interface MobileSidebarProps {
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void;
onViewAllPrivateChats?: () => void;
user: User;
@ -64,6 +65,7 @@ export function MobileSidebar({
onNewChat,
onChatSelect,
onChatDelete,
onChatArchive,
onViewAllSharedChats,
onViewAllPrivateChats,
user,
@ -141,6 +143,7 @@ export function MobileSidebar({
}}
onChatSelect={handleChatSelect}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
onViewAllSharedChats={onViewAllSharedChats}
onViewAllPrivateChats={onViewAllPrivateChats}
user={user}

View file

@ -20,7 +20,9 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
const joyrideAttr =
item.title === "Documents" || item.title.toLowerCase().includes("documents")
? { "data-joyride": "documents-sidebar" }
: {};
: item.title === "Inbox" || item.title.toLowerCase().includes("inbox")
? { "data-joyride": "inbox-sidebar" }
: {};
if (isCollapsed) {
return (
@ -32,14 +34,13 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
className={cn(
"relative flex h-10 w-10 items-center justify-center rounded-md transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
item.isActive && "bg-accent text-accent-foreground"
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
)}
{...joyrideAttr}
>
<Icon className="h-4 w-4" />
{item.badge && (
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center min-w-[14px] h-[14px] px-0.5 rounded-full bg-red-500 text-white text-[9px] font-medium">
{item.badge}
</span>
)}
@ -62,15 +63,14 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
className={cn(
"flex items-center gap-2 rounded-md mx-2 px-2 py-1.5 text-sm transition-colors text-left",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
item.isActive && "bg-accent text-accent-foreground"
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
)}
{...joyrideAttr}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate">{item.title}</span>
{item.badge && (
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-red-500 text-white text-xs font-medium">
<span className="inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">
{item.badge}
</span>
)}

View file

@ -1,6 +1,8 @@
"use client";
import { Mail } from "lucide-react";
import { Plus } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Progress } from "@/components/ui/progress";
interface PageUsageDisplayProps {
@ -9,6 +11,8 @@ interface PageUsageDisplayProps {
}
export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) {
const params = useParams();
const searchSpaceId = params.search_space_id;
const usagePercentage = (pagesUsed / pagesLimit) * 100;
return (
@ -21,13 +25,13 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
</div>
<Progress value={usagePercentage} className="h-1.5" />
<a
href="mailto:rohan@surfsense.com?subject=Request%20to%20Increase%20Page%20Limits"
<Link
href={`/dashboard/${searchSpaceId}/more-pages`}
className="flex items-center gap-1.5 text-[10px] text-muted-foreground hover:text-primary transition-colors"
>
<Mail className="h-3 w-3 shrink-0" />
<span>Contact to increase limits</span>
</a>
<Plus className="h-3 w-3 shrink-0" />
<span>Get More Pages</span>
</Link>
</div>
</div>
);

View file

@ -27,6 +27,7 @@ interface SidebarProps {
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void;
onViewAllPrivateChats?: () => void;
user: User;
@ -52,6 +53,7 @@ export function Sidebar({
onNewChat,
onChatSelect,
onChatDelete,
onChatArchive,
onViewAllSharedChats,
onViewAllPrivateChats,
user,
@ -175,7 +177,9 @@ export function Sidebar({
key={chat.id}
name={chat.name}
isActive={chat.id === activeChatId}
archived={chat.archived}
onClick={() => onChatSelect(chat)}
onArchive={() => onChatArchive?.(chat)}
onDelete={() => onChatDelete?.(chat)}
/>
))}
@ -216,7 +220,9 @@ export function Sidebar({
key={chat.id}
name={chat.name}
isActive={chat.id === activeChatId}
archived={chat.archived}
onClick={() => onChatSelect(chat)}
onArchive={() => onChatArchive?.(chat)}
onDelete={() => onChatDelete?.(chat)}
/>
))}

View file

@ -1,6 +1,6 @@
"use client";
import { ChevronsUpDown, ScrollText, Settings, Users } from "lucide-react";
import { ChevronsUpDown, Logs, Settings, Users } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
@ -57,7 +57,7 @@ export function SidebarHeader({
{t("manage_members")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(`/dashboard/${searchSpaceId}/logs`)}>
<ScrollText className="mr-2 h-4 w-4" />
<Logs className="mr-2 h-4 w-4" />
{t("logs")}
</DropdownMenuItem>
<DropdownMenuSeparator />

View file

@ -1,6 +1,6 @@
"use client";
import { ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react";
import { Check, ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react";
import { useTranslations } from "next-intl";
import {
DropdownMenu,
@ -197,11 +197,12 @@ export function SidebarUserProfile({
className={cn(
"mb-1 last:mb-0 transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80"
isSelected && "text-primary"
)}
>
<Icon className="mr-2 h-4 w-4" />
<span className="flex-1">{t(themeOption.value)}</span>
{isSelected && <Check className="h-4 w-4 shrink-0" />}
</DropdownMenuItem>
);
})}
@ -226,11 +227,12 @@ export function SidebarUserProfile({
className={cn(
"mb-1 last:mb-0 transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80"
isSelected && "text-primary"
)}
>
<span className="mr-2">{language.flag}</span>
<span className="flex-1">{language.name}</span>
{isSelected && <Check className="h-4 w-4 shrink-0" />}
</DropdownMenuItem>
);
})}
@ -313,11 +315,12 @@ export function SidebarUserProfile({
className={cn(
"mb-1 last:mb-0 transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80"
isSelected && "text-primary"
)}
>
<Icon className="mr-2 h-4 w-4" />
<span className="flex-1">{t(themeOption.value)}</span>
{isSelected && <Check className="h-4 w-4 shrink-0" />}
</DropdownMenuItem>
);
})}
@ -342,11 +345,12 @@ export function SidebarUserProfile({
className={cn(
"mb-1 last:mb-0 transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80"
isSelected && "text-primary"
)}
>
<span className="mr-2">{language.flag}</span>
<span className="flex-1">{language.name}</span>
{isSelected && <Check className="h-4 w-4 shrink-0" />}
</DropdownMenuItem>
);
})}

View file

@ -8,7 +8,6 @@ import {
Cloud,
Edit3,
Globe,
Loader2,
Plus,
Settings2,
Sparkles,
@ -36,6 +35,7 @@ import {
CommandSeparator,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Spinner } from "@/components/ui/spinner";
import type {
GlobalNewLLMConfig,
NewLLMConfigPublic,
@ -179,7 +179,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<Spinner size="sm" className="text-muted-foreground" />
<span className="text-muted-foreground hidden md:inline">Loading</span>
</>
) : currentConfig ? (

View file

@ -1,17 +1,9 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
BookOpen,
ChevronDown,
ExternalLink,
FileText,
Hash,
Loader2,
Sparkles,
X,
} from "lucide-react";
import { BookOpen, ChevronDown, ExternalLink, FileText, Hash, Sparkles, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useTranslations } from "next-intl";
import type React from "react";
import { forwardRef, type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
@ -20,6 +12,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import type {
GetDocumentByChunkResponse,
GetSurfsenseDocsByChunkResponse,
@ -63,7 +56,7 @@ interface ChunkCardProps {
}
const ChunkCard = forwardRef<HTMLDivElement, ChunkCardProps>(
({ chunk, index, totalChunks, isCited, isActive, disableLayoutAnimation }, ref) => {
({ chunk, index, totalChunks, isCited }, ref) => {
return (
<div
ref={ref}
@ -122,12 +115,13 @@ export function SourceDetailPanel({
children,
isDocsChunk = false,
}: SourceDetailPanelProps) {
const t = useTranslations("dashboard");
const scrollAreaRef = useRef<HTMLDivElement>(null);
const hasScrolledRef = useRef(false); // Use ref to avoid stale closures
const [summaryOpen, setSummaryOpen] = useState(false);
const [activeChunkIndex, setActiveChunkIndex] = useState<number | null>(null);
const [mounted, setMounted] = useState(false);
const [hasScrolledToCited, setHasScrolledToCited] = useState(false);
const [_hasScrolledToCited, setHasScrolledToCited] = useState(false);
const shouldReduceMotion = useReducedMotion();
useEffect(() => {
@ -382,11 +376,10 @@ export function SourceDetailPanel({
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-4"
>
<div className="relative">
<div className="absolute inset-0 rounded-full bg-primary/20 blur-xl" />
<Loader2 className="h-12 w-12 animate-spin text-primary relative" />
</div>
<p className="text-sm text-muted-foreground font-medium">Loading document</p>
<Spinner size="lg" />
<p className="text-sm text-muted-foreground font-medium">
{t("loading_document")}
</p>
</motion.div>
</div>
)}

View file

@ -32,6 +32,12 @@ const TOUR_STEPS: TourStep[] = [
content: "Access and manage all your uploaded documents.",
placement: "right",
},
{
target: '[data-joyride="inbox-sidebar"]',
title: "Check your inbox",
content: "View mentions and notifications in one place.",
placement: "right",
},
];
interface TooltipPosition {
@ -188,14 +194,15 @@ function TourTooltip({
const getPointerStyles = (): React.CSSProperties => {
const lineLength = 16;
const dotSize = 6;
// Check if this is the documents step (stepIndex === 1)
// Check if this is the documents step (stepIndex === 1) or inbox step (stepIndex === 2)
const isDocumentsStep = stepIndex === 1;
const isInboxStep = stepIndex === 2;
if (position.pointerPosition === "left") {
return {
position: "absolute",
left: -lineLength - dotSize,
top: isDocumentsStep ? "calc(50% - 8px)" : "50%",
top: isDocumentsStep || isInboxStep ? "calc(50% - 8px)" : "50%",
transform: "translateY(-50%)",
display: "flex",
alignItems: "center",
@ -518,12 +525,13 @@ export function OnboardingTour() {
// User is new and hasn't seen tour - wait for DOM elements and start tour
const checkAndStartTour = () => {
// Check if both required elements exist
// Check if all required elements exist
const connectorEl = document.querySelector(TOUR_STEPS[0].target);
const documentsEl = document.querySelector(TOUR_STEPS[1].target);
const inboxEl = document.querySelector(TOUR_STEPS[2].target);
if (connectorEl && documentsEl) {
// Both elements found, start tour
if (connectorEl && documentsEl && inboxEl) {
// All elements found, start tour
setIsActive(true);
setTargetEl(connectorEl);
setSpotlightTargetEl(connectorEl);

View file

@ -1,8 +1,11 @@
"use client";
import { useAtomValue } from "jotai";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getBearerToken } from "@/lib/auth-utils";
import {
cleanupElectric,
type ElectricClient,
@ -27,6 +30,7 @@ interface ElectricProviderProps {
* 5. Provides client via context - hooks should use useElectricClient()
*/
export function ElectricProvider({ children }: ElectricProviderProps) {
const t = useTranslations("common");
const [electricClient, setElectricClient] = useState<ElectricClient | null>(null);
const [error, setError] = useState<Error | null>(null);
const {
@ -105,21 +109,25 @@ export function ElectricProvider({ children }: ElectricProviderProps) {
};
}, [user?.id, isUserLoaded, electricClient]);
// Check if user is authenticated first (has bearer token)
// This prevents showing loading screen for unauthenticated users on homepage
const hasToken = typeof window !== "undefined" && !!getBearerToken();
// Determine if we should show loading
const shouldShowLoading = hasToken && isUserLoaded && !!user?.id && !electricClient && !error;
// Use global loading hook with ownership tracking - prevents flash during transitions
useGlobalLoadingEffect(shouldShowLoading, t("initializing"), "default");
// 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) {
if (!hasToken || !isUserLoaded || !user?.id || isUserError) {
return <ElectricContext.Provider value={null}>{children}</ElectricContext.Provider>;
}
// Show loading state while initializing for authenticated users
// Return children with null context while initializing - the global provider handles the loading UI
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>
);
return <ElectricContext.Provider value={null}>{children}</ElectricContext.Provider>;
}
// If there's an error, still render but warn

View file

@ -0,0 +1,79 @@
"use client";
import { useAtomValue } from "jotai";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { AmbientBackground } from "@/app/(home)/login/AmbientBackground";
import { globalLoadingAtom } from "@/atoms/ui/loading.atoms";
import { Logo } from "@/components/Logo";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
/**
* GlobalLoadingProvider renders a persistent loading overlay.
* The spinner is ALWAYS in the DOM to prevent animation reset when
* loading states change between different pages/components.
*
* Visibility is controlled via CSS opacity/pointer-events, NOT mounting/unmounting.
*/
export function GlobalLoadingProvider({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
const { isLoading, message, variant } = useAtomValue(globalLoadingAtom);
useEffect(() => {
setMounted(true);
}, []);
// The overlay is ALWAYS rendered, but visibility is controlled by CSS
// This prevents the spinner animation from resetting
const loadingOverlay = (
<div
className={cn(
"fixed inset-0 z-[9999]",
isLoading
? "opacity-100 pointer-events-auto"
: "opacity-0 pointer-events-none transition-opacity duration-150"
)}
aria-hidden={!isLoading}
>
{variant === "login" ? (
<div className="relative w-full h-full overflow-hidden bg-background">
<AmbientBackground />
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" />
<div className="mt-8 flex flex-col items-center space-y-4">
<div className="h-12 w-12 flex items-center justify-center">
{/* Spinner is always mounted, animation never resets */}
<Spinner size="lg" className="text-muted-foreground" />
</div>
<span className="text-muted-foreground text-sm min-h-[1.25rem] text-center max-w-xs">
{message}
</span>
</div>
</div>
</div>
) : (
<div className="flex min-h-screen flex-col items-center justify-center bg-background">
<div className="flex flex-col items-center space-y-4">
<div className="h-12 w-12 flex items-center justify-center">
{/* Spinner is always mounted, animation never resets */}
<Spinner size="xl" className="text-primary" />
</div>
<span className="text-muted-foreground text-sm min-h-[1.25rem] text-center max-w-md px-4">
{message}
</span>
</div>
</div>
)}
</div>
);
// Render inline during SSR/before hydration, use portal after mounting
// This prevents the white flash during initial render
return (
<>
{children}
{mounted ? createPortal(loadingOverlay, document.body) : loadingOverlay}
</>
);
}

View file

@ -0,0 +1,197 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { Info, RotateCcw, Save } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { updateSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
interface GeneralSettingsManagerProps {
searchSpaceId: number;
}
export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManagerProps) {
const t = useTranslations("searchSpaceSettings");
const tCommon = useTranslations("common");
const {
data: searchSpace,
isLoading: loading,
refetch: fetchSearchSpace,
} = useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
enabled: !!searchSpaceId,
});
const { mutateAsync: updateSearchSpace } = useAtomValue(updateSearchSpaceMutationAtom);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
// Initialize state from fetched search space
useEffect(() => {
if (searchSpace) {
setName(searchSpace.name || "");
setDescription(searchSpace.description || "");
setHasChanges(false);
}
}, [searchSpace]);
// Track changes
useEffect(() => {
if (searchSpace) {
const currentName = searchSpace.name || "";
const currentDescription = searchSpace.description || "";
const changed = currentName !== name || currentDescription !== description;
setHasChanges(changed);
}
}, [searchSpace, name, description]);
const handleSave = async () => {
try {
setSaving(true);
await updateSearchSpace({
id: searchSpaceId,
data: {
name: name.trim(),
description: description.trim() || undefined,
},
});
setHasChanges(false);
await fetchSearchSpace();
} catch (error: any) {
console.error("Error saving search space details:", error);
toast.error(error.message || "Failed to save search space details");
} finally {
setSaving(false);
}
};
const handleReset = () => {
if (searchSpace) {
setName(searchSpace.name || "");
setDescription(searchSpace.description || "");
setHasChanges(false);
}
};
if (loading) {
return (
<div className="space-y-4 md:space-y-6">
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<Skeleton className="h-5 md:h-6 w-36 md:w-48" />
<Skeleton className="h-3 md:h-4 w-full max-w-md mt-2" />
</CardHeader>
<CardContent className="space-y-3 md:space-y-4 px-3 md:px-6 pb-3 md:pb-6">
<Skeleton className="h-10 md:h-12 w-full" />
<Skeleton className="h-10 md:h-12 w-full" />
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-4 md:space-y-6">
<Alert className="py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
Update your search space name and description. These details help identify and organize
your workspace.
</AlertDescription>
</Alert>
{/* Search Space Details Card */}
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<CardTitle className="text-base md:text-lg">Search Space Details</CardTitle>
<CardDescription className="text-xs md:text-sm">
Manage the basic information for this search space.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 md:space-y-5 px-3 md:px-6 pb-3 md:pb-6">
<div className="space-y-1.5 md:space-y-2">
<Label htmlFor="search-space-name" className="text-sm md:text-base font-medium">
{t("general_name_label")}
</Label>
<Input
id="search-space-name"
placeholder={t("general_name_placeholder")}
value={name}
onChange={(e) => setName(e.target.value)}
className="text-sm md:text-base h-9 md:h-10"
/>
<p className="text-[10px] md:text-xs text-muted-foreground">
{t("general_name_description")}
</p>
</div>
<div className="space-y-1.5 md:space-y-2">
<Label htmlFor="search-space-description" className="text-sm md:text-base font-medium">
{t("general_description_label")}{" "}
<span className="text-muted-foreground font-normal">({tCommon("optional")})</span>
</Label>
<Input
id="search-space-description"
placeholder={t("general_description_placeholder")}
value={description}
onChange={(e) => setDescription(e.target.value)}
className="text-sm md:text-base h-9 md:h-10"
/>
<p className="text-[10px] md:text-xs text-muted-foreground">
{t("general_description_description")}
</p>
</div>
</CardContent>
</Card>
{/* Action Buttons */}
<div className="flex items-center justify-between pt-3 md:pt-4 gap-2">
<Button
variant="outline"
onClick={handleReset}
disabled={!hasChanges || saving}
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
>
<RotateCcw className="h-3.5 w-3.5 md:h-4 md:w-4" />
{t("general_reset")}
</Button>
<Button
onClick={handleSave}
disabled={!hasChanges || saving || !name.trim()}
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
>
<Save className="h-3.5 w-3.5 md:h-4 md:w-4" />
{saving ? t("general_saving") : t("general_save")}
</Button>
</div>
{hasChanges && (
<Alert
variant="default"
className="bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800 py-3 md:py-4"
>
<Info className="h-3 w-3 md:h-4 md:w-4 text-blue-600 dark:text-blue-500 shrink-0" />
<AlertDescription className="text-blue-800 dark:text-blue-300 text-xs md:text-sm">
{t("general_unsaved_changes")}
</AlertDescription>
</Alert>
)}
</div>
);
}

View file

@ -1,16 +1,7 @@
"use client";
import { useAtomValue } from "jotai";
import {
AlertCircle,
Bot,
CheckCircle,
FileText,
Loader2,
RefreshCw,
RotateCcw,
Save,
} from "lucide-react";
import { AlertCircle, Bot, CheckCircle, FileText, RefreshCw, RotateCcw, Save } from "lucide-react";
import { motion } from "motion/react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@ -32,6 +23,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
const ROLE_DESCRIPTIONS = {
agent: {
@ -206,7 +198,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
<Card>
<CardContent className="flex items-center justify-center py-8 md:py-12">
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="w-4 h-4 md:w-5 md:h-5 animate-spin" />
<Spinner size="sm" className="md:h-5 md:w-5" />
<span className="text-xs md:text-sm">
{configsLoading && preferencesLoading
? "Loading configurations and preferences..."
@ -398,7 +390,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
>
<Save className="w-3.5 h-3.5 md:w-4 md:h-4" />
{isSaving ? "Saving..." : "Save Changes"}
{isSaving ? "Saving" : "Save Changes"}
</Button>
<Button
variant="outline"

View file

@ -7,7 +7,6 @@ import {
Clock,
Edit3,
FileText,
Loader2,
MessageSquareQuote,
Plus,
RefreshCw,
@ -17,7 +16,6 @@ import {
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import {
createNewLLMConfigMutationAtom,
deleteNewLLMConfigMutationAtom,
@ -49,8 +47,8 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers";
import type { NewLLMConfig } from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils";
@ -112,12 +110,10 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
async (formData: LLMConfigFormData) => {
try {
if (editingConfig) {
const { search_space_id, ...updateData } = formData;
await updateConfig({
id: editingConfig.id,
data: {
...formData,
search_space_id: undefined, // Can't change search_space_id
},
data: updateData,
});
} else {
await createConfig(formData);
@ -156,9 +152,6 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
setEditingConfig(null);
};
const getProviderInfo = (providerValue: string) =>
LLM_PROVIDERS.find((p) => p.value === providerValue);
return (
<div className="space-y-4 md:space-y-6">
{/* Header */}
@ -180,9 +173,9 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
{/* Error Alerts */}
<AnimatePresence>
{errors.length > 0 &&
errors.map((err, i) => (
errors.map((err) => (
<motion.div
key={`error-${i}`}
key={err?.message ?? `error-${Date.now()}-${Math.random()}`}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
@ -218,7 +211,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
<Card>
<CardContent className="flex items-center justify-center py-10 md:py-16">
<div className="flex flex-col items-center gap-2 md:gap-3">
<Loader2 className="h-6 w-6 md:h-8 md:w-8 animate-spin text-muted-foreground" />
<Spinner size="md" className="md:h-8 md:w-8 text-muted-foreground" />
<span className="text-xs md:text-sm text-muted-foreground">
Loading configurations...
</span>
@ -269,7 +262,6 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
<motion.div variants={container} initial="hidden" animate="show" className="grid gap-4">
<AnimatePresence mode="popLayout">
{configs?.map((config) => {
const providerInfo = getProviderInfo(config.provider);
return (
<motion.div
key={config.id}
@ -492,8 +484,8 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
<Spinner size="sm" className="mr-2" />
Deleting
</>
) : (
<>

View file

@ -200,7 +200,7 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
>
<Save className="h-3.5 w-3.5 md:h-4 md:w-4" />
{saving ? "Saving..." : "Save Instructions"}
{saving ? "Saving" : "Save Instructions"}
</Button>
</div>

View file

@ -8,7 +8,6 @@ import {
ChevronDown,
ChevronsUpDown,
Key,
Loader2,
MessageSquareQuote,
Rocket,
Sparkles,
@ -48,6 +47,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { getModelsByProvider } from "@/contracts/enums/llm-models";
@ -592,7 +592,7 @@ export function LLMConfigForm({
>
{isSubmitting ? (
<>
<Loader2 className="h-3.5 w-3.5 sm:h-4 sm:w-4 animate-spin" />
<Spinner size="sm" />
{mode === "edit" ? "Updating..." : "Creating"}
</>
) : (

View file

@ -1,7 +1,7 @@
"use client";
import { useAtom } from "jotai";
import { CheckCircle2, FileType, Info, Loader2, Tag, Upload, X } from "lucide-react";
import { CheckCircle2, FileType, Info, Tag, Upload, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { useCallback, useMemo, useRef, useState } from "react";
@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import {
trackDocumentUploadFailure,
trackDocumentUploadStarted,
@ -424,7 +425,7 @@ export function DocumentUploadTab({
>
{isUploading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 sm:h-5 sm:w-5 animate-spin" />
<Spinner size="sm" />
{t("uploading")}
</span>
) : (

View file

@ -1,10 +1,11 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { AlertCircleIcon, Loader2Icon, MicIcon } from "lucide-react";
import { AlertCircleIcon, MicIcon } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { z } from "zod";
import { Audio } from "@/components/tool-ui/audio";
import { Spinner } from "@/components/ui/spinner";
import { baseApiService } from "@/lib/apis/base-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { clearActivePodcastTaskId, setActivePodcastTaskId } from "@/lib/chat/podcast-state";
@ -97,8 +98,8 @@ function PodcastGeneratingState({ title }: { title: string }) {
<div className="flex-1">
<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>
<Spinner size="sm" />
<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">
@ -144,7 +145,7 @@ function AudioLoadingState({ title }: { title: string }) {
<div className="flex-1">
<h3 className="font-semibold text-foreground">{title}</h3>
<div className="mt-2 flex items-center gap-2 text-muted-foreground">
<Loader2Icon className="size-4 animate-spin" />
<Spinner size="sm" />
<span className="text-sm">Loading audio...</span>
</div>
</div>

View file

@ -1,11 +1,12 @@
"use client";
import { ExternalLinkIcon, ImageIcon, Loader2 } from "lucide-react";
import { ExternalLinkIcon, ImageIcon } from "lucide-react";
import NextImage from "next/image";
import { Component, type ReactNode, useState } from "react";
import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
/**
@ -184,7 +185,7 @@ export function ImageLoading({ title = "Loading image..." }: { title?: string })
<Card className="w-full max-w-md overflow-hidden">
<div className="aspect-[4/3] bg-muted flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="size-8 text-muted-foreground animate-spin" />
<Spinner size="lg" className="text-muted-foreground" />
<p className="text-muted-foreground text-sm">{title}</p>
</div>
</div>

View file

@ -1,12 +1,13 @@
"use client";
import { ExternalLinkIcon, Globe, ImageIcon, LinkIcon, Loader2 } from "lucide-react";
import { ExternalLinkIcon, Globe, ImageIcon, LinkIcon } from "lucide-react";
import Image from "next/image";
import { Component, type ReactNode } from "react";
import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
@ -299,18 +300,17 @@ export function MediaCard({
{/* Response Actions */}
{responseActions && responseActions.length > 0 && (
<div
className="mt-4 flex items-center justify-end gap-2 border-t pt-3"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<div className="mt-4 flex items-center justify-end gap-2 border-t pt-3">
{responseActions.map((action) => (
<Tooltip key={action.id}>
<TooltipTrigger asChild>
<Button
variant={action.variant || "secondary"}
size="sm"
onClick={() => onResponseAction?.(action.id)}
onClick={(e) => {
e.stopPropagation();
onResponseAction?.(action.id);
}}
>
{action.label}
</Button>
@ -337,7 +337,7 @@ export function MediaCardLoading({ title = "Loading preview..." }: { title?: str
return (
<Card className="w-full max-w-md overflow-hidden">
<div className="aspect-[2/1] bg-muted animate-pulse flex items-center justify-center">
<Loader2 className="size-8 text-muted-foreground animate-spin" />
<Spinner size="lg" className="text-muted-foreground" />
</div>
<CardContent className="p-4">
<div className="flex items-center gap-3">

View file

@ -2,7 +2,6 @@
import { makeAssistantToolUI, useAssistantState } from "@assistant-ui/react";
import { useAtomValue, useSetAtom } from "jotai";
import { Loader2 } from "lucide-react";
import { useEffect, useMemo } from "react";
import { z } from "zod";
import {
@ -11,6 +10,7 @@ import {
registerPlanOwner,
updatePlanStateAtom,
} from "@/atoms/chat/plan-state.atom";
import { Spinner } from "@/components/ui/spinner";
import { Plan, PlanErrorBoundary, parseSerializablePlan, TodoStatusSchema } from "./plan";
// ============================================================================
@ -46,7 +46,7 @@ function WriteTodosLoading() {
return (
<div className="my-4 w-full max-w-xl rounded-2xl border bg-card/60 px-5 py-4 shadow-sm">
<div className="flex items-center gap-3">
<Loader2 className="size-5 animate-spin text-primary" />
<Spinner size="md" className="text-primary" />
<span className="text-sm text-muted-foreground">Creating plan...</span>
</div>
</div>

View file

@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0",
className
)}
{...props}

View file

@ -7,12 +7,14 @@ import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
disableHoverableContent = true,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
disableHoverableContent={disableHoverableContent}
{...props}
/>
);
@ -42,7 +44,7 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"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",
"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 pointer-events-none",
className
)}
{...props}