ConversationView.tsx11.1 KBView on GitHub
'use client';

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ConversationBodyLayout } from '@/modules/conversations/components/ConversationBodyLayout';
import { useConversationBody } from '@/modules/conversations/hooks/use-conversation-body';
import { popConversationDrillIn } from '@/modules/conversations/hooks/use-conversation-back';
import { useRefreshConversation } from '@/modules/conversations/hooks/use-refresh-conversation';
import { useAopRefreshWatcher } from '../hooks/use-aop-refresh-watcher';
import { ConversationDataSync } from '@/modules/conversations/components/ConversationDataSync';
import { buildDocPath } from '@/modules/files/store/buildDocPath';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ConversationIdState } from '@/modules/conversations/constants';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AccessDeniedState, isForbiddenError } from '@/components/access-denied-state';
import { useTRPC } from '@/providers/query-provider';
import { Loader2 } from 'lucide-react';
import { useCedarStore } from '@/modules/store';
import { toast } from 'sonner';

interface ConversationViewProps {
  onClose?: () => void;
}

/**
 * True when a transient layer is open above the conversation — a Radix popper
 * layer (Popover, DropdownMenu, Select, Combobox, …), the editor slash-command
 * menu, or any inline dropdown tagged `data-escape-overlay`. The conversation's
 * capture-phase Escape handler yields to these so Escape dismisses the layer
 * rather than backing out of the conversation.
 */
function isEscapeOwnedByOverlay(): boolean {
  if (document.querySelector('[data-radix-popper-content-wrapper],[data-escape-overlay]')) {
    return true;
  }
  // The slash-command popup toggles `display` rather than unmounting, so check
  // it is actually visible (`offsetParent` is null while `display: none`).
  const slash = document.querySelector<HTMLElement>('[data-slash-command-popup]');
  return !!slash && slash.offsetParent !== null;
}

export function ConversationView({ onClose }: ConversationViewProps) {
  const queryClient = useQueryClient();
  const trpc = useTRPC();
  const setIsConversationOpen = useCedarStore((state) => state.setIsConversationOpen);

  const { conversationId, conversationData } = useConversationBody();

  const handleClose = useMemo(
    () => onClose || (() => setIsConversationOpen(false)),
    [onClose, setIsConversationOpen],
  );

  const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);

  // Capture-phase Escape so it fires even when TipTap has focus. Yields to
  // any transient layer (popover, dropdown, slash menu) above the conversation.
  useEffect(() => {
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key !== 'Escape') return;
      if (isEscapeOwnedByOverlay()) return;
      e.preventDefault();
      e.stopImmediatePropagation();
      // A drill-in open INSIDE the conversation (a file in the Files tab, a Slack thread) is
      // its own layer, and Escape closes that layer — the same rule the back gutter and the
      // header arrow follow. Closing the conversation outright from inside a file threw the
      // user out of the conversation they were reading it in, and this handler captures, so
      // the bubble-phase hotkey that knows better never got to see the key.
      if (popConversationDrillIn()) return;
      handleClose();
    };
    document.addEventListener('keydown', onKeyDown, true);
    return () => document.removeEventListener('keydown', onKeyDown, true);
  }, [handleClose]);

  // ---------------------------------------------------------------------------
  // Mutations
  // ---------------------------------------------------------------------------

  const deleteConversationMutation = useMutation({
    ...trpc.crm.deleteConversation.mutationOptions(),
  });

  const handleDeleteConversation = useCallback(async () => {
    if (!conversationId || conversationId === 'loadingConversation') {
      toast.error('No conversation available');
      return;
    }
    try {
      await deleteConversationMutation.mutateAsync({
        id: conversationId,
        deleteOrphanedCompany: true,
      });
      useCedarStore.getState().removeConversation(conversationId);
      queryClient.setQueryData(
        trpc.crm.listConversations.queryKey(),
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (oldData: any) => {
          if (!oldData?.conversations) return oldData;
          return {
            ...oldData,
            conversations: oldData.conversations.filter(
              (c: { id: string }) => c.id !== conversationId,
            ),
          };
        },
      );
      queryClient.removeQueries({
        queryKey=[redacted] id: conversationId }),
      });
      handleClose();
      void queryClient.invalidateQueries({ queryKey=[redacted] });
    } catch (error) {
      toast.error(error instanceof Error ? error.message : 'Failed to delete conversation');
    } finally {
      setIsDeleteDialogOpen(false);
    }
  }, [conversationId, deleteConversationMutation, handleClose, queryClient, trpc]);

  // ---------------------------------------------------------------------------
  // Refresh
  // ---------------------------------------------------------------------------

  // Drives the "Agent updating fields…" indicator after an AOP change: polls the
  // background refresh run and pulls the refreshed fields in when it finishes. The
  // indicator itself is rendered by CRMConversationOverviewCard, which reads the same
  // store entry directly.
  useAopRefreshWatcher(conversationId === 'loadingConversation' ? null : conversationId);

  const { handleRefresh, isRefreshing } = useRefreshConversation(
    conversationId === 'loadingConversation' ? null : conversationId,
    conversationData?.data.conversation.integrationMetadata,
    {
      onBeforeRefetch: async () => {
        const overviewDocQueryKey=[redacted]
          documentType: 'deal_overview',
          path: buildDocPath.dealOverview(conversationId!),
        });
        const convPathPrefix = `conversation/${conversationId}/`;
        await queryClient.invalidateQueries({
          predicate: (query) => {
            const key=[redacted] as unknown[];
            if (!Array.isArray(key) || !Array.isArray(key[0])) return false;
            const path = (key[0] as string[]).join('.');
            if (path !== 'documents.getDoc') return false;
            const input = (key[1] as { input?: { path?: string } } | undefined)?.input;
            return typeof input?.path === 'string' && input.path.startsWith(convPathPrefix);
          },
        });
        await queryClient.refetchQueries({ queryKey=[redacted] });
      },
    },
  );

  const isLoadingConversation = conversationId === ConversationIdState.LOADING_CONVERSATION;
  const isFailedConversation = conversationId === ConversationIdState.FAILED_CREATING_CONVERSATION;
  const isRealConversationId =
    !!conversationId && !isLoadingConversation && !isFailedConversation;

  // Deduped read of the conversation query's error — ConversationDataSync owns
  // the fetch; this shares its cache and only re-renders on `error` changes.
  const { error: conversationAccessError } = useQuery({
    ...trpc.crm.getConversation.queryOptions({ id: conversationId! }),
    enabled: isRealConversationId,
    staleTime: 2 * 60 * 1000,
    notifyOnChangeProps: ['error'],
  });
  const isConversationForbidden = isForbiddenError(conversationAccessError);

  // ---------------------------------------------------------------------------
  // Render
  // ---------------------------------------------------------------------------

  return (
    <div className="relative flex h-full flex-col gap-2 overflow-hidden w-full">
      <ConversationDataSync />

      {(isLoadingConversation || isFailedConversation || !conversationData) && (
        <div className="flex h-full items-center justify-center">
          {isConversationForbidden && <AccessDeniedState resource="conversation" />}
          {!isConversationForbidden && isLoadingConversation && (
            <div className="flex flex-col items-center justify-center gap-3 p-6 text-center">
              <Loader2 className="h-8 w-8 animate-spin text-blue-500" />
              <p className="text-muted-foreground text-sm">Loading conversation...</p>
            </div>
          )}
          {isFailedConversation && (
            <div className="flex flex-col items-center justify-center gap-3 p-6 text-center">
              <p className="text-destructive text-sm font-medium">Failed to load conversation</p>
            </div>
          )}
          {!conversationData &&
            !isConversationForbidden &&
            !isLoadingConversation &&
            !isFailedConversation && (
              <div className="text-muted-foreground p-4 text-center text-sm">
                Select a conversation to view details
              </div>
            )}
        </div>
      )}

      <AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>Delete Conversation</AlertDialogTitle>
            <AlertDialogDescription className="space-y-2">
              <p>Are you sure you want to delete this conversation? This action cannot be undone.</p>
              <p className="text-sm">This will permanently delete:</p>
              <ul className="ml-2 list-inside list-disc space-y-1 text-sm">
                <li>All email events and meeting records</li>
                <li>All tasks associated with this conversation</li>
                <li>All agent executions and scheduled actions</li>
                <li>Working memory and custom field data</li>
              </ul>
              <p className="mt-2 text-sm">
                If the same email thread or meeting syncs again, it will be treated as a new
                conversation and trigger agent processing normally.
              </p>
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel>Cancel</AlertDialogCancel>
            <AlertDialogAction
              onClick={handleDeleteConversation}
              className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
              disabled={deleteConversationMutation.isPending}
            >
              {deleteConversationMutation.isPending ? (
                <><Loader2 className="mr-2 h-4 w-4 animate-spin" />Deleting...</>
              ) : (
                'Delete Conversation'
              )}
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>

      {conversationData && (
        <ConversationBodyLayout
          onRefresh={handleRefresh}
          isRefreshing={isRefreshing}
          onDelete={() => setIsDeleteDialogOpen(true)}
          isDeleting={deleteConversationMutation.isPending}
        />
      )}
    </div>
  );
}