@haikit/react 0.8.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hai contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # @haikit/react
2
+
3
+ Write haikit surface components in React.
4
+
5
+ ```bash
6
+ npm install @haikit/react react react-dom
7
+ ```
8
+
9
+ ```tsx
10
+ import { useState } from "react";
11
+ import { reactSurface, type SurfaceProps } from "@haikit/react";
12
+ import type { greetingPicker } from "../shared/surfaces.ts";
13
+
14
+ function GreetingPicker({ props, send, mode, state, selection }: SurfaceProps<typeof greetingPicker>) {
15
+ const [script, setScript] = useState<string | null>(null); // local state: never reaches the server
16
+ const live = state === "live" && mode === "elicit";
17
+ return (
18
+ <div className="rows">
19
+ {props.greetings
20
+ .filter((g) => !script || g.script === script)
21
+ .map((g) =>
22
+ live ? (
23
+ <button key={g.code} type="button" onClick={() => send("choose", g.code)}>
24
+ {g.text}
25
+ </button>
26
+ ) : (
27
+ <div key={g.code} className={g.code === selection ? "picked" : undefined}>
28
+ {g.text}
29
+ </div>
30
+ ),
31
+ )}
32
+ </div>
33
+ );
34
+ }
35
+
36
+ export const registry = { greeting_picker: reactSurface(GreetingPicker) };
37
+ ```
38
+
39
+ Pass `registry` to `mountChat` or `createChat` as usual. The client's contract
40
+ doesn't change: a registry entry is anything with `mount(element, props, ctx)`,
41
+ and `reactSurface` builds one around a React component.
42
+
43
+ ## What the component gets
44
+
45
+ `SurfaceProps<typeof yourSurface>` is typed from the same `defineSurface`
46
+ contract the server implements:
47
+
48
+ | prop | |
49
+ |---|---|
50
+ | `props` | the surface's props, as its `props` schema produced them |
51
+ | `send(action, value)` | the only channel to the server: an action the contract declares, with a value of its input type. Anything else is a compile error |
52
+ | `mode` | how the surface was opened: `elicit` as a question the turn waits on, `display` as something shown. It stays `elicit` once answered |
53
+ | `state` | `live`, or `frozen` once answered: a resolved question can't be answered again |
54
+ | `selection` | what the user picked, once frozen |
55
+ | `expired` | the conversation is out of date; nothing here can reach the server again |
56
+
57
+ Import the contract with `import type`. Only the types are needed, so the
58
+ schemas and their library stay out of the browser bundle.
59
+
60
+ ## Lifecycle
61
+
62
+ The default transcript mounts a surface **once**, so `useState` survives the
63
+ conversation streaming on around it. The root re-renders when the surface
64
+ freezes or the conversation goes out of date. It is unmounted when the surface
65
+ goes away, on `chat.reset()` or `chat.close()`, so effects clean up after
66
+ themselves. The first render is synchronous, so the surface already has its
67
+ height when the transcript scrolls to it.
68
+
69
+ React escapes text by default. Keep it that way: tool payloads are untrusted
70
+ input, so never pass them to `dangerouslySetInnerHTML`.
71
+
72
+ ## Building
73
+
74
+ React needs a bundler. `examples/hello-react` in the haikit repo runs Vite
75
+ inside its own node server while developing, so the app and `/hai` share one
76
+ port with no proxy, and serves `vite build`'s output in production.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @haikit/react — write surface components in React.
3
+ *
4
+ * function GreetingPicker({ props, send, state }: SurfaceProps<typeof greetingPicker>) { … }
5
+ * export const registry = { greeting_picker: reactSurface(GreetingPicker) };
6
+ *
7
+ * The client's contract is unchanged: a registry entry is something with a
8
+ * `mount(element, props, ctx)`. This builds one around a React component, and
9
+ * types it from the same `defineSurface` contract the server implements, so a
10
+ * prop or an action that changes there stops this side compiling.
11
+ */
12
+ import { type ComponentType } from "react";
13
+ import type { ComponentDef, SurfaceMode, SurfaceState } from "@haikit/client";
14
+ import type { ActionMap, Infer, QueryMap, Surface } from "@haikit/core";
15
+ type AnySurface = Surface<any, ActionMap, QueryMap>;
16
+ type PropsOf<S> = S extends Surface<infer P, any, any> ? P : never;
17
+ type ActionsOf<S> = S extends Surface<any, infer A, any> ? A : never;
18
+ /** What a React surface component receives, typed from its surface contract. */
19
+ export interface SurfaceProps<S extends AnySurface> {
20
+ /** The surface's props, as its `props` schema produced them. */
21
+ props: PropsOf<S>;
22
+ /**
23
+ * How the surface was opened: `elicit` as a question the turn waits on,
24
+ * `display` as something shown. It doesn't change once the question is
25
+ * answered; `state` does.
26
+ */
27
+ mode: SurfaceMode;
28
+ /** `frozen` once answered: a resolved question cannot be answered again. */
29
+ state: SurfaceState;
30
+ /** What the user picked, once the surface is frozen. */
31
+ selection?: unknown;
32
+ /** The conversation is out of date: nothing here can reach the server again. */
33
+ expired: boolean;
34
+ /**
35
+ * The only channel to the server: an action the contract declares, with a
36
+ * value of its input type. Never a tool, never a handler.
37
+ */
38
+ send<K extends keyof ActionsOf<S> & string>(action: K, value: Infer<ActionsOf<S>[K]["input"]>): Promise<void>;
39
+ }
40
+ /**
41
+ * A registry entry that renders `Component` into the surface's element.
42
+ *
43
+ * The first render is synchronous, so the surface has its height when the
44
+ * transcript scrolls to it; later ones are React's. It re-renders when the
45
+ * surface freezes or the conversation goes out of date, and its root is
46
+ * unmounted when the surface goes away, so effects clean up after themselves.
47
+ */
48
+ export declare function reactSurface<S extends AnySurface>(Component: ComponentType<SurfaceProps<S>>): ComponentDef<PropsOf<S>>;
49
+ export {};
50
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAiB,KAAK,aAAa,EAAE,MAAM,OAAO,CAAC;AAG1D,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9E,OAAO,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAExE,KAAK,UAAU,GAAG,OAAO,CAAC,GAAG,EAAE,SAAS,EAAE,QAAQ,CAAC,CAAC;AACpD,KAAK,OAAO,CAAC,CAAC,IAAI,CAAC,SAAS,OAAO,CAAC,MAAM,CAAC,EAAE,GAAG,EAAE,GAAG,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;AACnE,KAAK,SAAS,CAAC,CAAC,IAAI,CAAC,SAAS,OAAO,CAAC,GAAG,EAAE,MAAM,CAAC,EAAE,GAAG,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;AAErE,gFAAgF;AAChF,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS,UAAU;IAChD,gEAAgE;IAChE,KAAK,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IAClB;;;;OAIG;IACH,IAAI,EAAE,WAAW,CAAC;IAClB,4EAA4E;IAC5E,KAAK,EAAE,YAAY,CAAC;IACpB,wDAAwD;IACxD,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,gFAAgF;IAChF,OAAO,EAAE,OAAO,CAAC;IACjB;;;OAGG;IACH,IAAI,CAAC,CAAC,SAAS,MAAM,SAAS,CAAC,CAAC,CAAC,GAAG,MAAM,EAAE,MAAM,EAAE,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/G;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,CAAC,SAAS,UAAU,EAAE,SAAS,EAAE,aAAa,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,GAAG,YAAY,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CA4CtH"}
package/dist/index.js ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @haikit/react — write surface components in React.
3
+ *
4
+ * function GreetingPicker({ props, send, state }: SurfaceProps<typeof greetingPicker>) { … }
5
+ * export const registry = { greeting_picker: reactSurface(GreetingPicker) };
6
+ *
7
+ * The client's contract is unchanged: a registry entry is something with a
8
+ * `mount(element, props, ctx)`. This builds one around a React component, and
9
+ * types it from the same `defineSurface` contract the server implements, so a
10
+ * prop or an action that changes there stops this side compiling.
11
+ */
12
+ import { createElement } from "react";
13
+ import { createRoot } from "react-dom/client";
14
+ import { flushSync } from "react-dom";
15
+ /**
16
+ * A registry entry that renders `Component` into the surface's element.
17
+ *
18
+ * The first render is synchronous, so the surface has its height when the
19
+ * transcript scrolls to it; later ones are React's. It re-renders when the
20
+ * surface freezes or the conversation goes out of date, and its root is
21
+ * unmounted when the surface goes away, so effects clean up after themselves.
22
+ */
23
+ export function reactSurface(Component) {
24
+ return {
25
+ mount(element, props, ctx) {
26
+ const root = createRoot(element);
27
+ let state = ctx.state;
28
+ // set when it is mounted already frozen
29
+ let selection = ctx.selection;
30
+ let expired = false;
31
+ const draw = () => root.render(createElement(Component, {
32
+ props,
33
+ mode: ctx.mode,
34
+ state,
35
+ selection,
36
+ expired,
37
+ send: ctx.send,
38
+ }));
39
+ // A component that throws on its first render leaves no instance behind
40
+ // to unmount later, so its root is let go of here, before the error goes
41
+ // on; otherwise a remount would stack a second root on this element.
42
+ try {
43
+ flushSync(draw);
44
+ }
45
+ catch (err) {
46
+ root.unmount();
47
+ throw err;
48
+ }
49
+ return {
50
+ freeze(picked) {
51
+ state = "frozen";
52
+ selection = picked;
53
+ draw();
54
+ },
55
+ expire() {
56
+ expired = true;
57
+ draw();
58
+ },
59
+ unmount() {
60
+ root.unmount();
61
+ },
62
+ };
63
+ },
64
+ };
65
+ }
66
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,aAAa,EAAsB,MAAM,OAAO,CAAC;AAC1D,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AA+BtC;;;;;;;GAOG;AACH,MAAM,UAAU,YAAY,CAAuB,SAAyC;IAC1F,OAAO;QACL,KAAK,CAAC,OAAO,EAAE,KAAK,EAAE,GAAG;YACvB,MAAM,IAAI,GAAG,UAAU,CAAC,OAAO,CAAC,CAAC;YACjC,IAAI,KAAK,GAAG,GAAG,CAAC,KAAK,CAAC;YACtB,wCAAwC;YACxC,IAAI,SAAS,GAAG,GAAG,CAAC,SAAS,CAAC;YAC9B,IAAI,OAAO,GAAG,KAAK,CAAC;YACpB,MAAM,IAAI,GAAG,GAAG,EAAE,CAChB,IAAI,CAAC,MAAM,CACT,aAAa,CAAC,SAAS,EAAE;gBACvB,KAAK;gBACL,IAAI,EAAE,GAAG,CAAC,IAAI;gBACd,KAAK;gBACL,SAAS;gBACT,OAAO;gBACP,IAAI,EAAE,GAAG,CAAC,IAA+B;aAC1C,CAAC,CACH,CAAC;YACJ,wEAAwE;YACxE,yEAAyE;YACzE,qEAAqE;YACrE,IAAI,CAAC;gBACH,SAAS,CAAC,IAAI,CAAC,CAAC;YAClB,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,IAAI,CAAC,OAAO,EAAE,CAAC;gBACf,MAAM,GAAG,CAAC;YACZ,CAAC;YACD,OAAO;gBACL,MAAM,CAAC,MAAM;oBACX,KAAK,GAAG,QAAQ,CAAC;oBACjB,SAAS,GAAG,MAAM,CAAC;oBACnB,IAAI,EAAE,CAAC;gBACT,CAAC;gBACD,MAAM;oBACJ,OAAO,GAAG,IAAI,CAAC;oBACf,IAAI,EAAE,CAAC;gBACT,CAAC;gBACD,OAAO;oBACL,IAAI,CAAC,OAAO,EAAE,CAAC;gBACjB,CAAC;aACF,CAAC;QACJ,CAAC;KACF,CAAC;AACJ,CAAC"}
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@haikit/react",
3
+ "version": "0.8.0",
4
+ "description": "Write haikit surface components in React: typed props and actions from the shared surface contract.",
5
+ "keywords": [
6
+ "haikit",
7
+ "react",
8
+ "llm",
9
+ "agent",
10
+ "agentic-ui",
11
+ "generative-ui",
12
+ "tool-use",
13
+ "ai"
14
+ ],
15
+ "type": "module",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "src"
26
+ ],
27
+ "scripts": {
28
+ "prepack": "npm --prefix ../.. run build"
29
+ },
30
+ "dependencies": {
31
+ "@haikit/client": "0.8.0",
32
+ "@haikit/core": "0.8.0"
33
+ },
34
+ "peerDependencies": {
35
+ "react": "^18.0.0 || ^19.0.0",
36
+ "react-dom": "^18.0.0 || ^19.0.0"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "license": "MIT",
42
+ "homepage": "https://github.com/wfoxd/haikit/tree/main/packages/react#readme",
43
+ "bugs": {
44
+ "url": "https://github.com/wfoxd/haikit/issues"
45
+ },
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/wfoxd/haikit.git",
49
+ "directory": "packages/react"
50
+ }
51
+ }
package/src/index.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @haikit/react — write surface components in React.
3
+ *
4
+ * function GreetingPicker({ props, send, state }: SurfaceProps<typeof greetingPicker>) { … }
5
+ * export const registry = { greeting_picker: reactSurface(GreetingPicker) };
6
+ *
7
+ * The client's contract is unchanged: a registry entry is something with a
8
+ * `mount(element, props, ctx)`. This builds one around a React component, and
9
+ * types it from the same `defineSurface` contract the server implements, so a
10
+ * prop or an action that changes there stops this side compiling.
11
+ */
12
+
13
+ import { createElement, type ComponentType } from "react";
14
+ import { createRoot } from "react-dom/client";
15
+ import { flushSync } from "react-dom";
16
+ import type { ComponentDef, SurfaceMode, SurfaceState } from "@haikit/client";
17
+ import type { ActionMap, Infer, QueryMap, Surface } from "@haikit/core";
18
+
19
+ type AnySurface = Surface<any, ActionMap, QueryMap>;
20
+ type PropsOf<S> = S extends Surface<infer P, any, any> ? P : never;
21
+ type ActionsOf<S> = S extends Surface<any, infer A, any> ? A : never;
22
+
23
+ /** What a React surface component receives, typed from its surface contract. */
24
+ export interface SurfaceProps<S extends AnySurface> {
25
+ /** The surface's props, as its `props` schema produced them. */
26
+ props: PropsOf<S>;
27
+ /**
28
+ * How the surface was opened: `elicit` as a question the turn waits on,
29
+ * `display` as something shown. It doesn't change once the question is
30
+ * answered; `state` does.
31
+ */
32
+ mode: SurfaceMode;
33
+ /** `frozen` once answered: a resolved question cannot be answered again. */
34
+ state: SurfaceState;
35
+ /** What the user picked, once the surface is frozen. */
36
+ selection?: unknown;
37
+ /** The conversation is out of date: nothing here can reach the server again. */
38
+ expired: boolean;
39
+ /**
40
+ * The only channel to the server: an action the contract declares, with a
41
+ * value of its input type. Never a tool, never a handler.
42
+ */
43
+ send<K extends keyof ActionsOf<S> & string>(action: K, value: Infer<ActionsOf<S>[K]["input"]>): Promise<void>;
44
+ }
45
+
46
+ /**
47
+ * A registry entry that renders `Component` into the surface's element.
48
+ *
49
+ * The first render is synchronous, so the surface has its height when the
50
+ * transcript scrolls to it; later ones are React's. It re-renders when the
51
+ * surface freezes or the conversation goes out of date, and its root is
52
+ * unmounted when the surface goes away, so effects clean up after themselves.
53
+ */
54
+ export function reactSurface<S extends AnySurface>(Component: ComponentType<SurfaceProps<S>>): ComponentDef<PropsOf<S>> {
55
+ return {
56
+ mount(element, props, ctx) {
57
+ const root = createRoot(element);
58
+ let state = ctx.state;
59
+ // set when it is mounted already frozen
60
+ let selection = ctx.selection;
61
+ let expired = false;
62
+ const draw = () =>
63
+ root.render(
64
+ createElement(Component, {
65
+ props,
66
+ mode: ctx.mode,
67
+ state,
68
+ selection,
69
+ expired,
70
+ send: ctx.send as SurfaceProps<S>["send"],
71
+ }),
72
+ );
73
+ // A component that throws on its first render leaves no instance behind
74
+ // to unmount later, so its root is let go of here, before the error goes
75
+ // on; otherwise a remount would stack a second root on this element.
76
+ try {
77
+ flushSync(draw);
78
+ } catch (err) {
79
+ root.unmount();
80
+ throw err;
81
+ }
82
+ return {
83
+ freeze(picked) {
84
+ state = "frozen";
85
+ selection = picked;
86
+ draw();
87
+ },
88
+ expire() {
89
+ expired = true;
90
+ draw();
91
+ },
92
+ unmount() {
93
+ root.unmount();
94
+ },
95
+ };
96
+ },
97
+ };
98
+ }