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

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.
Files changed (80) hide show
  1. package/README.md +101 -2
  2. package/contract/conformance.d.ts +51 -0
  3. package/contract/conformance.js +304 -0
  4. package/contract/host.d.ts +54 -0
  5. package/contract/host.js +78 -0
  6. package/contract/index.d.ts +394 -0
  7. package/contract/index.js +35 -0
  8. package/index.d.ts +31 -0
  9. package/index.js +87 -0
  10. package/package.json +55 -4
  11. package/react.d.ts +42 -0
  12. package/react.js +114 -0
  13. package/remote/assets/AgentOptionControl-BnqfB5W3.js +1 -0
  14. package/remote/assets/CsvPreview-DD2VELWa.js +10 -0
  15. package/remote/assets/ErrorCard-Bu_0QAJ9.js +1 -0
  16. package/remote/assets/HighlightedCode-DpxGYHfF.js +1 -0
  17. package/remote/assets/HistorySearchDialog-q4cvx5jy.js +41 -0
  18. package/remote/assets/HostBridgeProvider-BV-UGU3Q.js +1 -0
  19. package/remote/assets/HtmlArtifactRenderer-EO_cwaEm.js +1 -0
  20. package/remote/assets/I18nContext-BlQKoDmv.js +24 -0
  21. package/remote/assets/PreviewState-VcIZyCXJ.js +1 -0
  22. package/remote/assets/SourceCitations-DvApeC9N.js +1 -0
  23. package/remote/assets/SourcesArtifactRenderer-BqnIOctm.js +1 -0
  24. package/remote/assets/TableArtifactRenderer-BLZLQMxU.js +2 -0
  25. package/remote/assets/TiptapComposerInput-CmxEiBlE.js +132 -0
  26. package/remote/assets/UnsupportedArtifact-CeOCu_J6.js +1 -0
  27. package/remote/assets/_virtual_mf-localSharedImportMap___mfe_internal__nexusChat__mf_owner__1-CObPUVU6.js +1 -0
  28. package/remote/assets/ar-BV81JAir.js +1 -0
  29. package/remote/assets/button-CO0tH6F2.js +1 -0
  30. package/remote/assets/client-Bv7EbCrM.js +2 -0
  31. package/remote/assets/composite-giRhJC3d.js +1 -0
  32. package/remote/assets/conformance-entry-uotihsU2.js +2 -0
  33. package/remote/assets/createLucideIcon-D1A0JYKN.js +1 -0
  34. package/remote/assets/de-BXJlhmC2.js +1 -0
  35. package/remote/assets/defineProperty-BbfpZ9Tg.js +1 -0
  36. package/remote/assets/dist-MaNcI-Om.js +4 -0
  37. package/remote/assets/errorReporter-hCgThzof.js +1 -0
  38. package/remote/assets/es-nnw68YFM.js +1 -0
  39. package/remote/assets/federation-entry-B8pojL73.css +2 -0
  40. package/remote/assets/federation-entry-BA9UpYiM.js +65 -0
  41. package/remote/assets/floating-ui.dom-B99QSfPH.js +1 -0
  42. package/remote/assets/floating-ui.utils.dom-ObnfY4F6.js +1 -0
  43. package/remote/assets/fr-CJIAbbzn.js +1 -0
  44. package/remote/assets/highlighter-C37NKSjx.js +19 -0
  45. package/remote/assets/hostInit-YMtJ2r-g.js +1 -0
  46. package/remote/assets/it-D3TiEKfy.js +1 -0
  47. package/remote/assets/ja-ZB0L4Lsc.js +1 -0
  48. package/remote/assets/jsx-runtime-DE3RlOCf.js +1 -0
  49. package/remote/assets/lib-CjT9YRPC.js +1 -0
  50. package/remote/assets/modelProviderIcons-fiyx4SaW.js +1 -0
  51. package/remote/assets/react-dom-FxOPXJ08.js +1 -0
  52. package/remote/assets/rehype-raw-CNOzW4vu.js +2 -0
  53. package/remote/assets/roboto-latin-400-italic-CSuqwwKr.woff2 +0 -0
  54. package/remote/assets/roboto-latin-400-normal-BqEyEoaF.woff2 +0 -0
  55. package/remote/assets/roboto-latin-500-italic-DOe3GFcv.woff2 +0 -0
  56. package/remote/assets/roboto-latin-500-normal-7RbcRiD8.woff2 +0 -0
  57. package/remote/assets/roboto-latin-700-italic-Cb9agdGy.woff2 +0 -0
  58. package/remote/assets/roboto-latin-700-normal-BZpUvMxY.woff2 +0 -0
  59. package/remote/assets/roboto-latin-ext-400-italic-BilqUbDw.woff2 +0 -0
  60. package/remote/assets/roboto-latin-ext-400-normal-C3tdtHj3.woff2 +0 -0
  61. package/remote/assets/roboto-latin-ext-500-italic-B5teBpxj.woff2 +0 -0
  62. package/remote/assets/roboto-latin-ext-500-normal-pMCM9Ixg.woff2 +0 -0
  63. package/remote/assets/roboto-latin-ext-700-italic-CBdFfgzf.woff2 +0 -0
  64. package/remote/assets/roboto-latin-ext-700-normal-DSBUz0N1.woff2 +0 -0
  65. package/remote/assets/rolldown-runtime-hePW80VL.js +1 -0
  66. package/remote/assets/sessionTitle-C9LhAN0r.js +1 -0
  67. package/remote/assets/spinner-eqRx6Fq-.js +1 -0
  68. package/remote/assets/sqlFormatting-BjJIfUN_.js +7 -0
  69. package/remote/assets/src-CIHHGjAO.js +1 -0
  70. package/remote/assets/store-FaSYSP4_.js +1 -0
  71. package/remote/assets/useBaseUiId-Dpqq0brK.js +1 -0
  72. package/remote/assets/usePositioner-BhCjFmeW.js +1 -0
  73. package/remote/assets/virtualExposes-C0oWo8z2.js +1 -0
  74. package/remote/assets/virtual_mf-REMOTE_ENTRY_ID___mfe_internal__nexusChat__remoteEntry_js-5uUvXWWH.js +1 -0
  75. package/remote/assets/virtual_mf-exposes-ssr___mfe_internal__nexusChat__remoteEntry_js-WBZ0Muv9.js +2 -0
  76. package/remote/assets/virtual_mf-exposes___mfe_internal__nexusChat__remoteEntry_js-BGZ2nkwm.js +2 -0
  77. package/remote/assets/vite-preload-helper-B7qeedMF.js +1 -0
  78. package/remote/assets/zh-De2Ilp0M.js +1 -0
  79. package/remote/remoteEntry.js +1 -0
  80. package/remote/remoteEntry.ssr.js +1 -0
package/README.md CHANGED
@@ -1,3 +1,102 @@
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: `<NexusChat />` for React,
4
+ `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. Importing the connector and the component adds about 3 KB to an app; `remote/` stays on
9
+ disk unless the app serves it.
10
+
11
+ ## React
12
+
13
+ ```tsx
14
+ import { NexusChat } from '@incorta/nexus-chat/react';
15
+
16
+ <NexusChat
17
+ connection={{ client: 'general', copilotUrl: '/api/ai', credentials: 'same-origin' }}
18
+ theme="dark"
19
+ user={{ id: user.sub, name: user.name }}
20
+ onLoadError={(error) => setError(error.message)}
21
+ />;
22
+ ```
23
+
24
+ The chat fills the component's box. `theme`, `locale` (default: the browser's), `direction`,
25
+ `tenantId`, `user` and `window` update the running chat; `connection`, `chat`, `actions`, `events`,
26
+ `instanceId`, `assetsUrl` and `timeoutMs` are read once at mount. `window` (`docked`, `canDock`,
27
+ `setDocked`, `close`) hands the chat's window to the app: the chat shows a dock toggle and a close
28
+ button. `onReady` receives the handle (`ask`, `attach`).
29
+
30
+ ## Any framework
31
+
32
+ ```ts
33
+ import { mountNexusChat } from '@incorta/nexus-chat';
34
+
35
+ const chat = await mountNexusChat(element, {
36
+ connection: { client: 'general', copilotUrl: '/api/ai' },
37
+ environment: {
38
+ get: () => ({ theme: 'light', locale: navigator.language }),
39
+ subscribe: () => () => {},
40
+ },
41
+ });
42
+ chat.ask('What changed last month?');
43
+ chat.unmount();
44
+ ```
45
+
46
+ The options are the host contract: `connection` and `environment` are required; `chat`, `actions`,
47
+ `events` and `window` are optional, and the chat hides what a host leaves out. Mount in the browser
48
+ only, never during SSR.
49
+
50
+ `loadNexusChat(source)` only loads the chat and resolves to its `mount`. It loads once per source,
51
+ retries once, and gives up after `timeoutMs` (default 20 s); a failed load is forgotten so the next
52
+ call tries again. Call it when the page is idle to make the first open instant.
53
+
54
+ ## Where the chat comes from
55
+
56
+ | Mode | Options | UI version | App setup |
57
+ | ------------------------- | ----------------------------------------------------------------------------- | ----------------------------- | ----------------------------------------------- |
58
+ | Always updated (default) | `connection.copilotUrl` only | what that Copilot runs | none: Copilot serves `<copilotUrl>/nexus-chat/` |
59
+ | Pinned, served by the app | `assetsUrl: '/nexus-chat/'` | the installed package version | serve this package's `remote/` at that path |
60
+ | Pinned, served from a CDN | `assetsUrl: 'https://cdn.jsdelivr.net/npm/@incorta/nexus-chat@1.4.2/remote/'` | the version in the URL | none; the page's CSP must allow that origin |
61
+
62
+ An app with a server serves `remote/` straight from the installed package:
63
+
64
+ ```ts
65
+ import { createRequire } from 'node:module';
66
+ import path from 'node:path';
67
+
68
+ const remoteDir = path.join(
69
+ path.dirname(createRequire(import.meta.url).resolve('@incorta/nexus-chat/package.json')),
70
+ 'remote',
71
+ );
72
+ // serve remoteDir at /nexus-chat/: remoteEntry.js revalidates (no-cache), assets/ is immutable
73
+ ```
74
+
75
+ A static app copies it instead:
76
+ `"postinstall": "cp -R node_modules/@incorta/nexus-chat/remote public/nexus-chat"`.
77
+
78
+ A pinned UI keeps talking to a Copilot that keeps moving; the default mode keeps the two in step.
79
+
80
+ ## Everything in the package
81
+
82
+ React and other apps need only the first two imports; the rest serve hosts that federate the chat
83
+ themselves (web-react, Hikari) and apps that serve a pinned copy.
84
+
85
+ | Import | What |
86
+ | ------------------------------------------ | ----------------------------------------------------------- |
87
+ | `@incorta/nexus-chat` | the connector (`mountNexusChat`, `loadNexusChat`) and types |
88
+ | `@incorta/nexus-chat/react` | `<NexusChat />` for React 18 and 19 apps |
89
+ | `@incorta/nexus-chat/contract` | the host contract's values (`NEXUS_CHAT_CONTRACT_VERSION`…) |
90
+ | `@incorta/nexus-chat/conformance` | checks a host bridge against the contract |
91
+ | `@incorta/nexus-chat/host` | host helpers (`createElementRootAdapter`…) |
92
+ | `node_modules/@incorta/nexus-chat/remote/` | the prebuilt chat, for apps that serve a pinned copy |
93
+
94
+ ## Versions
95
+
96
+ Every NexusChat release publishes this package (the repository README → Releasing). `@beta`
97
+ installs the newest prerelease. To try a build published under another scope without changing
98
+ imports, alias it:
99
+
100
+ ```json
101
+ "@incorta/nexus-chat": "npm:@someone/nexus-chat@beta"
102
+ ```
@@ -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,304 @@
1
+ import { 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 "file" or a source with a loader', () => {
259
+ const kinds = (chat?.contexts ?? []).map((spec) => {
260
+ if (spec === 'file')
261
+ return spec;
262
+ ensure(CONTEXT_KINDS.includes(spec.kind), `unknown context kind ${JSON.stringify(spec.kind)}.`);
263
+ ensure(typeof spec.load === 'function', `the ${spec.kind} source has no load().`);
264
+ return spec.kind;
265
+ });
266
+ ensure(new Set(kinds).size === kinds.length, 'a context kind is listed twice.');
267
+ });
268
+ await checks.run('chat.triggers', 'each trigger is @ or # with a search', () => {
269
+ const chars = (chat?.triggers ?? []).map((trigger) => {
270
+ ensure(TRIGGER_CHARS.includes(trigger.char), `unknown trigger ${JSON.stringify(trigger.char)}.`);
271
+ ensure(typeof trigger.search === 'function', `the ${trigger.char} trigger has no search().`);
272
+ return trigger.char;
273
+ });
274
+ ensure(new Set(chars).size === chars.length, 'a trigger is listed twice.');
275
+ });
276
+ await checks.run('actions', 'each action has can() and run()', () => {
277
+ Object.entries(actions ?? {}).forEach(([name, action]) => {
278
+ if (action === undefined)
279
+ return;
280
+ ensure(typeof action.can === 'function' && typeof action.run === 'function', `actions.${name} needs both can() and run(). Omit it rather than supplying a placeholder.`);
281
+ });
282
+ });
283
+ const renderer = chat?.renderers;
284
+ if (!renderer) {
285
+ checks.skip('renderers', 'provides a host-native renderer', 'No renderer declared.');
286
+ }
287
+ else if (!checkOptions.renderer) {
288
+ checks.skip('renderers', 'provides a host-native renderer', 'No renderable artifacts were supplied, so the renderer lifecycle was not exercised.');
289
+ }
290
+ else {
291
+ const report = await checkArtifactRendererContract(renderer, checkOptions.renderer);
292
+ checks.results.push(...report.results);
293
+ }
294
+ return toReport(checks.results);
295
+ }
296
+ /** Human-readable summary for a consumer's test failure output. */
297
+ export function formatHostContractReport(report) {
298
+ return report.results
299
+ .map((result) => {
300
+ const mark = result.status === 'passed' ? '✓' : result.status === 'failed' ? '✗' : '–';
301
+ return `${mark} ${result.id} — ${result.title}${result.detail ? `\n ${result.detail}` : ''}`;
302
+ })
303
+ .join('\n');
304
+ }
@@ -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;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The renderer lifecycle every host used to hand-copy, in one place:
3
+ *
4
+ * - **one root per element, generation-counted** — the remote re-mounts the
5
+ * same container on an artifact swap and React runs effects twice in
6
+ * StrictMode, so `mount` arrives again on an element that already has a
7
+ * root; creating a second one orphans the first, so the newer mount reuses
8
+ * it and wins;
9
+ * - **deferred, guarded teardown** — the remote can dispose a renderer from
10
+ * inside its own commit; unmounting is queued as a microtask and skipped
11
+ * when a newer generation has claimed the element in the meantime;
12
+ * - **a swallowed teardown throw** — a failing unmount is reported, never
13
+ * rethrown into the remote's React tree.
14
+ *
15
+ * Hosts implement only the four framework-specific calls above.
16
+ */
17
+ export function createElementRootAdapter(adapter) {
18
+ const roots = new WeakMap();
19
+ const reportUnmountError = adapter.onUnmountError ??
20
+ ((error) => {
21
+ console.error('The host renderer root could not be unmounted.', error);
22
+ });
23
+ return {
24
+ canRender(request) {
25
+ return adapter.canRender(request);
26
+ },
27
+ mount(element, request) {
28
+ let record = roots.get(element);
29
+ if (!record) {
30
+ record = { root: adapter.createRoot(element), generation: 0 };
31
+ roots.set(element, record);
32
+ }
33
+ const current = record;
34
+ const generation = ++current.generation;
35
+ let disposed = false;
36
+ adapter.render(current.root, request, element);
37
+ return {
38
+ update(next) {
39
+ if (disposed || current.generation !== generation)
40
+ return;
41
+ adapter.render(current.root, next, element);
42
+ },
43
+ unmount() {
44
+ if (disposed)
45
+ return;
46
+ disposed = true;
47
+ queueMicrotask(() => {
48
+ if (current.generation !== generation)
49
+ return;
50
+ try {
51
+ adapter.unmount(current.root, element);
52
+ }
53
+ catch (error) {
54
+ reportUnmountError(error, element);
55
+ }
56
+ finally {
57
+ if (roots.get(element) === current)
58
+ roots.delete(element);
59
+ }
60
+ });
61
+ },
62
+ };
63
+ },
64
+ };
65
+ }
66
+ /**
67
+ * Splits a `<schema>.<view>` id (Incorta's asset-path form) at the FIRST dot:
68
+ * a view name may contain a dot, a schema name may not. Null for anything that
69
+ * is not two non-empty halves.
70
+ */
71
+ export function splitBusinessViewId(id) {
72
+ if (!id)
73
+ return null;
74
+ const separator = id.indexOf('.');
75
+ if (separator <= 0 || separator === id.length - 1)
76
+ return null;
77
+ return { schema: id.slice(0, separator), view: id.slice(separator + 1) };
78
+ }