Files
iios/docs/superpowers/plans/2026-07-17-messaging-ui-foundation.md
T
2026-07-23 00:15:20 +05:30

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, 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:

{
  "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 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:

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 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 resetsetError(null) added to the thread-switch reset block (plan left a stale error banner across thread switches).
  • MockAdapterhistory() 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:

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.