From 4e3eb7ea15b3a8be1a2007970030e0dd374bb4ee Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:08:09 -0700 Subject: [PATCH] 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 * 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 --- .../views/marketplace-explorer-view.tsx | 13 +- .../components/views/marketplace-list-row.tsx | 7 +- .../composio-connectors-view.test.tsx | 192 ++++++- .../settings/composio-connectors-view.tsx | 543 ++++++++++++------ .../views/settings/customize-view.tsx | 30 +- .../webview/contexts/account-context.tsx | 10 +- 6 files changed, 589 insertions(+), 206 deletions(-) diff --git a/apps/examples/desktop-app/webview/components/views/marketplace-explorer-view.tsx b/apps/examples/desktop-app/webview/components/views/marketplace-explorer-view.tsx index b1f58150f4..90cfb02ae5 100644 --- a/apps/examples/desktop-app/webview/components/views/marketplace-explorer-view.tsx +++ b/apps/examples/desktop-app/webview/components/views/marketplace-explorer-view.tsx @@ -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({ + } meta={TYPE_META.connector} showType={showTypePills} installed={status === "connected"} diff --git a/apps/examples/desktop-app/webview/components/views/marketplace-list-row.tsx b/apps/examples/desktop-app/webview/components/views/marketplace-list-row.tsx index 60d745e060..fac6707198 100644 --- a/apps/examples/desktop-app/webview/components/views/marketplace-list-row.tsx +++ b/apps/examples/desktop-app/webview/components/views/marketplace-list-row.tsx @@ -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" > - + {glyph ?? } diff --git a/apps/examples/desktop-app/webview/components/views/settings/composio-connectors-view.test.tsx b/apps/examples/desktop-app/webview/components/views/settings/composio-connectors-view.test.tsx index b0ec65aa48..1f084b66b9 100644 --- a/apps/examples/desktop-app/webview/components/views/settings/composio-connectors-view.test.tsx +++ b/apps/examples/desktop-app/webview/components/views/settings/composio-connectors-view.test.tsx @@ -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()); } +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( + , + ), + ); + 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(), + ); + 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( + + + , + ), + ); + 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( + + + , + ), + ); + 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( + + + , + ), + ); + 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(), + ); + expect(container.textContent).toContain("Service unavailable"); + await act(async () => button("Retry")?.click()); + expect(container.textContent).toContain("Notion"); + expect(container.textContent).not.toContain("Service unavailable"); + }); }); diff --git a/apps/examples/desktop-app/webview/components/views/settings/composio-connectors-view.tsx b/apps/examples/desktop-app/webview/components/views/settings/composio-connectors-view.tsx index ddbc159564..e1c1759263 100644 --- a/apps/examples/desktop-app/webview/components/views/settings/composio-connectors-view.tsx +++ b/apps/examples/desktop-app/webview/components/views/settings/composio-connectors-view.tsx @@ -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) => , }; +/** 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(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. 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 ; - } return ( - - {name.slice(0, 1)} + + {LocalIcon ? ( + + ) : ( + + {name.slice(0, 1)} + + )} ); } @@ -239,7 +266,11 @@ export function ComposioConnectorsView({ const [catalog, setCatalog] = useState(null); const [catalogError, setCatalogError] = useState(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( @@ -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( + () => + (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(() => { 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 ( + + ); + } + // The Marketplace hides its connector section in this case. return (

Connectors aren't available. @@ -412,26 +467,105 @@ export function ComposioConnectorsView({ /> ); + const catalogList = + catalogLoading && !catalog ? ( + + + + ) : catalogError ? ( +

+

+ {catalogError} +

+ +
+ ) : ( + <> + {/* The host page owns scrolling. */} +
+
+ {visibleCatalog.map((entry) => ( +
+ {renderItem ? ( + renderItem({ + entry, + status: + statusBySlug.get(entry.slug)?.status ?? "not_connected", + selected: detailSlug === entry.slug, + onOpenDetails: () => setDetailSlug(entry.slug), + }) + ) : ( + 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. +

+ {actionError.message} +

+ ) : null} +
+ ))} +
+ {visibleCatalog.length === 0 ? ( +

+ {trimmedQuery + ? `No connectors match "${query.trim()}".` + : "No connectors are available for your account yet."} +

+ ) : null} +
+ {hasMore ? ( + - {matchingCatalog.length > 0 ? ( + {matchingInstalled.length > 0 ? (
- {matchingCatalog.map((entry) => ( + {matchingInstalled.map((entry) => ( ) : (
- {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."}
)} @@ -541,6 +677,30 @@ export function ComposioConnectorsView({ ) : null} +
+
+

Browse

+ {catalog ? ( + + {matchingCatalog.length} + + ) : null} +
+ {catalog ? ( +
+ + setLocalQuery(event.target.value)} + placeholder="Search all connectors" + value={localQuery} + /> +
+ ) : null} + {catalogList} +
+ {detailDialog}
); @@ -560,7 +720,7 @@ export function ComposioConnectorsView({ 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({ ) : null} - {variant === "catalog" && catalogLoading && !catalog ? ( - - - - ) : variant === "catalog" && catalogError ? ( -
-

- {catalogError} + {catalogList} + + {detailDialog} +

+ ); +} + +/** 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; + refreshing: boolean; +}) { + const { user, accountReady, refreshAccount } = useAccount(); + const [signingIn, setSigningIn] = useState(false); + const [error, setError] = useState(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 ( + + + + ); + } + return ( +
+
+
+ {PREVIEW_CONNECTORS.map((connector) => ( + + ))} +
+
+

+ {signedIn + ? "Connectors aren't enabled for your account yet" + : "Sign in to Cline to use connectors"} +

+

+ {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."}

+
+ {signedIn ? ( -
- ) : ( - <> - {/* The host page owns scrolling. */} -
-
+
- {visibleCatalog.length === 0 ? ( -

- {trimmedQuery - ? `No connectors match "${query.trim()}".` - : "No connectors are available for your account yet."} -

- ) : null} + {signingIn ? ( + + ) : ( + + )} + {signingIn ? "Waiting for browser…" : "Sign in"} + +
- {hasMore ? ( -
); } @@ -689,13 +901,12 @@ function ConnectorRow({ onClick={onOpenDetails} type="button" > - - - + {entry.name} @@ -861,7 +1072,7 @@ function RecipeCard({ const logo = ( @@ -950,14 +1161,12 @@ function ConnectorDetailDialog({ <>
- - - +
{entry.name}
diff --git a/apps/examples/desktop-app/webview/components/views/settings/customize-view.tsx b/apps/examples/desktop-app/webview/components/views/settings/customize-view.tsx index 833cee30f2..94f317734d 100644 --- a/apps/examples/desktop-app/webview/components/views/settings/customize-view.tsx +++ b/apps/examples/desktop-app/webview/components/views/settings/customize-view.tsx @@ -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>; @@ -68,16 +64,10 @@ export function CustomizeView({ onOpenMarketplace?: (filter?: "connector") => void; onOpenModelProviders?: () => void; }) { - const [tab, setTab] = useState("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("integrations"); const [counts, setCounts] = useState({}); - // 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({ />
- {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" ? ( diff --git a/apps/examples/desktop-app/webview/contexts/account-context.tsx b/apps/examples/desktop-app/webview/contexts/account-context.tsx index dc99eba9c4..b20838747f 100644 --- a/apps/examples/desktop-app/webview/contexts/account-context.tsx +++ b/apps/examples/desktop-app/webview/contexts/account-context.tsx @@ -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; @@ -28,6 +31,7 @@ type AccountContextValue = { const AccountContext = createContext({ 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(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 ( {children}