# @opengeni/react

React hooks and styled components for Opengeni, built on
[`@opengeni/sdk`](../sdk): live session streaming, a chat composer, a message
timeline that renders streaming deltas / tool calls / spawned-worker status,
session status badges, and fleet tiles for workspace overviews. Two opt-in
surfaces layer on top: a **sandbox workspace** workbench (files, terminal, diff,
and an optional desktop stream) and, at the
[`@opengeni/react/machines`](#connected-machines-opengenireactmachines) subpath,
the **Connected Machines** dashboard + enrollment flow.

The default root import (`@opengeni/react`) is the clean sandbox-agnostic
surface — the chat/timeline hooks and components plus the sandbox workspace
suite. Advanced chat-composer composition lives under
`@opengeni/react/composer`; Connected-Machine UI lives under
`@opengeni/react/machines`; realtime voice controls live under the lazily
loadable `@opengeni/react/realtime` subpath. (The root barrel still re-exports the machines
island for back-compat, deprecated per #144.)

Design-system-first: every visual decision routes through CSS-variable tokens
(`styles/tokens.css`) — color, typography, radius, shadow, motion. Dark mode is
the first-class token default; light is an opt-in via `data-og-theme="light"` on
any ancestor. The embedded chat roots (`OpenGeniChat`, `SessionConversation`)
resolve light or dark from the host page instead; see
[Conversation UI](#conversation-ui). Components are styled with Tailwind v4 utilities mapped onto the
tokens, Radix primitives for behavior, and Motion for state-communicating
animation. Override the tokens to rebrand everything.

## Embedded connections and Sites

For a child chat's capability selection, `prepareSessionCapabilityAccess` reads
an ancestor-to-child access plan without writing. Show the parent chats whose
`request` is non-null and obtain an explicit confirmation before calling
`applySessionCapabilityAccess`. The plan preserves existing choices, applies
version-fenced updates root first, and grants no authority: each ordinary API
request still authorizes its caller. Invalidate the review on actor/workspace
changes. A partial failure may leave approved parent changes saved; prepare a
new plan for retry instead of rolling them back. Existing
`attachSessionCapability` remains a single-chat operation and never changes
parents implicitly.

Optional `@opengeni/react/connect` exports `useConnect`, `ConnectChooser`,
`ConnectSetup`, `ConnectAccounts`, and the composed `ConnectPanel`. Inject one
`@opengeni/connect` controller per authenticated actor/workspace and dispose it
when that scope changes. The controller uses your authenticated backend proxy;
never place an organization key in browser props. Import
`@opengeni/react/connect.css` for opt-in, scoped styles without Tailwind.
`DeviceAuthorization` also accepts an optional `render` callback for host presentation;
it receives shared copy state, the copy action, and the validated verification URL.
Omitting it preserves the default embedded presentation.

`CapabilityCatalogRow` gives connections, skills, and plugins one consistent
icon/name/description button with a decorative plus/check and visible exception
states. Supply `onOpen` and an explicit `status`; the host owns setup and
installation. `ConnectionCatalog` accepts the same states through each option's
`state`. Older callers that omit `state` keep their visible status labels, so
provider warnings are not hidden during migration.

`PluginDiscovery` accepts `defaultProvider="openai"` or `"anthropic"` to choose
the initial registry. Omitting it starts with All. Users can still switch among
All, OpenAI, and Anthropic; changing the search preserves their selection. The
Opengeni Plugins page starts with OpenAI.

```tsx
import { ConnectPanel } from "@opengeni/react/connect";
import "@opengeni/react/connect.css";

<ConnectPanel
  controller={controller}
  returnUrl={hostSelectedReturnUrl}
  onAuthorize={hostAuthorizeFromClick}
/>;
```

The host owns synchronous popup/full-redirect navigation and pending-attempt
recovery. Backend state, not a popup message or URL query, proves completion.
Operation installation uses explicit selection; OAuth success alone is not
installation. Account disconnect requires an observed version and confirmation,
revokes local Opengeni access only, and never silently retries an unknown outcome.
Provider readiness is deployment-dependent; use the server catalog rather than
assuming every OAuth application or operator-managed provider is configured.

Optional `@opengeni/react/sites` exports `SiteList`, `SiteDetail`, and `SiteClient`.
Pass the public SDK client through your host proxy. `onOpen` owns list
navigation. Authoring buttons and prompts belong to the host and use the ordinary
session SDK; they are not part of the Site component API. `SiteDetail` reuses `PublishedHtmlArtifactFrame`, accepts
an optional authenticated/filtered tool bridge, and removes the frame after
read-authority refresh failure. Loaded Sites revalidate every 15 seconds;
downloaded HTML cannot be recalled and every tool call still needs live authority.

`canPublish` is only a presentation hint. Version-checked rollback/archive/restore
remain subject to backend `artifacts:publish` permission. Sites stay workspace
shared, even if a source session is private. See
[`examples/embedded-product`](../../examples/embedded-product/README.md) for the
runnable loopback host reference. Native route reuse and full visual acceptance
are not implied by these optional package surfaces.
## Conversation UI

For an account-aware header, `useLastStartedTurnPolicy(sessionId, { events,
includeServingModel: true })` overlays the latest lease-backed serving receipt
only when it belongs to the last started turn. This requires the optional
`getSessionModelAccount` client method. Without the option, the hook continues
to return the accepted turn policy. Neither mode changes the composer's
preferred model. `useSessionModelAccount` reads authorized accounts and choices;
pass `provider` so Automatic resets only that provider's choice. Its optional
`choice.serving` is the last committed placement, not a queued next-turn choice.
The timeline renders neutral account-switch events without exposing account
identity or duplicating provider compatibility events.

`OpenGeniChat` and `SessionConversation` are the default product integration.
Back them with `createSessionProxyHandler` from `@opengeni/sdk`, mounted on your
server, and pass its mount as `baseUrl`. No provider, client, or workspace id
is needed: the component creates the browser client and uses the workspace the
proxy resolved for the signed-in user (from the proxy's client config).

```tsx
import "@opengeni/react/compiled.css";

<OpenGeniChat baseUrl="/api/opengeni" />;
// or one conversation: <SessionConversation baseUrl="/api/opengeni" sessionId={sessionId} />
```

To give people one Send and nothing to learn, pass `busySend: "steer"`:

```tsx
<SessionConversation sessionId={sessionId} composerOptions={{ busySend: "steer" }} />
// OpenGeniChat: conversationProps={{ composerOptions: { busySend: "steer" } }}
```

A message sent while the agent works is then read as soon as it can, usually
within seconds. There is no Queue choice beside Send and no Queue or Steer
words; a message sent while the workstream is paused waits and shows as a
waiting message. Add `offerQueue: true` to offer Queue beside Send again.

An open conversation marks itself read for the signed-in person while they can
see it: its timeline is on screen, the page is visible, the window is focused,
and it shows the live end of the conversation. Each delivered reply, question
or failure is acknowledged once (never per streamed token) through
`updateSessionAttention` with `acknowledgedThroughSequence`, so a late request
never hides a newer reply. Unread state, and notifications your server derives
from it (for example a `turn.completed` webhook that skips sessions where
`session.unread` is false), then leave out replies the person already watched.
A timeline your layout hides (for example a collapsed bar that keeps only the
composer), a background tab, or an unfocused window acknowledges nothing. Pass
`readAcknowledgement={false}` when viewing must not count as reading. Behind
`createSessionProxyHandler` this uses its `PUT …/attention` route, which
accepts only `{ unread: false }`. Custom layouts around
`useSessionConversation` call `useSessionReadAcknowledgement` (from
`@opengeni/react/session`) with their own `active` signal.

Bearer-token apps pass `headers` (a function runs per request) or a custom
`fetch`; a `client` passed with `baseUrl` replaces the one the component
creates:

```tsx
<OpenGeniChat baseUrl="/api/opengeni" headers={() => ({ Authorization: `Bearer ${getToken()}` })} />
```

The provider form keeps working, for example for several components sharing one
client or the headless hooks:

```tsx
<OpenGeniProvider client={new OpenGeniClient({ baseUrl: "/api/opengeni" })} workspaceId={workspaceId}>
  <SessionConversation sessionId={sessionId} />
</OpenGeniProvider>
```

Compose `MessageTimeline` and `ChatComposer` with the session hooks only when
the product needs a materially different interaction model. These components do
not consume the text-only `@opengeni/sdk/chat` fallback protocol.

`OpenGeniChat` adds the user's chat list to the conversation: a sidebar when
the component is wide and a drawer behind a menu button when narrow (measured on
its own container, so it works inside panels), a new-chat composer that creates
the session from its first message, and inline rename and archive. It is
composed from `SessionList` and `SessionConversation`, which you can also mount
separately. Pass `conversationProps` for message rendering and tool renderers,
`createSession` to create chats through your own endpoint, or `sessionId` /
`onSessionChange` to control the selection (for example from the URL).

Live voice is opt-in, because a call spends your credits and asks the browser
for the microphone. Turn it on with `realtimeVoice={true}` (via `OpenGeniChat`'s
`conversationProps` too) or `createSessionProxyHandler({ realtimeVoice: true })`.
The composer then shows a voice button when the workspace offers an available
voice model (for example hosted GPT Live with credits): the user talks, the
voice model answers and hands work to the agent in the same chat, and the
transcript lands in the timeline. It stays hidden when no model is available,
when the proxy sets `realtimeVoice: false`, or when you pass
`realtimeVoice={false}`. The voice code loads lazily on first display.
The voice button never names the voice model (the model menu does, when
shown), and neither does the React Native call screen. To name the action
after your assistant, set its text through `messages`: on the web,
`"realtime.realtimeControl.startVoice": "Talk to Ava"`, with
`"realtime.realtimeControl.tryVoiceAgain"` and
`"realtime.realtimeControl.talkLiveWithTheAgent"` for the retry label and the
idle hint; on React Native, `"native.timeline.messages.startACall"`.

The working indicator before the first reply says "Thinking…". Pass
`genieLoading` (`phrases`, `messages`, `orb`, or a whole `render`) to use your
own copy or visual. File attachments follow the proxy: it reports them off when
`files: false`, and for anonymous visitors (`visitor: true` from `resolve`)
unless it sets `visitorUploads: true`.

`SessionConversation` and `OpenGeniChat` are built to look native inside
someone else's product with zero styling:

- **Theme follows the host**, not the OS: an enclosing `data-og-theme`, then
  `class="dark"`/`data-theme` (and similar) on `<html>`/`<body>`, the host's
  `color-scheme`, then the luminance of the background the chat sits on. Pass
  `theme="light" | "dark"` to force one.
- **Surfaces blend into the host** (`surface="host"`, default): backgrounds and
  cards are opaque mixes of the host background, so a navy app gets navy cards.
  Host-customized `--og-color-*` tokens are kept; `surface="theme"` uses the
  token surfaces as they are.
- **Run control is Stop, not Pause**: a Stop control appears only while a
  response runs, and the next message continues a stopped conversation. Opt
  into the console's workstream Pause with `composerProps={{ runControl: "pause" }}`
  (`"none"` hides both).
- **No model picker by default**: end users rarely choose models. Pass
  `modelPicker`, or report `modelSelection: true` from the proxy
  (`createSessionProxyHandler({ modelSelection: true })`); `modelSelection: false`
  also fixes the policy server-side.

Attachments appear when the deployment enables uploads (`attachments={false}`
opts out), the microphone appears when it reports voice input available
(`voiceInput={false}` opts out), generated images, video, published files and
screenshots display through the conversation's session scope, actions a
session proxy reports unavailable (`sessionCreation`, `archive`, `artifacts`)
are hidden, the session goal shows with Pause/Resume/Clear, sub-agent cards
open the child through `onOpenSession` (`OpenGeniChat` opens it in place),
pending tool approvals render Approve/Reject, a yes/no question
renders as two buttons, a failed load offers Try again, and `toolRegistry`
customizes tool rendering.

The root keeps all existing exports, including workbench components, but never
imports their optional peers. Conversation-only Next.js/Vite hosts do not need
terminal, desktop, editor, or diff packages. Hosts mounting those surfaces opt
in through the [per-surface setup entries](#optional-peer-dependencies).

Highlighted diffs use the optional `@pierre/diffs` peer only after an explicit
opt-in, so a host without it still builds with any bundler (Turbopack resolves
every reachable `import()`). Hosts that install it call `enablePierreDiffs()`
once; call it from the lazily loaded route that renders diffs to keep the peer
out of your initial bundle. Views already on screen upgrade when it registers:

```ts
import { enablePierreDiffs } from "@opengeni/react/diffs";
enablePierreDiffs();
```

Without it, diffs and file views render as plain text.

`OpenGeniProvider` never blocks or reloads the host page when Opengeni deploys
a new API contract revision. The stock Opengeni console opts into that
stale-tab protection with `reloadOnApiContractChange`; embedded products
should leave it off.

Subscription account lists and their usage readings (`useCodexAccounts`,
`useSuperGrokAccounts`) are shared per `client` object: readers mounted at the
same time share one request and one result, and account changes made through
these hooks re-read every mounted list (also when the component that made the
change has already unmounted). By default nothing is kept after the last reader
unmounts, so a host that reuses one `client` for different people never shows
one person's accounts to the next, also when it re-keys its tree for the next
person in a single render: the new reader and its children never render the
previous rows, and a read still in flight for the previous person is
discarded. (A reader joining one already on screen shows its rows before the
browser paints, after one extra render.) Set `retainReadsAcrossMounts` on
`OpenGeniProvider` to keep them across navigation instead: returning to a page
renders the last accounts at once and re-reads them in the background once
they are older than 10 seconds. Failed reads are never kept, and a
401/403/404 drops what was kept. Retained reads are dropped when `client`
changes or the provider unmounts.

The same opt-in keeps the last view of each conversation a person leaves: its
event tail, detail, prompt queue, pending questions, goal and lineage. Reopening
it within 10 minutes draws that view in the first render, with no loading
state, while every hook still reads its resource once in the background and the
fresh tail replaces the kept one when it lands. It is held in memory only (never
written to the device), and the kept event tails together stay within one open
conversation's window budget, dropping the least recently left first. Hosts
without `OpenGeniProvider` (for example a React Native app passing `client` to
each hook) opt in with `useRetainedReads(client)` in a component that stays
mounted while that person is signed in.

> **Host action item:** only set `retainReadsAcrossMounts` (or call
> `useRetainedReads`) when every signed-in person gets their own `client`
> object (create a new client on sign-in, sign-out and account switch), as the
> stock Opengeni console does. With a shared client, leave it off.

Read hooks watching a shared event feed (`events` from `useSessionEvents`)
re-read their projection only for an event after what their own read reflects.
Current servers report that in the `X-OpenGeni-Session-Sequence` response header
of the queue, pending questions, goal and lineage reads
(`observedSessionSequence` in `@opengeni/sdk`); behind a server or proxy that
does not report it, every matching event in the loaded tail re-reads as before.

### Exact conversation search navigation

`useSessionEvents().initialHistoryReady` becomes true when a history window has
loaded successfully, including an empty tail. It stays true through later stream
errors, history navigation, and tip reloads, and resets for a new session/replay
identity. Use it rather than inferring initial success from `initialLoading` or
the combined `error`. A failed first load stays unready until recovery;
`jumpToLatest()` clears its stale error and retries. Full replay has no history
snapshot-completion watermark, so SSE alone does not set this flag.

`useSessionEvents(sessionId).jumpToSequence(sequence)` replaces the current
window with at most two bounded cursor reads around an exact durable event.
It resolves `true` only when that event is retained, `false` for a missing or
superseded target, and rejects current request errors. `loadingTarget` exposes
the pending state. New targets supersede older targets/pages; session/client
changes fence stale results. Search enters history mode; use the existing
`loadOlder`, `loadNewer`, and `jumpToLatest` controls to navigate onward. It does
not download the intervening session history.
Pass `jumpToSequence(sequence, { signal })` to cancel one navigation: an
aborted signal fences the pending jump before its fetched window is applied
(the underlying reads may still complete), resolves `false`, and settles
`loadingTarget` without disturbing any newer navigation that superseded it.

Pass the selected hit to `MessageTimeline` independently of loading:

```tsx
const history = useSessionEvents(sessionId);
const [target, setTarget] = useState<TimelineSearchTarget | null>(null);

function selectHit(hit: { sequence: number; eventId: string }, query: string, occurrence = 0) {
  setTarget({ ...hit, query, occurrence });
  void history.jumpToSequence(hit.sequence);
}

<MessageTimeline events={history.events} items={history.timeline} searchTarget={target} />;
```

The host owns search results, error handling, next/previous controls and query
state. `TimelineSearchTarget` is exported from the root and `session-ui` entry:
`{ sequence: number; eventId?: string; query: string; occurrence?: number; offset?: number }`.
Matching is literal, case-insensitive, non-overlapping; `occurrence` is zero-based
within the message, defaulting to zero. When the backend all-occurrence search
returns `messageMatchOffset`, pass it as `offset`: the zero-based UTF-16 position
of the match in the original message text. An explicit `offset` takes precedence
over `occurrence` and is validated against the query, so a stale offset simply
produces no highlight. `buildTimeline` supplies `sourceEvents`
identities so completed assistant events remain addressable when the renderer
keeps a first-delta ID. Hosts supplying their own items should preserve those
identities (or the canonical `annotationSource`).

The active message's group and long-user disclosure open persistently. Setting
`searchTarget={null}` removes the active highlight without collapsing content or
restoring a former scroll position. The package mounts its complete bounded
window. The default Markdown renderer shows a labeled, bounded **message source**
excerpt for the active match, including Markdown syntax and link destinations;
closing find removes the highlight but retains that excerpt and its layout.
An explicit **Show formatted message** action restores the full body; this can
expand a huge message, so it is never done automatically on closing Find.
Raw UTF-16 offsets are never applied
to rendered Markdown text. Custom hosts can use
`<Markdown searchTarget={searchTarget}>{text}</Markdown>` for the same behavior.
A custom virtualized renderer receives
`renderMessageText(text, item, { searchTarget })` and must materialize the exact
occurrence when that context is non-null. Existing two-argument renderers remain
compatible. If previous occurrences are omitted from the virtualized DOM, wrap
the materialized match in an element with `data-og-search-occurrence` (zero-based
index), `data-og-search-sequence` and `data-og-search-query`, each set from the
target; when the target carries an `offset`, set `data-og-search-offset` to it
alongside `data-og-search-occurrence` (which can be zero). This lets navigation
identify that exact occurrence without recounting an incomplete DOM. Keep the
materialized window when the context clears to
preserve position. Navigation waits for the mounted text and highlights the active
DOM range using the CSS Custom Highlight API, without rewriting React-owned
text. Explicit source-offset targets require this marker: matching characters
at the same rendered offset are not proof of source identity. A custom renderer
that omits source text or puts it in an opaque iframe must reveal that text
itself. Existing renderers without this mapping remain render-compatible but
cannot provide exact source-offset navigation.

## Editable Office artifacts

The optional artifact workbench is isolated from the ordinary session and
realtime entries. The default browser composition owns one verified WASM
Worker, durable IndexedDB state, authenticated live sync, teardown, and the
matching editor for the artifact modality:

```tsx
import workerUrl from "@opengeni/sdk/editable-artifacts/worker?worker&url";
import { BrowserEditableArtifactWorkbench } from "@opengeni/react/artifacts";
import { editableArtifactKernelRuntime } from "@opengeni/artifact-kernel-wasm-spreadsheet";

<BrowserEditableArtifactWorkbench
  options={{
    baseUrl: "https://api.example.com",
    workspaceId,
    artifact,
    replicaId,
    storageAuthority,
    runtime: {
      workerUrl,
      ...editableArtifactKernelRuntime,
      applicationOrigin: window.location.origin,
    },
    transport: { credentials: "include" },
  }}
  spreadsheet={{ title: artifact.title }}
/>;
```

`storageAuthority` is the host's authenticated deployment/account/workspace/
principal partition and authorization epoch; it must rotate when that authority
or any static transport credential changes. `replicaId` is a persisted random
64-bit writer id. Cookie auth works through `credentials`; self-hosted browser
consoles may pass static deployment-bound `transport.headers`. Browser API keys
and custom fetch/WebSocket functions stay on the lower-level
`EditableArtifactWorkbenchHost`, whose host supplies its own SDK session factory
and complete `sessionKey`.

Hosts that already own an `EditableArtifactSession` can render one modality
directly. No snapshot or artifact-tool model crosses onto the React thread:

```tsx
import { EditableSpreadsheetArtifactSurface } from "@opengeni/react/artifacts/spreadsheet";
import { EditableDocumentArtifactSurface } from "@opengeni/react/artifacts/document";
import { EditablePresentationArtifactSurface } from "@opengeni/react/artifacts/presentation";

<EditableSpreadsheetArtifactSurface session={spreadsheetSession} title="Report" />;
<EditableDocumentArtifactSurface session={documentSession} title="Brief" />;
<EditablePresentationArtifactSurface session={presentationSession} title="Launch deck" />;
```

The spreadsheet surface supports typing, formulas, rectangular paste, clear,
and durable row/column resizing. Drag a header boundary for immediate visual
feedback and one command on release. Focus a boundary and use arrows to resize
(Shift for 1 px), Home to reset, or Escape to cancel. Pending saves do not block
typing; the sync indicator remains pending until server acknowledgement. Failed
independent edits stay visible, and retry cannot overwrite newer overlapping
edits. The same component is used in the dock and full-page artifact view;
dimensions and values remain authoritative in the SDK Worker.
The console supplies a Download action for every editable artifact in both dock
and full-page views: documents export DOCX, spreadsheets XLSX, and presentations
PPTX. Custom hosts can supply `download(signal): Promise<Blob>` on the document,
spreadsheet, or presentation surface, or `downloadArtifact(opened, signal):
Promise<Blob>` on `EditableArtifactView` (the older spreadsheet-only
`downloadSpreadsheet` still works). Use the authenticated artifact SDK to pin the
saved native head, materialize the modality's Office format, poll its job, and
download its bytes; never return the original imported file. Forward the abort
signal through every request. The control waits for live sync with no pending or
still-submitting edits, shows progress and retryable errors, and cancels on
teardown or revoked access. Omit the callback when the host does not support
export.
Double-click a sheet tab or focus it and press F2 to rename it. Enter/Save submits
the canonical `sheet.rename` command; Escape/Cancel discards an unsent name.
Validation and failed submissions keep the name editor open. Pending cell edits
show submitted input until the Worker projection catches up; pending formulas
show their source, not an invented result.

The durable document surface composes bounded summary, body, section, and
review queries at one native revision. Text edits become UTF-16-correct minimal
`paragraph.edit` commands; formatting becomes `paragraph.format`. Empty
documents use the SDK's native-namespace structural allocator to create their
first durable paragraph without inventing React-side identity rules.

The durable presentation surface composes the bounded slide catalog and
per-slide editor ABI. It keeps master/layout inheritance and node hierarchy in
the projection, renders inherited nodes read-only in slide mode, and maps
movement, resizing, and rich-text changes to canonical node commands. Geometry
uses the Office EMU-to-CSS-pixel scale; unchanged command fields retain their
exact canonical values. Slide and text-box insertion/deletion likewise use
canonical SDK/session commands; optimistic UI never mutates a shadow deck.

Run `OPENGENI_REACT_DEMO_API_TARGET=http://127.0.0.1:8000 bun run demo`, then
open `http://localhost:3100/editable-artifacts.html?workspaceId=<id>`. This is a
live public-SDK reference consumer for all three modalities; it has no in-memory
or permissive fallback.

`DocumentProjectionEditor`, `PresentationProjectionEditor`, and
`SpreadsheetProjectionGrid` expose the same host-owned projection/async-command
boundary without requiring the Opengeni sync session. Pending, failure, retry,
read-only, focus, and reconciliation behavior stays inside the components.

Projection editors use browser-safe structural views and do not load
`@opengeni/artifact-tool`, its codecs, or native bindings. Local authoring tools
can optionally supply the reference models from `@opengeni/artifact-tool/reference`
to the compatible `DocumentEditor`,
`PresentationEditor`, or `SpreadsheetGrid` adapters. Those convenience adapters
deliberately mutate the supplied public authoring model.
`@opengeni/react/artifacts` remains the all-modality barrel.

## Install & styles

Hosts that render their own UI should import the session-only surface:

```tsx
import {
  useComposer,
  useSessionControl,
  useSessionEvents,
  useTurnQueue,
} from "@opengeni/react/session";
```

That subpath contains session hooks, approval helpers, and the pure timeline
projection only. It does not load the styled composer, timeline, workbench,
CSS, or their optional editor/terminal peers. Pass `{ client, workspaceId }` to
each hook when the host intentionally does not mount `OpenGeniProvider`. The
exported `SessionClientLike` is deliberately narrow: a tenant-safe proxy needs
only session events, composer draft/send, queue, pause/resume, and approval
operations. Hooks outside that baseline expose exact refinements rather than
requiring the full SDK client: `SessionReadClientLike`, `GoalClientLike`,
`SessionLineageClientLike`, `FileAttachmentClientLike`,
`HumanInputSessionClientLike`, and `SessionMcpApprovalPolicyClientLike`. A host
can therefore implement only the methods used by each hook. A shared event feed
also avoids requiring a client-owned event stream at runtime. Workspace-level
Resume is an optional authority.

Use `createEmbeddedSessionClient(base, options)` to bind that narrow surface
from a full SDK client or host proxy. Delegated methods preserve their SDK
receiver, `options.overrides.submitComposerDraft` can route atomic submission
through a host-authenticated endpoint, and `mapComposerDraft` can project every
read/save/submit draft before React adopts it. Construction fails immediately
when a required method is missing, and submit returns the host/native
`SubmitComposerDraftResponse` unchanged apart from an explicitly configured
draft projection.

The styled file surfaces accept a presentation-only node predicate:
`FileBrowser.isNodeVisible`, `SandboxFiles.isNodeVisible`, and
`SandboxWorkspace.isFileNodeVisible`. Hidden parents never expose orphaned
children, hidden selections/reveal requests are ignored, and omitting the prop
preserves the complete authoritative tree. This is not an authorization seam.

The styled root surface ships with ready-to-use CSS. Import it once; the host
does not need Tailwind:

```tsx
import "@opengeni/react/compiled.css";
```

The compiled entry contains the package's Tailwind v4 utilities and tokens,
omits global Preflight, and scopes every style rule to `.og-root`. Components
put that class on their standalone roots, including portalled menus, dialogs,
tooltips, and lightboxes. The low-specificity scope covers both the root element
itself and its descendants, so utilities work in either shape without resetting
the host document or globally registering Tailwind's `--tw-*` custom properties.
Tailwind runtime variables are initialized per element only inside those roots.
Independent package defaults inherit without replacing host `--og-*` values;
derived defaults use scoped effective values so changing a base accent, radius,
motion, or surface token updates its dependents at runtime.

`compiled.css` covers the documented embeddable components. The
`@opengeni/react/ui/*` subpaths are first-party building blocks for the
Opengeni web app, not a documented public API, and `compiled.css` does not
style them. A host that imports them anyway must compile them through the
Tailwind source bridge below. Some use Tailwind's `dark:` variant, which
follows the host's own `dark` variant (Tailwind's default is the operating
system's color scheme), not `data-og-theme`.

The original Tailwind v4 bridge remains available for hosts that intentionally
want Opengeni utilities compiled into their own Tailwind entry:

```css
@import "tailwindcss";
@import "@opengeni/react/styles.css";
@import "@opengeni/react/responsive.css";
@source "../node_modules/@opengeni/react/src";
```

`@source` lets the host compiler discover utilities used inside the components.
`responsive.css` is the small opt-in layout layer for
`responsiveBasis="container"`; omit it when every composer keeps the historical
viewport basis. `compiled.css` already contains that layer. Do not import both
`compiled.css` and `styles.css`. Consuming only the tokens?
Import `@opengeni/react/tokens.css` and use the `--og-*` variables directly.

### Product-compatible theming and density

The default typography and control geometry are the same values used by
`apps/web`. Put theme overrides on one ancestor; SDK popovers and dropdowns
copy the effective `--og-*` values from their trigger across the portal
boundary, so a menu mounted under `<body>` still matches the embedded panel.
SDK type utilities are also scoped against ordinary host resets such as
`.app button { font: inherit }`; customize their public tokens instead of
adding selector-specific overrides.

Every portalled surface is an independent `.og-root`. Token-themed portal
content copies the effective `--og-*` values from its trigger, preserving an
enclosing light, compact, or rebranded theme even though it mounts under
`<body>`; a standalone root with `data-og-theme` or `data-og-density` works
directly as well.

For a narrow product sidebar, opt into the supported compact preset:

```tsx
<aside data-og-theme="light" data-og-density="compact">
  <MessageTimeline {...timelineProps} />
  <ChatComposer {...composerProps} />
</aside>
```

Density and responsive measurement are separate opt-ins. By default,
`ChatComposer` keeps its existing viewport breakpoints. An embed whose composer
can be narrow inside a wide page should measure the composer instead:

```tsx
<aside data-og-theme="light" data-og-density="compact">
  <ChatComposer {...composerProps} responsiveBasis="container" />
</aside>
```

`responsiveBasis="container"` makes the composer root an inline-size query
container. Input density, footer wrapping, command descriptions, paused labels,
model controls, transcription status, and nested realtime controls then follow
that root's actual width. Coarse-pointer target sizing remains a separate media
feature, so a narrow mouse-driven panel stays compact while touch targets remain
at least 44px. Composer-owned model and realtime menus keep their portal
placement but copy the live root width with the existing theme tokens and never
grow wider than that source container. Compound layouts can pass the same prop
to `Composer.Root`. Hosts using the Tailwind source bridge import
`@opengeni/react/responsive.css`; the ready-to-use `compiled.css` includes it.

The preset is only a starting point. Override individual runtime tokens on the
same ancestor without rebuilding Tailwind:

```css
.my-agent {
  --og-font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
  --og-font-size-menu: 12px;
  --og-line-height-menu: 18px;
  --og-font-size-composer-wide: 13px;
  --og-line-height-composer-wide: 20px;
  --og-model-picker-menu-width: 15rem;
  --og-color-accent: oklch(0.58 0.19 288);
}
```

The type ramp is `--og-font-size-{xs,sm,base,md}` with matching
`--og-line-height-*` tokens. Interactive chrome uses the semantic `control`,
`menu`, `composer`, and `composer-wide` pairs. Model picker height, width,
padding, and row-density tokens are grouped under `--og-model-picker-*` in
`styles/tokens.css`; realtime menu width uses `--og-realtime-menu-width`.
`ModelPolicyPicker` also exposes `contentClassName` and
`contentStyle` for exceptional surface-level customization; tokens are the
recommended path.

## Quick start

```tsx
import { OpenGeniClient } from "@opengeni/sdk";
import {
  ChatComposer,
  MessageTimeline,
  OpenGeniProvider,
  QueueSurface,
  SessionStatus,
  useComposer,
  useSessionEvents,
  useTurnQueue,
} from "@opengeni/react";

const client = new OpenGeniClient({ baseUrl: "/api/opengeni" }); // proxy through your API

function OpsChannel({ sessionId }: { sessionId: string }) {
  const { timeline, sessionStatus, hasOlder, loadingOlder, loadOlder } =
    useSessionEvents(sessionId);
  const queue = useTurnQueue(sessionId);
  const composer = useComposer(sessionId, {
    effectiveControl: queue.effectiveControl,
  });
  return (
    <div className="flex h-full flex-col">
      {sessionStatus ? <SessionStatus status={sessionStatus} /> : null}
      <MessageTimeline
        items={timeline}
        status={sessionStatus}
        hasOlder={hasOlder}
        loadingOlder={loadingOlder}
        onLoadOlder={loadOlder}
        className="min-h-0 flex-1"
      />
      <QueueSurface queue={queue} composer={composer} />
      <ChatComposer
        composer={composer}
        effectiveControl={queue.effectiveControl}
      />
    </div>
  );
}

export function App() {
  return (
    <OpenGeniProvider client={client} workspaceId={workspaceId}>
      <OpsChannel sessionId={sessionId} />
    </OpenGeniProvider>
  );
}
```

## Your own labels and languages

Every piece of text the components show, including user-facing error messages,
comes from one message catalog. The built-in catalog is English; pass `locale`
and `messages` to `OpenGeniProvider` to show your own words or a full translation.

```tsx
import { OpenGeniProvider, type OpenGeniMessages } from "@opengeni/react";

const norwegian: OpenGeniMessages = {
  "composer.stop": "Stopp",
  "counts.items_one": "{count} element",
  "counts.items_other": "{count} elementer",
};

<OpenGeniProvider client={client} workspaceId={workspaceId} locale="nb" messages={norwegian}>
  {children}
</OpenGeniProvider>;
```

- Keys are stable, typed identifiers. `englishMessages` is the complete default
  catalog: export it to your translation tooling as is.
- Templates use single-brace parameters (`"Move queued prompt {index} up"`); a
  translation must keep the same parameters.
- Text that names the product uses `{product}`, filled from `productName`
  (default "Opengeni"). Pass `productName="Acme"` to `OpenGeniProvider` and the
  components say Acme there; your own translations can use `{product}` too.
- Counted text uses plural suffixes (`_zero`, `_one`, `_two`, `_few`, `_many`,
  `_other`) chosen with `Intl.PluralRules` for `locale`, so each language
  supplies the forms it needs. `_other` is always required. Where the runtime
  has no `Intl.PluralRules` (Hermes on React Native), 1 uses `_one` and every
  other count `_other`; no polyfill is needed.
- `locale` also sets the language of the dates, times and numbers the
  components format.
- Missing keys fall back to an outer provider, then English, never to a raw key.
  Declare a full translation as `OpenGeniMessageCatalog` and TypeScript reports
  every key you have not translated yet, including keys added in later releases.
- `OpenGeniMessagesProvider` scopes overrides to part of a tree (for example a
  different label in one panel); `useOpenGeniText()` gives your own components
  the same translator.
- `@opengeni/react/messages` exports the catalog and runtime without any DOM
  code, for React Native and server rendering.

Component props that accept labels (for example `ChatComposer`'s `messages`)
still win over the catalog; their defaults, such as `defaultChatComposerMessages(t)`,
are functions of the translator. Internal diagnostics (developer invariants and
protocol errors never meant for end users) stay in English.

## Realtime composer controls (`@opengeni/react/realtime`)

The realtime subpath is the exact Opengeni composer experience: model catalog
and selection, split-button motion, start/stop/retry states, microphone and
audio mute controls, diagnostics, recovery, and the same copy, ARIA, classes,
and styling used by the web console. It is deliberately separate from the root
entry so SSR and non-realtime consumers do not eagerly load browser media or
transport code.

For an existing session, place one public component in the composer's action
slot. It resolves `client` and `workspaceId` from `OpenGeniProvider` and reuses
the host's existing session/event projection rather than opening a duplicate
stream:

```tsx
import { ChatComposer } from "@opengeni/react";
import { SessionRealtimeControl } from "@opengeni/react/realtime";

<ChatComposer
  composer={composer}
  effectiveControl={effectiveControl}
  actionsStart={
    <SessionRealtimeControl
      sessionId={sessionId}
      sessionStatus={sessionStatus}
      effectiveControl={effectiveControl}
      events={events}
      eventsReady={!initialLoading}
      codexConnected={codexConnected}
    />
  }
/>;
```

For a new-session composer, create the session with `startMode: "realtime"`,
navigate to it, and pass the selected model to the same existing-session
control's `realtimeAutostartModel` prop:

```tsx
import { NewSessionRealtimeControl } from "@opengeni/react/realtime";

<NewSessionRealtimeControl
  codexConnected={codexConnected}
  onStart={async (model) => {
    const session = await client.createSession(workspaceId, {
      requestedSessionId: crypto.randomUUID(),
      startMode: "realtime",
      idempotencyKey: crypto.randomUUID(),
    });
    navigateToSession(session.id, { realtimeAutostartModel: model });
    return true;
  }}
/>;
```

Proxy-backed hosts can pass explicit `client` and `workspaceId` overrides. The
exported `EmbeddedRealtimeSessionClientLike` requires only catalog, begin,
Codex/Gateway negotiation, activation, heartbeat, ledger sync, and end. For
custom layouts, use `useSessionRealtime`, `useRealtimeModelSelection`,
`RealtimeVoiceControl`, and `RealtimeModelPickerMenu`; the batteries-included
wrappers remain the recommended path. Concurrent catalog reads are deduplicated
per client and workspace, and successful results are reused for a short bounded
window so embedded controls can remount without refetching.

Voice models that hold a conversation in one fixed language (catalog entries
with `languages`, such as Eleven Realtime) listen and reply in the person's
language. By default that is the `OpenGeniProvider` locale, else the browser's
language; the model menu's Language page stores another choice on the device
(`useRealtimeLanguagePreference`). Custom layouts pass
`useRealtimeModelSelection().languageFor(model)` as `useSessionRealtime`'s
`language` and `voiceSettingsFor(model, hostSettings)` as its `voiceSettings`
(and both to `prestartSessionRealtime`).

The voice, conversation language and turn eagerness are configurable wherever
the selected model offers them (see the catalog entry's `voices`/`customVoice`,
`languages` and `turnEagerness`; settings a model does not offer are ignored).
To set them for every voice session in a workspace, save them once with the
SDK; Settings > Models > Voice edits the same setting:

```ts
await client.updateWorkspaceSettings(workspaceId, {
  realtimeVoice: {
    "workspace-elevenlabs/eleven_realtime_v1_max": {
      voice: "<ElevenLabs voice id>",
      language: "no",
      turnEagerness: "high",
    },
  },
});
```

For Eleven Realtime, the voice is one of the built-in names in the catalog
entry's `voices` or the id of a voice in the ElevenLabs account's My Voices. A
voice from the ElevenLabs voice library must be added to My Voices first;
otherwise ElevenLabs rejects it and voice fails to start with
`ELEVENLABS_REALTIME_VOICE_UNAVAILABLE`.

To set them per session instead, pass `voiceSettings` to the stock control,
for example `<OpenGeniChat conversationProps={{ realtimeVoiceProps: {
voiceSettings: { voice: "<ElevenLabs voice id>", language: "no" } } }} />`, or
to `SessionRealtimeControl`, `useSessionRealtime` or `prestartSessionRealtime`
(SDK controllers take `getVoiceSettings`). Each set field overrides the
workspace's value; a language the person picks in the voice menu overrides both.

The reference consumer is `demo/realtime.html`. Run `bun run demo` from
`packages/react`, then open `http://localhost:3100/realtime.html?mode=mock` for
deterministic selection/start/mute/stop/reconnect/error testing. Use
`?mode=live&workspaceId=…&sessionId=…` against the web server's same-origin
`/demo-api` proxy for a real local Opengeni environment. Configure
`OPENGENI_DEMO_API_URL` and, only on the server, optional demo API/access
credentials; the browser receives neither credential. Prefer the deployment's
normal browser authentication. If the proxy needs a server credential, create a
dedicated tenant-scoped, least-privilege key for the reference demo and never
reuse a deployment-wide runtime secret. Helm deployments can mount a Secret
containing only `api-key` and/or `access-key` with
`web.demoApiCredentialsSecret`; those values are mounted read-only rather than
exposed as container environment variables. Local non-Kubernetes servers may
instead use `OPENGENI_DEMO_API_KEY` and/or `OPENGENI_DEMO_ACCESS_KEY`.

The container-responsive reference is `demo/composer-responsive.html`. It keeps
the browser viewport wide while resizing one child composer through 280, 320,
360, 420, 640, and 768px, with theme, density, paused, voice, model-menu, and
slash-command states available from the harness controls.

Microphone capture works on `localhost` or a secure HTTPS origin. A remote HTTP
deployment cannot request microphone access. The live page exercises catalog
loading, realtime-first creation, Codex Live, Gateway models, mute, recovery,
delegation/context timeline updates, structured questions, and stop/restart
through published package APIs only.

## Browser and computer surfaces (`@opengeni/react/interaction`)

The interaction subpath renders the same browser-native and semantic computer
surfaces used by the Opengeni web app. It consumes only the public SDK client:
workspace discovery, peer switching, tabs/windows, live frames, human input,
identity versions, interventions, diagnostics, reconnect, and lifecycle state do
not require app-private controller glue.

Managed browser actions keep the foreground tab in place. Target-local Chromium
focus emulation keeps animation callbacks running without an open preview;
explicit activation brings a tab forward. The override ends when its controller
detaches. Native **App controls** also work in the background where supported.
Physical desktop mouse/keyboard input shares the foreground seat; **Bring to front**
makes that change explicit.
Computer frames fit the dock while preserving their proportions. Resizing or
reopening the dock refits the visible image without changing capture resolution
or the coordinates sent to the computer.
Desktop opens a whole screen by default. Multiple screens use a compact screen
selector; app controls and window views are available from **Advanced**. Explicit
view choices survive refreshes while the target remains available.

Desktop IME candidates and their selection keys stay local; only committed text is sent.

A managed browser's attachment authority error keeps a same-browser **Reconnect**
action available. It obtains a fresh server-authorized attachment without creating
a replacement browser or replaying input. The fresh Connected Chrome instruction
is reserved for the selected extension-attached browser's generation loss; a
different lost browser in the workspace cannot change that recovery path.

The browser viewer negotiates bounded typing batches from the active controller's
short-lived attachment. Supporting helpers recheck the original controller,
target, document and frame fence before every action; old helpers retain one
request per action. Only queued text actions share a request (at most 16), with
each text/input event preserved. Keys, pointer input and clipboard operations
remain ordering barriers. A failed or uncertain action discards the queued suffix
without replay.
IME candidate-selection keys stay local to the viewer; only committed text is
sent to the remote page. A later ordinary Enter remains a remote key action.
Live image props use opaque byte buffers so React development timing diagnostics
cannot expand and retain every screenshot byte. Normal timing diagnostics remain enabled.
Queued inputs retain only immutable frame fences, not screenshot bytes or prior
render callbacks. Replacing the selected viewer invalidates its queued suffix.

```tsx
import { OpenGeniProvider } from "@opengeni/react";
import { BrowserViewer, ComputerViewer } from "@opengeni/react/interaction";

<OpenGeniProvider client={client} workspaceId={workspaceId}>
  <BrowserViewer
    sessionId={sessionId}
    onOpenComputer={(computerSessionId) => setSelectedComputer(computerSessionId)}
  />
  {selectedComputer ? (
    <ComputerViewer
      sessionId={sessionId}
      requestedComputerSessionId={selectedComputer}
      requestedComputerRequestId={selectedComputer}
    />
  ) : null}
</OpenGeniProvider>;
```

`BrowserViewer` follows the current agent until the human pins another workspace
browser. A headed managed browser can receive `createLinkedComputer`; the
returned ComputerSession must be the exact placement/window the browser uses.
`onOpenComputer` then changes the host layout to that resource—it must not open a
lookalike desktop. Closing either viewer never ends its durable resource.

`ComputerViewer` disables input when its control service is unavailable, even if
frames keep arriving. Reconnect refreshes the selected desktop's controls and
frames. App accessibility inspection failures leave independent live input
available; `useComputerSession().controlError` reports service loss separately
from the hook's general `error`.

If the chat's latest browser was lost or failed, the viewer names it and explains
why it is unavailable instead of showing the ordinary empty state. It offers the
existing new-browser controls without reopening the lost controller or selecting
another chat's browser. Explicitly closed browsers still use the empty state;
older failures do not replace a newer closed browser. This loss notice also takes
precedence over `renderEmpty`. Preview the deadline-loss case with
`browser.html?mode=mock&lost=1` (add `width=360&theme=light` for a narrow light dock).

Native dropdown popups may not appear in page frames. With a controller that
advertises focused input observations, clicking one opens its choices beside
the click. Ordinary clicks use a bounded focus probe instead of a full page
snapshot; only a focused native dropdown (or a child-frame focus hint) requests
semantic options. Current controllers show no permanent **Choose option** button
over the page; older controllers retain that explicit fallback. **Alt+Down** opens
the focused dropdown and reads its options on either controller generation.
Selection uses the normal browser action API. The viewer retains that
observation's target/document/frame fence. Private, oversized, or ambiguous
choices remain unavailable; the page's keyboard controls still work. This
fallback requires a controller with focused native-select metadata support and
does not rewrite the page or capture the desktop.

The provider opens one shared workspace interaction-revision stream and every
catalog refreshes only when its revision advances; hidden or disconnected pages
fall back to bounded polling and reconcile from the retained cursor. Hosts that
do not use `OpenGeniProvider` may pass the same structural `client` and
`workspaceId` directly.

Run `bun run demo` and open `browser.html` or `computer.html`. Their `mode=mock`
paths are deterministic; `mode=live&workspaceId=…&sessionId=…` uses the
same-origin `/demo-api` proxy and the exact published SDK/React entrypoints.

## Composer customization (`@opengeni/react/composer`)

`SessionChrome` accepts `onComposerFocus` for queue editing. Connect it to
`controller.focusInput` from `useChatComposerController`, or focus a custom
input through its ref. Chrome calls it after a successful checkout and
`composer.applyDraft`, including confirmed draft replacement; failed checkout
and pending/cancelled replacement do not request focus. The host retains
ownership of the input and no DOM selector or global focus event is needed.

When sharing `useSessionEvents().events` with `useGoal`, the hook still fetches
the authoritative goal on mount and target changes, even when the supplied log
already contains goal events. The shared log drives subsequent invalidations
without opening another event stream. A 404 remains a normal goal-less state.

Use `ChatComposer` for the standard layout and its `controlsStart`, `header`,
and `messages` props for small additions. For a different layout, import the
headless controller and compound primitives as a namespace. The controller is
the one behavior path for keyboard routing, guarded queue/steer submission,
attachments, commands, pause/resume, focus, confirmations, and feedback.

```tsx
import * as Composer from "@opengeni/react/composer";

function InsertTranscript() {
  const composer = Composer.useChatComposer();
  return (
    <button
      type="button"
      onClick={() => {
        const separator = composer.value.trim().length > 0 ? " " : "";
        composer.setValue(`${composer.value}${separator}Transcribed text`);
        composer.focusInput();
      }}
    >
      Insert transcript
    </button>
  );
}

function CustomComposer({ sessionComposer, attachments, effectiveControl }) {
  const controller = Composer.useChatComposerController({
    delivery: sessionComposer,
    draft: sessionComposer,
    control: sessionComposer,
    attachments,
    effectiveControl,
  });

  return (
    <Composer.Root controller={controller} responsiveBasis="container">
      <Composer.Frame>
        <Composer.CommandPalette />
        <Composer.Surface>
          <Composer.PausedState />
          <Composer.RestoredResources />
          <Composer.Attachments />
          <Composer.Input />
          {controller.confirmState ? (
            <Composer.Confirmation />
          ) : (
            <Composer.Footer>
              <Composer.Controls>
                <Composer.AttachButton />
                <InsertTranscript />
              </Composer.Controls>
              <Composer.Actions>
                <Composer.PauseButton />
                <Composer.SendButton />
              </Composer.Actions>
            </Composer.Footer>
          )}
        </Composer.Surface>
      </Composer.Frame>
      <Composer.Help />
      <Composer.Status />
    </Composer.Root>
  );
}
```

For a pre-session or otherwise limited composer, pass only `delivery`; `draft`
and `control` are optional capabilities rather than no-op requirements. Custom
controls should call `controller.submit("queue" | "steer")` (or read it through
`useChatComposer`) instead of calling a delivery adapter directly, so upload,
disabled, in-flight, and slash-command guards stay intact. Accessory-local UI
state remains application-owned; durable draft and session state remain in
`useComposer`.

## Hooks

- `useSessionEvents(sessionId)` — loads a compact, bounded tail window by
  default, then live-streams on the SDK's exactly-once/ordered event delivery.
  Initial replay reads one 1000-row raw page, with at most one extra page to
  recover a dense turn's boundary; `loadOlder` is capped at two such pages;
  timeline group density is only an early stop. It returns the raw windowed
  `events`, projected `timeline`, latest `sessionStatus`, connection state, and
  older-history controls (`hasOlder`, `loadingOlder`, `loadOlder`). Pass
  `replay: "full"` to opt back into full replay; a nonzero `after` keeps the
  previous resume semantics. `loadOlder()` remains await-compatible and resolves
  to the existing `boolean`, while its promise also carries the causal
  `committed` receipt used by `MessageTimeline`. Pass it directly or through an
  existing wrapper, including `onLoadOlder={() => void loadOlder()}`; synchronous
  receipt capture preserves the commit signal without narrowing the historical
  callback return type. Custom loaders can use `createOlderHistoryLoadReceipt`
  and call `markCommitted` immediately before publishing their accepted older
  window.
- History is conversation-first: windowed reads exclude a turn's steps
  (`SESSION_EVENT_STEP_TYPES`: tool calls, tool outputs, reasoning and command
  output deltas, and the model request and usage rows of each model call) but
  keep presented tool results such as images and published files, and the
  model request rows up to a turn's first model response (its startup phases).
  Each turn with unloaded steps gets a `turn-steps` timeline item, which
  readable work rows render when opened. Opening a turn reads its newest
  `SESSION_TURN_STEP_PAGE_SIZE` steps, and "Show earlier steps" pages back.
  Live steps go to per-turn stores outside the conversation window, so a long
  turn never evicts the conversation. A collapsed live turn keeps its latest
  `SESSION_TURN_LIVE_STEP_TAIL` steps, an opened turn keeps every step, and
  steps of turns nobody has open are limited to
  `SESSION_STEP_BROWSER_MAX_BYTES`. `replay: "full"` still reads everything.
  Turnless step rows (PTY and background command output) are not part of the
  conversation window either: the sandbox terminal reads its own newest
  1,000 PTY and command-output rows by type the first time it is shown for a
  session, and live output keeps streaming in. When `useSessionEvents` rejoins
  the stream past unread terminal output (opening a chat, returning from a
  hidden tab or from history) or a burst overflows its live tail, the terminal
  reads its newest page again. That signal comes from this package's
  `useSessionEvents`; a terminal fed with `events` from elsewhere reads its
  history only when opened.
- The package and the Opengeni server can be upgraded in either order. A
  server that applies the conversation filter says so in the
  `X-OpenGeni-Conversation-Filter` response header
  (`SESSION_EVENT_CONVERSATION_FILTER_VERSION`). Without it, history reads keep
  tool calls and model bookkeeping and drop non-presented calls in the browser,
  so an older server that ignores `presentedToolCalls` loses no image,
  published file or Skill card. The header is read through `listEventPage`:
  a client without it (for example a host proxy exposing only `listEvents`)
  always uses that fallback. `createEmbeddedSessionClient` keeps
  `listEventPage` when the base client has it.
- Browser retention limits are exported as `SESSION_EVENT_BROWSER_MAX_BYTES`
  and `SESSION_EVENT_BROWSER_MAX_COUNT`; they do not change fetch page sizes.
  The live working set is at most 16 MiB or 20,000 events. One event larger than
  the byte target stays complete in a window of its own.
  Live appends reuse the retained window's byte total, measuring only incoming
  and evicted events. History still pages when either retention limit is reached.
- Newer history uses `hasNewer`, `loadingNewer`, and `loadNewer`. A failed
  `loadNewer()` preserves the retained events and cursors, exposes the original
  failure through `error`, and still rejects for the caller to handle. Pass
  `onLoadNewer={loadNewer}` (or return its promise from your wrapper) to
  `MessageTimeline`: it catches the request failure, shows the error and an
  explicit **Retry later activity** action, and does not automatically retry
  the failed boundary. A successful retry, jump to the start/latest window,
  or session change clears that failure. Loading older rows alone does not
  resolve a failed newer read. Late failures from a previous session are ignored.
  A host that discards the promise must handle its own rejected request.
- `useComposer(sessionId, { sendExtras, effectiveControl, busySend })` — revisioned private
  draft, Send, Steer, and workstream Pause/Resume state. `send()` appends in
  visible queue order (including while paused); `steer()` puts the new direction
  directly in chat, and a running turn reads it as soon as it can, usually
  within seconds, without cutting off running tools. With `busySend: "steer"`,
  `send()` steers while a turn is running or prompts wait (it still queues
  while paused) and `sendToQueue()` always queues; the default `"queue"` keeps
  Send queueing. `alternateSend` names the other choice while Send takes that
  busy path (`null` otherwise) and `sendAlternate()` performs it; hosts show it
  beside Send exactly when it is not `null`. Under `busySend: "steer"` there is
  no other choice unless `offerQueue: true` is set (`singleSend` is then
  false): `alternateSend` stays `null`, Cmd/Ctrl+Enter sends, and the stock
  surfaces use `SingleSendWording` for queue-free copy. Resume is always an
  explicit control action and never an implicit side effect of Send. Drafts
  autosave with optimistic concurrency, survive failed sends, and reuse one
  `clientEventId` across retries so the server dedupes. Draft reads retry transient
  failures, including request timeouts, with backoff. Successful refreshes clear
  draft-read errors without dismissing Send, Steer, or control errors; a draft
  sync timeout does not mean the agent turn has stopped. `composer.policy` and
  `setModel` / `setReasoningEffort` / `setLatencyMode` expose the exact policy
  owned by that actor/session draft; policy is `null` until hydration completes.
  `sendExtras` (object or function evaluated at send time) is only for
  non-policy per-message fields such as live attachment resources and connection
  authority. Disabling durable draft persistence requires an explicit
  `initialPolicy`; no session or workspace fallback is invented. All human input
  is plain chat text by design; approvals flow as control events
  (`useSessionControl`), not bespoke widgets.
- `useTurnQueue(sessionId, { events })` — the one server-authoritative human
  prompt queue with `moveTurn`, crash-safe `editTurn`, identity-preserving
  `steerTurn`, and `removeTurn`. Mutations carry observed revisions and conflicts
  refetch server truth. Live-updates on `turn.*` and `session.queue.*` events — pass the
  `events` log from `useSessionEvents` to reuse its stream, or let it tail the
  session itself. Providerless self-streams reconcile authoritative queue/draft
  state after the SSE connection opens, closing the initial-read handoff race.
- `useGoal(sessionId, { events })` — goal state + autonomy counters
  (`autoContinuations`, `noProgressStreak`) with `pause(rationale?)` /
  `resume()`. Goal-less sessions yield `goal: null`. Live-updates on `goal.*`
  events.
- `useSessionControl(sessionId)` — durable `pause(reason?)` / `resume(reason?)`
  workstream controls and `approve`/`reject(approvalId, message?)` for
  `requires_action` approvals. Pause is recursive control state, not lifecycle
  status or queue work; Resume creates no message.
- `useSession(sessionId)` — fetch one session (optional polling) with
  `updateTitle(title)` (rename) and live title-patching on `session.title_set`.
- `useFileAttachments()` — the composer's attach flow: stages files, drives the
  SDK's direct-to-blob upload, and yields the `resources` to send with a message.
- `useAvailableModels()` — the deployment's provider-grouped selectable `models`
  plus the `defaultModel` to preselect (from the client config) for a picker.
- `useCodexAccounts()` — connected Codex (ChatGPT) accounts, the active/next-run
  pointer, and per-session account pinning for multi-account subscriptions.
  `refreshUsage({ maxAgeMs })` reuses live usage readings only when every
  listed account was read within `maxAgeMs`; concurrent refreshes share one
  provider read. `liveUsageUpdatedAt` is the time of the oldest account
  reading. See `retainReadsAcrossMounts` above for keeping the list and
  readings across navigation.
- `useSlashCommands(...)` — the slash-command palette state (registry + parsing +
  handlers) behind `CommandPalette`.
- `useWorkspaceSessions()` / `useScheduledTasks()` — workspace lists for
  fleet/manager views (optional polling). `useWorkspaceSessions({ projection:
  "summary" })` returns compact list entries; omit the option for full sessions.
  Scripted clients can omit the summary method and the hook projects full pages
  locally while retaining cancellation and causal read revisions.
- `useVaults()` / `useVault()` / `useVaultItems()` / `useVaultItem()` /
  `useVaultFieldVersions()` / `useSessionSecrets()` — vaults, items, field
  history and a session's vault policy, metadata only. `useVaultsApi()` returns
  the SDK's `client.vaults(workspaceId)` routes for writes and the one audited
  `revealField` call; keep revealed values in component state, never in a
  cache. Variable Sets and `useVariableSets()` were removed in 2.0.0.
- `useWorkspaces()` — the caller's workspaces with create/update (client-only;
  not bound to the provider's workspace).
- `useBillingUsage({ accountId?, workspaceId? })` — credit balance + recent
  usage events for billing meters (client-only, optional polling).

All workspace-scoped hooks resolve the client/workspace from
`<OpenGeniProvider>` or accept `{ client, workspaceId }` per call
(`useWorkspaces`/`useBillingUsage` need only the client). They depend on
`SessionClientLike` — a structural slice of `OpenGeniClient` — so proxy-backed
or scripted clients work unchanged.

## Timeline projection

`buildTimeline(events)` is a pure, tested reducer from the raw event log to
renderable items (user/agent messages with streaming flags, tool calls matched
to outputs, `session_create`/`session_send_message` calls promoted to worker
items, sandbox operations with command output, goal markers, status changes,
notices). User messages carry their attached `resources` and requested `tools`
so consumers can render attachment chips. `groupTimeline` clusters consecutive
activity for collapsed display. Use them directly if you want custom rendering
with the same semantics.

Failed worker items may also carry `failure: { code, message }`. The projection
accepts only the bounded structured orchestration envelope retained in MCP
output; it does not reinterpret arbitrary legacy error strings. `ActivityRail`
renders the code and message directly beneath the failed worker row.

### Compatibility

The projection is a tolerant reader over `SessionEvent.payload` because the wire
contract intentionally keeps payloads open. Unknown event types and unknown or
malformed fields are ignored, never fatal. The golden event-grammar suite in
`test/golden` is the compatibility contract for how existing durable logs render;
intentional changes should regenerate those snapshots and review the diff.

## Components

- `ChatComposer` — auto-growing textarea, Enter-to-send (IME-safe), direct
  Cmd/Ctrl+Enter steering, pause/resume controls, and inline finite error
  recovery. Paused sends visibly join the queue without resuming. Slots for app chrome:
  `controlsStart` (footer controls like model pickers / attach buttons),
  `header` (e.g. attachment chips above the field), and `onPaste`
  (paste-image-to-attach). Its advanced controller and compound primitives are
  exported from `@opengeni/react/composer`.
- `MessageTimeline` — the session timeline with stick-to-bottom scrolling, a
  "jump to latest" affordance, streaming caret, collapsible activity clusters,
  and worker cards (wire `onOpenSession` to drill into a worker). Pass
  `renderMessageText` to plug a markdown renderer. The default renderer shows a
  single newline in a message as a line break, like other chat apps; a custom
  renderer gets the same result with `<Markdown softLineBreaks>{text}</Markdown>`
  (off by default for non-chat Markdown). Pass
  `loadRetainedArtifact` to render permanent generated-image receipts; a loader
  may return verified bytes or a short-lived signed URL. The stock web app uses
  the URL path to avoid copying multi-megabyte images into JavaScript memory.
  `createWorkspaceRetainedArtifactLoader`, `createSessionRetainedScreenshotLoader`,
  and `createWorkspaceRetainedVideoLoader` bind the SDK reads to a workspace
  (and session); `SessionConversation` uses them by default.
- `UserMessageBody` — the shared lossless rendered-height disclosure for
  already-sent user text. Use it inside a custom `renderMessageText` user branch
  so attachments and voice identity remain outside the clipped Markdown region.
  Pass `disclosureLabels={{ showMore: "Afficher davantage", showLess: "Réduire" }}`
  to localize a direct instance. For the default timeline, pass the same object
  as `MessageTimeline.userMessageDisclosureLabels` or
  `SessionConversation.userMessageDisclosureLabels`; custom `UserMessageBody`
  renderers inside that timeline inherit these labels too. Each direct label
  overrides its timeline label independently, then falls back to `Show more`
  or `Show less`. Changing labels does not reset a message's expanded state.
  `UserMessageDisclosureLabels` is exported from both `@opengeni/react` and
  `@opengeni/react/session-ui`.
- `SessionStatus` / `StatusDot` — status badges; live states breathe.
- `FleetTile` — one session in a fleet grid: title, status, model, recency.
- `ModelPicker` — a compact model dropdown for a composer slot, grouping the
  host-exposed models by provider.
- `ModelPolicyPicker` — the full model policy control used by the Opengeni web
  app: provider/billing rails, model availability, reasoning effort, and
  runnable latency modes such as Fast. It accepts either `ClientModel[]` or
  catalog-backed `PickerModelRow[]`, and supports host-supplied labels.
- `Markdown` — the timeline's markdown renderer (GFM), also usable standalone.
  Top-level assistant tables in `MessageTimeline` can expand beyond the prose
  column into the actual conversation panel's available space. Small tables,
  paragraphs, user bubbles, and nested or standalone Markdown keep their normal
  width; oversized tables retain table-only horizontal scrolling. No host prop
  or viewport-wide layout override is required.
  Remeasurement during host rerenders or tail streaming does not temporarily
  resize the live table or displace an unpinned history reader.
  With `onSandboxFile`, a valid `sandbox:<path>[:line]` application link becomes
  an in-session Open action. The callback receives the decoded path unchanged;
  the optional line is positive and 1-based. Invalid sandbox references render
  as disabled text, never as empty or browser-navigation links.
- `CommandPalette` — the slash-command palette UI over `useSlashCommands`.

`SandboxWorkspace.openFileRequest` is the host seam for that callback. Each new
`requestId` selects Files, opens the exact path on the already selected session
target, reveals its loaded/lazy ancestors in the file tree, selects and scrolls
to the file, and optionally focuses the requested line. A request without a line
opens the top of the file; file publication/download remains a separate explicit
artifact action.

The timeline is extensible: `createToolRegistry` / `defaultToolRegistry` plug
per-tool renderers, and the rendering primitives (`ActivityDisclosure`,
`ScreenshotFigure`, `TermBlock`, `LightboxProvider`, …) compose custom rows with
the same semantics.

Collapsed turn summaries can also be customized per `MessageTimeline` instance.
Omit `turnSummary` to keep the built-in facets unchanged, use `add`/`remove` for
small modifications, or `replace` for a complete ordered summary:

```tsx
import type { TurnSummaryFacet } from "@opengeni/react";

const updatedRecordsFacet: TurnSummaryFacet = {
  id: "updated-records",
  summarize: ({ toolCalls }) => {
    const count = toolCalls.filter((call) => call.name === "records.update").length;
    return count > 0 ? { content: `${count} records updated` } : null;
  },
};

<MessageTimeline
  items={timeline}
  turnSummary={{
    facets: {
      remove: ["memories"],
      add: [updatedRecordsFacet],
    },
  }}
/>;
```

Custom facets receive an immutable normalized activity snapshot, including
ordered tool arguments, outputs, status, and timing. Added facets follow the
remaining built-ins in supplied order. Duplicate IDs keep their first
definition; remove a built-in before adding a custom facet with the same ID.
`replace` is type-exclusive with `add` and `remove`.

`turnSummary={{ rolling: true }}` selects the readable per-turn presentation:
startup shows the preparation orb outside any disclosure, then hands over to
Working without resetting the startup-inclusive elapsed clock. While work is
live, assistant progress messages stay fully formatted and visible,
followed by one Working or Waiting disclosure with the rolling latest step.
When the turn finishes, earlier assistant messages and tools share the Worked
disclosure; the final response remains visible. Expanding reveals that turn's
earlier prose and activity in chronological order, not a fold of multiple turns.
An already expanded or actively read view is preserved through settlement.
Routine machine inputs get one compact reason per resumed turn. Normal tip-follow
continues through long answers, and manual scrolling never auto-repins on new work.
Expanded outer work headers stay reachable at the top of the timeline until
their own details end; nested headers never stick. The contextual navigation
pill stays below the sticky-header strip and avoids interactive controls. If
no horizontal slot fits beside a toolbar, only the pill moves below it; the
timeline's size and scroll position do not change.

The single **Back to your message** button returns to the loaded user prompt
associated with the response or work being read: the latest prompt preceding
the viewport midpoint. It appears only when that prompt's start is more than
24 px above the viewport and its entire body and attachments are out of view.
Clicking synchronously scrolls and focuses that exact
mounted prompt near the top and leaves tip-follow. Later messages and queued
prompts do not change the destination while the reader remains in older work.
An older bounded window uses its own loaded context; a window with no associated
prompt shows no action. Navigation never replaces the history window or looks up
the globally newest message. There are no previous/next arrows.

`SessionConversation` includes this behavior automatically. Custom timelines
need no prompt-navigation callback:

```tsx
const events = useSessionEvents(sessionId);
<MessageTimeline
  events={events.events}
  items={events.timeline}
  turnSummary={{ rolling: true }}
  hasNewer={events.hasNewer}
  onJumpToLatest={events.jumpToLatest}
/>;
```

`onJumpToLatest` retains its separate live-bottom behavior. The deprecated
`MessageTimeline.onJumpToLatestQuestion` prop remains source-compatible but is
not invoked by contextual navigation.

### Optional global newest-message lookup

The public `useSessionEvents().jumpToLatestQuestion()` hook remains available for
hosts that deliberately build a **separate** global lookup action. It is not wired
to `MessageTimeline` or the first-party conversation's contextual button.
The hook checks the authoritative queue and normally uses one filtered forensic
lookup plus, if needed, two bounded context reads. It pages past legacy worker
completions and withdrawn/cancelled-before-start prompts, never substituting an
arbitrary question from a loaded old page. Queued/legacy admission uses filtered
lifecycle evidence to locate its real turn start. A distant prompt is retained as
one projection-only witness in `events.timeline`; `events.events` remains the
bounded contiguous raw window, so pass `items` as above.
The optional resolver loads on its first invocation, not when opening a session.
Identity and navigation guards also cover that module-loading delay.

Custom hosts can provide their own queue destination when invoking this hook:

```tsx
const navigateToNewest = () => events.jumpToLatestQuestion({
  onQueuedQuestion: async (turn, navigation) => {
    await queue.refresh();
    if (!navigation.isCurrent()) return;
    // Check the host's latest queue/error state before applying its focus request.
    setQueueFocusTarget((previous) => ({
      turnId: turn.id,
      requestId: (previous?.requestId ?? 0) + 1,
    }));
  },
});
// Pass queueFocusTarget to SessionChrome; each new request opens/focuses once.
```

A queue destination returns `null`, not an invisible transcript sequence. Without
`onQueuedQuestion`, a pending prompt rejects with `LatestQuestionQueuedError`;
the custom host can show guidance. Transitional queue state can be retried.
Check `navigation.isCurrent()` after awaits and immediately before queue UI effects:
an explicit history jump can supersede a queued lookup without changing the session.
The shared projection predicate supplies execution evidence for older queued turns
without `turn.started`, including tools, agent/sandbox activity, startup, recovery,
and capacity events. Compact cursor coverage skips coalesced delta runs.
`groupTimeline(items)` retains classic
grouping; `{ readableTurns: true }` selects the new projection. The deprecated
`foldExchanges` option aliases readable turns, not the removed cross-turn fold. See
[`docs/design/genie-loading.md`](../../docs/design/genie-loading.md).

## Sandbox surfacing

An opt-in workbench that surfaces a session's live sandbox — files, terminal,
diff, and (when available) a desktop pixel stream — driven by a negotiated
capability document so every surface degrades to a reason instead of crashing.

- `useSessionCapabilities(sessionId, { attachDesktop?, attachTerminal?, attachFiles? })`
  — negotiates the per-session capability doc, tracks lease liveness
  (`cold`/`warming`/`warm`), and acquires the viewer holder(s) that keep the box
  warm. Opening `DesktopViewer` engages the desktop immediately and records any
  required un-redacted/shared acknowledgment automatically before attaching.
- `useSandboxFiles` / `useSandboxGit` — the Pierre file tree + git status/diff
  feeds. Initial and refresh reads use the SDK's batched file-frontier and
  multi-repository Git queries behind one sandbox lease; `fs.changed` /
  `git.changed` notifications keep the result live.
- `useSandboxTerminal` / `useTerminalStream` — the read-only command-output
  firehose and the real interactive PTY over the minted `pty-ws` cell.
- `useDesktopStream` — the noVNC socket, hot-swapped on box rollover via
  `stream.url.rotated`.
- Components: `WorkspaceDock` (the resizable/collapsible right-hand dock with a
  vertical activity rail, persistent visited panels, and a panel-local Hide action),
  `FileBrowser` / `SandboxFiles`, `DiffView` / `PierreDiff` / `PierreFile`,
  `CodeEditor`, `SandboxTerminal`, and `DesktopViewer`.

These surfaces pull in [optional peer dependencies](#optional-peer-dependencies)
— install only the ones for surfaces you actually mount.

`SandboxWorkspace` keeps capture-backed file browsing passive, but an explicit
live-file open acquires a viewer. Failed opens show the connection error and a
retry that renegotiates the viewer instead of leaving a waking spinner running.
When composing `SandboxFiles` directly, pass `workspaceError` alongside
`liveWorkspaceReady` and supply an `onWakeWorkspace` callback that can retry a
failed negotiation. Complete PNG, JPEG, GIF, and WebP reads render as read-only
image previews; truncated reads and other binary formats remain non-editable
notices. Image previews use the existing bounded file-read path and do not
publish or retain additional files.

## Connected Machines (`@opengeni/react/machines`)

Bring-your-own-compute UI: the Machines dashboard, per-machine metrics, the
active-sandbox swap, and the enrollment flow. Imported from the
`@opengeni/react/machines` subpath so consumers that never surface machines
never pull it in.

- `useMachines({ sessionId? })` — polls the fleet, exposes `attach(sandboxId)`
  (wired to the SDK's active-sandbox swap when a `sessionId` is in scope),
  `fetchSeries`, and the `activeSandboxId` / `activeEpoch` pointer.
- `MachinesDashboard` / `MachineCard` / `MachineMetrics` — the fleet grid with
  per-machine meters and an attach/swap affordance.
- `MachineDockBar` / `SharedMachineDisclosure` — the backend-aware bar over the
  sandbox dock naming which machine (Modal box or your machine) it is bound to.
- `EnrollmentDeviceFlow` — the in-session device-flow panel (`userCode` +
  `verificationUri`, pending → authorized/denied/expired).
- `EnrollmentConsent` — the loud whole-machine approve page.
- `MachineStatusPill` / `ConnectionStatusPill` / `ConnectionDot` — status chips,
  plus the `MachineView` / `MachineState` / `MetricSample` view-model types.

```tsx
import {
  MACHINES_SESSION_POLL_MS,
  MachinesDashboard,
  useMachines,
} from "@opengeni/react/machines";

function Fleet({ sessionId }: { sessionId: string }) {
  const { machines, activeSandboxId, attach, attachingSandboxId, refresh } =
    useMachines({ sessionId, pollIntervalMs: MACHINES_SESSION_POLL_MS });
  return (
    <MachinesDashboard
      machines={machines}
      activeSandboxId={activeSandboxId}
      attachingSandboxId={attachingSandboxId}
      onAttach={(m) => attach(m.sandboxId)}
      onRefresh={refresh}
    />
  );
}
```

See the [Connected Machines guide](../../docs/connected-machines.md) for the
end-to-end embedder story (create-on-machine, discover, swap, enroll, revoke).

## Usage allowances (`@opengeni/react/usage`)

Show people where they stand against a workspace or member usage allowance.
A separate subpath, so hosts that don't meter usage never load it.

- `useUsage({ workspaceId? })` — the signed-in person's own `/usage/me`
  (through `getMyUsage` or any client with `requestJson`) plus a summary of
  which limit binds first. `refreshKey` re-reads when work settles.
- `UsageMeter` — "38% left · Resets Nov 1". Shares only; pass `formatAmount`
  for money, credits or plan multiples. `density="compact"` for menus,
  `"hero"` to lead a page.
- `UsageLimitNotice` — the calm composer line: nothing while comfortable, a
  dismissible heads-up near the limit, then who can raise it and when it
  resets. `labels` rewords it; `action` adds your own "Upgrade" button.
- `UsageMemberList` — the admin roster with a share-of-budget slider,
  optional fixed amounts, and a visible note when shares add up to more than
  the pool. Save rules through your backend in `onChangeRule`.

```tsx
import { UsageLimitNotice, UsageMeter } from "@opengeni/react/usage";

<UsageMeter workspaceId={workspaceId} />
<SessionConversation
  sessionId={sessionId}
  composerProps={{ header: <UsageLimitNotice workspaceId={workspaceId} /> }}
  allowanceExhaustedLabels={{ memberRemedy: "Ask your team admin for more." }}
/>
```

The conversation's "usage limit reached" row is customized on
`MessageTimeline`/`SessionConversation` with `allowanceExhaustedLabels` or
replaced with `renderAllowanceExhausted`. See
[usage allowances](../../docs/usage-allowances.md#react-components-and-the-console).

## Terminal startup input

`SandboxTerminal` captures bounded early typing while attaching. Supported stock
sandbox images negotiate explicit Bash Readline readiness; banners and first
output do not release queued commands. Silent prompts work too. If a startup
profile asks for input or disables line editing, **Use manual input** clears
buffered typing before enabling unguaranteed input. Old images/running ttyd
daemons retain labelled **legacy input** behavior; wait for their shell startup
before typing. Relay PTYs retain their existing OPEN_ACK behavior.

Custom consumers of `useTerminalStream` can inspect `inputReadiness` and expose
`useLegacyInput()` for `waiting` states. That action discards queued typing; it
is not an acknowledgement of shell readiness. A new React package alone cannot
upgrade an old sandbox image. See the [native readiness contract](../../agent/native/terminal-ready/README.md)
for compatibility, startup limits and coordinated release requirements.

## Optional peer dependencies

The chat/timeline surface needs only the required React/React DOM peers. All
existing root exports remain available without optional workbench peers, even
when a bundler resolves every reachable dynamic import. Install and enable
only the surfaces you mount, once in their client route or bootstrap:

- Terminal (`SandboxTerminal`): `@xterm/xterm`, `@xterm/addon-fit`,
  `@xterm/addon-web-links`.
- Desktop (`DesktopViewer`): `@novnc/novnc`.
- Diff (`DiffView` / `PierreDiff` / `PierreFile`): `@pierre/diffs`.
- Code editor (`CodeEditor`): `@uiw/react-codemirror` + the `@codemirror/lang-*`
  language packs you need (`css`, `html`, `javascript`, `json`, `markdown`,
  `python`).

```ts
import { enableSandboxTerminal } from "@opengeni/react/terminal";
import { enableDesktopViewer } from "@opengeni/react/desktop";
import { enableCodeEditor } from "@opengeni/react/editor";

enableSandboxTerminal();
enableDesktopViewer();
enableCodeEditor({
  javascript: async () =>
    (await import("@codemirror/lang-javascript")).javascript({ jsx: true, typescript: true }),
});
```

Omit imports and calls for surfaces you do not use. These entries also re-export
their components; root component imports continue to work after setup. The
libraries load on mount, not during setup or SSR. Import the setup from a lazy
route to keep its peer chunks outside the initial conversation graph.

For optional terminal WebGL acceleration, install `@xterm/addon-webgl` and call
`enableSandboxTerminal({ webgl: () => import("@xterm/addon-webgl") })`. Without
it, or if it fails, the terminal uses its DOM renderer. Only supply editor
grammar loaders for packages you installed; absent/failed grammars use plain
CodeMirror. Without editor setup, the existing textarea fallback remains usable.
Without terminal/VNC setup, those surfaces show a setup error; relay-frame
desktops and a custom `rfbFactory` need no noVNC setup.

Custom hosts may supply `registerSandboxTerminal`, `registerCodeEditor`, or
`registerDesktopViewer` loaders from the root instead. Mounted fallbacks retry
when registration changes. Do not hide peer imports behind runtime bare strings
or `@vite-ignore`: browsers cannot resolve those without an import map.

## Demo harness

`bun run demo` (from this package) serves a harness that drives the real hooks
and components against a scripted mock client — a manager ops-channel narrative
with streaming, tool calls, and a worker spawn, plus fleet and scheduled-task
views and a dark/light toggle. `realtime.html` is the public-package reference
consumer described above, with deterministic mock and same-origin live modes.
`bun run demo:build` is part of the repo gate.

### Model selection

`ModelPolicyPicker` opens a flat, searchable list grouped by payment source, with
the selected model checked in its provider group. Choosing a model applies it and closes the popover.
Thinking and supported speed controls remain in a fixed footer instead of a
nested page. Model changes preserve supported reasoning effort and latency;
unsupported effort falls back to the new model's default, and unsupported speed
returns to Standard. Thinking uses inline radio choices and is hidden when the
model has no adjustable reasoning levels. Availability and Codex-only session restrictions still disable choices.

Use `allowModelOptions={false}` for policies that save only a model identity,
such as a fallback order. This hides effort and speed controls without changing
the catalog, payment-source labels, or model eligibility.

The trigger renders immediately; the searchable popover loads when opened. Hosts
can translate its search, current-selection, empty-result, attachment-warning, and
thinking labels through `messages`, and override payment descriptions through
`messages.billingHints`.

The stock deployment-provided group is labeled **Models**, with a generic model
icon. It can contain both credit-backed and free models; the **Free** badge
depends on the model's explicit cost, not its group. Genuine external-provider
and subscription identities retain their own labels and marks.

Hosts can rebrand the full picker without replacing its interaction logic:

```tsx
<ModelPolicyPicker
  {...pickerProps}
  groupPresentation={{
    opengeni_credits: {
      label: "Acme Assist",
      icon: <AcmeMark aria-hidden="true" />,
      description: "Provided by your workspace",
    },
    codex_subscription: { description: null },
  }}
/>
```

`groupPresentation` is a partial map keyed by `PickerBillingClass`. Labels apply
to group headings, search, and trigger-icon accessibility. Icons apply to both
the menu and trigger; supply decorative, non-interactive content (SVG or image)
that fits the existing 14px slot. Explicit `null` hides an icon or description;
omitted fields retain defaults. Descriptions override `messages.billingHints`,
can also be shown for the deployment-provided group, and are searchable. Existing
`rows[].billingClassLabel` remains the fallback when no label override is supplied.
This is presentation only: model IDs, billing, ordering, availability and callbacks
are unchanged. The type `ModelPolicyPickerGroupPresentation` is exported from
both `@opengeni/react` and `@opengeni/react/composer`. The native `ModelPicker`
is a separate control; this API targets the full `ModelPolicyPicker` shown above.

The complete conversation uses this same neutral picker. Customize its appearance
without replacing composer controls through `SessionConversation.modelPickerProps`,
or through `OpenGeniChat.conversationProps`:

```tsx
<OpenGeniChat
  client={client}
  workspaceId={workspaceId}
  conversationProps={{
    modelPickerProps: {
      groupPresentation: {
        opengeni_credits: { label: "Acme Assist", icon: <AcmeMark aria-hidden="true" /> },
      },
      messages: { label: "Choose a model" },
    },
  }}
/>
```

`modelPickerProps` accepts only `groupPresentation` and `messages`; it does not
enable a hidden picker or change policy, model availability or callbacks. Keep
model-picker visibility controlled by `modelPicker` and the client configuration.

For a rendered example, open the composer-responsive demo with `?branding=host`.

Models always show their clean name and maker logo: picker rows carry
`modelDisplayName(model)` (`claude-opus-4-8` and
`organization-claude-subscription/claude-opus-5-5` read `Claude Opus 4.8` and
`Claude Opus 5.5`) and a `ModelMark`. Organization- and workspace-connected API
keys share one "API keys" group, and identical copies of one model in a
connection group show once. Render a model anywhere else with
`<ModelName model={id} />`, or `modelDisplayName` / `modelVendor` from
`@opengeni/react` or `@opengeni/sdk/model-display`. The trigger keeps a host's
explicit group icon; otherwise API-key models show the maker's logo.

Subscription descriptions appear once per provider group. Free models carry a
Free badge. Pass `hasImageAttachments` for the current draft to show an image
compatibility warning only when the selected model cannot view those images.

`MessageTimeline.renderMessageActions(item)` places host-owned controls beside
Copy and the timestamp for user messages and completed assistant messages.
The host owns feedback, fork authorization, and mutations; streaming assistant
messages omit this slot. Use the `group/copy` hover/focus state and preserve
visible touch targets when styling actions.

### Portable action reviews

`ApprovalSurface` accepts `loadReview(approval)` and `onViewDetails(review, path)`;
load the versioned facts with `client.getToolActionReview`. It shows one selected
action, counts and bounded fields instead of raw argument JSON. Keep
`selectedApprovalId` and `onSelectedApprovalChange` in the host if a full-page
detail route unmounts the surface, so returning cannot silently select a different
action. The native `onApprove` / `onReject` identity is unchanged and duplicate
submission is fenced until authoritative events remove the request.

Render `ToolActionReviewDetails` on the host's normal page with
`client.getToolReviewDetails`, passing the review's `actionDigest`, path and offset.
Details are immutable, authenticated, paginated, and redact protected values.
Restore focus to the original `data-review-path` button on return. No web-app
imports, iframe, second confirmation, or nested scrolling pane is required.
The standalone fallback retains bounded details for older SDK approvals.

Wrap `MessageTimeline` in `ToolReviewHistoryProvider` with its visible events,
authenticated `load(approvalId)` callback and `onViewDetails` to keep recorded
reviews readable inside the corresponding tool activity. Historical receipts
never render decision buttons. The same semantic tokens support light/dark
embeds; `demo/approval-review.html` is the synthetic state gallery.
