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>
  );
}