mirror of
https://github.com/cline/cline.git
synced 2026-10-15 19:48:23 +08:00
desktop: browse connectors from Customize, make Connectors the default tab (#14764)
* desktop: browse connectors from Customize and make Connectors the default tab Customize > Connectors gains a Browse section below the suggested recipes that lists the rest of the Composio catalog with search and infinite scroll, so nobody has to open the Marketplace just to find a connector. Connectors is now the first and default Customize tab (falling back to Tools for accounts without beta access). Connector rows and cards always show the connector's own brand logo: ConnectorLogo falls back to Composio's slug-based logo URL, and the Marketplace connector rows use it instead of the generic type glyph. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * desktop: format connectors view * desktop: point the empty Connectors state at Browse and in-chat connect Connectors can now be connected from inside a task (cline/cline#14755), so the empty state no longer sends people to the Marketplace. * desktop: render connector logos at full tile size ConnectorLogo was drawn at size-5 inside size-7/8/10 tiles, leaving a grey margin around every mark. The image now fills the tile itself (size and radius come from className) and the wrapper spans are gone; the lucide/letter fallbacks keep the tile background. * desktop: always show the Connectors tab with sign-in and beta guidance The Connectors tab no longer depends on Composio availability, so the default tab never swaps underneath the user. When connectors are unavailable the tab explains why: signed out shows a Cline sign-in card (same device-code OAuth flow as the Account page) plus Create account; signed in without beta access shows a rollout notice with Check again. The tab count is hidden while connectors are unavailable. Also addresses review feedback: the search hint counts only the Browse entries actually hidden, and a failed logo retries when a different URL arrives. * desktop: separate Installed and Browse searches, wait for account before sign-in prompt Each Connectors list now has its own search field: the top field filters Installed (and is hidden when nothing is installed), the field under the Browse header filters the catalog. Suggested is no longer filtered. AccountProvider exposes accountReady so the unavailable card can show a spinner until the first identity lookup finishes instead of telling a signed-in user without beta access to sign in. --------- Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
This commit is contained in:
co-authored by
Saoud Rizwan
parent
63c2f65c24
commit
4e3eb7ea15
@@ -40,7 +40,10 @@ import {
|
||||
type MarketplaceTypeMeta,
|
||||
MarketplaceTypePill,
|
||||
} from "./marketplace-list-row";
|
||||
import { ComposioConnectorsView } from "./settings/composio-connectors-view";
|
||||
import {
|
||||
ComposioConnectorsView,
|
||||
ConnectorLogo,
|
||||
} from "./settings/composio-connectors-view";
|
||||
|
||||
/**
|
||||
* Marketplace explorer: a master/detail directory in the spirit of an IDE
|
||||
@@ -876,6 +879,14 @@ export function MarketplaceExplorerView({
|
||||
<MarketplaceListRow
|
||||
name={entry.name}
|
||||
description={entry.description}
|
||||
glyph={
|
||||
<ConnectorLogo
|
||||
className="size-7 rounded-md"
|
||||
logo={entry.logo}
|
||||
name={entry.name}
|
||||
slug={entry.slug}
|
||||
/>
|
||||
}
|
||||
meta={TYPE_META.connector}
|
||||
showType={showTypePills}
|
||||
installed={status === "connected"}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadgeCheck, Check, type LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Presentation for one marketplace primitive type (skill, MCP, ...). */
|
||||
@@ -58,6 +59,7 @@ export function MarketplaceListRow({
|
||||
name,
|
||||
description,
|
||||
meta,
|
||||
glyph,
|
||||
showType = false,
|
||||
verified = false,
|
||||
installed,
|
||||
@@ -67,6 +69,9 @@ export function MarketplaceListRow({
|
||||
name: string;
|
||||
description?: string;
|
||||
meta: MarketplaceTypeMeta;
|
||||
/** Replaces the type glyph when the entry has its own mark (a connector's
|
||||
* brand logo). Sized by the caller to match `size-7`. */
|
||||
glyph?: ReactNode;
|
||||
/** Show the type pill (useful when rows of mixed types sit together). */
|
||||
showType?: boolean;
|
||||
verified?: boolean;
|
||||
@@ -83,7 +88,7 @@ export function MarketplaceListRow({
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<MarketplaceTypeGlyph className="size-7" meta={meta} />
|
||||
{glyph ?? <MarketplaceTypeGlyph className="size-7" meta={meta} />}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
|
||||
+177
-15
@@ -7,19 +7,29 @@ import type { ComposioIntegrationSummary } from "@/lib/composio-types";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
integrations: [] as ComposioIntegrationSummary[],
|
||||
configured: true,
|
||||
catalog: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
invoke: vi.fn(),
|
||||
openExternalUrl: vi.fn(),
|
||||
loadError: null as string | null,
|
||||
}));
|
||||
vi.mock("@/lib/composio", () => ({
|
||||
fetchComposioToolkitCatalog: mocks.catalog,
|
||||
}));
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: { invoke: mocks.invoke, subscribe: () => () => {} },
|
||||
openExternalUrl: mocks.openExternalUrl,
|
||||
}));
|
||||
vi.mock("@/lib/provider-model-catalog", () => ({
|
||||
invalidateProviderCatalogCache: () => {},
|
||||
}));
|
||||
vi.mock("@/lib/use-composio-connections", () => ({
|
||||
useComposioConnections: () => ({
|
||||
configured: true,
|
||||
configured: mocks.configured,
|
||||
status: { integrations: mocks.integrations },
|
||||
statusBySlug: new Map(
|
||||
mocks.integrations.map((integration) => [
|
||||
@@ -36,6 +46,7 @@ vi.mock("@/lib/use-composio-connections", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { AccountProvider } from "@/contexts/account-context";
|
||||
import { ComposioConnectorsView } from "./composio-connectors-view";
|
||||
|
||||
let root: Root;
|
||||
@@ -44,6 +55,8 @@ beforeEach(() => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.clearAllMocks();
|
||||
mocks.loadError = null;
|
||||
mocks.configured = true;
|
||||
mocks.catalog.mockResolvedValue({ configured: true, toolkits: [] });
|
||||
mocks.integrations = [
|
||||
{
|
||||
toolkit: "gmail",
|
||||
@@ -71,6 +84,16 @@ function button(text: string) {
|
||||
async function render() {
|
||||
await act(async () => root.render(<ComposioConnectorsView />));
|
||||
}
|
||||
async function type(selector: string, value: string) {
|
||||
const input = container.querySelector(selector) as HTMLInputElement;
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
describe("Customize connector catalog", () => {
|
||||
it("shows the grid, installs apps, and manages an existing connection in its dialog", async () => {
|
||||
@@ -112,16 +135,7 @@ describe("Customize connector catalog", () => {
|
||||
await render();
|
||||
expect(container.textContent).toContain("search to find 1 more");
|
||||
expect(container.textContent).not.toContain("App 24");
|
||||
const input = container.querySelector(
|
||||
'input[aria-label="Search connectors"]',
|
||||
) as HTMLInputElement;
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set?.call(input, "App 24");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await type('input[aria-label="Search connectors"]', "App 24");
|
||||
expect(container.textContent).toContain("App 24");
|
||||
expect(container.textContent).not.toContain("App 23");
|
||||
});
|
||||
@@ -178,7 +192,6 @@ describe("installed connectors", () => {
|
||||
(item) => item.textContent,
|
||||
),
|
||||
).toEqual(toolNames);
|
||||
expect(mocks.catalog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the installed tool count, not the stale catalog total", async () => {
|
||||
@@ -238,7 +251,6 @@ describe("installed connectors", () => {
|
||||
expect(installed?.textContent).toContain("Installed0");
|
||||
expect(installed?.textContent).toContain("No connectors installed");
|
||||
expect(installed?.textContent).not.toContain("Gmail");
|
||||
expect(mocks.catalog).not.toHaveBeenCalled();
|
||||
// Every recipe is suggested; chips install their connector.
|
||||
expect(container.textContent).toContain("Suggested");
|
||||
expect(container.textContent).toContain("Organize your day");
|
||||
@@ -294,7 +306,7 @@ describe("installed connectors", () => {
|
||||
expect(mocks.refresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("lists installed connectors without fetching or showing recommendations", async () => {
|
||||
it("lists installed connectors without showing recommendations", async () => {
|
||||
mocks.integrations = [
|
||||
{
|
||||
toolkit: "gmail",
|
||||
@@ -318,8 +330,158 @@ describe("installed connectors", () => {
|
||||
expect(installed?.textContent).toContain("Gmail");
|
||||
expect(installed?.textContent).not.toContain("GitHub");
|
||||
expect(container.textContent).not.toContain("Recommended");
|
||||
expect(mocks.catalog).not.toHaveBeenCalled();
|
||||
await act(async () => button("Uninstall")?.click());
|
||||
expect(mocks.disconnect).toHaveBeenCalledWith("gmail");
|
||||
});
|
||||
|
||||
it("browses the catalog below the suggestions, skipping installed connectors", async () => {
|
||||
mocks.catalog.mockResolvedValue({
|
||||
configured: true,
|
||||
toolkits: [
|
||||
{ slug: "gmail", name: "Gmail", description: "Email" },
|
||||
{ slug: "github", name: "GitHub", description: "Code" },
|
||||
{ slug: "notion", name: "Notion", description: "Docs" },
|
||||
],
|
||||
});
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<ComposioConnectorsView appendOnScroll variant="installed" />,
|
||||
),
|
||||
);
|
||||
const [installed, , browse] = container.querySelectorAll("section");
|
||||
expect(installed?.textContent).toContain("Gmail");
|
||||
expect(browse?.textContent).toContain("Browse2");
|
||||
expect(browse?.textContent).toContain("GitHub");
|
||||
expect(browse?.textContent).toContain("Notion");
|
||||
expect(browse?.textContent).not.toContain("Gmail");
|
||||
expect(
|
||||
browse?.querySelector('img[src="https://logos.composio.dev/api/notion"]'),
|
||||
).not.toBeNull();
|
||||
await act(async () => button("Install")?.click());
|
||||
expect(mocks.connect).toHaveBeenCalledWith("github");
|
||||
// Each list has its own search: Browse's narrows only the catalog.
|
||||
await type('input[aria-label="Search all connectors"]', "notion");
|
||||
expect(browse?.textContent).toContain("Notion");
|
||||
expect(browse?.textContent).not.toContain("GitHub");
|
||||
expect(installed?.textContent).toContain("Gmail");
|
||||
await type('input[aria-label="Search installed connectors"]', "zzz");
|
||||
expect(installed?.textContent).toContain(
|
||||
'No installed connectors match "zzz"',
|
||||
);
|
||||
expect(browse?.textContent).toContain("Notion");
|
||||
});
|
||||
|
||||
it("hides the installed search when nothing is installed", async () => {
|
||||
mocks.integrations = [];
|
||||
await act(async () =>
|
||||
root.render(<ComposioConnectorsView variant="installed" />),
|
||||
);
|
||||
expect(
|
||||
container.querySelector(
|
||||
'input[aria-label="Search installed connectors"]',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector('input[aria-label="Search all connectors"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("waits for the account lookup before offering sign-in", async () => {
|
||||
mocks.configured = false;
|
||||
let resolveMe: (value: unknown) => void = () => {};
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string) =>
|
||||
new Promise((resolve) => {
|
||||
if (command === "cline_account") resolveMe = resolve;
|
||||
else resolve({});
|
||||
}),
|
||||
);
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<ComposioConnectorsView variant="installed" />
|
||||
</AccountProvider>,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
container.querySelector('[aria-label="Loading account"]'),
|
||||
).not.toBeNull();
|
||||
expect(button("Sign in")).toBeUndefined();
|
||||
await act(async () =>
|
||||
resolveMe({ email: "dev@cline.bot", displayName: "Dev" }),
|
||||
);
|
||||
expect(container.textContent).toContain(
|
||||
"Connectors aren't enabled for your account yet",
|
||||
);
|
||||
});
|
||||
|
||||
it("offers Cline sign-in when connectors are unavailable and the user is signed out", async () => {
|
||||
mocks.configured = false;
|
||||
mocks.invoke.mockResolvedValue({
|
||||
signedIn: false,
|
||||
code: "ACCOUNT_NOT_AUTHENTICATED",
|
||||
});
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<ComposioConnectorsView variant="installed" />
|
||||
</AccountProvider>,
|
||||
),
|
||||
);
|
||||
expect(container.textContent).toContain(
|
||||
"Sign in to Cline to use connectors",
|
||||
);
|
||||
expect(container.textContent).not.toContain("Browse");
|
||||
expect(
|
||||
container.querySelector(
|
||||
'img[src="https://logos.composio.dev/api/slack"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
await act(async () => button("Sign in")?.click());
|
||||
expect(mocks.invoke).toHaveBeenCalledWith(
|
||||
"run_provider_oauth_login",
|
||||
{ provider: "cline" },
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mocks.refresh).toHaveBeenCalled();
|
||||
await act(async () => button("Create account")?.click());
|
||||
expect(mocks.openExternalUrl).toHaveBeenCalledWith("https://app.cline.bot");
|
||||
});
|
||||
|
||||
it("explains the beta rollout when signed in without connector access", async () => {
|
||||
mocks.configured = false;
|
||||
mocks.invoke.mockResolvedValue({
|
||||
email: "dev@cline.bot",
|
||||
displayName: "Dev",
|
||||
});
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<ComposioConnectorsView variant="installed" />
|
||||
</AccountProvider>,
|
||||
),
|
||||
);
|
||||
expect(container.textContent).toContain(
|
||||
"Connectors aren't enabled for your account yet",
|
||||
);
|
||||
expect(button("Sign in")).toBeUndefined();
|
||||
await act(async () => button("Check again")?.click());
|
||||
expect(mocks.refresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows catalog errors and retries in the Browse section", async () => {
|
||||
mocks.catalog
|
||||
.mockRejectedValueOnce(new Error("Service unavailable"))
|
||||
.mockResolvedValueOnce({
|
||||
configured: true,
|
||||
toolkits: [{ slug: "notion", name: "Notion" }],
|
||||
});
|
||||
await act(async () =>
|
||||
root.render(<ComposioConnectorsView variant="installed" />),
|
||||
);
|
||||
expect(container.textContent).toContain("Service unavailable");
|
||||
await act(async () => button("Retry")?.click());
|
||||
expect(container.textContent).toContain("Notion");
|
||||
expect(container.textContent).not.toContain("Service unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
+376
-167
@@ -4,7 +4,9 @@ import { GitHubIcon } from "@cline/ui";
|
||||
import {
|
||||
CalendarDays,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
LogIn,
|
||||
Mail,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -23,6 +25,8 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAccount } from "@/contexts/account-context";
|
||||
import { useOAuthUserCode } from "@/hooks/use-oauth-user-code";
|
||||
import { fetchComposioToolkitCatalog } from "@/lib/composio";
|
||||
import {
|
||||
COMPOSIO_RECIPES,
|
||||
@@ -35,11 +39,16 @@ import type {
|
||||
ComposioIntegrationSummary,
|
||||
ComposioToolkitSlug,
|
||||
} from "@/lib/composio-types";
|
||||
import { desktopClient, openExternalUrl } from "@/lib/desktop-client";
|
||||
import { OAUTH_LOGIN_TIMEOUT_MS } from "@/lib/provider-connection";
|
||||
import { invalidateProviderCatalogCache } from "@/lib/provider-model-catalog";
|
||||
import { useComposioConnections } from "@/lib/use-composio-connections";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Shared connector browser for Customize and Marketplace. */
|
||||
|
||||
const CREATE_ACCOUNT_URL = "https://app.cline.bot";
|
||||
|
||||
/** How many catalog entries to show before asking the user to search. */
|
||||
const CATALOG_PREVIEW_COUNT = 24;
|
||||
const CATALOG_SEARCH_RESULT_LIMIT = 50;
|
||||
@@ -69,40 +78,58 @@ const FALLBACK_ICONS: Record<
|
||||
github: (props) => <GitHubIcon {...props} />,
|
||||
};
|
||||
|
||||
/** The connector's brand mark, filling `className` (size and radius) edge to
|
||||
* edge so it doubles as the tile wherever one is needed. */
|
||||
export function ConnectorLogo({
|
||||
slug,
|
||||
name,
|
||||
logo,
|
||||
className = "size-5",
|
||||
className,
|
||||
}: {
|
||||
slug: string;
|
||||
name: string;
|
||||
logo?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (logo && !failed) {
|
||||
// Keyed to the URL so a later, different logo (status arriving after the
|
||||
// slug fallback 404ed) gets its own attempt.
|
||||
const [failedSrc, setFailedSrc] = useState<string | null>(null);
|
||||
// Composio serves every toolkit's logo by slug, so a missing catalog logo
|
||||
// still resolves to the real brand mark.
|
||||
const src = logo ?? composioLogoUrl(slug);
|
||||
if (failedSrc !== src) {
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: Composio logos live on arbitrary remote hosts Next's optimizer is not configured for.
|
||||
<img
|
||||
alt=""
|
||||
// The white backing keeps dark brand marks (GitHub's, for one)
|
||||
// visible on dark tiles.
|
||||
className={`${className} rounded-sm bg-white object-contain p-px`}
|
||||
onError={() => setFailed(true)}
|
||||
src={logo}
|
||||
className={cn(
|
||||
"size-5 shrink-0 rounded-sm bg-white object-contain",
|
||||
className,
|
||||
)}
|
||||
onError={() => setFailedSrc(src)}
|
||||
src={src}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Themed fallbacks for the recommended toolkits when the catalog (and its
|
||||
// official logos) has not loaded yet.
|
||||
// Themed fallbacks for the recommended toolkits when the logo host is
|
||||
// unreachable.
|
||||
const LocalIcon = FALLBACK_ICONS[slug];
|
||||
if (LocalIcon) {
|
||||
return <LocalIcon className={className} />;
|
||||
}
|
||||
return (
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{name.slice(0, 1)}
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-5 shrink-0 items-center justify-center rounded-sm bg-secondary text-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{LocalIcon ? (
|
||||
<LocalIcon className="size-[60%]" />
|
||||
) : (
|
||||
<span className="text-xs font-semibold uppercase leading-none text-muted-foreground">
|
||||
{name.slice(0, 1)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -239,7 +266,11 @@ export function ComposioConnectorsView({
|
||||
const [catalog, setCatalog] = useState<ComposioCatalogToolkit[] | null>(null);
|
||||
const [catalogError, setCatalogError] = useState<string | null>(null);
|
||||
const [catalogLoading, setCatalogLoading] = useState(false);
|
||||
const [localQuery, setQuery] = useState("");
|
||||
// Customize keeps two fields so each one visibly owns one list: the top
|
||||
// field filters Installed, the Browse field filters the catalog (`query`,
|
||||
// which the Marketplace drives from its host search instead).
|
||||
const [installedQuery, setInstalledQuery] = useState("");
|
||||
const [localQuery, setLocalQuery] = useState("");
|
||||
const query = searchQuery ?? localQuery;
|
||||
const [retry, setRetry] = useState(0);
|
||||
const [detailSlug, setDetailSlug] = useState<ComposioToolkitSlug | null>(
|
||||
@@ -250,7 +281,7 @@ export function ComposioConnectorsView({
|
||||
let cancelled = false;
|
||||
setCatalog(null);
|
||||
setCatalogError(null);
|
||||
if (!configured || variant === "installed") return;
|
||||
if (!configured) return;
|
||||
setCatalogLoading(true);
|
||||
void fetchComposioToolkitCatalog()
|
||||
.then((response) => {
|
||||
@@ -268,25 +299,36 @@ export function ComposioConnectorsView({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [configured, retry, variant]);
|
||||
}, [configured, retry]);
|
||||
|
||||
useEffect(() => {
|
||||
// listToolkits resolves only after every backend page has been fetched.
|
||||
onCatalogCountChange?.(catalog?.length ?? null);
|
||||
}, [catalog, onCatalogCountChange]);
|
||||
|
||||
const installedEntries = useMemo<ComposioCatalogToolkit[]>(
|
||||
() =>
|
||||
(status?.integrations ?? [])
|
||||
.filter((integration) => integration.status !== "not_connected")
|
||||
.map((integration) => ({
|
||||
slug: integration.toolkit,
|
||||
name: integration.name,
|
||||
description: integration.description,
|
||||
logo: integration.logo,
|
||||
recommended: integration.recommended,
|
||||
})),
|
||||
[status],
|
||||
);
|
||||
// Customize's Browse section lists only what is not installed yet, like
|
||||
// the other Customize tabs; the Marketplace lists the whole catalog.
|
||||
const entries = useMemo<ComposioCatalogToolkit[]>(() => {
|
||||
if (variant === "catalog") return catalog ?? [];
|
||||
return (status?.integrations ?? [])
|
||||
.filter((integration) => integration.status !== "not_connected")
|
||||
.map((integration) => ({
|
||||
slug: integration.toolkit,
|
||||
name: integration.name,
|
||||
description: integration.description,
|
||||
logo: integration.logo,
|
||||
recommended: integration.recommended,
|
||||
}));
|
||||
}, [variant, catalog, status]);
|
||||
return (catalog ?? []).filter(
|
||||
(entry) =>
|
||||
(statusBySlug.get(entry.slug)?.status ?? "not_connected") ===
|
||||
"not_connected",
|
||||
);
|
||||
}, [variant, catalog, statusBySlug]);
|
||||
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
const [page, setPage] = useState({
|
||||
@@ -296,6 +338,14 @@ export function ComposioConnectorsView({
|
||||
if (page.query !== trimmedQuery) {
|
||||
setPage({ query: trimmedQuery, limit: CATALOG_PREVIEW_COUNT });
|
||||
}
|
||||
const trimmedInstalledQuery = installedQuery.trim().toLowerCase();
|
||||
const matchingInstalled = useMemo(() => {
|
||||
return trimmedInstalledQuery
|
||||
? installedEntries.filter((entry) =>
|
||||
connectorMatchesQuery(entry, trimmedInstalledQuery),
|
||||
)
|
||||
: installedEntries;
|
||||
}, [installedEntries, trimmedInstalledQuery]);
|
||||
const matchingCatalog = useMemo(() => {
|
||||
return trimmedQuery
|
||||
? entries.filter((entry) => connectorMatchesQuery(entry, trimmedQuery))
|
||||
@@ -303,13 +353,11 @@ export function ComposioConnectorsView({
|
||||
}, [entries, trimmedQuery]);
|
||||
const visibleCatalog = matchingCatalog.slice(
|
||||
0,
|
||||
variant === "installed"
|
||||
? matchingCatalog.length
|
||||
: appendOnScroll
|
||||
? page.limit
|
||||
: trimmedQuery
|
||||
? CATALOG_SEARCH_RESULT_LIMIT
|
||||
: CATALOG_PREVIEW_COUNT,
|
||||
appendOnScroll
|
||||
? page.limit
|
||||
: trimmedQuery
|
||||
? CATALOG_SEARCH_RESULT_LIMIT
|
||||
: CATALOG_PREVIEW_COUNT,
|
||||
);
|
||||
const hasMore =
|
||||
appendOnScroll && visibleCatalog.length < matchingCatalog.length;
|
||||
@@ -342,12 +390,14 @@ export function ComposioConnectorsView({
|
||||
|
||||
const hiddenCount = trimmedQuery
|
||||
? 0
|
||||
: variant === "installed"
|
||||
? 0
|
||||
: Math.max(0, (catalog?.length ?? 0) - CATALOG_PREVIEW_COUNT);
|
||||
: matchingCatalog.length - visibleCatalog.length;
|
||||
|
||||
// The catalog entry carries categories and tool counts the status
|
||||
// payload lacks, so prefer it even for installed connectors.
|
||||
const detailEntry = detailSlug
|
||||
? (entries.find((entry) => entry.slug === detailSlug) ?? null)
|
||||
? (catalog?.find((entry) => entry.slug === detailSlug) ??
|
||||
installedEntries.find((entry) => entry.slug === detailSlug) ??
|
||||
null)
|
||||
: null;
|
||||
const detailStatus = detailSlug ? statusBySlug.get(detailSlug) : undefined;
|
||||
|
||||
@@ -371,7 +421,12 @@ export function ComposioConnectorsView({
|
||||
}
|
||||
|
||||
if (!configured) {
|
||||
// The parent hides this tab when the account has no beta access.
|
||||
if (variant === "installed") {
|
||||
return (
|
||||
<ConnectorsUnavailable onRefresh={refresh} refreshing={refreshing} />
|
||||
);
|
||||
}
|
||||
// The Marketplace hides its connector section in this case.
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Connectors aren't available.
|
||||
@@ -412,26 +467,105 @@ export function ComposioConnectorsView({
|
||||
/>
|
||||
);
|
||||
|
||||
const catalogList =
|
||||
catalogLoading && !catalog ? (
|
||||
<output
|
||||
aria-label="Loading connector catalog"
|
||||
className="flex items-center justify-center py-10"
|
||||
>
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</output>
|
||||
) : catalogError ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-xs text-destructive" role="alert">
|
||||
{catalogError}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setRetry((value) => value + 1)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* The host page owns scrolling. */}
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className={
|
||||
renderItem
|
||||
? "grid gap-1"
|
||||
: "grid grid-cols-[repeat(auto-fit,minmax(min(100%,24rem),1fr))] gap-2"
|
||||
}
|
||||
>
|
||||
{visibleCatalog.map((entry) => (
|
||||
<div className="min-w-0" key={entry.slug}>
|
||||
{renderItem ? (
|
||||
renderItem({
|
||||
entry,
|
||||
status:
|
||||
statusBySlug.get(entry.slug)?.status ?? "not_connected",
|
||||
selected: detailSlug === entry.slug,
|
||||
onOpenDetails: () => setDetailSlug(entry.slug),
|
||||
})
|
||||
) : (
|
||||
<ConnectorRow
|
||||
busy={busyToolkit === entry.slug}
|
||||
entry={entry}
|
||||
onCancel={() => void cancelConnect(entry.slug)}
|
||||
onConnect={() => void connect(entry.slug)}
|
||||
onDisconnect={() => void disconnect(entry.slug)}
|
||||
onOpenDetails={() => setDetailSlug(entry.slug)}
|
||||
status={
|
||||
statusBySlug.get(entry.slug)?.status ?? "not_connected"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{actionError?.toolkit === entry.slug ? (
|
||||
// Scoped to this connector's own card; no shared
|
||||
// surface retains another connector's failure.
|
||||
<p
|
||||
className="mt-1 px-1 text-xs text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
{actionError.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{visibleCatalog.length === 0 ? (
|
||||
<p className="py-4 text-sm text-muted-foreground">
|
||||
{trimmedQuery
|
||||
? `No connectors match "${query.trim()}".`
|
||||
: "No connectors are available for your account yet."}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{hasMore ? (
|
||||
<div ref={loadMoreRef} className="h-px" aria-hidden="true" />
|
||||
) : null}
|
||||
{!appendOnScroll && hiddenCount > 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Showing the {CATALOG_PREVIEW_COUNT} most-used connectors — search to
|
||||
find {hiddenCount} more.
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (variant === "installed") {
|
||||
// Mirrors the Skills / Plugins / MCP tabs: description + refresh,
|
||||
// full-width search, and an Installed section with a count. Suggested
|
||||
// recipes follow; each one drops out once all its connectors are
|
||||
// connected, so the section empties itself over time.
|
||||
const recipes = COMPOSIO_RECIPES.filter(
|
||||
(recipe) =>
|
||||
recipe.connectors.some(
|
||||
(connector) =>
|
||||
statusBySlug.get(connector.slug)?.status !== "connected",
|
||||
) &&
|
||||
(!trimmedQuery ||
|
||||
[
|
||||
recipe.title,
|
||||
recipe.description,
|
||||
...recipe.connectors.map((connector) => connector.name),
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(trimmedQuery)),
|
||||
// connected, so the section empties itself over time. Browse lists the
|
||||
// rest of the catalog so nobody has to leave for the Marketplace.
|
||||
const recipes = COMPOSIO_RECIPES.filter((recipe) =>
|
||||
recipe.connectors.some(
|
||||
(connector) => statusBySlug.get(connector.slug)?.status !== "connected",
|
||||
),
|
||||
);
|
||||
return (
|
||||
<div className="grid gap-6 select-text">
|
||||
@@ -459,16 +593,18 @@ export function ComposioConnectorsView({
|
||||
Failed to refresh connectors: {loadError}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search connectors"
|
||||
className="h-10 pl-8"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search connectors"
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
{installedEntries.length > 0 ? (
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search installed connectors"
|
||||
className="h-10 pl-8"
|
||||
onChange={(event) => setInstalledQuery(event.target.value)}
|
||||
placeholder="Search installed connectors"
|
||||
value={installedQuery}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<section className="grid min-w-0 gap-3">
|
||||
@@ -477,12 +613,12 @@ export function ComposioConnectorsView({
|
||||
Installed
|
||||
</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{entries.length}
|
||||
{installedEntries.length}
|
||||
</span>
|
||||
</div>
|
||||
{matchingCatalog.length > 0 ? (
|
||||
{matchingInstalled.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-3">
|
||||
{matchingCatalog.map((entry) => (
|
||||
{matchingInstalled.map((entry) => (
|
||||
<ConnectorCard
|
||||
busy={busyToolkit === entry.slug}
|
||||
entry={entry}
|
||||
@@ -502,9 +638,9 @@ export function ComposioConnectorsView({
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed bg-card p-6 text-center text-sm text-muted-foreground">
|
||||
{trimmedQuery
|
||||
? `No installed connectors match "${query.trim()}".`
|
||||
: "No connectors installed. Start with a suggested setup below or browse the marketplace to add one."}
|
||||
{trimmedInstalledQuery
|
||||
? `No installed connectors match "${installedQuery.trim()}".`
|
||||
: "No connectors installed. Install a connector below or ask Cline about it in a task."}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -541,6 +677,30 @@ export function ComposioConnectorsView({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="grid min-w-0 gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-foreground">Browse</h2>
|
||||
{catalog ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{matchingCatalog.length}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{catalog ? (
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search all connectors"
|
||||
className="h-10 pl-8"
|
||||
onChange={(event) => setLocalQuery(event.target.value)}
|
||||
placeholder="Search all connectors"
|
||||
value={localQuery}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{catalogList}
|
||||
</section>
|
||||
|
||||
{detailDialog}
|
||||
</div>
|
||||
);
|
||||
@@ -560,7 +720,7 @@ export function ComposioConnectorsView({
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-8 w-64 pl-8"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onChange={(event) => setLocalQuery(event.target.value)}
|
||||
aria-label="Search connectors"
|
||||
placeholder="Search connectors"
|
||||
value={query}
|
||||
@@ -570,95 +730,147 @@ export function ComposioConnectorsView({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{variant === "catalog" && catalogLoading && !catalog ? (
|
||||
<output
|
||||
aria-label="Loading connector catalog"
|
||||
className="flex items-center justify-center py-10"
|
||||
>
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</output>
|
||||
) : variant === "catalog" && catalogError ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-xs text-destructive" role="alert">
|
||||
{catalogError}
|
||||
{catalogList}
|
||||
|
||||
{detailDialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A taste of what connectors cover, shown while the account cannot use them. */
|
||||
const PREVIEW_CONNECTORS: { slug: ComposioToolkitSlug; name: string }[] = [
|
||||
{ slug: "gmail", name: "Gmail" },
|
||||
{ slug: "slack", name: "Slack" },
|
||||
{ slug: "github", name: "GitHub" },
|
||||
{ slug: "googlecalendar", name: "Google Calendar" },
|
||||
{ slug: "notion", name: "Notion" },
|
||||
{ slug: "linear", name: "Linear" },
|
||||
];
|
||||
|
||||
/** Customize > Connectors when the account cannot use connectors: signed out
|
||||
* (sign in right here, same device-code flow as the Account page) or signed
|
||||
* in without beta access (explain and offer a refresh). */
|
||||
function ConnectorsUnavailable({
|
||||
onRefresh,
|
||||
refreshing,
|
||||
}: {
|
||||
onRefresh: () => Promise<void>;
|
||||
refreshing: boolean;
|
||||
}) {
|
||||
const { user, accountReady, refreshAccount } = useAccount();
|
||||
const [signingIn, setSigningIn] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const deviceUserCode = useOAuthUserCode(signingIn);
|
||||
|
||||
const signIn = async () => {
|
||||
setSigningIn(true);
|
||||
setError(null);
|
||||
try {
|
||||
await desktopClient.invoke(
|
||||
"run_provider_oauth_login",
|
||||
{ provider: "cline" },
|
||||
// The browser round-trip routinely outlives the default command
|
||||
// deadline; the sidecar bounds the flow by device-code expiry.
|
||||
{ timeoutMs: OAUTH_LOGIN_TIMEOUT_MS },
|
||||
);
|
||||
invalidateProviderCatalogCache();
|
||||
await Promise.all([refreshAccount(), onRefresh()]);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signedIn = user !== null;
|
||||
// A cached identity renders immediately; otherwise wait for the lookup
|
||||
// rather than telling a signed-in user to sign in.
|
||||
if (!signedIn && !accountReady) {
|
||||
return (
|
||||
<output
|
||||
aria-label="Loading account"
|
||||
className="flex items-center justify-center py-16"
|
||||
>
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</output>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-6 select-text">
|
||||
<div className="mx-auto flex max-w-xl flex-col items-center gap-5 py-6 text-center">
|
||||
<div className="flex items-center gap-2">
|
||||
{PREVIEW_CONNECTORS.map((connector) => (
|
||||
<ConnectorLogo
|
||||
className="size-9 rounded-lg border"
|
||||
key={connector.slug}
|
||||
name={connector.name}
|
||||
slug={connector.slug}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{signedIn
|
||||
? "Connectors aren't enabled for your account yet"
|
||||
: "Sign in to Cline to use connectors"}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{signedIn
|
||||
? "Connectors are rolling out in beta. Once your Cline account has access, Gmail, Slack, GitHub, and hundreds of other apps will show up here with one-click install."
|
||||
: "Connectors give Cline tools for Gmail, Slack, GitHub, and hundreds of other apps with a quick sign-in to each, no API keys. They require a Cline account."}
|
||||
</p>
|
||||
</div>
|
||||
{signedIn ? (
|
||||
<Button
|
||||
onClick={() => setRetry((value) => value + 1)}
|
||||
disabled={refreshing}
|
||||
onClick={() => void onRefresh()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Retry
|
||||
<RefreshCw className={cn("size-4", refreshing && "animate-spin")} />
|
||||
Check again
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* The host page owns scrolling. */}
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className={
|
||||
renderItem
|
||||
? "grid gap-1"
|
||||
: "grid grid-cols-[repeat(auto-fit,minmax(min(100%,24rem),1fr))] gap-2"
|
||||
}
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<Button
|
||||
disabled={signingIn}
|
||||
onClick={() => void signIn()}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
{visibleCatalog.map((entry) => (
|
||||
<div className="min-w-0" key={entry.slug}>
|
||||
{renderItem ? (
|
||||
renderItem({
|
||||
entry,
|
||||
status:
|
||||
statusBySlug.get(entry.slug)?.status ?? "not_connected",
|
||||
selected: detailSlug === entry.slug,
|
||||
onOpenDetails: () => setDetailSlug(entry.slug),
|
||||
})
|
||||
) : (
|
||||
<ConnectorRow
|
||||
busy={busyToolkit === entry.slug}
|
||||
entry={entry}
|
||||
onCancel={() => void cancelConnect(entry.slug)}
|
||||
onConnect={() => void connect(entry.slug)}
|
||||
onDisconnect={() => void disconnect(entry.slug)}
|
||||
onOpenDetails={() => setDetailSlug(entry.slug)}
|
||||
status={
|
||||
statusBySlug.get(entry.slug)?.status ?? "not_connected"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{actionError?.toolkit === entry.slug ? (
|
||||
// Scoped to this connector's own card; no shared
|
||||
// surface retains another connector's failure.
|
||||
<p
|
||||
className="mt-1 px-1 text-xs text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
{actionError.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{visibleCatalog.length === 0 ? (
|
||||
<p className="py-4 text-sm text-muted-foreground">
|
||||
{trimmedQuery
|
||||
? `No connectors match "${query.trim()}".`
|
||||
: "No connectors are available for your account yet."}
|
||||
</p>
|
||||
) : null}
|
||||
{signingIn ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<LogIn className="size-4" />
|
||||
)}
|
||||
{signingIn ? "Waiting for browser…" : "Sign in"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void openExternalUrl(CREATE_ACCOUNT_URL)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Create account
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{hasMore ? (
|
||||
<div ref={loadMoreRef} className="h-px" aria-hidden="true" />
|
||||
) : null}
|
||||
{!appendOnScroll && hiddenCount > 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Showing the {CATALOG_PREVIEW_COUNT} most-used connectors — search
|
||||
to find {hiddenCount} more.
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{detailDialog}
|
||||
)}
|
||||
{signingIn && deviceUserCode ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Confirm this code in your browser:{" "}
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{deviceUserCode}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="text-xs text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -689,13 +901,12 @@ function ConnectorRow({
|
||||
onClick={onOpenDetails}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-secondary text-foreground">
|
||||
<ConnectorLogo
|
||||
logo={entry.logo}
|
||||
name={entry.name}
|
||||
slug={entry.slug}
|
||||
/>
|
||||
</span>
|
||||
<ConnectorLogo
|
||||
className="size-8 rounded-lg"
|
||||
logo={entry.logo}
|
||||
name={entry.name}
|
||||
slug={entry.slug}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-foreground">
|
||||
{entry.name}
|
||||
@@ -861,7 +1072,7 @@ function RecipeCard({
|
||||
const logo = (
|
||||
<ConnectorLogo
|
||||
className="size-3.5"
|
||||
logo={summary?.logo ?? composioLogoUrl(connector.slug)}
|
||||
logo={summary?.logo}
|
||||
name={connector.name}
|
||||
slug={connector.slug}
|
||||
/>
|
||||
@@ -950,14 +1161,12 @@ function ConnectorDetailDialog({
|
||||
<>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-secondary text-foreground">
|
||||
<ConnectorLogo
|
||||
className="size-6"
|
||||
logo={entry.logo}
|
||||
name={entry.name}
|
||||
slug={entry.slug}
|
||||
/>
|
||||
</span>
|
||||
<ConnectorLogo
|
||||
className="size-10 rounded-xl"
|
||||
logo={entry.logo}
|
||||
name={entry.name}
|
||||
slug={entry.slug}
|
||||
/>
|
||||
<div>
|
||||
<DialogTitle>{entry.name}</DialogTitle>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Store } from "lucide-react";
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
fetchComposioStatus,
|
||||
getComposioAvailability,
|
||||
subscribeComposioAvailability,
|
||||
} from "@/lib/composio";
|
||||
import { fetchComposioStatus } from "@/lib/composio";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageFrame, PageHeader } from "../page-layout";
|
||||
@@ -36,13 +32,13 @@ type CustomizeTab =
|
||||
| "tools";
|
||||
|
||||
const CUSTOMIZE_TABS: { id: CustomizeTab; label: string }[] = [
|
||||
{ id: "integrations", label: "Connectors" },
|
||||
{ id: "tools", label: "Tools" },
|
||||
{ id: "plugins", label: "Plugins" },
|
||||
{ id: "skills", label: "Skills" },
|
||||
{ id: "rules", label: "Rules" },
|
||||
{ id: "mcp", label: "MCP" },
|
||||
{ id: "hooks", label: "Hooks" },
|
||||
{ id: "integrations", label: "Connectors" },
|
||||
];
|
||||
|
||||
type TabCounts = Partial<Record<CustomizeTab, number>>;
|
||||
@@ -68,16 +64,10 @@ export function CustomizeView({
|
||||
onOpenMarketplace?: (filter?: "connector") => void;
|
||||
onOpenModelProviders?: () => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<CustomizeTab>("tools");
|
||||
// Connectors is always the first tab; when the account cannot use them
|
||||
// the tab itself explains why and offers sign-in.
|
||||
const [tab, setTab] = useState<CustomizeTab>("integrations");
|
||||
const [counts, setCounts] = useState<TabCounts>({});
|
||||
// Connectors are an org-provisioned feature: the tab only exists when the
|
||||
// account has Composio beta access.
|
||||
const connectorsAvailable =
|
||||
useSyncExternalStore(
|
||||
subscribeComposioAvailability,
|
||||
getComposioAvailability,
|
||||
() => null,
|
||||
) === true;
|
||||
|
||||
const refreshCounts = useCallback(async () => {
|
||||
const [inventory, composioStatus] = await Promise.all([
|
||||
@@ -86,7 +76,7 @@ export function CustomizeView({
|
||||
.catch(() => null),
|
||||
fetchComposioStatus().catch(() => null),
|
||||
]);
|
||||
const connectedIntegrations = composioStatus
|
||||
const connectedIntegrations = composioStatus?.configured
|
||||
? composioStatus.integrations.filter(
|
||||
(integration) => integration.status === "connected",
|
||||
).length
|
||||
@@ -152,10 +142,7 @@ export function CustomizeView({
|
||||
/>
|
||||
|
||||
<div className="mb-6 flex items-center gap-0 border-b border-border">
|
||||
{CUSTOMIZE_TABS.filter(
|
||||
(customizeTab) =>
|
||||
customizeTab.id !== "integrations" || connectorsAvailable,
|
||||
).map((customizeTab) => {
|
||||
{CUSTOMIZE_TABS.map((customizeTab) => {
|
||||
const count = counts[customizeTab.id];
|
||||
const active = tab === customizeTab.id;
|
||||
return (
|
||||
@@ -210,6 +197,7 @@ export function CustomizeView({
|
||||
/>
|
||||
) : tab === "integrations" ? (
|
||||
<ComposioConnectorsView
|
||||
appendOnScroll
|
||||
onChanged={handleInventoryChanged}
|
||||
variant="installed"
|
||||
/>
|
||||
|
||||
@@ -21,6 +21,9 @@ const SIGNED_OUT_ERROR_MARKERS = [
|
||||
|
||||
type AccountContextValue = {
|
||||
user: ClineAccountUser | null;
|
||||
/** False until the first identity lookup has finished, so a `null` user
|
||||
* can be told apart from "still loading". */
|
||||
accountReady: boolean;
|
||||
organizations: ClineAccountOrganization[];
|
||||
activeOrganization: ClineAccountOrganization | null;
|
||||
refreshAccount: () => Promise<void>;
|
||||
@@ -28,6 +31,7 @@ type AccountContextValue = {
|
||||
|
||||
const AccountContext = createContext<AccountContextValue>({
|
||||
user: null,
|
||||
accountReady: false,
|
||||
organizations: [],
|
||||
activeOrganization: null,
|
||||
refreshAccount: async () => undefined,
|
||||
@@ -95,6 +99,7 @@ export function isSignedOutAccountError(error: unknown): boolean {
|
||||
|
||||
export function AccountProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<ClineAccountUser | null>(null);
|
||||
const [accountReady, setAccountReady] = useState(false);
|
||||
|
||||
const refreshAccount = useCallback(async () => {
|
||||
try {
|
||||
@@ -118,6 +123,8 @@ export function AccountProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
// Transient failures (offline, sidecar restarting) keep the cached
|
||||
// identity rather than flashing a signed-out state.
|
||||
} finally {
|
||||
setAccountReady(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -135,12 +142,13 @@ export function AccountProvider({ children }: { children: React.ReactNode }) {
|
||||
const organizations = user?.organizations ?? [];
|
||||
return {
|
||||
user,
|
||||
accountReady,
|
||||
organizations,
|
||||
activeOrganization:
|
||||
organizations.find((organization) => organization.active) ?? null,
|
||||
refreshAccount,
|
||||
};
|
||||
}, [refreshAccount, user]);
|
||||
}, [accountReady, refreshAccount, user]);
|
||||
|
||||
return (
|
||||
<AccountContext.Provider value={value}>{children}</AccountContext.Provider>
|
||||
|
||||
Reference in New Issue
Block a user