CardListCanvasView.tsx8.6 KBView on GitHub
/**
 * CardListCanvasView
 *
 * Renders a `cardList` canvas: a set of conversations shown as stacked cards in
 * a single column (instead of table rows).
 *
 * Hydration reuses useCanvasConversations — the same hook the table / kanban /
 * conversation canvases use — which already handles both the pinned
 * (viewConfig.pinnedConversationIds) and the filtered+sorted paths. We render from the
 * ordered list it returns, so the on-screen order matches the fetch/sort order exactly
 * without this view claiming the app-wide `currentConversationList`.
 */

import type { Canvas, CardListViewConfig } from '@/modules/canvas/types/canvas-types';
import { useCanvasConversations } from '@/modules/crm/hooks/use-canvas-conversations';
import { useCanvasConfiguration } from '@/modules/crm/hooks/use-canvas-configuration';
import type { HydratedConversation } from '@/modules/crm/types';
import { ConversationCard } from './ConversationCard';
import { useCedarStore } from '@/modules/store';
import { CONVERSATION_COLUMN } from '@/modules/conversations/components/ConversationScrollArea';
import { FilterSortPopoverContent } from '@/modules/crm/components/conversation-canvas/FilterSortConfigurationRow';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Filter, Loader2, Search, X } from 'lucide-react';
import { useMemo, useState } from 'react';
import { cn } from '@/lib/utils';

interface CardListCanvasViewProps {
  canvas: Canvas;
  /** When set, a close button appears in the title row (used when opened as an artifact). */
  onClose?: () => void;
  /**
   * Render inline inside a page that already scrolls (the /agent home hero) instead of as a
   * self-scrolling panel: no inner scroller, and the list fills its container rather than
   * being capped at the reading column.
   */
  embedded?: boolean;
}

/**
 * Open the global conversation search — the SAME menu Cmd+K opens. That command
 * bar (ConversationSearchCommandBar, mounted once in the root layout) has no store
 * open-action; it toggles its own local `open` off a capture-phase Cmd+K window
 * listener. So we replay that exact keypress rather than fork an open path.
 */
function openConversationSearch() {
  window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey=[redacted], bubbles: true }));
}

export function CardListCanvasView({ canvas, onClose, embedded = false }: CardListCanvasViewProps) {
  const viewConfig = canvas.viewConfig as CardListViewConfig;

  // Hydrate conversations (pinned or filtered+sorted). `syncCurrentList: false` keeps this
  // view OUT of the global `currentConversationList`: that state is the list the user is
  // navigating (j/k) and the chat's "N Conversations" context, and a card list is neither —
  // it is a tile that displays deals. Claiming it here is what put the /agent home's Top
  // Deals rows into the app's shared selection state, where the first row leaked onward as a
  // brand-new chat's context. The ordered list comes back from the hook instead.
  const [conversationsQuery, , , conversationList] = useCanvasConversations(
    canvas.id,
    viewConfig.pinnedConversationIds,
    { fetchAllPages: true, syncCurrentList: false },
  );
  const conversationsById = useCedarStore((state) => state.conversations);
  const openConversationContext = useCedarStore((state) => state.openConversationContext);
  const setCardListPinnedIds = useCedarStore((state) => state.setCardListPinnedIds);

  // The canvas's resolved status enumOptions (colours/icons) — the SAME source the CRM
  // ConversationItem uses, so the stage badge colours match exactly.
  const { columns } = useCanvasConfiguration(canvas.id);
  const statusOptions = useMemo(
    () => columns.find((c) => c.id === 'status')?.enumOptions ?? [],
    [columns],
  );

  // Comprehensive multi-field filter — the SAME canvas-aware content the CRM canvas
  // uses (all Cedar fields, per-field select), opened from a round icon button.
  const [filterOpen, setFilterOpen] = useState(false);

  // Pinned mode: a curated manual list. In this mode each card gets a remove "×"
  // that drops its id from viewConfig.pinnedConversationIds (persisted via updateCanvas).
  const pinnedIds = viewConfig.pinnedConversationIds;
  const isPinnedMode = Boolean(pinnedIds && pinnedIds.length > 0);

  const handleRemove = (conversationId: string) => {
    const next = (pinnedIds ?? []).filter((id) => id !== conversationId);
    void setCardListPinnedIds(canvas.id, next);
  };

  const conversations = useMemo(() => {
    const out: HydratedConversation[] = [];
    for (const { id } of conversationList) {
      const entry = conversationsById[id];
      if (entry) out.push(entry.data);
    }
    return out;
  }, [conversationList, conversationsById]);

  const isLoading = conversationsQuery.isLoading;

  return (
    <div className={cn('w-full', !embedded && 'h-full overflow-y-auto')}>
      {/* Same centered content column as ConversationView (CONVERSATION_COLUMN,
          centered). Uniform gap-3; pt-2.5 so the title aligns with the chat title. Embedded
          in a host page (the home hero), the column and padding are the host's job. */}
      <div
        className={cn(
          'flex flex-col gap-3',
          embedded ? 'w-full' : cn(CONVERSATION_COLUMN, 'px-4 pb-4 pt-2.5'),
        )}
      >
        {/* Title row — title left, round Filter button (+ optional close) right. */}
        <div className="flex items-center justify-between gap-2 px-1">
          <h2 className="text-lg font-semibold text-foreground">
            {canvas.title || 'Deals that need attention'}
          </h2>
          <div className="flex shrink-0 items-center gap-2">
            <Popover open={filterOpen} onOpenChange={setFilterOpen}>
              <PopoverTrigger asChild>
                {/* Circle icon-only button. */}
                <button
                  type="button"
                  aria-label="Filter"
                  className="flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-background text-muted-foreground transition-colors hover:bg-accent"
                >
                  <Filter className="h-3 w-3" />
                </button>
              </PopoverTrigger>
              <PopoverContent className="w-auto p-3" align="end">
                {/* All-Cedar-field filter (status, priority, risk, dates, custom fields, …). */}
                <FilterSortPopoverContent canvasId={canvas.id} />
              </PopoverContent>
            </Popover>
            {onClose && (
              <button
                type="button"
                aria-label="Close"
                onClick={onClose}
                className="flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-background text-muted-foreground transition-colors hover:bg-accent"
              >
                <X className="h-3.5 w-3.5" />
              </button>
            )}
          </div>
        </div>

        {/* Search — opens the same menu as Cmd+K */}
        <button
          type="button"
          onClick={openConversationSearch}
          className={cn(
            'flex cursor-pointer items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-sm text-muted-foreground',
            // Opaque hover, for the same reason as ConversationCard's: a translucent one lets
            // the page behind show through the control.
            'transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
          )}
        >
          <Search className="h-4 w-4 shrink-0" />
          <span>Search conversations</span>
          <kbd className="ml-auto rounded border border-border bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
            ⌘K
          </kbd>
        </button>

        {isLoading && conversations.length === 0 ? (
          <div className="flex items-center justify-center py-10">
            <Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
          </div>
        ) : conversations.length === 0 ? (
          <div className="text-muted-foreground flex items-center justify-center py-10 text-sm">
            No conversations
          </div>
        ) : (
          conversations.map((conversation) => (
            <ConversationCard
              key=[redacted]
              conversation={conversation}
              statusOptions={statusOptions}
              onClick={() => openConversationContext(conversation.conversation.id)}
              onRemove={
                isPinnedMode ? () => handleRemove(conversation.conversation.id) : undefined
              }
            />
          ))
        )}
      </div>
    </div>
  );
}