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