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
+4
View File
@@ -18,6 +18,10 @@
"types": "./dist/adapters/kernel-client.d.ts",
"import": "./dist/adapters/kernel-client.js"
},
"./adapters/mock-inbox": {
"types": "./dist/adapters/mock-inbox.d.ts",
"import": "./dist/adapters/mock-inbox.js"
},
"./conformance": {
"types": "./dist/conformance.d.ts",
"import": "./dist/conformance.js"
@@ -0,0 +1,104 @@
import type { InboxAdapter } from '../inbox/adapter';
import type { InboxItem, InboxState, MailMessage, MailPerson } from '../inbox/types';
const PEOPLE: MailPerson[] = [
{ id: 'pp_sofia', name: 'Sofia Ramirez', kind: 'staff' },
{ id: 'pp_dan', name: 'Dan Whitaker', kind: 'staff' },
{ id: 'cust_acme', name: 'Acme Roofing (Client)', kind: 'customer' },
];
interface MockMail {
subject: string;
messages: MailMessage[];
}
/**
* In-memory inbox for demos/tests. State is PER INSTANCE. Seeds a few work items (mention,
* needs-reply, alert) plus mail threads that fold into the Open view as MAIL rows.
*/
export class MockInboxAdapter implements InboxAdapter {
private seq = 100;
private readonly items: InboxItem[];
private readonly threads = new Map<string, MockMail>();
/** threadIds that surface as standalone MAIL rows (in addition to work items). */
private readonly mailFold = ['mt_welcome', 'mt_invoice'];
constructor(private readonly now: () => string = () => new Date().toISOString()) {
const at = this.now();
this.items = [
{ id: 'in_1', kind: 'MENTION', state: 'OPEN', title: 'Sofia mentioned you', summary: '@you — can you confirm the Henderson scope?', priority: 'HIGH', threadId: 'mt_mention', createdAt: at },
{ id: 'in_2', kind: 'NEEDS_REPLY', state: 'OPEN', title: 'Reply needed — Storm response', summary: 'Dan: crew rolling out at 7', priority: 'MEDIUM', threadId: 'mt_storm', createdAt: at },
{ id: 'in_3', kind: 'SYSTEM_ALERT', state: 'OPEN', title: 'Ticket TK-204 updated', summary: 'Customer replied on the roof-leak case.', priority: 'LOW', createdAt: at },
];
this.threads.set('mt_mention', {
subject: 'Henderson scope',
messages: [{ id: 'm1', actorId: 'pp_sofia', kind: 'EMAIL', at, html: '<p>Can you confirm the <b>Henderson</b> scope by EOD?</p>', text: 'Can you confirm the Henderson scope by EOD?', attachment: null }],
});
this.threads.set('mt_storm', {
subject: 'Storm response — East side',
messages: [{ id: 'm2', actorId: 'pp_dan', kind: 'EMAIL', at, html: '<p>Crew is rolling out at 7. Confirm the Henderson job?</p>', text: 'Crew rolling out at 7. Confirm the Henderson job?', attachment: null }],
});
this.threads.set('mt_welcome', {
subject: 'Welcome to the Founders Club',
messages: [{ id: 'm3', actorId: 'system', kind: 'EMAIL', at, html: '<p>Thanks for joining the <b>Founders Club</b>. Set up your account to get started.</p>', text: 'Thanks for joining the Founders Club.', attachment: null }],
});
this.threads.set('mt_invoice', {
subject: 'Invoice #1042 — Acme Roofing',
messages: [{ id: 'm4', actorId: 'cust_acme', kind: 'EMAIL', at, html: '<p>Attached is invoice <b>#1042</b> for the East-side job.</p>', text: 'Attached is invoice #1042 for the East-side job.', attachment: null }],
});
}
async listInbox(state?: InboxState): Promise<InboxItem[]> {
const showMail = !state || state === 'OPEN';
const work = this.items.filter((i) => (state ? i.state === state : true));
const mail: InboxItem[] = showMail
? this.mailFold.map((tid) => {
const t = this.threads.get(tid)!;
const last = t.messages[t.messages.length - 1];
return {
id: `mail:${tid}`,
kind: 'MAIL',
state: 'OPEN' as InboxState,
title: t.subject,
...(last?.text ? { summary: last.text } : {}),
priority: 'LOW',
threadId: tid,
createdAt: last?.at ?? this.now(),
};
})
: [];
return [...mail, ...work];
}
async transition(id: string, state: InboxState): Promise<void> {
const item = this.items.find((i) => i.id === id);
if (item) item.state = state;
}
async mailHistory(threadId: string): Promise<MailMessage[]> {
return [...(this.threads.get(threadId)?.messages ?? [])];
}
async mailReply(threadId: string, content: string): Promise<void> {
const t = this.threads.get(threadId);
if (t) t.messages = [...t.messages, { id: `r_${this.seq++}`, actorId: 'me', kind: 'MESSAGE', at: this.now(), html: null, text: content, attachment: null }];
}
async directory(): Promise<MailPerson[]> {
return [...PEOPLE];
}
async composeInternal(recipientUserId: string, subject: string, text: string): Promise<void> {
const threadId = `mt_${this.seq++}`;
this.threads.set(threadId, { subject, messages: [{ id: `m_${this.seq++}`, actorId: 'me', kind: 'EMAIL', at: this.now(), html: `<p>${text}</p>`, text, attachment: null }] });
this.mailFold.unshift(threadId);
void recipientUserId;
}
async composeExternal(target: string, subject: string, text: string): Promise<void> {
// A mock external send has no in-app thread — no-op beyond acknowledging.
void target;
void subject;
void text;
}
}
@@ -0,0 +1,28 @@
import type { InboxItem, InboxState, MailMessage, MailPerson } from './types';
/**
* The inbox seam. A host implements this; the SDK renders it. Mirrors MessagingAdapter's philosophy:
* `listInbox` returns the UNIFIED feed (work items + mail folded in) so the folding logic lives in
* one place (the adapter, which knows both sources). Compose methods are optional and degrade.
*/
export interface InboxAdapter {
/** The unified inbox: work items + mail threads as rows, filtered by state (mail shows in OPEN). */
listInbox(state?: InboxState): Promise<InboxItem[]>;
/** Transition a work item's state (Done/Snooze/Archive…). Mail rows are not transitioned. */
transition(id: string, state: InboxState): Promise<void>;
/** Messages of one mail thread (HTML + text parts) for the reader. */
mailHistory(threadId: string): Promise<MailMessage[]>;
/** Reply into an existing mail thread. */
mailReply(threadId: string, content: string): Promise<void>;
// ── Compose (optional) — absent hides the "New message" affordance ──
/** People you can compose an in-app message to. */
directory?(): Promise<MailPerson[]>;
/** App-to-app mail (no SMTP) to a registered user's in-app inbox. */
composeInternal?(recipientUserId: string, subject: string, text: string): Promise<void>;
/** External email (SMTP) to an address. */
composeExternal?(target: string, subject: string, text: string): Promise<void>;
}
@@ -0,0 +1,156 @@
import { useCallback, useEffect, useState } from 'react';
import { useInboxAdapter } from './provider';
import type { InboxItem, InboxState, MailMessage, MailPerson } from './types';
export interface InboxData {
items: InboxItem[];
loading: boolean;
error: string | null;
transition: (id: string, state: InboxState) => Promise<void>;
refetch: () => void;
}
/** The unified inbox for a given filter state. Refetches when the filter changes. */
export function useInbox(state?: InboxState): InboxData {
const adapter = useInboxAdapter();
const [items, setItems] = useState<InboxItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [nonce, setNonce] = useState(0);
useEffect(() => {
let alive = true;
setLoading(true);
adapter
.listInbox(state)
.then((list) => {
if (!alive) return;
setItems(list);
setError(null);
})
.catch((e: unknown) => {
if (!alive) return;
setError(e instanceof Error ? e.message : String(e));
setItems([]);
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [adapter, state, nonce]);
const refetch = useCallback(() => setNonce((n) => n + 1), []);
const transition = useCallback(
async (id: string, next: InboxState) => {
await adapter.transition(id, next);
setNonce((n) => n + 1);
},
[adapter],
);
return { items, loading, error, transition, refetch };
}
export interface MailThreadState {
messages: MailMessage[];
loading: boolean;
error: string | null;
reply: (content: string) => Promise<void>;
refetch: () => void;
}
/** One mail thread: history + reply. */
export function useMailThread(threadId: string | null): MailThreadState {
const adapter = useInboxAdapter();
const [messages, setMessages] = useState<MailMessage[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [nonce, setNonce] = useState(0);
useEffect(() => {
if (!threadId) {
setMessages([]);
setLoading(false);
return;
}
let alive = true;
setLoading(true);
adapter
.mailHistory(threadId)
.then((m) => {
if (!alive) return;
setMessages(m);
setError(null);
})
.catch((e: unknown) => {
if (alive) setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [adapter, threadId, nonce]);
const refetch = useCallback(() => setNonce((n) => n + 1), []);
const reply = useCallback(
async (content: string) => {
if (!threadId) return;
await adapter.mailReply(threadId, content);
setNonce((n) => n + 1);
},
[adapter, threadId],
);
return { messages, loading, error, reply, refetch };
}
export interface ComposeState {
supported: boolean;
directory: MailPerson[];
sendInternal: (recipientUserId: string, subject: string, text: string) => Promise<void>;
sendExternal: (target: string, subject: string, text: string) => Promise<void>;
}
/** Compose a new message — in-app (to a person) or external (to an email). */
export function useCompose(): ComposeState {
const adapter = useInboxAdapter();
const supported = typeof adapter.composeInternal === 'function';
const [directory, setDirectory] = useState<MailPerson[]>([]);
useEffect(() => {
if (!adapter.directory) return;
let alive = true;
adapter
.directory()
.then((d) => {
if (alive) setDirectory(d);
})
.catch(() => {
if (alive) setDirectory([]);
});
return () => {
alive = false;
};
}, [adapter]);
const sendInternal = useCallback(
async (recipientUserId: string, subject: string, text: string) => {
if (!adapter.composeInternal) throw new Error('compose is not supported by this adapter');
await adapter.composeInternal(recipientUserId, subject, text);
},
[adapter],
);
const sendExternal = useCallback(
async (target: string, subject: string, text: string) => {
if (!adapter.composeExternal) throw new Error('compose is not supported by this adapter');
await adapter.composeExternal(target, subject, text);
},
[adapter],
);
return { supported, directory, sendInternal, sendExternal };
}
@@ -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());
});
});
@@ -0,0 +1,241 @@
import { useEffect, useState, type FormEvent } from 'react';
import { useCompose, useInbox, useMailThread } from './hooks';
import type { InboxItem, InboxState, MailPerson } from './types';
const FILTERS: { value: InboxState; label: string }[] = [
{ value: 'OPEN', label: 'Open' },
{ value: 'SNOOZED', label: 'Snoozed' },
{ value: 'DONE', label: 'Done' },
{ value: 'ARCHIVED', label: 'Archived' },
];
const KIND_LABEL: Record<string, string> = {
MAIL: 'Mail',
MENTION: 'Mention',
NEEDS_REPLY: 'Needs reply',
SYSTEM_ALERT: 'Alert',
SUPPORT_UPDATE: 'Support',
};
const timeOf = (iso?: string): string => {
if (!iso) return '';
const d = new Date(iso);
return Number.isNaN(+d) ? '' : d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
/** The unified inbox: work items + mail in one list; click a threaded row to read + reply. */
export function Inbox() {
const [filter, setFilter] = useState<InboxState>('OPEN');
const { items, loading, error, transition, refetch } = useInbox(filter);
const compose = useCompose();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [composing, setComposing] = useState(false);
useEffect(() => {
if (selectedId && items.some((i) => i.id === selectedId)) return;
setSelectedId(items[0]?.id ?? null);
}, [items, selectedId]);
const selected = items.find((i) => i.id === selectedId) ?? null;
return (
<div className="miu-inbox">
<div className="miu-inbox-bar">
<div className="miu-inbox-filters">
{FILTERS.map((f) => (
<button key={f.value} type="button" className={`miu-tab${filter === f.value ? ' is-active' : ''}`} onClick={() => setFilter(f.value)}>
{f.label}
</button>
))}
</div>
{compose.supported ? (
<button type="button" className="miu-send" onClick={() => setComposing(true)}>
New message
</button>
) : null}
</div>
<div className="miu-inbox-body">
<aside className="miu-inbox-list">
{loading && items.length === 0 ? <div className="miu-empty">Loading</div> : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null}
{!loading && items.length === 0 ? <div className="miu-empty">Nothing here you&apos;re all caught up 🎉</div> : null}
{items.map((it) => (
<button key={it.id} type="button" className={`miu-inbox-row${it.id === selectedId ? ' is-active' : ''}`} onClick={() => setSelectedId(it.id)}>
<span className="miu-inbox-glyph" aria-hidden="true">{it.kind === 'MAIL' ? '✉' : it.kind === 'MENTION' ? '@' : '•'}</span>
<span className="miu-inbox-main">
<span className="miu-inbox-row-top">
<span className="miu-pill">{KIND_LABEL[it.kind] ?? it.kind}</span>
<span className="miu-inbox-title">{it.title}</span>
</span>
{it.summary ? <span className="miu-inbox-summary">{it.summary}</span> : null}
</span>
{it.state !== 'OPEN' ? <span className="miu-pill">{it.state.toLowerCase()}</span> : null}
</button>
))}
</aside>
<section className="miu-inbox-detail">
{selected ? <Detail item={selected} onTransition={(s) => void transition(selected.id, s)} /> : <div className="miu-empty miu-thread-empty">Select an item to read.</div>}
</section>
</div>
{composing ? <ComposeModal onClose={() => setComposing(false)} onSent={() => { setComposing(false); refetch(); }} /> : null}
</div>
);
}
function Detail({ item, onTransition }: { item: InboxItem; onTransition: (state: InboxState) => void }) {
return (
<div className="miu-detail">
{item.state === 'OPEN' && item.kind !== 'MAIL' ? (
<div className="miu-detail-actions">
<button type="button" className="miu-tab" onClick={() => onTransition('SNOOZED')}>Snooze</button>
<button type="button" className="miu-tab" onClick={() => onTransition('DONE')}>Done</button>
<button type="button" className="miu-tab" onClick={() => onTransition('ARCHIVED')}>Archive</button>
</div>
) : null}
{item.threadId ? (
<MailReader threadId={item.threadId} subject={item.title} />
) : (
<div className="miu-detail-body">
<div className="miu-detail-title">{item.title}</div>
{item.summary ? <div className="miu-detail-summary">{item.summary}</div> : null}
</div>
)}
</div>
);
}
/** Read a mail thread (HTML in a sandboxed iframe) + reply. */
export function MailReader({ threadId, subject }: { threadId: string; subject: string }) {
const { messages, loading, error, reply } = useMailThread(threadId);
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
async function submit(e: FormEvent): Promise<void> {
e.preventDefault();
const text = draft.trim();
if (!text || sending) return;
setDraft('');
setSending(true);
try {
await reply(text);
} catch {
setDraft(text);
} finally {
setSending(false);
}
}
return (
<div className="miu-mail">
<header className="miu-mail-head">{subject || '(no subject)'}</header>
<div className="miu-mail-body">
{loading && messages.length === 0 ? <div className="miu-empty">Loading</div> : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null}
{messages.map((m) => (
<article key={m.id} className="miu-mail-msg">
<div className="miu-mail-meta">
<span>{m.kind === 'EMAIL' ? 'Email' : 'Reply'}{m.actorId ? ` · ${m.actorId}` : ''}</span>
<span>{timeOf(m.at)}</span>
</div>
{m.html ? (
<iframe sandbox="" srcDoc={m.html} title="mail body" className="miu-mail-frame" />
) : (
<div className="miu-mail-text">{m.text}</div>
)}
</article>
))}
</div>
<form className="miu-composer" onSubmit={submit}>
<div className="miu-composer-row">
<input className="miu-input" value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Reply…" aria-label="Reply" />
<button type="submit" className="miu-send" disabled={!draft.trim() || sending}>Reply</button>
</div>
</form>
</div>
);
}
function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () => void }) {
const compose = useCompose();
const [mode, setMode] = useState<'internal' | 'external'>('internal');
const [recipient, setRecipient] = useState('');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [q, setQ] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const filtered = compose.directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
const canSend = !!recipient && !!subject.trim() && !!body.trim() && !busy;
async function send(): Promise<void> {
if (!canSend) return;
setBusy(true);
setErr(null);
try {
if (mode === 'internal') await compose.sendInternal(recipient, subject.trim(), body.trim());
else await compose.sendExternal(recipient.trim(), subject.trim(), body.trim());
onSent();
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
return (
<div className="miu-modal-overlay" onMouseDown={onClose}>
<div className="miu-modal" role="dialog" aria-modal="true" onMouseDown={(e) => e.stopPropagation()}>
<div className="miu-modal-head">
<span>New message</span>
<button type="button" className="miu-pane-close" onClick={onClose} aria-label="Close"></button>
</div>
<div className="miu-modal-body">
<div className="miu-compose-modes">
<button type="button" className={`miu-tab${mode === 'internal' ? ' is-active' : ''}`} onClick={() => { setMode('internal'); setRecipient(''); }}>In-app</button>
<button type="button" className={`miu-tab${mode === 'external' ? ' is-active' : ''}`} onClick={() => { setMode('external'); setRecipient(''); }}>Email</button>
</div>
{mode === 'internal' ? (
<div className="miu-field">
<span className="miu-field-lbl">To (person)</span>
<input className="miu-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" aria-label="Search people" />
<div className="miu-people">
{filtered.length === 0 ? <div className="miu-empty">No people found.</div> : null}
{filtered.map((p: MailPerson) => (
<label key={p.id} className={`miu-person${recipient === p.id ? ' is-active' : ''}`}>
<input type="radio" name="miu-recipient" checked={recipient === p.id} onChange={() => setRecipient(p.id)} />
<span>{p.name}</span>
<span className="miu-pill">{p.kind}</span>
</label>
))}
</div>
</div>
) : (
<div className="miu-field">
<span className="miu-field-lbl">To (email)</span>
<input className="miu-input" value={recipient} onChange={(e) => setRecipient(e.target.value)} placeholder="name@company.com" aria-label="To email" />
</div>
)}
<div className="miu-field">
<span className="miu-field-lbl">Subject</span>
<input className="miu-input" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject" aria-label="Subject" />
</div>
<div className="miu-field">
<span className="miu-field-lbl">Message</span>
<textarea className="miu-input miu-textarea" value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" rows={5} aria-label="Message body" />
</div>
{err ? <div className="miu-empty miu-error">{err}</div> : null}
</div>
<div className="miu-modal-foot">
<button type="button" className="miu-tab" onClick={onClose}>Cancel</button>
<button type="button" className="miu-send" onClick={() => void send()} disabled={!canSend}>{busy ? 'Sending…' : 'Send'}</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,16 @@
import { createContext, useContext, type ReactNode } from 'react';
import type { InboxAdapter } from './adapter';
const InboxContext = createContext<InboxAdapter | null>(null);
export function InboxProvider({ adapter, children }: { adapter: InboxAdapter; children: ReactNode }) {
return <InboxContext.Provider value={adapter}>{children}</InboxContext.Provider>;
}
/** Access the host-injected inbox adapter. Throws outside a provider — a missing provider is a
* wiring bug, and failing loudly beats a confusing null-deref three layers down. */
export function useInboxAdapter(): InboxAdapter {
const adapter = useContext(InboxContext);
if (!adapter) throw new Error('useInboxAdapter must be used within an <InboxProvider>');
return adapter;
}
@@ -0,0 +1,42 @@
// Domain types for the inbox surface (work items + mail). Zero transport imports.
export type InboxState = 'OPEN' | 'SNOOZED' | 'DONE' | 'ARCHIVED' | 'CANCELLED' | 'STALE';
/** A unified inbox row — a work item (mention/needs-reply/alert) OR a mail thread. */
export interface InboxItem {
id: string;
kind: string; // MAIL | MENTION | NEEDS_REPLY | SYSTEM_ALERT | …
state: InboxState;
title: string;
summary?: string;
priority: string;
/** Present when the row opens a conversation/mail thread. */
threadId?: string;
createdAt: string;
}
/** A mail attachment reference (bytes live in storage; the reader resolves a display URL). */
export interface MailAttachment {
contentRef: string;
mimeType: string;
sizeBytes: number;
filename: string | null;
}
/** One message in a mail thread — HTML and/or plain text, optionally an attachment. */
export interface MailMessage {
id: string;
actorId: string | null;
kind: string;
at: string;
html: string | null;
text: string | null;
attachment: MailAttachment | null;
}
/** A person you can compose an in-app message to. */
export interface MailPerson {
id: string;
name: string;
kind: 'staff' | 'customer';
}
+7
View File
@@ -17,6 +17,13 @@ export { ChannelBrowser } from './components/channel-browser';
export { Thread } from './components/thread';
export { ThreadPane } from './components/thread-pane';
// ── Inbox domain (work items + mail) ──
export { InboxProvider, useInboxAdapter } from './inbox/provider';
export { useInbox, useMailThread, useCompose } from './inbox/hooks';
export { Inbox, MailReader } from './inbox/inbox';
export type { InboxAdapter } from './inbox/adapter';
export type { InboxItem, InboxState, MailAttachment, MailMessage, MailPerson } from './inbox/types';
export type { MessagingAdapter } from './adapter';
export type { ConversationsState } from './hooks/use-conversations';
export type { MessagesState, UiMessage } from './hooks/use-messages';
+290
View File
@@ -556,6 +556,296 @@
border-bottom: 1px solid var(--miu-border);
}
/* ── Inbox ── */
.miu-inbox {
--miu-bg: #0e0e13;
--miu-panel: #16161d;
--miu-panel-2: #1d1d26;
--miu-border: #262631;
--miu-text: #e9e9ef;
--miu-muted: #9a9aa7;
--miu-accent: #fda913;
--miu-accent-text: #1a1206;
--miu-radius: 12px;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
color: var(--miu-text);
background: var(--miu-bg);
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
font-size: 14px;
}
.miu-inbox-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--miu-border);
}
.miu-inbox-filters {
display: flex;
gap: 4px;
}
.miu-tab {
padding: 6px 12px;
border-radius: 999px;
border: 1px solid var(--miu-border);
background: var(--miu-panel);
color: var(--miu-text);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.miu-tab.is-active {
background: var(--miu-accent);
color: var(--miu-accent-text);
border-color: var(--miu-accent);
font-weight: 700;
}
.miu-inbox-body {
flex: 1;
min-height: 0;
display: flex;
}
.miu-inbox-list {
width: 340px;
flex-shrink: 0;
overflow-y: auto;
border-right: 1px solid var(--miu-border);
background: var(--miu-panel);
}
.miu-inbox-row {
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
padding: 12px 14px;
border: none;
border-bottom: 1px solid var(--miu-border);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
font: inherit;
}
.miu-inbox-row:hover,
.miu-inbox-row.is-active {
background: var(--miu-panel-2);
}
.miu-inbox-glyph {
width: 26px;
height: 26px;
flex-shrink: 0;
border-radius: 7px;
display: grid;
place-items: center;
color: var(--miu-muted);
background: var(--miu-panel-2);
font-weight: 700;
}
.miu-inbox-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.miu-inbox-row-top {
display: flex;
align-items: center;
gap: 6px;
}
.miu-pill {
flex-shrink: 0;
font-size: 10.5px;
font-weight: 700;
padding: 1px 7px;
border-radius: 999px;
color: var(--miu-muted);
background: var(--miu-panel-2);
text-transform: capitalize;
}
.miu-inbox-title {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.miu-inbox-summary {
color: var(--miu-muted);
font-size: 12.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.miu-inbox-detail {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--miu-bg);
}
.miu-detail {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.miu-detail-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
padding: 10px 14px;
border-bottom: 1px solid var(--miu-border);
}
.miu-detail-body {
padding: 22px;
}
.miu-detail-title {
font-weight: 700;
font-size: 16px;
margin-bottom: 6px;
}
.miu-detail-summary {
color: var(--miu-muted);
line-height: 1.55;
}
/* mail reader */
.miu-mail {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.miu-mail-head {
padding: 12px 18px;
border-bottom: 1px solid var(--miu-border);
font-weight: 700;
font-size: 15px;
}
.miu-mail-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.miu-mail-msg {
border: 1px solid var(--miu-border);
border-radius: var(--miu-radius);
background: var(--miu-panel);
overflow: hidden;
}
.miu-mail-meta {
display: flex;
justify-content: space-between;
padding: 7px 12px;
border-bottom: 1px solid var(--miu-border);
font-size: 12px;
color: var(--miu-muted);
}
.miu-mail-frame {
width: 100%;
height: 200px;
border: none;
background: #fff;
}
.miu-mail-text {
padding: 12px;
white-space: pre-wrap;
word-break: break-word;
}
/* compose modal */
.miu-modal-overlay {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(2, 2, 6, 0.6);
display: grid;
place-items: center;
padding: 20px;
}
.miu-modal {
width: 100%;
max-width: 520px;
max-height: 88vh;
display: flex;
flex-direction: column;
border: 1px solid var(--miu-border);
border-radius: 16px;
background: var(--miu-panel);
color: var(--miu-text);
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
}
.miu-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px;
border-bottom: 1px solid var(--miu-border);
font-weight: 700;
}
.miu-modal-body {
padding: 16px 18px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.miu-modal-foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 18px;
border-top: 1px solid var(--miu-border);
}
.miu-compose-modes {
display: flex;
gap: 6px;
}
.miu-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.miu-field-lbl {
font-size: 12.5px;
font-weight: 600;
color: var(--miu-muted);
}
.miu-textarea {
resize: vertical;
min-height: 90px;
}
.miu-people {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 180px;
overflow-y: auto;
}
.miu-person {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
border-radius: 10px;
cursor: pointer;
}
.miu-person.is-active {
background: var(--miu-panel-2);
}
.miu-person span:nth-of-type(1) {
flex: 1;
}
/* misc */
.miu-empty {
padding: 24px;
+2 -2
View File
@@ -6,10 +6,10 @@ export default defineConfig({
// entry, never reachable from `index`, because it imports vitest, and bundling
// that into the main barrel would drag a test runner into every consumer's
// production build.
entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts', 'src/styles.css'],
entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/mock-inbox.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts', 'src/styles.css'],
format: ['esm'],
// Types only for the TS entries — styles.css has no .d.ts (and tsc chokes on a .css root file).
dts: { entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts'] },
dts: { entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/mock-inbox.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts'] },
clean: true,
external: ['react', 'react-dom', 'vitest', '@insignia/iios-kernel-client'],
});