AgentFamilyConnectDialog.tsx7.8 KBView on GitHub 'use client';
import { useQuery } from '@tanstack/react-query';
import { useLocation } from 'react-router';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import { authClient } from '@/modules/auth/utils/auth-client';
import { useTRPC } from '@/providers/query-provider';
import { emailProviders } from '@/lib/constants';
import { MeetingIntegrationCard } from '@/modules/integrations/meeting-integration-card';
import { SlackIntegrationCard } from '@/modules/integrations/slack-integration-card';
import { LinkedInIntegrationCard } from '@/modules/integrations/linkedin-integration-card';
import { DriveReauthCard } from '@/modules/integrations/drive-reauth-card';
/**
* Connecting Gmail / Slack / LinkedIn / Meetings / Drive WITHOUT leaving the agent.
*
* These five are the `kind: 'default'` rows on Config §2 — reached through Cedar's
* own tools rather than through MCP — and their Connect button was the last thing on
* this page that navigated away. That navigation is the whole problem: you are here
* deciding what this agent may reach, and the link answers by dropping the question
* and landing you on a settings page with no idea which agent sent you.
*
* It renders THE SAME cards Settings renders, not a reimplementation. Each one owns
* its own OAuth handshake, polling and disconnect; mounting them here means the two
* screens cannot drift, and a provider added to Settings appears here for free.
*
* Gmail is the exception, and only because it has no card: an email account is
* linked through better-auth's `linkSocial`, which is a redirect rather than a
* component. The buttons below are the same call `AddConnectionDialog` makes.
*/
export const AGENT_CONNECTION_FAMILIES = [
'gmail',
'slack',
'linkedin',
'meetings',
'drive',
] as const;
export type AgentConnectionFamily = (typeof AGENT_CONNECTION_FAMILIES)[number];
/**
* `AgentConnection.key` is a `string` on the wire, and only the `kind: 'default'` rows
* carry one of these. Checked rather than asserted — and the check is why the failure was
* survivable, but this list still has to be KEPT IN STEP with `DEFAULT_FAMILIES`
* server-side, which is the half that went wrong.
*
* When `drive` was added as a fifth family it was not added here. The prediction in this
* comment ("a dialog with an empty body") was optimistic: `AgentView`'s handler falls
* back to `setPickerOpen(true)` for an unrecognised key, so clicking Connect on the Drive
* row opened the ADD-AN-MCP-SERVER picker — an answer to a question nobody asked, on the
* very screen whose whole point is that a control does what it says.
*/
export function isAgentConnectionFamily(key=[redacted] key is AgentConnectionFamily {
return (AGENT_CONNECTION_FAMILIES as readonly string[]).includes(key);
}
/** Exported so a test can assert every family in the list actually has copy. */
export const FAMILY_CONNECT_TITLES: Record<
AgentConnectionFamily,
{ title: string; description: string }
> = {
gmail: {
title: 'Connect email',
description: 'Cedar reads and drafts from this account. You can connect more than one.',
},
slack: {
title: 'Connect Slack',
description: 'Syncs channel conversations and lets this agent post and read messages.',
},
linkedin: {
title: 'Connect LinkedIn',
description: 'Connect a seat so this agent can read conversations and message prospects.',
},
meetings: {
title: 'Connect meetings',
description: 'Connect a notetaker so this agent can read transcripts and prep from them.',
},
drive: {
title: 'Connect Google Drive',
description: 'Re-consent to Google with Drive access so this agent can read your files.',
},
};
export function AgentFamilyConnectDialog({
family,
onOpenChange,
}: {
/** Null closes the dialog. The caller stores which row was clicked. */
family: AgentConnectionFamily | null;
onOpenChange: (next: AgentConnectionFamily | null) => void;
}) {
const trpc = useTRPC();
const pathname = useLocation().pathname;
/**
* The notetakers this workspace can connect — the CATALOG, not the ones already
* delivering. Those are on the row itself (`AgentConnection.recorders`); this list is what
* is on offer, which is a different question and the only one a Connect dialog answers.
*/
const { data: integrationsData } = useQuery({
...trpc.integrations.list.queryOptions(),
enabled: family === 'meetings',
});
const meetingProviders = integrationsData?.integrations.filter((i) => i.type === 'meeting') ?? [];
const copy = family ? FAMILY_CONNECT_TITLES[family] : null;
return (
<Dialog open={family !== null} onOpenChange={(open) => !open && onOpenChange(null)}>
<DialogContent className="max-h-[85vh] gap-0 overflow-hidden p-0 sm:max-w-lg">
<DialogHeader className="space-y-1 border-b px-5 py-4">
<DialogTitle className="text-base">{copy?.title ?? 'Connect'}</DialogTitle>
<DialogDescription className="text-xs">{copy?.description}</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[65vh]">
<div className="space-y-4 p-5">
{family === 'gmail' &&
emailProviders.map((provider) => {
const Icon = provider.icon;
return (
<Button
key=[redacted]
variant="outline"
className="h-20 w-full flex-col items-center justify-center gap-2"
onClick={() =>
void authClient.linkSocial({
provider: provider.providerId,
// Return to the AGENT, not to Settings — the redirect is the
// one place this flow could still lose the question.
callbackURL: `${window.location.origin}${pathname}${window.location.search}`,
})
}
>
<Icon className="size-6!" />
<span className="text-xs">{provider.name}</span>
</Button>
);
})}
{family === 'slack' && <SlackIntegrationCard bare />}
{family === 'linkedin' && <LinkedInIntegrationCard />}
{/* Drive is not a connection of its own — it is a SCOPE on the Google account,
so there is nothing to "connect", only a broader consent to re-grant. That is
why this is the re-auth card and not an integration card: offering Connect
for an account already connected is the confusion the Drive row exists to
clear up. `none` is the level that reaches this dialog; the `file` (legacy,
picker-only) level renders its own card inline on the row instead. */}
{family === 'drive' && <DriveReauthCard accessLevel="none" />}
{/* EVERY notetaker on offer, each its own card. A menu is the right answer here
and was the wrong answer on a recorder row: the row named one provider, so
its Connect had to open that one, while THIS row is the family and the
question it asks really is "which notetaker". */}
{family === 'meetings' &&
(meetingProviders.length > 0 ? (
meetingProviders.map((p) => (
<MeetingIntegrationCard key=[redacted] providerId={p.id} userFacing />
))
) : (
<p className="text-muted-foreground text-sm">
No meeting integrations are available for your workspace.
</p>
))}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}