ConversationInboxLayout.tsx10.0 KBView on GitHub /**
* ConversationInboxLayout — Conversation Inbox grouped by AOP.
*
* Data comes from useCRMConversations (same hook as the CRM table/kanban).
* Filtering/sorting is driven by FilterSortPopover — the exact same UI as
* the CRM canvas, sharing CRMSlice state.
*/
import { FilterSortPopover } from '@/modules/crm/components/conversation-canvas/FilterSortConfigurationRow';
import { useCRMConversations } from '@/modules/crm/hooks/use-crm-conversations';
import { ActiveViewDisplay } from '@/components/ui/active-view-display';
import { EmptyStateIcon } from '@/components/icons/empty-state-svg';
import { PencilCompose } from '@/components/icons/icons';
import type { HydratedConversation } from '@/modules/crm/types';
import { useEffect, useMemo, useRef } from 'react';
import { ConversationGroup } from './ConversationGroup';
import { useAOPs } from '@/modules/aop/hooks/use-aops';
import { useCedarStore } from '@/modules/store';
import { Building2 } from 'lucide-react';
import { cn } from '@/lib/utils';
export function ConversationInboxLayout() {
const openNewEmail = useCedarStore((state) => state.openNewEmail);
const isThreadOpen = useCedarStore((state) => state.isThreadOpen);
const isConversationOpen = useCedarStore((state) => state.isConversationOpen);
const isTaskOutputOpen = useCedarStore((state) => state.isTaskOutputOpen);
const isChannelChatOpen = useCedarStore((state) => state.isChannelChatOpen);
const newEmail = useCedarStore((state) => state.newEmail);
// Every artifact that can fill the main column, or the overlay never mounts and opening one
// looks like nothing happened. `?task=` is routed from the ROOT layout, so a task ticket and a
// Slack channel reach this route too — both used to be set here and never drawn.
const overlayActive =
isConversationOpen || isThreadOpen || isTaskOutputOpen || isChannelChatOpen || newEmail;
const { data: aopsData } = useAOPs();
// Seed a default "latest contact" sort on first mount if no sort is active.
// This restores the original "latest email first" behaviour.
const setColumnSort = useCedarStore((state) => state.setColumnSort);
const columns = useCedarStore((state) => state.columns);
useEffect(() => {
const hasActiveSort = Object.values(columns).some((c) => c.sort?.active);
if (!hasActiveSort) {
setColumnSort('lastContactedAt', { active: true, direction: 'desc', priority: 0 });
}
// Run once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Use the same CRM conversations hook as the CRM table — shares CRMSlice filter state
const [conversationsQuery, hasNextPage, loadMore] = useCRMConversations();
// Read ordered conversation list from Zustand (populated by useCRMConversations)
const currentList = useCedarStore((state) => state.getCurrentConversationList());
const conversationsStore = useCedarStore((state) => state.conversations);
const conversations = useMemo<HydratedConversation[]>(() => {
return currentList
.map((c) => conversationsStore[c.id]?.data)
.filter((d): d is HydratedConversation => !!d);
}, [currentList, conversationsStore]);
// Infinite-scroll sentinel
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!sentinelRef.current) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasNextPage && !conversationsQuery.isFetchingNextPage) {
void loadMore();
}
},
{ threshold: 0.1 },
);
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [hasNextPage, conversationsQuery.isFetchingNextPage, loadMore]);
// Default-expand groups with unread email or overdue next step
const defaultExpandedIds = useMemo(() => {
const set = new Set<string>();
for (const hc of conversations) {
const hasUnread = hc.conversation.events.some((e) => e.emailEvent?.isRead === false);
const isOverdue =
!!hc.conversation.nextStepDate && new Date(hc.conversation.nextStepDate) < new Date();
if (hasUnread || isOverdue) set.add(hc.conversation.id);
}
return set;
}, [conversations]);
// Group by AOP, preserving the sort order from CRMSlice
const aopSections = useMemo(() => {
const aops = aopsData?.aops ?? [];
const byAop = new Map<string | null, HydratedConversation[]>();
for (const hc of conversations) {
const key=[redacted] ?? null;
const bucket = byAop.get(key) ?? [];
bucket.push(hc);
byAop.set(key, bucket);
}
const sections: {
aopId: string | null;
aopName: string;
aopColor: string | null;
conversations: HydratedConversation[];
}[] = [];
for (const aop of aops) {
const bucket = byAop.get(aop.id);
if (bucket?.length) {
sections.push({
aopId: aop.id,
aopName: aop.name,
aopColor: aop.color ?? null,
conversations: bucket,
});
}
}
const unassigned = byAop.get(null);
if (unassigned?.length) {
sections.push({ aopId: null, aopName: 'Other', aopColor: null, conversations: unassigned });
}
return sections;
}, [conversations, aopsData?.aops]);
const isLoading = conversationsQuery.isLoading;
const isFetching = conversationsQuery.isFetching;
const isFetchingNextPage = conversationsQuery.isFetchingNextPage;
return (
<>
{/* URL⇄store sync is now a single root-level LayoutUrlSync (url-driven-layout Phase 3). */}
<div className="relative flex h-full w-full flex-col">
<div className="min-h-0 flex-1">
<div
className={cn(
'flex h-full w-full',
overlayActive && 'hidden',
)}
>
<div className="flex h-full w-full max-w-full flex-col">
{/* ── Header ─────────────────────────────────────────── */}
<div className="z-15 sticky top-0">
<div className="flex h-[52px] items-center gap-2 px-16">
<Building2 className="h-4 w-4 shrink-0 text-foreground" />
<span className="shrink-0 text-base font-semibold text-foreground">
Conversation Inbox
</span>
<div className="flex-1" />
{/* Filters & Sorting — exactly the same popover as the CRM canvas */}
<FilterSortPopover />
{/* Compose */}
<button
onClick={() => openNewEmail()}
className="mx-1 shrink-0 text-muted-foreground transition-colors hover:text-foreground"
>
<PencilCompose className="h-3.5 w-3.5 fill-muted-foreground" />
</button>
</div>
</div>
{/* Loading bar */}
<div
className={cn(
'bg-[#006FFE] h-0.5 w-full transition-opacity',
isFetching ? 'opacity-100' : 'opacity-0',
)}
/>
{/* ── AOP-grouped list ───────────────────────────────── */}
<div className="relative z-[1] flex h-full flex-col overflow-y-auto overflow-x-hidden pb-4">
{isLoading ? (
<div className="flex h-32 w-full items-center justify-center">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-neutral-900 border-t-transparent dark:border-white dark:border-t-transparent" />
</div>
) : conversations.length === 0 ? (
<div className="flex w-full items-center justify-center pt-16">
<div className="flex flex-col items-center gap-2 text-center">
<EmptyStateIcon width={200} height={200} />
<div className="mt-5">
<p className="text-lg">No conversations</p>
<p className="text-md text-muted-foreground dark:text-white/50">
Try adjusting your filters
</p>
</div>
</div>
</div>
) : (
<>
{aopSections.map((section) => (
<div key=[redacted] ?? '__other__'}>
{/* AOP section header */}
<div className="mx-12 mb-1 mt-4 flex items-center gap-2 first:mt-2">
{section.aopColor && (
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: section.aopColor }}
/>
)}
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{section.aopName}
</span>
</div>
{section.conversations.map((hc) => (
<ConversationGroup
key=[redacted]
hydratedConv={hc}
defaultExpanded={defaultExpandedIds.has(hc.conversation.id)}
/>
))}
</div>
))}
<div ref={sentinelRef} className="h-4" />
{isFetchingNextPage && (
<div className="flex w-full justify-center py-4">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-neutral-900 border-t-transparent dark:border-white dark:border-t-transparent" />
</div>
)}
</>
)}
</div>
</div>
</div>
{overlayActive && <ActiveViewDisplay />}
</div>
</div>
</>
);
}