ConversationCanvas.tsx18.6 KBView on GitHub /**
* ConversationCanvas Component
*
* A flexible view for displaying CRM conversations with:
* - Search bar and filter/sort popover at the top
* - List-based layout similar to mail-list but using CRM data
*
* Phase B/D: This component now subscribes directly to canvas.viewConfig
* instead of relying on the seeding useEffect in ConversationCanvasView.
* All column state (layout, sort, filter, AOP selection) is sourced from
* canvas.viewConfig + AopSlice via useCanvasConfiguration — no CRMSlice reads.
*/
import { FilterSortPopover, CanvasFilterSearchSection } from './FilterSortConfigurationRow';
import { CanvasMetricsBar } from '@/modules/crm/components/conversation-canvas/CanvasMetricsBar';
import type { Canvas, ConversationViewConfig } from '@/modules/canvas/types/canvas-types';
import { useCallback, useRef, useState, useEffect, startTransition } from 'react';
import { useCrmCanvasRefresh } from '@/modules/crm/hooks/use-crm-canvas-refresh';
import { useCanvasConversations } from '../../hooks/use-canvas-conversations';
import { ConversationListHotkeys } from '../conversation-list-hotkeys';
import { useIsFetching, useIsMutating } from '@tanstack/react-query';
import type { ColumnFilter, ColumnSort } from '../../store/crmSlice';
import { ColumnConfigurationItem } from './ColumnConfigurationItem';
import { useConversationNavigation } from '@/modules/conversations';
import { useBulkSelection } from '../../hooks/use-bulk-selection';
import { AnalysisReviewPill } from './SexyAnalysisReviewPill';
import { PresetHotkeysLayer } from './PresetNavigationBar';
import { AnalysisReviewMode } from './AnalysisReviewMode';
import { ConversationItem } from './ConversationItem';
import { CanvasReviewMode } from './CanvasReviewMode';
import { CRMSearchInput } from '../crm-search-input';
import { useShallow } from 'zustand/react/shallow';
import { VList, type VListHandle } from 'virtua';
import { Button } from '@/components/ui/button';
import { useHotkeys } from 'react-hotkeys-hook';
import { useCedarStore } from '@/modules/store';
import { Loader2 } from 'lucide-react';
import { motion } from 'motion/react';
import { cn } from '@/lib/utils';
/**
* Initial filter/sort configuration per column
*/
export interface InitialFilterSortConfiguration {
[columnId: string]: {
sort?: ColumnSort;
filter?: ColumnFilter;
};
}
/**
* Initial column configuration (visibility, width, order)
*/
export interface InitialColumnConfiguration {
[columnId: string]: {
width?: number;
order?: number;
visible?: boolean;
};
}
// Stable empty references — prevent new object/array allocations in selectors from
// triggering re-renders when there is genuinely no data.
const EMPTY_STRING_ARRAY: string[] = [];
export interface ConversationCanvasProps {
title?: string;
/** Description for the canvas (reserved for future use) */
description?: string;
/**
* Canvas entity — used to subscribe to viewConfig directly (Phase B).
* When provided, display config (selectedAopIds, presetId, filterSortConfig)
* comes from canvas.viewConfig instead of the global CRMSlice.
*/
canvas?: Canvas;
/**
* Initial column configuration (visibility, width, order).
* Applied once on mount. Used by the agent canvas for one-time overrides.
*/
initialColumnConfiguration?: InitialColumnConfiguration;
/**
* Pin the canvas to a specific set of conversation IDs.
* When provided, only these conversations are shown (client-side filtered).
* Used by the Finding Conversations tool call to surface search results in canvas.
*/
initialConversationIds?: string[];
}
// Re-export applyDynamicDates from the utility module for backward compat
export { applyDynamicDates } from '../../utils/compute-canvas-filters';
export function ConversationCanvas({
canvas,
initialColumnConfiguration,
initialConversationIds,
}: ConversationCanvasProps) {
// Track if this is the first load for animation purposes
const [isFirstLoad, setIsFirstLoad] = useState(true);
// Use this canvas's own ID for all per-canvas state reads.
// Using canvas?.id from the prop (not state.activeCanvasId) ensures that two
// simultaneously-visible canvases each read from their own data.
const thisCanvasId = canvas?.id ?? '';
const setCanvasPresetMode = useCedarStore((s) => s.setCanvasPresetMode);
// 'c' — show the preset picker in the command bar. The column header configures itself
// through per-column popovers now, so there is no whole-header open state to drive.
useHotkeys('c', () => setCanvasPresetMode({ canvasId: thisCanvasId }), { preventDefault: true }, [
setCanvasPresetMode,
thisCanvasId,
]);
// 'f' — show preset picker in command bar (no popover, so number keys work immediately)
useHotkeys('f', () => setCanvasPresetMode({ canvasId: thisCanvasId }), { preventDefault: true }, [
setCanvasPresetMode,
thisCanvasId,
]);
// Review mode state
const [isReviewMode, setIsReviewMode] = useState(false);
const [reviewModeInitialConversationId, setReviewModeInitialConversationId] = useState<
string | undefined
>(undefined);
// Compute pending draft IDs directly from state (useShallow prevents re-renders when array is equal)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _pendingDraftIds = useCedarStore(
useShallow((state) => {
if (!thisCanvasId) return EMPTY_STRING_ARRAY;
const entries = state.canvasesById[thisCanvasId]?.data?.conversationEntries;
if (!entries) return EMPTY_STRING_ARRAY;
return Object.entries(entries)
.filter(([, e]) => e.pendingDraft?.reviewStatus === 'pending')
.map(([id]) => id);
}),
);
// Analysis review modal open state — scoped to this canvas
const analysisReviewIsOpen = useCedarStore((state) =>
thisCanvasId ? (state.canvasUIState[thisCanvasId]?.analysisReview?.isOpen ?? false) : false,
);
// Draft review modal open state — scoped to this canvas (can be triggered from chat panel)
const draftReviewIsOpen = useCedarStore((state) =>
thisCanvasId ? (state.canvasUIState[thisCanvasId]?.draftReview?.isOpen ?? false) : false,
);
// Open review mode when triggered from external sources (e.g. chat panel "Review" button)
useEffect(() => {
if (draftReviewIsOpen) {
setIsReviewMode(true);
}
}, [draftReviewIsOpen]);
// Count completed (non-streaming) analyses — scoped to this canvas
const completedAnalysesCount = useCedarStore((state) => {
if (!thisCanvasId) return 0;
const entries = state.canvasesById[thisCanvasId]?.data?.conversationEntries;
if (!entries) return 0;
return Object.values(entries).filter((e) => e.pendingAnalysis && !e.pendingAnalysis.isStreaming)
.length;
});
const closeAnalysisReview = useCedarStore((state) => state.closeAnalysisReview);
const openAnalysisReview = useCedarStore((state) => state.openAnalysisReview);
const clearAnalyses = useCedarStore((state) => state.clearCanvasAnalyses);
const closeDraftReview = useCedarStore((state) => state.closeDraftReview);
// Apply initial column configuration (agent canvas override only).
// Writes to canvas viewConfig instead of global CRMSlice so each canvas is independent.
const updateCanvasViewConfig = useCedarStore((state) => state.updateCanvasViewConfig);
const saveCanvasViewConfig = useCedarStore((state) => state.saveCanvasViewConfig);
const [initialColumnConfigApplied, setInitialColumnConfigApplied] = useState(false);
useEffect(() => {
if (!initialColumnConfiguration || initialColumnConfigApplied || !canvas?.id) return;
const columnConfiguration: Record<
string,
{ width?: number; order?: number; visible?: boolean }
> = {};
Object.entries(initialColumnConfiguration).forEach(([columnId, config]) => {
columnConfiguration[columnId] = {
width: config.width,
order: config.order,
visible: config.visible,
};
});
const currentViewConfig = (useCedarStore.getState().canvasesById[canvas.id]?.viewConfig ??
{}) as ConversationViewConfig;
updateCanvasViewConfig(canvas.id, {
...currentViewConfig,
columnConfiguration,
presetId: undefined, // Clear preset — agent is overriding column layout
});
void saveCanvasViewConfig(canvas.id);
setInitialColumnConfigApplied(true);
}, [
canvas?.id,
initialColumnConfiguration,
initialColumnConfigApplied,
updateCanvasViewConfig,
saveCanvasViewConfig,
]);
// Fetch conversations via canvas-scoped hook.
// useCanvasConversations reads viewConfig + AOP data from useCanvasConfiguration
// internally — no manual override construction needed.
const [conversationsQuery, hasNextPage, loadMore] = useCanvasConversations(
canvas?.id,
initialConversationIds,
);
// Get list ordering from conversationsSlice
const conversationList = useCedarStore((state) => state.getCurrentConversationList());
// Mark first load as complete after conversations are loaded
// Use double requestAnimationFrame to ensure VList has finished rendering all visible items
useEffect(() => {
if (conversationList.length > 0 && isFirstLoad) {
// Double RAF ensures VList has calculated and rendered all visible items
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setIsFirstLoad(false);
});
});
}
}, [conversationList.length, isFirstLoad]);
// Get selection from conversationsSlice (bulk operations — moved from crmSlice in Phase B)
const selectedIds = useCedarStore((state) => state.conversationSelection);
const clearConversationSelection = useCedarStore((state) => state.clearConversationSelection);
const toggleConversationSelection = useCedarStore((state) => state.toggleConversationSelection);
const setConversationSelection = useCedarStore((state) => state.setConversationSelection);
const selectionAnchorId = useCedarStore((state) => state.selectionAnchorId);
const setSelectionAnchorId = useCedarStore((state) => state.setSelectionAnchorId);
// Bulk selection hook
const { getSelectMode, handleClickWithSelection, toggleAndSetAnchor } = useBulkSelection({
items: conversationList,
selectedIds,
setSelection: setConversationSelection,
toggleSelection: toggleConversationSelection,
clearSelection: clearConversationSelection,
anchorId: selectionAnchorId,
setAnchorId: setSelectionAnchorId,
});
// Loading state for refresh indicator
const isFetching = useIsFetching();
const isMutating = useIsMutating();
const conversationIds = conversationList.map((c) => c.id);
const { handleRefresh: handleCrmCanvasRefresh, isPending: isCrmRefreshPending } =
useCrmCanvasRefresh(conversationIds, conversationsQuery.refetch);
const isLoading = isFetching > 0 || isMutating > 0 || isCrmRefreshPending;
// Refs for virtualization and navigation
const parentRef = useRef<HTMLDivElement>(null);
const vListRef = useRef<VListHandle>(null);
// Enable keyboard navigation (j/k, arrow keys, Enter, Escape)
const { handleMouseEnter } = useConversationNavigation({
items: conversationList,
containerRef: parentRef,
});
const handleItemClick = useCallback(
(conversationId: string) => {
handleClickWithSelection(conversationId, () => {
// Simple selection - just highlight the row
useCedarStore.getState().setActiveConversationId(conversationId);
});
},
[handleClickWithSelection],
);
const handleOpenConversation = useCallback((conversationId: string) => {
// Open the conversation view (like clicking company name)
const state = useCedarStore.getState();
state.setActiveConversationId(conversationId);
state.setIsConversationOpen(true);
}, []);
const handleOpenReviewMode = useCallback((conversationId: string) => {
// Open review mode at a specific draft
startTransition(() => {
setReviewModeInitialConversationId(conversationId);
setIsReviewMode(true);
});
}, []);
const handleItemMouseEnter = useCallback(
(conversationId: string) => {
handleMouseEnter(conversationId);
// Dispatch custom event for hotkeys to track hovered conversation
window.dispatchEvent(
new CustomEvent('conversationHover', { detail: { id: conversationId } }),
);
},
[handleMouseEnter],
);
// VList renderer with animation support
const vListRenderer = useCallback(
(index: number) => {
const conversation = conversationList[index];
if (!conversation) return <></>;
return (
<motion.div
key=[redacted]
initial={isFirstLoad ? { opacity: 0 } : false}
animate={isFirstLoad ? { opacity: 1 } : false}
transition={
isFirstLoad
? {
delay: index * 0.01 + 0.1, // Stagger delay
ease: 'easeOut',
}
: undefined
}
>
<ConversationItem
conversationId={conversation.id}
canvasId={thisCanvasId}
isFirst={index === 0}
onClick={handleItemClick}
onToggleWithAnchor={toggleAndSetAnchor}
onMouseEnter={handleItemMouseEnter}
onOpenConversation={handleOpenConversation}
onOpenReviewMode={handleOpenReviewMode}
/>
</motion.div>
);
},
[
conversationList,
isFirstLoad,
thisCanvasId,
handleItemClick,
toggleAndSetAnchor,
handleItemMouseEnter,
handleOpenConversation,
handleOpenReviewMode,
],
);
// If in review mode, render CanvasReviewMode instead
if (isReviewMode) {
return (
<div className="flex h-full flex-col">
<CanvasReviewMode
onExit={() => {
setIsReviewMode(false);
setReviewModeInitialConversationId(undefined);
if (thisCanvasId) closeDraftReview(thisCanvasId);
}}
initialConversationId={reviewModeInitialConversationId}
/>
</div>
);
}
return (
<div className="relative flex h-full flex-col">
{/* Analysis review mode - covers entire canvas including X button via absolute z-20 */}
{analysisReviewIsOpen && thisCanvasId && (
<div className="absolute inset-0 z-20 bg-background">
<AnalysisReviewMode onExit={() => closeAnalysisReview(thisCanvasId)} />
</div>
)}
{/* Hotkey scope management for CRM - uses conversation-list scope */}
<ConversationListHotkeys />
<PresetHotkeysLayer canvasId={canvas?.id} />
<div className="flex h-full flex-col">
{/* Search + Filter Container (collapsible AI search) */}
{canvas?.id && (
<div className="shrink-0">
<CanvasFilterSearchSection
canvas={canvas}
onRefresh={() => void handleCrmCanvasRefresh()}
isRefreshing={isLoading}
/>
</div>
)}
{/* Fallback search row when no canvas */}
{!canvas?.id && (
<div className="flex shrink-0 items-center gap-2 px-2 pb-1 pt-2">
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<CRMSearchInput />
</div>
<FilterSortPopover canvasId={canvas?.id} />
{selectedIds.length > 0 && (
<Button variant="ghost" size="sm" onClick={clearConversationSelection}>
Clear Selection ({selectedIds.length})
</Button>
)}
</div>
)}
{/* Roll-up tiles. Renders nothing unless the canvas configures `summaries`, and sits
above the column header to match where HubSpot puts the same numbers. */}
{canvas?.id && (
<CanvasMetricsBar canvasId={canvas.id} conversationIds={initialConversationIds} />
)}
{/* Conversation List Area */}
<div
ref={parentRef}
className={cn(
'hide-link-indicator relative flex h-full w-full flex-1 flex-col overflow-hidden',
getSelectMode() === 'range' && 'select-none',
)}
>
{/* Pill-shaped analysis review notification */}
<AnalysisReviewPill
pendingCount={completedAnalysesCount}
onOpenReview={() => thisCanvasId && openAnalysisReview(thisCanvasId)}
onClear={() => thisCanvasId && clearAnalyses(thisCanvasId)}
/>
{conversationsQuery.isLoading ? (
<div className="flex h-32 w-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : conversationList.length === 0 ? (
<div className="flex w-full flex-1 items-center justify-center">
<div className="flex flex-col items-center justify-center gap-2 text-center">
<p className="text-lg">No conversations found</p>
<p className="text-muted-foreground text-md">
Try adjusting your filters or search criteria
</p>
</div>
</div>
) : (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex flex-1 flex-col overflow-x-auto overflow-y-hidden">
<div className="flex min-w-fit flex-1 flex-col">
{/* Column Configuration Header — scrolls horizontally with items */}
<ColumnConfigurationItem canvasId={canvas?.id} />
<VList
ref={vListRef}
count={conversationList.length}
overscan={15}
itemSize={43}
className="scrollbar-none flex-1 overflow-x-hidden"
onScroll={() => {
if (!vListRef.current) return;
const endIndex = vListRef.current.findEndIndex();
const distanceFromEnd = Math.abs(conversationList.length - 1 - endIndex);
if (
// Load more when near the end
distanceFromEnd < 7 &&
!conversationsQuery.isLoading &&
!conversationsQuery.isFetchingNextPage &&
hasNextPage
) {
void loadMore();
}
}}
>
{vListRenderer}
</VList>
{/* Loading indicator at bottom */}
{conversationsQuery.isFetchingNextPage && (
<div className="flex w-full justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}