Source: https://monarchui.dev/components/chat-dock/

Patterns · Collaboration

# ChatDock

The Rook Chat launcher with an unread badge and the panel that holds Rook Chat in an iframe (rook-chat-embed v1).

@rookcom/monarch-react/shell

Pattern

## Docked, with three unread

## Usage examples

The example at the top of the page is the only one for this component.

## Purpose

Every Rook app (except Rook Chat itself) docks Rook Chat in the bottom-right corner. The panel is the real Chat app in an iframe, so the host only draws the launcher and speaks the `rook-chat-embed` v1 protocol. ChatDock is the single shared implementation.

### Use when

- Once per signed-in app layout, when the person has Chat in this workspace.
- With `hidden` on pages that have their own AI assistant on the right.

### Avoid when

- Help from the Rook team: that is SupportPanel, opened from the rail, never bottom-right.
- Embedding another app’s settings page: use EmbeddedAppFrame.

## Design principles

- The host never fetches: the product passes the unread count it already polls and its own single-flight `refreshSession`; Chat asks for refreshes and the dock forwards them.
- Never refreshes on its own: the frame is created on first open (or hover) and kept alive while closed, so calls, drafts and scroll position survive; it reloads only when `src` or the workspace changes.
- Strict messaging: messages are read only from the frame’s own window at an allowed origin, validated field by field, and posted only to the embed’s exact origin.
- Gets out of the way: `hidden` hides it where an assistant sits; a running call stays on screen.

## API reference

```tsx
import { CHAT_EMBED_PROTOCOL, CHAT_EMBED_VERSION, ChatDock, DEFAULT_CHAT_ALLOW, DEFAULT_CHAT_SANDBOX, chatPageContext, cleanOpenDetail, createChatDockBridge, decodeChatEmbedMessage, encodeChatEmbedMessage } from '@rookcom/monarch-react/shell';
```

| Export | Kind | Rendering | Root class |
| --- | --- | --- | --- |
| `CHAT_EMBED_PROTOCOL` | utility | server-safe | – |
| `CHAT_EMBED_VERSION` | utility | server-safe | – |
| `ChatDock` | component | client | `.mn-chat-dock` |
| `DEFAULT_CHAT_ALLOW` | utility | client | – |
| `DEFAULT_CHAT_SANDBOX` | utility | client | – |
| `chatPageContext` | utility | server-safe | – |
| `cleanOpenDetail` | utility | server-safe | – |
| `createChatDockBridge` | utility | server-safe | – |
| `decodeChatEmbedMessage` | utility | server-safe | – |
| `encodeChatEmbedMessage` | utility | server-safe | – |

Forwards its ref to the root element. `controllerRef` gives `open(detail)`, `close()` and `post(message)` for "Share in Chat" buttons.

Also exported: `CHAT_EMBED_PROTOCOL`, `CHAT_EMBED_VERSION`, `encodeChatEmbedMessage`, `decodeChatEmbedMessage`, `createChatDockBridge` (framework-free host bridge), `chatPageContext`, `cleanOpenDetail`, `DEFAULT_CHAT_SANDBOX`, `DEFAULT_CHAT_ALLOW`.

Generated from the TypeScript declarations of `packages/react/src/patterns/collaboration/chat-dock`.

## Properties

`allowedOrigins` is required: exact origins only (`*` and paths are dropped). The origin of `src` must be in it or nothing is posted.

`unread` is the count before the frame runs; once the frame reports `rook-chat:unread`, its count wins.

`open` + `onOpenChange` control the panel; `defaultOpen` leaves it uncontrolled.

### `ChatDock`

The Rook Chat dock: a launcher with an unread badge and the panel that holds the real Rook Chat app in an iframe (`rook-chat-embed` v1). The frame is created on first open (or on hover) and then kept alive, so calls and drafts survive closing the panel. The dock never fetches: unread counts and session refreshes come from the product and the frame.

| Prop | Type | Description |
| --- | --- | --- |
| `allowedOrigins` required | `readonly string[]` | Exact origins the frame's messages are accepted from. Messages are posted only to the origin of `src`, and only when it is in this list. |
| `appName` required | `string` | The host app, e.g. "Rook Stride" (sent in `init`, shown in shared links). |
| `refreshSession` required | `() => Promise<unknown>` | The host app's own single-flight session refresh. The dock calls it only when the frame asks; it never refreshes the session or reloads the frame on its own. |
| `src` required | `string` | The Chat embed page (e.g. `https://chat.example.com/embed/dock?ws=…`). |
| `workspaceId` required | `string` | The Rook ID workspace id (sent in `init`). |
| `allow` | `readonly string[]` | Permissions policy for the frame. Default `DEFAULT_CHAT_ALLOW`. |
| `className` | `string` |  |
| `context` | `(() => ChatHostContext)` | The page the person is on. Default: this URL without the fragment, and the document title. |
| `controllerRef` | `Ref<ChatDockController>` |  |
| `defaultOpen` | `boolean` |  |
| `hidden` | `boolean` | Hide the launcher and panel (the frame stays alive and connected), e.g. on pages with their own AI assistant. A call in progress stays on screen. |
| `icon` | `ReactNode` | Launcher icon. Default: a chat glyph. Pass the product's Chat mark. Decorative. |
| `id` | `string` |  |
| `messages` | `Partial<ChatDockMessages>` | Interface text, for translation. |
| `mountOnIntent` | `boolean` | Create the frame on hover or focus of the launcher, so opening feels instant. Default true. |
| `onEmbedMessage` | `((message: ChatEmbedMessage) => void)` | Every valid message from the frame, after the dock handled it. |
| `onLayoutChange` | `((layout: ChatDockLayout) => void)` |  |
| `onLocationChange` | `((conversationId: string \| null) => void)` | The conversation now open in the frame (null: none). |
| `onOpenChange` | `((open: boolean) => void)` |  |
| `onStatusChange` | `((status: ChatEmbedStatus) => void)` |  |
| `onUnreadChange` | `((count: number) => void)` |  |
| `open` | `boolean` | Panel open (controlled). |
| `ringTimeout` | `number` | How long an unanswered ring stays. Default 30000 ms. |
| `sandbox` | `readonly EmbedSandboxToken[]` | Sandbox tokens for the frame. Default `DEFAULT_CHAT_SANDBOX`. |
| `unread` | `number` | Unread count known before the frame runs (the product's own poll). The frame's count wins once it reports. |

## Variants

| Prop | Values | Description |
| --- | --- | --- |
| `layout (from the frame)` | `'panel'``'expanded'``'call'``'fullscreen'` | Chat asks for a size with `rook-chat:layout`; exposed as `data-layout`. Fullscreen hides the launcher and ignores Escape on the host. |
| `hidden` | `'false'``'true'` | Hides launcher and panel; the frame stays connected. |

## States

| State | Exposed as | Behaviour |
| --- | --- | --- |
| Closed | – | The launcher only (no frame until it is wanted). |
| Unread | – | A badge with the count (99+ above 99), hidden from assistive technology; the count is in the launcher’s name. |
| Loading | – | The panel shows a spinner until the frame says `rook-chat:ready`. |
| Open | `data-open` | The panel is shown and the launcher is `aria-expanded="true"`. |
| Ringing | – | An incoming call while closed: an alert with Open and Dismiss, gone after 30 seconds. |
| Hidden / unavailable | `data-hidden` | `hidden`, or Chat reported `unavailable`: nothing on screen, frame kept. |

## Interaction behaviour

- Hover or focus on the launcher creates the frame, so opening feels instant (`mountOnIntent`).
- Opening moves focus into the frame; closing (launcher, Escape on the panel, or Chat’s own minimise) returns it to the launcher.
- When the frame says ready, the dock sends `init` and the page context first, then anything queued (such as an `open` for a conversation).
- The frame is told when it is shown or hidden (`rook-chat:visibility`).

## Keyboard support

| Keys | Action |
| --- | --- |
| Enter / Space | Open or close the panel from the launcher. |
| Escape | Close the panel when focus is on it (not in fullscreen). |
| Tab | From the launcher into the open panel’s frame. |

Inside the frame, keys belong to Rook Chat; its own minimise sends `rook-chat:close` and focus returns to the launcher.

## Accessibility

Role

button (launcher) controlling a non-modal dialog that holds an iframe

Accessible name

The launcher is "Rook Chat" or "Rook Chat, 3 unread" (

messages

); the panel and the iframe are named "Rook Chat".

- The launcher has `aria-expanded` and `aria-controls`; the badge is `aria-hidden` because the count is in the name.
- The kept-alive panel is `aria-hidden` and out of the tab order while closed.
- The incoming call is a `role="alert"` with labelled Open and Dismiss buttons.
- Security: `event.origin` must be in `allowedOrigins` (exact) and `event.source` the frame’s window; every message is decoded against its exact shape (ids `[A-Za-z0-9_-]{1,64}`, counts integers under a million, known layouts and statuses) and dropped otherwise. Host messages to the frame go only to the origin of `src`, never to `*`.
- Security: `open` details are cleaned before they are sent (bad ids and non-http URLs are dropped). Refresh requests are answered only with the host’s own `refreshSession` result (ok or not), never with a token.
- Threat model: another frame or tab cannot drive the badge, layout or refresh (origin and source checks). The frame is sandboxed without top navigation; it gets camera, microphone, screen capture and clipboard for calls (`DEFAULT_CHAT_ALLOW`), so only point `src` at Rook Chat.

## Responsive behaviour

- The panel is 25rem wide and up to 40rem tall, never larger than the screen; below 30rem it spans the width above the launcher.
- Expanded and call layouts grow to 67.5rem; fullscreen covers the viewport. Offsets respect safe-area insets.
- Size and offset can be tuned with `--mn-chat-dock-width`, `--mn-chat-dock-height` and `--mn-chat-dock-offset`.

## Theme support

- The launcher uses the inverse surface (dark on light, light on dark) with a border so it stays visible on either; the badge uses the danger solid colour.
- Hover lift, panel transitions and the ring pulse are removed under reduced motion.

## Do and don't

### Do

- Render one dock in the signed-in app layout. Switching workspace changes `workspaceId`, and the dock starts a fresh frame for it.
- Pass the app’s single-flight session refresh, the same one the rest of the app uses.
- Set `hidden` on pages with an assistant panel on the right.

### Don't

- Do not remount the dock on navigation; that would end calls.
- Do not refresh the session from the frame or on a timer for it.
- Do not put the support chat here: Help & support lives in the rail.

## Related components

- [SupportPanel](https://monarchui.dev/components/support-panel/): Help from the Rook team, from the rail.
- [EmbeddedAppFrame](https://monarchui.dev/components/embedded-app-frame/): Embedding other Rook app pages.
- [RookRail](https://monarchui.dev/components/rook-rail/): The rest of the app chrome.

## Dependencies

| Kind | Depends on |
| --- | --- |
| npm packages | `react` |
| Monarch components | [Button](https://monarchui.dev/components/button/), [Spinner](https://monarchui.dev/components/spinner/) |

## Testing coverage

- Named launcher, no frame until wanted; mount on hover/focus, or not with mountOnIntent off.
- Opens a sandboxed dialog panel, focus into the frame; Escape closes and returns focus.
- The frame is kept across close and reopen (never reloads on its own).
- Handshake: init and context to the exact origin, then queued messages; visibility.
- Drops other origins, look-alikes, other windows, bad shapes, other versions and host-only types.
- Unread badge in the name, 99+, follows the frame.
- Refresh requests answered with the host refresh (ok and failed); never called on its own.
- hidden keeps the frame; a call stays visible; close from inside; unavailable hides.
- Incoming call alert opens the conversation; unsafe open details dropped; controlled; translation.
- Protocol decoding; the bridge never posts outside the allow list.

| Check | Covered |
| --- | --- |
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: /__gallery/chat-dock; Cross-browser axe: /components/chat-dock |

Tests: `packages/react/src/patterns/collaboration/chat-dock`

## All example code

### Docked, with three unread

```tsx
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
import { ChatDock } from '@rookcom/monarch-react/shell';

/**
 * Docs only: a same-origin stand-in for the Chat embed, served from a blob:
 * URL with this page's styles. It says `ready` and reports an unread count,
 * like the real embed does.
 */
function useDemoEmbed(): string | null {
  const [url, setUrl] = useState<string | null>(null);
  useEffect(() => {
    const head = Array.from(document.head.querySelectorAll('style, link[rel="stylesheet"]'))
      // Absolute hrefs: a blob: document cannot resolve this page's relative URLs.
      .map((n) =>
        n instanceof HTMLLinkElement ? `<link rel="stylesheet" href="${n.href}">` : n.outerHTML,
      )
      .join('');
    const attrs = Array.from(document.documentElement.attributes)
      .map((a) => `${a.name}="${a.value.replace(/"/g, '&quot;')}"`)
      .join(' ');
    const rows = ['Design review', 'Launch planning', 'Ada Lovelace']
      .map(
        (t, i) =>
          `<li><strong>${t}</strong><span>${['Notes are in the doc.', 'Moving the date to Friday.', 'Thanks!'][i]}</span></li>`,
      )
      .join('');
    const html = `<!doctype html><html ${attrs}><head><meta charset="utf-8">${head}<style>body{margin:0;background:var(--mn-color-bg-surface);color:var(--mn-color-text-default);font-family:var(--mn-font-sans);font-size:var(--mn-text-sm)}h2{margin:0;padding:var(--mn-space-4);font-size:var(--mn-text-base);border-bottom:1px solid var(--mn-color-border-subtle)}ul{list-style:none;margin:0;padding:0}li{display:flex;flex-direction:column;gap:2px;padding:var(--mn-space-3) var(--mn-space-4);border-bottom:1px solid var(--mn-color-border-subtle)}span{color:var(--mn-color-text-muted)}</style></head><body><h2>Chat</h2><ul>${rows}</ul><script>
const send = (m) => parent.postMessage(Object.assign({ protocol: 'rook-chat-embed', v: 1 }, m), location.origin);
send({ type: 'rook-chat:ready' });
send({ type: 'rook-chat:unread', count: 3 });
</script></body></html>`;
    const next = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
    setUrl(next);
    return () => URL.revokeObjectURL(next);
  }, []);
  return url;
}

/** A transformed box contains the dock's fixed positioning, so it stays in the example. */
function Stage({ children }: { children: ReactNode }) {
  return (
    <div
      style={
        {
          position: 'relative',
          width: '100%',
          height: '30rem',
          overflow: 'hidden',
          transform: 'translateZ(0)',
          border: '1px solid var(--mn-color-border-default)',
          borderRadius: 'var(--mn-radius-lg)',
          background: 'var(--mn-color-bg-canvas)',
          '--mn-chat-dock-height': '22rem',
          '--mn-chat-dock-width': 'min(22rem, calc(100% - 2.5rem))',
        } as CSSProperties
      }
    >
      <p style={{ margin: 'var(--mn-space-5)', color: 'var(--mn-color-text-muted)' }}>
        The page of the host app.
      </p>
      {children}
    </div>
  );
}

function Basic() {
  const src = useDemoEmbed();
  return (
    <Stage>
      {src ? (
        <ChatDock
          src={src}
          allowedOrigins={[window.location.origin]}
          appName="Example Desk"
          workspaceId="ws_demo"
          refreshSession={() => Promise.resolve()}
          unread={3}
        />
      ) : null}
    </Stage>
  );
}

<Basic />
```
