@temporalio/agent-harness-react 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +315 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/session.d.ts +7 -1
- package/dist/session.js +31 -5
- package/dist/state.d.ts +0 -1
- package/dist/state.js +20 -13
- package/package.json +15 -9
package/README.md
CHANGED
|
@@ -22,6 +22,10 @@ function Board({ sessionId }: { sessionId: string }) {
|
|
|
22
22
|
development does not reconnect.
|
|
23
23
|
- **Renders follow the stream.** The hook reads the session through `useSyncExternalStore`, and
|
|
24
24
|
the core writes once per flush, so a burst of frames is one render.
|
|
25
|
+
- **Select to render less.** `useAgentSessionSelector(options, (s) => s.connection)` re-renders
|
|
26
|
+
only when the selected value changes (by `Object.is`), so a status badge sits out every flush
|
|
27
|
+
that only adds frames or messages. It reads the raw `SessionSnapshot`, not the `AgentSession`
|
|
28
|
+
view.
|
|
25
29
|
- **A changed `sessionId` moves the connection.** Pass it as a prop or state like any other value.
|
|
26
30
|
- **One connection per session.** Put a `<HarnessProvider>` above your components and every
|
|
27
31
|
`useAgentSession` below it with the same session id shares one connection and one state.
|
|
@@ -32,15 +36,322 @@ function Board({ sessionId }: { sessionId: string }) {
|
|
|
32
36
|
- **The whole tree.** `messages`, `states` and `agentStatus` are the root agent's; `agents` and
|
|
33
37
|
`agent(id)` reach every subagent, and a `subagent` part's `messageId` names the child message
|
|
34
38
|
it ran. `pendingApprovals` and `pendingCallbacks` collect waiting tool calls from all of them.
|
|
39
|
+
- **Key message rows on `message.key`**, not `message.id`. A send's `id` changes from its local
|
|
40
|
+
id to the server's when it is admitted; its `key` does not, so the row keeps its DOM, focus
|
|
41
|
+
and measured height. (From the client release after 0.1.0.)
|
|
35
42
|
- **Server rendering** shows the empty session and opens no connection; the client connects once
|
|
36
43
|
it hydrates.
|
|
37
44
|
|
|
45
|
+
## With TanStack Query
|
|
46
|
+
|
|
47
|
+
The session list and session creation are plain request/response calls, so they suit TanStack
|
|
48
|
+
Query. Sends, approvals, callbacks and status stay in the session: they go through
|
|
49
|
+
`useAgentSession` and arrive on its stream, not through queries or mutations.
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
53
|
+
import { HttpTransport, startSession } from "@temporalio/agent-harness-react";
|
|
54
|
+
import { TicTacToeAgent } from "./client_sdk/TicTacToeAgent"; // from harness-codegen
|
|
55
|
+
|
|
56
|
+
const transport = new HttpTransport({ baseUrl: "/api/" });
|
|
57
|
+
|
|
58
|
+
export const sessionsQuery = () =>
|
|
59
|
+
queryOptions({
|
|
60
|
+
queryKey: ["agent-harness", "sessions"],
|
|
61
|
+
queryFn: ({ signal }) => transport.listSessions(signal)
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export function Sessions() {
|
|
65
|
+
const queryClient = useQueryClient();
|
|
66
|
+
const sessions = useQuery(sessionsQuery());
|
|
67
|
+
const start = useMutation({
|
|
68
|
+
mutationFn: () => startSession(transport, TicTacToeAgent),
|
|
69
|
+
onSuccess: () => queryClient.invalidateQueries({ queryKey: sessionsQuery().queryKey })
|
|
70
|
+
});
|
|
71
|
+
return (
|
|
72
|
+
<>
|
|
73
|
+
<button onClick={() => start.mutate()} disabled={start.isPending}>New session</button>
|
|
74
|
+
{sessions.data?.map((s) => <a key={s.workflow_id} href={`?session=${s.workflow_id}`}>{s.label}</a>)}
|
|
75
|
+
</>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Rendering replies as Markdown
|
|
81
|
+
|
|
82
|
+
A reply is model output, so treat its Markdown as untrusted. With `@tanstack/markdown`, which
|
|
83
|
+
escapes raw HTML by default, use `urlTransform` for two more rules:
|
|
84
|
+
- keep links only to `http:`, `https:` and `mailto:`;
|
|
85
|
+
- render images as their alt text. The renderer loads remote images by default, so a reply could
|
|
86
|
+
plant a tracking pixel or an image URL that carries data out.
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
import { useMemo } from "react";
|
|
90
|
+
import type { UrlTransform } from "@tanstack/markdown";
|
|
91
|
+
import { renderHtml } from "@tanstack/markdown/html";
|
|
92
|
+
import { parseMarkdown } from "@tanstack/markdown/parser";
|
|
93
|
+
|
|
94
|
+
const urlTransform: UrlTransform = (url, kind, defaultUrl) =>
|
|
95
|
+
kind === "link" && defaultUrl && /^(https?:|mailto:)/i.test(defaultUrl) ? defaultUrl : null;
|
|
96
|
+
|
|
97
|
+
export function Reply({ text }: { text: string }) {
|
|
98
|
+
// Once per render, which the session batches per flush, not once per streamed character.
|
|
99
|
+
const html = useMemo(() => renderHtml(parseMarkdown(text, { urlTransform })), [text]);
|
|
100
|
+
return <div className="reply" dangerouslySetInnerHTML={{ __html: html }} />;
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`null` from `urlTransform` drops the link or image but keeps its text. Relative links are
|
|
105
|
+
dropped too, since a reply has no base to be relative to.
|
|
106
|
+
|
|
107
|
+
To show some images, allow only sources that cannot phone home, as the console does: a raster
|
|
108
|
+
data URL (`/^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9+/]*={0,2}$/i`, never SVG, which
|
|
109
|
+
can carry script), capped in size, or a content-addressed path your own API serves. Return `url`
|
|
110
|
+
for `kind === "image"` when it passes, and keep `null` for everything else.
|
|
111
|
+
|
|
112
|
+
## Rendering replies per handler
|
|
113
|
+
|
|
114
|
+
With generated types, `message.handler` narrows `message.output` to that handler's reply, so a
|
|
115
|
+
map from handler to component can be typed end to end. Handlers without a card fall back to
|
|
116
|
+
JSON:
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
import { useMemo, type ComponentType } from "react";
|
|
120
|
+
import {
|
|
121
|
+
parsePartialJson,
|
|
122
|
+
type AgentSchema,
|
|
123
|
+
type HandlerName,
|
|
124
|
+
type HandlerOutput,
|
|
125
|
+
type HarnessMessage
|
|
126
|
+
} from "@temporalio/agent-harness-client";
|
|
127
|
+
import type { TicTacToeAgent } from "./client_sdk/TicTacToeAgent"; // from harness-codegen
|
|
128
|
+
|
|
129
|
+
type Cards<A extends AgentSchema> = {
|
|
130
|
+
[H in HandlerName<A>]?: ComponentType<{ output: Partial<HandlerOutput<A, H>> }>;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const cards: Cards<TicTacToeAgent> = {
|
|
134
|
+
play: ({ output }) => <p className="move">{output.text}</p>
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** The reply text streamed so far: a data handler streams its output as JSON. */
|
|
138
|
+
const streamed = (message: HarnessMessage<TicTacToeAgent>) =>
|
|
139
|
+
message.parts.map((part) => (part.type === "reply_delta" ? part.text : "")).join("");
|
|
140
|
+
|
|
141
|
+
export function Reply({ message }: { message: HarnessMessage<TicTacToeAgent> }) {
|
|
142
|
+
// TypeScript can't tie the lookup to the narrowed message, so it is cast once here.
|
|
143
|
+
const Card = cards[message.handler] as ComponentType<{ output: unknown }> | undefined;
|
|
144
|
+
const text = message.output == null && Card ? streamed(message) : "";
|
|
145
|
+
// Once per flush; strip a ```json fence a model may open with.
|
|
146
|
+
const draft = useMemo(() => (text ? parsePartialJson(text.replace(/^\s*```(?:json)?\s*/, "")) : undefined), [text]);
|
|
147
|
+
const output = message.output ?? draft;
|
|
148
|
+
if (output == null) return null;
|
|
149
|
+
return Card ? <Card output={output} /> : <pre>{JSON.stringify(output, null, 2)}</pre>;
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
While the reply streams, `parsePartialJson` reads the JSON so far as a document: it closes open
|
|
154
|
+
strings and brackets and drops a half-written key or value. A card therefore gets a `Partial`
|
|
155
|
+
of its output and should draw a missing field as a placeholder. The parser is best effort, and
|
|
156
|
+
quadratic on text that never parses, so run it once per flush and only for handlers with a
|
|
157
|
+
card. (`parsePartialJson` is in the client release after 0.1.0.)
|
|
158
|
+
|
|
159
|
+
## Forms from a handler's schema
|
|
160
|
+
|
|
161
|
+
`GET /api/agent-interface/{sessionId}` lists each handler with `parameters`, the JSON Schema
|
|
162
|
+
of its input. The client turns that schema into fields (`describeSchema`), checks values
|
|
163
|
+
against it (`validate`, keyed by field path such as `address.city`) and builds the payload
|
|
164
|
+
(`buildPayload`). The server validates again and is the authority. (These helpers are in the
|
|
165
|
+
client release after 0.1.0.)
|
|
166
|
+
|
|
167
|
+
With native inputs, the schema's constraints become attributes the browser checks too:
|
|
168
|
+
|
|
169
|
+
```tsx
|
|
170
|
+
import { useState, type FormEvent } from "react";
|
|
171
|
+
import { buildPayload, describeSchema, emptyValues, validate, type SchemaField } from "@temporalio/agent-harness-client";
|
|
172
|
+
import { useAgentSession } from "@temporalio/agent-harness-react";
|
|
173
|
+
|
|
174
|
+
/** One entry of `GET /api/agent-interface/{sessionId}`. */
|
|
175
|
+
type Handler = { name: string; parameters: Record<string, unknown> };
|
|
176
|
+
|
|
177
|
+
function Input({ field, value, onChange }: { field: SchemaField; value: unknown; onChange: (value: unknown) => void }) {
|
|
178
|
+
const common = { id: field.name, required: field.required && field.kind !== "boolean" };
|
|
179
|
+
switch (field.kind) {
|
|
180
|
+
case "boolean":
|
|
181
|
+
return <input {...common} type="checkbox" checked={value === true} onChange={(e) => onChange(e.target.checked)} />;
|
|
182
|
+
case "enum":
|
|
183
|
+
return (
|
|
184
|
+
<select {...common} value={String(value)} onChange={(e) => onChange(e.target.value)}>
|
|
185
|
+
{field.choices?.map((choice) => <option key={choice}>{choice}</option>)}
|
|
186
|
+
</select>
|
|
187
|
+
);
|
|
188
|
+
case "integer":
|
|
189
|
+
case "number":
|
|
190
|
+
return (
|
|
191
|
+
<input {...common} type="number" step={field.kind === "integer" ? 1 : "any"} min={field.minimum} max={field.maximum}
|
|
192
|
+
value={String(value)} onChange={(e) => onChange(e.target.value)} />
|
|
193
|
+
);
|
|
194
|
+
default:
|
|
195
|
+
// Text. `pattern` is anchored by the browser but not by JSON Schema, hence the `.*`s. Give
|
|
196
|
+
// "json", "array" and "object" fields a textarea or recurse into `field.fields`/`field.item`.
|
|
197
|
+
return (
|
|
198
|
+
<input {...common} minLength={field.minLength} maxLength={field.maxLength}
|
|
199
|
+
pattern={field.pattern && `.*(?:${field.pattern}).*`} value={String(value)} onChange={(e) => onChange(e.target.value)} />
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Key it on `handler.name`, so another handler starts from its own blank values. */
|
|
205
|
+
export function HandlerForm({ sessionId, handler }: { sessionId: string; handler: Handler }) {
|
|
206
|
+
const agent = useAgentSession({ sessionId });
|
|
207
|
+
const [fields] = useState(() => describeSchema(handler.parameters));
|
|
208
|
+
const [values, setValues] = useState(() => emptyValues(fields));
|
|
209
|
+
const [problems, setProblems] = useState<Record<string, string>>({});
|
|
210
|
+
const submit = (event: FormEvent) => {
|
|
211
|
+
event.preventDefault();
|
|
212
|
+
const found = validate(fields, values);
|
|
213
|
+
setProblems(found);
|
|
214
|
+
if (Object.keys(found).length === 0) void agent.sendMessage(handler.name, buildPayload(fields, values));
|
|
215
|
+
};
|
|
216
|
+
return (
|
|
217
|
+
<form onSubmit={submit}>
|
|
218
|
+
{fields.map((field) => (
|
|
219
|
+
<p key={field.name}>
|
|
220
|
+
<label htmlFor={field.name}>{field.title}</label>
|
|
221
|
+
<Input field={field} value={values[field.name]} onChange={(value) => setValues({ ...values, [field.name]: value })} />
|
|
222
|
+
{problems[field.name] && <span role="alert">{problems[field.name]}</span>}
|
|
223
|
+
</p>
|
|
224
|
+
))}
|
|
225
|
+
<button>Send</button>
|
|
226
|
+
</form>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
With [TanStack Form](https://tanstack.com/form), wrap `validate` as a
|
|
232
|
+
[Standard Schema](https://standardschema.dev), which TanStack Form accepts as a validator and
|
|
233
|
+
whose issue paths it maps onto fields. The same `Input` renders each field:
|
|
234
|
+
|
|
235
|
+
```tsx
|
|
236
|
+
import { useForm, type StandardSchemaV1 } from "@tanstack/react-form";
|
|
237
|
+
|
|
238
|
+
/** The client's `validate` as a Standard Schema: each problem is an issue at its field's path. */
|
|
239
|
+
const standardSchema = (fields: SchemaField[]): StandardSchemaV1<Record<string, unknown>> => ({
|
|
240
|
+
"~standard": {
|
|
241
|
+
version: 1,
|
|
242
|
+
vendor: "agent-harness",
|
|
243
|
+
validate: (input) => {
|
|
244
|
+
const value = input as Record<string, unknown>;
|
|
245
|
+
const problems = Object.entries(validate(fields, value));
|
|
246
|
+
return problems.length === 0
|
|
247
|
+
? { value }
|
|
248
|
+
: { issues: problems.map(([path, message]) => ({ message, path: path.split(".") })) };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
export function HandlerTanStackForm({ sessionId, handler }: { sessionId: string; handler: Handler }) {
|
|
254
|
+
const agent = useAgentSession({ sessionId });
|
|
255
|
+
const [fields] = useState(() => describeSchema(handler.parameters));
|
|
256
|
+
const form = useForm({
|
|
257
|
+
defaultValues: emptyValues(fields),
|
|
258
|
+
validators: { onSubmit: standardSchema(fields) },
|
|
259
|
+
onSubmit: ({ value }) => agent.sendMessage(handler.name, buildPayload(fields, value))
|
|
260
|
+
});
|
|
261
|
+
return (
|
|
262
|
+
<form onSubmit={(event) => { event.preventDefault(); void form.handleSubmit(); }}>
|
|
263
|
+
{fields.map((schemaField) => (
|
|
264
|
+
<form.Field key={schemaField.name} name={schemaField.name}>
|
|
265
|
+
{(field) => (
|
|
266
|
+
<p>
|
|
267
|
+
<label htmlFor={schemaField.name}>{schemaField.title}</label>
|
|
268
|
+
<Input field={schemaField} value={field.state.value} onChange={field.handleChange} />
|
|
269
|
+
{field.state.meta.errors.map((issue) => <span key={issue?.message} role="alert">{issue?.message}</span>)}
|
|
270
|
+
</p>
|
|
271
|
+
)}
|
|
272
|
+
</form.Field>
|
|
273
|
+
))}
|
|
274
|
+
<button>Send</button>
|
|
275
|
+
</form>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## A virtual transcript
|
|
281
|
+
|
|
282
|
+
For long sessions, render only the visible messages with `@tanstack/react-virtual`:
|
|
283
|
+
- `anchorTo: "end"` keeps the view on the latest message as replies grow.
|
|
284
|
+
- `followOnAppend` follows new messages only while the reader is already at the end.
|
|
285
|
+
- `getItemKey` uses `message.key`, so a send keeps its measured height when the server admits it.
|
|
286
|
+
|
|
287
|
+
```tsx
|
|
288
|
+
import { useRef } from "react";
|
|
289
|
+
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
290
|
+
|
|
291
|
+
export function Transcript({ sessionId }: { sessionId: string }) {
|
|
292
|
+
const agent = useAgentSession({ sessionId });
|
|
293
|
+
const messages = [...agent.messages, ...agent.pending];
|
|
294
|
+
const scroller = useRef<HTMLDivElement>(null);
|
|
295
|
+
const virtualizer = useVirtualizer({
|
|
296
|
+
count: messages.length,
|
|
297
|
+
getScrollElement: () => scroller.current,
|
|
298
|
+
estimateSize: () => 72,
|
|
299
|
+
getItemKey: (index) => messages[index]!.key,
|
|
300
|
+
anchorTo: "end",
|
|
301
|
+
followOnAppend: true,
|
|
302
|
+
overscan: 6
|
|
303
|
+
});
|
|
304
|
+
return (
|
|
305
|
+
<div ref={scroller} tabIndex={-1} style={{ height: 600, overflow: "auto" }}>
|
|
306
|
+
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
|
307
|
+
{virtualizer.getVirtualItems().map((item) => (
|
|
308
|
+
<div
|
|
309
|
+
key={item.key}
|
|
310
|
+
ref={virtualizer.measureElement}
|
|
311
|
+
data-index={item.index}
|
|
312
|
+
style={{ position: "absolute", width: "100%", transform: `translateY(${item.start}px)` }}
|
|
313
|
+
>
|
|
314
|
+
<Message message={messages[item.index]!} />
|
|
315
|
+
</div>
|
|
316
|
+
))}
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
A row scrolled out of the window unmounts, taking focus with it. When that happens, move focus to
|
|
324
|
+
the row now at the same index, or to the list itself (hence its `tabIndex={-1}`).
|
|
325
|
+
|
|
326
|
+
## With TanStack Store
|
|
327
|
+
|
|
328
|
+
A session's state can also feed TanStack Store's `useSelector`, which reads any `get()` and
|
|
329
|
+
`subscribe()` source. Hold the session open with `useAgentSession` (or the selector hook)
|
|
330
|
+
somewhere under the same `<HarnessProvider store={store}>`:
|
|
331
|
+
|
|
332
|
+
```tsx
|
|
333
|
+
import { useSelector } from "@tanstack/react-store";
|
|
334
|
+
|
|
335
|
+
const state = store.entry({ sessionId }).state;
|
|
336
|
+
const source = {
|
|
337
|
+
get: state.getSnapshot,
|
|
338
|
+
subscribe: (listener: (snapshot: SessionSnapshot) => void) => ({
|
|
339
|
+
unsubscribe: state.subscribe(() => listener(state.getSnapshot()))
|
|
340
|
+
})
|
|
341
|
+
};
|
|
342
|
+
const busy = useSelector(source, (s) => s.agents[s.rootAgentId ?? ""]?.agentStatus === "busy");
|
|
343
|
+
```
|
|
344
|
+
|
|
38
345
|
## Development
|
|
39
346
|
|
|
40
|
-
This package
|
|
347
|
+
This package is part of the pnpm workspace in `typescript/`, and builds and tests against the
|
|
348
|
+
`@temporalio/agent-harness-client` in the working tree; its published dependency is the
|
|
349
|
+
`^<version>` range in `package.json`. `bazel test //typescript/packages/react/...` builds and
|
|
350
|
+
tests it. Without Bazel, build the client first, then run these commands from
|
|
351
|
+
`typescript/packages/react` (see
|
|
352
|
+
[Testing local changes](../README.md#testing-local-changes-without-publishing)):
|
|
41
353
|
|
|
42
354
|
```sh
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
npm run check && npm test && npm run build
|
|
355
|
+
pnpm install
|
|
356
|
+
pnpm run check && pnpm test && pnpm run build
|
|
46
357
|
```
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { HarnessProvider, SessionStore, useAgentSession, type AgentSession } from "./session.js";
|
|
1
|
+
export { HarnessProvider, SessionStore, useAgentSession, useAgentSessionSelector, type AgentSession } from "./session.js";
|
|
2
2
|
export { ReactSessionState, type SessionSnapshot } from "./state.js";
|
|
3
3
|
export * from "@temporalio/agent-harness-client";
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { HarnessProvider, SessionStore, useAgentSession } from "./session.js";
|
|
1
|
+
export { HarnessProvider, SessionStore, useAgentSession, useAgentSessionSelector } from "./session.js";
|
|
2
2
|
export { ReactSessionState } from "./state.js";
|
|
3
3
|
export * from "@temporalio/agent-harness-client";
|
package/dist/session.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
import { AgentSessionCore, type AgentSchema, type AgentSessionOptions, type AgentSseFrame, type AgentView, type ConnectionStatus, type HandlerInput, type HandlerName, type HarnessMessage, type SubmitMessageResponse, type ToolApprovalDecision, type UntypedAgent, type WaitingToolCall } from "@temporalio/agent-harness-client";
|
|
3
|
-
import { ReactSessionState } from "./state.js";
|
|
3
|
+
import { ReactSessionState, type SessionSnapshot } from "./state.js";
|
|
4
4
|
/** One session's core and state, shared by every component that follows it. */
|
|
5
5
|
declare class SessionEntry<A extends AgentSchema> {
|
|
6
6
|
#private;
|
|
@@ -71,4 +71,10 @@ export interface AgentSession<A extends AgentSchema = UntypedAgent> {
|
|
|
71
71
|
* moves the connection to the new session.
|
|
72
72
|
*/
|
|
73
73
|
export declare function useAgentSession<A extends AgentSchema = UntypedAgent>(options: AgentSessionOptions<A>): AgentSession<A>;
|
|
74
|
+
/**
|
|
75
|
+
* Follow one session, like `useAgentSession`, but re-render only when `select`'s result changes
|
|
76
|
+
* (by `Object.is`): `useAgentSessionSelector(options, (s) => s.connection)` sits out every flush
|
|
77
|
+
* that only adds frames or messages.
|
|
78
|
+
*/
|
|
79
|
+
export declare function useAgentSessionSelector<T, A extends AgentSchema = UntypedAgent>(options: AgentSessionOptions<A>, select: (snapshot: SessionSnapshot<A>) => T): T;
|
|
74
80
|
export {};
|
package/dist/session.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// useAgentSession: one agent session as React state. Call it in a component, render its fields,
|
|
2
2
|
// and call its actions. The connection is open exactly while a component using the session is
|
|
3
3
|
// mounted: the first to mount opens it, and it closes once the last one unmounts.
|
|
4
|
-
import { createContext, createElement, useContext, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { createContext, createElement, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
5
5
|
import { AgentSessionCore, rootView, waitingCalls } from "@temporalio/agent-harness-client";
|
|
6
6
|
import { ReactSessionState } from "./state.js";
|
|
7
7
|
/** One session's core and state, shared by every component that follows it. */
|
|
@@ -67,6 +67,32 @@ export function HarnessProvider({ store, children }) {
|
|
|
67
67
|
* moves the connection to the new session.
|
|
68
68
|
*/
|
|
69
69
|
export function useAgentSession(options) {
|
|
70
|
+
const entry = useEntry(options);
|
|
71
|
+
/* The server snapshot is the same empty state: effects never run there, so nothing connects. */
|
|
72
|
+
const snapshot = useSyncExternalStore(entry.state.subscribe, entry.state.getSnapshot, entry.state.getSnapshot);
|
|
73
|
+
return useMemo(() => sessionView(entry, snapshot), [entry, snapshot]);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Follow one session, like `useAgentSession`, but re-render only when `select`'s result changes
|
|
77
|
+
* (by `Object.is`): `useAgentSessionSelector(options, (s) => s.connection)` sits out every flush
|
|
78
|
+
* that only adds frames or messages.
|
|
79
|
+
*/
|
|
80
|
+
export function useAgentSessionSelector(options, select) {
|
|
81
|
+
const entry = useEntry(options);
|
|
82
|
+
const last = useRef(null);
|
|
83
|
+
const get = () => {
|
|
84
|
+
const snapshot = entry.state.getSnapshot();
|
|
85
|
+
// A new `select` (say, one closing over changed props) reads the same snapshot afresh.
|
|
86
|
+
if (last.current?.snapshot !== snapshot || last.current.select !== select) {
|
|
87
|
+
const value = select(snapshot);
|
|
88
|
+
last.current = { snapshot, select, value: last.current && Object.is(last.current.value, value) ? last.current.value : value };
|
|
89
|
+
}
|
|
90
|
+
return last.current.value;
|
|
91
|
+
};
|
|
92
|
+
return useSyncExternalStore(entry.state.subscribe, get, get);
|
|
93
|
+
}
|
|
94
|
+
/** The session's shared entry, held open while the calling component is mounted. */
|
|
95
|
+
function useEntry(options) {
|
|
70
96
|
const context = useContext(StoreContext);
|
|
71
97
|
const [own] = useState(() => new SessionStore());
|
|
72
98
|
const entry = (context ?? own).entry(options);
|
|
@@ -74,9 +100,7 @@ export function useAgentSession(options) {
|
|
|
74
100
|
entry.acquire();
|
|
75
101
|
return () => entry.release();
|
|
76
102
|
}, [entry]);
|
|
77
|
-
|
|
78
|
-
const snapshot = useSyncExternalStore(entry.state.subscribe, entry.state.getSnapshot, entry.state.getSnapshot);
|
|
79
|
-
return useMemo(() => sessionView(entry, snapshot), [entry, snapshot]);
|
|
103
|
+
return entry;
|
|
80
104
|
}
|
|
81
105
|
function sessionView(entry, snapshot) {
|
|
82
106
|
const { core } = entry;
|
|
@@ -94,7 +118,9 @@ function sessionView(entry, snapshot) {
|
|
|
94
118
|
pending: snapshot.pending,
|
|
95
119
|
agents: snapshot.agents,
|
|
96
120
|
rootAgentId: snapshot.rootAgentId,
|
|
97
|
-
frames
|
|
121
|
+
get frames() {
|
|
122
|
+
return snapshot.frames;
|
|
123
|
+
},
|
|
98
124
|
get pendingApprovals() {
|
|
99
125
|
return waitingNow().approvals;
|
|
100
126
|
},
|
package/dist/state.d.ts
CHANGED
|
@@ -22,7 +22,6 @@ export declare class ReactSessionState<A extends AgentSchema = UntypedAgent> imp
|
|
|
22
22
|
get pending(): readonly HarnessMessage<A>[];
|
|
23
23
|
set pending(pending: readonly HarnessMessage<A>[]);
|
|
24
24
|
get frames(): readonly AgentSseFrame[];
|
|
25
|
-
set frames(frames: readonly AgentSseFrame[]);
|
|
26
25
|
updateAgents(updates: ReadonlyArray<readonly [string, AgentUpdate]>): void;
|
|
27
26
|
appendFrames(frames: readonly AgentSseFrame[]): void;
|
|
28
27
|
}
|
package/dist/state.js
CHANGED
|
@@ -2,15 +2,23 @@
|
|
|
2
2
|
// Every write replaces the snapshot, so its identity changes exactly when something did; the
|
|
3
3
|
// core never mutates what it has handed over, so the fields inside can be shared as they are.
|
|
4
4
|
import { applyAgentUpdate } from "@temporalio/agent-harness-client";
|
|
5
|
+
/** A snapshot whose frames are the first `frameCount` of the state's append-only log, copied out
|
|
6
|
+
* only when read: every flush makes a snapshot, and copying the whole log into each would make
|
|
7
|
+
* a flush cost more the longer the session ran. */
|
|
8
|
+
class Snapshot {
|
|
9
|
+
#log;
|
|
10
|
+
#frames;
|
|
11
|
+
constructor(fields, log) {
|
|
12
|
+
Object.assign(this, fields);
|
|
13
|
+
this.#log = log;
|
|
14
|
+
}
|
|
15
|
+
get frames() {
|
|
16
|
+
return (this.#frames ??= this.#log.slice(0, this.frameCount));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
5
19
|
export class ReactSessionState {
|
|
6
|
-
#
|
|
7
|
-
|
|
8
|
-
error: undefined,
|
|
9
|
-
rootAgentId: null,
|
|
10
|
-
agents: {},
|
|
11
|
-
pending: [],
|
|
12
|
-
frames: []
|
|
13
|
-
};
|
|
20
|
+
#log = [];
|
|
21
|
+
#snapshot = new Snapshot({ connection: "stopped", error: undefined, rootAgentId: null, agents: {}, pending: [], frameCount: 0 }, this.#log);
|
|
14
22
|
#listeners = new Set();
|
|
15
23
|
/* Arrow properties: `useSyncExternalStore` resubscribes whenever these change identity. */
|
|
16
24
|
subscribe = (listener) => {
|
|
@@ -51,9 +59,6 @@ export class ReactSessionState {
|
|
|
51
59
|
get frames() {
|
|
52
60
|
return this.#snapshot.frames;
|
|
53
61
|
}
|
|
54
|
-
set frames(frames) {
|
|
55
|
-
this.#set("frames", frames);
|
|
56
|
-
}
|
|
57
62
|
updateAgents(updates) {
|
|
58
63
|
const next = { ...this.#snapshot.agents };
|
|
59
64
|
for (const [id, update] of updates)
|
|
@@ -61,12 +66,14 @@ export class ReactSessionState {
|
|
|
61
66
|
this.#set("agents", next);
|
|
62
67
|
}
|
|
63
68
|
appendFrames(frames) {
|
|
64
|
-
|
|
69
|
+
for (const frame of frames)
|
|
70
|
+
this.#log.push(frame);
|
|
71
|
+
this.#set("frameCount", this.#log.length);
|
|
65
72
|
}
|
|
66
73
|
#set(key, value) {
|
|
67
74
|
if (this.#snapshot[key] === value)
|
|
68
75
|
return;
|
|
69
|
-
this.#snapshot = { ...this.#snapshot, [key]: value };
|
|
76
|
+
this.#snapshot = new Snapshot({ ...this.#snapshot, [key]: value }, this.#log);
|
|
70
77
|
for (const listener of this.#listeners)
|
|
71
78
|
listener();
|
|
72
79
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@temporalio/agent-harness-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "React binding for @temporalio/agent-harness-client: a useAgentSession hook over a temporal-agent-harness agent's event stream.",
|
|
@@ -17,26 +17,32 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"dist"
|
|
19
19
|
],
|
|
20
|
-
"scripts": {
|
|
21
|
-
"build": "tsc -p tsconfig.build.json",
|
|
22
|
-
"check": "tsc -p tsconfig.json",
|
|
23
|
-
"test": "vitest run"
|
|
24
|
-
},
|
|
25
20
|
"peerDependencies": {
|
|
26
21
|
"react": "^18.2.0 || ^19.0.0"
|
|
27
22
|
},
|
|
28
23
|
"dependencies": {
|
|
29
|
-
"@temporalio/agent-harness-client": "^0.
|
|
24
|
+
"@temporalio/agent-harness-client": "^0.2.0"
|
|
30
25
|
},
|
|
31
26
|
"devDependencies": {
|
|
32
27
|
"@testing-library/react": "^16.3.3",
|
|
33
28
|
"@types/node": "^22.20.4",
|
|
34
29
|
"@types/react": "^19.3.0",
|
|
35
30
|
"@types/react-dom": "^19.3.0",
|
|
36
|
-
"happy-dom": "^
|
|
31
|
+
"happy-dom": "^21.0.4",
|
|
37
32
|
"react": "^19.3.0",
|
|
38
33
|
"react-dom": "^19.3.0",
|
|
39
34
|
"typescript": "^7.0.2",
|
|
35
|
+
"vite": "^7.3.7",
|
|
40
36
|
"vitest": "^5.0.1"
|
|
37
|
+
},
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/temporal-community/temporal-agent-harness.git",
|
|
41
|
+
"directory": "typescript/packages/react"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc -p tsconfig.build.json",
|
|
45
|
+
"check": "tsc -p tsconfig.json --noEmit",
|
|
46
|
+
"test": "vitest run"
|
|
41
47
|
}
|
|
42
|
-
}
|
|
48
|
+
}
|