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

+30
View File
@@ -20,6 +20,8 @@ interface ChatFixtureOptions {
lastMessageSnippet?: string;
hasReceivedImages?: boolean;
sellerDetected?: boolean;
automationStatus?: 'active' | 'image-received' | 'human-required' | 'boundary' | 'user-overridden';
automationHandoffReason?: string;
}
function chatFixture(options: ChatFixtureOptions) {
@@ -34,6 +36,10 @@ function chatFixture(options: ChatFixtureOptions) {
unread: options.unread ?? false,
...(options.hasReceivedImages === undefined ? {} : { hasReceivedImages: options.hasReceivedImages }),
...(options.sellerDetected === undefined ? {} : { sellerDetected: options.sellerDetected }),
...(options.automationStatus === undefined ? {} : { automationStatus: options.automationStatus }),
...(options.automationHandoffReason === undefined
? {}
: { automationHandoffReason: options.automationHandoffReason }),
};
}
@@ -269,6 +275,30 @@ describe('ConversationsPage', () => {
expect(screen.getByRole('img', { name: 'Image received from u/alice' })).not.toHaveClass('is-blurred');
});
it('marks and filters conversations that are being replied to by hand', async () => {
const manual = chatFixture({
username: 'alice',
roomId: 'room-alice',
isFromUs: true,
automationStatus: 'user-overridden',
automationHandoffReason: 'A manual reply was sent in this conversation.',
});
const automated = chatFixture({ username: 'bob', roomId: 'room-bob', automationStatus: 'active' });
server.use(http.get('/api/conversations', () => HttpResponse.json([manual, automated])));
const user = userEvent.setup();
renderConversations();
await screen.findByRole('button', { name: /u\/alice/ });
expect(screen.getByText('Manual', { selector: '.conversation-chip' })).toHaveAttribute(
'title',
'Manual — you replied — A manual reply was sent in this conversation.',
);
await user.click(screen.getByRole('button', { name: 'Manual, 1' }));
expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument();
});
it('turns a suggestion into an editable draft and confirms sending without losing failed input', async () => {
installConversationList();
let sendRequests = 0;