Make conversation queues mutually exclusive

This commit is contained in:
Jamie Brunton committed 2026-08-14 09:25:45 +01:00
1 parent 3bb1dda6bb
commit 2447563c60
20 files changed
+386 -220

No files matched your search

+38 -52
View File
@@ -3,6 +3,7 @@ import type { ActiveChat } from '../packages/api-contracts/src/index.js';
import {
DEFAULT_CONVERSATION_FILTERS,
accountOptions,
conversationQueue,
filterCounts,
isConversationFilter,
isConversationSort,
@@ -91,7 +92,7 @@ describe('conversation triage', () => {
expect(manualFlowLabel(chat({ username: 'c', automationStatus: 'active', automationPolicyMode: 'manual' })))
.toBe('Manual by policy');
expect(manualFlowLabel(chat({ username: 'd', automationStatus: 'killed' })))
.toBe('Killed — seller');
.toBeNull();
expect(manualFlowLabel(chat({ username: 'e', automationStatus: 'active' }))).toBeNull();
});
});
@@ -101,44 +102,32 @@ describe('conversation triage', () => {
const replied = chat({ username: 'replied', isFromUs: true });
const snoozed = chat({ username: 'snoozed', triageStatus: 'snoozed' });
const done = chat({ username: 'done', triageStatus: 'done' });
const withImage = chat({ username: 'image', hasReceivedImages: true });
const deleted = chat({ username: 'deleted', recipientAccountStatus: 'deleted' });
const archived = chat({ username: 'archived', readOnly: true });
const seller = chat({ username: 'seller', sellerDetected: true });
it('excludes snoozed and resolved conversations from the default view', () => {
expect(matchesFilter(open, 'all')).toBe(true);
expect(matchesFilter(replied, 'all')).toBe(true);
expect(matchesFilter(snoozed, 'all')).toBe(false);
expect(matchesFilter(done, 'all')).toBe(false);
});
it('keeps snoozed and resolved conversations reachable through their own buckets', () => {
it('assigns every conversation to one workflow queue', () => {
expect(conversationQueue(open)).toBe('needs-reply');
expect(conversationQueue(replied)).toBe('awaiting-reply');
expect(matchesFilter(snoozed, 'snoozed')).toBe(true);
expect(matchesFilter(done, 'done')).toBe(true);
expect(matchesFilter(seller, 'sellers')).toBe(true);
expect(matchesFilter(archived, 'archived')).toBe(true);
});
it('moves deleted accounts out of actionable buckets into their own bucket', () => {
expect(matchesFilter(deleted, 'all')).toBe(false);
it('moves deleted and archived accounts out of actionable queues', () => {
expect(matchesFilter(deleted, 'needs-reply')).toBe(false);
expect(matchesFilter(deleted, 'account-deleted')).toBe(true);
expect(matchesFilter(archived, 'needs-reply')).toBe(false);
expect(matchesFilter(archived, 'archived')).toBe(true);
expect(needsReply(deleted)).toBe(false);
});
it('scopes unread and image buckets to open conversations', () => {
expect(matchesFilter(open, 'unread')).toBe(true);
expect(matchesFilter(chat({ username: 'x', hasUnreadInbound: true, triageStatus: 'done' }), 'unread')).toBe(false);
expect(matchesFilter(withImage, 'received-images')).toBe(true);
expect(matchesFilter(chat({ username: 'y', hasReceivedImages: true, triageStatus: 'done' }), 'received-images')).toBe(false);
});
it('collects conversations the operator is driving into the manual bucket', () => {
const manual = chat({ username: 'manual', automationStatus: 'user-overridden' });
expect(matchesFilter(manual, 'manual')).toBe(true);
expect(matchesFilter(open, 'manual')).toBe(false);
// Resolved work leaves the manual queue the same way it leaves the others.
expect(matchesFilter(
chat({ username: 'z', automationStatus: 'user-overridden', triageStatus: 'done' }),
'manual',
)).toBe(false);
it('gives seller and archive outcomes precedence over status and direction', () => {
expect(conversationQueue(chat({ username: 's', sellerDetected: true, isFromUs: true }))).toBe('sellers');
expect(conversationQueue(chat({ username: 'a', readOnly: true, sellerDetected: true }))).toBe('archived');
expect(conversationQueue(chat({ username: 'd', recipientAccountStatus: 'deleted', sellerDetected: true })))
.toBe('account-deleted');
});
it('matches search against username, snippet and loaded message text', () => {
@@ -169,33 +158,31 @@ describe('conversation triage', () => {
it('puts the longest-waiting unanswered conversation first when sorting by oldest unanswered', () => {
const result = partitionConversations([
chat({ username: 'recent-inbound', lastMessageTime: 300 }),
chat({ username: 'replied-old', lastMessageTime: 50, isFromUs: true }),
chat({ username: 'stale-inbound', lastMessageTime: 100 }),
], filters({ sort: 'oldest-unanswered' }));
// Unanswered first, oldest of those leading; everything else newest-first.
expect(result.visible.map((item) => item.username))
.toEqual(['stale-inbound', 'recent-inbound', 'replied-old']);
.toEqual(['stale-inbound', 'recent-inbound']);
});
it('reports how many rows the seller filter removed', () => {
it('keeps sellers in their dedicated queue', () => {
const result = partitionConversations([
chat({ username: 'alice' }),
chat({ username: 'seller-one', sellerDetected: true }),
chat({ username: 'seller-two', sellerDetected: true }),
], filters());
], filters({ filter: 'sellers' }));
expect(result.visible.map((item) => item.username)).toEqual(['alice']);
expect(result.hiddenSellerCount).toBe(2);
expect(result.visible.map((item) => item.username)).toEqual(['seller-one', 'seller-two']);
});
it('includes sellers without counting them as hidden once revealed', () => {
it('applies unread, manual and image filters as secondary modifiers', () => {
const result = partitionConversations([
chat({ username: 'seller', sellerDetected: true }),
], filters({ showSellers: true }));
chat({ username: 'match', hasUnreadInbound: true, automationStatus: 'user-overridden', hasReceivedImages: true }),
chat({ username: 'read', hasUnreadInbound: false, automationStatus: 'user-overridden', hasReceivedImages: true }),
chat({ username: 'automatic', hasUnreadInbound: true, automationStatus: 'active', hasReceivedImages: true }),
], filters({ unreadOnly: true, manualOnly: true, imagesOnly: true }));
expect(result.visible).toHaveLength(1);
expect(result.hiddenSellerCount).toBe(0);
expect(result.visible.map((item) => item.username)).toEqual(['match']);
});
it('narrows to a single owning account', () => {
@@ -219,25 +206,25 @@ describe('conversation triage', () => {
});
describe('filterCounts', () => {
it('counts each bucket independently', () => {
it('counts mutually exclusive workflow queues', () => {
const counts = filterCounts([
chat({ username: 'unread-inbound', hasUnreadInbound: true }),
chat({ username: 'read-inbound', hasUnreadInbound: false }),
chat({ username: 'replied', isFromUs: true }),
chat({ username: 'snoozed', triageStatus: 'snoozed' }),
chat({ username: 'done', triageStatus: 'done' }),
chat({ username: 'manual', isFromUs: true, automationStatus: 'user-overridden' }),
chat({ username: 'seller', sellerDetected: true }),
chat({ username: 'deleted', recipientAccountStatus: 'deleted' }),
chat({ username: 'archived', readOnly: true }),
], filters());
expect(counts.all).toBe(4);
expect(counts.unread).toBe(1);
expect(counts['needs-reply']).toBe(2);
expect(counts['awaiting-reply']).toBe(2);
expect(counts.manual).toBe(1);
expect(counts['awaiting-reply']).toBe(1);
expect(counts.sellers).toBe(1);
expect(counts.snoozed).toBe(1);
expect(counts.done).toBe(1);
expect(counts['account-deleted']).toBe(1);
expect(counts.archived).toBe(1);
});
it('respects the search term so a pill never promises rows the list will not show', () => {
@@ -246,17 +233,16 @@ describe('conversation triage', () => {
chat({ username: 'bob', hasUnreadInbound: true }),
], filters({ search: 'alice' }));
expect(counts.unread).toBe(1);
expect(counts.all).toBe(1);
expect(counts['needs-reply']).toBe(1);
});
it('excludes hidden sellers from the counts', () => {
it('applies secondary filters to every queue count', () => {
const counts = filterCounts([
chat({ username: 'alice', hasUnreadInbound: true }),
chat({ username: 'seller', hasUnreadInbound: true, sellerDetected: true }),
], filters());
chat({ username: 'read', hasUnreadInbound: false }),
], filters({ unreadOnly: true }));
expect(counts.unread).toBe(1);
expect(counts['needs-reply']).toBe(1);
});
});