@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 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 links `../client` and resolves it through that package's build:
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
- (cd ../client && npm ci && npm run build)
44
- npm ci
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
- /* The server snapshot is the same empty state: effects never run there, so nothing connects. */
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: snapshot.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
- #snapshot = {
7
- connection: "stopped",
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
- this.#set("frames", this.#snapshot.frames.concat(frames));
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.1.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.1.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": "^20.14.5",
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
+ }