feat: recover missing conversations, fix media attachment, add manual inbox tab
Missing conversations: recipient Reddit accounts that are deleted or suspended left conversations unaccounted for. A new RecipientAccountStatusService polls Reddit for the users behind cached conversations and records their account status durably, with progressive backfill and delta-media fixes in ChatService so history and images are no longer dropped mid-sync. Media: attachMedia now surfaces download failures as MediaAttachmentError so the sync cursor can be retried instead of silently losing an image, recognizes image bytes when Matrix returns a generic content type, and keeps separate gallery occurrences when the same asset appears in more than one event. Manual inbox tab: the conversations list now carries the reply-loop status, so the inbox can separate conversations the operator is driving by hand from the ones automation is still handling. A Manual filter pill and a per-row chip explain why each conversation was handed over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
725b5fc4e6
commit
ac12a3146a
35 files changed
+1164
-129
No files matched your search
@@ -6,8 +6,10 @@ import {
|
||||
filterCounts,
|
||||
isConversationFilter,
|
||||
isConversationSort,
|
||||
isManualFlow,
|
||||
isUnread,
|
||||
isWaitingOnThem,
|
||||
manualFlowLabel,
|
||||
matchesFilter,
|
||||
matchesSearch,
|
||||
needsReply,
|
||||
@@ -63,6 +65,33 @@ describe('conversation triage', () => {
|
||||
it('defaults a conversation with no stored state to open', () => {
|
||||
expect(triageStatus(chat({ username: 'a' }))).toBe('open');
|
||||
});
|
||||
|
||||
it('treats every handed-over reply-loop status as a manual conversation', () => {
|
||||
for (const status of ['user-overridden', 'image-received', 'boundary', 'human-required'] as const) {
|
||||
expect(isManualFlow(chat({ username: 'a', automationStatus: status }))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats a manual policy as manual even while the reply loop is still nominally active', () => {
|
||||
expect(isManualFlow(chat({
|
||||
username: 'a', automationStatus: 'active', automationPolicyMode: 'manual',
|
||||
}))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not call an actively automated or never-automated conversation manual', () => {
|
||||
expect(isManualFlow(chat({ username: 'a', automationStatus: 'active' }))).toBe(false);
|
||||
expect(isManualFlow(chat({ username: 'b' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('explains why a conversation is being handled by hand', () => {
|
||||
expect(manualFlowLabel(chat({ username: 'a', automationStatus: 'user-overridden' })))
|
||||
.toBe('Manual — you replied');
|
||||
expect(manualFlowLabel(chat({ username: 'b', automationStatus: 'image-received' })))
|
||||
.toBe('Manual — image received');
|
||||
expect(manualFlowLabel(chat({ username: 'c', automationStatus: 'active', automationPolicyMode: 'manual' })))
|
||||
.toBe('Manual by policy');
|
||||
expect(manualFlowLabel(chat({ username: 'd', automationStatus: 'active' }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('filters', () => {
|
||||
@@ -71,6 +100,7 @@ describe('conversation triage', () => {
|
||||
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' });
|
||||
|
||||
it('excludes snoozed and resolved conversations from the default view', () => {
|
||||
expect(matchesFilter(open, 'all')).toBe(true);
|
||||
@@ -84,6 +114,13 @@ describe('conversation triage', () => {
|
||||
expect(matchesFilter(done, 'done')).toBe(true);
|
||||
});
|
||||
|
||||
it('moves deleted accounts out of actionable buckets into their own bucket', () => {
|
||||
expect(matchesFilter(deleted, 'all')).toBe(false);
|
||||
expect(matchesFilter(deleted, 'needs-reply')).toBe(false);
|
||||
expect(matchesFilter(deleted, 'account-deleted')).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);
|
||||
@@ -91,6 +128,17 @@ describe('conversation triage', () => {
|
||||
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('matches search against username, snippet and loaded message text', () => {
|
||||
const conversation = chat({
|
||||
username: 'alice',
|
||||
@@ -176,14 +224,18 @@ describe('conversation triage', () => {
|
||||
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: 'deleted', recipientAccountStatus: 'deleted' }),
|
||||
], filters());
|
||||
|
||||
expect(counts.all).toBe(3);
|
||||
expect(counts.all).toBe(4);
|
||||
expect(counts.unread).toBe(1);
|
||||
expect(counts['needs-reply']).toBe(2);
|
||||
expect(counts['awaiting-reply']).toBe(1);
|
||||
expect(counts['awaiting-reply']).toBe(2);
|
||||
expect(counts.manual).toBe(1);
|
||||
expect(counts.snoozed).toBe(1);
|
||||
expect(counts.done).toBe(1);
|
||||
expect(counts['account-deleted']).toBe(1);
|
||||
});
|
||||
|
||||
it('respects the search term so a pill never promises rows the list will not show', () => {
|
||||
|
||||
Reference in new issue
Block a user