diff --git a/cloudflare/composio-broker/src/index.test.ts b/cloudflare/composio-broker/src/index.test.ts index 9b6a966ad..cdafd23ad 100644 --- a/cloudflare/composio-broker/src/index.test.ts +++ b/cloudflare/composio-broker/src/index.test.ts @@ -226,6 +226,7 @@ describe("connected-apps broker boundaries", () => { expect(fetchCalls.some((call) => call.url.includes("/tool_router/session/trs_multi/toolkits?") && !call.url.includes("toolkits=") + && call.url.includes("is_connected=true") && call.url.includes("cursor=toolkits-page-2") )).toBe(true); diff --git a/cloudflare/composio-broker/src/index.ts b/cloudflare/composio-broker/src/index.ts index 2a018c17e..d6be0bd8f 100644 --- a/cloudflare/composio-broker/src/index.ts +++ b/cloudflare/composio-broker/src/index.ts @@ -335,7 +335,9 @@ async function listSessionToolkits( const seenCursors = new Set(); let cursor: string | undefined; for (let page = 0; page < MAX_CONNECTED_ACCOUNT_PAGES; page += 1) { - const params = new URLSearchParams({ limit: "50" }); + // Avoid walking the full marketplace just to render the Connected tab. + // Composio supports a server-side connected-only filter on this route. + const params = new URLSearchParams({ limit: "50", is_connected: "true" }); if (cursor) params.set("cursor", cursor); const response = await composioRequest( env, diff --git a/server/composio.test.ts b/server/composio.test.ts index ca0d6a8cb..36c91a486 100644 --- a/server/composio.test.ts +++ b/server/composio.test.ts @@ -307,6 +307,7 @@ describe.sequential("Composio Sessions", () => { expect(inventoryCalls[1]?.query).toContain("cursor=accounts-page-2"); const toolkitCalls = calls.slice(callCount).filter((call) => call.path.endsWith("/toolkits")); expect(toolkitCalls).toHaveLength(2); + expect(toolkitCalls[0]?.query).toContain("is_connected=true"); expect(toolkitCalls[1]?.query).toContain("cursor=toolkits-page-2"); }); diff --git a/server/composio.ts b/server/composio.ts index 329e48c3f..faaefe015 100644 --- a/server/composio.ts +++ b/server/composio.ts @@ -399,7 +399,10 @@ async function listSessionToolkits( const seenCursors = new Set(); let cursor: string | undefined; for (let page = 0; page < MAX_CONNECTED_ACCOUNT_PAGES; page += 1) { - const params = new URLSearchParams({ limit: "50" }); + // The unfiltered endpoint contains the entire Composio marketplace and is + // cursor-paginated in 50-item pages. The Connected tab only needs the + // user's connected toolkits, so avoid scanning hundreds of unrelated apps. + const params = new URLSearchParams({ limit: "50", is_connected: "true" }); if (cursor) params.set("cursor", cursor); const response = await fetch( `${apiBase()}/tool_router/session/${encodeURIComponent(sessionId)}/toolkits?${params}`, @@ -673,6 +676,8 @@ export interface ToolkitCard { label: string; blurb: string; logo: string | null; + /** Toolkits such as public search need no user authorization. */ + noAuth?: boolean; /** used for the client-side favicon fallback when logo is null/broken */ domain: string | null; } @@ -735,6 +740,7 @@ export async function listToolkits(cfg: AppConfig): Promise<{ cards: ToolkitCard label: t.name ?? t.slug ?? "", blurb: (t.meta?.description ?? t.description ?? "").slice(0, 90), logo: t.meta?.logo ?? t.logo ?? null, + noAuth: t.no_auth === true, domain: null, })); toolkitCache = { at: Date.now(), cards }; diff --git a/src/App.tsx b/src/App.tsx index 8c6e97c7c..226551701 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,7 +8,7 @@ import { Sidebar } from "@/components/Sidebar"; import { ChatView } from "@/components/ChatView"; import { GroupView } from "@/components/GroupView"; import { SettingsPanel } from "@/components/SettingsPanel"; -import { PluginsPanel } from "@/components/PluginsPanel"; +import { PluginsPanel, preloadConnectedApps } from "@/components/PluginsPanel"; import { ComputerPanel } from "@/components/ComputerPanel"; import { InspectorPanel } from "@/components/InspectorPanel"; import { SettingsModal } from "@/components/SettingsModal"; @@ -75,6 +75,14 @@ function Shell() { window.ogb?.setUnreadCount?.(unreadCount); }, [unreadCount]); + // Warm connected-account state as soon as the local server is available. + // The modal then opens with the correct Connect/Add account buttons and + // quietly revalidates instead of rediscovering every account from scratch. + useEffect(() => { + if (!state.connected) return; + void preloadConnectedApps().catch(() => {}); + }, [state.connected]); + // Picking a conversation closes the drawer: on a phone the chat is what you // asked for, and leaving the list up would hide it. Watching activeView too // catches re-selecting the bot that is already current from another view — diff --git a/src/components/PluginsPanel.test.ts b/src/components/PluginsPanel.test.ts index 21a1724e6..02cf89092 100644 --- a/src/components/PluginsPanel.test.ts +++ b/src/components/PluginsPanel.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "vitest"; import { + connectedInventoryCopy, + connectorActionLabel, disconnectAccountConfirmation, mergeCompleteConnectorStatus, mergeCurrentConnectorStatus, + requiresAccountAlias, type ConnectorStatus, } from "./PluginsPanel"; @@ -112,4 +115,34 @@ describe("connected-app status races", () => { "Disconnect “ca_personal” from GitHub? Only this GitHub account will be revoked.", ); }); + + it("recognizes the existing-account alias guard and ignores unrelated errors", () => { + expect(requiresAccountAlias("Add an account alias so the existing connection is not replaced")).toBe(true); + expect(requiresAccountAlias("Authorization expired")).toBe(false); + }); + + it("never presents unloaded account state as disconnected", () => { + expect(connectedInventoryCopy("loading").title).toBe("Checking connected apps…"); + expect(connectorActionLabel("loading", { + busy: false, + included: false, + canContinue: false, + hasAccounts: false, + failed: false, + })).toBe("Checking…"); + expect(connectorActionLabel("ready", { + busy: false, + included: false, + canContinue: false, + hasAccounts: true, + failed: false, + })).toBe("Add account"); + expect(connectorActionLabel("error", { + busy: false, + included: false, + canContinue: false, + hasAccounts: false, + failed: false, + })).toBe("Unavailable"); + }); }); diff --git a/src/components/PluginsPanel.tsx b/src/components/PluginsPanel.tsx index 08e81174c..a7546734c 100644 --- a/src/components/PluginsPanel.tsx +++ b/src/components/PluginsPanel.tsx @@ -12,6 +12,7 @@ interface ToolkitCard { label: string; blurb: string; logo: string | null; + noAuth?: boolean; domain: string | null; } @@ -26,6 +27,34 @@ export interface ConnectorStatus { }>; } +// The panel is a modal and unmounts whenever it closes. Keep the last known +// account inventory at module scope so reopening never flashes every service +// as disconnected while a fresh secure status check runs in the background. +let cachedConnectorStatus: Record | null = null; +let cachedConnectorStatusAt = 0; +let connectorStatusRequest: Promise> | null = null; +const CONNECTOR_STATUS_CACHE_MS = 30_000; + +/** Warm the account inventory once the app server is ready. Concurrent panel + * opens share the same request, and recent data survives modal unmounts. */ +export function preloadConnectedApps(force = false): Promise> { + if (!force && cachedConnectorStatus !== null && Date.now() - cachedConnectorStatusAt < CONNECTOR_STATUS_CACHE_MS) { + return Promise.resolve(cachedConnectorStatus); + } + if (connectorStatusRequest) return connectorStatusRequest; + connectorStatusRequest = api("/api/connectors/connected") + .then((response) => { + const services: Record = response.services ?? {}; + cachedConnectorStatus = services; + cachedConnectorStatusAt = Date.now(); + return services; + }) + .finally(() => { + connectorStatusRequest = null; + }); + return connectorStatusRequest; +} + export function disconnectAccountConfirmation( service: string, account: { id: string; alias?: string }, @@ -34,6 +63,41 @@ export function disconnectAccountConfirmation( return `Disconnect ${identity} from ${service}? Only this ${service} account will be revoked. Your other ${service} accounts will stay connected.`; } +export function requiresAccountAlias(message: string) { + return /account alias.*existing connection.*not replaced/i.test(message); +} + +export type ConnectorInventoryPhase = "loading" | "ready" | "error"; + +export function connectorActionLabel( + phase: ConnectorInventoryPhase, + state: { busy: boolean; included: boolean; canContinue: boolean; hasAccounts: boolean; failed: boolean }, +) { + if (state.busy) return null; + if (state.included) return "Included"; + if (phase === "loading") return "Checking…"; + if (phase === "error") return "Unavailable"; + if (state.canContinue) return "Continue"; + if (state.hasAccounts) return "Add account"; + if (state.failed) return "Retry"; + return "Connect"; +} + +export function connectedInventoryCopy(phase: ConnectorInventoryPhase) { + if (phase === "loading") return { + title: "Checking connected apps…", + description: "Your accounts will appear here as soon as the secure connection check finishes.", + }; + if (phase === "error") return { + title: "Couldn’t load connected apps", + description: "Retry the connection check before adding another account.", + }; + return { + title: "No connected apps yet", + description: "Connect an app from Marketplace and it will appear here.", + }; +} + export function mergeCurrentConnectorStatus( current: Record, incoming: Record, @@ -94,12 +158,17 @@ export function PluginsPanel() { const [source, setSource] = useState<"api" | "curated">("curated"); const [configured, setConfigured] = useState(true); const [mode, setMode] = useState<"managed" | "self-hosted" | "unavailable">("unavailable"); - const [status, setStatus] = useState>({}); + const [status, setStatus] = useState>( + () => cachedConnectorStatus ?? {}, + ); const [pendingUrls, setPendingUrls] = useState>({}); const [aliasSlug, setAliasSlug] = useState(null); const [aliasDraft, setAliasDraft] = useState(""); const [busySlug, setBusySlug] = useState(null); const [refreshing, setRefreshing] = useState(false); + const [inventoryPhase, setInventoryPhase] = useState( + cachedConnectorStatus === null ? "loading" : "ready", + ); const [error, setError] = useState(null); const [search, setSearch] = useState(""); const [tab, setTab] = useState<"marketplace" | "connected">("marketplace"); @@ -138,12 +207,11 @@ export function PluginsPanel() { .finally(() => setRefreshing(false)); }, []); - const refreshConnectedStatus = useCallback((): Promise> => { + const refreshConnectedStatus = useCallback((force = false): Promise> => { const requestGenerations = new Map(statusGenerations.current); setRefreshing(true); - return api("/api/connectors/connected") - .then((r) => { - const services: Record = r.services ?? {}; + return preloadConnectedApps(force) + .then((services) => { setStatus((current) => mergeCompleteConnectorStatus( current, services, @@ -161,17 +229,39 @@ export function PluginsPanel() { } return services; }) - .catch(() => ({})) .finally(() => setRefreshing(false)); }, []); + const loadConnectionInventory = useCallback((force = false) => { + const hadCachedInventory = cachedConnectorStatus !== null; + if (!hadCachedInventory) setInventoryPhase("loading"); + setError(null); + return refreshConnectedStatus(force) + .then((services) => { + setInventoryPhase("ready"); + return services; + }) + .catch((cause) => { + if (!hadCachedInventory) setInventoryPhase("error"); + setError(cause instanceof Error ? cause.message : String(cause)); + return {}; + }); + }, [refreshConnectedStatus]); + useEffect(() => () => { for (const timer of pollTimers.current.values()) clearInterval(timer); pollTimers.current.clear(); }, []); + useEffect(() => { + if (inventoryPhase !== "ready") return; + cachedConnectorStatus = status; + cachedConnectorStatusAt = Date.now(); + }, [inventoryPhase, status]); + useEffect(() => { let alive = true; + void loadConnectionInventory(); api("/api/connectors/catalog") .then((r) => { if (!alive) return; @@ -179,13 +269,15 @@ export function PluginsPanel() { setSource(r.source ?? "curated"); setConfigured(Boolean(r.configured)); setMode(r.mode ?? "unavailable"); - if (r.configured) void refreshConnectedStatus(); }) - .catch((e) => alive && setError(e.message)); + .catch((e) => { + if (!alive) return; + setError(e.message); + }); return () => { alive = false; }; - }, [refreshConnectedStatus]); + }, [loadConnectionInventory]); useEffect(() => { const returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; @@ -284,7 +376,17 @@ export function PluginsPanel() { startPolling(slug); await openConnectUrl(url); } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + const message = e instanceof Error ? e.message : String(e); + if (requiresAccountAlias(message)) { + // Recover gracefully if an existing account was discovered after the + // button rendered. Show the label field and refresh only this app. + setAliasSlug(slug); + setAliasDraft(""); + setError("This app already has an account. Add a label such as work or personal to connect another."); + void refreshStatus([slug]); + } else { + setError(message); + } } finally { setBusySlug(null); } @@ -305,6 +407,7 @@ export function PluginsPanel() { tab === "marketplace" || status[card.slug]?.connected || Boolean(status[card.slug]?.accounts?.length) ); const connectedCount = Object.values(status).filter((service) => service.connected || service.accounts?.length).length; + const connectedEmptyCopy = connectedInventoryCopy(inventoryPhase); const close = () => dispatch({ type: "togglePlugins", open: false }); return ( @@ -327,7 +430,7 @@ export function PluginsPanel() {
@@ -547,11 +649,20 @@ export function PluginsPanel() { {cards !== null && visible.length === 0 && (
- {tab === "connected" ? "No connected apps yet" : "No apps found"} + {tab === "connected" ? connectedEmptyCopy.title : "No apps found"}
- {tab === "connected" ? "Connect an app from Marketplace and it will appear here." : "Try a different search."} + {tab === "connected" ? connectedEmptyCopy.description : "Try a different search."}
+ {tab === "connected" && inventoryPhase === "error" && ( + + )}
)}