VirtualizedTableRow.tsx14.3 KBView on GitHub

Introduced 1 production defect in 180 days, median 227 days to fix.

/**
 * VirtualizedTableRow Component
 *
 * Individual virtualized row that subscribes directly to conversation data.
 * Simplified to work without TanStack Table - similar to Thread component in mail-list.tsx
 *
 * Key optimizations:
 * - Direct Zustand subscription to specific conversation
 * - React.memo with simple comparison
 * - No TanStack Table dependencies
 */

import type {
  CRMColumn,
  CRMRow,
  CompanyCombined,
  CurrentActionData,
  FutureActionData,
  ScheduledAction,
} from '../store/crmSlice';
import { pickActiveDeal } from '@/modules/crm/types';
import { useAOPs } from '@/modules/aop/hooks/use-aops';
import { toISODateString, parseAsUTC } from '../utils/time';
import { getVisibleFieldDefinitions } from '../utils/background-fields';
import { memo, useMemo, useState } from 'react';
import { useCedarStore } from '@/modules/store';
import { CRMCell } from './crm-cell';
import { cn } from '@/lib/utils';

interface VirtualizedTableRowProps {
  conversationId: string;
  columns: CRMColumn[];
  columnWidths: Record<string, number>;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  virtualRow: any;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  rowVirtualizer: any;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  onCellBlur: (rowId: string, columnId: string, value: any) => void;
  onClick: (rowId: string) => void;
  onMouseEnter?: (rowId: string) => void;
}

export const VirtualizedTableRow = memo(
  ({
    conversationId,
    columns,
    columnWidths,
    virtualRow,
    rowVirtualizer,
    onCellBlur,
    onClick,
    onMouseEnter,
  }: VirtualizedTableRowProps) => {
    // Subscribe to this specific conversation
    const conversationWithMetadata = useCedarStore((state) => state.conversations[conversationId]);
    const isActive = useCedarStore((state) => state.activeConversationId === conversationId);
    const isBulkSelected = useCedarStore((state) =>
      state.conversationSelection.includes(conversationId),
    );

    const { data: aopsData } = useAOPs();

    // Track hover state for the row
    const [isRowHovered, setIsRowHovered] = useState(false);

    // Compute row data from conversation
    const rowData = useMemo((): CRMRow | null => {
      if (!conversationWithMetadata) return null;

      const hydratedConversation = conversationWithMetadata.data;
      const conv = hydratedConversation.conversation;

      // Collect all field IDs from AOPs customFieldDefinitions
      const schemaFieldIds = new Set<string>();
      aopsData?.aops?.forEach((aop) => {
        if (aop.customFieldDefinitions && typeof aop.customFieldDefinitions === 'object') {
          // Exclude background (sub-event taxonomy) fields — never rendered as row cells.
          Object.keys(getVisibleFieldDefinitions(aop.customFieldDefinitions)).forEach((fieldId) => {
            schemaFieldIds.add(fieldId);
          });
        }
      });

      // Derive type from AOP name
      const aop = aopsData?.aops?.find((a) => a.id === conv.aopId);
      const conversationType = aop?.name || '';

      // Get first 3 events for history column (no conversion needed - Timeline handles rendering)
      const history = conv.events ? conv.events.slice(0, 3) : [];
      // Get all events for activity overview column
      const activityOverview = conv.events || [];

      // Get primary company
      const primaryCompany = hydratedConversation.company?.name || 'No company';

      // Process custom fields (working memory)
      const customFields = hydratedConversation.customFields || [];
      const workingMemoryMap: Record<string, string> = {};
      const customMemoryFields: Array<{ name: string; value: string }> = [];

      customFields.forEach((entry) => {
        const fieldId = entry.name;
        const fieldValue = entry.value;

        if (schemaFieldIds.has(fieldId)) {
          workingMemoryMap[`wm_${fieldId}`] = fieldValue;
          // Map signal sub-column if available
          if (entry.signal != null) {
            workingMemoryMap[`wm_${fieldId}_signal`] = entry.signal;
            if (entry.signalReasoning != null) {
              workingMemoryMap[`wm_${fieldId}_signal_reasoning`] = entry.signalReasoning;
            }
          }
        } else {
          customMemoryFields.push({ name: fieldId, value: fieldValue });
        }
      });

      // `crm.getConversation` stopped hydrating agent executions (see "remove scheduled execution
      // from conversation gets"), so the scheduled-action cell has no source. The cell renderer
      // still handles null; it renders empty until a dedicated query backs this column again.
      const scheduledAction: ScheduledAction | null = null;

      // Transform user tasks
      const userTasks =
        hydratedConversation.userTasks?.map((task) => ({
          id: task.id,
          taskOutput: task.taskOutput,
          description: task.description,
          status: task.status,
          taskActionData: task.taskActionData || null,
        })) || [];

      // Whether this conversation is synced to an external CRM, and which deal represents it,
      // both follow the ACTIVE (open) deal — so the synced badge and the deal shown come from
      // one source and a stale closed sibling on the same conversation can't flip the state.
      // pickActiveDeal honours the `active` label (then newest-open, then newest).
      const integrationMetadata = conv.integrationMetadata || [];
      const externalCrmMeta = pickActiveDeal(integrationMetadata);
      const isCrmSynced = !!externalCrmMeta && !!externalCrmMeta.provider && !!externalCrmMeta.dealId;

      // Compute current and future action data from full user tasks
      const fullUserTasks = hydratedConversation.userTasks || [];
      const todoTasks = fullUserTasks.filter((t) => t.status === 'todo');
      const now = new Date();
      // Use end of today (23:59:59.999) so any task due "today" is considered current, not future
      const endOfToday = new Date(
        now.getFullYear(),
        now.getMonth(),
        now.getDate(),
        23,
        59,
        59,
        999,
      );

      // Split tasks into overdue/current (due date <= end of today) and future (due date > end of today)
      const overdueTasks = todoTasks
        .filter((t) => t.dueDate && new Date(t.dueDate) <= endOfToday)
        .sort((a, b) => new Date(a.dueDate!).getTime() - new Date(b.dueDate!).getTime());

      const futureTasks = todoTasks
        .filter((t) => t.dueDate && new Date(t.dueDate) > endOfToday)
        .sort((a, b) => new Date(a.dueDate!).getTime() - new Date(b.dueDate!).getTime());

      // Tasks with no due date go to future
      const noDateTasks = todoTasks.filter((t) => !t.dueDate);
      const allFutureTasks = [...futureTasks, ...noDateTasks];

      // Find the next upcoming calendar event
      const scheduledCalendarEvents = hydratedConversation.scheduledCalendarEvents || [];
      let nextCalendarEvent: import('../store/crmSlice').NextCalendarEvent | null = null;
      if (scheduledCalendarEvents.length > 0) {
        for (const event of scheduledCalendarEvents) {
          const startTime = new Date(event.startTime);
          const endTime = new Date(event.endTime);
          if (event.isAllDay || endTime <= now) continue;
          const isInProgress = startTime <= now && endTime > now;
          const externalAttendees = (event.attendees || []).filter(
            (a: { self?: boolean }) => !a.self,
          );
          nextCalendarEvent = {
            title: event.title || 'Meeting',
            startTime,
            endTime,
            isInProgress,
            hangoutLink: event.hangoutLink || null,
            conferenceUri: event.conferenceUri || null,
            attendeeCount: externalAttendees.length,
          };
          break;
        }
      }

      // Build current action data - pass full task for TimelineTaskItem
      let currentAction: CurrentActionData;
      if (overdueTasks.length > 0) {
        currentAction = {
          conversationId: conv.id,
          primaryTask: overdueTasks[0],
          overdueTasks,
          additionalTaskCount: overdueTasks.length - 1,
          lastActivity: null,
          nextCalendarEvent,
          nextFutureTask: futureTasks[0] ?? null,
        };
      } else {
        // No current tasks - show last activity from events
        const latestEvent = hydratedConversation.latestEvent;
        let lastActivity = null;
        if (latestEvent) {
          const eventType = latestEvent.eventType;
          let activityType: 'email_sent' | 'email_received' | 'meeting' | 'slack' | 'other' =
            'other';
          if (eventType === 'email' && latestEvent.direction === 'outbound') {
            activityType = 'email_sent';
          } else if (eventType === 'email' && latestEvent.direction === 'inbound') {
            activityType = 'email_received';
          } else if (eventType === 'meeting') {
            activityType = 'meeting';
          } else if (eventType === 'slack') {
            activityType = 'slack';
          }
          lastActivity = {
            type: activityType,
            date: parseAsUTC(latestEvent.occurredAt) || new Date(),
            title: latestEvent.title || 'Activity',
          };
        }
        currentAction = {
          conversationId: conv.id,
          primaryTask: null,
          overdueTasks: [],
          additionalTaskCount: 0,
          lastActivity,
          nextCalendarEvent,
          nextFutureTask: futureTasks[0] ?? null,
        };
      }

      // Build future action data - pass full task for TimelineTaskItem
      let futureAction: FutureActionData;
      if (allFutureTasks.length > 0) {
        futureAction = {
          conversationId: conv.id,
          primaryTask: allFutureTasks[0],
          additionalTaskCount: allFutureTasks.length - 1,
          nextSteps: null,
          nextStepDate: null,
        };
      } else {
        // No future tasks - show next steps
        futureAction = {
          conversationId: conv.id,
          primaryTask: null,
          additionalTaskCount: 0,
          nextSteps: conv.nextSteps || null,
          nextStepDate: toISODateString(conv.nextStepDate) ?? null,
        };
      }

      const row: CRMRow = {
        id: conv.id,
        primaryCompany: {
          primaryCompany: primaryCompany,
          name: conv.name || '',
        } as CompanyCombined,
        type: conversationType,
        status: conv.status || null,
        statusBadge: conv.status || null,
        statusOverview: conv.statusOverview || '',
        priority: conv.priority || null,
        nextSteps: conv.nextSteps || '',
        nextStepDate: toISODateString(conv.nextStepDate),
        // `crm_conversations` has no `notes` column, so this cell has always rendered empty.
        notes: '',
        dealValue: conv.dealValue || 0,
        lastContactedAt: (() => {
          if (!conv.lastContactedAt) return undefined;
          return typeof conv.lastContactedAt === 'string'
            ? conv.lastContactedAt
            : conv.lastContactedAt.toISOString();
        })(),
        lastEmailAt: toISODateString(conv.lastEmailAt),
        history,
        activityOverview,
        scheduledAction,
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        currentTasks: userTasks as any,
        currentAction: currentAction as CurrentActionData,
        futureAction: futureAction as FutureActionData,
        ...workingMemoryMap,
        wm_custom: customMemoryFields,
        crmSynced: { isSynced: isCrmSynced, provider: externalCrmMeta?.provider || null },
      };

      return row;
    }, [conversationWithMetadata, aopsData?.aops]);

    if (!rowData) return null;

    return (
      <tr
        data-index={virtualRow.index}
        data-conversation-id={conversationId}
        ref={(node) => {
          if (node && rowVirtualizer.measureElement) {
            rowVirtualizer.measureElement(node);
          }
        }}
        onClick={() => onClick(conversationId)}
        onMouseEnter={() => {
          setIsRowHovered(true);
          onMouseEnter?.(conversationId);
        }}
        onMouseLeave={() => setIsRowHovered(false)}
        data-state={(isActive || isBulkSelected) && 'selected'}
        className={cn(
          (isActive || isBulkSelected) && 'bg-muted/50',
          'border-border absolute flex w-full cursor-pointer border-b',
        )}
        style={{
          transform: `translateY(${virtualRow.start}px)`,
        }}
      >
        {columns.map((column) => {
          const isCompanyColumn = column.id === 'primaryCompany';
          const value = rowData[column.id as keyof CRMRow];
          const width = columnWidths[column.id] || 200;

          return (
            <td
              key=[redacted]
              className={cn(
                'flex shrink-0 items-center justify-center p-1',
                isCompanyColumn && 'sticky left-0 !z-[15]',
                isCompanyColumn && (isActive || isBulkSelected) ? 'bg-muted' : '',
                isCompanyColumn && !(isActive || isBulkSelected) ? 'bg-sidebar' : '',
              )}
              style={{
                width,
                minWidth: 0,
              }}
            >
              <CRMCell
                rowId={conversationId}
                column={column}
                // eslint-disable-next-line @typescript-eslint/no-explicit-any
                value={value as any}
                reasoning={
                  column.agentOutputType === 'score'
                    ? (rowData[`${column.id}_reasoning` as keyof CRMRow] as string | undefined) ??
                      null
                    : null
                }
                onBlur={onCellBlur}
                conversationId={conversationId}
                isRowHovered={isRowHovered}
                isRowSelected={isActive}
              />
            </td>
          );
        })}
      </tr>
    );
  },
  (prev, next) => {
    // Check if any props changed
    if (prev.conversationId !== next.conversationId) return false;
    if (prev.columns !== next.columns) return false;
    if (prev.columnWidths !== next.columnWidths) return false;
    if (prev.virtualRow.index !== next.virtualRow.index) return false;
    if (prev.virtualRow.start !== next.virtualRow.start) return false;
    if (prev.onCellBlur !== next.onCellBlur) return false;
    if (prev.onClick !== next.onClick) return false;
    if (prev.onMouseEnter !== next.onMouseEnter) return false;

    return true;
  },
);

VirtualizedTableRow.displayName = 'VirtualizedTableRow';