@ahmedramez-org/nexus-chat 0.0.0-stage → 1.0.0-beta.10

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 CHANGED
@@ -1,3 +1,192 @@
1
- # Temporary Holding Version
1
+ # @incorta/nexus-chat
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ NexusChat, the IncortaCopilot chat UI, for any app on any framework: one `<script>` tag for any
4
+ site, `<NexusChat />` for React, `mountNexusChat()` for everything else.
5
+
6
+ The chat always runs as a prebuilt Module Federation bundle with its own React and isolated
7
+ styles, never re-compiled by the app's bundler, so it never collides with the app's React, its
8
+ dependencies or its CSS. The connector adds about 6 KB gzipped to an app (half of it the Incorta
9
+ Intelligence mark its loading skeleton draws), and the React component about 7 KB more. The chat
10
+ build itself is not in this package: Copilot serves it, and each GitHub Release attaches it as
11
+ `nexus-chat-dist.tar.gz`.
12
+
13
+ ## Any site: one script
14
+
15
+ ```html
16
+ <script src="https://<incorta>/incorta/copilot/nexus-chat/widget.js" defer></script>
17
+ ```
18
+
19
+ Incorta serves this script next to `embed.html`; it is not in this package and needs no build. It
20
+ draws the Incorta Intelligence launcher at the bottom right and opens the embedded chat (below)
21
+ floating, docked or full screen, on the user's Incorta session, so the page holds no token. It is
22
+ one classic script with no imports, so a page on any origin loads it without CORS.
23
+
24
+ Optional `data-` attributes set it up: `data-theme="dark"`, `data-modes="floating,docked"` (the
25
+ windows allowed, in that order; default all three), `data-default-mode="docked"` (open on load;
26
+ default only the launcher) and `data-locale="fr"`. The page drives it afterwards through
27
+ `window.NexusChatWidget`: `open()`, `close()`, `setMode('docked')`, `setTheme('dark')`.
28
+
29
+ ## React
30
+
31
+ Two ways in: **direct**, where the chat mounts into the page and shares its session (Incorta's own
32
+ UIs), and **embedded**, where it runs in an iframe served by Incorta on the user's Incorta session,
33
+ so the app never holds a token (generated apps, other sites).
34
+
35
+ ### Embedded
36
+
37
+ ```tsx
38
+ import { NexusChat } from '@incorta/nexus-chat/react';
39
+
40
+ <NexusChat embedUrl={`${incortaUrl}/copilot/nexus-chat/embed.html`} theme="dark" />;
41
+ ```
42
+
43
+ It draws its own window: an Incorta Intelligence launcher at the bottom right opens the chat
44
+ floating (a card lifted off the page, opening out of the launcher, which stays under it to hide
45
+ it) or docked flush on the right (the page makes room). The chat's own top bar switches between
46
+ the windows (Float, Dock to the side, Enter full screen: every allowed one but the current) and
47
+ closes it back to the launcher; nothing sits above the chat. `modes` limits the windows (default
48
+ all three, in that order) and `defaultMode` opens one on load. Below 768px wide the chat covers
49
+ the page. The chat is drawn smaller in a small window, and a skeleton holds the window while the
50
+ chat loads. `theme` is followed live; `locale` is read when the chat first opens.
51
+
52
+ The Incorta cluster needs `iframe.inclusion.enabled` (and `frame-ancestors` set to the app's
53
+ domains). Where the browser keeps the Incorta session from the iframe (Safari, Firefox, private
54
+ windows), the chat shows Continue with Incorta: the browser asks the user to allow it once, and
55
+ the chat starts on their Incorta session. A user not signed in to Incorta signs in through a small
56
+ Incorta window that closes itself afterwards. No browser setting is needed.
57
+
58
+ While the user clicks or types in the app, the component tells the chat at most every ten seconds,
59
+ so Incorta's own idle timeout does not end the chat's session while the app is in use. A page that
60
+ places the iframe itself can do the same: `iframe.contentWindow.postMessage({ nexusChat: 'active' },
61
+ '<incorta origin>')`. If the session ends anyway, the chat shows Sign in to Incorta.
62
+
63
+ ### Direct
64
+
65
+ ```ts
66
+ // Copilot answers 400 without the signed-in user's token: read it from Incorta's
67
+ // session, and read it again after a 401 or 403.
68
+ let session;
69
+ const incortaHeaders = () =>
70
+ (session ??= fetch('/incorta/service/user/isLoggedIn', { credentials: 'include' }).then(
71
+ async (response) => ({
72
+ Authorization: `Bearer ${(await response.json()).accessToken}`,
73
+ 'X-XSRF-TOKEN': response.headers.get('X-XSRF-TOKEN') ?? '',
74
+ }),
75
+ ));
76
+ ```
77
+
78
+ ```tsx
79
+ <NexusChat
80
+ connection={{
81
+ client: 'general',
82
+ copilotUrl: '/incorta/copilot',
83
+ credentials: 'include',
84
+ headers: incortaHeaders, // read before each request
85
+ refreshAuth: async () => ((session = undefined), true), // after a 401 or 403: read again, retry
86
+ }}
87
+ theme="dark"
88
+ user={{ id: user.sub, name: user.name }}
89
+ onLoadError={(error) => setError(error.message)}
90
+ />
91
+ ```
92
+
93
+ The chat fills the component's box; the app draws any frame around it. Until the chat is mounted, a skeleton holds the box (the embedded window's loading face: the Incorta Intelligence mark and two lines, after 300 ms), and it fades once the chat mounts, or leaves at once when `onLoadError` fires, so the app's own failure takes the box. `theme`, `locale` (default:
94
+ the browser's), `direction`, `tenantId`, `user` and `window` update the running chat;
95
+ `connection`, `chat`, `actions`, `events`, `instanceId`, `assetsUrl` and `timeoutMs` are read once
96
+ at mount. `window` (`docked`, `canDock`, `setDocked`, `close`) hands the chat's window to the app:
97
+ the chat shows a dock toggle and a close button; with `mode`, `modes` and `setMode` (read at
98
+ mount) it shows a button per other mode instead of the toggle. An app that already builds the contract's
99
+ `environment` or `window` passes it as is. `onReady` receives the handle (`ask`, `attach`), and
100
+ `null` once the chat unmounts; `loadedNexusChatVersion()` names the loaded build for error reports.
101
+
102
+ ### Direct, in a window
103
+
104
+ ```tsx
105
+ <NexusChat
106
+ connection={{
107
+ client: 'general',
108
+ copilotUrl: '/incorta/copilot',
109
+ credentials: 'include',
110
+ headers: incortaHeaders, // as above
111
+ refreshAuth: async () => ((session = undefined), true),
112
+ }}
113
+ theme="dark"
114
+ modes={['floating', 'docked', 'fullscreen']}
115
+ defaultMode="floating"
116
+ />
117
+ ```
118
+
119
+ `modes` or `defaultMode` instead of `window` gives the direct chat the embedded window: the
120
+ launcher, floating, docked (the page makes room) and full-screen windows, full screen below 768px.
121
+ The chat's own top bar switches windows and closes it to the launcher, where the conversation
122
+ stays. `modes` limits the windows (default all three, in that order); `defaultMode` opens one on
123
+ load, else only the launcher shows. The other props are the direct ones, and an app passing
124
+ `window` keeps drawing its own frame.
125
+
126
+ ## Any framework
127
+
128
+ ```ts
129
+ import { mountNexusChat } from '@incorta/nexus-chat';
130
+
131
+ const chat = await mountNexusChat(element, {
132
+ connection: { client: 'general', copilotUrl: '/api/ai' },
133
+ environment: {
134
+ get: () => ({ theme: 'light', locale: navigator.language }),
135
+ subscribe: () => () => {},
136
+ },
137
+ });
138
+ chat.ask('What changed last month?');
139
+ chat.unmount();
140
+ ```
141
+
142
+ The options are the host contract: `connection` and `environment` are required; `chat`, `actions`,
143
+ `events` and `window` are optional, and the chat hides what a host leaves out. The chat fills
144
+ `element`. Until it is mounted the React component's skeleton holds the element (after 300 ms, in
145
+ the `environment`'s theme), and it leaves at once if the chat cannot load, so the app's own failure
146
+ takes its place. Mount in the browser only, never during SSR.
147
+
148
+ `chat={{ contexts: ['business-view', 'ai-container', 'file'] }}` lets users attach contexts, in
149
+ that menu order: `'file'` uploads, and each other built-in kind (`business-view`, `physical-table`,
150
+ `ai-container`, `knowledge-store`) is loaded by the chat from Incorta through
151
+ `connection.incortaApi` (for example, `{ type: 'bff', baseUrl: '/incorta/bff/v1' }`). Without
152
+ `incortaApi`, a bare kind is left out with a console warning; the public API has no Knowledge
153
+ Stores. A `ContextSource` of a kind replaces the chat's loader for it.
154
+
155
+ `loadNexusChat(source)` only loads the chat and resolves to its `mount`. It loads once per source,
156
+ retries once, and gives up after `timeoutMs` (default 20 s, counted while the page is visible: a
157
+ hidden tab gets a fresh deadline once it shows); a failed load is forgotten so the next
158
+ call tries again. Call it when the page is idle to make the first open instant.
159
+
160
+ ## Where the chat comes from
161
+
162
+ | Mode | Options | UI version | App setup |
163
+ | ------------------------ | ---------------------------- | ------------------------- | ---------------------------------------------------------------------- |
164
+ | Always updated (default) | `connection.copilotUrl` only | what that Copilot runs | none: Copilot serves `<copilotUrl>/nexus-chat/` |
165
+ | Pinned | `assetsUrl: '/nexus-chat/'` | the release you extracted | serve that release's `nexus-chat-dist.tar.gz`, extracted, at that path |
166
+
167
+ A pinned copy serves `remoteEntry.js` with revalidation (`no-cache`) and `assets/` as immutable.
168
+
169
+ A pinned UI keeps talking to a Copilot that keeps moving; the default mode keeps the two in step.
170
+
171
+ ## Everything in the package
172
+
173
+ React and other apps need only the first two imports; the rest serve hosts that federate the chat
174
+ themselves (web-react, Hikari).
175
+
176
+ | Import | What |
177
+ | --------------------------------- | ----------------------------------------------------------- |
178
+ | `@incorta/nexus-chat` | the connector (`mountNexusChat`, `loadNexusChat`) and types |
179
+ | `@incorta/nexus-chat/react` | `<NexusChat />` for React 18 and 19 apps |
180
+ | `@incorta/nexus-chat/contract` | the host contract's values (`NEXUS_CHAT_CONTRACT_VERSION`…) |
181
+ | `@incorta/nexus-chat/conformance` | checks a host bridge against the contract |
182
+ | `@incorta/nexus-chat/host` | host helpers (`createElementRootAdapter`…) |
183
+
184
+ ## Versions
185
+
186
+ Every NexusChat release publishes this package (the repository README → Releasing). `@beta`
187
+ installs the newest prerelease. To try a build published under another scope without changing
188
+ imports, alias it:
189
+
190
+ ```json
191
+ "@incorta/nexus-chat": "npm:@someone/nexus-chat@beta"
192
+ ```
@@ -0,0 +1,51 @@
1
+ import { type HostArtifactRendererCapability, type NexusArtifact, type NexusChatOptions } from './index.js';
2
+ /**
3
+ * Executable conformance checks for the NexusChat host contract.
4
+ *
5
+ * Every host builds its options in its own stack — web-react with Redux,
6
+ * Hikari with TanStack Query — so "it compiles against the types" proves very
7
+ * little. These checks exercise what the remote actually depends on: a valid
8
+ * environment, subscription teardown, well-formed contexts, triggers and
9
+ * actions, and the mount lifecycle of a host-native renderer.
10
+ *
11
+ * Deliberately free of any test framework. Consumers run it from whatever
12
+ * runner they already have and assert on the report, and the remote can expose
13
+ * it over federation so a consumer verifies the exact build it loads.
14
+ */
15
+ export type ContractCheckStatus = 'passed' | 'failed' | 'skipped';
16
+ export interface ContractCheckResult {
17
+ id: string;
18
+ title: string;
19
+ status: ContractCheckStatus;
20
+ /** Why it failed, or why there was nothing to check. */
21
+ detail?: string;
22
+ }
23
+ export interface HostContractReport {
24
+ results: readonly ContractCheckResult[];
25
+ failures: readonly ContractCheckResult[];
26
+ skipped: readonly ContractCheckResult[];
27
+ passed: boolean;
28
+ }
29
+ export interface ArtifactRendererContractOptions {
30
+ /**
31
+ * Artifacts the consumer claims to render natively. At least one is required:
32
+ * the lifecycle cannot be observed without a payload the host accepts.
33
+ */
34
+ artifacts: readonly NexusArtifact[];
35
+ /** Overridable so a consumer can supply a container inside its own tree. */
36
+ createElement?: () => HTMLElement;
37
+ }
38
+ export interface HostContractOptions {
39
+ renderer?: ArtifactRendererContractOptions;
40
+ /** Consumer test hook that changes one environment source (theme, locale) in place. */
41
+ triggerEnvironmentChange?: () => void | Promise<void>;
42
+ }
43
+ /**
44
+ * Verifies the mount lifecycle the remote relies on: the host paints only into
45
+ * the element it is given, keeps that element across an update, and leaves it
46
+ * empty on unmount so the remote can reuse or discard it.
47
+ */
48
+ export declare function checkArtifactRendererContract(capability: HostArtifactRendererCapability, options: ArtifactRendererContractOptions): Promise<HostContractReport>;
49
+ export declare function checkHostContract(options: NexusChatOptions, checkOptions?: HostContractOptions): Promise<HostContractReport>;
50
+ /** Human-readable summary for a consumer's test failure output. */
51
+ export declare function formatHostContractReport(report: HostContractReport): string;
@@ -0,0 +1,306 @@
1
+ import { BUILT_IN_CONTEXT_KINDS, NEXUS_CHAT_CONTRACT_VERSION, } from './index.js';
2
+ class CheckCollector {
3
+ results = [];
4
+ async run(id, title, check) {
5
+ try {
6
+ await check();
7
+ this.results.push({ id, title, status: 'passed' });
8
+ return true;
9
+ }
10
+ catch (cause) {
11
+ this.results.push({
12
+ id,
13
+ title,
14
+ status: 'failed',
15
+ detail: cause instanceof Error ? cause.message : String(cause),
16
+ });
17
+ return false;
18
+ }
19
+ }
20
+ skip(id, title, detail) {
21
+ this.results.push({ id, title, status: 'skipped', detail });
22
+ }
23
+ }
24
+ function ensure(condition, message) {
25
+ if (!condition)
26
+ throw new Error(message);
27
+ }
28
+ function toReport(results) {
29
+ const failures = results.filter((result) => result.status === 'failed');
30
+ return {
31
+ results,
32
+ failures,
33
+ skipped: results.filter((result) => result.status === 'skipped'),
34
+ passed: failures.length === 0,
35
+ };
36
+ }
37
+ function renderRequest(artifact, placement) {
38
+ return {
39
+ artifact,
40
+ messageId: 'contract-check-message',
41
+ taskId: 'contract-check-task',
42
+ placement,
43
+ isHistorical: false,
44
+ appearance: { theme: 'light', direction: 'ltr', locale: 'en' },
45
+ };
46
+ }
47
+ /**
48
+ * Verifies the mount lifecycle the remote relies on: the host paints only into
49
+ * the element it is given, keeps that element across an update, and leaves it
50
+ * empty on unmount so the remote can reuse or discard it.
51
+ */
52
+ export async function checkArtifactRendererContract(capability, options) {
53
+ const checks = new CheckCollector();
54
+ const createElement = options.createElement ?? (() => document.createElement('div'));
55
+ const accepted = options.artifacts.filter((artifact) => {
56
+ try {
57
+ return capability.canRender(renderRequest(artifact, 'canvas'));
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ });
63
+ await checks.run('renderer.canRender.total', 'canRender never throws', () => {
64
+ options.artifacts.forEach((artifact) => {
65
+ capability.canRender(renderRequest(artifact, 'canvas'));
66
+ capability.canRender(renderRequest(artifact, 'inline'));
67
+ const title = capability.getTitle?.(artifact);
68
+ ensure(title === undefined || (typeof title === 'string' && title.trim().length > 0), 'The optional renderer title must be a non-empty string or undefined.');
69
+ });
70
+ });
71
+ await checks.run('renderer.canRender.declines', 'canRender declines unknown artifact kinds', () => {
72
+ const declined = capability.canRender(renderRequest({ id: 'contract-check-unknown', kind: 'contract-check-unknown', data: null }, 'canvas'));
73
+ ensure(declined === false, 'canRender accepted an artifact of an unknown kind; the remote would render an empty frame.');
74
+ });
75
+ if (accepted.length === 0) {
76
+ const detail = 'No supplied artifact was accepted by canRender, so the mount lifecycle could not be observed.';
77
+ [
78
+ 'renderer.mount.paints',
79
+ 'renderer.mount.isolated',
80
+ 'renderer.update.keepsElement',
81
+ 'renderer.unmount.empties',
82
+ 'renderer.unmount.idempotent',
83
+ ].forEach((id) => checks.skip(id, id, detail));
84
+ return toReport(checks.results);
85
+ }
86
+ const artifact = accepted[0];
87
+ await checks.run('renderer.mount.paints', 'mount paints into the supplied element', async () => {
88
+ const element = createElement();
89
+ const mount = capability.mount(element, renderRequest(artifact, 'canvas'));
90
+ const painted = await paintsWithin(element);
91
+ mount.unmount();
92
+ await settle();
93
+ ensure(painted, 'mount produced no DOM inside the element supplied by the remote.');
94
+ });
95
+ await checks.run('renderer.mount.isolated', 'mount touches no element but its own', async () => {
96
+ const scope = document.createElement('div');
97
+ const element = createElement();
98
+ scope.appendChild(element);
99
+ const bodyChildren = new Set(document.body.children);
100
+ const headChildren = new Set(document.head.children);
101
+ document.body.appendChild(scope);
102
+ try {
103
+ const mount = capability.mount(element, renderRequest(artifact, 'canvas'));
104
+ await settle();
105
+ const leakedToBody = [...document.body.children].some((child) => child !== scope && !bodyChildren.has(child));
106
+ const leakedInsideScope = [...scope.children].some((child) => child !== element);
107
+ // A new <style> in the head is not a leak. Every CSS-in-JS host injects
108
+ // one on first render — antd's token sheet, emotion, styled-components —
109
+ // and that sheet is how the host's own BI content gets styled at all. It
110
+ // is deduplicated by the library and shared with the rest of the host
111
+ // page. What must not appear is anything else: a script, an iframe, or a
112
+ // stylesheet <link> the remote never asked for.
113
+ const leakedToHead = [...document.head.children].some((child) => !headChildren.has(child) && child.tagName !== 'STYLE');
114
+ mount.unmount();
115
+ await settle();
116
+ ensure(!leakedToBody && !leakedInsideScope && !leakedToHead, 'mount wrote DOM outside the element supplied by the remote.');
117
+ }
118
+ finally {
119
+ scope.remove();
120
+ }
121
+ });
122
+ await checks.run('renderer.update.keepsElement', 'update re-paints in place', async () => {
123
+ const element = createElement();
124
+ let supportedViews = [];
125
+ const request = {
126
+ ...renderRequest(artifact, 'canvas'),
127
+ onSupportedViewsChange: (views) => {
128
+ supportedViews = views;
129
+ },
130
+ };
131
+ const mount = capability.mount(element, request);
132
+ try {
133
+ await paintsWithin(element);
134
+ ensure(Array.isArray(supportedViews) &&
135
+ supportedViews.every((view) => view === 'chart' || view === 'table') &&
136
+ new Set(supportedViews).size === supportedViews.length, 'The renderer advertised invalid or duplicate artifact views.');
137
+ for (const view of supportedViews) {
138
+ mount.update({ ...request, view });
139
+ ensure(await paintsStablyWithin(element), `The advertised ${view} view did not remain painted.`);
140
+ }
141
+ mount.update(renderRequest(artifact, 'inline'));
142
+ ensure(await paintsStablyWithin(element), 'update emptied the element instead of re-painting it.');
143
+ }
144
+ finally {
145
+ mount.unmount();
146
+ await settle();
147
+ }
148
+ });
149
+ await checks.run('renderer.unmount.empties', 'unmount leaves the element empty', async () => {
150
+ const element = createElement();
151
+ const mount = capability.mount(element, renderRequest(artifact, 'canvas'));
152
+ await settle();
153
+ mount.unmount();
154
+ await settle();
155
+ ensure(element.childElementCount === 0, 'unmount left DOM behind; the remote reuses and removes these elements.');
156
+ });
157
+ await checks.run('renderer.unmount.idempotent', 'unmount and post-unmount update are safe', async () => {
158
+ const element = createElement();
159
+ const mount = capability.mount(element, renderRequest(artifact, 'canvas'));
160
+ await settle();
161
+ mount.unmount();
162
+ await settle();
163
+ mount.unmount();
164
+ mount.update(renderRequest(artifact, 'canvas'));
165
+ await settle();
166
+ ensure(element.childElementCount === 0, 'an update after unmount re-painted a detached element.');
167
+ });
168
+ return toReport(checks.results);
169
+ }
170
+ /** Lets a host root commit and flush its own microtask-deferred teardown. */
171
+ function settle() {
172
+ return new Promise((resolve) => setTimeout(resolve, 0));
173
+ }
174
+ const RENDER_STABILITY_MS = 50;
175
+ /**
176
+ * Polls for the element to paint, bounded. A host UI framework schedules its
177
+ * first commit, and in a real browser that can land a frame or two after
178
+ * `mount` returns — the contract requires painting, not painting within one
179
+ * macrotask (found by Hikari's built-remote e2e, the first consumer to run
180
+ * the renderer checks in a real browser). Timers only, so it behaves the same
181
+ * headless and under jsdom, where an immediate commit returns on the first
182
+ * probe.
183
+ */
184
+ async function paintsWithin(element, timeoutMs = 2_000) {
185
+ const deadline = Date.now() + timeoutMs;
186
+ for (;;) {
187
+ if (element.childElementCount > 0)
188
+ return true;
189
+ if (Date.now() >= deadline)
190
+ return false;
191
+ await new Promise((resolve) => setTimeout(resolve, 25));
192
+ }
193
+ }
194
+ /**
195
+ * An update starts with DOM from the previous render. Looking immediately can
196
+ * therefore miss a scheduled clear. Cross one scheduler boundary before
197
+ * probing, then require the element to remain painted for a defined stability
198
+ * window. This checks lifecycle continuity, not whether visual content changed.
199
+ */
200
+ async function paintsStablyWithin(element) {
201
+ await settle();
202
+ if (!(await paintsWithin(element)))
203
+ return false;
204
+ await new Promise((resolve) => setTimeout(resolve, RENDER_STABILITY_MS));
205
+ return element.childElementCount > 0;
206
+ }
207
+ const CONTEXT_KINDS = [
208
+ 'business-view',
209
+ 'physical-table',
210
+ 'insight',
211
+ 'knowledge-store',
212
+ 'ai-container',
213
+ ];
214
+ const TRIGGER_CHARS = ['@', '#'];
215
+ export async function checkHostContract(options, checkOptions = {}) {
216
+ const checks = new CheckCollector();
217
+ const { connection, environment, chat, actions } = options;
218
+ await checks.run('version', 'declares the supported contract version', () => {
219
+ ensure(options.version === NEXUS_CHAT_CONTRACT_VERSION, `version must be ${NEXUS_CHAT_CONTRACT_VERSION}; received ${String(options.version)}.`);
220
+ });
221
+ await checks.run('connection', 'connection names a client and a Copilot root', async () => {
222
+ ensure(typeof connection?.client === 'string' && connection.client.trim().length > 0, 'connection.client is required.');
223
+ ensure(typeof connection.copilotUrl === 'string', 'connection.copilotUrl is required.');
224
+ ensure(!/\/a2a\/?$/i.test(connection.copilotUrl.trim()), `connection.copilotUrl is Copilot's root, without /a2a; received ${JSON.stringify(connection.copilotUrl)}.`);
225
+ if (connection.headers) {
226
+ const headers = await connection.headers();
227
+ ensure(headers && typeof headers === 'object', 'connection.headers() must return an object.');
228
+ }
229
+ });
230
+ await checks.run('environment', 'environment.get returns a theme and a locale', () => {
231
+ const snapshot = environment.get();
232
+ ensure(snapshot && typeof snapshot === 'object', 'environment.get did not return an object.');
233
+ ensure(snapshot.theme === 'light' || snapshot.theme === 'dark', `theme must be "light" or "dark"; received ${JSON.stringify(snapshot.theme)}.`);
234
+ ensure(typeof snapshot.locale === 'string' && snapshot.locale.length > 0, 'locale is required.');
235
+ });
236
+ await checks.run('environment.subscribe', 'subscribe returns a working unsubscribe', async () => {
237
+ let notifications = 0;
238
+ const unsubscribe = environment.subscribe(() => {
239
+ notifications += 1;
240
+ });
241
+ ensure(typeof unsubscribe === 'function', 'subscribe did not return an unsubscribe function.');
242
+ if (checkOptions.triggerEnvironmentChange) {
243
+ await checkOptions.triggerEnvironmentChange();
244
+ await settle();
245
+ ensure(notifications > 0, 'subscribe did not notify after an environment source changed.');
246
+ }
247
+ unsubscribe();
248
+ // NexusChat unsubscribes on unmount, and a host may tear a mount down
249
+ // twice; the second call must not throw.
250
+ unsubscribe();
251
+ if (checkOptions.triggerEnvironmentChange) {
252
+ const before = notifications;
253
+ await checkOptions.triggerEnvironmentChange();
254
+ await settle();
255
+ ensure(notifications === before, 'unsubscribe did not detach the listener.');
256
+ }
257
+ });
258
+ await checks.run('chat.contexts', 'each context is a built-in kind or a source with a loader', () => {
259
+ const kinds = (chat?.contexts ?? []).map((spec) => {
260
+ if (typeof spec === 'string') {
261
+ ensure(BUILT_IN_CONTEXT_KINDS.includes(spec), `unknown context ${JSON.stringify(spec)}; a bare string is one of the built-in kinds: ${BUILT_IN_CONTEXT_KINDS.join(', ')}.`);
262
+ return spec;
263
+ }
264
+ ensure(CONTEXT_KINDS.includes(spec.kind), `unknown context kind ${JSON.stringify(spec.kind)}.`);
265
+ ensure(typeof spec.load === 'function', `the ${spec.kind} source has no load().`);
266
+ return spec.kind;
267
+ });
268
+ ensure(new Set(kinds).size === kinds.length, 'a context kind is listed twice (a source replaces its bare kind: list only the source).');
269
+ });
270
+ await checks.run('chat.triggers', 'each trigger is @ or # with a search', () => {
271
+ const chars = (chat?.triggers ?? []).map((trigger) => {
272
+ ensure(TRIGGER_CHARS.includes(trigger.char), `unknown trigger ${JSON.stringify(trigger.char)}.`);
273
+ ensure(typeof trigger.search === 'function', `the ${trigger.char} trigger has no search().`);
274
+ return trigger.char;
275
+ });
276
+ ensure(new Set(chars).size === chars.length, 'a trigger is listed twice.');
277
+ });
278
+ await checks.run('actions', 'each action has can() and run()', () => {
279
+ Object.entries(actions ?? {}).forEach(([name, action]) => {
280
+ if (action === undefined)
281
+ return;
282
+ ensure(typeof action.can === 'function' && typeof action.run === 'function', `actions.${name} needs both can() and run(). Omit it rather than supplying a placeholder.`);
283
+ });
284
+ });
285
+ const renderer = chat?.renderers;
286
+ if (!renderer) {
287
+ checks.skip('renderers', 'provides a host-native renderer', 'No renderer declared.');
288
+ }
289
+ else if (!checkOptions.renderer) {
290
+ checks.skip('renderers', 'provides a host-native renderer', 'No renderable artifacts were supplied, so the renderer lifecycle was not exercised.');
291
+ }
292
+ else {
293
+ const report = await checkArtifactRendererContract(renderer, checkOptions.renderer);
294
+ checks.results.push(...report.results);
295
+ }
296
+ return toReport(checks.results);
297
+ }
298
+ /** Human-readable summary for a consumer's test failure output. */
299
+ export function formatHostContractReport(report) {
300
+ return report.results
301
+ .map((result) => {
302
+ const mark = result.status === 'passed' ? '✓' : result.status === 'failed' ? '✗' : '–';
303
+ return `${mark} ${result.id} — ${result.title}${result.detail ? `\n ${result.detail}` : ''}`;
304
+ })
305
+ .join('\n');
306
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Host-side helpers for implementing NexusChat capabilities. Framework-free,
3
+ * like the rest of the contract: nothing here knows about React, only about
4
+ * elements, generations, and microtasks.
5
+ *
6
+ * `@incorta/nexus-chat-contract/host`
7
+ */
8
+ import type { HostArtifactRendererCapability, HostArtifactRenderRequest } from './index.js';
9
+ /**
10
+ * What an element-root adapter needs from the host: how to create a root inside
11
+ * a NexusChat-owned element, how to paint a request into it, and how to tear
12
+ * it down. `TRoot` is whatever the host's UI framework hands back — a React 18
13
+ * or 19 root, a hub entry, a vanilla controller.
14
+ */
15
+ export interface ElementRootAdapter<TRoot> {
16
+ /** Declines an artifact the host cannot draw natively; the remote falls back to its portable renderers. */
17
+ canRender(request: HostArtifactRenderRequest): boolean;
18
+ /** Called once per element while a root is live; a newer mount on the same element reuses the root. */
19
+ createRoot(element: HTMLElement): TRoot;
20
+ /** Paints (or repaints) one request into the root. Runs for `mount` and every `update`. */
21
+ render(root: TRoot, request: HostArtifactRenderRequest, element: HTMLElement): void;
22
+ /** Tears the root down. Runs in a microtask after the remote's disposing commit, only if no newer mount claimed the element. */
23
+ unmount(root: TRoot, element: HTMLElement): void;
24
+ /** Where a throwing teardown is reported; defaults to `console.error`. */
25
+ onUnmountError?(error: unknown, element: HTMLElement): void;
26
+ }
27
+ /**
28
+ * The renderer lifecycle every host used to hand-copy, in one place:
29
+ *
30
+ * - **one root per element, generation-counted** — the remote re-mounts the
31
+ * same container on an artifact swap and React runs effects twice in
32
+ * StrictMode, so `mount` arrives again on an element that already has a
33
+ * root; creating a second one orphans the first, so the newer mount reuses
34
+ * it and wins;
35
+ * - **deferred, guarded teardown** — the remote can dispose a renderer from
36
+ * inside its own commit; unmounting is queued as a microtask and skipped
37
+ * when a newer generation has claimed the element in the meantime;
38
+ * - **a swallowed teardown throw** — a failing unmount is reported, never
39
+ * rethrown into the remote's React tree.
40
+ *
41
+ * Hosts implement only the four framework-specific calls above.
42
+ */
43
+ export declare function createElementRootAdapter<TRoot>(adapter: ElementRootAdapter<TRoot>): HostArtifactRendererCapability;
44
+ /** A Business View named by its schema and view. */
45
+ export interface BusinessViewRef {
46
+ schema: string;
47
+ view: string;
48
+ }
49
+ /**
50
+ * Splits a `<schema>.<view>` id (Incorta's asset-path form) at the FIRST dot:
51
+ * a view name may contain a dot, a schema name may not. Null for anything that
52
+ * is not two non-empty halves.
53
+ */
54
+ export declare function splitBusinessViewId(id: string | undefined): BusinessViewRef | null;