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