Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
565e6c8
feat: add Connectors page UI with disconnect functionality
sidneyswift Jan 12, 2026
286f50f
Merge remote-tracking branch 'origin/test' into composio-tool-router
sidneyswift Jan 19, 2026
4123cbe
refactor: extract getConnectorIcon to lib/connectors (SRP)
sidneyswift Jan 19, 2026
df9eb8c
refactor: extract ConnectorsMenuItem component (OCP)
sidneyswift Jan 19, 2026
f6cd062
refactor: remove ConnectionSuccessBanner from shared chat (SRP)
sidneyswift Jan 19, 2026
8517377
refactor: extract useConnectorHandlers hook (SRP)
sidneyswift Jan 19, 2026
dcc44dd
refactor: extract ConnectorsPage into standalone components (SRP)
sidneyswift Jan 19, 2026
27b284c
refactor: extract ComposioAuthResult into standalone components (SRP)
sidneyswift Jan 19, 2026
11e80c0
refactor: use specific COMPOSIO_MANAGE_CONNECTIONS tool name (KISS)
sidneyswift Jan 19, 2026
597fda9
refactor: hide Composio branding - display as 'Manage Connections'
sidneyswift Jan 19, 2026
8ddeac0
refactor: add display names for all Composio tools
sidneyswift Jan 19, 2026
4ea3299
feat: add Coming Soon state for unavailable connectors (YAGNI)
sidneyswift Jan 19, 2026
9fb833b
refactor: remove unused connector display names (YAGNI)
sidneyswift Jan 19, 2026
32ad2a8
refactor: remove IS_LOCAL complexity (KISS)
sidneyswift Jan 19, 2026
bd1a00a
refactor: extract ConnectorCard conditionals into components (SRP)
sidneyswift Jan 19, 2026
55208e5
refactor: rename lib/connectors to lib/composio
sidneyswift Jan 19, 2026
0865be2
Address PR feedback: YAGNI and SRP improvements
sweetmantech Jan 20, 2026
3a13ad6
Extract findAuthResult and hasValidAuthData to lib/composio (SRP)
sweetmantech Jan 20, 2026
c07f9c5
Remove comingSoon logic from connectors (YAGNI)
sweetmantech Jan 20, 2026
3cac7fd
fix: update connector hooks to use Bearer auth and fix API base URL
sidneyswift Jan 20, 2026
49040c8
revert: remove IS_LOCAL and API URL changes from lib/consts.ts
sidneyswift Jan 20, 2026
ff929b6
Merge remote-tracking branch 'origin/test' into composio-tool-router
sweetmantech Jan 20, 2026
7b9e93e
feat: only show Google Sheets connector on settings page
sweetmantech Jan 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions app/settings/connectors/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"use client";

import { Suspense } from "react";
import { ConnectorsPage } from "@/components/ConnectorsPage";

export default function SettingsConnectorsPage() {
return (
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground">Loading...</p>
</div>
}
>
<ConnectorsPage />
</Suspense>
);
}
65 changes: 65 additions & 0 deletions components/ConnectorsPage/ConnectorCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"use client";

import { ConnectorInfo } from "@/hooks/useConnectors";
import { useConnectorHandlers } from "@/hooks/useConnectorHandlers";
import { getConnectorMeta } from "@/lib/composio/connectorMetadata";
import { formatConnectorName } from "@/lib/composio/formatConnectorName";
import { getConnectorIcon } from "@/lib/composio/getConnectorIcon";
import { ConnectorConnectedMenu } from "./ConnectorConnectedMenu";
import { ConnectorEnableButton } from "./ConnectorEnableButton";

interface ConnectorCardProps {
connector: ConnectorInfo;
onConnect: (slug: string) => Promise<string | null>;
onDisconnect: (connectedAccountId: string) => Promise<boolean>;
}

/**
* Card component for a single connector.
*/
export function ConnectorCard({
connector,
onConnect,
onDisconnect,
}: ConnectorCardProps) {
const { isConnecting, isDisconnecting, handleConnect, handleDisconnect } =
useConnectorHandlers({
slug: connector.slug,
connectedAccountId: connector.connectedAccountId,
onConnect,
onDisconnect,
});
const meta = getConnectorMeta(connector.slug);

return (
<div className="group flex items-center gap-4 p-4 rounded-xl border border-border bg-card transition-all duration-200 hover:border-muted-foreground/30 hover:shadow-sm">
<div className="shrink-0 p-2.5 rounded-xl transition-colors bg-muted/50 group-hover:bg-muted">
{getConnectorIcon(connector.slug, 22)}
</div>

<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground truncate">
{formatConnectorName(connector.name, connector.slug)}
</h3>
<p className="text-sm text-muted-foreground truncate">
{meta.description}
</p>
</div>

<div className="shrink-0">
{connector.isConnected ? (
<ConnectorConnectedMenu
isDisconnecting={isDisconnecting}
onReconnect={handleConnect}
onDisconnect={handleDisconnect}
/>
) : (
<ConnectorEnableButton
isConnecting={isConnecting}
onClick={handleConnect}
/>
)}
</div>
</div>
);
}
56 changes: 56 additions & 0 deletions components/ConnectorsPage/ConnectorConnectedMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Loader2, MoreVertical, RefreshCw, Unlink, CheckCircle } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

interface ConnectorConnectedMenuProps {
isDisconnecting: boolean;
onReconnect: () => void;
onDisconnect: () => void;
}

export function ConnectorConnectedMenu({
isDisconnecting,
onReconnect,
onDisconnect,
}: ConnectorConnectedMenuProps) {
return (
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1">
Connected
<CheckCircle className="h-3.5 w-3.5 text-green-600 dark:text-green-500" />
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="p-1.5 rounded-md hover:bg-muted transition-colors"
title="Connector options"
>
<MoreVertical className="h-4 w-4 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onReconnect} className="cursor-pointer">
<RefreshCw className="h-4 w-4 mr-2" />
Reconnect
</DropdownMenuItem>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reconnect button missing disabled state during operation

Medium Severity

The ConnectorConnectedMenu component receives isDisconnecting to disable the Disconnect button during operation, but isConnecting is not passed to disable the Reconnect button. The useConnectorHandlers hook tracks both states, and ConnectorEnableButton correctly uses isConnecting to disable itself, but ConnectorConnectedMenu lacks this prop entirely. Users can click "Reconnect" multiple times while an authorization request is in progress, potentially triggering multiple OAuth flows.

Additional Locations (1)

Fix in Cursor Fix in Web

<DropdownMenuItem
onClick={onDisconnect}
disabled={isDisconnecting}
className="cursor-pointer text-red-600 dark:text-red-400"
>
{isDisconnecting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Unlink className="h-4 w-4 mr-2" />
)}
{isDisconnecting ? "Disconnecting..." : "Disconnect"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
22 changes: 22 additions & 0 deletions components/ConnectorsPage/ConnectorEnableButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Loader2 } from "lucide-react";

interface ConnectorEnableButtonProps {
isConnecting: boolean;
onClick: () => void;
}

export function ConnectorEnableButton({
isConnecting,
onClick,
}: ConnectorEnableButtonProps) {
return (
<button
onClick={onClick}
disabled={isConnecting}
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-foreground bg-transparent border border-border hover:bg-muted rounded-lg transition-colors disabled:opacity-50"
>
{isConnecting && <Loader2 className="h-4 w-4 animate-spin" />}
Enable
</button>
);
}
7 changes: 7 additions & 0 deletions components/ConnectorsPage/ConnectorsEmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function ConnectorsEmptyState() {
return (
<p className="text-center text-muted-foreground py-12">
No connectors available.
</p>
);
}
13 changes: 13 additions & 0 deletions components/ConnectorsPage/ConnectorsErrorBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
interface ConnectorsErrorBannerProps {
error: string | null;
}

export function ConnectorsErrorBanner({ error }: ConnectorsErrorBannerProps) {
if (!error) return null;

return (
<div className="mb-6 p-4 rounded-lg bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 text-sm">
{error}
</div>
);
}
32 changes: 32 additions & 0 deletions components/ConnectorsPage/ConnectorsHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Plug, RefreshCw } from "lucide-react";

interface ConnectorsHeaderProps {
onRefresh: () => void;
isLoading: boolean;
}

export function ConnectorsHeader({ onRefresh, isLoading }: ConnectorsHeaderProps) {
return (
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-semibold flex items-center gap-2">
<Plug className="h-6 w-6" />
Connectors
</h1>
<p className="text-muted-foreground mt-1">
Connect your tools to enable AI-powered automation
</p>
</div>
<button
onClick={onRefresh}
disabled={isLoading}
className="p-2 rounded-lg hover:bg-muted transition-colors disabled:opacity-50"
title="Refresh"
>
<RefreshCw
className={`h-5 w-5 text-muted-foreground ${isLoading ? "animate-spin" : ""}`}
/>
</button>
</div>
);
}
9 changes: 9 additions & 0 deletions components/ConnectorsPage/ConnectorsLoadingState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Loader2 } from "lucide-react";

export function ConnectorsLoadingState() {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
81 changes: 81 additions & 0 deletions components/ConnectorsPage/ConnectorsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"use client";

import { Fragment, useEffect, useState } from "react";
import { usePrivy } from "@privy-io/react-auth";
import { useSearchParams } from "next/navigation";
import { useUserProvider } from "@/providers/UserProvder";
import { useConnectors } from "@/hooks/useConnectors";
import { ConnectorsSuccessBanner } from "./ConnectorsSuccessBanner";
import { ConnectorsErrorBanner } from "./ConnectorsErrorBanner";
import { ConnectorsLoadingState } from "./ConnectorsLoadingState";
import { ConnectorsEmptyState } from "./ConnectorsEmptyState";
import { ConnectorsSection } from "./ConnectorsSection";
import { ConnectorsHeader } from "./ConnectorsHeader";

/**
* Main connectors page component.
* Redesigned to match Perplexity's Connectors style.
*/
export function ConnectorsPage() {
const { userData } = useUserProvider();
const { ready } = usePrivy();
const { connectors, isLoading, error, refetch, authorize, disconnect } =
useConnectors();
const searchParams = useSearchParams();
const [showSuccess, setShowSuccess] = useState(false);

useEffect(() => {
if (searchParams.get("connected") === "true") {
setShowSuccess(true);
refetch();
window.history.replaceState({}, "", "/settings/connectors");
const timer = setTimeout(() => setShowSuccess(false), 5000);
return () => clearTimeout(timer);
}
}, [searchParams, refetch]);

if (!ready) return <Fragment />;

if (!userData?.account_id) {
return (
<div className="flex h-full items-center justify-center p-4">
<p className="text-muted-foreground">
Please sign in to manage connectors.
</p>
</div>
);
}

const connected = connectors.filter((c) => c.isConnected);
const available = connectors.filter((c) => !c.isConnected);

return (
<main className="min-h-screen p-4 md:p-8 max-w-5xl mx-auto">
<ConnectorsSuccessBanner show={showSuccess} />
<ConnectorsHeader onRefresh={refetch} isLoading={isLoading} />
<ConnectorsErrorBanner error={error} />

{isLoading ? (
<ConnectorsLoadingState />
) : (
<div className="space-y-10">
<ConnectorsSection
title="Installed Connectors"
description="Connected tools provide richer and more accurate answers, gated by permissions you have granted."
connectors={connected}
onConnect={authorize}
onDisconnect={disconnect}
/>
<ConnectorsSection
title="Available Connectors"
description="Connect your tools to search across them and take action. Your permissions are always respected."
connectors={available}
onConnect={authorize}
onDisconnect={disconnect}
/>
{connectors.length === 0 && <ConnectorsEmptyState />}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
{connectors.length === 0 && <ConnectorsEmptyState />}
{connectors.length === 0 && !error && <ConnectorsEmptyState />}

The empty state is displayed alongside the error banner when a fetch error occurs, which misleads users into thinking there are legitimately no connectors rather than showing that an error occurred.

View Details

Analysis

Empty state displayed alongside error banner when connector fetch fails

What fails: ConnectorsPage.tsx displays both the error banner and "No connectors available" empty state message simultaneously when the connector API fetch fails, creating a confusing mixed message to users.

How to reproduce:

  1. Open the Connectors page at /settings/connectors
  2. Trigger a connector fetch failure by:
    • Simulating a network error (invalid API endpoint)
    • Or blocking the API call to {NEW_API_BASE_URL}/api/connectors

Result: Both error banner ("Failed to fetch connectors") and empty state message ("No connectors available") render together, creating conflicting messages - the empty state suggests zero results (legitimate scenario) while error banner indicates failure (error scenario).

Expected: When a fetch error occurs, only the error banner should display. The empty state should only display when the fetch succeeds with legitimately zero connectors.

Root cause: Line 74 of ConnectorsPage.tsx conditionally renders the empty state with only {connectors.length === 0 && <ConnectorsEmptyState />}, but when a fetch error occurs, both error is set and connectors remains an empty array, so the empty state renders alongside the error banner.

Fix: Updated ConnectorsPage.tsx line 74 to check for absence of error:

{connectors.length === 0 && !error && <ConnectorsEmptyState />}

This ensures the empty state only displays when there is genuinely no data (successful fetch with zero results), not when an error prevented data retrieval.

</div>
)}
</main>
);
}
39 changes: 39 additions & 0 deletions components/ConnectorsPage/ConnectorsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ConnectorInfo } from "@/hooks/useConnectors";
import { ConnectorCard } from "./ConnectorCard";

interface ConnectorsSectionProps {
title: string;
description: string;
connectors: ConnectorInfo[];
onConnect: (slug: string) => Promise<string | null>;
onDisconnect: (connectedAccountId: string) => Promise<boolean>;
}

export function ConnectorsSection({
title,
description,
connectors,
onConnect,
onDisconnect,
}: ConnectorsSectionProps) {
if (connectors.length === 0) return null;

return (
<section>
<div className="mb-4">
<h2 className="text-lg font-semibold">{title}</h2>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{connectors.map((connector) => (
<ConnectorCard
key={connector.slug}
connector={connector}
onConnect={onConnect}
onDisconnect={onDisconnect}
/>
))}
</div>
</section>
);
}
18 changes: 18 additions & 0 deletions components/ConnectorsPage/ConnectorsSuccessBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { CheckCircle } from "lucide-react";

interface ConnectorsSuccessBannerProps {
show: boolean;
}

export function ConnectorsSuccessBanner({ show }: ConnectorsSuccessBannerProps) {
if (!show) return null;

return (
<div className="mb-6 flex items-center gap-2 px-4 py-3 rounded-lg bg-green-50 dark:bg-green-950/80 border border-green-200 dark:border-green-800 animate-in slide-in-from-top-2">
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-green-800 dark:text-green-200">
Connector enabled successfully!
</span>
</div>
);
}
2 changes: 2 additions & 0 deletions components/ConnectorsPage/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ConnectorsPage } from "./ConnectorsPage";
export { ConnectorCard } from "./ConnectorCard";
16 changes: 16 additions & 0 deletions components/Sidebar/UserProfileDropdown/ConnectorsMenuItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Link from "next/link";
import { Plug } from "lucide-react";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";

const ConnectorsMenuItem = () => {
return (
<DropdownMenuItem asChild className="cursor-pointer">
<Link href="/settings/connectors">
<Plug className="h-4 w-4" />
Connectors
</Link>
</DropdownMenuItem>
);
};

export default ConnectorsMenuItem;
Loading
Loading