Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
55 KiB
Messaging UI SDK — Foundation (Plan 1 of 3) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build the transport-free core of @insignia/iios-messaging-ui — types, the MessagingAdapter contract, a shared conformance suite, a mock adapter, and the provider + hooks — proving the seam works with zero network.
Architecture: The core knows nothing about any transport. Hosts inject a MessagingAdapter; the provider holds it in context; hooks (useConversations, useMessages) call it. iios-kernel-client is reachable only from the ./adapters/kernel subpath (Plan 3), never from core — enforced by a test, not convention. A conformance suite defines "a correct adapter" once, so the mock, kernel, and CRM adapters are all verified against one definition.
Tech Stack: TypeScript, React 19 (peer), tsup (ESM), vitest 3 + jsdom + @testing-library/react, pnpm workspaces.
Spec: lynkeduppro-crm/docs/superpowers/specs/2026-07-17-messaging-sdk-design.md
Scope of this plan: Package scaffold, test infra, types, adapter contract + conformance suite, mock adapter, provider, hooks, boundary enforcement. Out of scope: UI components and CSS (Plan 2); kernel adapter, CRM adapter, attachments, and the CRM migration (Plan 3).
Context an engineer needs
Read these first:
packages/iios-message-web/— the existing headless package. This is the anti-pattern, not the model: 109 lines, narrowedsendAPI, no consumers. We are deliberately building the layer it refused to build.lynkeduppro-crm/src/lib/messenger-api.ts— the source of the adapter contract. ItsMessengerData/ThreadDatainterfaces already survived two implementations (live + mock).scripts/check-import-boundary.mjs— the repo's dependency law.
Repo facts that will bite you:
- Root
vitest.config.tsincludes onlypackages/**/src/**/*.{test,spec}.ts—.tsxis not matched, and itsglobalSetupprovisions a Postgres DB on port 5434 for every run. This package therefore gets its ownvitest.config.tswithenvironment: 'jsdom'and noglobalSetup. Never add a DB dependency to a pure-UI package. tsconfig.base.jsonsets"module": "commonjs"— every web package overrides this toESNext+moduleResolution: bundler+jsx: react-jsx. Copy that override; don't fight the base.- Packages are ESM-only (
"type": "module").
The bug we are fixing (do not port it): the CRM infers the current actor id by scanning for a message you sent (messenger-api.ts:200-203). Until you have sent in a thread, myActorId is null, so the REST poll fallback computes mine: false for every message. Root cause: the kernel's receipt event carries no threadId. Our fix is adapter.currentActorId() — identity is the adapter's job, stated explicitly. Task 8 has the regression test.
File Structure
| File | Responsibility |
|---|---|
packages/iios-messaging-ui/package.json |
Package manifest, exports map, scripts |
packages/iios-messaging-ui/tsup.config.ts |
ESM build, 3 entries (core, adapters/mock, adapters/kernel) |
packages/iios-messaging-ui/tsconfig.json |
ESNext + jsx override over base |
packages/iios-messaging-ui/vitest.config.ts |
jsdom, includes .tsx, no globalSetup |
src/types.ts |
Domain types only. Zero imports. |
src/adapter.ts |
The MessagingAdapter interface |
src/conformance.ts |
Shared suite defining a correct adapter (shipped, not test-only) |
src/conformance.test.ts |
Runs the suite against the mock adapter |
src/adapters/mock.ts |
In-memory adapter for demos/tests |
src/provider.tsx |
MessagingProvider + useAdapter |
src/hooks/use-conversations.ts |
Conversation list state |
src/hooks/use-messages.ts |
Thread state, optimistic send, subscribe reconciliation |
src/index.ts |
Public barrel |
src/boundary.test.ts |
Asserts core never imports a transport |
Files split by responsibility, not layer. conformance.ts ships in src/ (not a test file) because Plan 3's adapters import it to verify themselves.
Task 1: Package scaffold + test infrastructure
Files:
-
Create:
packages/iios-messaging-ui/package.json -
Create:
packages/iios-messaging-ui/tsconfig.json -
Create:
packages/iios-messaging-ui/tsup.config.ts -
Create:
packages/iios-messaging-ui/vitest.config.ts -
Create:
packages/iios-messaging-ui/src/index.ts -
Test:
packages/iios-messaging-ui/src/smoke.test.tsx -
Step 1: Create the package manifest
Create packages/iios-messaging-ui/package.json:
{
"name": "@insignia/iios-messaging-ui",
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./adapters/mock": {
"types": "./dist/adapters/mock.d.ts",
"import": "./dist/adapters/mock.js"
},
"./conformance": {
"types": "./dist/conformance.d.ts",
"import": "./dist/conformance.js"
},
"./styles.css": "./dist/styles.css"
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"peerDependencies": {
"react": ">=18"
},
"devDependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.1.0",
"@types/react": "^19.0.0",
"jsdom": "^26.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tsup": "^8.3.5",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
}
}
Note: no dependencies. The core must stay transport-free. ./adapters/kernel and its @insignia/iios-kernel-client dependency arrive in Plan 3. ./styles.css is declared now but produced in Plan 2.
- Step 2: Create tsconfig
Create packages/iios-messaging-ui/tsconfig.json:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx",
"types": ["react"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}
- Step 3: Create the build config
Create packages/iios-messaging-ui/tsup.config.ts:
import { defineConfig } from 'tsup';
export default defineConfig({
// `conformance` is its own entry, never reachable from `index`. 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/conformance.ts'],
format: ['esm'],
dts: true,
clean: true,
external: ['react', 'react-dom', 'vitest'],
});
- Step 4: Create the package's own vitest config
Create packages/iios-messaging-ui/vitest.config.ts:
import { defineConfig } from 'vitest/config';
// This package owns its vitest config on purpose. The ROOT config includes only
// `.ts` (never `.tsx`) and provisions a Postgres DB via globalSetup for every run —
// a pure-UI package must not drag a database along, and its tests are .tsx.
export default defineConfig({
test: {
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{ts,tsx}'],
globals: false,
},
});
- Step 5: Create a placeholder barrel
Create packages/iios-messaging-ui/src/index.ts:
export {};
- Step 6: Write the failing smoke test
Create packages/iios-messaging-ui/src/smoke.test.tsx:
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
describe('test infrastructure', () => {
it('renders React components in jsdom', () => {
render(<div>messaging-ui</div>);
expect(screen.getByText('messaging-ui')).toBeDefined();
});
});
- Step 7: Install dependencies
Run from the repo root:
pnpm install
Expected: pnpm links the new workspace package; @testing-library/react, jsdom, react-dom resolve.
- Step 8: Run the test to verify it passes
Run: pnpm --filter @insignia/iios-messaging-ui test
Expected: PASS, 1 test. If it fails with "document is not defined", environment: 'jsdom' did not take effect — check you created vitest.config.ts in the package dir, not the root.
- Step 9: Verify typecheck and build
Run: pnpm --filter @insignia/iios-messaging-ui typecheck && pnpm --filter @insignia/iios-messaging-ui build
Expected: no type errors; dist/index.js + dist/index.d.ts produced.
- Step 10: Commit
git add packages/iios-messaging-ui
git commit -m "chore: scaffold @insignia/iios-messaging-ui with jsdom test setup"
Task 2: Domain types
Files:
- Create:
packages/iios-messaging-ui/src/types.ts - Test:
packages/iios-messaging-ui/src/types.test.ts
These are the SDK's own types — deliberately not re-exported from iios-kernel-client, because core must not depend on it. Adapters map kernel/CRM DTOs onto these. Shape is taken from the CRM's UiConversation/UiMessage/UiPerson, which are already proven.
- Step 1: Write the failing test
Create packages/iios-messaging-ui/src/types.test.ts:
import { describe, it, expect } from 'vitest';
import { isOwnMessage } from './types';
import type { Message } from './types';
const base: Message = {
id: 'm1',
actorId: 'actor_a',
text: 'hello',
at: '2026-07-17T10:00:00.000Z',
};
describe('isOwnMessage', () => {
it('is true when the message actor matches the current actor', () => {
expect(isOwnMessage(base, 'actor_a')).toBe(true);
});
it('is false when the actors differ', () => {
expect(isOwnMessage(base, 'actor_b')).toBe(false);
});
it('is false when the current actor is unknown', () => {
expect(isOwnMessage(base, null)).toBe(false);
});
it('is false when the message has no actor', () => {
expect(isOwnMessage({ ...base, actorId: null }, 'actor_a')).toBe(false);
});
});
- Step 2: Run test to verify it fails
Run: pnpm --filter @insignia/iios-messaging-ui test
Expected: FAIL — "Failed to resolve import ./types" or "isOwnMessage is not a function".
- Step 3: Write the implementation
Create packages/iios-messaging-ui/src/types.ts:
// Domain types for the messaging UI. Zero imports on purpose: this file must never
// reach for a transport package. Adapters map their own DTOs onto these.
export type Membership = 'dm' | 'group';
export interface Person {
id: string;
name: string;
kind: 'staff' | 'customer';
}
export interface Conversation {
threadId: string;
title: string;
subject: string | null;
membership: Membership | null;
participants: string[];
unread: number;
lastMessage?: string;
lastAt?: string;
}
export interface Reaction {
emoji: string;
count: number;
mine: boolean;
}
export interface Attachment {
url: string;
mime: string;
name: string;
}
export interface Message {
id: string;
actorId: string | null;
text: string;
at: string;
parentInteractionId?: string | null;
reactions?: Reaction[];
attachment?: Attachment;
/** True only when the transport is optimistic-local and not yet acknowledged. */
pending?: boolean;
}
export interface SendOpts {
parentInteractionId?: string;
attachment?: Attachment;
}
export type MessageEvent =
| { kind: 'message'; message: Message }
| { kind: 'typing'; userId: string }
| { kind: 'receipt'; messageId: string; actorId: string }
| { kind: 'reaction'; messageId: string; reactions: Reaction[] };
export type Unsubscribe = () => void;
/** Ownership is a pure function of explicit identity — never inferred from history.
* See the spec: inferring it is the bug this SDK exists partly to kill. */
export function isOwnMessage(message: Message, currentActorId: string | null): boolean {
return currentActorId !== null && message.actorId !== null && message.actorId === currentActorId;
}
- Step 4: Run test to verify it passes
Run: pnpm --filter @insignia/iios-messaging-ui test
Expected: PASS, 5 tests (4 new + smoke).
- Step 5: Commit
git add packages/iios-messaging-ui/src/types.ts packages/iios-messaging-ui/src/types.test.ts
git commit -m "feat: add messaging-ui domain types"
Task 3: The MessagingAdapter contract
Files:
- Create:
packages/iios-messaging-ui/src/adapter.ts
An interface has no runtime behaviour to test directly; Task 4's conformance suite is its test. This task is type-only, so it is verified by typecheck.
- Step 1: Write the interface
Create packages/iios-messaging-ui/src/adapter.ts:
import type {
Attachment,
Conversation,
Membership,
Message,
MessageEvent,
SendOpts,
Unsubscribe,
} from './types';
/**
* The one seam of this SDK. Hosts implement this; the SDK renders it.
*
* Lifted from lynkeduppro-crm's MessengerData/ThreadData, which already survived
* two implementations (live data-door + mock) — the minimum real evidence that a
* seam is genuine rather than imagined.
*
* Optional methods degrade gracefully: the UI hides the reaction picker when
* `react` is absent, and the attach button when `upload` is absent. That is how one
* component set serves both a full CRM messenger and a stripped-down widget with no
* `mode` prop.
*/
export interface MessagingAdapter {
listConversations(): Promise<Conversation[]>;
openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }>;
history(threadId: string): Promise<Message[]>;
send(threadId: string, content: string, opts?: SendOpts): Promise<Message>;
/** Returns an unsubscribe fn. Implementations MUST be idempotent on repeat unsubscribe. */
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe;
sendTyping(threadId: string): void;
markRead(threadId: string, messageId: string): Promise<void>;
/**
* The current user's actor id, or null if not yet known.
*
* MUST NOT be inferred from message history. The CRM's bug was exactly that:
* scanning for a sent message meant every message read as not-yours until you
* had spoken. Adapters derive this from auth/session.
*/
currentActorId(): string | null;
/** Absent => the UI hides reactions entirely. */
react?(threadId: string, messageId: string, emoji: string): Promise<void>;
/** Absent => the UI hides attachments. Storage/auth/limits are the host's concern. */
upload?(file: File): Promise<Attachment>;
/** Absent => treated as always connected (e.g. a pure-REST adapter). */
isConnected?(): boolean;
}
- Step 2: Verify it typechecks
Run: pnpm --filter @insignia/iios-messaging-ui typecheck
Expected: no errors.
- Step 3: Commit
git add packages/iios-messaging-ui/src/adapter.ts
git commit -m "feat: define MessagingAdapter contract"
Task 4: Adapter conformance suite
Files:
- Create:
packages/iios-messaging-ui/src/conformance.ts
This ships in src/ (not as a .test.ts) because Plan 3's kernel and CRM adapters import it to verify themselves. One definition of "correct adapter", three implementations checked against it.
- Step 1: Write the suite
Create packages/iios-messaging-ui/src/conformance.ts:
import { describe, it, expect } from 'vitest';
import type { MessagingAdapter } from './adapter';
import type { MessageEvent } from './types';
export interface ConformanceOptions {
/** Build a fresh, isolated adapter per test. */
makeAdapter: () => Promise<MessagingAdapter> | MessagingAdapter;
/** A thread id that exists in the fixture. */
seededThreadId: string;
/** Participant ids openThread can legally be called with. */
openWith: string[];
}
/**
* The definition of a correct MessagingAdapter. Every adapter runs this.
*
* Imports vitest, so it ships from the './conformance' subpath ONLY and is never
* reachable from the main barrel. Consumers supply their own vitest.
*
* Usage from another package:
* import { runAdapterConformance } from '@insignia/iios-messaging-ui/conformance';
* runAdapterConformance({ makeAdapter: () => new MockAdapter(), seededThreadId: 'th_1', openWith: ['pp_a'] });
*/
export function runAdapterConformance(opts: ConformanceOptions): void {
const make = async () => await opts.makeAdapter();
describe('MessagingAdapter conformance', () => {
it('lists conversations', async () => {
const a = await make();
const list = await a.listConversations();
expect(Array.isArray(list)).toBe(true);
});
it('returns history for a seeded thread', async () => {
const a = await make();
const msgs = await a.history(opts.seededThreadId);
expect(Array.isArray(msgs)).toBe(true);
});
it('send resolves with a message carrying the sent text and a stable id', async () => {
const a = await make();
const m = await a.send(opts.seededThreadId, 'conformance hello');
expect(m.text).toBe('conformance hello');
expect(typeof m.id).toBe('string');
expect(m.id.length).toBeGreaterThan(0);
});
it('a sent message is attributed to the current actor', async () => {
const a = await make();
const m = await a.send(opts.seededThreadId, 'whose is this');
expect(m.actorId).toBe(a.currentActorId());
});
it('currentActorId is known BEFORE any message is sent', async () => {
// The bug this SDK exists to kill: identity must come from auth, never be
// inferred from history. A fresh adapter already knows who you are.
const a = await make();
expect(a.currentActorId()).not.toBeNull();
});
it('a sent message appears in history', async () => {
const a = await make();
await a.send(opts.seededThreadId, 'persist me');
const msgs = await a.history(opts.seededThreadId);
expect(msgs.some((m) => m.text === 'persist me')).toBe(true);
});
it('subscribe delivers a message event on send', async () => {
const a = await make();
const seen: MessageEvent[] = [];
const off = a.subscribe(opts.seededThreadId, (e) => seen.push(e));
await a.send(opts.seededThreadId, 'live one');
off();
const msgs = seen.filter((e) => e.kind === 'message');
expect(msgs.length).toBeGreaterThan(0);
});
it('unsubscribe stops delivery', async () => {
const a = await make();
const seen: MessageEvent[] = [];
const off = a.subscribe(opts.seededThreadId, (e) => seen.push(e));
off();
await a.send(opts.seededThreadId, 'should not be heard');
expect(seen).toHaveLength(0);
});
it('unsubscribe is idempotent', async () => {
const a = await make();
const off = a.subscribe(opts.seededThreadId, () => {});
off();
expect(() => off()).not.toThrow();
});
it('openThread returns a thread id', async () => {
const a = await make();
const { threadId } = await a.openThread({ participantIds: opts.openWith });
expect(typeof threadId).toBe('string');
expect(threadId.length).toBeGreaterThan(0);
});
it('markRead resolves', async () => {
const a = await make();
const m = await a.send(opts.seededThreadId, 'read me');
await expect(a.markRead(opts.seededThreadId, m.id)).resolves.toBeUndefined();
});
it('sendTyping does not throw', async () => {
const a = await make();
expect(() => a.sendTyping(opts.seededThreadId)).not.toThrow();
});
});
}
- Step 2: Verify it typechecks
Run: pnpm --filter @insignia/iios-messaging-ui typecheck
Expected: no errors. (The suite has no adapter to run against yet — that is Task 5.)
- Step 3: Commit
git add packages/iios-messaging-ui/src/conformance.ts
git commit -m "feat: add adapter conformance suite"
Task 5: Mock adapter
Files:
- Create:
packages/iios-messaging-ui/src/adapters/mock.ts - Test:
packages/iios-messaging-ui/src/conformance.test.ts
Ported from the CRM's MOCK_STORE, with one deliberate change: the store is per-instance, not module-level. The CRM's module-level Map leaks state between tests. Fixtures match the CRM's so demo mode looks identical after migration.
- Step 1: Write the failing test
Create packages/iios-messaging-ui/src/conformance.test.ts:
import { runAdapterConformance } from './conformance';
import { MockAdapter } from './adapters/mock';
runAdapterConformance({
makeAdapter: () => new MockAdapter(),
seededThreadId: 'th_mock_1',
openWith: ['pp_sofia'],
});
- Step 2: Run test to verify it fails
Run: pnpm --filter @insignia/iios-messaging-ui test
Expected: FAIL — "Failed to resolve import ./adapters/mock".
- Step 3: Write the implementation
Create packages/iios-messaging-ui/src/adapters/mock.ts:
import type { MessagingAdapter } from '../adapter';
import type {
Attachment,
Conversation,
Membership,
Message,
MessageEvent,
Person,
SendOpts,
Unsubscribe,
} from '../types';
const ME = 'me';
export const MOCK_PEOPLE: Person[] = [
{ id: 'pp_sofia', name: 'Sofia Ramirez', kind: 'staff' },
{ id: 'pp_dan', name: 'Dan Whitaker', kind: 'staff' },
{ id: 'pp_priya', name: 'Priya Nair', kind: 'staff' },
{ id: 'cust_acme', name: 'Acme Roofing (Client)', kind: 'customer' },
{ id: 'cust_globex', name: 'Globex Homes (Client)', kind: 'customer' },
];
interface MockThread {
threadId: string;
membership: Membership;
subject: string | null;
participants: string[];
messages: Message[];
}
const nameById = new Map(MOCK_PEOPLE.map((p) => [p.id, p.name]));
/**
* In-memory adapter for demos and tests. State is PER INSTANCE — the CRM's version
* used a module-level Map, which leaks between tests. Each `new MockAdapter()` is
* fully isolated.
*/
export class MockAdapter implements MessagingAdapter {
private seq = 100;
private threads = new Map<string, MockThread>();
private listeners = new Map<string, Set<(e: MessageEvent) => void>>();
constructor(private readonly now: () => string = () => new Date().toISOString()) {
this.threads.set('th_mock_1', {
threadId: 'th_mock_1',
membership: 'dm',
subject: null,
participants: [ME, 'pp_sofia'],
messages: [
{
id: 'm1',
actorId: 'pp_sofia',
text: 'Can you review the Henderson estimate?',
at: this.now(),
reactions: [],
},
],
});
this.threads.set('th_mock_2', {
threadId: 'th_mock_2',
membership: 'group',
subject: 'Storm response — East side',
participants: [ME, 'pp_dan', 'pp_priya'],
messages: [
{ id: 'm2', actorId: 'pp_dan', text: 'Crew is rolling out at 7.', at: this.now(), reactions: [] },
],
});
}
currentActorId(): string {
return ME;
}
async listConversations(): Promise<Conversation[]> {
return [...this.threads.values()].map((t) => {
const last = t.messages[t.messages.length - 1];
const others = t.participants.filter((p) => p !== ME);
return {
threadId: t.threadId,
title: t.subject || others.map((id) => nameById.get(id) ?? id).join(', ') || 'Conversation',
subject: t.subject,
membership: t.membership,
participants: t.participants,
unread: 0,
...(last ? { lastMessage: last.text, lastAt: last.at } : {}),
};
});
}
async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
const membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group');
const threadId = `th_mock_${this.seq++}`;
this.threads.set(threadId, {
threadId,
membership,
subject: p.subject ?? null,
participants: [ME, ...p.participantIds],
messages: [],
});
return { threadId };
}
async history(threadId: string): Promise<Message[]> {
return this.threads.get(threadId)?.messages ?? [];
}
async send(threadId: string, content: string, opts?: SendOpts): Promise<Message> {
const t = this.threads.get(threadId);
if (!t) throw new Error(`Unknown thread: ${threadId}`);
const message: Message = {
id: `m_${this.seq++}`,
actorId: ME,
text: content,
at: this.now(),
reactions: [],
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
...(opts?.attachment ? { attachment: opts.attachment } : {}),
};
t.messages = [...t.messages, message];
this.emit(threadId, { kind: 'message', message });
return message;
}
async react(threadId: string, messageId: string, emoji: string): Promise<void> {
const t = this.threads.get(threadId);
if (!t) return;
let next: Message | undefined;
t.messages = t.messages.map((m) => {
if (m.id !== messageId) return m;
const existing = (m.reactions ?? []).find((r) => r.emoji === emoji);
const reactions = existing
? (m.reactions ?? []).filter((r) => r.emoji !== emoji)
: [...(m.reactions ?? []), { emoji, count: 1, mine: true }];
next = { ...m, reactions };
return next;
});
if (next) this.emit(threadId, { kind: 'reaction', messageId, reactions: next.reactions ?? [] });
}
async upload(file: File): Promise<Attachment> {
return { url: `mock://uploads/${file.name}`, mime: file.type, name: file.name };
}
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe {
if (!this.listeners.has(threadId)) this.listeners.set(threadId, new Set());
this.listeners.get(threadId)!.add(cb);
return () => {
this.listeners.get(threadId)?.delete(cb);
};
}
sendTyping(): void {
// No-op: nobody is typing back in a mock.
}
async markRead(): Promise<void> {
// No-op: the mock has no second party to report a read.
}
isConnected(): boolean {
return true;
}
private emit(threadId: string, e: MessageEvent): void {
this.listeners.get(threadId)?.forEach((cb) => cb(e));
}
}
- Step 4: Run test to verify it passes
Run: pnpm --filter @insignia/iios-messaging-ui test
Expected: PASS — 12 conformance tests green.
- Step 5: Commit
git add packages/iios-messaging-ui/src/adapters/mock.ts packages/iios-messaging-ui/src/conformance.test.ts
git commit -m "feat: add MockAdapter passing the conformance suite"
Task 6: MessagingProvider
Files:
-
Create:
packages/iios-messaging-ui/src/provider.tsx -
Test:
packages/iios-messaging-ui/src/provider.test.tsx -
Step 1: Write the failing test
Create packages/iios-messaging-ui/src/provider.test.tsx:
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MessagingProvider, useAdapter } from './provider';
import { MockAdapter } from './adapters/mock';
function ShowActor() {
const adapter = useAdapter();
return <span>{adapter.currentActorId()}</span>;
}
describe('MessagingProvider', () => {
it('supplies the injected adapter to descendants', () => {
render(
<MessagingProvider adapter={new MockAdapter()}>
<ShowActor />
</MessagingProvider>,
);
expect(screen.getByText('me')).toBeDefined();
});
it('throws a helpful error when a hook is used outside the provider', () => {
// React logs the error boundary trace; that noise is expected.
expect(() => render(<ShowActor />)).toThrow(/useAdapter must be used within a <MessagingProvider>/);
});
});
- Step 2: Run test to verify it fails
Run: pnpm --filter @insignia/iios-messaging-ui test
Expected: FAIL — "Failed to resolve import ./provider".
- Step 3: Write the implementation
Create packages/iios-messaging-ui/src/provider.tsx:
import { createContext, useContext, type ReactNode } from 'react';
import type { MessagingAdapter } from './adapter';
const AdapterContext = createContext<MessagingAdapter | null>(null);
export function MessagingProvider({
adapter,
children,
}: {
adapter: MessagingAdapter;
children: ReactNode;
}) {
return <AdapterContext.Provider value={adapter}>{children}</AdapterContext.Provider>;
}
/** Access the host-injected 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 useAdapter(): MessagingAdapter {
const adapter = useContext(AdapterContext);
if (!adapter) throw new Error('useAdapter must be used within a <MessagingProvider>');
return adapter;
}
- Step 4: Run test to verify it passes
Run: pnpm --filter @insignia/iios-messaging-ui test
Expected: PASS, 2 new tests.
- Step 5: Commit
git add packages/iios-messaging-ui/src/provider.tsx packages/iios-messaging-ui/src/provider.test.tsx
git commit -m "feat: add MessagingProvider and useAdapter"
Task 7: useConversations
Files:
-
Create:
packages/iios-messaging-ui/src/hooks/use-conversations.ts -
Test:
packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx -
Step 1: Write the failing test
Create packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx:
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import type { ReactNode } from 'react';
import { MessagingProvider } from '../provider';
import { MockAdapter } from '../adapters/mock';
import { useConversations } from './use-conversations';
import type { MessagingAdapter } from '../adapter';
const wrap = (adapter: MessagingAdapter) =>
function Wrapper({ children }: { children: ReactNode }) {
return <MessagingProvider adapter={adapter}>{children}</MessagingProvider>;
};
describe('useConversations', () => {
it('starts loading, then resolves the adapter list', async () => {
const { result } = renderHook(() => useConversations(), { wrapper: wrap(new MockAdapter()) });
expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.conversations).toHaveLength(2);
expect(result.current.conversations[0]!.threadId).toBe('th_mock_1');
expect(result.current.error).toBeNull();
});
it('surfaces adapter failure as error state and never throws', async () => {
const adapter = new MockAdapter();
vi.spyOn(adapter, 'listConversations').mockRejectedValue(new Error('data door down'));
const { result } = renderHook(() => useConversations(), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe('data door down');
expect(result.current.conversations).toEqual([]);
});
it('refetch picks up newly opened threads', async () => {
const adapter = new MockAdapter();
const { result } = renderHook(() => useConversations(), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.conversations).toHaveLength(2));
await act(async () => {
await adapter.openThread({ participantIds: ['pp_dan'] });
});
await act(async () => {
result.current.refetch();
});
await waitFor(() => expect(result.current.conversations).toHaveLength(3));
});
});
- Step 2: Run test to verify it fails
Run: pnpm --filter @insignia/iios-messaging-ui test
Expected: FAIL — "Failed to resolve import ./use-conversations".
- Step 3: Write the implementation
Create packages/iios-messaging-ui/src/hooks/use-conversations.ts:
import { useCallback, useEffect, useState } from 'react';
import { useAdapter } from '../provider';
import type { Conversation } from '../types';
export interface ConversationsState {
conversations: Conversation[];
loading: boolean;
error: string | null;
refetch: () => void;
}
export function useConversations(): ConversationsState {
const adapter = useAdapter();
const [conversations, setConversations] = useState<Conversation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [nonce, setNonce] = useState(0);
useEffect(() => {
let alive = true;
setLoading(true);
adapter
.listConversations()
.then((list) => {
if (!alive) return;
setConversations(list);
setError(null);
})
.catch((e: unknown) => {
if (!alive) return;
setError(e instanceof Error ? e.message : String(e));
setConversations([]);
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [adapter, nonce]);
const refetch = useCallback(() => setNonce((n) => n + 1), []);
return { conversations, loading, error, refetch };
}
- Step 4: Run test to verify it passes
Run: pnpm --filter @insignia/iios-messaging-ui test
Expected: PASS, 3 new tests.
- Step 5: Commit
git add packages/iios-messaging-ui/src/hooks
git commit -m "feat: add useConversations hook"
Task 8: useMessages
Files:
- Create:
packages/iios-messaging-ui/src/hooks/use-messages.ts - Test:
packages/iios-messaging-ui/src/hooks/use-messages.test.tsx
The heart of the SDK. Three behaviours matter most: ownership before you speak (the CRM bug), optimistic send with rollback, and event reconciliation by id (no duplicates when the echo arrives).
- Step 1: Write the failing test
Create packages/iios-messaging-ui/src/hooks/use-messages.test.tsx:
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import type { ReactNode } from 'react';
import { MessagingProvider } from '../provider';
import { MockAdapter } from '../adapters/mock';
import { useMessages } from './use-messages';
import type { MessagingAdapter } from '../adapter';
const wrap = (adapter: MessagingAdapter) =>
function Wrapper({ children }: { children: ReactNode }) {
return <MessagingProvider adapter={adapter}>{children}</MessagingProvider>;
};
describe('useMessages', () => {
it('loads history for the thread', async () => {
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(new MockAdapter()) });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]!.text).toBe('Can you review the Henderson estimate?');
});
// REGRESSION: the CRM inferred actor identity by scanning for a sent message, so
// before you had spoken in a thread EVERY message rendered as not-yours.
it('marks ownership correctly before the user has sent anything', async () => {
const adapter = new MockAdapter();
await adapter.send('th_mock_1', 'an earlier message of mine');
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.messages).toHaveLength(2));
// Never sent anything via the hook — ownership still resolves from currentActorId().
expect(result.current.messages[0]!.mine).toBe(false); // from pp_sofia
expect(result.current.messages[1]!.mine).toBe(true); // from me
});
it('appends an optimistic message immediately on send', async () => {
const adapter = new MockAdapter();
let release!: () => void;
vi.spyOn(adapter, 'send').mockImplementation(
() => new Promise((res) => { release = () => res({ id: 'srv_1', actorId: 'me', text: 'hi', at: '2026-07-17T10:00:00.000Z' }); }),
);
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => { void result.current.send('hi'); });
await waitFor(() => expect(result.current.messages).toHaveLength(2));
expect(result.current.messages[1]!.pending).toBe(true);
expect(result.current.messages[1]!.mine).toBe(true);
await act(async () => { release(); });
await waitFor(() => expect(result.current.messages[1]!.pending).toBeFalsy());
});
it('rolls back the optimistic message and reports error when send fails', async () => {
const adapter = new MockAdapter();
vi.spyOn(adapter, 'send').mockRejectedValue(new Error('offline'));
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await expect(result.current.send('doomed')).rejects.toThrow('offline');
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages.some((m) => m.text === 'doomed')).toBe(false);
expect(result.current.error).toBe('offline');
});
it('does not duplicate a message when the transport echoes it back', async () => {
const adapter = new MockAdapter();
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
// MockAdapter.send emits a 'message' event AND resolves with the same message.
await act(async () => { await result.current.send('echo once'); });
expect(result.current.messages.filter((m) => m.text === 'echo once')).toHaveLength(1);
});
it('collects typing user ids from subscribe events', async () => {
const adapter = new MockAdapter();
let emit!: (userId: string) => void;
vi.spyOn(adapter, 'subscribe').mockImplementation((_t, cb) => {
emit = (userId) => cb({ kind: 'typing', userId });
return () => {};
});
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => emit('pp_sofia'));
expect(result.current.typingUserIds).toEqual(['pp_sofia']);
});
it('unsubscribes on unmount', async () => {
const adapter = new MockAdapter();
const off = vi.fn();
vi.spyOn(adapter, 'subscribe').mockReturnValue(off);
const { unmount, result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
unmount();
expect(off).toHaveBeenCalled();
});
});
- Step 2: Run test to verify it fails
Run: pnpm --filter @insignia/iios-messaging-ui test
Expected: FAIL — "Failed to resolve import ./use-messages".
- Step 3: Write the implementation
Create packages/iios-messaging-ui/src/hooks/use-messages.ts:
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAdapter } from '../provider';
import { isOwnMessage } from '../types';
import type { Message, SendOpts } from '../types';
const TYPING_TTL_MS = 3500;
export interface UiMessage extends Message {
mine: boolean;
}
export interface MessagesState {
messages: UiMessage[];
loading: boolean;
error: string | null;
send: (content: string, opts?: SendOpts) => Promise<void>;
react: (messageId: string, emoji: string) => Promise<void>;
typingUserIds: string[];
seenIds: Set<string>;
sendTyping: () => void;
canReact: boolean;
canUpload: boolean;
}
let optimisticSeq = 0;
export function useMessages(threadId: string | null): MessagesState {
const adapter = useAdapter();
const [raw, setRaw] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [typing, setTyping] = useState<Record<string, number>>({});
const [seenIds, setSeenIds] = useState<Set<string>>(new Set());
const [, forceTick] = useState(0);
const currentActorId = adapter.currentActorId();
const actorRef = useRef(currentActorId);
actorRef.current = currentActorId;
// Load history, then subscribe. Reconciliation is by message id, so an echoed
// send never duplicates the optimistic row.
useEffect(() => {
if (!threadId) {
setRaw([]);
setLoading(false);
return;
}
let alive = true;
setLoading(true);
setRaw([]);
setSeenIds(new Set());
setTyping({});
adapter
.history(threadId)
.then((h) => {
if (alive) {
setRaw(h);
setError(null);
}
})
.catch((e: unknown) => {
if (alive) setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (alive) setLoading(false);
});
const off = adapter.subscribe(threadId, (e) => {
if (!alive) return;
switch (e.kind) {
case 'message':
setRaw((l) => (l.some((m) => m.id === e.message.id) ? l : [...l, e.message]));
break;
case 'typing':
if (e.userId !== actorRef.current) {
setTyping((t) => ({ ...t, [e.userId]: Date.now() + TYPING_TTL_MS }));
}
break;
case 'receipt':
// Only the OTHER side reading my message counts as "seen".
if (e.actorId !== actorRef.current) {
setSeenIds((s) => (s.has(e.messageId) ? s : new Set(s).add(e.messageId)));
}
break;
case 'reaction':
setRaw((l) => l.map((m) => (m.id === e.messageId ? { ...m, reactions: e.reactions } : m)));
break;
}
});
return () => {
alive = false;
off();
};
}, [adapter, threadId]);
const messages: UiMessage[] = useMemo(
() => raw.map((m) => ({ ...m, mine: isOwnMessage(m, currentActorId) })),
[raw, currentActorId],
);
const send = useCallback(
async (content: string, opts?: SendOpts) => {
if (!threadId) return;
const tempId = `optimistic_${optimisticSeq++}`;
const optimistic: Message = {
id: tempId,
actorId: actorRef.current,
text: content,
at: new Date().toISOString(),
pending: true,
reactions: [],
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
...(opts?.attachment ? { attachment: opts.attachment } : {}),
};
setRaw((l) => [...l, optimistic]);
try {
const saved = await adapter.send(threadId, content, opts);
setError(null);
// Replace the optimistic row with the server's. If the subscribe echo already
// added the real message, just drop the optimistic one.
setRaw((l) => {
const withoutTemp = l.filter((m) => m.id !== tempId);
return withoutTemp.some((m) => m.id === saved.id) ? withoutTemp : [...withoutTemp, saved];
});
} catch (e: unknown) {
setRaw((l) => l.filter((m) => m.id !== tempId));
setError(e instanceof Error ? e.message : String(e));
throw e;
}
},
[adapter, threadId],
);
const react = useCallback(
async (messageId: string, emoji: string) => {
if (!threadId || !adapter.react) return;
await adapter.react(threadId, messageId, emoji);
},
[adapter, threadId],
);
const sendTyping = useCallback(() => {
if (threadId) adapter.sendTyping(threadId);
}, [adapter, threadId]);
// Report my read of the newest message (drives the other side's "seen" tick).
useEffect(() => {
if (!threadId || raw.length === 0) return;
const last = raw[raw.length - 1]!;
if (last.pending) return;
void adapter.markRead(threadId, last.id).catch(() => {});
}, [adapter, threadId, raw]);
const typingUserIds = useMemo(() => {
const now = Date.now();
return Object.entries(typing)
.filter(([, exp]) => exp > now)
.map(([u]) => u);
}, [typing]);
// Expire stale typing entries by re-evaluating after the TTL.
useEffect(() => {
if (typingUserIds.length === 0) return;
const t = setTimeout(() => forceTick((n) => n + 1), TYPING_TTL_MS);
return () => clearTimeout(t);
}, [typingUserIds.length, typing]);
// Only my messages that the other side has read.
const seenMine = useMemo(() => {
const out = new Set<string>();
for (const id of seenIds) if (messages.some((m) => m.id === id && m.mine)) out.add(id);
return out;
}, [seenIds, messages]);
return {
messages,
loading,
error,
send,
react,
typingUserIds,
seenIds: seenMine,
sendTyping,
canReact: typeof adapter.react === 'function',
canUpload: typeof adapter.upload === 'function',
};
}
- Step 4: Run test to verify it passes
Run: pnpm --filter @insignia/iios-messaging-ui test
Expected: PASS, 7 new tests.
- Step 5: Commit
git add packages/iios-messaging-ui/src/hooks/use-messages.ts packages/iios-messaging-ui/src/hooks/use-messages.test.tsx
git commit -m "feat: add useMessages hook with explicit ownership and optimistic send"
Task 9: Public barrel + transport-freedom enforcement
Files:
- Modify:
packages/iios-messaging-ui/src/index.ts - Create:
packages/iios-messaging-ui/src/boundary.test.ts - Modify:
scripts/check-import-boundary.mjs:22(add the new package toALLOWED)
The repo's boundary script is package-level, so it cannot express "core must not import kernel-client, but adapters/kernel may". That distinction is this SDK's core premise, so it gets its own test.
- Step 1: Write the failing test
Create packages/iios-messaging-ui/src/boundary.test.ts:
import { describe, it, expect } from 'vitest';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
// This package is ESM ("type": "module") — __dirname does not exist here.
const SRC = fileURLToPath(new URL('.', import.meta.url));
const ADAPTERS = join(SRC, 'adapters');
function tsFilesIn(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...tsFilesIn(full));
else if (/\.tsx?$/.test(full)) out.push(full);
}
return out;
}
// The whole premise of this package: core renders, adapters transport. If core ever
// imports a transport, an app on a different backend pays for socket code it cannot
// use — which is exactly how iios-message-web welded itself to MessageSocket.
describe('transport boundary', () => {
const coreFiles = tsFilesIn(SRC).filter((f) => !f.startsWith(ADAPTERS) && !/\.test\.tsx?$/.test(f));
it('has core files to check', () => {
expect(coreFiles.length).toBeGreaterThan(0);
});
it('core never imports a transport package', () => {
const offenders: string[] = [];
for (const file of coreFiles) {
const content = readFileSync(file, 'utf8');
if (/@insignia\/iios-kernel-client|socket\.io|@abe-kap\/appshell-sdk/.test(content)) {
offenders.push(file.replace(SRC, 'src'));
}
}
expect(offenders).toEqual([]);
});
});
- Step 2: Run test to verify it passes
Run: pnpm --filter @insignia/iios-messaging-ui test
Expected: PASS. (This test guards a property that already holds — it fails loudly in Plan 3 if someone imports the kernel from core.)
- Step 3: Write the public barrel
Replace packages/iios-messaging-ui/src/index.ts:
// Public API. Components land in Plan 2; adapters/kernel in Plan 3.
//
// `runAdapterConformance` is deliberately NOT exported here — it imports vitest and
// ships from the './conformance' subpath so consumers never pull a test runner into
// their production bundle.
export { MessagingProvider, useAdapter } from './provider';
export { useConversations } from './hooks/use-conversations';
export { useMessages } from './hooks/use-messages';
export { isOwnMessage } from './types';
export type { MessagingAdapter } from './adapter';
export type { ConversationsState } from './hooks/use-conversations';
export type { MessagesState, UiMessage } from './hooks/use-messages';
export type {
Attachment,
Conversation,
Membership,
Message,
MessageEvent,
Person,
Reaction,
SendOpts,
Unsubscribe,
} from './types';
- Step 4: Register the package in the repo's import-boundary law
In scripts/check-import-boundary.mjs, add this entry to the ALLOWED map (after the '@insignia/iios-message-web' line):
// Core is transport-free; only ./adapters/kernel may import the kernel client.
// This map is package-level and cannot express that subpath rule — the finer
// constraint is enforced by packages/iios-messaging-ui/src/boundary.test.ts.
'@insignia/iios-messaging-ui': ['@insignia/iios-kernel-client'],
- Step 5: Verify the boundary law still passes
Run: pnpm boundary
Expected: ✓ import-boundary: OK
- Step 6: Verify the full package
Run: pnpm --filter @insignia/iios-messaging-ui test && pnpm --filter @insignia/iios-messaging-ui typecheck && pnpm --filter @insignia/iios-messaging-ui build
Expected: all tests PASS; no type errors; dist/index.js, dist/index.d.ts, dist/adapters/mock.js, dist/conformance.js produced.
- Step 6b: Verify the main entry does not drag in vitest
Run: grep -c vitest packages/iios-messaging-ui/dist/index.js || echo "clean"
Expected: clean (grep finds nothing and exits 1). If this prints a number, conformance.ts leaked into the main barrel — remove the re-export from src/index.ts.
Run: grep -rE "iios-kernel-client|socket\.io" packages/iios-messaging-ui/dist/index.js || echo "transport-free"
Expected: transport-free.
- Step 7: Verify the root test suite is unaffected
Run: pnpm test
Expected: the root suite passes exactly as before. Its include matches only .ts, so this package's .tsx tests are not picked up there — intentional, since root provisions Postgres. The package's tests run via its own test script.
- Step 8: Commit
git add packages/iios-messaging-ui/src/index.ts packages/iios-messaging-ui/src/boundary.test.ts scripts/check-import-boundary.mjs
git commit -m "feat: export messaging-ui public API and enforce transport boundary"
Done criteria
pnpm --filter @insignia/iios-messaging-ui test— all green (33 tests as shipped).pnpm boundary— OK.pnpm testat root — unchanged.- The package builds to ESM with types and has zero runtime dependencies.
- A host can already drive messaging headlessly:
MessagingProvider+MockAdapter+ hooks.
Post-implementation corrections (2026-07-18)
Executed via subagent-driven development on branch feat/inbox-owner-interest. Code review during Tasks 5 and 8 caught defects in the code blocks above — the shipped code diverges from the plan text in these ways, and Plan 3 should treat the committed source (not the Task 8 listing above) as authoritative:
useMessagestyping expiry — the plan usedforceTick((n) => n + 1)to expire stale typing entries. That is a no-op: thetypingUserIdsmemo is keyed on[typing], so an unrelated state bump returns the cached array and the indicator sticks forever. Shipped code usessetTimeout(() => setTyping((p) => ({ ...p })), TYPING_TTL_MS)(a newtypingreference forces the memo to recompute with a freshnow).forceTickstate removed entirely.useMessageshistory race — the plan's.then((h) => setRaw(h))clobbers live messages that arrive viasubscribewhilehistory()is still in flight (silent data loss on any async transport; the sync MockAdapter can't show it). Shipped code merges by id:setRaw((live) => { const histIds = new Set(h.map(m => m.id)); const extras = live.filter(m => !histIds.has(m.id)); return extras.length ? [...h, ...extras] : h; }). Guarded by the test "keeps a live message that arrives before history resolves" (verified to fail if the merge is reverted).useMessagesmarkRead — re-keyed from[adapter, threadId, raw](fired on every array churn, incl. reactions) to a derivedlastReadableId(newest non-pending id), so it fires only when the read frontier actually moves.useMessageserror reset —setError(null)added to the thread-switch reset block (plan left a stale error banner across thread switches).MockAdapter—history()andlistConversations().participantsnow return copies (plan leaked the live internal arrays); constructor callsnow()once for both seed messages.boundary.test.ts— carries a// @vitest-environment nodedirective on line 1. jsdom overrides the globalURLconstructor, which breaksfileURLToPath(new URL('.', import.meta.url)); a filesystem test must run in node.
Known limitation deferred to Plan 3 (issue I2): useMessages reads adapter.currentActorId() per-render but nothing makes it reactive. If a real adapter's identity resolves from null to a value with no intervening React state change, ownership stays wrong until an incidental re-render — and an optimistic message stamped while identity is null renders as not-mine. The MockAdapter never has null identity, so this does not manifest in the foundation. Plan 3 must decide how the real adapter exposes identity reactively (e.g. an onActorIdChange subscription on the interface, or the host re-rendering the provider when auth resolves) rather than relying on incidental re-renders.
What Plan 2 adds
Components (ConversationList, ThreadView, Composer, MessageBubble, Messenger), the SDK's own internal primitives (the CRM's Avatar/Btn/Icon cannot be imported — they live in lynkeduppro-crm/src/components/dashboard/ui.tsx), styles.css with the --msg-* token contract, and the classNames slot map.
What Plan 3 adds
./adapters/kernel (wrapping iios-kernel-client, running the conformance suite), CrmMessagingAdapter in the CRM repo (data door + socket + 4s poll fallback, also running conformance), attachments end-to-end, and the CRM migration that deletes messenger.tsx.
Both adapters verify themselves with import { runAdapterConformance } from '@insignia/iios-messaging-ui/conformance'. Plan 3 must add @insignia/iios-kernel-client to this package's dependencies and to the tsup entry + external lists — and src/boundary.test.ts will fail if that import reaches core rather than staying inside src/adapters/.
Strengthen the safety nets before writing the kernel adapter (from the final foundation review — the current versions are adequate for the mock but too weak to catch a broken real adapter):
- Expand
runAdapterConformance. Today it only exercises the happymessagepath. It does NOT test the optional methods (react/upload/isConnected), subscribe thread-isolation (an adapter that leaks every thread's events to every subscriber would pass yet break the UI), or thereceipt/reaction/typingevent kinds. Since conformance IS the safety net for the real kernel adapter, add these cases first. - Convert
boundary.test.tsfrom a denylist to an allowlist. It currently greps core for three transport strings (iios-kernel-client|socket.io|@abe-kap/appshell-sdk); a core file importing a different transport (ws,pusher, raw WebSocket) would slip through, and the rootpnpm boundaryallowlist only governs at package granularity. Onceadapters/kernelexists, assert core imports onlyreact+ relative paths. - Also address the deferred I2 (reactive identity) noted under Post-implementation corrections above, and resolve the two Plan-2 notes: emit
dist/styles.cssso the exports entry resolves, and decide whetherreact()failures should populateerror(today onlysend()does).
CI note for whoever wires it
Root pnpm test does not run this package's tests. CI must also run:
pnpm --filter @insignia/iios-messaging-ui test
The alternative — folding .tsx + jsdom into the root config — would make every UI test wait on Postgres provisioning. Not worth it.