@jigs.design/react 0.0.0-stage → 0.1.1
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 +21 -0
- package/README.md +52 -2
- package/dist/index.d.ts +9 -0
- package/dist/index.js +5 -0
- package/dist/view/PickleView.d.ts +63 -0
- package/dist/view/PickleView.js +106 -0
- package/dist/view/local-value.d.ts +19 -0
- package/dist/view/local-value.js +47 -0
- package/dist/view/registry.d.ts +21 -0
- package/dist/view/session.d.ts +21 -0
- package/dist/view/session.js +141 -0
- package/dist/view/settled.d.ts +11 -0
- package/dist/view/settled.js +17 -0
- package/dist/view/usePickleSession.d.ts +31 -0
- package/dist/view/usePickleSession.js +29 -0
- package/package.json +31 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tom Bates
|
|
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
CHANGED
|
@@ -1,3 +1,53 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @jigs.design/react
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The web renderer. `PickleView` runs a compiled program in a sandboxed frame and draws it with your registry. `usePickleSession` holds a conversation with the server and streams its latest program.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
import { components } from "@jigs.design/catalog/react";
|
|
7
|
+
import { PickleView, usePickleSession } from "@jigs.design/react";
|
|
8
|
+
import { client } from "./pickle-client";
|
|
9
|
+
|
|
10
|
+
export function App() {
|
|
11
|
+
const session = usePickleSession(client);
|
|
12
|
+
return (
|
|
13
|
+
<>
|
|
14
|
+
<button onClick={() => session.send("My open orders")}>Ask</button>
|
|
15
|
+
{session.program && (
|
|
16
|
+
<PickleView
|
|
17
|
+
client={client}
|
|
18
|
+
components={components}
|
|
19
|
+
documentId={session.documentId}
|
|
20
|
+
program={session.program}
|
|
21
|
+
/>
|
|
22
|
+
)}
|
|
23
|
+
</>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The program runs in a worker inside a frame served by your Pickle route. The frame has no network: queries and mutations go through the client, and every prop is checked against the program's schema before it reaches your component. A new `program` replaces the running one and keeps the state it still declares. `onAction` handles the program's actions after the server checked their input. The view drops an `autoFocus` prop unless you pass `allowAutoFocus`, so a pickle streaming into a chat cannot take the focus. It checks what the worker asks for against the program, since the worker is not trusted.
|
|
29
|
+
|
|
30
|
+
## Exports
|
|
31
|
+
|
|
32
|
+
| Export | What it is |
|
|
33
|
+
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
34
|
+
| `PickleView` | Runs one program and draws it. Takes `client`, `components`, `program`, `onAction`, `showIssues`, `allowAutoFocus` |
|
|
35
|
+
| `usePickleSession` | The conversation and its latest program. `initialMessages` resumes one |
|
|
36
|
+
| `useHandlerSettled` | For a registry control: changes once the program has answered its events, so it can drop an edit the program refused |
|
|
37
|
+
| `useLocalValue`, `controlled` | For a controlled control: a local copy of the program's value that runs ahead of the round trip and drops a refused edit |
|
|
38
|
+
| `Registry`, `RegistryFor` | The type of a map from catalog names to React components |
|
|
39
|
+
| `SessionState`, `PickleSession` | The session's state and the hook's return value |
|
|
40
|
+
| `ViewIssue` | What `onIssue` receives |
|
|
41
|
+
|
|
42
|
+
It re-exports the shared types from `@jigs.design/runtime`. The frame, its messages and the calls through the client are `createViewController` in `@jigs.design/client`, which `PickleView` is built on; a renderer for another framework starts there.
|
|
43
|
+
|
|
44
|
+
## Source
|
|
45
|
+
|
|
46
|
+
- `src/view/PickleView.tsx`: the view component, on the client's view controller.
|
|
47
|
+
- `src/view/registry.ts`, `src/view/settled.ts`: the registry types and `useHandlerSettled`.
|
|
48
|
+
- `src/view/local-value.ts`: `useLocalValue` and `controlled`, for controls.
|
|
49
|
+
- `src/view/session.ts`, `src/view/usePickleSession.ts`: the session store and its hook.
|
|
50
|
+
|
|
51
|
+
## Docs
|
|
52
|
+
|
|
53
|
+
[React](../../apps/docs/docs/react/index.mdx), [Registry](../../apps/docs/docs/react/registry.mdx), [Sandbox](../../apps/docs/docs/react/sandbox.mdx), [Session](../../apps/docs/docs/running/session.mdx), [Renderers](../../apps/docs/docs/renderers/index.mdx).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { controlled, useLocalValue } from "./view/local-value.js";
|
|
2
|
+
import { Registry, RegistryFor } from "./view/registry.js";
|
|
3
|
+
import { PickleView } from "./view/PickleView.js";
|
|
4
|
+
import { useHandlerSettled } from "./view/settled.js";
|
|
5
|
+
import { SessionState } from "./view/session.js";
|
|
6
|
+
import { PickleSession, usePickleSession } from "./view/usePickleSession.js";
|
|
7
|
+
import { ConfirmFunction, ViewIssue } from "@jigs.design/client";
|
|
8
|
+
import { Diagnostic, Message, PickleErrorCode, PickleErrorData, Program, SessionEvent, SessionOptions, Span } from "@jigs.design/runtime";
|
|
9
|
+
export { type ConfirmFunction, type Diagnostic, type Message, type PickleErrorCode, type PickleErrorData, type PickleSession, PickleView, type Program, type Registry, type RegistryFor, type SessionEvent, type SessionOptions, type SessionState, type Span, type ViewIssue, controlled, useHandlerSettled, useLocalValue, usePickleSession };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { useHandlerSettled } from "./view/settled.js";
|
|
2
|
+
import { controlled, useLocalValue } from "./view/local-value.js";
|
|
3
|
+
import { PickleView } from "./view/PickleView.js";
|
|
4
|
+
import { usePickleSession } from "./view/usePickleSession.js";
|
|
5
|
+
export { PickleView, controlled, useHandlerSettled, useLocalValue, usePickleSession };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Registry } from "./registry.js";
|
|
2
|
+
import { ConfirmFunction, PickleClient, ViewIssue } from "@jigs.design/client";
|
|
3
|
+
import { Program } from "@jigs.design/runtime";
|
|
4
|
+
//#region src/view/PickleView.d.ts
|
|
5
|
+
/** An action as `onAction` receives it; type it with `ActionEvent<typeof host>`. */
|
|
6
|
+
interface ActionLike {
|
|
7
|
+
input: unknown;
|
|
8
|
+
name: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Runs one program in the sandbox and draws it with `components`. A new
|
|
12
|
+
* `program` replaces the running one; state the new program still declares
|
|
13
|
+
* keeps its value, unless `documentId` changed with it. A new `client`
|
|
14
|
+
* starts the program over on that client.
|
|
15
|
+
*/
|
|
16
|
+
export declare function PickleView<TAction extends ActionLike = ActionLike>(props: {
|
|
17
|
+
/**
|
|
18
|
+
* Let the program focus a control when it appears, through the
|
|
19
|
+
* `autoFocus` prop. Default false: in a chat, a UI that streams in would
|
|
20
|
+
* otherwise take the focus from the composer while the user types.
|
|
21
|
+
*/
|
|
22
|
+
allowAutoFocus?: boolean;
|
|
23
|
+
client: PickleClient;
|
|
24
|
+
components: Registry;
|
|
25
|
+
/**
|
|
26
|
+
* Which UI `program` is a version of. When it changes, the program starts
|
|
27
|
+
* from fresh state instead of keeping the state of the one before. Pass
|
|
28
|
+
* `session.documentId`, which changes when the model writes a new UI
|
|
29
|
+
* rather than editing the last one. Leave it out to keep state across
|
|
30
|
+
* every program, as for edits.
|
|
31
|
+
*/
|
|
32
|
+
documentId?: number | string;
|
|
33
|
+
/**
|
|
34
|
+
* Handles the program's actions, after the server checked their input.
|
|
35
|
+
* What it returns, awaited and copied with `structuredClone`, is the
|
|
36
|
+
* handle's `data`; what it throws is its `error`. Without it, every
|
|
37
|
+
* action fails. It is not called once the view has unmounted.
|
|
38
|
+
*/
|
|
39
|
+
onAction?: (action: TAction) => unknown;
|
|
40
|
+
/**
|
|
41
|
+
* Asks the user to approve a mutation whose policy is "confirm", with the
|
|
42
|
+
* host's `message`, in the page's own UI: the sandboxed program never
|
|
43
|
+
* draws or answers it. Resolve true to run the mutation; false, or a
|
|
44
|
+
* throw, fails it with `DECLINED`. Without it, the client's `confirm` asks.
|
|
45
|
+
*/
|
|
46
|
+
onConfirm?: ConfirmFunction;
|
|
47
|
+
/**
|
|
48
|
+
* Called once for each new issue: a runtime error, something the view
|
|
49
|
+
* dropped, or a sandbox notice. An issue that persists is reported once;
|
|
50
|
+
* one that clears and comes back, again. It runs whether or not
|
|
51
|
+
* `showIssues` is on, so an app can count issues where users don't see
|
|
52
|
+
* them.
|
|
53
|
+
*/
|
|
54
|
+
onIssue?: (issue: ViewIssue) => void;
|
|
55
|
+
program: Program;
|
|
56
|
+
/**
|
|
57
|
+
* List runtime errors, dropped props and sandbox notices under the tree.
|
|
58
|
+
* Default true; turn it off where end users should not see them.
|
|
59
|
+
*/
|
|
60
|
+
showIssues?: boolean;
|
|
61
|
+
}): import("react").JSX.Element;
|
|
62
|
+
//#endregion
|
|
63
|
+
export {};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { SettledContext } from "./settled.js";
|
|
2
|
+
import { useEffect, useState, useSyncExternalStore } from "react";
|
|
3
|
+
import { createViewController, missingComponents } from "@jigs.design/client";
|
|
4
|
+
import { ROOT_ID } from "@jigs.design/runtime";
|
|
5
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
//#region src/view/PickleView.tsx
|
|
7
|
+
function TreeNodeView(props) {
|
|
8
|
+
const { components, controller } = props;
|
|
9
|
+
const getNode = () => controller.store.get(props.id);
|
|
10
|
+
const node = useSyncExternalStore(controller.subscribe, getNode, getNode);
|
|
11
|
+
const getSettled = () => controller.getSettled(props.id);
|
|
12
|
+
const settled = useSyncExternalStore(controller.subscribe, getSettled, getSettled);
|
|
13
|
+
if (node === void 0) return null;
|
|
14
|
+
if (node.type === "#text") return node.text;
|
|
15
|
+
const children = node.children.map((child) => /* @__PURE__ */ jsx(TreeNodeView, {
|
|
16
|
+
allowAutoFocus: props.allowAutoFocus,
|
|
17
|
+
components,
|
|
18
|
+
controller,
|
|
19
|
+
id: child
|
|
20
|
+
}, child));
|
|
21
|
+
if (node.type === "#root") return children;
|
|
22
|
+
const registered = Object.hasOwn(components, node.type) ? components[node.type] : void 0;
|
|
23
|
+
if (registered === void 0) return null;
|
|
24
|
+
const drawn = controller.drawnProps(node, { allowAutoFocus: props.allowAutoFocus });
|
|
25
|
+
return /* @__PURE__ */ jsx(SettledContext, {
|
|
26
|
+
value: settled,
|
|
27
|
+
children: /* @__PURE__ */ jsx(registered, {
|
|
28
|
+
...drawn,
|
|
29
|
+
children: controller.childrenAllowed(node.type) && children.length > 0 ? children : void 0
|
|
30
|
+
})
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const missingFromRegistry = (program, components) => missingComponents(program, Object.keys(components));
|
|
34
|
+
/**
|
|
35
|
+
* Runs one program in the sandbox and draws it with `components`. A new
|
|
36
|
+
* `program` replaces the running one; state the new program still declares
|
|
37
|
+
* keeps its value, unless `documentId` changed with it. A new `client`
|
|
38
|
+
* starts the program over on that client.
|
|
39
|
+
*/
|
|
40
|
+
function PickleView(props) {
|
|
41
|
+
const { program } = props;
|
|
42
|
+
const [view, setView] = useState(() => ({
|
|
43
|
+
client: props.client,
|
|
44
|
+
controller: createViewController(props.client)
|
|
45
|
+
}));
|
|
46
|
+
let { controller } = view;
|
|
47
|
+
if (view.client !== props.client) {
|
|
48
|
+
controller = createViewController(props.client);
|
|
49
|
+
setView({
|
|
50
|
+
client: props.client,
|
|
51
|
+
controller
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const status = useSyncExternalStore(controller.subscribe, controller.getStatus, controller.getStatus);
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
controller.setIssueHandler(props.onIssue);
|
|
57
|
+
}, [controller, props.onIssue]);
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
controller.start();
|
|
60
|
+
return () => controller.stop();
|
|
61
|
+
}, [controller]);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
controller.setUnregistered(missingFromRegistry(program, props.components));
|
|
64
|
+
}, [
|
|
65
|
+
controller,
|
|
66
|
+
program,
|
|
67
|
+
props.components
|
|
68
|
+
]);
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const handler = props.onAction;
|
|
71
|
+
controller.setActionHandler(handler);
|
|
72
|
+
}, [controller, props.onAction]);
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
controller.setConfirmHandler(props.onConfirm);
|
|
75
|
+
}, [controller, props.onConfirm]);
|
|
76
|
+
const documentId = props.documentId;
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
controller.load(program, documentId);
|
|
79
|
+
}, [
|
|
80
|
+
controller,
|
|
81
|
+
program,
|
|
82
|
+
documentId
|
|
83
|
+
]);
|
|
84
|
+
const showIssues = props.showIssues ?? true;
|
|
85
|
+
const issues = showIssues ? [...missingFromRegistry(program, props.components), ...status.issues] : [];
|
|
86
|
+
const errors = showIssues ? status.errors : [];
|
|
87
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
88
|
+
/* @__PURE__ */ jsx(TreeNodeView, {
|
|
89
|
+
allowAutoFocus: props.allowAutoFocus === true,
|
|
90
|
+
components: props.components,
|
|
91
|
+
controller,
|
|
92
|
+
id: ROOT_ID
|
|
93
|
+
}),
|
|
94
|
+
showIssues && status.notice !== null && /* @__PURE__ */ jsx("p", {
|
|
95
|
+
className: "pickle-view-notice",
|
|
96
|
+
role: "status",
|
|
97
|
+
children: status.notice
|
|
98
|
+
}),
|
|
99
|
+
errors.length + issues.length > 0 && /* @__PURE__ */ jsxs("ul", {
|
|
100
|
+
className: "pickle-view-errors",
|
|
101
|
+
children: [errors.map((error, index) => /* @__PURE__ */ jsxs("li", { children: [error.line === null ? "" : `Line ${error.line}: `, error.message] }, `error:${index}:${error.message}`)), issues.map((issue) => /* @__PURE__ */ jsx("li", { children: issue }, `issue:${issue}`))]
|
|
102
|
+
})
|
|
103
|
+
] });
|
|
104
|
+
}
|
|
105
|
+
//#endregion
|
|
106
|
+
export { PickleView };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
//#region src/view/local-value.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Whether the program controls a control's value: it sets the value prop
|
|
4
|
+
* (`value`, or `checked`) and handles `onChange`. A control with only
|
|
5
|
+
* `onChange` is uncontrolled, like a React input without `value`: the
|
|
6
|
+
* program hears each edit but the control keeps what the user did.
|
|
7
|
+
*/
|
|
8
|
+
export declare const controlled: <T>(value: T | undefined, onChange: ((next: T) => void) | undefined) => boolean;
|
|
9
|
+
/**
|
|
10
|
+
* A local copy of a value the program owns, so a control can run ahead of the
|
|
11
|
+
* round trip to the sandbox. Pass `echoes` when the program controls the
|
|
12
|
+
* value (`controlled`): echoes of the edits sent to it then don't reset the
|
|
13
|
+
* control, but any other new value does, and so does an edit the program did
|
|
14
|
+
* not take: once it has answered every call (`useHandlerSettled`) and `value`
|
|
15
|
+
* is still not the edit, the control goes back to `value`. Without `echoes`,
|
|
16
|
+
* the control keeps its own state until `value` changes.
|
|
17
|
+
*/
|
|
18
|
+
export declare function useLocalValue<T>(value: T, echoes: boolean): [T, (next: T) => void];
|
|
19
|
+
//#endregion
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useHandlerSettled } from "./settled.js";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
//#region src/view/local-value.ts
|
|
4
|
+
/**
|
|
5
|
+
* Whether the program controls a control's value: it sets the value prop
|
|
6
|
+
* (`value`, or `checked`) and handles `onChange`. A control with only
|
|
7
|
+
* `onChange` is uncontrolled, like a React input without `value`: the
|
|
8
|
+
* program hears each edit but the control keeps what the user did.
|
|
9
|
+
*/
|
|
10
|
+
const controlled = (value, onChange) => value !== void 0 && onChange !== void 0;
|
|
11
|
+
/**
|
|
12
|
+
* A local copy of a value the program owns, so a control can run ahead of the
|
|
13
|
+
* round trip to the sandbox. Pass `echoes` when the program controls the
|
|
14
|
+
* value (`controlled`): echoes of the edits sent to it then don't reset the
|
|
15
|
+
* control, but any other new value does, and so does an edit the program did
|
|
16
|
+
* not take: once it has answered every call (`useHandlerSettled`) and `value`
|
|
17
|
+
* is still not the edit, the control goes back to `value`. Without `echoes`,
|
|
18
|
+
* the control keeps its own state until `value` changes.
|
|
19
|
+
*/
|
|
20
|
+
function useLocalValue(value, echoes) {
|
|
21
|
+
const settled = useHandlerSettled();
|
|
22
|
+
const [local, setLocal] = useState(value);
|
|
23
|
+
const [seen, setSeen] = useState(value);
|
|
24
|
+
const [seenSettled, setSeenSettled] = useState(settled);
|
|
25
|
+
const [pending, setPending] = useState([]);
|
|
26
|
+
if (!Object.is(value, seen)) {
|
|
27
|
+
setSeen(value);
|
|
28
|
+
const echo = pending.findIndex((sent) => Object.is(sent, value));
|
|
29
|
+
if (echo === -1) {
|
|
30
|
+
setLocal(value);
|
|
31
|
+
setPending([]);
|
|
32
|
+
} else setPending(pending.slice(echo + 1));
|
|
33
|
+
} else if (settled !== seenSettled) {
|
|
34
|
+
setSeenSettled(settled);
|
|
35
|
+
if (pending.length > 0) {
|
|
36
|
+
setLocal(value);
|
|
37
|
+
setPending([]);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const edit = (next) => {
|
|
41
|
+
setLocal(next);
|
|
42
|
+
if (echoes) setPending([...pending.slice(-20), next]);
|
|
43
|
+
};
|
|
44
|
+
return [local, edit];
|
|
45
|
+
}
|
|
46
|
+
//#endregion
|
|
47
|
+
export { controlled, useLocalValue };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ComponentType, ReactNode } from "react";
|
|
2
|
+
//#region src/view/registry.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* One React component per catalog name. Props reach a component only after
|
|
5
|
+
* they passed the schema the program carries, so each component declares the
|
|
6
|
+
* props it reads; `never` lets any such component into the map.
|
|
7
|
+
*/
|
|
8
|
+
export type Registry = Readonly<Record<string, ComponentType<never>>>;
|
|
9
|
+
/**
|
|
10
|
+
* A registry checked against a catalog's props: every name present, each
|
|
11
|
+
* component accepting what the catalog may pass. Use it with
|
|
12
|
+
* `CatalogProps` from `@jigs.design/host`:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* export const components = { … } satisfies RegistryFor<CatalogProps<typeof catalog>>;
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export type RegistryFor<TProps extends Readonly<Record<string, object>>> = { readonly [K in keyof TProps]: (props: TProps[K] & {
|
|
19
|
+
children?: ReactNode;
|
|
20
|
+
}) => ReactNode; };
|
|
21
|
+
//#endregion
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { PickleClient } from "@jigs.design/client";
|
|
2
|
+
import { Diagnostic, Message, PickleErrorData, Program, SessionOptions } from "@jigs.design/runtime";
|
|
3
|
+
//#region src/view/session.d.ts
|
|
4
|
+
export interface SessionState {
|
|
5
|
+
diagnostics: ReadonlyArray<Diagnostic>;
|
|
6
|
+
/**
|
|
7
|
+
* Which UI `program` is a version of. It goes up when the model writes a
|
|
8
|
+
* new UI rather than editing the last one, or a stored one is restored,
|
|
9
|
+
* and goes back with the program when a turn is stopped. Pass it to
|
|
10
|
+
* `PickleView` as `documentId`, so a new UI starts from fresh state.
|
|
11
|
+
*/
|
|
12
|
+
documentId: number;
|
|
13
|
+
/** `{ code, message }` when `status` is "error". */
|
|
14
|
+
error: PickleErrorData | null;
|
|
15
|
+
/** The conversation, to store or render. Assistant entries hold checked source. */
|
|
16
|
+
messages: ReadonlyArray<Message>;
|
|
17
|
+
/** The latest program, or null before the first one arrives. */
|
|
18
|
+
program: Program | null;
|
|
19
|
+
status: "error" | "idle" | "streaming";
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
//#region src/view/session.ts
|
|
2
|
+
const endedEarly = {
|
|
3
|
+
code: "NETWORK",
|
|
4
|
+
message: "The turn ended before it finished."
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* The conversation and the latest program for one chat. A turn that ends
|
|
8
|
+
* without `done` leaves no trace: the messages and the program go back to
|
|
9
|
+
* where they were before it started.
|
|
10
|
+
*/
|
|
11
|
+
function createSessionStore(initialClient, initialMessages) {
|
|
12
|
+
let client = initialClient;
|
|
13
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
14
|
+
let committed = {
|
|
15
|
+
diagnostics: [],
|
|
16
|
+
documentId: 0,
|
|
17
|
+
messages: initialMessages,
|
|
18
|
+
program: null
|
|
19
|
+
};
|
|
20
|
+
let state = {
|
|
21
|
+
...committed,
|
|
22
|
+
error: null,
|
|
23
|
+
status: "idle"
|
|
24
|
+
};
|
|
25
|
+
let active = null;
|
|
26
|
+
const set = (next) => {
|
|
27
|
+
state = next;
|
|
28
|
+
for (const listener of listeners) listener();
|
|
29
|
+
};
|
|
30
|
+
const rollback = (error) => {
|
|
31
|
+
set({
|
|
32
|
+
...committed,
|
|
33
|
+
error,
|
|
34
|
+
status: error === null ? "idle" : "error"
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
const run = async (messages, options, turn) => {
|
|
38
|
+
const body = options === void 0 ? { messages } : {
|
|
39
|
+
messages,
|
|
40
|
+
options
|
|
41
|
+
};
|
|
42
|
+
try {
|
|
43
|
+
for await (const event of client.session(body, { signal: turn.signal })) {
|
|
44
|
+
if (turn !== active) return;
|
|
45
|
+
if (event.type === "program") {
|
|
46
|
+
set({
|
|
47
|
+
...state,
|
|
48
|
+
diagnostics: event.diagnostics,
|
|
49
|
+
documentId: event.fresh ? state.documentId + 1 : state.documentId,
|
|
50
|
+
program: event.program
|
|
51
|
+
});
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (event.type === "error") {
|
|
55
|
+
active = null;
|
|
56
|
+
rollback(event.error);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const last = messages.at(-1);
|
|
60
|
+
committed = {
|
|
61
|
+
diagnostics: event.diagnostics,
|
|
62
|
+
documentId: state.documentId,
|
|
63
|
+
messages: last?.role === "assistant" ? messages : [...messages, {
|
|
64
|
+
content: event.source,
|
|
65
|
+
role: "assistant"
|
|
66
|
+
}],
|
|
67
|
+
program: state.program
|
|
68
|
+
};
|
|
69
|
+
active = null;
|
|
70
|
+
set({
|
|
71
|
+
...committed,
|
|
72
|
+
error: null,
|
|
73
|
+
status: "idle"
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
} catch {}
|
|
78
|
+
if (turn === active) {
|
|
79
|
+
active = null;
|
|
80
|
+
rollback(endedEarly);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const begin = (messages, options) => {
|
|
84
|
+
const turn = new AbortController();
|
|
85
|
+
active = turn;
|
|
86
|
+
set({
|
|
87
|
+
...state,
|
|
88
|
+
error: null,
|
|
89
|
+
messages,
|
|
90
|
+
status: "streaming"
|
|
91
|
+
});
|
|
92
|
+
run(messages, options, turn);
|
|
93
|
+
};
|
|
94
|
+
const stop = () => {
|
|
95
|
+
if (active === null) return;
|
|
96
|
+
active.abort();
|
|
97
|
+
active = null;
|
|
98
|
+
rollback(null);
|
|
99
|
+
};
|
|
100
|
+
const resume = () => {
|
|
101
|
+
if (active === null && committed.messages.at(-1)?.role === "assistant") begin(committed.messages, void 0);
|
|
102
|
+
};
|
|
103
|
+
return {
|
|
104
|
+
getState: () => state,
|
|
105
|
+
reset: (messages = []) => {
|
|
106
|
+
stop();
|
|
107
|
+
committed = {
|
|
108
|
+
diagnostics: [],
|
|
109
|
+
documentId: committed.documentId,
|
|
110
|
+
messages,
|
|
111
|
+
program: null
|
|
112
|
+
};
|
|
113
|
+
set({
|
|
114
|
+
...committed,
|
|
115
|
+
error: null,
|
|
116
|
+
status: "idle"
|
|
117
|
+
});
|
|
118
|
+
resume();
|
|
119
|
+
},
|
|
120
|
+
resume,
|
|
121
|
+
send: (prompt, options) => {
|
|
122
|
+
stop();
|
|
123
|
+
begin([...committed.messages, {
|
|
124
|
+
content: prompt,
|
|
125
|
+
role: "user"
|
|
126
|
+
}], options);
|
|
127
|
+
},
|
|
128
|
+
setClient: (next) => {
|
|
129
|
+
client = next;
|
|
130
|
+
},
|
|
131
|
+
stop,
|
|
132
|
+
subscribe: (listener) => {
|
|
133
|
+
listeners.add(listener);
|
|
134
|
+
return () => {
|
|
135
|
+
listeners.delete(listener);
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
//#endregion
|
|
141
|
+
export { createSessionStore };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/view/settled.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Counts the times the program finished answering every handler call of the
|
|
4
|
+
* component being drawn: it changes once the program has seen the
|
|
5
|
+
* component's latest events and its answer is on screen. A control that
|
|
6
|
+
* runs ahead of the program, such as a text field showing what was typed,
|
|
7
|
+
* can then go back to its value prop if the program kept another value.
|
|
8
|
+
* Always 0 outside `PickleView`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function useHandlerSettled(): number;
|
|
11
|
+
//#endregion
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
//#region src/view/settled.ts
|
|
3
|
+
/** How many times the node being drawn had all its handler calls answered. */
|
|
4
|
+
const SettledContext = createContext(0);
|
|
5
|
+
/**
|
|
6
|
+
* Counts the times the program finished answering every handler call of the
|
|
7
|
+
* component being drawn: it changes once the program has seen the
|
|
8
|
+
* component's latest events and its answer is on screen. A control that
|
|
9
|
+
* runs ahead of the program, such as a text field showing what was typed,
|
|
10
|
+
* can then go back to its value prop if the program kept another value.
|
|
11
|
+
* Always 0 outside `PickleView`.
|
|
12
|
+
*/
|
|
13
|
+
function useHandlerSettled() {
|
|
14
|
+
return useContext(SettledContext);
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|
|
17
|
+
export { SettledContext, useHandlerSettled };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { SessionState } from "./session.js";
|
|
2
|
+
import { PickleClient } from "@jigs.design/client";
|
|
3
|
+
import { Message, SessionOptions } from "@jigs.design/runtime";
|
|
4
|
+
//#region src/view/usePickleSession.d.ts
|
|
5
|
+
export interface PickleSession<TOptions extends SessionOptions = SessionOptions> extends SessionState {
|
|
6
|
+
/**
|
|
7
|
+
* Stops any turn and starts over with `messages` (default none). Like
|
|
8
|
+
* `initialMessages`, messages that end with an assistant message are
|
|
9
|
+
* compiled again without calling the model.
|
|
10
|
+
*/
|
|
11
|
+
reset: (messages?: ReadonlyArray<Message>) => void;
|
|
12
|
+
/**
|
|
13
|
+
* Starts a turn. A turn already streaming stops first. `options` go to the
|
|
14
|
+
* server's `options` schema; type them with the schema's input type.
|
|
15
|
+
*/
|
|
16
|
+
send: (prompt: string, options?: TOptions) => void;
|
|
17
|
+
/** Aborts the turn in flight; the conversation goes back to before it. */
|
|
18
|
+
stop: () => void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Holds a conversation with the server and its latest program. Pass
|
|
22
|
+
* `initialMessages` to resume a stored conversation: when they end with an
|
|
23
|
+
* assistant message, the server compiles it again without calling the model.
|
|
24
|
+
* `initialMessages` is read once, when the hook mounts; to load another
|
|
25
|
+
* conversation later, call `reset(messages)`. A new `client` stops the turn
|
|
26
|
+
* in flight and compiles the conversation's last answer again through it.
|
|
27
|
+
*/
|
|
28
|
+
export declare function usePickleSession<TOptions extends SessionOptions = SessionOptions>(client: PickleClient, options?: {
|
|
29
|
+
initialMessages?: ReadonlyArray<Message>;
|
|
30
|
+
}): PickleSession<TOptions>;
|
|
31
|
+
//#endregion
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createSessionStore } from "./session.js";
|
|
2
|
+
import { useEffect, useState, useSyncExternalStore } from "react";
|
|
3
|
+
//#region src/view/usePickleSession.ts
|
|
4
|
+
const NO_MESSAGES = [];
|
|
5
|
+
/**
|
|
6
|
+
* Holds a conversation with the server and its latest program. Pass
|
|
7
|
+
* `initialMessages` to resume a stored conversation: when they end with an
|
|
8
|
+
* assistant message, the server compiles it again without calling the model.
|
|
9
|
+
* `initialMessages` is read once, when the hook mounts; to load another
|
|
10
|
+
* conversation later, call `reset(messages)`. A new `client` stops the turn
|
|
11
|
+
* in flight and compiles the conversation's last answer again through it.
|
|
12
|
+
*/
|
|
13
|
+
function usePickleSession(client, options) {
|
|
14
|
+
const [store] = useState(() => createSessionStore(client, options?.initialMessages ?? NO_MESSAGES));
|
|
15
|
+
const state = useSyncExternalStore(store.subscribe, store.getState, store.getState);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
store.setClient(client);
|
|
18
|
+
store.resume();
|
|
19
|
+
return () => store.stop();
|
|
20
|
+
}, [store, client]);
|
|
21
|
+
return {
|
|
22
|
+
...state,
|
|
23
|
+
reset: store.reset,
|
|
24
|
+
send: store.send,
|
|
25
|
+
stop: store.stop
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
export { usePickleSession };
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jigs.design/react",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "The web renderer: PickleView runs a compiled program in a sandbox and draws it with your registry; usePickleSession streams programs from the server.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/yoamomonstruos/pickle.git",
|
|
9
|
+
"directory": "packages/react"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@jigs.design/runtime": "0.1.1",
|
|
29
|
+
"@jigs.design/client": "0.1.1"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
33
|
+
}
|
|
6
34
|
}
|