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