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