Merge remote-tracking branch 'upstream/dev' into fixes-playground-ui

# Conflicts:
#	surfsense_web/lib/apis/base-api.service.ts
This commit is contained in:
CREDO23 2026-07-24 08:29:39 +02:00
commit 06c7e27c72
87 changed files with 2882 additions and 1673 deletions

View file

@ -13,7 +13,7 @@ import { Spinner } from "@/components/ui/spinner";
import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors";
import { getPostLoginRedirectPath } from "@/lib/auth-utils";
import { ValidationError } from "@/lib/error";
import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events";
import { trackLoginAttempt, trackLoginFailure } from "@/lib/posthog/events";
export function LocalLoginForm() {
const t = useTranslations("auth");
@ -45,8 +45,8 @@ export function LocalLoginForm() {
grant_type: "password",
});
// Track successful login
trackLoginSuccess("local");
// auth_login_success is now emitted server-side
// (UserManager.on_after_login) — authoritative vs. optimistic.
// Small delay to show success message
setTimeout(() => {

View file

@ -15,11 +15,7 @@ import { Spinner } from "@/components/ui/spinner";
import { useSession } from "@/hooks/use-session";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { AppError, ValidationError } from "@/lib/error";
import {
trackRegistrationAttempt,
trackRegistrationFailure,
trackRegistrationSuccess,
} from "@/lib/posthog/events";
import { trackRegistrationAttempt, trackRegistrationFailure } from "@/lib/posthog/events";
import { AmbientBackground } from "../login/AmbientBackground";
export default function RegisterPage() {
@ -81,8 +77,8 @@ export default function RegisterPage() {
is_verified: false,
});
// Track successful registration
trackRegistrationSuccess();
// auth_registration_success is now emitted server-side
// (UserManager.on_after_register) — authoritative vs. optimistic.
// Success toast
toast.success(t("register_success"), {

View file

@ -8,7 +8,6 @@ import { useEffect } from "react";
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
import { llmSetupStatusAtomFamily } from "@/atoms/model-connections/model-connections-query.atoms";
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
import { LayoutDataProvider } from "@/components/layout";
import { OnboardingTour } from "@/components/onboarding-tour";
@ -169,7 +168,6 @@ export function DashboardClientLayout({
initialPlaygroundSidebarCollapsed={initialPlaygroundSidebarCollapsed}
>
{children}
<ConnectorIndicator showTrigger={false} />
</LayoutDataProvider>
</DocumentUploadDialogProvider>
);

View file

@ -19,7 +19,9 @@ export async function GET(
const response = new NextResponse(null, {
status: 302,
headers: {
Location: `/dashboard/${workspace_id}/new-chat`,
// Land on the connectors panel so `useConnectorDialog` (mounted there)
// consumes the result cookie and continues the indexing/edit flow.
Location: `/dashboard/${workspace_id}/connectors`,
},
});
response.cookies.set(OAUTH_RESULT_COOKIE, result, {

View file

@ -0,0 +1,5 @@
import { ConnectorsSection } from "@/components/assistant-ui/connector-popup/connectors-panel";
export default function ConnectorsPage() {
return <ConnectorsSection />;
}

View file

@ -96,7 +96,6 @@ import type { Role } from "@/contracts/types/roles.types";
import { invitesApiService } from "@/lib/apis/invites-api.service";
import { rolesApiService } from "@/lib/apis/roles-api.service";
import { formatRelativeDate } from "@/lib/format-date";
import { trackWorkspaceInviteSent, trackWorkspaceUsersViewed } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
@ -226,12 +225,7 @@ export function TeamContent({ workspaceId }: TeamContentProps) {
const canPrev = pageIndex > 0;
const canNext = displayEnd < totalItems;
useEffect(() => {
if (members.length > 0 && !membersLoading) {
const ownerCount = members.filter((m) => m.is_owner).length;
trackWorkspaceUsersViewed(workspaceId, members.length, ownerCount);
}
}, [members, membersLoading, workspaceId]);
// workspace_users_viewed removed — redundant with $pageview.
if (accessLoading || membersLoading) {
return (
@ -342,11 +336,7 @@ export function TeamContent({ workspaceId }: TeamContentProps) {
Invite members
</Button>
) : (
<CreateInviteDialog
roles={roles}
onCreateInvite={handleCreateInvite}
workspaceId={workspaceId}
/>
<CreateInviteDialog roles={roles} onCreateInvite={handleCreateInvite} />
)}
{invitesLoading ? (
<Button
@ -617,11 +607,9 @@ function MemberRow({
function CreateInviteDialog({
roles,
onCreateInvite,
workspaceId,
}: {
roles: Role[];
onCreateInvite: (data: CreateInviteRequest["data"]) => Promise<Invite>;
workspaceId: number;
}) {
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
@ -653,12 +641,7 @@ function CreateInviteDialog({
const invite = await onCreateInvite(data);
setCreatedInvite(invite);
const roleName = roleId ? roles.find((r) => r.id.toString() === roleId)?.name : undefined;
trackWorkspaceInviteSent(workspaceId, {
roleName,
hasExpiry: !!expiresAt,
hasMaxUses: !!maxUses,
});
// workspace_invite_sent is now emitted server-side (rbac_routes.create_invite).
} catch (error) {
console.error("Failed to create invite:", error);
toast.error("Failed to create invite. Please try again.");

View file

@ -43,21 +43,15 @@ export default function DashboardError({
<Button type="button" onClick={reset}>
Try again
</Button>
<Link
href="/dashboard"
className="rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
>
Go to dashboard home
</Link>
<a
href={issueUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
Report Issue
</a>
<Button asChild variant="ghost">
<Link href="/dashboard">Back to dashboard</Link>
</Button>
<Button asChild variant="ghost">
<a href={issueUrl} target="_blank" rel="noopener noreferrer">
Report Issue
<ExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
</div>
</div>
);

View file

@ -16,7 +16,7 @@ import { motion } from "motion/react";
import Image from "next/image";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { use, useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { acceptInviteMutationAtom } from "@/atoms/invites/invites-mutation.atoms";
import { Button } from "@/components/ui/button";
@ -33,11 +33,7 @@ import type { AcceptInviteResponse } from "@/contracts/types/invites.types";
import { useSession } from "@/hooks/use-session";
import { invitesApiService } from "@/lib/apis/invites-api.service";
import { setRedirectPath } from "@/lib/auth-utils";
import {
trackWorkspaceInviteAccepted,
trackWorkspaceInviteDeclined,
trackWorkspaceUserAdded,
} from "@/lib/posthog/events";
import { trackWorkspaceInviteDeclined } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
export default function InviteAcceptPage() {
@ -96,9 +92,9 @@ export default function InviteAcceptPage() {
setAccepted(true);
setAcceptedData(result);
// Track invite accepted and user added events
trackWorkspaceInviteAccepted(result.workspace_id, result.workspace_name, result.role_name);
trackWorkspaceUserAdded(result.workspace_id, result.workspace_name, result.role_name);
// workspace_invite_accepted + workspace_user_added are now emitted
// server-side (rbac_routes.accept_invite) — the server redirect is
// the authoritative join point.
}
} catch (err: any) {
setError(err.message || "Failed to accept invite");

View file

@ -8,18 +8,11 @@ import type {
} from "@/contracts/types/automation.types";
import { automationsApiService } from "@/lib/apis/automations-api.service";
import {
trackAutomationCreated,
trackAutomationCreateFailed,
trackAutomationDeleted,
trackAutomationDeleteFailed,
trackAutomationStatusChanged,
trackAutomationTriggerAdded,
trackAutomationTriggerAddFailed,
trackAutomationTriggerRemoved,
trackAutomationTriggerRemoveFailed,
trackAutomationTriggerUpdated,
trackAutomationTriggerUpdateFailed,
trackAutomationUpdated,
trackAutomationUpdateFailed,
} from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
@ -48,20 +41,10 @@ export const createAutomationMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: AutomationCreateRequest) => {
return automationsApiService.createAutomation(request);
},
onSuccess: (automation, variables) => {
onSuccess: (_automation, variables) => {
invalidateList(variables.workspace_id);
toast.success("Automation created");
trackAutomationCreated({
workspace_id: variables.workspace_id,
automation_id: automation.id,
task_count: variables.definition.plan.length,
trigger_type: variables.triggers?.[0]?.type ?? "none",
has_schedule: (variables.triggers?.length ?? 0) > 0,
chat_model_id: variables.definition.models?.chat_model_id,
image_gen_model_id: variables.definition.models?.image_gen_model_id,
vision_model_id: variables.definition.models?.vision_model_id,
tags_count: variables.definition.metadata?.tags?.length,
});
// automation_created is now emitted server-side (AutomationService.create).
},
onError: (error: Error, variables) => {
console.error("Error creating automation:", error);
@ -82,24 +65,8 @@ export const updateAutomationMutationAtom = atomWithMutation(() => ({
invalidateDetail(vars.automationId);
invalidateList(automation.workspace_id);
toast.success("Automation updated");
// A status-only patch (pause/resume/archive) is a distinct action from a
// definition/name edit, so split it into its own event.
if (vars.patch.status && !vars.patch.definition) {
trackAutomationStatusChanged({
automation_id: vars.automationId,
workspace_id: automation.workspace_id,
next_status: vars.patch.status,
});
} else {
trackAutomationUpdated({
automation_id: vars.automationId,
workspace_id: automation.workspace_id,
has_definition_change: !!vars.patch.definition,
has_name_change: vars.patch.name != null,
has_description_change: vars.patch.description !== undefined,
task_count: vars.patch.definition?.plan?.length,
});
}
// automation_updated / automation_status_changed are now emitted
// server-side (AutomationService.update).
},
onError: (error: Error, vars) => {
console.error("Error updating automation:", error);
@ -121,10 +88,7 @@ export const deleteAutomationMutationAtom = atomWithMutation(() => ({
invalidateList(vars.workspaceId);
invalidateDetail(vars.automationId);
toast.success("Automation deleted");
trackAutomationDeleted({
automation_id: vars.automationId,
workspace_id: vars.workspaceId,
});
// automation_deleted is now emitted server-side (AutomationService.delete).
},
onError: (error: Error, vars) => {
console.error("Error deleting automation:", error);
@ -141,16 +105,10 @@ export const addTriggerMutationAtom = atomWithMutation(() => ({
mutationFn: async (vars: { automationId: number; payload: TriggerCreateRequest }) => {
return automationsApiService.addTrigger(vars.automationId, vars.payload);
},
onSuccess: (trigger, vars) => {
onSuccess: (_trigger, vars) => {
invalidateDetail(vars.automationId);
toast.success("Trigger added");
trackAutomationTriggerAdded({
automation_id: vars.automationId,
trigger_id: trigger.id,
trigger_type: trigger.type,
enabled: trigger.enabled,
has_cron: !!trigger.params?.cron,
});
// automation_trigger_added is now emitted server-side (TriggerService.add).
},
onError: (error: Error, vars) => {
console.error("Error adding trigger:", error);
@ -174,17 +132,7 @@ export const updateTriggerMutationAtom = atomWithMutation(() => ({
onSuccess: (_, vars) => {
invalidateDetail(vars.automationId);
toast.success("Trigger updated");
const change: "enabled" | "params" | "other" = vars.patch.params
? "params"
: vars.patch.enabled !== undefined && vars.patch.enabled !== null
? "enabled"
: "other";
trackAutomationTriggerUpdated({
automation_id: vars.automationId,
trigger_id: vars.triggerId,
change,
enabled: vars.patch.enabled ?? undefined,
});
// automation_trigger_updated is now emitted server-side (TriggerService.update).
},
onError: (error: Error, vars) => {
console.error("Error updating trigger:", error);
@ -206,10 +154,7 @@ export const removeTriggerMutationAtom = atomWithMutation(() => ({
onSuccess: (vars) => {
invalidateDetail(vars.automationId);
toast.success("Trigger removed");
trackAutomationTriggerRemoved({
automation_id: vars.automationId,
trigger_id: vars.triggerId,
});
// automation_trigger_removed is now emitted server-side (TriggerService.remove).
},
onError: (error: Error, vars) => {
console.error("Error removing trigger:", error);

View file

@ -0,0 +1,349 @@
"use client";
import {
ChevronLeft,
ChevronRight,
LayoutGrid,
Settings2,
TriangleAlert,
Unplug,
Upload,
Wrench,
} from "lucide-react";
import Image from "next/image";
import { type ReactNode, useCallback, useRef, useState } from "react";
import type { ConnectorRow } from "@/components/assistant-ui/connector-popup/hooks/use-connector-rows";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerHandle,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import {
CONNECTOR_TOOL_ICON_PATHS,
getToolDisplayName,
getToolIcon,
} from "@/contracts/enums/toolIcons";
import { cn } from "@/lib/utils";
/** Minimal shape of a grouped agent-tool section (mirrors thread.tsx). */
export interface ToolGroupView {
label: string;
tools: { name: string }[];
connectorIcon?: string;
}
interface ComposerAddMenuDrawerProps {
/** The `+` button; rendered as the drawer trigger. */
trigger: ReactNode;
onUploadFiles: () => void;
/** Connected connectors: one row per type, with live indexing health (`useConnectorRows`). */
connectorRows: ConnectorRow[];
/** Open a connector's manage view (deep-links via importConnectorRequestAtom). */
onSelectConnector: (row: ConnectorRow) => void;
/** Navigate to the full connectors catalog. */
onBrowseConnectors: () => void;
regularToolGroups: ToolGroupView[];
connectorToolGroups: ToolGroupView[];
otherToolGroup?: ToolGroupView;
disabledToolsSet: Set<string>;
onToggleTool: (name: string) => void;
onToggleToolGroup: (names: string[]) => void;
/** True while the tool list is still loading (shows a skeleton). */
toolsLoading: boolean;
}
/**
* Mobile "+" menu. A single vaul drawer that behaves like a flat list at the
* root and drills into submenus in place (each level replaces the previous,
* with a back button) rather than nesting overlays the touch-friendly
* equivalent of the desktop dropdown submenus. Screen state is a small push/pop
* stack; closing the drawer resets it to the root.
*/
type Screen =
| { kind: "root" }
| { kind: "connectors" }
| { kind: "tools" }
| { kind: "toolGroup"; label: string };
const ROW = "flex w-full items-center gap-3 px-4 py-3 text-sm hover:bg-accent hover:text-accent-foreground transition-colors";
export function ComposerAddMenuDrawer({
trigger,
onUploadFiles,
connectorRows,
onSelectConnector,
onBrowseConnectors,
regularToolGroups,
connectorToolGroups,
otherToolGroup,
disabledToolsSet,
onToggleTool,
onToggleToolGroup,
toolsLoading,
}: ComposerAddMenuDrawerProps) {
const [open, setOpen] = useState(false);
const [stack, setStack] = useState<Screen[]>([{ kind: "root" }]);
// Slide direction: forward on push, back on pop — drives the enter animation.
const dirRef = useRef<"forward" | "back">("forward");
const current = stack[stack.length - 1];
const push = useCallback((screen: Screen) => {
dirRef.current = "forward";
setStack((prev) => [...prev, screen]);
}, []);
const pop = useCallback(() => {
dirRef.current = "back";
setStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev));
}, []);
const handleOpenChange = useCallback((next: boolean) => {
setOpen(next);
// Reset to root when closed so the next open starts fresh.
if (!next) {
dirRef.current = "forward";
setStack([{ kind: "root" }]);
}
}, []);
const close = useCallback(() => handleOpenChange(false), [handleOpenChange]);
const title =
current.kind === "connectors"
? "MCP Connectors"
: current.kind === "tools"
? "Manage Tools"
: current.kind === "toolGroup"
? current.label
: "Add";
const renderToolRow = (name: string) => {
const isDisabled = disabledToolsSet.has(name);
const ToolIcon = getToolIcon(name);
return (
<div key={name} className={ROW}>
<ToolIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-medium">{getToolDisplayName(name)}</span>
<Switch
checked={!isDisabled}
onCheckedChange={() => onToggleTool(name)}
className="shrink-0"
/>
</div>
);
};
const renderBody = () => {
if (current.kind === "root") {
return (
<>
<button
type="button"
className={ROW}
onClick={() => {
onUploadFiles();
close();
}}
>
<Upload className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 text-left">Upload Files</span>
</button>
<button
type="button"
className={ROW}
onClick={() => push({ kind: "connectors" })}
>
<Unplug className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 text-left">MCP Connectors</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</button>
<button type="button" className={ROW} onClick={() => push({ kind: "tools" })}>
<Settings2 className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 text-left">Manage Tools</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</button>
</>
);
}
if (current.kind === "connectors") {
return (
<>
{connectorRows.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
No connectors yet.
</p>
) : (
connectorRows.map((row) => (
<button
type="button"
key={row.type}
className={ROW}
onClick={() => {
onSelectConnector(row);
close();
}}
>
{getConnectorIcon(row.type, "size-4 shrink-0 text-muted-foreground")}
<span className="min-w-0 flex-1 truncate text-left">{row.title}</span>
{row.health === "syncing" ? (
<Spinner size="xs" className="shrink-0" />
) : row.health === "failed" ? (
<TriangleAlert
className="size-4 shrink-0 text-destructive"
aria-label={row.errorMessage ?? "Indexing failed"}
/>
) : row.accountCount > 1 ? (
<span className="shrink-0 text-xs text-muted-foreground">{row.accountCount}</span>
) : null}
</button>
))
)}
<Separator className="my-1" />
<button
type="button"
className={ROW}
onClick={() => {
onBrowseConnectors();
close();
}}
>
<LayoutGrid className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 text-left">Manage connectors</span>
</button>
</>
);
}
if (current.kind === "toolGroup") {
const group = connectorToolGroups.find((g) => g.label === current.label);
return <>{group?.tools.map((t) => renderToolRow(t.name))}</>;
}
// current.kind === "tools"
if (toolsLoading) {
return (
<div className="px-4 pt-3 pb-2">
<Skeleton className="h-3 w-16 mb-2" />
{["t1", "t2", "t3", "t4"].map((k) => (
<div key={k} className="flex items-center gap-3 py-2">
<Skeleton className="size-4 rounded shrink-0" />
<Skeleton className="h-3.5 flex-1" />
<Skeleton className="h-5 w-9 rounded-full shrink-0" />
</div>
))}
</div>
);
}
return (
<>
{regularToolGroups.map((group) => (
<div key={group.label}>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground font-semibold select-none">
{group.label}
</div>
{group.tools.map((t) => renderToolRow(t.name))}
</div>
))}
{connectorToolGroups.length > 0 && (
<div>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground font-semibold select-none">
Connector Actions
</div>
{connectorToolGroups.map((group) => {
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[group.connectorIcon ?? ""];
const toolNames = group.tools.map((t) => t.name);
const allDisabled = toolNames.every((n) => disabledToolsSet.has(n));
return (
<div key={group.label} className={ROW}>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3"
onClick={() => push({ kind: "toolGroup", label: group.label })}
>
{iconInfo ? (
<Image
src={iconInfo.src}
alt={iconInfo.alt}
width={18}
height={18}
className="size-[18px] shrink-0 select-none pointer-events-none"
draggable={false}
/>
) : (
<Wrench className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1 truncate text-left font-medium">
{group.label}
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</button>
<Switch
checked={!allDisabled}
onCheckedChange={() => onToggleToolGroup(toolNames)}
className="shrink-0"
/>
</div>
);
})}
</div>
)}
{otherToolGroup && (
<div>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground font-semibold select-none">
{otherToolGroup.label}
</div>
{otherToolGroup.tools.map((t) => renderToolRow(t.name))}
</div>
)}
</>
);
};
return (
<Drawer open={open} onOpenChange={handleOpenChange} shouldScaleBackground={false}>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="h-[85vh] max-h-[85vh] z-80" overlayClassName="z-80">
<DrawerHandle />
<DrawerHeader className="flex flex-row items-center gap-1 px-2 pb-2 pt-1">
{stack.length > 1 ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 shrink-0"
onClick={pop}
aria-label="Back"
>
<ChevronLeft className="size-5" />
</Button>
) : (
<span className="size-9 shrink-0" aria-hidden />
)}
<DrawerTitle className="flex-1 text-center text-base font-semibold">{title}</DrawerTitle>
<span className="size-9 shrink-0" aria-hidden />
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin pb-6">
<div
key={current.kind === "toolGroup" ? `toolGroup:${current.label}` : current.kind}
className={cn(
"animate-in fade-in-0 duration-200",
dirRef.current === "forward" ? "slide-in-from-right-4" : "slide-in-from-left-4"
)}
>
{renderBody()}
</div>
</div>
</DrawerContent>
</Drawer>
);
}

View file

@ -1,388 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { useConnectorsSync } from "@/hooks/use-connectors-sync";
import { PICKER_CLOSE_EVENT, PICKER_OPEN_EVENT } from "@/hooks/use-google-picker";
import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts";
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 {
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 { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
export interface ConnectorIndicatorHandle {
open: () => void;
}
interface ConnectorIndicatorProps {
showTrigger?: boolean;
}
export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, ConnectorIndicatorProps>(
(_props, ref) => {
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
// Real-time document type counts via Zero (updates instantly as docs are indexed)
const documentTypeCounts = useZeroDocumentTypeCounts(workspaceId);
// Read status inbox items from shared atom (populated by LayoutDataProvider)
// instead of creating a duplicate useInbox("status") hook.
const statusInboxItems = useAtomValue(statusInboxItemsAtom);
const inboxItems = useMemo(
() => statusInboxItems.filter((item) => item.type === "connector_indexing"),
[statusInboxItems]
);
// Use the custom hook for dialog state management
const {
isOpen,
activeTab,
connectingId,
isScrolled,
searchQuery,
indexingConfig,
indexingConnector,
indexingConnectorConfig,
editingConnector,
connectingConnectorType,
isCreatingConnector,
startDate,
endDate,
isStartingIndexing,
isSaving,
isDisconnecting,
periodicEnabled,
frequencyMinutes,
enableVisionLlm,
allConnectors,
viewingAccountsType,
viewingMCPList,
isYouTubeView,
isFromOAuth,
setSearchQuery,
setStartDate,
setEndDate,
setPeriodicEnabled,
setFrequencyMinutes,
setEnableVisionLlm,
handleOpenChange,
handleTabChange,
handleScroll,
handleConnectOAuth,
handleConnectNonOAuth,
handleCreateWebcrawler,
handleCreateYouTubeCrawler,
handleSubmitConnectForm,
handleStartIndexing,
handleSkipIndexing,
handleStartEdit,
handleSaveConnector,
handleDisconnectConnector,
handleBackFromEdit,
handleBackFromConnect,
handleBackFromYouTube,
handleViewAccountsList,
handleBackFromAccountsList,
handleBackFromMCPList,
handleAddNewMCPFromList,
handleQuickIndexConnector,
connectorConfig,
setConnectorConfig,
setIndexingConnectorConfig,
setConnectorName,
} = useConnectorDialog();
const [pickerOpen, setPickerOpen] = useState(false);
useEffect(() => {
const onOpen = () => setPickerOpen(true);
const onClose = () => setPickerOpen(false);
window.addEventListener(PICKER_OPEN_EVENT, onOpen);
window.addEventListener(PICKER_CLOSE_EVENT, onClose);
return () => {
window.removeEventListener(PICKER_OPEN_EVENT, onOpen);
window.removeEventListener(PICKER_CLOSE_EVENT, onClose);
};
}, []);
const {
connectors: connectorsFromSync = [],
loading: connectorsLoading,
error: connectorsError,
refreshConnectors: refreshConnectorsSync,
} = useConnectorsSync(workspaceId);
const useSyncData = connectorsFromSync.length > 0 || (connectorsLoading && !connectorsError);
const connectors = useSyncData ? connectorsFromSync : allConnectors || [];
const refreshConnectors = async () => {
if (useSyncData) {
await refreshConnectorsSync();
}
};
// Track indexing state locally - clears automatically when last_indexed_at changes via real-time sync
// Also clears when failed notifications are detected
const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors(
connectors as SearchSourceConnector[],
inboxItems
);
// Get document types that have documents in the workspace
const activeDocumentTypes = documentTypeCounts
? Object.entries(documentTypeCounts).filter(([, count]) => count > 0)
: [];
const hasConnectors = connectors.length > 0;
const hasSources = hasConnectors || activeDocumentTypes.length > 0;
const totalSourceCount = connectors.length + activeDocumentTypes.length;
const activeConnectorsCount = connectors.length;
// Check which connectors are already connected
// Real-time connector updates via Zero sync
const connectedTypes = new Set<string>(
(connectors || []).map((c: SearchSourceConnector) => c.connector_type)
);
useImperativeHandle(ref, () => ({
open: () => handleOpenChange(true),
}));
if (!workspaceId) return null;
return (
<Dialog open={isOpen} modal={false} onOpenChange={handleOpenChange}>
{isOpen &&
createPortal(
<div
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
aria-hidden="true"
onClick={() => {
if (!pickerOpen) handleOpenChange(false);
}}
/>,
document.body
)}
<DialogContent
onFocusOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => {
if (pickerOpen) e.preventDefault();
}}
onPointerDownOutside={(e) => {
if (pickerOpen) e.preventDefault();
}}
className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden ring-0 dark:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-accent [&>button]:hover:text-accent-foreground [&>button>svg]:size-5 select-none"
>
<DialogTitle className="sr-only">MCP Connectors</DialogTitle>
{/* YouTube Crawler View - shown when adding YouTube videos */}
{isYouTubeView && workspaceId ? (
<YouTubeCrawlerView workspaceId={workspaceId} onBack={handleBackFromYouTube} />
) : viewingMCPList ? (
<ConnectorAccountsListView
connectorType="MCP_CONNECTOR"
connectorTitle="MCP Connectors"
connectors={(allConnectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromMCPList}
onManage={handleStartEdit}
onAddAccount={handleAddNewMCPFromList}
addButtonText="Add New MCP Server"
/>
) : viewingAccountsType ? (
<ConnectorAccountsListView
connectorType={viewingAccountsType.connectorType}
connectorTitle={viewingAccountsType.connectorTitle}
connectors={(connectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromAccountsList}
onManage={handleStartEdit}
onAddAccount={() => {
// 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);
}
}}
isConnecting={connectingId !== null}
/>
) : connectingConnectorType ? (
<ConnectorConnectView
connectorType={connectingConnectorType}
onSubmit={(formData) => handleSubmitConnectForm(formData, startIndexing)}
onBack={handleBackFromConnect}
isSubmitting={isCreatingConnector}
/>
) : editingConnector ? (
<ConnectorEditView
connector={{
...editingConnector,
config: connectorConfig || editingConnector.config,
name: editingConnector.name,
// Sync last_indexed_at with live data from real-time sync
last_indexed_at:
(connectors as SearchSourceConnector[]).find((c) => c.id === editingConnector.id)
?.last_indexed_at ?? editingConnector.last_indexed_at,
}}
startDate={startDate}
endDate={endDate}
periodicEnabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
enableVisionLlm={enableVisionLlm}
isSaving={isSaving}
isDisconnecting={isDisconnecting}
isIndexing={indexingConnectorIds.has(editingConnector.id)}
workspaceId={workspaceId?.toString()}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onEnableVisionLlmChange={setEnableVisionLlm}
onSave={() => {
startIndexing(editingConnector.id);
handleSaveConnector(() => refreshConnectors());
}}
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
onBack={handleBackFromEdit}
onQuickIndex={(() => {
const cfg = connectorConfig || editingConnector.config;
const isDriveOrOneDrive =
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR" ||
editingConnector.connector_type === "DROPBOX_CONNECTOR";
const hasDriveItems = isDriveOrOneDrive
? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
((cfg?.selected_files as unknown[]) ?? []).length > 0
: true;
if (!hasDriveItems) return undefined;
return () => {
startIndexing(editingConnector.id);
handleQuickIndexConnector(
editingConnector.id,
editingConnector.connector_type,
stopIndexing,
startDate,
endDate
);
};
})()}
onConfigChange={setConnectorConfig}
onNameChange={setConnectorName}
/>
) : indexingConfig ? (
<IndexingConfigurationView
config={indexingConfig}
connector={
indexingConnector
? {
...indexingConnector,
config: indexingConnectorConfig || indexingConnector.config,
}
: undefined
}
startDate={startDate}
endDate={endDate}
periodicEnabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
enableVisionLlm={enableVisionLlm}
isStartingIndexing={isStartingIndexing}
isFromOAuth={isFromOAuth}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onEnableVisionLlmChange={setEnableVisionLlm}
onConfigChange={setIndexingConnectorConfig}
onStartIndexing={() => {
if (indexingConfig.connectorId) {
startIndexing(indexingConfig.connectorId);
}
handleStartIndexing(() => refreshConnectors());
}}
onSkip={handleSkipIndexing}
/>
) : (
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="flex-1 flex flex-col min-h-0"
>
{/* Header */}
<ConnectorDialogHeader
activeTab={activeTab}
totalSourceCount={activeConnectorsCount}
searchQuery={searchQuery}
onTabChange={handleTabChange}
onSearchChange={setSearchQuery}
isScrolled={isScrolled}
/>
{/* Content */}
<div className="flex-1 min-h-0 relative overflow-hidden">
<div className="h-full overflow-y-auto" onScroll={handleScroll}>
<div className="px-4 sm:px-12 py-4 sm:py-8 pb-12 sm:pb-16">
<TabsContent value="all" className="m-0">
<AllConnectorsTab
searchQuery={searchQuery}
workspaceId={workspaceId}
connectedTypes={connectedTypes}
connectingId={connectingId}
allConnectors={connectors}
documentTypeCounts={documentTypeCounts}
indexingConnectorIds={indexingConnectorIds}
onConnectOAuth={handleConnectOAuth}
onConnectNonOAuth={handleConnectNonOAuth}
onCreateWebcrawler={handleCreateWebcrawler}
onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
onManage={handleStartEdit}
onViewAccountsList={handleViewAccountsList}
/>
</TabsContent>
<ActiveConnectorsTab
searchQuery={searchQuery}
hasSources={hasSources}
totalSourceCount={totalSourceCount}
activeDocumentTypes={activeDocumentTypes}
connectors={connectors as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onTabChange={handleTabChange}
onManage={handleStartEdit}
onViewAccountsList={handleViewAccountsList}
/>
</div>
</div>
{/* Bottom fade shadow */}
<div className="absolute bottom-0 left-0 right-0 h-7 bg-linear-to-t from-popover via-popover/80 to-transparent pointer-events-none z-10" />
</div>
</Tabs>
)}
</DialogContent>
</Dialog>
);
}
);
ConnectorIndicator.displayName = "ConnectorIndicator";

View file

@ -23,7 +23,6 @@ interface ConnectorCardProps {
accountCount?: number;
connectorCount?: number;
isIndexing?: boolean;
deprecated?: boolean;
onConnect?: () => void;
onManage?: () => void;
}
@ -53,15 +52,11 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
accountCount,
connectorCount,
isIndexing = false,
deprecated = false,
onConnect,
onManage,
}) => {
const isMCP = connectorType === EnumConnectorName.MCP_CONNECTOR;
const isLive = !!connectorType && LIVE_CONNECTOR_TYPES.has(connectorType);
// Deprecated connectors can no longer be connected, but existing rows stay
// manageable (so users can disconnect them).
const isDeprecatedForConnect = deprecated && !isConnected;
// Get connector status
const { getConnectorStatus, isConnectorEnabled, getConnectorStatusMessage, shouldShowWarnings } =
useConnectorStatus();
@ -78,10 +73,6 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
return null;
}
if (isDeprecatedForConnect) {
return "Deprecated. No longer available to connect.";
}
return description;
};
@ -102,16 +93,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
: "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
)}
>
{isMCP ? (
<span
aria-hidden="true"
className="size-6 bg-current"
style={{
mask: "url('/connectors/modelcontextprotocol.svg') center / contain no-repeat",
WebkitMask: "url('/connectors/modelcontextprotocol.svg') center / contain no-repeat",
}}
/>
) : connectorType ? (
{connectorType ? (
getConnectorIcon(connectorType, "size-6")
) : id === "youtube-crawler" ? (
<IconBrandYoutube className="size-6" />
@ -169,20 +151,18 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
!isConnected && "shadow-xs"
)}
onClick={isConnected ? onManage : onConnect}
disabled={isConnecting || !isEnabled || isDeprecatedForConnect}
disabled={isConnecting || !isEnabled}
>
<span className={isConnecting ? "opacity-0" : ""}>
{isDeprecatedForConnect
? "Deprecated"
: !isEnabled
? "Unavailable"
: isConnected
? "Manage"
: id === "youtube-crawler"
? "Add"
: connectorType
? "Connect"
: "Add"}
{!isEnabled
? "Unavailable"
: isConnected
? "Manage"
: id === "youtube-crawler"
? "Add"
: connectorType
? "Connect"
: "Add"}
</span>
{isConnecting && <Spinner size="xs" className="absolute" />}
</Button>

View file

@ -139,7 +139,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Server className="h-4 w-4 shrink-0" />
<AlertDescription className="text-[10px] sm:text-xs">
Connect to an MCP (Model Context Protocol) server. Each MCP server is added as a separate
Connect to a Model Context Protocol server. Each MCP server is added as a separate
connector.
</AlertDescription>
</Alert>
@ -275,8 +275,8 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
<div className="mt-3 pt-3 border-t border-green-500/20">
<p className="font-semibold mb-2">Available tools:</p>
<ul className="list-disc list-inside text-xs space-y-0.5">
{testResult.tools.map((tool, i) => (
<li key={i}>{tool.name}</li>
{testResult.tools.map((tool) => (
<li key={tool.name}>{tool.name}</li>
))}
</ul>
</div>

View file

@ -1,14 +0,0 @@
"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,14 +0,0 @@
"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

@ -0,0 +1,48 @@
"use client";
import { CheckCircle2 } from "lucide-react";
import type { FC } from "react";
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
import {
COMPOSIO_CONNECTORS,
CRAWLERS,
OAUTH_CONNECTORS,
OTHER_CONNECTORS,
} from "../../constants/connector-constants";
import type { ConnectorConfigProps } from "../index";
// Catalog descriptions keyed by connector type, reused as the capability line so
// the copy stays in sync with the catalog cards.
const DESCRIPTION_BY_TYPE = new Map<string, string>(
[...OAUTH_CONNECTORS, ...COMPOSIO_CONNECTORS, ...OTHER_CONNECTORS, ...CRAWLERS].map((c) => [
c.connectorType,
c.description,
])
);
/**
* Fallback manage view for live connectors that are neither MCP-backed nor have
* a dedicated config component (native/Composio Gmail & Calendar). There is
* nothing to configure, so we just confirm the connection and echo what the
* agent can do no Trusted Tools (that feature is MCP-only, see the backend
* `_ensure_mcp_connector_for_user`).
*/
export const LiveConnectorConnectedCard: FC<ConnectorConfigProps> = ({ connector }) => {
const capability =
DESCRIPTION_BY_TYPE.get(connector.connector_type) ??
`connect to ${getConnectorTypeDisplay(connector.connector_type)}`;
return (
<div className="space-y-6">
<div className="rounded-xl border border-border bg-emerald-500/5 p-4 flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500/10 shrink-0 mt-0.5">
<CheckCircle2 className="size-4 text-emerald-500" />
</div>
<div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Connected</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">{capability}.</p>
</div>
</div>
</div>
);
};

View file

@ -55,12 +55,8 @@ const configMap: Record<string, () => Promise<{ default: FC<ConnectorConfigProps
import("./components/obsidian-config").then((m) => ({ default: m.ObsidianConfig })),
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: () =>
import("./components/composio-drive-config").then((m) => ({ default: m.ComposioDriveConfig })),
COMPOSIO_GMAIL_CONNECTOR: () =>
import("./components/composio-gmail-config").then((m) => ({ default: m.ComposioGmailConfig })),
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: () =>
import("./components/composio-calendar-config").then((m) => ({
default: m.ComposioCalendarConfig,
})),
// Composio Gmail/Calendar have nothing to configure; the edit view falls back
// to LiveConnectorConnectedCard for live connectors without a config here.
};
const componentCache = new Map<string, ConnectorConfigComponent>();

View file

@ -89,7 +89,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Header */}
<div className="flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10">
<div className="flex-shrink-0">
<Button
variant="ghost"
type="button"
@ -123,7 +123,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
{/* Form Content - Scrollable */}
<div
ref={formContainerRef}
className="connector-connect-form-root flex-1 min-h-0 overflow-y-auto px-6 sm:px-12"
className="connector-connect-form-root flex-1 min-h-0 overflow-y-auto"
>
<ConnectFormComponent
onSubmit={onSubmit}
@ -134,15 +134,15 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
</div>
{/* Fixed Footer - Action buttons */}
<div className="flex-shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-popover">
<Button
<div className="flex-shrink-0 flex items-center justify-end py-6 bg-transparent">
{/* <Button
variant="ghost"
onClick={onBack}
disabled={isSubmitting}
className="text-xs sm:text-sm"
>
Cancel
</Button>
</Button> */}
<Button
type="button"
onClick={handleFormSubmit}

View file

@ -20,6 +20,7 @@ import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
import { VisionLLMConfig } from "../../components/vision-llm-config";
import { LIVE_CONNECTOR_TYPES } from "../../constants/connector-constants";
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
import { LiveConnectorConnectedCard } from "../components/live-connector-connected-card";
import { MCPServiceConfig } from "../components/mcp-service-config";
import { getConnectorConfigComponent } from "../index";
@ -124,8 +125,13 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
// Get connector-specific config component (MCP-backed connectors use a generic view)
const ConnectorConfigComponent = useMemo(() => {
if (isMCPBacked) return MCPServiceConfig;
return getConnectorConfigComponent(connector.connector_type);
}, [connector.connector_type, isMCPBacked]);
// Live connectors without a dedicated config (native/Composio Gmail &
// Calendar) fall back to a simple "Connected" card instead of a blank body.
return (
getConnectorConfigComponent(connector.connector_type) ??
(isLive ? LiveConnectorConnectedCard : null)
);
}, [connector.connector_type, isMCPBacked, isLive]);
const [isScrolled, setIsScrolled] = useState(false);
const [hasMoreContent, setHasMoreContent] = useState(false);
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
@ -201,7 +207,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
{/* Fixed Header */}
<div
className={cn(
"flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10 transition-shadow duration-200 relative z-10",
"flex-shrink-0 transition-shadow duration-200 relative z-10",
isScrolled && "shadow-sm"
)}
>
@ -262,7 +268,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
<div className="flex-1 min-h-0 relative overflow-hidden">
<div
ref={scrollContainerRef}
className="h-full overflow-y-auto px-6 sm:px-12"
className="h-full overflow-y-auto"
onScroll={handleScroll}
>
<div className="space-y-6 pb-6 pt-2">
@ -362,7 +368,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
</div>
{/* Fixed Footer - Action buttons */}
<div className="flex-shrink-0 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 sm:gap-0 px-6 sm:px-12 py-6 sm:py-6 bg-popover">
<div className="flex-shrink-0 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 sm:gap-0 py-6 sm:py-6 bg-transparent">
{showDisconnectConfirm ? (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 sm:flex-initial">
<span className="text-xs sm:text-sm text-muted-foreground sm:whitespace-nowrap">

View file

@ -120,7 +120,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
{/* Fixed Header */}
<div
className={cn(
"shrink-0 px-6 sm:px-12 pt-8 sm:pt-10 transition-shadow duration-200 relative z-10",
"shrink-0 transition-shadow duration-200 relative z-10",
isScrolled && "shadow-sm"
)}
>
@ -166,7 +166,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
<div className="flex-1 min-h-0 relative overflow-hidden">
<div
ref={scrollContainerRef}
className="h-full overflow-y-auto px-6 sm:px-12"
className="h-full overflow-y-auto"
onScroll={handleScroll}
>
<div className="space-y-6 pb-6 pt-2">
@ -240,7 +240,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
</div>
{/* Fixed Footer - Action buttons */}
<div className="flex-shrink-0 flex items-center justify-end px-6 sm:px-12 py-6 bg-popover">
<div className="flex-shrink-0 flex items-center justify-end py-6 bg-transparent">
{isLive ? (
<Button onClick={onSkip} className="text-xs sm:text-sm">
Done

View file

@ -0,0 +1,483 @@
"use client";
import { useAtomValue } from "jotai";
import { ChevronRight, LayoutGrid, Search, TriangleAlert, X } from "lucide-react";
import { type ReactNode, useMemo, useState } from "react";
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerHandle,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
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";
import { useConnectorsSync } from "@/hooks/use-connectors-sync";
import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts";
import { cn } from "@/lib/utils";
import { ConnectorConnectView } from "./connector-configs/views/connector-connect-view";
import { ConnectorEditView } from "./connector-configs/views/connector-edit-view";
import { IndexingConfigurationView } from "./connector-configs/views/indexing-configuration-view";
import {
COMPOSIO_CONNECTORS,
getConnectorTitle,
OAUTH_CONNECTORS,
} from "./constants/connector-constants";
import { type ConnectorRow, useConnectorRows } from "./hooks/use-connector-rows";
import { useConnectorDialog } from "./hooks/use-connector-dialog";
import { AllConnectorsTab } from "./tabs/all-connectors-tab";
import { ConnectorAccountsListView } from "./views/connector-accounts-list-view";
import { YouTubeCrawlerView } from "./views/youtube-crawler-view";
/**
* Connector management surface (single `/connectors` route) rendered inside the
* workspace panel. Stateful masterdetail:
* - sub-rail: Overview + a flat "Your connectors" list (only what's connected,
* each with a live status glyph) + an "Add MCP server" action;
* - detail pane: the flat catalog (search + cards) OR one of the existing flow
* views (connect / edit / indexing / accounts / YouTube), reused verbatim
* from the former dialog.
*
* Unconnected connectors are one click from the catalog cards, so they never
* clutter the rail. The hook's internal `isOpen`/`connectorDialogOpenAtom` is
* inert on a route and ignored.
*/
export function ConnectorsSection() {
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
const documentTypeCounts = useZeroDocumentTypeCounts(workspaceId);
const [drawerOpen, setDrawerOpen] = useState(false);
const {
connectingId,
searchQuery,
indexingConfig,
indexingConnector,
indexingConnectorConfig,
editingConnector,
connectingConnectorType,
isCreatingConnector,
startDate,
endDate,
isStartingIndexing,
isSaving,
isDisconnecting,
periodicEnabled,
frequencyMinutes,
enableVisionLlm,
allConnectors,
viewingAccountsType,
viewingMCPList,
isYouTubeView,
isFromOAuth,
setSearchQuery,
setStartDate,
setEndDate,
setPeriodicEnabled,
setFrequencyMinutes,
setEnableVisionLlm,
handleOpenChange,
handleConnectOAuth,
handleConnectNonOAuth,
handleCreateWebcrawler,
handleCreateYouTubeCrawler,
handleSubmitConnectForm,
handleStartIndexing,
handleSkipIndexing,
handleStartEdit,
handleSaveConnector,
handleDisconnectConnector,
handleBackFromEdit,
handleBackFromConnect,
handleBackFromYouTube,
handleViewAccountsList,
handleBackFromAccountsList,
handleViewMCPList,
handleBackFromMCPList,
handleAddNewMCPFromList,
handleQuickIndexConnector,
connectorConfig,
setConnectorConfig,
setIndexingConnectorConfig,
setConnectorName,
} = useConnectorDialog();
const {
connectors: connectorsFromSync = [],
loading: connectorsLoading,
error: connectorsError,
refreshConnectors: refreshConnectorsSync,
} = useConnectorsSync(workspaceId);
const useSyncData = connectorsFromSync.length > 0 || (connectorsLoading && !connectorsError);
const connectors = (useSyncData ? connectorsFromSync : allConnectors || []) as SearchSourceConnector[];
const refreshConnectors = async () => {
if (useSyncData) {
await refreshConnectorsSync();
}
};
// "Your connectors" rows with live indexing health, plus the shared indexing
// controls the flow views reuse (single source of truth via useConnectorRows).
const {
rows: connectedRows,
indexingConnectorIds,
startIndexing,
stopIndexing,
} = useConnectorRows(connectors);
const connectedTypes = useMemo(
() => new Set<string>(connectors.map((c) => c.connector_type)),
[connectors]
);
if (!workspaceId) return null;
const activeConnectorType = editingConnector
? editingConnector.connector_type
: connectingConnectorType ||
viewingAccountsType?.connectorType ||
(viewingMCPList ? EnumConnectorName.MCP_CONNECTOR : null);
const flowView = ((): ReactNode => {
if (isYouTubeView) {
return <YouTubeCrawlerView workspaceId={workspaceId} onBack={handleBackFromYouTube} />;
}
if (viewingMCPList) {
return (
<ConnectorAccountsListView
connectorType="MCP_CONNECTOR"
connectorTitle="MCP Connectors"
connectors={(allConnectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromMCPList}
onManage={handleStartEdit}
onAddAccount={handleAddNewMCPFromList}
addButtonText="Add New MCP Server"
/>
);
}
if (viewingAccountsType) {
return (
<ConnectorAccountsListView
connectorType={viewingAccountsType.connectorType}
connectorTitle={viewingAccountsType.connectorTitle}
connectors={connectors}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromAccountsList}
onManage={handleStartEdit}
onAddAccount={() => {
const oauthConnector =
OAUTH_CONNECTORS.find(
(c) => c.connectorType === viewingAccountsType.connectorType
) ||
COMPOSIO_CONNECTORS.find(
(c) => c.connectorType === viewingAccountsType.connectorType
);
if (oauthConnector) {
handleConnectOAuth(oauthConnector);
}
}}
isConnecting={connectingId !== null}
/>
);
}
if (connectingConnectorType) {
return (
<ConnectorConnectView
connectorType={connectingConnectorType}
onSubmit={(formData) => handleSubmitConnectForm(formData, startIndexing)}
onBack={handleBackFromConnect}
isSubmitting={isCreatingConnector}
/>
);
}
if (editingConnector) {
return (
<ConnectorEditView
connector={{
...editingConnector,
config: connectorConfig || editingConnector.config,
name: editingConnector.name,
last_indexed_at:
connectors.find((c) => c.id === editingConnector.id)?.last_indexed_at ??
editingConnector.last_indexed_at,
}}
startDate={startDate}
endDate={endDate}
periodicEnabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
enableVisionLlm={enableVisionLlm}
isSaving={isSaving}
isDisconnecting={isDisconnecting}
isIndexing={indexingConnectorIds.has(editingConnector.id)}
workspaceId={workspaceId?.toString()}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onEnableVisionLlmChange={setEnableVisionLlm}
onSave={() => {
startIndexing(editingConnector.id);
handleSaveConnector(() => refreshConnectors());
}}
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
onBack={handleBackFromEdit}
onQuickIndex={(() => {
const cfg = connectorConfig || editingConnector.config;
const isDriveOrOneDrive =
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR" ||
editingConnector.connector_type === "DROPBOX_CONNECTOR";
const hasDriveItems = isDriveOrOneDrive
? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
((cfg?.selected_files as unknown[]) ?? []).length > 0
: true;
if (!hasDriveItems) return undefined;
return () => {
startIndexing(editingConnector.id);
handleQuickIndexConnector(
editingConnector.id,
editingConnector.connector_type,
stopIndexing,
startDate,
endDate
);
};
})()}
onConfigChange={setConnectorConfig}
onNameChange={setConnectorName}
/>
);
}
if (indexingConfig) {
return (
<IndexingConfigurationView
config={indexingConfig}
connector={
indexingConnector
? {
...indexingConnector,
config: indexingConnectorConfig || indexingConnector.config,
}
: undefined
}
startDate={startDate}
endDate={endDate}
periodicEnabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
enableVisionLlm={enableVisionLlm}
isStartingIndexing={isStartingIndexing}
isFromOAuth={isFromOAuth}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onEnableVisionLlmChange={setEnableVisionLlm}
onConfigChange={setIndexingConnectorConfig}
onStartIndexing={() => {
if (indexingConfig.connectorId) {
startIndexing(indexingConfig.connectorId);
}
handleStartIndexing(() => refreshConnectors());
}}
onSkip={handleSkipIndexing}
/>
);
}
return null;
})();
const isFlowActive = flowView !== null;
const detailTitle =
isFlowActive && activeConnectorType ? getConnectorTitle(activeConnectorType) : "Overview";
const hasConnected = connectedRows.length > 0;
// Rail navigation: clear any open flow first (handleOpenChange(false) is the
// hook's full reset), then apply the target management action.
const resetFlow = () => handleOpenChange(false);
const goOverview = () => {
setDrawerOpen(false);
resetFlow();
};
const manageRow = (row: ConnectorRow) => {
setDrawerOpen(false);
resetFlow();
if (row.type === EnumConnectorName.MCP_CONNECTOR) {
handleViewMCPList();
return;
}
if (row.accountCount > 1) {
handleViewAccountsList(row.type, row.title);
return;
}
handleStartEdit(row.connectors[0]);
};
const addMcpServer = () => {
setDrawerOpen(false);
resetFlow();
handleConnectNonOAuth?.(EnumConnectorName.MCP_CONNECTOR);
};
const navBtnClass = (active: boolean) =>
cn(
"inline-flex w-full items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none",
active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
);
const railLabel = (text: string) => (
<p className="px-3 pb-1 pt-1 text-xs font-semibold text-muted-foreground">{text}</p>
);
const renderNav = () => (
<>
<button type="button" onClick={goOverview} className={navBtnClass(!isFlowActive)}>
<LayoutGrid className="h-4 w-4" />
<span className="min-w-0 truncate">Overview</span>
</button>
{hasConnected && (
<>
<Separator className="my-3 bg-border" />
{railLabel("Your connectors")}
<div className="flex flex-col gap-0.5">
{connectedRows.map((row) => {
const isActive = isFlowActive && activeConnectorType === row.type;
return (
<button
key={row.type}
type="button"
onClick={() => manageRow(row)}
className={cn(
"inline-flex w-full items-center justify-start gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors duration-150 focus:outline-none",
isActive
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<span className="flex size-4 shrink-0 items-center justify-center">
{getConnectorIcon(row.type, "size-4")}
</span>
<span className="min-w-0 truncate">{row.title}</span>
{row.health === "syncing" ? (
<Spinner size="xs" className="ml-auto shrink-0" />
) : row.health === "failed" ? (
<TriangleAlert
className="ml-auto h-3.5 w-3.5 shrink-0 text-destructive"
aria-label={row.errorMessage ?? "Indexing failed"}
/>
) : row.accountCount > 1 ? (
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-muted-foreground">
{row.accountCount}
</span>
) : null}
</button>
);
})}
</div>
</>
)}
<Separator className="my-3 bg-border" />
<button type="button" onClick={addMcpServer} className={navBtnClass(false)}>
{getConnectorIcon(EnumConnectorName.MCP_CONNECTOR, "h-4 w-4")}
<span className="min-w-0 truncate">Add MCP server</span>
</button>
</>
);
const catalog = (
<div className="flex flex-col gap-8">
<div className="w-full">
<div className="relative">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4.5 w-4.5 text-muted-foreground" />
<Input
type="text"
autoComplete="off"
placeholder="Search connectors"
className="h-10 border-0 bg-muted pl-10 pr-9 text-base shadow-none"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 h-7 w-7 -translate-y-1/2 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
aria-label="Clear search"
>
<X className="h-4.5 w-4.5" />
</Button>
)}
</div>
</div>
<AllConnectorsTab
searchQuery={searchQuery}
workspaceId={workspaceId}
connectedTypes={connectedTypes}
connectingId={connectingId}
allConnectors={connectors}
documentTypeCounts={documentTypeCounts}
indexingConnectorIds={indexingConnectorIds}
onConnectOAuth={handleConnectOAuth}
onConnectNonOAuth={handleConnectNonOAuth}
onCreateWebcrawler={handleCreateWebcrawler}
onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
onManage={handleStartEdit}
onViewAccountsList={handleViewAccountsList}
/>
</div>
);
return (
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:flex-row">
<div className="md:w-[220px] md:shrink-0">
<h1 className="mb-4 px-1 text-xl font-semibold tracking-tight text-foreground md:text-2xl">
Connectors
</h1>
<nav className="hidden flex-col gap-0.5 md:flex">{renderNav()}</nav>
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen} shouldScaleBackground={false}>
<DrawerTrigger asChild>
<Button
type="button"
variant="outline"
className="flex h-10 w-full justify-between bg-transparent px-3 hover:bg-accent md:hidden"
>
<span className="truncate">{detailTitle}</span>
<ChevronRight className="h-4 w-4 rotate-90 text-muted-foreground" />
</Button>
</DrawerTrigger>
<DrawerContent className="h-[88vh] overflow-hidden rounded-t-2xl border bg-popover text-popover-foreground">
<DrawerHandle className="mt-3 h-1.5 w-10" />
<DrawerTitle className="sr-only">Connectors navigation</DrawerTitle>
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-4">
{renderNav()}
</nav>
</DrawerContent>
</Drawer>
</div>
<div className="min-w-0 flex-1">
<div className="hidden md:block">
<h2 className="text-lg font-semibold">{detailTitle}</h2>
<Separator className="mt-4 bg-border" />
</div>
<div className="min-w-0 pt-4 md:max-w-5xl">{isFlowActive ? flowView : catalog}</div>
</div>
</section>
);
}

View file

@ -1,42 +1,38 @@
import { EnumConnectorName } from "@/contracts/enums/connector";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
/**
* Connectors retired during the MCP migration (no viable official MCP server).
* The catalog card is shown disabled with a "Deprecated" badge so existing
* users understand why; the backend `/add` routes also refuse with HTTP 410.
* Reinstate by removing the type here and in the backend
* `DEPRECATED_CONNECTOR_TYPES` if demand returns.
*
* UI behavior: a deprecated connector is hidden from the catalog entirely unless
* the user already connected it before deprecation in that case it still shows
* as a normal card (inline, no "Deprecated" section) so they can manage/disconnect
* it. New connections are refused by the backend `/add` routes with HTTP 410.
* Reinstate by removing the type here and in the backend `DEPRECATED_CONNECTOR_TYPES`
* if demand returns.
*
* Full deprecated list (for devs):
* - DISCORD_CONNECTOR, TEAMS_CONNECTOR, LUMA_CONNECTOR
* - Search APIs (superseded by the Google-only web-search consolidation; the
* google_search subagent handles public web search, and Tavily/Linkup can
* still be added via the generic Custom MCP connector with API-key headers):
* TAVILY_API, SEARXNG_API, LINKUP_API, BAIDU_SEARCH_API
* - Legacy content crawlers/search (superseded by the file Import menu and
* hosted MCP tooling): YOUTUBE_CONNECTOR, WEBCRAWLER_CONNECTOR, ELASTICSEARCH_CONNECTOR
*/
export const DEPRECATED_CONNECTOR_TYPES = new Set<string>([
EnumConnectorName.DISCORD_CONNECTOR,
EnumConnectorName.TEAMS_CONNECTOR,
EnumConnectorName.LUMA_CONNECTOR,
// Search APIs retired by the Google-only web-search consolidation. Public
// web search now runs through the google_search subagent; Tavily/Linkup can
// still be added via the generic Custom MCP connector (API-key headers).
EnumConnectorName.TAVILY_API,
EnumConnectorName.SEARXNG_API,
EnumConnectorName.LINKUP_API,
EnumConnectorName.BAIDU_SEARCH_API,
// Legacy content crawlers/search retired in favor of the file Import menu and
// hosted MCP tooling. Existing rows stay manageable; new connections refused.
EnumConnectorName.YOUTUBE_CONNECTOR,
EnumConnectorName.WEBCRAWLER_CONNECTOR,
EnumConnectorName.ELASTICSEARCH_CONNECTOR,
]);
/**
* File-import connectors surfaced through the Documents sidebar "Import" menu
* instead of the external-MCP connector catalog. They index remote files into
* the knowledge base, so they belong with uploads, not with live MCP tools.
*/
export const IMPORT_CONNECTOR_TYPES = new Set<string>([
EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
EnumConnectorName.ONEDRIVE_CONNECTOR,
EnumConnectorName.DROPBOX_CONNECTOR,
]);
/**
* Connectors that operate in real time (no background indexing).
* Used to adjust UI: hide sync controls, show "Connected" instead of doc counts.
@ -283,36 +279,35 @@ export function getConnectorTitle(connectorType: string): string {
);
}
/** One row per connected connector type (multiple accounts collapsed into one). */
export interface ConnectorTypeRow {
type: string;
title: string;
connectors: SearchSourceConnector[];
accountCount: number;
}
/**
* Primary way a user interacts with a connector.
* Drives the two top-level groupings in the connector catalog UI.
* Collapse a flat connector list into one row per `connector_type`, titled for
* display (MCP collapses to "MCP Servers") and sorted alphabetically. Shared by
* the connectors panel's "Your connectors" rail and the composer add-menu so the
* two lists never drift; callers layer their own extras (e.g. health) on top.
*/
export type ConnectorCategory = "knowledge_base" | "tools_live";
export const CONNECTOR_CATEGORY_LABELS: Record<ConnectorCategory, string> = {
knowledge_base: "Knowledge Base",
tools_live: "Tools & Live Sources",
};
const KNOWLEDGE_BASE_CONNECTOR_TYPES = new Set<string>([
EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
EnumConnectorName.ONEDRIVE_CONNECTOR,
EnumConnectorName.DROPBOX_CONNECTOR,
EnumConnectorName.NOTION_CONNECTOR,
EnumConnectorName.CONFLUENCE_CONNECTOR,
EnumConnectorName.YOUTUBE_CONNECTOR,
EnumConnectorName.WEBCRAWLER_CONNECTOR,
EnumConnectorName.BOOKSTACK_CONNECTOR,
EnumConnectorName.GITHUB_CONNECTOR,
EnumConnectorName.ELASTICSEARCH_CONNECTOR,
EnumConnectorName.CIRCLEBACK_CONNECTOR,
EnumConnectorName.OBSIDIAN_CONNECTOR,
]);
/** Unmapped connectors surface under Tools & Live Sources. */
export function getConnectorCategory(connectorType: string): ConnectorCategory {
return KNOWLEDGE_BASE_CONNECTOR_TYPES.has(connectorType) ? "knowledge_base" : "tools_live";
export function groupConnectorsByType(connectors: SearchSourceConnector[]): ConnectorTypeRow[] {
const byType = new Map<string, SearchSourceConnector[]>();
for (const c of connectors) {
const arr = byType.get(c.connector_type) ?? [];
arr.push(c);
byType.set(c.connector_type, arr);
}
return [...byType.entries()]
.map(([type, list]) => ({
type,
title: type === EnumConnectorName.MCP_CONNECTOR ? "MCP Servers" : getConnectorTitle(type),
connectors: list,
accountCount: list.length,
}))
.sort((a, b) => a.title.localeCompare(b.title));
}
// Composio Toolkits (available integrations via Composio)

View file

@ -21,8 +21,6 @@ import { OAUTH_RESULT_COOKIE, parseOAuthCallbackResult } from "@/contracts/types
import { authenticatedFetch } from "@/lib/auth-fetch";
import { buildBackendUrl } from "@/lib/env-config";
import {
trackConnectorConnected,
trackConnectorDeleted,
trackConnectorSetupFailure,
trackConnectorSetupStarted,
trackIndexWithDateRangeOpened,
@ -296,11 +294,9 @@ export const useConnectorDialog = () => {
if (newConnector && oauthConnector) {
const connectorValidation = searchSourceConnector.safeParse(newConnector);
if (connectorValidation.success) {
trackConnectorConnected(
Number(workspaceId),
oauthConnector.connectorType,
newConnector.id
);
// connector_connected is now emitted server-side (after_insert
// listener in search_source_connectors_routes.py) — covers the
// OAuth callback redirect the frontend often never observes.
const isLiveConnector = LIVE_CONNECTOR_TYPES.has(oauthConnector.connectorType);
@ -436,12 +432,7 @@ export const useConnectorDialog = () => {
if (connector) {
const connectorValidation = searchSourceConnector.safeParse(connector);
if (connectorValidation.success) {
// Track webcrawler connector connected
trackConnectorConnected(
Number(workspaceId),
EnumConnectorName.WEBCRAWLER_CONNECTOR,
connector.id
);
// connector_connected is now emitted server-side (after_insert).
const config = validateIndexingConfigState({
connectorType: EnumConnectorName.WEBCRAWLER_CONNECTOR,
@ -540,8 +531,7 @@ export const useConnectorDialog = () => {
// Store connectingConnectorType before clearing it
const currentConnectorType = connectingConnectorType;
// Track connector connected event for non-OAuth connectors
trackConnectorConnected(Number(workspaceId), currentConnectorType, connector.id);
// connector_connected is now emitted server-side (after_insert).
// Find connector title from constants
const connectorInfo = OTHER_CONNECTORS.find(
@ -1229,12 +1219,8 @@ export const useConnectorDialog = () => {
id: editingConnector.id,
});
// Track connector deleted event
trackConnectorDeleted(
Number(workspaceId),
editingConnector.connector_type,
editingConnector.id
);
// connector_deleted is now emitted server-side
// (search_source_connectors_routes.delete_search_source_connector).
toast.success(
editingConnector.connector_type === "MCP_CONNECTOR"

View file

@ -0,0 +1,78 @@
"use client";
import { useAtomValue } from "jotai";
import { useMemo } from "react";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { connectorIndexingMetadata } from "@/contracts/types/inbox.types";
import { type ConnectorTypeRow, groupConnectorsByType } from "../constants/connector-constants";
import { useIndexingConnectors } from "./use-indexing-connectors";
/** Health of a connected connector type, derived from indexing + inbox state. */
export type ConnectorHealth = "syncing" | "failed" | "ok";
/** A grouped connector row enriched with live indexing health. */
export interface ConnectorRow extends ConnectorTypeRow {
health: ConnectorHealth;
errorMessage?: string;
}
/**
* Single source of truth for the "Your connectors" list: groups the connectors
* by type and derives each row's health (syncing > failed > ok) from the status
* inbox and optimistic indexing state. Shared by the connectors panel rail and
* the composer add-menu so both render identical status glyphs.
*
* Also returns the underlying indexing controls so the panel can reuse them for
* its flow views instead of instantiating `useIndexingConnectors` twice.
*/
export function useConnectorRows(connectors: SearchSourceConnector[]) {
const statusInboxItems = useAtomValue(statusInboxItemsAtom);
const inboxItems = useMemo(
() => statusInboxItems.filter((item) => item.type === "connector_indexing"),
[statusInboxItems]
);
const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors(
connectors,
inboxItems
);
// Latest indexing status per connector id, parsed from the status inbox.
const statusByConnectorId = useMemo(() => {
const map = new Map<number, { status?: string; error?: string; at: string }>();
for (const item of inboxItems) {
const parsed = connectorIndexingMetadata.safeParse(item.metadata);
if (!parsed.success) continue;
const at = item.updated_at ?? item.created_at;
const prev = map.get(parsed.data.connector_id);
if (!prev || at > prev.at) {
map.set(parsed.data.connector_id, {
status: parsed.data.status,
error: parsed.data.error_message ?? undefined,
at,
});
}
}
return map;
}, [inboxItems]);
const rows = useMemo<ConnectorRow[]>(() => {
return groupConnectorsByType(connectors).map((row) => {
const ids = row.connectors.map((c) => c.id);
const syncing = ids.some(
(id) => indexingConnectorIds.has(id) || statusByConnectorId.get(id)?.status === "in_progress"
);
const failedEntry = syncing
? undefined
: ids.map((id) => statusByConnectorId.get(id)).find((s) => s?.status === "failed");
return {
...row,
health: syncing ? "syncing" : failedEntry ? "failed" : "ok",
errorMessage: failedEntry?.error,
} satisfies ConnectorRow;
});
}, [connectors, indexingConnectorIds, statusByConnectorId]);
return { rows, indexingConnectorIds, startIndexing, stopIndexing, inboxItems };
}

View file

@ -1,6 +1,3 @@
// Main component export
export { ConnectorIndicator } from "../connector-popup";
// Sub-components (if needed for external use)
export { ConnectorCard } from "./components/connector-card";
export { ConnectorDialogHeader } from "./components/connector-dialog-header";

View file

@ -11,7 +11,6 @@ import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels";
import { cn } from "@/lib/utils";
import {
COMPOSIO_CONNECTORS,
IMPORT_CONNECTOR_TYPES,
LIVE_CONNECTOR_TYPES,
OAUTH_CONNECTORS,
} from "../constants/connector-constants";
@ -40,11 +39,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
onManage,
onViewAccountsList,
}) => {
// Import connectors (Drive/OneDrive/Dropbox) are managed via the Documents
// sidebar "Import" menu, so they are excluded from the MCP connector list.
const connectors = allActiveConnectors.filter(
(c) => !IMPORT_CONNECTOR_TYPES.has(c.connector_type)
);
const connectors = allActiveConnectors;
// Convert activeDocumentTypes array to Record for utility function
const documentTypeCounts = activeDocumentTypes.reduce(
(acc, [docType, count]) => {
@ -152,7 +147,6 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
<div className="flex flex-col items-center justify-center py-20 text-center">
<Search className="size-8 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">No connectors found</p>
<p className="text-xs text-muted-foreground/60 mt-1">Try a different search term</p>
</div>
) : hasSources ? (
<div className="space-y-6">

View file

@ -1,6 +1,5 @@
"use client";
import { Search } from "lucide-react";
import type { FC } from "react";
import { useIsSelfHosted } from "@/components/providers/runtime-config";
import { EnumConnectorName } from "@/contracts/enums/connector";
@ -9,12 +8,8 @@ import { usePlatform } from "@/hooks/use-platform";
import { ConnectorCard } from "../components/connector-card";
import {
COMPOSIO_CONNECTORS,
CONNECTOR_CATEGORY_LABELS,
type ConnectorCategory,
CRAWLERS,
DEPRECATED_CONNECTOR_TYPES,
getConnectorCategory,
IMPORT_CONNECTOR_TYPES,
OAUTH_CONNECTORS,
OTHER_CONNECTORS,
} from "../constants/connector-constants";
@ -83,63 +78,39 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
const passesDeploymentFilter = (c: DeploymentFilterableConnector) =>
(!c.selfHostedOnly || selfHosted) && (!c.desktopOnly || isDesktop);
// Import connectors (Drive/OneDrive/Dropbox) are managed via the Documents
// sidebar "Import" menu, so they never appear in the MCP connector catalog.
const notImport = (c: { connectorType?: string }) =>
!c.connectorType || !IMPORT_CONNECTOR_TYPES.has(c.connectorType);
// Filter connectors based on search, deployment mode, and import exclusion
// Filter connectors based on search and deployment mode
const filteredOAuth = OAUTH_CONNECTORS.filter(
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c) && notImport(c)
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
);
const filteredCrawlers = CRAWLERS.filter(
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c) && notImport(c)
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
);
const filteredOther = OTHER_CONNECTORS.filter(
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c) && notImport(c)
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
);
// Filter Composio connectors
const filteredComposio = COMPOSIO_CONNECTORS.filter(
(c) =>
(c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.description.toLowerCase().includes(searchQuery.toLowerCase())) &&
notImport(c)
c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.description.toLowerCase().includes(searchQuery.toLowerCase())
);
const isDeprecated = (c: { connectorType?: string }) =>
!!c.connectorType && DEPRECATED_CONNECTOR_TYPES.has(c.connectorType);
const inCategory =
(category: ConnectorCategory) =>
<T extends { connectorType?: string }>(connector: T): boolean =>
!isDeprecated(connector) &&
!!connector.connectorType &&
getConnectorCategory(connector.connectorType) === category;
const knowledgeBase = {
oauth: filteredOAuth.filter(inCategory("knowledge_base")),
composio: filteredComposio.filter(inCategory("knowledge_base")),
other: filteredOther.filter(inCategory("knowledge_base")),
crawlers: filteredCrawlers.filter(inCategory("knowledge_base")),
};
const toolsLive = {
oauth: filteredOAuth.filter(inCategory("tools_live")),
composio: filteredComposio.filter(inCategory("tools_live")),
other: filteredOther.filter(inCategory("tools_live")),
crawlers: filteredCrawlers.filter(inCategory("tools_live")),
};
// Deprecated cards render in a single section at the very bottom. Discord and
// Teams come from OAUTH_CONNECTORS; Luma, Tavily, Linkup, Baidu, Elasticsearch
// from OTHER_CONNECTORS (SearXNG has no catalog card); YouTube and Web Pages
// from CRAWLERS.
const deprecated = {
oauth: filteredOAuth.filter(isDeprecated),
other: filteredOther.filter(isDeprecated),
crawlers: filteredCrawlers.filter(isDeprecated),
// One flat grid, no separate "Deprecated" section. A deprecated connector is
// shown only if the user already connected it (before deprecation), so it
// stays manageable/disconnectable; never-connected deprecated connectors are
// hidden entirely. See DEPRECATED_CONNECTOR_TYPES for the full list.
const isVisible = <T extends { connectorType?: string }>(c: T) =>
!c.connectorType ||
!DEPRECATED_CONNECTOR_TYPES.has(c.connectorType) ||
connectedTypes.has(c.connectorType);
const available = {
oauth: filteredOAuth.filter(isVisible),
composio: filteredComposio.filter(isVisible),
other: filteredOther.filter(isVisible),
crawlers: filteredCrawlers.filter(isVisible),
};
const renderOAuthCard = (connector: OAuthConnector | ComposioConnector) => {
@ -169,7 +140,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
documentCount={documentCount}
accountCount={accountCount}
isIndexing={isIndexing}
deprecated={DEPRECATED_CONNECTOR_TYPES.has(connector.connectorType)}
onConnect={() => onConnectOAuth(connector)}
onManage={
isConnected && onViewAccountsList
@ -218,7 +188,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
documentCount={documentCount}
connectorCount={mcpConnectorCount}
isIndexing={isIndexing}
deprecated={DEPRECATED_CONNECTOR_TYPES.has(connector.connectorType)}
onConnect={handleConnect}
onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined}
/>
@ -267,86 +236,34 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
isConnecting={isConnecting}
documentCount={documentCount}
isIndexing={isIndexing}
deprecated={
!!crawler.connectorType && DEPRECATED_CONNECTOR_TYPES.has(crawler.connectorType)
}
onConnect={handleConnect}
onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined}
/>
);
};
const hasKnowledgeBase =
knowledgeBase.oauth.length > 0 ||
knowledgeBase.composio.length > 0 ||
knowledgeBase.other.length > 0 ||
knowledgeBase.crawlers.length > 0;
const hasToolsLive =
toolsLive.oauth.length > 0 ||
toolsLive.composio.length > 0 ||
toolsLive.other.length > 0 ||
toolsLive.crawlers.length > 0;
const hasDeprecated =
deprecated.oauth.length > 0 || deprecated.other.length > 0 || deprecated.crawlers.length > 0;
const hasAnyResults = hasKnowledgeBase || hasToolsLive || hasDeprecated;
const hasAnyResults =
available.oauth.length > 0 ||
available.composio.length > 0 ||
available.other.length > 0 ||
available.crawlers.length > 0;
if (!hasAnyResults && searchQuery) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Search className="size-8 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">No connectors found</p>
<p className="text-xs text-muted-foreground/60 mt-1">Try a different search term</p>
</div>
);
}
return (
<div className="space-y-8">
{hasKnowledgeBase && (
<section>
<div className="flex items-center gap-2 mb-4">
<h3 className="text-sm font-semibold text-muted-foreground">
{CONNECTOR_CATEGORY_LABELS.knowledge_base}
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{knowledgeBase.oauth.map(renderOAuthCard)}
{knowledgeBase.composio.map(renderOAuthCard)}
{knowledgeBase.crawlers.map(renderCrawlerCard)}
{knowledgeBase.other.map(renderOtherCard)}
</div>
</section>
)}
{hasToolsLive && (
<section>
<div className="flex items-center gap-2 mb-4">
<h3 className="text-sm font-semibold text-muted-foreground">
{CONNECTOR_CATEGORY_LABELS.tools_live}
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{toolsLive.oauth.map(renderOAuthCard)}
{toolsLive.composio.map(renderOAuthCard)}
{toolsLive.crawlers.map(renderCrawlerCard)}
{toolsLive.other.map(renderOtherCard)}
</div>
</section>
)}
{hasDeprecated && (
<section>
<div className="flex items-center gap-2 mb-4">
<h3 className="text-sm font-semibold text-muted-foreground">Deprecated</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{deprecated.oauth.map(renderOAuthCard)}
{deprecated.crawlers.map(renderCrawlerCard)}
{deprecated.other.map(renderOtherCard)}
</div>
</section>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{available.oauth.map(renderOAuthCard)}
{available.composio.map(renderOAuthCard)}
{available.crawlers.map(renderCrawlerCard)}
{available.other.map(renderOtherCard)}
</div>
</div>
);
};

View file

@ -111,7 +111,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-6 sm:px-12 pt-8 sm:pt-10 pb-1 sm:pb-4 bg-popover">
<div className="pb-1 sm:pb-4 bg-transparent">
{/* Back button */}
<Button
type="button"
@ -165,7 +165,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 sm:px-12 pt-0 sm:pt-6 pb-6 sm:pb-8">
<div className="flex-1 overflow-y-auto pt-0 sm:pt-6 pb-6 sm:pb-8">
{/* Connected Accounts Grid */}
{typeConnectors.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">

View file

@ -216,7 +216,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ workspaceId, o
return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Header */}
<div className="shrink-0 px-6 sm:px-12 pt-8 sm:pt-10">
<div className="shrink-0">
<Button
variant="ghost"
type="button"
@ -239,7 +239,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ workspaceId, o
</div>
{/* Form Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto px-6 sm:px-12">
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="space-y-4 pb-6">
<div className="space-y-2">
<Label htmlFor="video-input" className="text-sm sm:text-base">
@ -325,7 +325,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ workspaceId, o
</div>
{/* Fixed Footer - Action buttons */}
<div className="shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-popover">
<div className="shrink-0 flex items-center justify-between py-6 bg-transparent">
<Button
variant="ghost"
onClick={onBack}

View file

@ -12,17 +12,17 @@ import {
ArrowUpIcon,
Camera,
ChevronDown,
ChevronRight,
Clipboard,
LayoutGrid,
Plus,
Settings2,
SquareIcon,
TriangleAlert,
Unplug,
Upload,
Wrench,
X,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { useParams, useRouter } from "next/navigation";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -44,7 +44,7 @@ import {
clearPremiumAlertForThreadAtom,
premiumAlertByThreadAtom,
} from "@/atoms/chat/premium-alert.atom";
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { importConnectorRequestAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { membersAtom } from "@/atoms/members/members-query.atoms";
import { llmSetupStatusAtomFamily } from "@/atoms/model-connections/model-connections-query.atoms";
@ -52,6 +52,11 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
import { ChatViewport } from "@/components/assistant-ui/chat-viewport";
import { ComposerAddMenuDrawer } from "@/components/assistant-ui/composer-add-menu-drawer";
import {
type ConnectorRow,
useConnectorRows,
} from "@/components/assistant-ui/connector-popup/hooks/use-connector-rows";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import {
InlineMentionEditor,
@ -68,19 +73,12 @@ import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer
import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
Drawer,
DrawerContent,
DrawerHandle,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
@ -88,6 +86,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Popover, PopoverAnchor } from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
@ -97,6 +96,7 @@ import {
getToolDisplayName,
getToolIcon,
} from "@/contracts/enums/toolIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { useBatchCommentsPreload } from "@/hooks/use-comments";
import { useCommentsSync } from "@/hooks/use-comments-sync";
import { useMediaQuery } from "@/hooks/use-media-query";
@ -173,7 +173,7 @@ const ThreadContent: FC<ThreadProps> = ({
footer={
<>
<PremiumQuotaPinnedAlert />
<Composer hasActiveThread={hasActiveThread} isLoadingMessages={isLoadingMessages} />
<Composer isLoadingMessages={isLoadingMessages} />
</>
}
>
@ -313,99 +313,6 @@ const ThreadWelcome: FC = () => {
);
};
const BANNER_CONNECTORS = [
{ type: "GOOGLE_DRIVE_CONNECTOR", label: "Google Drive" },
{ type: "GOOGLE_GMAIL_CONNECTOR", label: "Gmail" },
{ type: "NOTION_CONNECTOR", label: "Notion" },
{ type: "YOUTUBE_CONNECTOR", label: "YouTube" },
{ type: "SLACK_CONNECTOR", label: "Slack" },
] as const;
const BANNER_DISMISSED_KEY = "surfsense-connect-tools-banner-dismissed";
const ConnectToolsBanner: FC<{
isThreadEmpty: boolean;
onVisibleChange?: (visible: boolean) => void;
}> = ({ isThreadEmpty, onVisibleChange }) => {
const { data: connectors } = useAtomValue(connectorsAtom);
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const [dismissed, setDismissed] = useState(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem(BANNER_DISMISSED_KEY) === "true";
});
const [dismissRequested, setDismissRequested] = useState(false);
const hasConnectors = (connectors?.length ?? 0) > 0;
const isVisible = !dismissed && !hasConnectors && isThreadEmpty;
const shouldShowTray = isVisible && !dismissRequested;
useEffect(() => {
onVisibleChange?.(isVisible);
}, [isVisible, onVisibleChange]);
const handleDismiss = (e: React.MouseEvent) => {
e.stopPropagation();
setDismissRequested(true);
};
return (
<AnimatePresence
initial={false}
onExitComplete={() => {
if (!dismissRequested) return;
setDismissed(true);
localStorage.setItem(BANNER_DISMISSED_KEY, "true");
}}
>
{shouldShowTray ? (
<motion.div
key="connect-tools-tray"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -14 }}
transition={{ duration: 0.18, ease: "easeOut" }}
className="relative z-0 -mt-5 flex min-w-0 items-center gap-2 rounded-b-3xl border border-input bg-muted/40 px-4 pt-7 pb-3 shadow-sm shadow-black/5 dark:shadow-black/10"
>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 min-w-0 cursor-pointer justify-start gap-2 rounded-md px-0 text-[13px] font-normal text-muted-foreground select-none hover:bg-transparent hover:text-foreground"
onClick={() => setConnectorDialogOpen(true)}
>
<Unplug className="size-4 shrink-0" />
<span className="truncate">Connect your tools</span>
</Button>
<div className="min-w-0 flex-1" />
<AvatarGroup className="shrink-0">
{BANNER_CONNECTORS.map(({ type }, i) => (
<Avatar
key={type}
className="size-5"
style={{ zIndex: BANNER_CONNECTORS.length - i }}
>
<AvatarFallback className="bg-accent text-[10px]">
{getConnectorIcon(type, "size-3")}
</AvatarFallback>
</Avatar>
))}
</AvatarGroup>
<Button
type="button"
onClick={handleDismiss}
variant="ghost"
size="icon"
className="size-7 shrink-0 cursor-pointer rounded-md text-muted-foreground hover:bg-transparent hover:text-foreground"
aria-label="Dismiss"
>
<X className="size-3.5" />
</Button>
</motion.div>
) : null}
</AnimatePresence>
);
};
const PendingScreenImageStrip: FC = () => {
const [urls, setUrls] = useAtom(pendingUserImageDataUrlsAtom);
if (urls.length === 0) return null;
@ -485,8 +392,7 @@ const ClipboardChip: FC<{ text: string; onDismiss: () => void }> = ({ text, onDi
};
/**
* Inline "this workspace can't chat yet" tray, mirrored on top of the composer
* (same visual treatment as the "Connect your tools" tray below it).
* Inline "this workspace can't chat yet" tray, mirrored on top of the composer.
*
* Replaces the old full-screen onboarding redirect: a workspace with no usable
* chat model (fresh install, or an admin deleted the last model) renders the
@ -531,14 +437,10 @@ const ChatUnavailableNotice: FC<{ workspaceId: number; canConfigure: boolean }>
};
interface ComposerProps {
hasActiveThread?: boolean;
isLoadingMessages?: boolean;
}
const Composer: FC<ComposerProps> = ({
hasActiveThread = false,
isLoadingMessages = false,
}) => {
const Composer: FC<ComposerProps> = ({ isLoadingMessages = false }) => {
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
const setSubmittedMentions = useSetAtom(submittedMentionsAtom);
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
@ -575,7 +477,6 @@ const Composer: FC<ComposerProps> = ({
const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty);
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
const [connectToolsTrayVisible, setConnectToolsTrayVisible] = useState(false);
const { data: chatSetupStatus } = useAtomValue(llmSetupStatusAtomFamily(workspaceId ?? 0));
const isChatUnavailable = !!chatSetupStatus && chatSetupStatus.status !== "ready";
@ -1037,7 +938,6 @@ const Composer: FC<ComposerProps> = ({
<div
className={cn(
"aui-composer-attachment-dropzone relative z-10 flex w-full flex-col overflow-hidden rounded-3xl border border-input/20 bg-muted pt-2 shadow-sm shadow-black/5 outline-none transition-[border-color,box-shadow] hover:border-input/60 focus-within:border-input/60 dark:shadow-black/10",
connectToolsTrayVisible && "rounded-b-3xl shadow-none dark:shadow-none",
isChatUnavailable && "shadow-none dark:shadow-none"
)}
>
@ -1071,10 +971,6 @@ const Composer: FC<ComposerProps> = ({
onChatModelSelected={handleChatModelSelected}
/>
</div>
<ConnectToolsBanner
isThreadEmpty={!hasActiveThread && isThreadEmpty}
onVisibleChange={setConnectToolsTrayVisible}
/>
{!isLoadingMessages && isThreadEmpty && isComposerInputEmpty ? (
<div className="absolute top-full left-0 right-0 z-20">
<ChatExamplePrompts onSelect={handleExampleSelect} />
@ -1152,12 +1048,13 @@ const ComposerAction: FC<ComposerActionProps> = ({
onChatModelSelected,
}) => {
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const router = useRouter();
const openConnectors = useCallback(
() => router.push(`/dashboard/${workspaceId}/connectors`),
[router, workspaceId]
);
const [toolsPopoverOpen, setToolsPopoverOpen] = useState(false);
const [openConnectorSubmenu, setOpenConnectorSubmenu] = useState<string | null>(null);
const [expandedConnectorGroups, setExpandedConnectorGroups] = useState<Set<string>>(
() => new Set()
);
const isDesktop = useMediaQuery("(min-width: 640px)");
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
const pendingScreenImages = useAtomValue(pendingUserImageDataUrlsAtom);
@ -1192,6 +1089,20 @@ const ComposerAction: FC<ComposerActionProps> = ({
() => new Set<string>((connectors ?? []).map((c) => c.connector_type)),
[connectors]
);
// "Your connectors" rows for the add-menu (desktop submenu + mobile drawer):
// same hook the connectors panel rail uses, so grouping + health glyphs match.
const { rows: connectorRows } = useConnectorRows((connectors ?? []) as SearchSourceConnector[]);
const setImportRequest = useSetAtom(importConnectorRequestAtom);
const openConnectorManage = useCallback(
(row: ConnectorRow) => {
// Deep-link into the connector's manage view: the panel's
// useConnectorDialog consumes this atom and routes by account count
// (none -> connect, one -> edit, many -> accounts list).
setImportRequest({ connectorType: row.type, mode: "auto" });
openConnectors();
},
[setImportRequest, openConnectors]
);
const toggleToolGroup = useCallback(
(toolNames: string[]) => {
@ -1204,18 +1115,6 @@ const ComposerAction: FC<ComposerActionProps> = ({
},
[disabledToolsSet, setDisabledTools]
);
const setConnectorGroupExpanded = useCallback((label: string, expanded: boolean) => {
setExpandedConnectorGroups((prev) => {
const next = new Set(prev);
if (expanded) {
next.add(label);
} else {
next.delete(label);
}
return next;
});
}, []);
const filteredTools = agentTools;
const groupedTools = useMemo(() => {
if (!filteredTools) return [];
@ -1284,206 +1183,30 @@ const ComposerAction: FC<ComposerActionProps> = ({
<div className="aui-composer-action-wrapper relative mx-3 mb-3 flex items-center justify-between">
<div className="flex items-center gap-1">
{!isDesktop ? (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 rounded-full p-0 font-semibold text-xs text-muted-foreground transition-colors dark:border-muted-foreground/15 hover:bg-foreground/10 hover:text-foreground"
aria-label="Upload files, manage tools and more"
data-joyride="connector-icon"
>
<Plus className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start" sideOffset={8}>
<DropdownMenuItem onSelect={() => openUploadDialog()}>
<Upload className="size-4" />
Upload Files
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setConnectorDialogOpen(true)}>
<Unplug className="size-4" />
MCP Connectors
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setToolsPopoverOpen(true)}>
<Settings2 className="size-4" />
Manage Tools
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Drawer
open={toolsPopoverOpen}
onOpenChange={setToolsPopoverOpen}
shouldScaleBackground={false}
>
<DrawerContent className="h-[85vh] max-h-[85vh] z-80" overlayClassName="z-80">
<DrawerHandle />
<DrawerHeader className="px-4 pb-3 pt-2">
<DrawerTitle className="flex items-center justify-center gap-2 text-base font-semibold">
Manage Tools
</DrawerTitle>
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin pb-6">
{regularToolGroups.map((group) => (
<div key={group.label}>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
{group.label}
</div>
{group.tools.map((tool) => {
const isDisabled = disabledToolsSet.has(tool.name);
const ToolIcon = getToolIcon(tool.name);
return (
<div
key={tool.name}
className="flex w-full items-center gap-3 px-4 py-2 hover:bg-accent hover:text-accent-foreground transition-colors"
>
<ToolIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 text-sm font-medium truncate">
{formatToolName(tool.name)}
</span>
<Switch
checked={!isDisabled}
onCheckedChange={() => toggleTool(tool.name)}
className="shrink-0"
/>
</div>
);
})}
</div>
))}
{connectorToolGroups.length > 0 && (
<div>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
Connector Actions
</div>
{connectorToolGroups.map((group) => {
const iconKey = group.connectorIcon ?? "";
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey];
const toolNames = group.tools.map((t) => t.name);
const allDisabled = toolNames.every((n) => disabledToolsSet.has(n));
const isExpanded = expandedConnectorGroups.has(group.label);
return (
<Collapsible
key={group.label}
open={isExpanded}
onOpenChange={(open) => setConnectorGroupExpanded(group.label, open)}
>
<div className="flex w-full items-center gap-3 px-4 py-2 hover:bg-accent hover:text-accent-foreground transition-colors">
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-auto min-w-0 flex-1 justify-start gap-3 p-0 text-left hover:bg-transparent hover:text-inherit"
>
{iconInfo ? (
<Image
src={iconInfo.src}
alt={iconInfo.alt}
width={18}
height={18}
className="size-[18px] shrink-0 select-none pointer-events-none"
draggable={false}
/>
) : (
<Wrench className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{group.label}
</span>
{isExpanded ? (
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
)}
</Button>
</CollapsibleTrigger>
<Switch
checked={!allDisabled}
onCheckedChange={() => toggleToolGroup(toolNames)}
className="shrink-0"
/>
</div>
<CollapsibleContent className="pb-1">
{group.tools.map((tool) => {
const isDisabled = disabledToolsSet.has(tool.name);
return (
<div
key={tool.name}
className={cn(
"ml-8 flex items-center gap-3 px-4 py-1.5 rounded-md transition-colors",
"hover:bg-accent hover:text-accent-foreground",
!isDisabled && "text-primary"
)}
>
<span className="min-w-0 flex-1 truncate text-sm">
{formatToolName(tool.name)}
</span>
<Switch
checked={!isDisabled}
onCheckedChange={() => toggleTool(tool.name)}
className="shrink-0"
/>
</div>
);
})}
</CollapsibleContent>
</Collapsible>
);
})}
</div>
)}
{otherToolGroup && (
<div>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
{otherToolGroup.label}
</div>
{otherToolGroup.tools.map((tool) => {
const isDisabled = disabledToolsSet.has(tool.name);
const ToolIcon = getToolIcon(tool.name);
return (
<div
key={tool.name}
className="flex w-full items-center gap-3 px-4 py-2 hover:bg-accent hover:text-accent-foreground transition-colors"
>
<ToolIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 text-sm font-medium truncate">
{formatToolName(tool.name)}
</span>
<Switch
checked={!isDisabled}
onCheckedChange={() => toggleTool(tool.name)}
className="shrink-0"
/>
</div>
);
})}
</div>
)}
{!filteredTools?.length && (
<div className="px-4 pt-3 pb-2">
<Skeleton className="h-3 w-16 mb-2" />
{["t1", "t2", "t3", "t4"].map((k) => (
<div key={k} className="flex items-center gap-3 py-2">
<Skeleton className="size-4 rounded shrink-0" />
<Skeleton className="h-3.5 flex-1" />
<Skeleton className="h-5 w-9 rounded-full shrink-0" />
</div>
))}
<Skeleton className="h-3 w-24 mt-3 mb-2" />
{["c1", "c2", "c3"].map((k) => (
<div key={k} className="flex items-center gap-3 py-2">
<Skeleton className="size-4 rounded shrink-0" />
<Skeleton className="h-3.5 flex-1" />
<Skeleton className="h-5 w-9 rounded-full shrink-0" />
</div>
))}
</div>
)}
</div>
</DrawerContent>
</Drawer>
</>
<ComposerAddMenuDrawer
trigger={
<Button
variant="ghost"
size="icon"
className="h-9 w-9 rounded-full p-0 font-semibold text-xs text-muted-foreground transition-colors dark:border-muted-foreground/15 hover:bg-foreground/10 hover:text-foreground"
aria-label="Upload files, manage tools and more"
data-joyride="connector-icon"
>
<Plus className="size-5" />
</Button>
}
onUploadFiles={() => openUploadDialog()}
connectorRows={connectorRows}
onSelectConnector={openConnectorManage}
onBrowseConnectors={openConnectors}
regularToolGroups={regularToolGroups}
connectorToolGroups={connectorToolGroups}
otherToolGroup={otherToolGroup}
disabledToolsSet={disabledToolsSet}
onToggleTool={toggleTool}
onToggleToolGroup={toggleToolGroup}
toolsLoading={!filteredTools?.length}
/>
) : (
<DropdownMenu
onOpenChange={(open) => {
@ -1522,10 +1245,53 @@ const ComposerAction: FC<ComposerActionProps> = ({
<Camera className="h-4 w-4" />
Take a screenshot
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setConnectorDialogOpen(true)}>
<Unplug className="h-4 w-4" />
MCP Connectors
</DropdownMenuItem>
{connectorRows.length > 0 ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Unplug className="h-4 w-4" />
MCP Connectors
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent
collisionPadding={8}
className="w-60 max-h-72 gap-1 overflow-y-auto overscroll-none"
>
{connectorRows.map((row) => (
<DropdownMenuItem
key={row.type}
onSelect={() => openConnectorManage(row)}
className="gap-2"
>
{getConnectorIcon(row.type, "h-4 w-4")}
<span className="min-w-0 flex-1 truncate">{row.title}</span>
{row.health === "syncing" ? (
<Spinner size="xs" className="shrink-0" />
) : row.health === "failed" ? (
<TriangleAlert
className="h-3.5 w-3.5 shrink-0 text-destructive"
aria-label={row.errorMessage ?? "Indexing failed"}
/>
) : row.accountCount > 1 ? (
<span className="shrink-0 text-xs text-muted-foreground">
{row.accountCount}
</span>
) : null}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={openConnectors} className="gap-2">
<LayoutGrid className="h-4 w-4" />
Manage connectors
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
) : (
<DropdownMenuItem onSelect={openConnectors}>
<Unplug className="h-4 w-4" />
MCP Connectors
</DropdownMenuItem>
)}
<DropdownMenuSub
open={toolsPopoverOpen}
onOpenChange={(open) => {
@ -1546,7 +1312,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
>
{regularToolGroups.map((group) => (
<div key={group.label}>
<div className="px-2 pt-1.5 pb-0.5 text-[10px] text-muted-foreground/80 font-normal select-none">
<div className="px-2 pt-1.5 pb-0.5 text-xs text-muted-foreground font-semibold select-none">
{group.label}
</div>
{group.tools.map((tool) => {
@ -1581,7 +1347,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
))}
{connectorToolGroups.length > 0 && (
<div>
<div className="px-2 pt-1.5 pb-0.5 text-[10px] text-muted-foreground/80 font-normal select-none">
<div className="px-2 pt-1.5 pb-0.5 text-xs text-muted-foreground font-semibold select-none">
Connector Actions
</div>
{connectorToolGroups.map((group) => {
@ -1667,7 +1433,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
)}
{otherToolGroup && (
<div>
<div className="px-2 pt-1.5 pb-0.5 text-[10px] text-muted-foreground/80 font-normal select-none">
<div className="px-2 pt-1.5 pb-0.5 text-xs text-muted-foreground font-semibold select-none">
{otherToolGroup.label}
</div>
{otherToolGroup.tools.map((tool) => {

View file

@ -324,7 +324,6 @@ export function FolderTreeView({
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-12 text-muted-foreground">
<Search className="h-10 w-10" />
<p className="text-sm text-muted-foreground">No matching documents</p>
<p className="text-xs text-muted-foreground/70 mt-1">Try a different search term</p>
</div>
);
}

View file

@ -2,7 +2,7 @@
import { useQuery } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { AlarmClock, AlertTriangle, Shapes, SquareTerminal } from "lucide-react";
import { AlarmClock, AlertTriangle, Shapes, SquareTerminal, Unplug } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
@ -300,6 +300,7 @@ export function LayoutDataProvider({
const isAutomationsActive = pathname?.includes("/automations") === true;
const isArtifactsActive = pathname?.endsWith("/artifacts") === true;
const isPlaygroundRoute = pathname?.includes("/playground") === true;
const isConnectorsRoute = pathname?.includes("/connectors") === true;
const navItems: NavItem[] = useMemo(
() =>
(
@ -316,6 +317,12 @@ export function LayoutDataProvider({
icon: Shapes,
isActive: isArtifactsActive,
},
{
title: "Connectors",
url: `/dashboard/${workspaceId}/connectors`,
icon: Unplug,
isActive: isConnectorsRoute,
},
{
title: "Playground",
url: `/dashboard/${workspaceId}/playground`,
@ -324,7 +331,7 @@ export function LayoutDataProvider({
},
] as (NavItem | null)[]
).filter((item): item is NavItem => item !== null),
[workspaceId, isAutomationsActive, isArtifactsActive, isPlaygroundRoute]
[workspaceId, isAutomationsActive, isArtifactsActive, isConnectorsRoute, isPlaygroundRoute]
);
// Handlers
@ -631,6 +638,7 @@ export function LayoutDataProvider({
const isAutomationsPage = pathname?.includes("/automations") === true;
const isArtifactsPage = pathname?.endsWith("/artifacts") === true;
const isPlaygroundPage = pathname?.includes("/playground") === true;
const isConnectorsPage = pathname?.includes("/connectors") === true;
const isAllChatsPage = pathname?.endsWith("/chats") === true;
const handleChatsClick = useCallback(() => {
router.push(`/dashboard/${workspaceId}/chats`);
@ -650,6 +658,7 @@ export function LayoutDataProvider({
isAutomationsPage ||
isArtifactsPage ||
isPlaygroundPage ||
isConnectorsPage ||
isAllChatsPage;
return (
@ -702,6 +711,7 @@ export function LayoutDataProvider({
isAutomationsPage ||
isArtifactsPage ||
isPlaygroundPage ||
isConnectorsPage ||
isAllChatsPage
? "items-start justify-center px-6 py-8 md:px-10 md:pb-10 md:pt-16"
: undefined

View file

@ -28,7 +28,6 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { trackWorkspaceCreated } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
@ -68,7 +67,8 @@ export function CreateWorkspaceDialog({ open, onOpenChange }: CreateWorkspaceDia
description: values.description || "",
});
trackWorkspaceCreated(result.id, values.name);
// workspace_created is now emitted server-side (workspaces_routes.py)
// so PAT/MCP-created workspaces are also counted.
// Seed the gate's query so it resolves without a loader flash, and
// route straight to onboarding vs. new-chat on the first hop.

View file

@ -487,13 +487,9 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) {
</div>
) : isSearchMode ? (
<div className="text-center py-8">
<Search className="mx-auto mb-2.5 h-10 w-10 text-muted-foreground" />
<p className="text-xs text-muted-foreground">
<p className="text-sm text-muted-foreground">
{t("no_chats_found") || "No chats found"}
</p>
<p className="mt-1 text-[11px] text-muted-foreground/70">
{t("try_different_search") || "Try a different search term"}
</p>
</div>
) : (
<div className="text-center py-8">

View file

@ -8,25 +8,18 @@ import {
FolderPlus,
FolderSync,
ListFilter,
Plus,
Settings2,
SlidersVertical,
Trash2,
Upload,
} from "lucide-react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { makeFolderMention, mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
import { importConnectorRequestAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms";
import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { useConnectorStatus } from "@/components/assistant-ui/connector-popup/hooks/use-connector-status";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog";
import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
import { getDocumentTypeIcon } from "@/components/documents/DocumentTypeIcon";
@ -34,7 +27,7 @@ import type { FolderDisplay } from "@/components/documents/FolderNode";
import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog";
import { FolderTreeView } from "@/components/documents/FolderTreeView";
import { VersionHistoryDialog } from "@/components/documents/version-history";
import { useOptionalRuntimeConfig, useRuntimeConfig } from "@/components/providers/runtime-config";
import { useRuntimeConfig } from "@/components/providers/runtime-config";
import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
import {
DEFAULT_EXCLUDE_PATTERNS,
@ -57,7 +50,6 @@ import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
@ -67,9 +59,6 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { useAnonymousMode, useIsAnonymous } from "@/contexts/anonymous-mode";
import { useLoginGate } from "@/contexts/login-gate";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useIsMobile } from "@/hooks/use-mobile";
import { useElectronAPI, usePlatform } from "@/hooks/use-platform";
@ -239,11 +228,9 @@ export function EmbeddedDocumentsMenu({
}
/**
* Import menu: local file upload plus the cloud-drive import connectors
* (Google Drive / OneDrive / Dropbox). Drive/OneDrive/Dropbox set
* `importConnectorRequestAtom`, which the connector dialog consumes to run
* OAuth or open the existing account's config. In anonymous mode, `gate`
* intercepts every item to trigger the login flow.
* Desktop-only "Watch Local Folder" entry point. File uploads now live in the
* chat composer and cloud drives in the connector catalog, so this renders
* nothing on web. `gate` login-gates the action in anonymous mode.
*/
export function EmbeddedImportMenu({
gate,
@ -252,30 +239,14 @@ export function EmbeddedImportMenu({
gate?: (feature: string) => void;
onFolderWatched?: () => void;
}) {
const { openDialog } = useDocumentUploadDialog();
const setImportRequest = useSetAtom(importConnectorRequestAtom);
// Provider is absent on anonymous /free pages, where every item is login-gated
// anyway — defaulting to hosted (Composio) there is cosmetic.
const selfHosted = useOptionalRuntimeConfig()?.deploymentMode === "self-hosted";
const { isConnectorEnabled, getConnectorStatusMessage } = useConnectorStatus();
const { data: connectors } = useAtomValue(connectorsAtom);
// Watch Local Folder is a desktop-app feature (needs the Electron folder watcher).
const { isDesktop } = usePlatform();
const params = useParams();
const workspaceId = getWorkspaceIdNumber(params) ?? 0;
const [folderWatchOpen, setFolderWatchOpen] = useState(false);
// Native Google Drive connector self-hosted only; hosted deployments use Composio.
const driveType = selfHosted
? EnumConnectorName.GOOGLE_DRIVE_CONNECTOR
: EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR;
const cloudItems = [
{ type: driveType, label: "Google Drive" },
{ type: EnumConnectorName.ONEDRIVE_CONNECTOR, label: "OneDrive" },
{ type: EnumConnectorName.DROPBOX_CONNECTOR, label: "Dropbox" },
];
// Nothing to import on web anymore — hide the button rather than show an empty menu.
if (!isDesktop) return null;
return (
<DropdownMenu>
@ -285,99 +256,20 @@ export function EmbeddedImportMenu({
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
aria-label="Import documents"
aria-label="Watch local folder"
>
<FilePlus className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onSelect={() => (gate ? gate("upload files") : openDialog())}>
<Upload className="h-4 w-4" />
Upload Files
<DropdownMenuItem
onSelect={() => (gate ? gate("watch local folders") : setFolderWatchOpen(true))}
>
<FolderSync className="h-4 w-4" />
Watch Local Folder
</DropdownMenuItem>
{isDesktop && (
<DropdownMenuItem
onSelect={() => (gate ? gate("watch local folders") : setFolderWatchOpen(true))}
>
<FolderSync className="h-4 w-4" />
Watch Local Folder
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{cloudItems.map((item) => {
const enabled = gate ? true : isConnectorEnabled(item.type);
const statusMessage = enabled ? null : getConnectorStatusMessage(item.type);
const icon = getConnectorIcon(item.type, "h-4 w-4");
// gate = anonymous mode; treat every connector as unconnected so items
// route through the login gate rather than reading workspace connectors.
const accountCount = gate
? 0
: (connectors ?? []).filter(
(c: SearchSourceConnector) => c.connector_type === item.type
).length;
// Unavailable (e.g. maintenance): non-actionable item explaining why.
if (!enabled) {
return (
<DropdownMenuItem key={item.type} disabled title={statusMessage ?? undefined}>
{icon}
{item.label}
</DropdownMenuItem>
);
}
// Connected: manage the existing account(s) or add another.
if (accountCount > 0) {
return (
<DropdownMenuSub key={item.type}>
<DropdownMenuSubTrigger>
{icon}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
<span className="ml-auto text-xs text-muted-foreground">{accountCount}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-48">
<DropdownMenuItem
onSelect={() =>
gate
? gate("manage import connectors")
: setImportRequest({ connectorType: item.type, mode: "auto" })
}
>
<Settings2 className="h-4 w-4" />
{accountCount > 1 ? "Manage accounts" : "Manage"}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
gate
? gate("import from cloud storage")
: setImportRequest({ connectorType: item.type, mode: "connect" })
}
>
<Plus className="h-4 w-4" />
Add another account
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
// Not connected: single click starts the first OAuth connect.
return (
<DropdownMenuItem
key={item.type}
onSelect={() =>
gate
? gate("import from cloud storage")
: setImportRequest({ connectorType: item.type, mode: "auto" })
}
>
{icon}
{item.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
{isDesktop && !gate && (
{!gate && (
<FolderWatchDialog
open={folderWatchOpen}
onOpenChange={setFolderWatchOpen}

View file

@ -140,6 +140,10 @@ export function Sidebar({
() => navItems.find((item) => item.url.endsWith("/artifacts")),
[navItems]
);
const connectorsItem = useMemo(
() => navItems.find((item) => item.url.endsWith("/connectors")),
[navItems]
);
const playgroundItem = useMemo(
() => navItems.find((item) => item.url.endsWith("/playground")),
[navItems]
@ -150,6 +154,7 @@ export function Sidebar({
(item) =>
!item.url.endsWith("/automations") &&
!item.url.endsWith("/artifacts") &&
!item.url.endsWith("/connectors") &&
!item.url.endsWith("/playground")
),
[navItems]
@ -254,6 +259,16 @@ export function Sidebar({
tooltipContent={isCollapsed ? artifactsItem.title : undefined}
/>
)}
{connectorsItem && (
<SidebarButton
icon={connectorsItem.icon}
label={connectorsItem.title}
onClick={() => onNavItemClick?.(connectorsItem)}
isCollapsed={isCollapsed}
isActive={connectorsItem.isActive}
tooltipContent={isCollapsed ? connectorsItem.title : undefined}
/>
)}
{playgroundItem && (
<SidebarButton
icon={playgroundItem.icon}

View file

@ -17,6 +17,16 @@ interface PublicChatSnapshotsManagerProps {
workspaceId: number;
}
const infoAlert = (
<Alert>
<Info />
<AlertDescription>
Public chats allow anyone with the URL to view a snapshot of a chat. They do not update when
the original chat changes.
</AlertDescription>
</Alert>
);
export function PublicChatSnapshotsManager({
workspaceId: _workspaceId,
}: PublicChatSnapshotsManagerProps) {
@ -81,14 +91,7 @@ export function PublicChatSnapshotsManager({
if (isLoading) {
return (
<div className="space-y-4 md:space-y-5">
<Alert>
<Info />
<AlertDescription>
<div className="flex min-h-[1.625em] items-center">
<Skeleton className="h-4 w-60 bg-accent-foreground/15" />
</div>
</AlertDescription>
</Alert>
{infoAlert}
{/* Cards grid skeleton */}
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2">
@ -135,13 +138,7 @@ export function PublicChatSnapshotsManager({
return (
<div className="space-y-4 md:space-y-5">
<Alert>
<Info />
<AlertDescription>
Public chats allow anyone with the URL to view a snapshot of a chat. They do not update
when the original chat changes.
</AlertDescription>
</Alert>
{infoAlert}
<PublicChatSnapshotsList
snapshots={snapshots}

View file

@ -4,7 +4,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect } from "react";
import { toast } from "sonner";
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
import { Button } from "@/components/ui/button";
@ -15,11 +14,7 @@ import { Spinner } from "@/components/ui/spinner";
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
import { stripeApiService } from "@/lib/apis/stripe-api.service";
import {
trackIncentivePageViewed,
trackIncentiveTaskClicked,
trackIncentiveTaskCompleted,
} from "@/lib/posthog/events";
import { trackIncentiveTaskClicked } from "@/lib/posthog/events";
import { getWorkspaceIdParam } from "@/lib/route-params";
import { cn } from "@/lib/utils";
@ -35,9 +30,7 @@ export function EarnCreditsContent() {
const queryClient = useQueryClient();
const workspaceId = getWorkspaceIdParam(params) ?? "";
useEffect(() => {
trackIncentivePageViewed();
}, []);
// incentive_page_viewed removed — redundant with $pageview.
const { data, isLoading } = useQuery({
queryKey: ["incentive-tasks"],
@ -51,13 +44,11 @@ export function EarnCreditsContent() {
const completeMutation = useMutation({
mutationFn: incentiveTasksApiService.completeTask,
onSuccess: (response, taskType) => {
onSuccess: (response) => {
if (response.success) {
toast.success(response.message);
const task = data?.tasks.find((t) => t.task_type === taskType);
if (task) {
trackIncentiveTaskCompleted(taskType, task.credit_micros_reward);
}
// incentive_task_completed is now emitted server-side
// (incentive_tasks_routes.complete_task) where credit is granted.
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY });
}

View file

@ -29,11 +29,7 @@ import { Switch } from "@/components/ui/switch";
import type { ProcessingMode } from "@/contracts/types/document.types";
import { useElectronAPI } from "@/hooks/use-platform";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import {
trackDocumentUploadFailure,
trackDocumentUploadStarted,
trackDocumentUploadSuccess,
} from "@/lib/posthog/events";
import { trackDocumentUploadStarted } from "@/lib/posthog/events";
import {
getAcceptedFileTypes,
getSupportedExtensions,
@ -380,13 +376,14 @@ export function DocumentUploadTab({
setUploadProgress(Math.round((uploaded / total) * 100));
}
trackDocumentUploadSuccess(Number(workspaceId), total);
// Ingestion outcome is now emitted server-side
// (document_processing_completed/_failed in document_tasks.py); the
// upload POST succeeding only means the file was accepted, not processed.
toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
setFolderUpload(null);
onSuccess?.();
} catch (error) {
const message = error instanceof Error ? error.message : "Upload failed";
trackDocumentUploadFailure(Number(workspaceId), message);
toast(t("upload_error"), {
description: `${t("upload_error_desc")}: ${message}`,
});
@ -421,7 +418,7 @@ export function DocumentUploadTab({
onSuccess: () => {
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
setUploadProgress(100);
trackDocumentUploadSuccess(Number(workspaceId), files.length);
// Ingestion outcome now server-side (document_processing_*).
toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
onSuccess?.();
},
@ -429,7 +426,6 @@ export function DocumentUploadTab({
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
setUploadProgress(0);
const message = error instanceof Error ? error.message : "Upload failed";
trackDocumentUploadFailure(Number(workspaceId), message);
toast(t("upload_error"), {
description: `${t("upload_error_desc")}: ${message}`,
});

View file

@ -26,7 +26,7 @@ Connectors bring data into SurfSense — either as searchable knowledge or as li
## How connectors surface in SurfSense
- **Native scrapers** — the AI agent uses them as tools automatically in chat, and you can run them yourself from the **API Playground** or your own code via the REST API.
- **Tools & Live Sources** — external connectors like Notion, Slack, Jira, or Linear give the agent real-time tools. Ask a question in chat and the agent searches or acts on the connected service directly; nothing is copied into SurfSense in the background.
- **Integrations** — external connectors like Notion, Slack, Jira, or Linear give the agent real-time tools. Ask a question in chat and the agent searches or acts on the connected service directly; nothing is copied into SurfSense in the background.
- **Knowledge Base** — file sources like Google Drive, OneDrive, and Dropbox are imported through the Documents sidebar's **Import** menu and indexed into your knowledge base alongside uploads and notes.
To manage external connectors, open the **Connectors** dialog inside your workspace. Connected accounts, sync status, and disconnect options all live there.

View file

@ -156,6 +156,40 @@ transport.
Celery runtime, and runtime gauges appear within one export interval.
6. Confirm logs emitted inside a traced request show non-zero trace and span IDs.
## Product Analytics (PostHog)
Separate from OpenTelemetry, the backend can emit server-side product events to
PostHog. This is the authoritative source for outcome events (chats, document
ingestion, connector indexing, billing, automations) because it captures traffic
the browser never sees — MCP clients, personal-access-token scripts, and Celery
background jobs. It is fully opt-in and mirrors the OTel contract: with
`POSTHOG_API_KEY` unset, every capture is a silent no-op.
Use the **same** project key as the frontend's `NEXT_PUBLIC_POSTHOG_KEY` so
server events merge onto the same PostHog persons the web app identifies by user
id. Add these to `surfsense_backend/.env` (local) or `docker/.env` (production);
they reach the API, Celery worker, and beat services via `env_file`, so no
compose changes are needed:
```dotenv
POSTHOG_API_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
POSTHOG_HOST=https://us.i.posthog.com
POSTHOG_AI_PRIVACY_MODE=true
```
`POSTHOG_AI_PRIVACY_MODE` defaults to `true`; set it to `false` only if you want
LLM prompt and completion bodies shipped to PostHog's AI observability views.
Every backend event is stamped `source=backend` (so it is distinguishable from
frontend captures), carries `auth_method` / `client` for surface attribution, and
sends `disable_geoip=true` so the server IP never overwrites a person's real
location. LangGraph chat turns additionally emit `$ai_generation` / `$ai_span`
traces via the PostHog LangChain callback handler, keyed by turn and chat id.
Keep event properties low-cardinality. Never attach user content — workspace
names, connector titles, document titles, prompts, or raw queries — as event
properties; they carry no aggregation value and are a privacy risk.
## Out Of Scope
- Frontend/browser OpenTelemetry.

View file

@ -74,7 +74,17 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
case EnumConnectorName.CIRCLEBACK_CONNECTOR:
return <Image src="/connectors/circleback.svg" alt="Circleback" {...imgProps} />;
case EnumConnectorName.MCP_CONNECTOR:
return <Image src="/connectors/modelcontextprotocol.svg" alt="MCP" {...imgProps} />;
// Masked so the black glyph inherits currentColor (white in dark mode).
return (
<span
aria-hidden="true"
className={`${className || "h-5 w-5"} bg-current select-none pointer-events-none`}
style={{
mask: "url('/connectors/modelcontextprotocol.svg') center / contain no-repeat",
WebkitMask: "url('/connectors/modelcontextprotocol.svg') center / contain no-repeat",
}}
/>
);
case EnumConnectorName.OBSIDIAN_CONNECTOR:
return <Image src="/connectors/obsidian.svg" alt="Obsidian" {...imgProps} />;
case EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR:

View file

@ -1,7 +1,7 @@
"use client";
import { useSetAtom } from "jotai";
import { Boxes, RefreshCw, TriangleAlert } from "lucide-react";
import { RefreshCw, Shapes, TriangleAlert } from "lucide-react";
import { useMemo, useState } from "react";
import { openReportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { MobileReportPanel } from "@/components/report-panel/report-panel";
@ -48,7 +48,7 @@ function EmptyState() {
return (
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 px-6 py-12 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Boxes className="h-6 w-6" aria-hidden />
<Shapes className="h-6 w-6" aria-hidden />
</div>
<h3 className="mt-4 text-base font-semibold text-foreground">No artifacts yet</h3>
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">

View file

@ -4,7 +4,6 @@ import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ScraperRunDetail, ScraperRunEvent } from "@/contracts/types/scraper.types";
import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
import { trackWeeklyUser } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
export type RunStatus = "idle" | "running" | "success" | "error" | "cancelled";
@ -110,7 +109,8 @@ export function useRunStream(workspaceId: number) {
try {
const started = await scrapersApiService.runAsync(workspaceId, platform, verb, payload);
runIdRef.current = started.run_id;
trackWeeklyUser("api_run", workspaceId);
// weekly_users removed — WAU is derived server-side from
// scraper_run_completed / chat_turn_completed.
setState((s) => ({ ...s, runId: started.run_id }));
void consume(started.run_id, controller.signal);
} catch (e) {

View file

@ -40,6 +40,30 @@ function blockRefreshRetry(key: string): void {
refreshRetryBlockedUntil.set(key, Date.now() + REFRESH_RETRY_BLOCK_MS);
}
/**
* Send an API failure to PostHog error tracking. Scoped by the caller to only
* 5xx server faults + network outages 4xx responses are expected behavior.
* Lazy-imports posthog-js so an ad-blocker can never break the request path.
*/
function captureApiException(error: unknown, url: string, method?: RequestOptions["method"]): void {
import("posthog-js")
.then(({ default: posthog }) => {
posthog.captureException(error, {
api_url: url,
api_method: method ?? "GET",
...(error instanceof AppError && {
status_code: error.status,
status_text: error.statusText,
error_code: error.code,
request_id: error.requestId,
}),
});
})
.catch(() => {
console.error("Failed to capture exception in PostHog");
});
}
export type RequestOptions = {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
headers?: Record<string, string>;
@ -288,9 +312,12 @@ class BaseApiService {
throw new AbortedError();
}
if (error instanceof TypeError && !(error instanceof AppError)) {
throw new NetworkError(
const networkError = new NetworkError(
"Unable to connect to the server. Check your internet connection and try again."
);
// Network failures are genuine outages worth tracking.
captureApiException(networkError, url, options?.method);
throw networkError;
}
// Handled client errors (validation, credits, auth, not-found, ...) are
@ -305,23 +332,10 @@ class BaseApiService {
if (!isHandledClientError) {
console.error("Request failed:", JSON.stringify(error));
if (!(error instanceof AuthenticationError)) {
import("posthog-js")
.then(({ default: posthog }) => {
posthog.captureException(error, {
api_url: url,
api_method: options?.method ?? "GET",
...(error instanceof AppError && {
status_code: error.status,
status_text: error.statusText,
error_code: error.code,
request_id: error.requestId,
}),
});
})
.catch(() => {
console.error("Failed to capture exception in PostHog");
});
// Only 5xx server faults are unexpected; 4xx are handled above and
// network outages were already captured before this point.
if (error instanceof AppError && error.status >= 500) {
captureApiException(error, url, options?.method);
}
}
throw error;

View file

@ -58,7 +58,6 @@ import {
import { buildBackendUrl } from "@/lib/env-config";
import {
trackChatBlocked,
trackChatCreated,
trackChatErrorDetailed,
trackChatMessageSent,
trackChatResponseReceived,
@ -384,7 +383,8 @@ export async function startNewChat(ctx: EngineContext, message: AppendMessage):
queryClient.setQueryData(cacheKeys.threads.detail(newThread.id), newThread);
queryClient.setQueryData(cacheKeys.threads.messages(newThread.id), { messages: [] });
trackChatCreated(workspaceId, currentThreadId);
// chat_created is now emitted server-side (new_chat_routes.create_thread)
// so PAT/MCP-created threads are also counted.
isNewThread = true;
// Update URL silently using browser API (not router.replace) to avoid

View file

@ -3,24 +3,20 @@ import type { ChatErrorKind, ChatErrorSeverity, ChatFlow } from "@/lib/chat/chat
import { getConnectorTelemetryMeta } from "@/lib/connector-telemetry";
/**
* PostHog Analytics Event Definitions
* PostHog Analytics Event Definitions (frontend)
*
* All capture/identify/reset calls are wrapped in try-catch so that
* ad-blockers that interfere with posthog-js can never break app
* functionality (e.g. the chat flow).
*
* Events follow a consistent naming convention: category_action
* SCOPE: this file now holds only *intent* and client-perceived *UX* events.
* Authoritative *outcome* events (resource creation, task/ingestion/indexing
* completion, auth success, billing) are emitted server-side in
* surfsense_backend/app/observability/analytics.py they are reliable
* regardless of ad-blockers, tab-close, or non-browser (MCP/PAT/OAuth)
* clients. Do NOT re-add optimistic outcome captures here; they double-count.
*
* Categories:
* - auth: Authentication events
* - workspace: Search space management
* - document: Document management
* - chat: Chat and messaging (authenticated + anonymous)
* - connector: External connector events (all lifecycle stages)
* - contact: Contact form events
* - settings: Settings changes
* - automation: Automation lifecycle (create/update/delete/trigger/chat)
* - marketing: Marketing/referral tracking
* Events follow a consistent naming convention: category_action
*/
function safeCapture(event: string, properties?: Record<string, unknown>) {
@ -43,17 +39,13 @@ function compact<T extends object>(obj: T): Record<string, unknown> {
}
// ============================================
// AUTH EVENTS
// AUTH EVENTS (attempts + failures only; successes are server-side)
// ============================================
export function trackLoginAttempt(method: "local" | "google") {
safeCapture("auth_login_attempt", { method });
}
export function trackLoginSuccess(method: "local" | "google") {
safeCapture("auth_login_success", { method });
}
export function trackLoginFailure(method: "local" | "google", error?: string) {
safeCapture("auth_login_failure", { method, error });
}
@ -62,10 +54,6 @@ export function trackRegistrationAttempt() {
safeCapture("auth_registration_attempt");
}
export function trackRegistrationSuccess() {
safeCapture("auth_registration_success");
}
export function trackRegistrationFailure(error?: string) {
safeCapture("auth_registration_failure", { error });
}
@ -75,56 +63,9 @@ export function trackLogout() {
}
// ============================================
// SEARCH SPACE EVENTS
// CHAT EVENTS (client-perceived UX)
// ============================================
export function trackWorkspaceCreated(workspaceId: number, name: string) {
safeCapture("workspace_created", {
workspace_id: workspaceId,
name,
});
}
export function trackWorkspaceDeleted(workspaceId: number) {
safeCapture("workspace_deleted", {
workspace_id: workspaceId,
});
}
export function trackWorkspaceViewed(workspaceId: number) {
safeCapture("workspace_viewed", {
workspace_id: workspaceId,
});
}
// ============================================
// ACTIVE-USER (WAU) EVENT
// ============================================
/**
* Single signal for active-user counting. Fired whenever a user sends a
* chat message or starts an API run, so a "weekly unique users on
* weekly_users" insight in PostHog is our WAU number.
*
* ponytail: frontend-only capture API runs made directly against the
* backend (PAT/curl, no browser) are not counted. Upgrade path is a
* server-side capture in the backend if that ever matters.
*/
export function trackWeeklyUser(source: "chat_message" | "api_run", workspaceId?: number) {
safeCapture("weekly_users", compact({ source, workspace_id: workspaceId }));
}
// ============================================
// CHAT EVENTS
// ============================================
export function trackChatCreated(workspaceId: number, chatId: number) {
safeCapture("chat_created", {
workspace_id: workspaceId,
chat_id: chatId,
});
}
export function trackChatMessageSent(
workspaceId: number,
chatId: number,
@ -141,7 +82,6 @@ export function trackChatMessageSent(
has_mentioned_documents: options?.hasMentionedDocuments ?? false,
message_length: options?.messageLength,
});
trackWeeklyUser("chat_message", workspaceId);
}
export function trackChatResponseReceived(workspaceId: number, chatId: number) {
@ -213,6 +153,10 @@ export function trackChatErrorDetailed(
* flow. This is intentionally a separate event from `chat_message_sent`
* so WAU / retention queries on the authenticated event stay clean while
* still giving us visibility into top-of-funnel usage on /free/*.
*
* Kept frontend-side despite the backend's `anon_chat_turn_completed`: the
* frontend anon distinct id is what merges into the person at signup,
* powering the anonymous-to-registered conversion funnel.
*/
export function trackAnonymousChatMessageSent(options: {
modelSlug: string;
@ -229,7 +173,7 @@ export function trackAnonymousChatMessageSent(options: {
}
// ============================================
// DOCUMENT EVENTS
// DOCUMENT EVENTS (intent only; ingestion outcome is server-side)
// ============================================
export function trackDocumentUploadStarted(
@ -244,59 +188,20 @@ export function trackDocumentUploadStarted(
});
}
export function trackDocumentUploadSuccess(workspaceId: number, fileCount: number) {
safeCapture("document_upload_success", {
workspace_id: workspaceId,
file_count: fileCount,
});
}
export function trackDocumentUploadFailure(workspaceId: number, error?: string) {
safeCapture("document_upload_failure", {
workspace_id: workspaceId,
error,
});
}
export function trackDocumentDeleted(workspaceId: number, documentId: number) {
safeCapture("document_deleted", {
workspace_id: workspaceId,
document_id: documentId,
});
}
export function trackDocumentBulkDeleted(workspaceId: number, count: number) {
safeCapture("document_bulk_deleted", {
workspace_id: workspaceId,
count,
});
}
export function trackYouTubeImport(workspaceId: number, url: string) {
safeCapture("youtube_import_started", {
workspace_id: workspaceId,
url,
});
}
// ============================================
// CONNECTOR EVENTS (generic lifecycle dispatcher)
// CONNECTOR EVENTS (setup intent/UX; connected/deleted are server-side)
// ============================================
//
// All connector events go through `trackConnectorEvent`. The connector's
// human-readable title and its group (oauth/composio/crawler/other) are
// auto-attached from the shared registry in `connector-constants.ts`, so
// adding a new connector to that list is the only change required for it
// to show up correctly in PostHog dashboards.
// group (oauth/composio/crawler/other) is auto-attached from the shared
// registry, so adding a new connector to that list is the only change
// required for it to show up correctly in PostHog dashboards.
export type ConnectorEventStage =
| "setup_started"
| "setup_success"
| "setup_failure"
| "oauth_initiated"
| "connected"
| "deleted"
| "synced";
| "oauth_initiated";
export interface ConnectorEventOptions {
workspaceId?: number | null;
@ -312,6 +217,9 @@ export interface ConnectorEventOptions {
/**
* Generic connector lifecycle tracker. Every connector analytics event
* should funnel through here so the enrichment stays consistent.
*
* ``connector_title`` is intentionally NOT sent it's a display label with
* no aggregation value; segment on ``connector_type`` / ``connector_group``.
*/
export function trackConnectorEvent(
stage: ConnectorEventStage,
@ -327,7 +235,6 @@ export function trackConnectorEvent(
error: options.error,
}),
connector_type: meta.connector_type,
connector_title: meta.connector_title,
connector_group: meta.connector_group,
is_oauth: meta.is_oauth,
...(options.extra ?? {}),
@ -365,59 +272,10 @@ export function trackConnectorSetupFailure(
});
}
export function trackConnectorDeleted(
workspaceId: number,
connectorType: string,
connectorId: number
) {
trackConnectorEvent("deleted", connectorType, { workspaceId, connectorId });
}
export function trackConnectorSynced(
workspaceId: number,
connectorType: string,
connectorId: number
) {
trackConnectorEvent("synced", connectorType, { workspaceId, connectorId });
}
// ============================================
// SETTINGS EVENTS
// ============================================
export function trackSettingsViewed(workspaceId: number, section: string) {
safeCapture("settings_viewed", {
workspace_id: workspaceId,
section,
});
}
export function trackSettingsUpdated(workspaceId: number, section: string, setting: string) {
safeCapture("settings_updated", {
workspace_id: workspaceId,
section,
setting,
});
}
// ============================================
// FEATURE USAGE EVENTS
// ============================================
export function trackPodcastGenerated(workspaceId: number, chatId: number) {
safeCapture("podcast_generated", {
workspace_id: workspaceId,
chat_id: chatId,
});
}
export function trackSourcesTabViewed(workspaceId: number, tab: string) {
safeCapture("sources_tab_viewed", {
workspace_id: workspaceId,
tab,
});
}
export function trackDesktopDownloadClicked(options: {
os: string;
placement: "sidebar_collapsed" | "sidebar_expanded";
@ -429,84 +287,7 @@ export function trackDesktopDownloadClicked(options: {
}
// ============================================
// SEARCH SPACE INVITE EVENTS
// ============================================
export function trackWorkspaceInviteSent(
workspaceId: number,
options?: {
roleName?: string;
hasExpiry?: boolean;
hasMaxUses?: boolean;
}
) {
safeCapture("workspace_invite_sent", {
workspace_id: workspaceId,
role_name: options?.roleName,
has_expiry: options?.hasExpiry ?? false,
has_max_uses: options?.hasMaxUses ?? false,
});
}
export function trackWorkspaceInviteAccepted(
workspaceId: number,
workspaceName: string,
roleName?: string | null
) {
safeCapture("workspace_invite_accepted", {
workspace_id: workspaceId,
workspace_name: workspaceName,
role_name: roleName,
});
}
export function trackWorkspaceInviteDeclined(workspaceName?: string) {
safeCapture("workspace_invite_declined", {
workspace_name: workspaceName,
});
}
export function trackWorkspaceUserAdded(
workspaceId: number,
workspaceName: string,
roleName?: string | null
) {
safeCapture("workspace_user_added", {
workspace_id: workspaceId,
workspace_name: workspaceName,
role_name: roleName,
});
}
export function trackWorkspaceUsersViewed(
workspaceId: number,
userCount: number,
ownerCount: number
) {
safeCapture("workspace_users_viewed", {
workspace_id: workspaceId,
user_count: userCount,
owner_count: ownerCount,
});
}
// ============================================
// CONNECTOR CONNECTION EVENTS
// ============================================
export function trackConnectorConnected(
workspaceId: number,
connectorType: string,
connectorId?: number
) {
trackConnectorEvent("connected", connectorType, {
workspaceId,
connectorId: connectorId ?? undefined,
});
}
// ============================================
// INDEXING EVENTS
// INDEXING EVENTS (intent/UX; indexing outcome is server-side)
// ============================================
export function trackIndexWithDateRangeOpened(
@ -578,20 +359,19 @@ export function trackPeriodicIndexingStarted(
}
// ============================================
// INCENTIVE TASKS EVENTS
// SEARCH SPACE INVITE EVENTS (decline is client-only; sent/accepted server-side)
// ============================================
export function trackIncentivePageViewed() {
safeCapture("incentive_page_viewed");
}
export function trackIncentiveTaskCompleted(taskType: string, creditMicrosRewarded: number) {
safeCapture("incentive_task_completed", {
task_type: taskType,
credit_micros_rewarded: creditMicrosRewarded,
export function trackWorkspaceInviteDeclined(workspaceName?: string) {
safeCapture("workspace_invite_declined", {
workspace_name: workspaceName,
});
}
// ============================================
// INCENTIVE TASKS EVENTS (click intent only; completion is server-side)
// ============================================
export function trackIncentiveTaskClicked(taskType: string) {
safeCapture("incentive_task_clicked", {
task_type: taskType,
@ -616,83 +396,25 @@ export function trackReferralLanding(refCode: string, landingUrl: string) {
}
// ============================================
// AUTOMATION EVENTS
// AUTOMATION EVENTS (failures + chat-builder UX; CRUD outcomes are server-side)
// ============================================
interface AutomationCreatedProps {
workspace_id: number;
automation_id: number;
task_count?: number;
trigger_type?: string;
has_schedule?: boolean;
chat_model_id?: number;
image_gen_model_id?: number;
vision_model_id?: number;
tags_count?: number;
}
export function trackAutomationCreated(props: AutomationCreatedProps) {
safeCapture("automation_created", compact(props));
}
export function trackAutomationCreateFailed(props: { workspace_id?: number; error?: string }) {
safeCapture("automation_create_failed", compact(props));
}
export function trackAutomationUpdated(props: {
automation_id: number;
workspace_id?: number;
has_definition_change?: boolean;
has_name_change?: boolean;
has_description_change?: boolean;
task_count?: number;
}) {
safeCapture("automation_updated", compact(props));
}
export function trackAutomationStatusChanged(props: {
automation_id: number;
workspace_id?: number;
next_status: string;
}) {
safeCapture("automation_status_changed", compact(props));
}
export function trackAutomationUpdateFailed(props: { automation_id: number; error?: string }) {
safeCapture("automation_update_failed", compact(props));
}
export function trackAutomationDeleted(props: { automation_id: number; workspace_id?: number }) {
safeCapture("automation_deleted", compact(props));
}
export function trackAutomationDeleteFailed(props: { automation_id: number; error?: string }) {
safeCapture("automation_delete_failed", compact(props));
}
export function trackAutomationTriggerAdded(props: {
automation_id: number;
trigger_id?: number;
trigger_type?: string;
enabled?: boolean;
has_cron?: boolean;
}) {
safeCapture("automation_trigger_added", compact(props));
}
export function trackAutomationTriggerAddFailed(props: { automation_id: number; error?: string }) {
safeCapture("automation_trigger_add_failed", compact(props));
}
export function trackAutomationTriggerUpdated(props: {
automation_id: number;
trigger_id: number;
change?: "enabled" | "params" | "other";
enabled?: boolean;
}) {
safeCapture("automation_trigger_updated", compact(props));
}
export function trackAutomationTriggerUpdateFailed(props: {
automation_id: number;
trigger_id: number;
@ -701,13 +423,6 @@ export function trackAutomationTriggerUpdateFailed(props: {
safeCapture("automation_trigger_update_failed", compact(props));
}
export function trackAutomationTriggerRemoved(props: {
automation_id: number;
trigger_id: number;
}) {
safeCapture("automation_trigger_removed", compact(props));
}
export function trackAutomationTriggerRemoveFailed(props: {
automation_id: number;
trigger_id: number;

View file

@ -664,7 +664,6 @@
"chat_deleted": "Chat deleted successfully",
"error_deleting_chat": "Failed to delete chat",
"delete": "Delete",
"try_different_search": "Try a different search term",
"updated": "Updated",
"more_options": "More options",
"clear_search": "Clear search",

View file

@ -664,7 +664,6 @@
"chat_deleted": "Chat eliminado correctamente",
"error_deleting_chat": "Error al eliminar el chat",
"delete": "Eliminar",
"try_different_search": "Intenta con un término de búsqueda diferente",
"updated": "Actualizado",
"more_options": "Más opciones",
"clear_search": "Limpiar búsqueda",

View file

@ -664,7 +664,6 @@
"chat_deleted": "चैट सफलतापूर्वक हटाया गया",
"error_deleting_chat": "चैट हटाने में विफल",
"delete": "हटाएं",
"try_different_search": "कोई अलग खोज शब्द आज़माएं",
"updated": "अपडेट किया गया",
"more_options": "और विकल्प",
"clear_search": "खोज साफ करें",

View file

@ -664,7 +664,6 @@
"chat_deleted": "채팅이 성공적으로 삭제되었습니다",
"error_deleting_chat": "채팅 삭제에 실패했습니다",
"delete": "삭제",
"try_different_search": "다른 검색어를 시도해보세요",
"updated": "업데이트됨",
"more_options": "추가 옵션",
"clear_search": "검색 지우기",

View file

@ -664,7 +664,6 @@
"chat_deleted": "Chat excluído com sucesso",
"error_deleting_chat": "Falha ao excluir chat",
"delete": "Excluir",
"try_different_search": "Tente um termo de pesquisa diferente",
"updated": "Atualizado",
"more_options": "Mais opções",
"clear_search": "Limpar pesquisa",

View file

@ -663,7 +663,6 @@
"chat_deleted": "对话删除成功",
"error_deleting_chat": "删除对话失败",
"delete": "删除",
"try_different_search": "尝试其他搜索词",
"updated": "更新时间",
"more_options": "更多选项",
"clear_search": "清除搜索",