@ada-cx/messaging-bridge 1.0.0-setup.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,328 @@
1
+ # @ada-cx/messaging-bridge
2
+
3
+ Build your own chat UI (a "custom app") on Ada Messaging's bridge contract.
4
+
5
+ This package carries TypeScript types, test mocks, and a thin loader. The
6
+ bridge runtime always loads from Ada's CDN at
7
+ `https://messaging-assets.ada.support/bridge.js`. Ada patches the runtime
8
+ continuously, so your installed package version never pins security logic.
9
+
10
+ ## Requirements
11
+
12
+ - Your custom app runs inside an iframe that Ada's core frame mounts. The
13
+ core frame is served from Ada's asset host, and it sits inside the page
14
+ that embeds the widget. Both origins are in your app's ancestor chain.
15
+ - You point the Web SDK at your app with its `appUrl` setting. Custom apps
16
+ are experimental. Contact your Ada team before you build on this feature.
17
+ - If your app's responses send no `X-Frame-Options` header and no CSP
18
+ `frame-ancestors` directive, browsers permit framing, and no server change
19
+ is needed. If your app restricts framing, `frame-ancestors` must allow
20
+ Ada's asset hosts and every site origin that embeds the widget:
21
+
22
+ ```
23
+ Content-Security-Policy: frame-ancestors https://messaging-assets.ada.support https://static.ada.support https://your-site.com
24
+ ```
25
+
26
+ Replace `https://your-site.com` with the origins of the pages that embed
27
+ the Ada widget. Browsers check `frame-ancestors` against every ancestor
28
+ frame, so a policy that lists only Ada's hosts blocks your app.
29
+
30
+ ## Quick start
31
+
32
+ Install the package:
33
+
34
+ ```bash
35
+ npm install @ada-cx/messaging-bridge
36
+ ```
37
+
38
+ Load the runtime, complete the handshake, then render from state:
39
+
40
+ ```ts
41
+ import { loadMessagingBridge } from "@ada-cx/messaging-bridge";
42
+
43
+ const bridge = await loadMessagingBridge();
44
+ const client = bridge.createBridgeClient();
45
+
46
+ // REQUIRED: send app.initialize within 15 seconds of your frame loading.
47
+ // If the handshake does not arrive, core unmounts your frame.
48
+ client.sendEvent("app.initialize");
49
+
50
+ // Subscribe to display-state updates from core.
51
+ const unsubscribe = client.subscribe(() => {
52
+ const state = client.getState();
53
+ renderMessages(state?.["chat.messages"] ?? []);
54
+ });
55
+
56
+ // Send a user message through the operations layer.
57
+ const handle = client.operations.sendMessage("Hello");
58
+ const message = await handle.settled();
59
+
60
+ // Tear down when your app unmounts.
61
+ unsubscribe();
62
+ client.destroy();
63
+ ```
64
+
65
+ `loadMessagingBridge()` memoizes the load. Repeated calls return the same
66
+ promise. A failed load is forgotten, so a later call retries.
67
+
68
+ Use typed state keys through the loaded module:
69
+
70
+ ```ts
71
+ const botName = client.getState()?.[bridge.STATE.CONFIG_BOT_NAME];
72
+ ```
73
+
74
+ ## Operations
75
+
76
+ `client.operations` provides typed helpers over the raw event contract. Each
77
+ helper carries the guards, debounces, and correlation logic that Ada's own
78
+ app uses. Prefer them over hand-built `sendEvent` calls.
79
+
80
+ ```ts
81
+ // Correlated send: the handle resolves with the message row.
82
+ const handle = client.operations.sendMessage("Hello");
83
+ const message = await handle.settled({ timeoutMs: 10_000 });
84
+
85
+ // Read tracking: debounced, monotonic, reset per conversation.
86
+ client.operations.markRead(message.cursor ?? "");
87
+
88
+ // Correlated survey submit.
89
+ const result = await client.operations
90
+ .submitCsat({ score: 5 }, { surveyType: "bot", conversationId })
91
+ .settled();
92
+ ```
93
+
94
+ Highlights:
95
+
96
+ - `sendMessage(body, { secret? })` returns a handle. `settled()` resolves
97
+ with the message once it appears in `chat.messages`, and rejects when core
98
+ reports a send error first (rate limit, over-length body, session not
99
+ ready, dispatch failure) — detected on the `chat.error.seq` advance, so a
100
+ repeat of the identical error text still rejects. A secret send writes
101
+ no transcript row, so its handle never resolves. Pass `timeoutMs` or skip
102
+ `settled()` for secret sends.
103
+ - `submitCapture(value).settled()` reports the server verdict for the
104
+ active capture field.
105
+ - `checkEndChatEligibility()` resolves with the End Chat survey decision.
106
+ - `markRead(cursor)` debounces 400 ms and keeps a monotonic watermark. Do
107
+ not reimplement read tracking.
108
+ - `startNewConversation()` applies a 5 second cooldown and returns `false`
109
+ while cooling down.
110
+ - `startFileUpload(file)` retains the `{ file, uploadId }` pair so
111
+ `retry()` replays exactly it.
112
+ - `notifyComposerChanged()` is payload-free and suppressed in secret mode.
113
+ Composer text never crosses the frame boundary.
114
+ - Chrome and settings helpers: `close`, `minimize`, `dismissError`,
115
+ `setLanguage`, `setTheme`, `emailTranscript`, and more. See the
116
+ `BridgeOperations` type for the full surface.
117
+
118
+ Use `outage.connectivityLost` as the connectivity signal. Do not substitute
119
+ `navigator.onLine`. It reports false positives on VPN and virtual-adapter
120
+ transitions.
121
+
122
+ ## Observation
123
+
124
+ `client.subscribeKey(key, callback)` fires only when one key's value
125
+ changes. `client.select(selector, callback)` does the same for a derived
126
+ projection. Both default to `Object.is` equality and accept a custom
127
+ `equals`. Core reuses references for unchanged keys, so identity equality
128
+ is sound for every key except `chat.messages`.
129
+
130
+ ```ts
131
+ client.subscribeKey("chat.isGenerating", (generating) => {
132
+ toggleTypingIndicator(generating === true);
133
+ });
134
+ ```
135
+
136
+ ## Derivation helpers
137
+
138
+ The loaded module exports pure helpers mined from Ada's reference app:
139
+ `messageKey`, `isHistoricalRow`, `findFirstUnread`, `selectUnread`,
140
+ `groupMessages`, `filterDisplayable`, `resolveBotName`, `isAgentTyping`,
141
+ and `isConnectivityLost`.
142
+
143
+ ```ts
144
+ const { messageKey, filterDisplayable, groupMessages } = bridge;
145
+ const rows = filterDisplayable(messages, state?.["chat.isConversationActive"] ?? false);
146
+ const grouping = groupMessages(rows);
147
+ ```
148
+
149
+ Use `messageKey` as your render key. Raw message ids change twice: a
150
+ streaming bubble swaps to its final id, and an optimistic send swaps to its
151
+ durable id. Keying on the raw id replays animations and breaks unread
152
+ anchors.
153
+
154
+ ### Options
155
+
156
+ | Option | Default | Purpose |
157
+ | --- | --- | --- |
158
+ | `cdnBase` | `https://messaging-assets.ada.support` | Asset origin for `bridge.js`. Must be an `https` URL. Plain `http` works only for loopback hosts such as `localhost` during local development. Override only for staging validation. |
159
+ | `pinBuildSha` | none | Full 40-character git SHA of a deployed CDN build. Loads that build's immutable copy instead of the current root asset. Not recommended for production. See [Pin the CDN build](#pin-the-cdn-build). |
160
+
161
+ Errors: `loadMessagingBridge` rejects with a `MessagingBridgeLoadError`. Its
162
+ `code` property identifies the failure:
163
+
164
+ | Code | Meaning |
165
+ | --- | --- |
166
+ | `invalid_cdn_base` | The `cdnBase` option is not a valid `https` URL. |
167
+ | `invalid_build_sha` | The `pinBuildSha` option is not a full 40-character hex git SHA. |
168
+ | `unstamped_build_sha` | The `pinBuildSha` option is the unstamped placeholder from a repository build. |
169
+ | `bridge_import_failed` | The asset failed to load (network, CSP, 404). |
170
+ | `bridge_module_invalid` | The loaded module is not an Ada bridge build. |
171
+
172
+ ### Pin the CDN build
173
+
174
+ The package exports `CDN_BUILD_SHA`. The value is the git commit SHA of the
175
+ monorepo commit this npm version was published from. Ada deploys each
176
+ commit's bridge runtime as an immutable SHA-rooted copy on the CDN. The
177
+ value therefore names the CDN build associated with this npm version.
178
+
179
+ Pass the SHA as the `pinBuildSha` loader option. The loader then skips the
180
+ root `bridge.js` asset and imports that build's immutable copy,
181
+ `<cdnBase>/<sha>/bridge/bridge.js`, directly:
182
+
183
+ ```ts
184
+ import {
185
+ CDN_BUILD_SHA,
186
+ isCdnBuildShaStamped,
187
+ loadMessagingBridge,
188
+ } from "@ada-cx/messaging-bridge";
189
+
190
+ if (isCdnBuildShaStamped()) {
191
+ await loadMessagingBridge({ pinBuildSha: CDN_BUILD_SHA });
192
+ }
193
+ ```
194
+
195
+ **Pinning is not recommended for production.** A pinned runtime misses Ada's
196
+ fixes and the loader-fence rollout. A pinned runtime can also predate later
197
+ core or server contract changes and stop working. Use a pin only to debug an
198
+ issue, to validate a staged build, or to reproduce a report against a known
199
+ runtime.
200
+
201
+ The option accepts any full 40-character hex git SHA of a deployed main
202
+ build. The loader rejects any other value with the `invalid_build_sha` error
203
+ code. Only published packages carry a real SHA. In the repository, and in a
204
+ locally built copy, `CDN_BUILD_SHA` is a 40-zero placeholder and
205
+ `isCdnBuildShaStamped()` returns `false`. The loader rejects the placeholder
206
+ with the `unstamped_build_sha` error code.
207
+
208
+ The npm publish and the CDN deploy of the same commit run in parallel. In
209
+ the first minutes after a release, a pin can fail with
210
+ `bridge_import_failed` until the deploy completes. If the deploy of that
211
+ commit failed, the pinned build never exists. The unpinned default is
212
+ unaffected in both cases.
213
+
214
+ `pinBuildSha` composes with `cdnBase`. The pinned copy resolves under the
215
+ asset host you pass.
216
+
217
+ ## React
218
+
219
+ The `./react` entry provides a provider and hooks. They delegate to a
220
+ `BridgeClient` instance. They contain no runtime logic of their own.
221
+
222
+ The simplest path handles loading, the `app.initialize` handshake, and
223
+ teardown for you:
224
+
225
+ ```tsx
226
+ import { createBridgeProvider, useBridgeState } from "@ada-cx/messaging-bridge/react";
227
+
228
+ const AdaBridgeProvider = createBridgeProvider();
229
+
230
+ function Root() {
231
+ return (
232
+ <AdaBridgeProvider fallback={<Spinner />}>
233
+ <MyChatUi />
234
+ </AdaBridgeProvider>
235
+ );
236
+ }
237
+
238
+ function MyChatUi() {
239
+ const state = useBridgeState();
240
+ return <MessageList messages={state?.["chat.messages"] ?? []} />;
241
+ }
242
+ ```
243
+
244
+ The provider accepts two more props for load failures. `errorFallback`
245
+ renders when the runtime fails to load. `onError` receives the
246
+ `MessagingBridgeLoadError`. Without them, the provider logs the error and
247
+ keeps rendering `fallback`.
248
+
249
+ To own the lifecycle yourself, pass a client you created:
250
+
251
+ ```tsx
252
+ import { BridgeProvider } from "@ada-cx/messaging-bridge/react";
253
+
254
+ <BridgeProvider client={client}>
255
+ <MyChatUi />
256
+ </BridgeProvider>
257
+ ```
258
+
259
+ With an injected client, you send `app.initialize` and call `destroy()`
260
+ yourself.
261
+
262
+ Hooks: `useBridgeClient()`, `useBridgeState()`, and `useBridgeStateKey(key)`.
263
+ `useBridgeStateKey` re-renders only when its key's value changes.
264
+ `useBridgeState` re-renders on every state update. Reach the operations
265
+ layer through `useBridgeClient().operations`.
266
+
267
+ ## Testing
268
+
269
+ The `./testing` entry provides a scriptable in-memory client for unit tests.
270
+ It performs no postMessage and no network access.
271
+
272
+ ```ts
273
+ import { createMockBridgeClient } from "@ada-cx/messaging-bridge/testing";
274
+
275
+ const mock = createMockBridgeClient();
276
+ render(<BridgeProvider client={mock}><MyChatUi /></BridgeProvider>);
277
+
278
+ mock.updateState({ "chat.isSending": true });
279
+ expect(mock.sentEvents).toContainEqual({
280
+ event: "chat.message.send",
281
+ payload: expect.objectContaining({ body: "Hello" }),
282
+ });
283
+ ```
284
+
285
+ Mock helpers: `setState`, `updateState`, `sentEvents`, `clearSentEvents`,
286
+ `subscriberCount`, and `destroyed`.
287
+
288
+ The mock implements the full client surface, `operations` included. Every
289
+ operation records its events in `sentEvents`. A correlated handle resolves
290
+ when your test scripts the answering state change:
291
+
292
+ ```ts
293
+ const handle = mock.operations.sendMessage("Hello");
294
+ mock.updateState({
295
+ "chat.messages": [
296
+ { type: "text", id: "m1", sender: "user", timestamp: 1,
297
+ body: "Hello", clientKey: handle.tempMessageUuid },
298
+ ],
299
+ });
300
+ await handle.settled();
301
+ ```
302
+
303
+ ## Contract notes
304
+
305
+ - `AppDisplayState` is the full state shape core publishes. `AppEvents` maps
306
+ each sendable event to its payload type.
307
+ - `app.initialize` is mandatory. Send it within 15 seconds of frame load.
308
+ - The runtime derives the core origin from `document.referrer`, or locks it
309
+ on the first valid state message. It rejects state from other origins.
310
+ - In Ada's custom-app frame, the first `app.initialize` hands core a
311
+ `MessagePort`. All later state and events ride that port. The port is
312
+ pinned to your first document, so your app must not navigate or reload its
313
+ own frame. A navigation disconnects the bridge permanently.
314
+ - The runtime detects the custom-app frame by the frame name core sets
315
+ (`ada-custom-app`), or by an opaque origin under older core builds. Do not
316
+ change `window.name` inside your app document.
317
+ - The custom-app frame keeps your real origin, so your own cookies and
318
+ storage work inside it. Browsers partition third-party storage by the
319
+ embedding site. Cookies need `Partitioned; Secure; SameSite=None`.
320
+ - The custom-app frame needs `document.referrer` to target the handshake.
321
+ Core provides the referrer by default. If a browser extension or policy
322
+ strips the Referer header, the handshake cannot send. The runtime logs an
323
+ error, and core falls back to Ada's default app after 15 seconds.
324
+
325
+ ## Support
326
+
327
+ This package supports Ada's custom app program. Open issues through your Ada
328
+ support contact.
@@ -0,0 +1,69 @@
1
+ import type { BridgeOperations, ObserveOptions } from "./operations.js";
2
+ import type { AppDisplayState, AppEvents } from "./types.js";
3
+ type Subscriber = () => void;
4
+ /**
5
+ * Framework-agnostic client connecting a display layer (Ada's app, or a
6
+ * customer-built custom app) to the core business-logic frame via typed
7
+ * postMessage. Create one with {@link createBridgeClient}.
8
+ */
9
+ export interface BridgeClient {
10
+ /** Current {@link AppDisplayState} snapshot, or `null` before first update. */
11
+ getState(): AppDisplayState | null;
12
+ /** Subscribe to state changes. Returns an unsubscribe function. */
13
+ subscribe(callback: Subscriber): () => void;
14
+ /**
15
+ * Dispatch a typed event to core business logic. Send `app.initialize`
16
+ * within 15 seconds of the frame loading, or core unmounts the app frame.
17
+ */
18
+ sendEvent<K extends keyof AppEvents>(event: K, ...args: AppEvents[K] extends undefined ? [] : [payload: AppEvents[K]]): void;
19
+ /**
20
+ * Subscribe to ONE state key. The callback fires only when that key's
21
+ * value changes (`Object.is` by default) — unlike {@link subscribe}, which
22
+ * fires on every state update. State arrives over postMessage (structured
23
+ * clone), which would mint a fresh identity for every object-valued key
24
+ * on every update, so the client restores the PREVIOUS snapshot's
25
+ * reference for each key that is structurally unchanged before notifying.
26
+ * Identity equality is therefore sound for every key: an object key's
27
+ * identity changes exactly when its content does (for `chat.messages`,
28
+ * that is every streaming delta). Returns an unsubscribe function.
29
+ */
30
+ subscribeKey<K extends keyof AppDisplayState>(key: K, callback: (value: AppDisplayState[K] | undefined, previous: AppDisplayState[K] | undefined) => void, opts?: ObserveOptions<AppDisplayState[K] | undefined>): () => void;
31
+ /**
32
+ * Subscribe to a derived projection of the state. The callback fires only
33
+ * when the selected value changes (`Object.is` by default; pass a shallow
34
+ * or structural `equals` for object/array selectors). Returns an
35
+ * unsubscribe function.
36
+ */
37
+ select<T>(selector: (state: AppDisplayState | null) => T, callback: (value: T, previous: T | undefined) => void, opts?: ObserveOptions<T>): () => void;
38
+ /**
39
+ * Typed helpers over the event/state contract — sends with their guards,
40
+ * debounces, and state-edge correlation mined from Ada's reference app.
41
+ */
42
+ readonly operations: BridgeOperations;
43
+ /**
44
+ * Remove all listeners and postMessage handlers, and reject every
45
+ * in-flight `operations` `settled()` promise with a
46
+ * `BridgeClientDestroyedError` (detect it by `error.name`).
47
+ */
48
+ destroy(): void;
49
+ }
50
+ /**
51
+ * Create a {@link BridgeClient} bound to the parent core frame.
52
+ *
53
+ * Declaration only (D21 dependency inversion): the implementation lives in
54
+ * the private `packages/bridge-runtime` workspace and ships exclusively as
55
+ * the CDN asset `bridge.js` — obtain it through `loadMessagingBridge()`.
56
+ * The runtime compiles against this signature (`MessagingBridgeModule` is
57
+ * pinned both ways by bridge-runtime's `cdn-contract.ts`), so the published
58
+ * type can never drift from the CDN implementation.
59
+ *
60
+ * Security model: the core origin is derived from `document.referrer` when
61
+ * available, otherwise locked on the first valid `core.state.update` message
62
+ * (trust-on-first-use); state from any other origin is rejected, and events
63
+ * only ever target the locked origin — never `"*"`. In a custom-app document
64
+ * the first `app.initialize` transfers a MessagePort to core and all later
65
+ * traffic rides that port. A native-injected `window.__ADA_INITIAL_STATE__`
66
+ * snapshot seeds the state when its timestamp is within a 10-minute TTL.
67
+ */
68
+ export declare function createBridgeClient(): BridgeClient;
69
+ export {};
@@ -0,0 +1,63 @@
1
+ import type { BridgeClient } from "./bridge-client.js";
2
+ import type { MessagingBridgeModule } from "./loader.js";
3
+ import type { AppDisplayState } from "./types.js";
4
+ export declare const BridgeContext: import("react").Context<BridgeClient | null>;
5
+ /**
6
+ * Provide a {@link BridgeClient} to the React tree.
7
+ *
8
+ * Pure delegation: the caller owns the client lifecycle. The provider does not
9
+ * create, initialize, or destroy the client — obtain one from the CDN module
10
+ * returned by `loadMessagingBridge()` (or use {@link createBridgeProvider} to
11
+ * have loading, `app.initialize`, and teardown handled for you).
12
+ */
13
+ export declare function BridgeProvider({ client, children, }: {
14
+ client: BridgeClient;
15
+ children: React.ReactNode;
16
+ }): React.JSX.Element;
17
+ /** The active {@link BridgeClient}. Throws outside a `BridgeProvider`. */
18
+ export declare function useBridgeClient(): BridgeClient;
19
+ /**
20
+ * The full display-state snapshot, or `null` before the first
21
+ * `core.state.update`. Re-renders on every state change.
22
+ */
23
+ export declare function useBridgeState(): AppDisplayState | null;
24
+ /**
25
+ * Returns a single typed value from the bridge state, or `null` when the state
26
+ * is not yet available. Provides full TypeScript autocomplete for the key names
27
+ * and returns the exact type declared in AppDisplayState.
28
+ *
29
+ * Re-renders only when THIS key's value changes (delegating to the client's
30
+ * `subscribeKey`), not on every state update the way `useBridgeState` does.
31
+ */
32
+ export declare function useBridgeStateKey<K extends keyof AppDisplayState>(key: K): AppDisplayState[K] | null;
33
+ export interface LoadedBridgeProviderProps {
34
+ children: React.ReactNode;
35
+ /** Rendered while the bridge runtime is loading. Defaults to nothing. */
36
+ fallback?: React.ReactNode;
37
+ /**
38
+ * Rendered when the runtime fails to load (network, CSP, 404). Defaults
39
+ * to `fallback`, so without it a failure looks like loading forever.
40
+ */
41
+ errorFallback?: React.ReactNode;
42
+ /**
43
+ * Called when the runtime fails to load, with the rejection — a
44
+ * {@link MessagingBridgeLoadError} for every loader-detected failure.
45
+ * When omitted, the failure is logged with `console.error`.
46
+ */
47
+ onError?: (error: unknown) => void;
48
+ }
49
+ /**
50
+ * Build a provider component that loads the bridge runtime from the CDN,
51
+ * creates a client, sends the required `app.initialize` handshake, and
52
+ * destroys the client on unmount.
53
+ *
54
+ * The returned component renders `fallback` (default: nothing) until the
55
+ * runtime has loaded, then renders `children` inside a {@link BridgeProvider}.
56
+ * When the load fails it renders `errorFallback` (default: `fallback`) and
57
+ * reports the error through `onError` (default: `console.error`).
58
+ *
59
+ * @example
60
+ * const AdaBridgeProvider = createBridgeProvider();
61
+ * root.render(<AdaBridgeProvider><MyChatUi /></AdaBridgeProvider>);
62
+ */
63
+ export declare function createBridgeProvider(load?: () => Promise<MessagingBridgeModule>): (props: LoadedBridgeProviderProps) => React.JSX.Element;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Git commit SHA of the monorepo commit this npm version was published from.
3
+ * The CDN pipeline deploys each main commit's bridge runtime under an
4
+ * immutable SHA root, so this value names the CDN build associated with this
5
+ * npm version. Pass it as the `pinBuildSha` loader option to load exactly
6
+ * that runtime build — not recommended for production (see the README).
7
+ *
8
+ * In the repository, and in any locally built copy, the value is the
9
+ * unstamped 40-zero placeholder ({@link isCdnBuildShaStamped} returns
10
+ * `false`); only published tarballs carry a real SHA.
11
+ */
12
+ export declare const CDN_BUILD_SHA: string;
13
+ /**
14
+ * Whether `sha` (default {@link CDN_BUILD_SHA}) is a stamped full git commit
15
+ * SHA rather than the unstamped repo placeholder.
16
+ */
17
+ export declare function isCdnBuildShaStamped(sha?: string): boolean;
@@ -0,0 +1,115 @@
1
+ import type { AppDisplayState, Message } from "./types.js";
2
+ /**
3
+ * Pure derivation helpers mined from Ada's reference app (`packages/app`).
4
+ * Each function is a faithful port of logic the app validates in production,
5
+ * so a custom app does not have to rediscover the edge cases the hard way.
6
+ * No DOM, no client: every helper is a pure function of state or messages.
7
+ *
8
+ * Declarations only (D21 dependency inversion): the implementations live in
9
+ * the private `packages/bridge-runtime` workspace and ship exclusively on
10
+ * the CDN runtime, typed on `MessagingBridgeModule`. The runtime compiles
11
+ * against these signatures (pinned both ways by bridge-runtime's
12
+ * `cdn-contract.ts`), so the published types cannot drift from the CDN
13
+ * implementation.
14
+ */
15
+ /**
16
+ * Stable identity for a message across both id swaps. A live stream bubble
17
+ * (`id: "stream:<correlationId>"`) and its authoritative final (`id: <server
18
+ * _id>`) are the same logical reply, and an optimistic user message and its
19
+ * server echo are the same logical send — keying "new message arrived"
20
+ * effects (alert sound, scroll, unread) on raw `id` treats each swap as a
21
+ * fresh arrival. `streamId` is identical across the first swap, `clientKey`
22
+ * across the second, so prefer them in that order and fall back to `id`.
23
+ * Use this as your render key and as the anchor for unread boundaries.
24
+ */
25
+ export declare function messageKey(message: {
26
+ id: string;
27
+ streamId?: string;
28
+ clientKey?: string;
29
+ }): string;
30
+ /**
31
+ * Whether a rendered row belongs to a conversation the chatter has left.
32
+ *
33
+ * The transcript is deliberately preserved across a conversation change, so a
34
+ * row from the previous conversation stays on screen but must not stay
35
+ * actionable: answering an old interactive row sends its target into the
36
+ * current conversation. The fail-safe directions are asymmetric on purpose:
37
+ * an unstamped message (predates stamping, or optimistic) is treated as
38
+ * current, while a stamped row with no active conversation pin is historical
39
+ * (a light reset clears the pin but keeps the transcript, and a stamped row
40
+ * cannot belong to "no conversation"). Consult this before enabling any
41
+ * interactive row (quick replies, options, retry).
42
+ */
43
+ export declare function isHistoricalRow(messageConversationId: string | null | undefined, state: AppDisplayState | null | undefined): boolean;
44
+ /**
45
+ * The first message a returning user has not read yet — the first bot/agent
46
+ * message whose durable `_id` cursor sorts (lexically) past the persisted
47
+ * read watermark (`chat.lastReadCursor`). Skips the user's own messages and
48
+ * presence markers, so the unread divider sits before incoming content.
49
+ * Returns `null` when the watermark is unset or everything has been read.
50
+ * Returns the message (not an id) so callers pick the right key: anchor
51
+ * unread boundaries on {@link messageKey} (stable across id swaps); use the
52
+ * raw `id` for DOM lookup.
53
+ */
54
+ export declare function findFirstUnread(messages: Message[], lastReadCursor: string): Message | null;
55
+ export interface UnreadSelection {
56
+ /**
57
+ * {@link messageKey} of the first unread message (the unread-divider
58
+ * anchor), or `null` when nothing is unread.
59
+ */
60
+ firstUnreadId: string | null;
61
+ /**
62
+ * Unread messages from the boundary on: `sender !== "user"`, excluding
63
+ * rows that render nothing (presence events without a dedicated row and
64
+ * videos with an unrenderable source).
65
+ */
66
+ count: number;
67
+ }
68
+ /**
69
+ * Unread boundary and count derived from the transcript and
70
+ * `chat.lastReadCursor`. Pass the output of {@link filterDisplayable} (plus
71
+ * your own `chat.answeredInteractiveIds` filtering) as `messages` so the
72
+ * boundary anchors on a row your transcript actually renders and the count
73
+ * matches visible rows — the reference app derives from its filtered list.
74
+ * Defaults to the raw `chat.messages`; rows that render nothing (non-row
75
+ * presence events, unrenderable videos) are excluded from the count either
76
+ * way. Pass `boundaryId` (a {@link messageKey}) to keep an already-shown
77
+ * divider anchored while newer messages arrive; when the anchor is no longer
78
+ * in the list (a baseline resync replaced the transcript) the boundary falls
79
+ * back to the persisted watermark — the same recovery the reference app
80
+ * performs.
81
+ */
82
+ export declare function selectUnread(state: AppDisplayState | null, opts?: {
83
+ messages?: Message[];
84
+ boundaryId?: string;
85
+ }): UnreadSelection;
86
+ export interface MessageGroupingEntry {
87
+ /** Render a date divider above this message (first message of its day). */
88
+ showDivider: boolean;
89
+ /**
90
+ * This message continues the previous sender's run — hide the avatar and
91
+ * sender attribution and tighten the spacing.
92
+ */
93
+ isGroupedWithPrev: boolean;
94
+ }
95
+ /** Per-index date-divider and sender-run grouping decisions. */
96
+ export declare function groupMessages(messages: Message[]): MessageGroupingEntry[];
97
+ /**
98
+ * The messages a transcript should actually render, in order. Drops:
99
+ * non-rendering presence events (see the event allowlist), `video` rows with
100
+ * an unrenderable source, superseded CSAT rows (only the latest survey per
101
+ * `(conversationId, surveyType)` renders), unsubmitted/unscored CSAT rows of
102
+ * an inactive conversation, and the positional Sign-In "Never mind" pair once
103
+ * it is stale. Pass `chat.isConversationActive` (default it to `false` when
104
+ * absent) as `isConversationActive`. Apply your own
105
+ * `chat.answeredInteractiveIds` filtering on quick-reply rows afterwards,
106
+ * then feed the result to {@link selectUnread} via its `messages` option so
107
+ * the unread boundary and count describe the rows you render.
108
+ */
109
+ export declare function filterDisplayable(messages: Message[], isConversationActive: boolean): Message[];
110
+ /** Display name for the bot: `config.botName`, else `config.handle`, else "Ada". */
111
+ export declare function resolveBotName(state: AppDisplayState | null): string;
112
+ /** Someone is composing a reply (core's typing verdict + active agent). */
113
+ export declare function isAgentTyping(state: AppDisplayState | null): boolean;
114
+ /** Core's connectivity verdict — never substitute `navigator.onLine`. */
115
+ export declare function isConnectivityLost(state: AppDisplayState | null): boolean;