google-slides-manager.tsx7.1 KBView on GitHub
'use client';

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ExternalLink, Loader2, Plus, Presentation, Trash2 } from 'lucide-react';
import { authClient } from '@/modules/auth/utils/auth-client';

import { GoogleDriveIcon } from '@/components/icons/google-drive-icon';
import { useTRPC } from '@/providers/query-provider';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from 'sonner';
import { useGooglePicker } from './use-google-picker';

const MIME_PRESENTATION = 'application/vnd.google-apps.presentation';
const MIME_DOCUMENT = 'application/vnd.google-apps.document';
/**
 * What the agent can be pointed at in Drive. Docs belong here as much as Slides: customers
 * keep living playbooks, FAQs and process notes in Docs, and the KB entry is just a
 * reference ({kind, fileId, mimeType}) that the read path already resolves per MIME type.
 */
const KB_DRIVE_MIMES = [MIME_PRESENTATION, MIME_DOCUMENT];

/** Drive's viewer URL differs per native type — a Doc opened as /presentation/ 404s. */
function driveFileUrl(mimeType: string, fileId: string): string {
  if (mimeType === MIME_PRESENTATION) {
    return `https://docs.google.com/presentation/d/${fileId}`;
  }
  if (mimeType === MIME_DOCUMENT) {
    return `https://docs.google.com/document/d/${fileId}`;
  }
  return `https://drive.google.com/file/d/${fileId}`;
}

export function GoogleSlidesManager() {
  const trpc = useTRPC();
  const queryClient = useQueryClient();

  const { data: pickerConfig } = useQuery(
    trpc.presentations.getPickerConfig.queryOptions(undefined, { retry: false }),
  );

  // Load linked Drive files from the KB (google_drive entries Cedar can read)
  const { data: kbData, isLoading } = useQuery(trpc.kb.listKbDocuments.queryOptions());

  const driveFiles = (kbData?.documents ?? []).filter(
    (doc) =>
      typeof doc.metadata === 'object' &&
      doc.metadata !== null &&
      (doc.metadata as { kind?: unknown }).kind === 'google_drive' &&
      KB_DRIVE_MIMES.includes((doc.metadata as { mimeType?: string }).mimeType ?? ''),
  );

  const { mutateAsync: createFromDrive, isPending: isAdding } = useMutation(
    trpc.kb.createKbFromGoogleDrive.mutationOptions({
      onSuccess: () => queryClient.invalidateQueries(trpc.kb.listKbDocuments.queryOptions()),
    }),
  );

  const { mutate: deleteKbDoc, isPending: isDeleting } = useMutation(
    trpc.kb.deleteKbDocument.mutationOptions({
      onSuccess: () => queryClient.invalidateQueries(trpc.kb.listKbDocuments.queryOptions()),
    }),
  );

  const { openPicker, isLoading: isPickerLoading } = useGooglePicker({
    multiselect: true,
    mimeTypes: KB_DRIVE_MIMES,
    onPick: async (files) => {
      const existingFileIds = new Set(
        driveFiles.map((p) => (p.metadata as { fileId?: string }).fileId),
      );
      const newFiles = files.filter((f) => !existingFileIds.has(f.id));
      if (newFiles.length === 0) return;

      for (const file of newFiles) {
        try {
          await createFromDrive({
            fileId: file.id,
            mimeType: file.mimeType ?? MIME_PRESENTATION,
            name: file.name,
          });
        } catch (err) {
          toast.error(
            `Failed to add "${file.name}": ${err instanceof Error ? err.message : 'Unknown error'}`,
          );
        }
      }
    },
  });

  const isConnected = !!pickerConfig;
  const needsReauth = pickerConfig?.needsReauth === true;

  if (needsReauth) {
    return (
      <div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/30">
        <p className="text-sm font-medium text-amber-900 dark:text-amber-200">
          Google Drive access required
        </p>
        <p className="mt-1 text-sm text-amber-700 dark:text-amber-400">
          Your Google account was connected before Drive access was added. Re-authorize to link
          Google Docs and Slides.
        </p>
        <Button
          size="sm"
          className="mt-3"
          onClick={() =>
            authClient.linkSocial({
              provider: 'google',
              callbackURL: `${window.location.origin}/settings/connections?tab=slides`,
            })
          }
        >
          Re-authorize Google
        </Button>
      </div>
    );
  }

  return (
    <div className="space-y-3">
      <div className="flex items-center justify-between">
        <p className="text-muted-foreground text-sm">
          {isConnected
            ? 'Link Google Docs and Slides to give the agent access to them.'
            : 'Connect a Google account above to enable this feature.'}
        </p>
        {isConnected && (
          <Button
            size="sm"
            variant="outline"
            onClick={openPicker}
            disabled={isPickerLoading || isAdding}
          >
            {isAdding ? (
              <Loader2 className="mr-1.5 size-3.5 animate-spin" />
            ) : (
              <Plus className="mr-1.5 size-3.5" />
            )}
            Add file
          </Button>
        )}
      </div>

      {isLoading ? (
        <div className="space-y-2">
          <Skeleton className="h-9 w-full rounded-md" />
          <Skeleton className="h-9 w-full rounded-md" />
        </div>
      ) : driveFiles.length === 0 ? (
        <p className="text-muted-foreground text-sm italic">
          No files linked yet. The agent can still create new ones.
        </p>
      ) : (
        <ul className="space-y-1.5">
          {driveFiles.map((p) => {
            const meta = p.metadata as { fileId: string; mimeType: string };
            return (
              <li
                key=[redacted]
                className="bg-muted/40 flex items-center justify-between rounded-md px-3 py-2 text-sm"
              >
                <div className="flex min-w-0 items-center gap-2">
                  {meta.mimeType === MIME_PRESENTATION ? (
                    <Presentation className="text-muted-foreground size-4 shrink-0" />
                  ) : (
                    <GoogleDriveIcon className="size-4 shrink-0" />
                  )}
                  <span className="truncate">{p.name}</span>
                </div>
                <div className="ml-2 flex shrink-0 items-center gap-1">
                  <Button variant="ghost" size="icon" className="size-7" asChild>
                    <a
                      href={driveFileUrl(meta.mimeType, meta.fileId)}
                      target="_blank"
                      rel="noopener noreferrer"
                    >
                      <ExternalLink className="size-3.5" />
                    </a>
                  </Button>
                  <Button
                    variant="ghost"
                    size="icon"
                    className="text-muted-foreground hover:text-destructive size-7"
                    disabled={isDeleting}
                    onClick={() => deleteKbDoc({ id: p.id })}
                  >
                    <Trash2 className="size-3.5" />
                  </Button>
                </div>
              </li>
            );
          })}
        </ul>
      )}
    </div>
  );
}