feat(messaging-ui): Inbox domain — unified work items + mail (contract + UI + mock)

Second domain in the SDK, mirroring messaging's adapter/provider/hooks/components:
- InboxAdapter (listInbox unified feed + transition + mailHistory/mailReply +
  optional directory/composeInternal/composeExternal), InboxProvider/useInboxAdapter
- hooks: useInbox (filter + transition), useMailThread (history + reply), useCompose
- <Inbox> (filter tabs, unified list, detail pane), <MailReader> (HTML in a
  sandboxed iframe + reply), compose modal (in-app / email); themeable styles
- MockInboxAdapter (seeded mentions/needs-reply/alert + folded mail threads +
  directory) shipped from ./adapters/mock-inbox
- 6 inbox tests (mock unify/transition/reply + render open/reply/compose); 64 green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 00:48:41 +05:30
parent d02cd152de
commit 2f24a95ef4
11 changed files with 958 additions and 2 deletions
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { InboxProvider } from './provider';
import { Inbox } from './inbox';
import { MockInboxAdapter } from '../adapters/mock-inbox';
function mount() {
return render(
<InboxProvider adapter={new MockInboxAdapter()}>
<Inbox />
</InboxProvider>,
);
}
describe('mock inbox adapter', () => {
it('unifies work items + mail in the Open view; other states drop mail', async () => {
const a = new MockInboxAdapter();
const open = await a.listInbox('OPEN');
expect(open.some((i) => i.kind === 'MAIL')).toBe(true);
expect(open.some((i) => i.kind === 'MENTION')).toBe(true);
const done = await a.listInbox('DONE');
expect(done.some((i) => i.kind === 'MAIL')).toBe(false);
});
it('transition moves a work item out of Open', async () => {
const a = new MockInboxAdapter();
await a.transition('in_3', 'DONE');
expect((await a.listInbox('OPEN')).some((i) => i.id === 'in_3')).toBe(false);
expect((await a.listInbox('DONE')).some((i) => i.id === 'in_3')).toBe(true);
});
it('mail reply appends to the thread', async () => {
const a = new MockInboxAdapter();
await a.mailReply('mt_welcome', 'thanks!');
expect((await a.mailHistory('mt_welcome')).some((m) => m.text === 'thanks!')).toBe(true);
});
});
describe('<Inbox /> (rendered)', () => {
it('lists items and opens a mail thread on click', async () => {
mount();
// A folded mail row is present.
const welcome = await screen.findByText('Welcome to the Founders Club');
fireEvent.click(welcome);
// The reader opens with a reply box.
expect(await screen.findByLabelText('Reply')).toBeTruthy();
});
it('replies into a mail thread', async () => {
mount();
fireEvent.click(await screen.findByText('Welcome to the Founders Club'));
const input = (await screen.findByLabelText('Reply')) as HTMLInputElement;
fireEvent.change(input, { target: { value: 'got it' } });
fireEvent.click(screen.getByText('Reply'));
await waitFor(() => expect(screen.getByText('got it')).toBeTruthy());
});
it('composes an in-app message and it shows in the inbox', async () => {
mount();
fireEvent.click(await screen.findByText('New message'));
// pick a recipient
fireEvent.click(await screen.findByText('Sofia Ramirez'));
fireEvent.change(screen.getByLabelText('Subject'), { target: { value: 'Quick q' } });
fireEvent.change(screen.getByLabelText('Message body'), { target: { value: 'ping' } });
fireEvent.click(screen.getByText('Send'));
await waitFor(() => expect(screen.getByText('Quick q')).toBeTruthy());
});
});