Files
multica/packages/views/settings/components/composio-tab.tsx
LinYushen 77ba0fdddb MUL-4009: hide not-configured Composio toolkits at Service layer (#4880)
* MUL-4009: hide not-configured Composio toolkits at Service layer

Filter out toolkits with no enabled auth config in Service.ListToolkits so
the Settings UI only shows connectable apps. A dead 'Not configured' card is
noise for end users, so drop the entry entirely instead of showing a greyed
label (which existed only to avoid a dead Connect button, MUL-3720).

- service.go: only append connectable toolkits; drop the connectable-first
  sort (all entries are connectable now); a resolver error now returns an
  error (502) instead of masking to an empty catalog, so the UI shows its
  honest load-failed state rather than a misleading 'no apps configured'.
- Keep the wire 'connectable' field (always true) for backward compat with
  older desktop clients that branch on it.
- composio-tab.tsx: remove the 'Not configured' branch; keep the
  toolkit.connectable guard as a client-side backstop.
- i18n: drop composio.not_connectable / not_connectable_hint from en/ja/ko/zh-Hans.
- Update service + handler tests to assert filtering and the resolver-error path.

Co-authored-by: multica-agent <github@multica.ai>

* MUL-4009: address review — empty-state copy, 502 handler test, stale comments

Review follow-up on #4880:
- i18n: rewrite composio.page_description / empty_title / empty_description in
  all 4 locales. The empty state now means 'no toolkit with an enabled auth
  config in the project', not 'Composio returned no catalog' — the old copy
  misled users after filtering landed.
- Add TestComposio_ListToolkits_ResolverErrorIs502: fakes an auth-config
  resolver error and asserts ListComposioToolkits returns 502, pinning the
  no-silent-empty-catalog behavior (composioFakeSDK gains listAuthErr).
- Refresh stale 'full catalog / false connectable' docs in
  packages/core/types/composio.ts, composio/queries.ts, api/client.ts to the
  connectable-only model.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-07-03 16:10:19 +08:00

363 lines
14 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { AlertTriangle, Check, Loader2, Plug, RefreshCw, Trash2 } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { Input } from "@multica/ui/components/ui/input";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@multica/ui/components/ui/alert-dialog";
import { api } from "@multica/core/api";
import {
composioConnectionsOptions,
composioKeys,
composioToolkitsOptions,
} from "@multica/core/composio";
import type { ComposioToolkit } from "@multica/core/types";
import { ComposioToolkitLogo } from "../../common/composio-toolkit-logo";
import { useT, useTimeAgo } from "../../i18n";
import { useNavigation } from "../../navigation";
// ComposioTab renders the connectable Composio toolkit catalog and lets the
// user connect / disconnect the apps their agents can act on.
//
// Key UX rule (MUL-4009): the backend only returns toolkits with an enabled
// auth config in the Composio project, so every card here is connectable —
// toolkits with no auth config are filtered out server-side rather than shown
// with a dead "not configured" hint. The `toolkit.connectable` guard on the
// Connect button is kept as a client-side backstop (older/misbehaving servers
// could still send a non-connectable entry); such an entry simply renders no
// action affordance rather than a broken Connect button that would 400.
export function ComposioTab() {
const { t } = useT("settings");
const qc = useQueryClient();
const navigation = useNavigation();
const toolkitsQuery = useQuery(composioToolkitsOptions());
const connectionsQuery = useQuery(composioConnectionsOptions());
const [query, setQuery] = useState("");
const [connectingSlug, setConnectingSlug] = useState<string | null>(null);
const [disconnectTarget, setDisconnectTarget] = useState<{
connectionId: string;
name: string;
} | null>(null);
const [disconnecting, setDisconnecting] = useState(false);
// The hosted Composio consent flow is a full-page redirect that lands back
// on the settings page carrying either `?connected=<slug>` (success) or
// `?error=composio_connect_failed` (any backend-side failure — see
// Service.CallbackRedirect, MUL-3720). Consume it exactly once: fire a toast,
// refresh the connections list so the freshly-linked card flips to Connected
// without a manual reload, then strip the one-shot params via `replace` so a
// browser refresh doesn't re-toast.
const connectedParam = navigation.searchParams.get("connected");
const errorParam = navigation.searchParams.get("error");
// React Strict Mode (dev / Next) double-invokes mount effects as
// mount → cleanup → mount. On the second invoke the `replace` from the first
// hasn't committed yet, so the closure still sees the same params and would
// toast + invalidate twice. Guard with a ref keyed on the callback we already
// consumed; a genuinely new callback (different slug, or the redirect being a
// full page load that resets this ref) still fires.
const consumedCallbackKey = useRef<string | null>(null);
useEffect(() => {
const callbackKey = connectedParam
? `connected:${connectedParam}`
: errorParam === "composio_connect_failed"
? "error:composio_connect_failed"
: null;
if (!callbackKey) return;
if (consumedCallbackKey.current === callbackKey) return;
consumedCallbackKey.current = callbackKey;
if (connectedParam) {
toast.success(t(($) => $.composio.toast_connected));
void qc.invalidateQueries({ queryKey: composioKeys.connections() });
} else {
toast.error(t(($) => $.composio.toast_connect_failed));
}
// Drop only the Composio one-shot params; keep everything else (notably
// ?tab=integrations) so the user stays on this tab.
const params = new URLSearchParams(navigation.searchParams);
params.delete("connected");
params.delete("error");
const qs = params.toString();
navigation.replace(qs ? `${navigation.pathname}?${qs}` : navigation.pathname);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectedParam, errorParam]);
// Map active connections by toolkit slug so each card knows whether it is
// already connected (and which connection id to disconnect).
const connectionBySlug = useMemo(() => {
const m = new Map<string, string>();
for (const c of connectionsQuery.data ?? []) {
if (c.status === "active") m.set(c.toolkit_slug, c.id);
}
return m;
}, [connectionsQuery.data]);
// Toolkits whose latest connection is expired render a Reconnect affordance
// instead of Connected/Connect. Backend only emits `expired` once Stage 4
// (MUL-3719) lands, but the branch is wired up now so it lights up for free.
const expiredBySlug = useMemo(() => {
const m = new Set<string>();
for (const c of connectionsQuery.data ?? []) {
if (c.status === "expired") m.add(c.toolkit_slug);
}
return m;
}, [connectionsQuery.data]);
// Last-used timestamp per active connection, for the "Last used …" line on a
// connected card. Backend leaves this null until tool-call dispatch starts
// stamping it (Stage 3, MUL-3721); the card shows a "never used" placeholder
// until then.
const lastUsedBySlug = useMemo(() => {
const m = new Map<string, string | null>();
for (const c of connectionsQuery.data ?? []) {
if (c.status === "active") m.set(c.toolkit_slug, c.last_used_at ?? null);
}
return m;
}, [connectionsQuery.data]);
const toolkits = useMemo(() => toolkitsQuery.data ?? [], [toolkitsQuery.data]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return toolkits;
return toolkits.filter(
(tk) =>
tk.name.toLowerCase().includes(q) ||
tk.slug.toLowerCase().includes(q) ||
(tk.category ?? "").toLowerCase().includes(q),
);
}, [toolkits, query]);
// 503 handling lives in the parent IntegrationsTab, which hides the whole
// Composio section when COMPOSIO_API_KEY is unset — this component only
// mounts when the integration is configured, so it deals with the loaded /
// error / empty / list states below.
async function handleConnect(tk: ComposioToolkit) {
if (connectingSlug) return;
setConnectingSlug(tk.slug);
try {
const { redirect_url } = await api.beginComposioConnect(tk.slug);
// Hand the browser to Composio's hosted consent flow; it redirects back
// to /api/integrations/composio/callback when done.
window.location.href = redirect_url;
} catch (e) {
toast.error(e instanceof Error ? e.message : t(($) => $.composio.connect_failed));
setConnectingSlug(null);
}
}
async function handleDisconnect() {
if (!disconnectTarget || disconnecting) return;
setDisconnecting(true);
try {
await api.deleteComposioConnection(disconnectTarget.connectionId);
await qc.invalidateQueries({ queryKey: composioKeys.connections() });
toast.success(t(($) => $.composio.toast_disconnected));
setDisconnectTarget(null);
} catch (e) {
toast.error(e instanceof Error ? e.message : t(($) => $.composio.disconnect_failed));
} finally {
setDisconnecting(false);
}
}
return (
<div className="space-y-6">
<section className="space-y-1">
<p className="text-sm text-muted-foreground">{t(($) => $.composio.page_description)}</p>
</section>
{toolkitsQuery.isLoading ? (
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">{t(($) => $.composio.loading)}</p>
</CardContent>
</Card>
) : toolkitsQuery.isError ? (
<Card>
<CardContent>
<p className="text-sm text-destructive">{t(($) => $.composio.load_failed)}</p>
</CardContent>
</Card>
) : toolkits.length === 0 ? (
<Card>
<CardContent className="space-y-2">
<p className="text-sm font-medium">{t(($) => $.composio.empty_title)}</p>
<p className="text-xs text-muted-foreground">{t(($) => $.composio.empty_description)}</p>
</CardContent>
</Card>
) : (
<section className="space-y-3">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t(($) => $.composio.search_placeholder)}
className="max-w-xs"
/>
{connectionsQuery.isError && (
// Don't silently treat a failed connections fetch as "nothing
// connected" — that would hide real connections and offer Connect
// on something already linked. Surface it so the user knows the
// connected state may be incomplete; the catalog still renders.
<p className="text-xs text-destructive">
{t(($) => $.composio.connections_load_failed)}
</p>
)}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((tk) => (
<ToolkitCard
key={tk.slug}
toolkit={tk}
connectionId={connectionBySlug.get(tk.slug)}
expired={expiredBySlug.has(tk.slug)}
lastUsedAt={lastUsedBySlug.get(tk.slug) ?? null}
connecting={connectingSlug === tk.slug}
anyConnecting={connectingSlug !== null}
onConnect={() => handleConnect(tk)}
onDisconnect={(connectionId, name) =>
setDisconnectTarget({ connectionId, name })
}
/>
))}
</div>
</section>
)}
<AlertDialog
open={!!disconnectTarget}
onOpenChange={(v) => {
if (!v && !disconnecting) setDisconnectTarget(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t(($) => $.composio.disconnect_confirm_title)}</AlertDialogTitle>
<AlertDialogDescription>
{t(($) => $.composio.disconnect_confirm_description)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={disconnecting}>
{t(($) => $.composio.disconnect_confirm_cancel)}
</AlertDialogCancel>
<AlertDialogAction onClick={handleDisconnect} disabled={disconnecting}>
{disconnecting
? t(($) => $.composio.disconnecting)
: t(($) => $.composio.disconnect)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
function ToolkitCard({
toolkit,
connectionId,
expired,
lastUsedAt,
connecting,
anyConnecting,
onConnect,
onDisconnect,
}: {
toolkit: ComposioToolkit;
connectionId?: string;
expired: boolean;
lastUsedAt: string | null;
connecting: boolean;
anyConnecting: boolean;
onConnect: () => void;
onDisconnect: (connectionId: string, name: string) => void;
}) {
const { t } = useT("settings");
const timeAgo = useTimeAgo();
const isConnected = !!connectionId;
return (
<Card>
<CardContent className="flex items-center gap-3 p-3">
<ComposioToolkitLogo
slug={toolkit.slug}
name={toolkit.name}
fallbackLogo={toolkit.logo}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{toolkit.name || toolkit.slug}</p>
{isConnected ? (
// Last-used line. Backend leaves last_used_at null until Stage 3
// dispatch stamps it, so show a localized "never used" placeholder
// rather than hiding the line entirely.
<p className="truncate text-[10px] text-muted-foreground">
{lastUsedAt
? t(($) => $.composio.last_used, { when: timeAgo(lastUsedAt) })
: t(($) => $.composio.last_used_never)}
</p>
) : toolkit.category ? (
<p className="truncate text-[10px] uppercase tracking-wide text-muted-foreground">
{toolkit.category}
</p>
) : null}
</div>
{isConnected ? (
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 text-xs text-emerald-600">
<Check className="h-3 w-3" />
{t(($) => $.composio.connected)}
</span>
<Button
variant="outline"
size="sm"
onClick={() => onDisconnect(connectionId!, toolkit.name || toolkit.slug)}
aria-label={t(($) => $.composio.disconnect)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
) : expired ? (
// Token-expired connection: surface the failure and let the user
// re-run the same connect flow in one click (no disconnect step).
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 text-xs text-amber-600">
<AlertTriangle className="h-3 w-3" />
{t(($) => $.composio.expired)}
</span>
<Button size="sm" variant="outline" onClick={onConnect} disabled={anyConnecting}>
{connecting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
{connecting ? t(($) => $.composio.connecting) : t(($) => $.composio.reconnect)}
</Button>
</div>
) : toolkit.connectable ? (
<Button size="sm" onClick={onConnect} disabled={anyConnecting}>
{connecting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Plug className="h-3 w-3" />
)}
{connecting ? t(($) => $.composio.connecting) : t(($) => $.composio.connect)}
</Button>
) : null}
</CardContent>
</Card>
);
}