ConversationItem.tsx36.5 KBView on GitHub

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

/**
 * ConversationItem Component
 *
 * Renders a single conversation in horizontal list format
 * Similar to thread.tsx but uses CRM data and column configuration
 * Supports dynamic agent columns for generative UI during batch operations
 */

import {
  CurrentActionDisplay,
  NumberEditor,
  SelectEditor,
  StatusBadgeEditor,
  TextEditor,
} from '../ConversationCellComponents';
import { SignalDot } from '../SignalDot';
import { isLinkedExternalCrmIntegration, pickActiveDeal } from '@/modules/crm/types';
import { ConversationActivityOverview } from '@/modules/conversations/components/timeline/ConversationActivityOverview';
import { useOptimisticConversationActions } from '../../hooks/use-optimistic-conversation-actions';
import type { CRMColumn, CurrentActionData, CrmSyncedValue, NextCalendarEvent } from '../../store/crmSlice';
import { Badge } from '@/components/ui/badge';
import { Link2, Link2Off } from 'lucide-react';
import { EventDetailDialog } from '@/modules/conversations/components/timeline/EventDetailDialog';
import { ProcessingStateDisplay, mapAgentDataToProcessingState } from '../ProcessingStateDisplay';
import { DatePickerWithNaturalInput } from '@/components/ui/date-picker-with-natural-input';
import type { ConversationViewConfig } from '@/modules/canvas/types/canvas-types';
import { useCanvasConfiguration } from '../../hooks/use-canvas-configuration';
import { useAgentData } from '@/modules/agentCanvas/hooks/use-agent-columns';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { RelativeDateBadge } from '@/components/ui/relative-date-badge';
import { Star } from 'lucide-react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTRPC } from '@/providers/query-provider';
import { AnimatePresence, motion } from 'motion/react';
import type { ConversationEvent, FieldSignal } from '../../types';
import { Checkbox } from '@/components/ui/checkbox';
import { useCedarStore } from '@/modules/store';
import { parseAsUTC } from '../../utils/time';
import { getExpandedColumnWidth, getExpandedColumnWidthPx, getDefaultColumnWidthPx } from '../../utils/canvas-columns';
import { cn } from '@/lib/utils';
interface ConversationItemProps {
  conversationId: string;
  canvasId: string;
  onClick?: (conversationId: string) => void;
  onToggleWithAnchor?: (conversationId: string) => void;
  onMouseEnter?: (conversationId: string) => void;
  onOpenConversation?: (conversationId: string) => void;
  onOpenReviewMode?: (conversationId: string) => void;
  isFirst?: boolean;
}

// Memoized hover actions component
// const ConversationHoverActions = memo(function ConversationHoverActions({
//   isFirst,
//   onAddTask,
// }: {
//   isFirst: boolean;
//   onAddTask: (e: React.MouseEvent) => void;
// }) {
//   return (
//     <div
//       className={cn(
//         'dark:bg-panelDark z-25 absolute right-2 flex -translate-y-1/2 items-center gap-1 rounded-xl border bg-white p-1 opacity-0 shadow-sm group-hover:opacity-100',
//         isFirst ? 'top-4' : 'top-1/2',
//       )}
//     >
//       <Tooltip>
//         <TooltipTrigger asChild>
//           <Button variant="ghost" size="sm" className="h-6 px-2 text-xs" onClick={onAddTask}>
//             <Plus className="h-3.5 w-3.5 mr-1" />
//             Add task
//           </Button>
//         </TooltipTrigger>
//         <TooltipContent
//           side={isFirst ? 'bottom' : 'top'}
//           className="mb-1 bg-white dark:bg-[#1A1A1A]"
//         >
//           Add task
//         </TooltipContent>
//       </Tooltip>
//     </div>
//   );
// });

const CURRENT_ACTION_TASK_PRIORITY: Record<string, number> = {
  'post-meeting': 0,
  response: 1,
  'follow-up': 2,
  manual: 3,
};

export const ConversationItem = memo(
  function ConversationItem({
    conversationId,
    canvasId,
    onClick,
    onToggleWithAnchor,
    onMouseEnter,
    onOpenConversation,
    onOpenReviewMode,
  }: ConversationItemProps) {
    const trpc = useTRPC();
    const [isHovered, setIsHovered] = useState(false);
    // Only the id: a row's events are the OVERVIEW projection, which carries no body, subject
    // or attachments for the dialog to render. The full event is fetched below, on click —
    // which is the point of the split, since almost nobody clicks a dot on almost every row.
    const [selectedEventId, setSelectedEventId] = useState<string | null>(null);

    // Get conversation data from store
    const conversations = useCedarStore((state) => state.conversations);
    const showProcessingState = !!canvasId && canvasId !== 'calendar';
    const conversationData = conversations[conversationId];

    const { columns } = useCanvasConfiguration(canvasId);

    // Get agent data for generative UI columns — scoped to this canvas, not the global activeCanvasId
    const agentData = useAgentData(conversationId, canvasId);

    // Get pending draft for this conversation from this canvas (not the global activeCanvasId)
    const pendingDraft = useCedarStore(
      (state) =>
        state.canvasesById[canvasId]?.data?.conversationEntries?.[conversationId]?.pendingDraft,
    );

    // Get pending analysis for this conversation from this canvas (not the global activeCanvasId)
    const pendingAnalysis = useCedarStore(
      (state) =>
        state.canvasesById[canvasId]?.data?.conversationEntries?.[conversationId]?.pendingAnalysis,
    );

    // Map agent data to simplified processing state
    const processingState = mapAgentDataToProcessingState(agentData, pendingDraft, pendingAnalysis);

    // Get optimistic update actions for persisting changes
    const { optimisticUpdateConversation, optimisticUpdateCustomField } =
      useOptimisticConversationActions();

    // Derive expanded state from the per-canvas view config
    const isExpanded = useCedarStore((state) => {
      const viewConfig = state.canvasesById[canvasId]?.viewConfig as
        | ConversationViewConfig
        | null
        | undefined;
      return viewConfig?.expanded ?? false;
    });

    // Read company/conversation name toggle and column width from canvas view config
    const showConversationName = useCedarStore((state) => {
      const viewConfig = state.canvasesById[canvasId]?.viewConfig as
        | ConversationViewConfig
        | null
        | undefined;
      return viewConfig?.showConversationName ?? false;
    });

    const companyColumnWidth = useCedarStore((state) => {
      const viewConfig = state.canvasesById[canvasId]?.viewConfig as
        | ConversationViewConfig
        | null
        | undefined;
      return viewConfig?.companyColumnWidth;
    });
    const effectiveCompanyWidth = companyColumnWidth ?? (showConversationName ? 224 : 144);

    const columnConfiguration = useCedarStore((state) => {
      const viewConfig = state.canvasesById[canvasId]?.viewConfig as
        | ConversationViewConfig
        | null
        | undefined;
      return viewConfig?.columnConfiguration;
    });

    // Get selection state
    const selectedIds = useCedarStore((state) => state.conversationSelection);
    const activeConversationId = useCedarStore((state) => state.activeConversationId);

    const isSelected = activeConversationId === conversationId;
    const isBulkSelected = selectedIds.includes(conversationId);

    // Filter out columns we don't want to show in the list view
    // Keep company-combined as first, then select other important columns
    const displayColumns = useMemo(() => {
      // Get company column first
      const companyCol = columns.find((col) => col.type === 'company-combined');

      // Get other visible columns (exclude company, timeline, working-memory as they're too wide)
      // Note: activity-overview IS allowed since it's a compact horizontal timeline
      const otherCols = columns.filter(
        (col) =>
          col.type !== 'company-combined' &&
          col.type !== 'timeline' &&
          col.type !== 'working-memory' &&
          col.id !== 'wm_custom' &&
          col.visible !== false,
      );

      return companyCol ? [companyCol, ...otherCols] : otherCols;
    }, [columns]);

    // Memoized time range for activity overview (last month)
    const activityTimeRange = useMemo(() => {
      const now = new Date();
      const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
      return { start: oneMonthAgo, end: now };
    }, []);

    // Compute action data from user tasks
    const actionData = useMemo(() => {
      if (!conversationData) return { currentAction: null };

      const userTasks = conversationData.data.userTasks || [];
      const conversation = conversationData.data.conversation;
      const latestEvent = conversationData.data.latestEvent;
      const scheduledCalendarEvents = conversationData.data.scheduledCalendarEvents || [];

      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const todoTasks = userTasks.filter((t: any) => t.status === 'todo');
      const now = new Date();
      const endOfToday = new Date(
        now.getFullYear(),
        now.getMonth(),
        now.getDate(),
        23,
        59,
        59,
        999,
      );

      // Find the next upcoming calendar event (in progress or starting soonest in the future)
      let nextCalendarEvent: 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;
        }
      }

      const overdueTasks = todoTasks
        .filter(
          (t: { dueDate: string | Date | null }) => t.dueDate && new Date(t.dueDate) <= endOfToday,
        )
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        .sort((a: any, b: any) => {
          const aPriority = CURRENT_ACTION_TASK_PRIORITY[a.taskType] ?? 4;
          const bPriority = CURRENT_ACTION_TASK_PRIORITY[b.taskType] ?? 4;
          if (aPriority !== bPriority) return aPriority - bPriority;
          return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
        });

      // Nearest upcoming task due after today (used when nothing is overdue)
      const nextFutureTask =
        todoTasks
          .filter(
            (t: { dueDate: string | Date | null }) =>
              t.dueDate && new Date(t.dueDate) > endOfToday,
          )
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          .sort(
            (a: any, b: any) =>
              new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime(),
          )[0] ?? null;

      let currentAction: CurrentActionData;
      if (overdueTasks.length > 0) {
        currentAction = {
          conversationId: conversation.id,
          primaryTask: overdueTasks[0],
          overdueTasks,
          additionalTaskCount: overdueTasks.length - 1,
          lastActivity: null,
          nextCalendarEvent,
          nextFutureTask,
        };
      } else {
        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: conversation.id,
          primaryTask: null,
          overdueTasks: [],
          additionalTaskCount: 0,
          lastActivity,
          nextCalendarEvent,
          nextFutureTask,
        };
      }

      return { currentAction, nextCalendarEvent };
    }, [conversationData]);

    // Get column values
    const getColumnValue = useCallback(
      (column: CRMColumn) => {
        if (!conversationData) return null;

        const conversation = conversationData.data.conversation;
        const company = conversationData.data.company;

        switch (column.id) {
          case 'primaryCompany':
            return {
              primaryCompany: company?.name || null,
              name: conversation.name || '',
            };
          case 'status':
            return conversation.status || null;
          case 'statusOverview':
            return conversation.statusOverview || '';
          case 'activityOverview':
            return conversation.events || [];
          case 'nextSteps':
            return conversation.nextSteps || '';
          case 'nextStepDate': {
            const nextStepDateValue = conversation.nextStepDate;
            if (!nextStepDateValue) return null;
            const dateStr =
              typeof nextStepDateValue === 'string'
                ? nextStepDateValue
                : nextStepDateValue.toISOString();
            return parseAsUTC(dateStr) || null;
          }
          case 'priority':
            return conversation.priority || null;
          case 'dealValue':
            return conversation.dealValue || null;
          case 'crmSynced': {
            const integrationMetadata = conversation.integrationMetadata || [];
            // "Synced" is a linkage boolean; the displayed provider is the open deal's.
            const isSynced = integrationMetadata.some(
              (m) => isLinkedExternalCrmIntegration(m) && !!m.provider && !!m.dealId,
            );
            const crmEntry = pickActiveDeal(integrationMetadata);
            return {
              isSynced,
              provider: crmEntry?.provider || null,
            } satisfies CrmSyncedValue;
          }
          case 'lastContactedAt':
            return parseAsUTC(conversation.lastContactedAt) || null;
          case 'type':
            return conversation.aopId || null;
          case 'currentAction':
            return actionData.currentAction;
          case 'hasFutureCalendar': {
            return actionData.nextCalendarEvent?.startTime ?? null;
          }
          // These three read a scalar the list query computes over the conversation's WHOLE
          // history. They used to scan `conversation.events`, which is a capped, most-recent-
          // first window — so a deal whose last meeting predated its 15 most recent events
          // showed no last-meeting date at all, while SORTING by the same field (which the
          // server has always computed unbounded) put it somewhere the column contradicted.
          case 'lastMeetingTime':
            return parseAsUTC(conversation.lastMeetingAt) ?? null;
          case 'lastOutboundEmailTime':
            return parseAsUTC(conversation.lastOutboundEmailAt) ?? null;
          case 'lastInboundEmailTime':
            return parseAsUTC(conversation.lastInboundAt) ?? null;
          case 'primaryUser':
            return conversationData.data.ownerUser ?? null;
          default:
            // Handle signal sub-column (wm_*_signal)
            if (column.type === 'score' && column.parentColumnId) {
              const parentFieldName = column.parentColumnId.substring(3);
              const field = conversationData.data.customFields?.find(
                (f: { name: string }) => f.name === parentFieldName,
              );
              if (!field) return null;
              if (field.signal == null) return null;
              return { signal: field.signal, reasoning: field.signalReasoning ?? null };
            }
            // Handle custom fields (wm_*)
            if (column.id.startsWith('wm_') && column.id !== 'wm_custom') {
              const fieldName = column.id.substring(3);
              const field = conversationData.data.customFields?.find(
                (f: { name: string; value: string }) => f.name === fieldName,
              );
              return field?.value || '';
            }
            return null;
        }
      },
      [conversationData, actionData],
    );

    const handleClick = useCallback(() => {
      onClick?.(conversationId);
    }, [onClick, conversationId]);

    // Handler for column cells - select conversation but stop propagation to prevent double-click
    const handleCellClick = useCallback(
      (e: React.MouseEvent) => {
        e.stopPropagation();
        onClick?.(conversationId);
      },
      [onClick, conversationId],
    );

    const handleOpenConversation = useCallback(
      (e: React.MouseEvent) => {
        e.stopPropagation();
        // First select the conversation
        onClick?.(conversationId);
        // Then open it
        onOpenConversation?.(conversationId);
      },
      [onClick, onOpenConversation, conversationId],
    );

    // Double-clicking anywhere on the row opens the conversation (single click selects).
    const handleDoubleClick = useCallback(() => {
      onClick?.(conversationId);
      onOpenConversation?.(conversationId);
    }, [onClick, onOpenConversation, conversationId]);

    const handleMouseEnter = useCallback(() => {
      setIsHovered(true);
      onMouseEnter?.(conversationId);
    }, [onMouseEnter, conversationId]);

    const handleMouseLeave = useCallback(() => {
      setIsHovered(false);
    }, []);

    const handleCheckboxChange = useCallback(
      (e: React.MouseEvent) => {
        e.stopPropagation();
        if (e.shiftKey) {
          // Shift+click: use bulk selection handler for range selection
          onClick?.(conversationId);
        } else {
          // Regular click: toggle this item AND set anchor for future range selection
          onToggleWithAnchor?.(conversationId);
        }
      },
      [conversationId, onClick, onToggleWithAnchor],
    );

    // Handler for add task action
    // const handleAddTask = useCallback(
    //   (e: React.MouseEvent) => {
    //     e.stopPropagation();
    //     console.log('Add task clicked for conversation:', conversationId);
    //   },
    //   [conversationId],
    // );

    // Handlers for specific field updates
    const handleStatusSelect = useCallback(
      async (value: string) => {
        await optimisticUpdateConversation(conversationId, { status: value || null });
      },
      [conversationId, optimisticUpdateConversation],
    );

    const handlePrioritySelect = useCallback(
      async (value: string) => {
        await optimisticUpdateConversation(conversationId, { priority: value });
      },
      [conversationId, optimisticUpdateConversation],
    );

    const handleTextBlur = useCallback(
      async (columnId: string, value: string) => {
        if (columnId.startsWith('wm_') && columnId !== 'wm_custom') {
          const fieldName = columnId.substring(3);
          await optimisticUpdateCustomField(conversationId, fieldName, value);
        } else if (columnId === 'nextSteps') {
          await optimisticUpdateConversation(conversationId, { nextSteps: value });
        } else if (columnId === 'statusOverview') {
          await optimisticUpdateConversation(conversationId, { statusOverview: value });
        }
      },
      [conversationId, optimisticUpdateConversation, optimisticUpdateCustomField],
    );

    const handleNumberBlur = useCallback(
      async (value: string) => {
        const parsed = parseFloat(value);
        if (!isNaN(parsed)) {
          await optimisticUpdateConversation(conversationId, { dealValue: parsed });
        }
      },
      [conversationId, optimisticUpdateConversation],
    );

    const handleNextStepDateChange = useCallback(
      async (date: Date | null) => {
        await optimisticUpdateConversation(conversationId, {
          nextStepDate: date,
        });
      },
      [conversationId, optimisticUpdateConversation],
    );

    // Handler for activity overview event clicks - opens EventDetailDialog
    const handleEventClick = useCallback((event: { id: string }) => {
      setSelectedEventId(event.id);
    }, []);

    const handleCloseEventDialog = useCallback(() => {
      setSelectedEventId(null);
    }, []);

    // Fetches only once a dot is actually clicked, and shares the cache entry with the
    // detail panel's own getConversation, so opening the deal afterwards is already warm.
    const selectedEventQuery = useQuery(
      trpc.crm.getConversation.queryOptions(
        { id: conversationId },
        { enabled: !!selectedEventId, staleTime: 2 * 60 * 1000 },
      ),
    );
    const selectedEvent = useMemo(() => {
      if (!selectedEventId) return null;
      return (
        selectedEventQuery.data?.conversation.events.find((e) => e.id === selectedEventId) ?? null
      );
    }, [selectedEventId, selectedEventQuery.data]);

    const handleToggleImportant = useCallback(
      async (e: React.MouseEvent) => {
        e.stopPropagation();
        const current = conversationData?.data.conversation.important ?? false;
        await optimisticUpdateConversation(conversationId, { important: !current });
      },
      [conversationData, conversationId, optimisticUpdateConversation],
    );

    if (!conversationData) return null;

    const company = conversationData.data.company;
    const companyName = company?.name || 'No company';
    const conversationName = conversationData.data.conversation.name || companyName;
    const displayName = showConversationName ? conversationName : companyName;

    const isImportant = conversationData.data.conversation.important ?? false;
    const companyLogoUrl = company?.logoUrl;
    const companyInitial = companyName?.[0]?.toUpperCase() || '?';

    return (
      <motion.div
        className={cn(
          'group relative select-none border-b',
          isExpanded ? 'border-border' : 'border-border/30',
          'hover:bg-muted/50',
          (isSelected || isBulkSelected) && 'bg-primary/5',
        )}
        onClick={handleClick}
        onDoubleClick={handleDoubleClick}
        onMouseEnter={handleMouseEnter}
        onMouseLeave={handleMouseLeave}
      >
        {/* <ConversationHoverActions isFirst={isFirst} onAddTask={handleAddTask} /> */}

        <div
          data-conversation-id={conversationId}
          className={cn(
            'relative flex cursor-pointer items-center gap-3 px-3 text-left',
            isExpanded && 'w-fit min-w-full',
          )}
        >
          {/* Company Section (Fixed Width) - Click to open conversation */}
          <div
            className={cn(
              'flex shrink-0 cursor-pointer items-center justify-between gap-2 py-2.5',
              isExpanded && 'self-stretch border-r border-border dark:border-border/80 pr-3',
            )}
            style={{ width: effectiveCompanyWidth }}
            onClick={handleOpenConversation}
          >
            {/* Left: Avatar + Name */}
            <div className="flex min-w-0 items-center gap-2 hover:underline">
              {/* Avatar or Checkbox */}
              <div
                className="relative flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center"
                onClick={handleCheckboxChange}
              >
                {isHovered || isBulkSelected ? (
                  <>
                    <div className="hover:bg-primary/20 absolute left-1/2 top-1/2 z-10 h-8 w-8 -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors" />
                    <Checkbox checked={isBulkSelected} className="relative h-4 w-4" />
                  </>
                ) : (
                  <Avatar className="h-6 w-6">
                    <AvatarImage src={companyLogoUrl || undefined} className="object-contain" />
                    <AvatarFallback className="rounded-full bg-primary/10 text-xs font-semibold text-primary">
                      {companyInitial}
                    </AvatarFallback>
                  </Avatar>
                )}
              </div>

              {/* Company or Conversation Name */}
              <span className="truncate text-sm font-semibold">{displayName}</span>
            </div>

            {/* Star button — shown on hover or when starred */}
            {(isHovered || isImportant) && (
              <button
                className="shrink-0 rounded p-0.5 transition-colors hover:bg-muted"
                onClick={handleToggleImportant}
              >
                <Star
                  className={cn(
                    'h-3.5 w-3.5 transition-colors',
                    isImportant
                      ? 'fill-amber-400 text-amber-400'
                      : 'fill-none text-muted-foreground/50',
                  )}
                />
              </button>
            )}
          </div>

          {/* Columns Section (Flexible) - All editable with inline styling */}
          <div className={cn('flex items-center gap-3', isExpanded ? 'shrink-0' : 'min-w-0 flex-1')}>
            {displayColumns.slice(1).map((column) => {
              const value = getColumnValue(column);

              // Determine if column should grow or be fixed width
              const shouldGrow =
                column.type === 'text' ||
                column.type === 'current-action' ||
                column.type === 'activity-overview';

              // Skip company-combined as it's rendered in the fixed section
              if (column.type === 'company-combined') {
                return null;
              }

              const customWidth = columnConfiguration?.[column.id]?.width;
              const hasCustomWidth = customWidth != null;

              const widthPx = isExpanded
                ? (customWidth ?? getExpandedColumnWidthPx(column.type, column.id))
                : hasCustomWidth
                  ? customWidth
                  : shouldGrow
                    ? undefined
                    : getDefaultColumnWidthPx(column.type, column.id);

              return (
                <div
                  key=[redacted]
                  className={cn(
                    'flex items-center gap-1.5 py-2',
                    column.type === 'score' && 'justify-center',
                    isExpanded
                      ? cn('shrink-0', getExpandedColumnWidth(column.type, column.id), 'self-stretch border-r border-border dark:border-border/80 pr-3 last:border-r-0')
                      : (shouldGrow && !hasCustomWidth ? 'min-w-0 flex-1' : 'shrink-0'),
                  )}
                  style={widthPx != null ? { width: widthPx } : undefined}
                  onClick={handleCellClick}
                >
                  {/* Render column based on type */}
                  {column.type === 'status-badge' ? (
                    <StatusBadgeEditor
                      value={value as string | null}
                      options={column.enumOptions || []}
                      onSelect={handleStatusSelect}
                      columnId="status"
                      placeholder="Status..."
                    />
                  ) : column.type === 'activity-overview' ? (
                    <div className="min-w-[180px] flex-1 -my-2.5 -mt-3.5">
                      <ConversationActivityOverview
                        events={(value as ConversationEvent[]) || []}
                        timeRange={activityTimeRange}
                        onEventClick={handleEventClick}
                      />
                    </div>
                  ) : column.type === 'select' && column.id === 'priority' ? (
                    <StatusBadgeEditor
                      value={value as string}
                      options={column.enumOptions || []}
                      onSelect={handlePrioritySelect}
                      columnId="priority"
                    />
                  ) : column.type === 'select' ? (
                    <SelectEditor
                      value={value as string}
                      options={column.enumOptions || []}
                      onSelect={(v) => handleTextBlur(column.id, v)}
                    />
                  ) : column.type === 'date' && column.id === 'nextStepDate' ? (
                    <DatePickerWithNaturalInput
                      value={value instanceof Date ? value : value ? new Date(String(value)) : null}
                      onChange={handleNextStepDateChange}
                      closeOnSelect={true}
                      trigger={
                        <div className="cursor-pointer" onClick={(e) => e.stopPropagation()}>
                          {value ? (
                            <RelativeDateBadge
                              date={value instanceof Date ? value.toISOString() : String(value)}
                              colorType="scheduled"
                              className="px-2 py-0.5 text-xs"
                            />
                          ) : (
                            <span className="text-muted-foreground text-xs">No date</span>
                          )}
                        </div>
                      }
                    />
                  ) : column.type === 'date' ? (
                    value ? (
                      <RelativeDateBadge
                        date={value instanceof Date ? value.toISOString() : String(value)}
                        colorType="history"
                        className="px-2 py-0.5 text-xs"
                      />
                    ) : null
                  ) : column.type === 'text' ? (
                    <TextEditor
                      value={value as string}
                      onBlur={(v) => handleTextBlur(column.id, v)}
                      placeholder={`Add ${column.name?.toLowerCase()}...`}
                      multiline={isSelected}
                      className={cn(
                        'min-w-0 flex-1',
                        // A selected row expands its text fields (next steps, custom fields, …) to
                        // up to 5 wrapped lines — even in the compact (non-expanded) list.
                        isSelected
                          ? 'whitespace-pre-wrap line-clamp-5'
                          : isExpanded
                            ? 'whitespace-pre-wrap line-clamp-3'
                            : 'truncate line-clamp-1',
                      )}
                    />
                  ) : column.type === 'number' || column.type === 'currency' ? (
                    <NumberEditor
                      value={value as string | number}
                      onBlur={handleNumberBlur}
                      type={column.type === 'currency' ? 'currency' : 'number'}
                    />
                  ) : column.type === 'score' ? (
                    value != null && typeof value === 'object' && 'signal' in value ? (
                      <SignalDot
                        signal={(value as { signal: FieldSignal; reasoning: string | null }).signal}
                        reasoning={(value as { signal: FieldSignal; reasoning: string | null }).reasoning}
                      />
                    ) : null
                  ) : column.type === 'current-action' ? (
                    <CurrentActionDisplay
                      value={value as CurrentActionData}
                      className="min-w-0 flex-1"
                      isSelected={isSelected}
                      onOpenConversation={() => {
                        onClick?.(conversationId);
                        onOpenConversation?.(conversationId);
                      }}
                    />
                  ) : column.type === 'user' ? (
                    value && typeof value === 'object' && 'id' in value ? (
                      <div className="flex items-center gap-1.5 min-w-0">
                        <Avatar className="h-5 w-5 shrink-0">
                          <AvatarImage
                            src={(value as { image: string | null }).image ?? undefined}
                            className="object-contain"
                          />
                          <AvatarFallback className="bg-primary/10 text-primary text-[10px] font-semibold">
                            {((value as { name: string }).name ?? '?')[0]?.toUpperCase()}
                          </AvatarFallback>
                        </Avatar>
                        <span className="truncate text-xs text-muted-foreground">
                          {(value as { name: string }).name}
                        </span>
                      </div>
                    ) : null
                  ) : column.type === 'crm-synced' ? (
                    (value as CrmSyncedValue | null)?.isSynced ? (
                      <Badge
                        variant="secondary"
                        className="justify-center gap-1 rounded-full bg-sunken px-1.5 py-0.5 text-xs font-normal text-green-600 dark:text-green-400"
                      >
                        <Link2 className="h-3 w-3 shrink-0" />
                        <span className="truncate">
                          {(value as CrmSyncedValue).provider
                            ? (value as CrmSyncedValue).provider!.charAt(0).toUpperCase() +
                              (value as CrmSyncedValue).provider!.slice(1)
                            : 'Synced'}
                        </span>
                      </Badge>
                    ) : (
                      <Badge
                        variant="secondary"
                        className="justify-center gap-1 rounded-full bg-sunken px-1.5 py-0.5 text-xs font-normal text-muted-foreground"
                      >
                        <Link2Off className="h-3 w-3 shrink-0" />
                        <span className="truncate">Not Synced</span>
                      </Badge>
                    )
                  ) : null}
                </div>
              );
            })}
          </div>
        </div>

        {/* Processing State Display - only shown in agent canvas context */}
        <AnimatePresence>
          {showProcessingState && processingState.type !== 'idle' && (
            <motion.div
              initial={{ height: 0, opacity: 0 }}
              animate={{ height: 'auto', opacity: 1 }}
              exit={{ height: 0, opacity: 0 }}
              transition={{ duration: 0.2, ease: 'easeOut' }}
              className="overflow-hidden border-t border-border/30"
            >
              <ProcessingStateDisplay
                state={processingState}
                conversationId={conversationId}
                onClickReady={() => {
                  // Open draft review modal for this specific conversation on this canvas
                  useCedarStore.getState().openDraftReview(canvasId, conversationId);
                }}
                onOpenDraft={(e) => {
                  e.stopPropagation();
                  // Open review mode at this specific draft
                  onOpenReviewMode?.(conversationId);
                }}
                onDiscard={() => {
                  // Delete the entry entirely
                  useCedarStore.getState().clearConversationEntry(canvasId, conversationId);
                }}
                onDraftChange={(subject, body) => {
                  // Update draft content
                  useCedarStore
                    .getState()
                    .updateConversationDraft(canvasId, conversationId, { subject, body });
                }}
                onOpenAnalysis={() => {
                  // Open analysis review modal at this conversation on this canvas
                  useCedarStore.getState().openAnalysisReview(canvasId, conversationId);
                }}
              />
            </motion.div>
          )}
        </AnimatePresence>

        {/* Event Detail Dialog for activity overview clicks */}
        <EventDetailDialog
          event={selectedEvent}
          open={!!selectedEventId}
          loading={selectedEventQuery.isLoading}
          onClose={handleCloseEventDialog}
        />
      </motion.div>
    );
  },
  (prev, next) => {
    return (
      prev.conversationId === next.conversationId &&
      prev.canvasId === next.canvasId &&
      prev.isFirst === next.isFirst &&
      Object.is(prev.onClick, next.onClick) &&
      Object.is(prev.onToggleWithAnchor, next.onToggleWithAnchor) &&
      Object.is(prev.onOpenConversation, next.onOpenConversation)
    );
  },
);