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}