iblai-agent-chat

Add the in-process Chat SDK component (full agent surface — message stream, canvas, file attach, voice, prompts) to a Next.js app

12 stars

Best use case

iblai-agent-chat is best used when you need a repeatable AI agent workflow instead of a one-off prompt.

Add the in-process Chat SDK component (full agent surface — message stream, canvas, file attach, voice, prompts) to a Next.js app

Teams using iblai-agent-chat should expect a more consistent output, faster repeated execution, less prompt rewriting.

When to use this skill

  • You want a reusable workflow that can be run more than once with consistent structure.

When not to use this skill

  • You only need a quick one-off answer and do not need a reusable workflow.
  • You cannot install or maintain the underlying files, dependencies, or repository context.

Installation

Claude Code / Cursor / Codex

$curl -o ~/.claude/skills/iblai-agent-chat/SKILL.md --create-dirs "https://raw.githubusercontent.com/iblai/vibe/main/skills/iblai-agent-chat/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/iblai-agent-chat/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How iblai-agent-chat Compares

Feature / Agentiblai-agent-chatStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/A

Frequently Asked Questions

What does this skill do?

Add the in-process Chat SDK component (full agent surface — message stream, canvas, file attach, voice, prompts) to a Next.js app

Where can I find the source code?

You can find the source code on GitHub using the link provided at the top of the page.

SKILL.md Source

# /iblai-agent-chat

Add the full ibl.ai agent chat surface — message stream, conversation
starters, canvas, file attach, voice input, voice call, screen sharing,
prompt gallery — to your Next.js app. Uses the `Chat` React component
from `@iblai/iblai-js/web-containers/next`, which renders **in-process**
(not in an iframe) and shares the host app's Redux store, providers, and
auth session.

![Welcome state](https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/skills/iblai-agent-chat/iblai-agent-chat-1-welcome.png)
![Message sent](https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/skills/iblai-agent-chat/iblai-agent-chat-2-message-sent.png)

> **What you get:** the SDK's `Chat` component wired directly into your
> app (in-process, not an iframe). Full feature surface, intercept
> actions, shared auth/store with the rest of your app, themeable with
> your Tailwind config.

Do NOT add custom styles, colors, or CSS overrides to the SDK `Chat`
component. It ships with its own styling. Keep the component as-is.
Do NOT implement dark mode unless the user explicitly asks for it.

> **This is a revised skill.** It folds in everything learned from a
> real brownfield integration (Next.js App Router, React 19, RTK, pnpm,
> sub-path `basePath`). Greenfield apps can mostly follow the happy
> path; brownfield apps must read the **Brownfield** and **Known
> issues** notes — most of the integration cost lives there, not in the
> happy path.

> **Common setup (brand, conventions, env files, verification):** see
> [docs/skill-setup.md](https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/docs/skill-setup.md).

## Prerequisites

- Auth must be set up first (`/iblai-auth`) — the `Chat` component reads
  the axd token, username, and tenants from the providers tree.
- A working store, providers, and SSO callback route — i.e. an app that
  already passes `/iblai-auth` verification.
- An agent/mentor ID (a UUID) — get one at https://mentorai.iblai.app.
- **Minimum SDK versions.** The `Chat` component and its required hooks
  are only present in recent SDKs. Older-but-recent installs silently
  lack them. Require at least:

  | package | min version |
  |---|---|
  | `@iblai/iblai-js` | `^1.10.3` |
  | `@iblai/web-containers` | `1.6.14` |
  | `@iblai/web-utils` | `1.6.9` |
  | `@iblai/data-layer` | `1.5.7` |

- **Pre-flight export check (do this before writing any code):**

  ```bash
  grep -qE "declare function Chat\b|type ChatConfig" \
    node_modules/@iblai/web-containers/dist/next/index.d.ts \
    && echo "Chat present" \
    || echo "MISSING — upgrade the SDK packages (see Step 3)"
  ```

  `@iblai/iblai-js/web-containers/next` re-exports `@iblai/web-containers/next`;
  the component physically lives in `@iblai/web-containers`. If the
  check fails, the rest of this skill cannot work — upgrade first.

> **Caveat: the SDK `.d.ts` is incomplete and sometimes misleading.**
> Several runtime props/args (e.g. `getChatHistory`'s `userId` path
> param) are absent from the type defs, and some documented slot props
> are typed but not implemented. When something doesn't match, verify
> against `node_modules/@iblai/web-containers/dist/next/index.esm.js`
> (the actual bundle), not just the types.

## What Gets Wired

| File | Change |
|------|--------|
| `package.json` | Adds SDK packages + peers (Step 3) |
| `providers/index.tsx` | Wraps tree in `<ServiceWorkerProvider>`; `skip={isSsoLoginRoute}` on Auth/Tenant providers (Step 4) |
| `public/sw.js` | The SDK offline service worker — required by `ServiceWorkerProvider` (Step 4) |
| `next.config.*` | `Service-Worker-Allowed` header for sub-path mounts; `reactStrictMode:false` (Step 4, Known issues) |
| `store/index.ts` | Registers `chat`, `chatInput`, `chatSliceShared`, `files`, `rbac`, `subscription`, `topBanner` reducers (Step 5) |
| `app/agents/[mentorId]/chat-new/page.tsx` | New route rendering `<Chat>` with resume/new-chat handling (Step 6) |
| `components/radix-pointer-events-guard.tsx` | Host recovery for the SDK prompt-gallery teardown bug (Known issues) |

## Step 1: Check Environment

Confirm `.env.local` has the env vars the SDK reads at runtime:

```
NEXT_PUBLIC_BASE_WS_URL=wss://asgi.data.iblai.org
NEXT_PUBLIC_AUTH_URL=https://auth.iblai.org
NEXT_PUBLIC_DM_URL=https://api.iblai.org/dm
NEXT_PUBLIC_LMS_URL=https://api.iblai.org/lms
NEXT_PUBLIC_AXD_URL=https://api.iblai.org/dm
NEXT_PUBLIC_AGENT_URL=http://localhost:3000
NEXT_PUBLIC_MAIN_TENANT_KEY=<your-platform-key>
NEXT_PUBLIC_SUPPORT_EMAIL=support@ibl.ai
```

Note: some host `config` helpers don't expose `supportEmail()`. If
yours doesn't, read `process.env.NEXT_PUBLIC_SUPPORT_EMAIL` directly in
`ChatConfig` (Step 6) — don't assume the helper has every field.

## Step 2: Get the Agent ID

Ask the user for their agent/mentor UUID. Write it directly to
`.env.local` using the Edit tool — do NOT echo it back in shell
commands:

```
NEXT_PUBLIC_DEFAULT_AGENT_ID=<the-uuid>
```

If the user doesn't have one, direct them to https://mentorai.iblai.app
to create an agent.

## Step 3: Install Dependencies

```bash
pnpm add @iblai/data-layer @iblai/web-containers @iblai/web-utils \
         @iblai/agent-ai \
         react-paginate livekit-client \
         @livekit/components-react @livekit/components-styles \
         @tauri-apps/plugin-os @tauri-apps/api
```

Why each one:

| Package | Used by |
|---------|---------|
| `@iblai/data-layer`, `@iblai/web-containers`, `@iblai/web-utils` | Hoist SDK packages to top-level `node_modules` so the bundler resolves them from the host |
| `@iblai/agent-ai` | Voice/agent web component — **dynamically imported by `<Chat>`** (`import('@iblai/agent-ai')`). Easy to miss; its absence is a hard build error |
| `react-paginate` | Peer of `@iblai/web-containers` (agent search pagination) |
| `livekit-client`, `@livekit/components-react`, `@livekit/components-styles` | Voice call / screen-sharing |
| `@tauri-apps/plugin-os`, `@tauri-apps/api` | OS detection (Tauri desktop wrapper) |

> **All of these are build-time-required, even the runtime-guarded
> ones.** `<Chat>` statically/dynamically imports them; a missing
> package is a hard `Module not found` from Turbopack/Webpack, **not** a
> soft runtime fallback. Don't defer any of them "until needed".

If on a fresh upgrade you hit `Module not found: Can't resolve
'@iblai/...'` for a name you *did* install, the export may have moved
between subpaths — e.g. `AgentSearchResult` moved from
`@iblai/iblai-js/web-containers` to `@iblai/iblai-js/web-containers/next`.
Expect to fix a few import paths after an SDK bump; the
`/web-containers` vs `/web-containers/next` split is real (the chat
component lives under `/next`).

## Step 4: Wire the Providers + Service Worker

The `Chat` component uses `useAuthContext`, `useTenantContext`,
`useEmbedMode`, `useServiceWorker`, and several Redux hooks. The host
needs all of these.

### 4a. Providers

In `providers/index.tsx`:

1. Import `ServiceWorkerProvider` from `@iblai/iblai-js/web-utils`.
2. Wrap the existing `<AuthProvider>` tree in `<ServiceWorkerProvider>`.
3. Pass `skip={isSsoLoginRoute}` to **both** `<AuthProvider>` and
   `<TenantProvider>` so the `/sso-login-complete` callback page doesn't
   crash on missing context.
4. **Pass `basePath` if the app is mounted under a sub-path** (e.g.
   `NEXT_PUBLIC_BASE_PATH=/hq`):

```tsx
import {
  AuthProvider,
  TenantProvider,
  MentorProvider,
  ServiceWorkerProvider,
} from "@iblai/iblai-js/web-utils";

return (
  <ServiceWorkerProvider basePath={config.basePath() /* "" or "/hq" */}>
    <AuthProvider /* …, */ skip={isSsoLoginRoute}>
      <TenantProvider /* …, */ skip={isSsoLoginRoute}>
        <MentorProvider {...mentorProps}>{children}</MentorProvider>
      </TenantProvider>
    </AuthProvider>
  </ServiceWorkerProvider>
);
```

### 4b. Ship the service worker (required)

`ServiceWorkerProvider` calls
`navigator.serviceWorker.register(`${basePath}/sw.js`, { scope: basePath || "/" })`.
**The host must provide `public/sw.js`** — the SDK does not ship a
copyable one. Without it, registration 404s (non-fatal but noisy, and
offline is disabled). Use the SDK's standard offline worker (generic;
cache-in-background, serve-from-cache only when Tauri+offline). A known
working `sw.js` is bundled with this skill — copy it to `public/sw.js`.

### 4c. `Service-Worker-Allowed` header (sub-path mounts only)

If the app is under a `basePath`, the script lives at `/hq/sw.js`,
whose default max scope is `/hq/`, but the provider registers with
`scope: "/hq"` → `SecurityError: scope ('/hq') is not under the max
scope allowed ('/hq/')`. Fix in `next.config`:

```js
async headers() {
  return [
    {
      source: '/sw.js', // Next prefixes basePath → matches /hq/sw.js
      headers: [{ key: 'Service-Worker-Allowed', value: basePath || '/' }],
    },
  ]
}
```

(No-op safe when there's no basePath: header value `/`.)

## Step 5: Register Store Reducers

In `store/index.ts`, register these reducers. **The SDK's selectors
hard-code these exact keys** (`state.chat`, `state.rbac`, …) — they are
non-negotiable:

```ts
import {
  coreApiSlice, mentorReducer, mentorMiddleware,
} from "@iblai/iblai-js/data-layer";
import {
  hostChatReducer,
  chatInputSliceReducer,
  chatSliceReducerShared,
  filesReducer,
  rbacReducer,
  subscriptionReducer,
  topBannerReducer,
} from "@iblai/iblai-js/web-utils";

reducer: {
  chat: hostChatReducer,
  chatInput: chatInputSliceReducer,
  chatSliceShared: chatSliceReducerShared, // used by useAdvancedChat; NOT in mentorReducer
  files: filesReducer,
  rbac: rbacReducer,
  subscription: subscriptionReducer,
  topBanner: topBannerReducer,
  [coreApiSlice.reducerPath]: coreApiSlice.reducer,
  ...mentorReducer,
}
```

> **Brownfield: store-key collisions.** A mature app very likely already
> has its own `chat` / `files` (or `rbac`/`subscription`/…) slices with
> different shapes. The SDK's keys are fixed, so **rename the host's
> slices**, not the SDK's. Find collisions first:
>
> ```bash
> grep -rn "state\.\(chat\|files\|rbac\|subscription\|topBanner\|chatInput\)\b" \
>   --include="*.ts" --include="*.tsx" app components lib hooks
> ```
>
> Rename each host slice (e.g. `chat → v0Chat`) and migrate **every
> selector** that reads it (slice `name` and action creators are
> key-agnostic — only `useSelector(s => s.<key>)` accesses change).
> `mentorReducer` is RTK Query API slices only; it does **not** shadow
> plain feature slices like `chat`/`files`.

The slice **keys matter** — don't rename them. `mentorMiddleware` is
still concatenated as before.

## Step 6: Create the Route

Create `app/agents/[mentorId]/chat-new/page.tsx`. This version handles
session resume, new-chat, the `useSearchParams` Suspense requirement,
and the mounting discipline (see notes after the code):

```tsx
"use client";

export const dynamic = "force-dynamic";

import { Suspense, useEffect, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { Chat, type ChatConfig } from "@iblai/iblai-js/web-containers/next";
import {
  useUsername, useAxdToken, useUserTenants, useVisitingTenant,
  useIsAdmin, useCachedSessionId,
} from "@iblai/iblai-js/web-utils";
import { redirectToAuthSpa } from "@/lib/utils";
import { config } from "@/lib/config";

export default function AgentChatPageWrapper() {
  // `useSearchParams()` requires a Suspense boundary in the App Router
  // (the SDK <Chat> tree uses it too).
  return (
    <Suspense fallback={null}>
      <AgentChatPage />
    </Suspense>
  );
}

function AgentChatPage() {
  const { mentorId } = useParams<{ mentorId: string }>();
  const router = useRouter();
  const searchParams = useSearchParams();

  // Resume (`?session=<id>`) or new chat (`?new=<nonce>`). The SDK reads
  // its per-mentor cached-session map (`useCachedSessionId`,
  // localStorage `{ [mentorId]: sessionId }`) ONCE at <Chat> mount
  // (`isNewSession = cachedSessionId[mentorId] ? false : true`). So seed
  // (resume) or clear (new) BEFORE mount, gate the render until done,
  // and key <Chat> on session/new so switching remounts it.
  const restoreSessionId = searchParams.get("session") ?? undefined;
  const newParam = searchParams.get("new") ?? undefined;
  const [cachedSessionId, saveCachedSessionId] = useCachedSessionId();
  const [seededFor, setSeededFor] = useState<string | undefined>(
    restoreSessionId || newParam ? undefined : "none",
  );
  useEffect(() => {
    if (!mentorId) { setSeededFor("none"); return; }
    const map = { ...((cachedSessionId ?? {}) as Record<string, string>) };
    if (restoreSessionId) {
      if (map[mentorId] !== restoreSessionId) {
        saveCachedSessionId({ ...map, [mentorId]: restoreSessionId });
      }
      setSeededFor(restoreSessionId);
    } else if (newParam) {
      if (map[mentorId]) { delete map[mentorId]; saveCachedSessionId(map); }
      setSeededFor(`new:${newParam}`);
    } else {
      setSeededFor("none");
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [restoreSessionId, newParam, mentorId]);
  const sessionReady = restoreSessionId
    ? seededFor === restoreSessionId
    : newParam
      ? seededFor === `new:${newParam}`
      : true;

  const [tenantKey, setTenantKey] = useState("");
  useEffect(() => {
    const appTenant = localStorage.getItem("app_tenant");
    const tenant = localStorage.getItem("tenant");
    let currentTenant = "";
    try {
      currentTenant =
        JSON.parse(localStorage.getItem("current_tenant") ?? "{}")?.key ?? "";
    } catch {}
    setTenantKey(appTenant || currentTenant || tenant || config.mainTenantKey());
  }, []);

  const username = useUsername();
  const axdToken = useAxdToken();
  const { userTenants } = useUserTenants();
  const { visitingTenant } = useVisitingTenant();
  const isAdmin = useIsAdmin();

  const chatConfig: ChatConfig = {
    baseWsUrl: () => config.baseWsUrl(),
    // Host config may not expose supportEmail — fall back to env.
    supportEmail: () =>
      process.env.NEXT_PUBLIC_SUPPORT_EMAIL ?? "support@ibl.ai",
    authUrl: () => config.authUrl(),
    mainTenantKey: config.mainTenantKey(),
    navigateToAdminBilling: () =>
      router.push(`/agents/${mentorId}/settings?tab=billing`),
    navigateToExplore: () => router.push("/agents"),
    navigateToMentor: (id) => router.push(`/agents/${id}`),
  };

  if (!tenantKey || !sessionReady) return null;

  return (
    <div className="flex h-screen w-full flex-col">
      <Chat
        // Key on mentor+session+new so an intentional switch remounts
        // <Chat> and the SDK re-reads the cached session. Do NOT remount
        // it any other way (see "Mounting discipline").
        key={`${mentorId}:${restoreSessionId ?? ""}:${newParam ?? ""}`}
        isPreviewMode={false}
        mentorId={mentorId}
        tenantKey={tenantKey}
        config={chatConfig}
        // Wrap so the callback is `=> void` (host helpers are often
        // async / extra-arg; SDK type wants `(…) => void`).
        redirectToAuthSpa={(to, key, logout) =>
          void redirectToAuthSpa(to, key, logout)
        }
        username={username ?? null}
        userTenants={userTenants ?? []}
        visitingTenant={visitingTenant}
        axdToken={axdToken ?? ""}
        userIsStudent={!isAdmin}
      />
    </div>
  );
}
```

**Why each piece:**

| Item | Why |
|------|-----|
| `"use client"` + `dynamic = "force-dynamic"` | `Chat` is client-only; prevent static pre-render |
| `<Suspense>` wrapper | `useSearchParams()` requires a Suspense boundary in App Router |
| `useCachedSessionId` seeding + render gate | only way to resume / start-new (no `sessionId` prop, no URL handling — see "Resuming / starting sessions") |
| `key={mentor:session:new}` | the ONLY safe remount; switching session needs it |
| `void redirectToAuthSpa(...)` wrapper | SDK type is `(…) => void`; host helper is often `async` |
| env fallback for `supportEmail` | host `config` may not expose it |

## Mounting discipline

The SDK reads session/voice state **once at `<Chat>` mount** and has an
`isMounted` defect (see Known issues), so an unintended remount is
destructive (loses session, wedges voice). Rules:

- **Never** conditionally swap subtrees around `<Chat>`
  (`isMobile ? <A/> : <B/>` with `<Chat>` in both → remount on
  breakpoint resolve). Use one stable tree and change **layout via
  props** (e.g. `ResizablePanelGroup direction`), not subtree swaps.
- The **only** legitimate remount is an intentional session/new-chat
  `key` change (Step 6).
- `const el = (<Chat .../>)` reused in the same JSX position is fine
  (React reconciles — not a remount).

## Resuming / starting sessions

`<Chat>` has **no `sessionId` prop** and does **not** read a `session`
URL param. Sessions live in `localStorage` via `useCachedSessionId()`
→ `{ [mentorId]: sessionId }`, read once at mount.

- **Resume** (e.g. host history link → `?session=<id>`): seed
  `cachedSessionId[mentorId] = id` before mount, gate render, key
  `<Chat>` on the id (Step 6).
- **New chat:** `delete cachedSessionId[mentorId]` + remount via a
  changing key nonce (`?new=<ts>`). Just navigating does **not** start a
  new chat — the SDK resumes the persisted session.
- **There is no host-readable "conversation loaded" signal.**
  `isLoadingChats` is internal to `useAdvancedChat`; `chatSliceShared`
  is not reset between sessions, so `selectSessionId` /
  `selectNumberOfActiveChatMessages` read **stale** on first render.
  For a "loading" overlay during resume, use a guaranteed-minimum +
  hard-capped **timer**, not a slice-derived condition.

### Host history list (`getChatHistory`)

Endpoint: `/api/ai-analytics/orgs/{org}/users/{user_id}/chat-history/`.
- `userId` → **URL path** `{user_id}` (required to form the URL; absent
  from the d.ts arg type but real).
- `filterUserId` → **`filter_user_id` query** — what actually restricts
  results. For an **org admin** the path alone returns org-wide; pass
  **both** `userId` and `filterUserId` (= signed-in username) to scope
  to the current user.

## Props

| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `mentorId` | `string` | yes | Agent/mentor UUID |
| `tenantKey` | `string` | yes | Platform/tenant key |
| `config` | `ChatConfig` | yes | URLs + navigation callbacks |
| `redirectToAuthSpa` | `(redirectTo?, platformKey?, logout?) => void` | yes | Host-owned auth redirect (wrap async helpers to `=> void`) |
| `username` | `string \| null` | yes | Current user; `null` for anonymous |
| `userTenants` | `Tenant[]` | yes | From `useUserTenants` |
| `axdToken` | `string` | yes | AXD auth token |
| `userIsStudent` | `boolean` | yes | RBAC role hint |
| `visitingTenant` | `Tenant \| undefined` | no | Viewing another tenant's mentor |
| `isPreviewMode` | `boolean` | yes | `true` for admin preview |
| `mode` | `"default" \| "advanced"` | no | `"advanced"` enables builder UI |
| `isPublicRoute` | `boolean` | no | Unauthenticated share links |
| slot overrides (`canvasView`, `disclaimerModal`, `advancedChatHeader`, `advancedChatBuilder`, `liveKitChat`, `liveKitScreenSharing`, `welcomeChat`, `promptGalleryModal`) | `React.ComponentType<…>` | no | **Availability is SDK-version-dependent. As of `web-containers@1.6.14` these are NOT implemented** — `Chat` does not destructure them and they're absent from `index.d.ts`. Verify in `dist/next/index.d.ts` before relying on any of them; do not assume a slot override will take effect. |
| `onSubscriptionGate`, `on402Error`, `canPerformAction`, `renderDocumentSidebar`, `projectId`, `onExploreClick`, `onMentorClick`, `projectLandingPage` | mixed | no | As available in your SDK version |

### `ChatConfig`

| Field | Type | Description |
|-------|------|-------------|
| `baseWsUrl` | `() => string` | WebSocket origin |
| `supportEmail` | `() => string` | Footer / error-toast email (env fallback if host config lacks it) |
| `authUrl` | `() => string` | Auth SPA origin |
| `mainTenantKey` | `string` | Default tenant key |
| `navigateToAdminBilling` | `() => void` | Open the billing tab |
| `navigateToExplore` | `() => void` | "Browse All" agents link |
| `navigateToMentor` | `(id: string) => void` | Open an individual mentor |
| `appSyncBanner?` | `{ badge, text, link, linkText } \| null` | Optional sync banner |

## Known issues & host workarounds

These are SDK-side defects/limitations confirmed against
`web-containers@1.6.14` / `web-utils@1.6.9`. Fix upstream if you can;
otherwise apply the host workaround.

### Voice "Processing…" hangs forever (React StrictMode / remounts)

`useVoiceChat`'s `isMounted` ref is only ever reset to `false` (effect
cleanup), never back to `true` on setup. React StrictMode (Next default)
double-invokes effects in dev (mount→cleanup→remount) → the ref stays
`false` → audio-to-text resolves but the `isMounted`-guarded
`setProcessing(false)` is skipped → stuck "Processing…". Any `<Chat>`
remount reproduces it.

- **Workaround:** `reactStrictMode: false` in `next.config` (dev-only
  behavior; prod runs effects once) **and** follow "Mounting
  discipline" so `<Chat>` doesn't remount.
- **Real fix (upstream):** add `isMounted.current = true;` to the
  effect setup in `useVoiceChat`.

### Prompt-gallery dialog wedges the whole app

The bundled `PromptGalleryModal` is a Radix dialog whose teardown leaves
`document.body { pointer-events: none }` and sibling
`inert`/`aria-hidden` after close → the entire app becomes unclickable.
The `promptGalleryModal` slot override **does not exist** in this SDK
(see Props), so it can't be replaced via API.

- **Workaround:** mount a global recovery component at the app root that
  clears the stuck lock **only when no Radix dialog is open** (no-op
  while a modal is legitimately open):

```tsx
"use client";
import { useEffect } from "react";
const OPEN = '[role="dialog"][data-state="open"],[role="alertdialog"][data-state="open"],[data-radix-popper-content-wrapper]';
function recover() {
  if (document.querySelector(OPEN)) return;
  if (document.body.style.pointerEvents === "none") document.body.style.pointerEvents = "";
  for (const el of Array.from(document.body.children)) {
    if (el.hasAttribute("inert")) el.removeAttribute("inert");
    if (el.getAttribute("aria-hidden") === "true") el.removeAttribute("aria-hidden");
  }
}
export function RadixPointerEventsGuard() {
  useEffect(() => {
    recover();
    const mo = new MutationObserver(recover);
    mo.observe(document.body, { attributes: true, attributeFilter: ["style","inert","aria-hidden"], childList: true, subtree: true });
    const onDown = () => recover();
    document.addEventListener("pointerdown", onDown, true);
    return () => { mo.disconnect(); document.removeEventListener("pointerdown", onDown, true); };
  }, []);
  return null;
}
```

  Mount it once next to your store/providers. This is recovery, not a
  root-cause fix — the gallery still tears down wrong, but the app stays
  usable.
- **Real fix (upstream):** correct the dialog teardown, or actually
  implement the `promptGalleryModal` slot so hosts can supply a
  cleanly-unmounting modal.

## Step 7: Verify

1. `pnpm build` — must pass with zero errors. (Watch for
   `Module not found` on the Step 3 peers, esp. `@iblai/agent-ai`.)
2. `pnpm test` — host unit suite still green (esp. any slices you
   renamed in the Brownfield step).
3. `pnpm dev` — open the route, sign in, and walk:
   - mentor name + greeting, "Ask anything" textarea, action buttons
     (attach / canvas / screen share / voice input / voice call / send),
     conversation starters;
   - send a message → "I Accept" the User Agreement → bubble + "is
     generating…" indicator;
   - **voice**: record → transcription clears "Processing…" (if it
     hangs, the StrictMode workaround isn't applied or `<Chat>` is
     remounting);
   - **prompt gallery**: open then close → app still clickable (guard
     working);
   - **resume/new** (if wired): history link loads the conversation;
     "new chat" starts empty.

## CLI Integration (proposal — not yet implemented)

A future `iblai add agent-chat` should: install the Step 3 deps
(incl. `@iblai/agent-ai`); patch providers (wrap `ServiceWorkerProvider`
+ `skip`, pass `basePath`); copy `public/sw.js`; add the
`Service-Worker-Allowed` header + `reactStrictMode:false`; register the
Step 5 reducers (detecting + renaming host key collisions); scaffold the
Step 6 route; drop in `RadixPointerEventsGuard`; and no-op if already
added.

**Brand guidelines**: [BRAND.md](https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/BRAND.md)

Related Skills

We are still matching the closest adjacent skills for this page. In the meantime, continue through the full directory.