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:
Jamie BruntonandClaude Opus 5 committed 2026-08-10 11:03:33 +01:00
1 parent 725b5fc4e6
commit ac12a3146a
35 files changed
+1164 -129

No files matched your search

+54 -2
View File
@@ -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', () => {