From 191c9748c58e9dd0b8bf441cd63554906b9e02d8 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 01:42:54 +0530 Subject: [PATCH] docs: messaging-ui foundation implementation plan (plan 1 of 3) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-17-messaging-ui-foundation.md | 1547 +++++++++++++++++ 1 file changed, 1547 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-17-messaging-ui-foundation.md diff --git a/docs/superpowers/plans/2026-07-17-messaging-ui-foundation.md b/docs/superpowers/plans/2026-07-17-messaging-ui-foundation.md new file mode 100644 index 0000000..5c14ff3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-messaging-ui-foundation.md @@ -0,0 +1,1547 @@ +# 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, narrowed `send` API, 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. Its `MessengerData`/`ThreadData` interfaces 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.ts` includes only `packages/**/src/**/*.{test,spec}.ts` — **`.tsx` is not matched**, and its `globalSetup` provisions a Postgres DB on port 5434 for every run. This package therefore gets its **own** `vitest.config.ts` with `environment: 'jsdom'` and **no** `globalSetup`. Never add a DB dependency to a pure-UI package. +- `tsconfig.base.json` sets `"module": "commonjs"` — every web package overrides this to `ESNext` + `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`: + +```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`: + +```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`: + +```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`: + +```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`: + +```ts +export {}; +``` + +- [ ] **Step 6: Write the failing smoke test** + +Create `packages/iios-messaging-ui/src/smoke.test.tsx`: + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +describe('test infrastructure', () => { + it('renders React components in jsdom', () => { + render(
messaging-ui
); + expect(screen.getByText('messaging-ui')).toBeDefined(); + }); +}); +``` + +- [ ] **Step 7: Install dependencies** + +Run from the repo root: + +```bash +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** + +```bash +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`: + +```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`: + +```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** + +```bash +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`: + +```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; + + openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }>; + + history(threadId: string): Promise; + + send(threadId: string, content: string, opts?: SendOpts): Promise; + + /** 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; + + /** + * 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; + + /** Absent => the UI hides attachments. Storage/auth/limits are the host's concern. */ + upload?(file: File): Promise; + + /** 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** + +```bash +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`: + +```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; + /** 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** + +```bash +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`: + +```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`: + +```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(); + private listeners = new Map 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 { + 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 { + return this.threads.get(threadId)?.messages ?? []; + } + + async send(threadId: string, content: string, opts?: SendOpts): Promise { + 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 { + 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 { + 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 { + // 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** + +```bash +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`: + +```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 {adapter.currentActorId()}; +} + +describe('MessagingProvider', () => { + it('supplies the injected adapter to descendants', () => { + render( + + + , + ); + 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()).toThrow(/useAdapter must be used within a /); + }); +}); +``` + +- [ ] **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`: + +```tsx +import { createContext, useContext, type ReactNode } from 'react'; +import type { MessagingAdapter } from './adapter'; + +const AdapterContext = createContext(null); + +export function MessagingProvider({ + adapter, + children, +}: { + adapter: MessagingAdapter; + children: ReactNode; +}) { + return {children}; +} + +/** 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 '); + 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** + +```bash +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`: + +```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 {children}; + }; + +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`: + +```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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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** + +```bash +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`: + +```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 {children}; + }; + +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`: + +```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; + react: (messageId: string, emoji: string) => Promise; + typingUserIds: string[]; + seenIds: Set; + sendTyping: () => void; + canReact: boolean; + canUpload: boolean; +} + +let optimisticSeq = 0; + +export function useMessages(threadId: string | null): MessagesState { + const adapter = useAdapter(); + const [raw, setRaw] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [typing, setTyping] = useState>({}); + const [seenIds, setSeenIds] = useState>(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(); + 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** + +```bash +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 to `ALLOWED`) + +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`: + +```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`: + +```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): + +```js + // 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** + +```bash +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 test` at 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: + +- **`useMessages` typing expiry** — the plan used `forceTick((n) => n + 1)` to expire stale typing entries. That is a no-op: the `typingUserIds` memo is keyed on `[typing]`, so an unrelated state bump returns the cached array and the indicator sticks forever. Shipped code uses `setTimeout(() => setTyping((p) => ({ ...p })), TYPING_TTL_MS)` (a new `typing` reference forces the memo to recompute with a fresh `now`). `forceTick` state removed entirely. +- **`useMessages` history race** — the plan's `.then((h) => setRaw(h))` clobbers live messages that arrive via `subscribe` while `history()` 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). +- **`useMessages` markRead** — re-keyed from `[adapter, threadId, raw]` (fired on every array churn, incl. reactions) to a derived `lastReadableId` (newest non-pending id), so it fires only when the read frontier actually moves. +- **`useMessages` error reset** — `setError(null)` added to the thread-switch reset block (plan left a stale error banner across thread switches). +- **`MockAdapter`** — `history()` and `listConversations().participants` now return copies (plan leaked the live internal arrays); constructor calls `now()` once for both seed messages. +- **`boundary.test.ts`** — carries a `// @vitest-environment node` directive on line 1. jsdom overrides the global `URL` constructor, which breaks `fileURLToPath(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 happy `message` path. 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 the `receipt`/`reaction`/`typing` event kinds. Since conformance IS the safety net for the real kernel adapter, add these cases first. +- **Convert `boundary.test.ts` from 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 root `pnpm boundary` allowlist only governs at package granularity. Once `adapters/kernel` exists, assert core imports only `react` + 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.css` so the exports entry resolves, and decide whether `react()` failures should populate `error` (today only `send()` does). + +## CI note for whoever wires it + +Root `pnpm test` does not run this package's tests. CI must also run: + +```bash +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.