@novoagents/react 0.1.0-alpha.0 → 0.2.0-alpha.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/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0-alpha.0 - 2026-07-14
4
+
5
+ - Replaced the alpha store/transport hooks with effect-owned bindings over the
6
+ canonical `NovoRunController`.
7
+ - Added `useNovoRunSelector` and controller-bound `useNovoInteractions`.
8
+ - Fixed run-key/unmount leaks with stop + disposal and generation-fenced core
9
+ consumption.
10
+ - Added client entry directives, `sideEffects: false`, React 18/19 peer range,
11
+ SSR snapshots, and Strict Mode lifecycle coverage.
package/README.md CHANGED
@@ -1,31 +1,64 @@
1
1
  # @novoagents/react
2
2
 
3
- Headless React hooks for rendering Novo Agents runs in the browser. The hooks
4
- manage state and render nothing — bring your own components. This package
5
- never accepts an API key: it talks to **your** server routes, which proxy to
6
- `api.novoagents.ai` with the server-only `novoagents` SDK. Its only runtime
7
- dependency is the browser-safe `novoagents/browser` entry point.
8
-
9
- ## Hooks
10
-
11
- - `useNovoRun({ transport })` — consumes the run stream through your proxy
12
- routes and returns live UI state: `messages`, `status`,
13
- `pendingInteractions`, `usage`, and `resume()`. One component instance keeps
14
- exactly one store and one stream consumption, so an inline `transport`
15
- object literal is safe across re-renders; pass a new `runKey` to
16
- intentionally reset. Reconnects until the `data-novo-terminal` chunk (a
17
- closed connection is not a finished run), with jittered exponential backoff
18
- between unproductive reconnects.
19
- - `useNovoInteractions({ endpoint, run })` — sources pending
20
- approvals/questions from the same stream state, POSTs the batch resolution
21
- shape to your proxied resolve route, and calls the run's `resume()` after a
22
- successful resolution so the parked stream picks back up from the last seen
23
- event id.
24
-
25
- See the full guide with proxy routes and a working component:
26
- [Build a React run UI](https://novoagents.ai/docs/guides/build-a-react-run-ui).
3
+ Headless React bindings for the canonical browser run controller in
4
+ `novoagents/browser`. The package renders nothing, ships no CSS, and never
5
+ accepts a Novo API key. Browser requests go only to your authenticated proxy
6
+ routes.
7
+
8
+ ```tsx
9
+ 'use client';
10
+
11
+ import { useNovoInteractions, useNovoRun } from '@novoagents/react';
12
+ import {
13
+ createHttpInteractionResolveTransport,
14
+ createNovoHttpRunSource,
15
+ createNovoRunController,
16
+ } from 'novoagents/browser';
17
+
18
+ export function RunView({ runId }: { runId: string }) {
19
+ const run = useNovoRun({
20
+ runKey: runId,
21
+ createController: () =>
22
+ createNovoRunController({
23
+ source: createNovoHttpRunSource({
24
+ url: `/api/runs/${runId}/stream`,
25
+ pollUrl: `/api/runs/${runId}`,
26
+ }),
27
+ interactions: createHttpInteractionResolveTransport({
28
+ endpoint: `/api/runs/${runId}/interactions/resolve`,
29
+ }),
30
+ }),
31
+ });
32
+ const inbox = useNovoInteractions(run.controller);
33
+
34
+ return <main>{run.status} · {run.messages.length} messages · {inbox.interactions.length} pending</main>;
35
+ }
36
+ ```
37
+
38
+ `useNovoRun` creates its controller inside an effect, owns it for one `runKey`,
39
+ and stops/disposes it on replacement or unmount. `useNovoRunSelector` provides
40
+ concurrent-safe selected subscriptions. `useNovoInteractions` binds the
41
+ controller's `interactions.pending` queue and resolution primitive.
42
+
43
+ If your host persists interaction requests independently of the stream,
44
+ configure `interactionDiscovery: { list, intervalMs? }` on the controller.
45
+ Discovery is generation-fenced, runs while the SSE connection is idle, and
46
+ reconciles DB-only requests into the same canonical pending queue.
47
+
48
+ ## Migrating from 0.1.0-alpha.0
49
+
50
+ - `createNovoRunStore` → `createNovoRunController` from `novoagents/browser`.
51
+ - `BrowserRunTransport` → `NovoRunEventSource`; use
52
+ `createNovoHttpRunSource` for fetch/SSE routes.
53
+ - `useNovoRun({ transport })` →
54
+ `useNovoRun({ createController, runKey })`.
55
+ - `useNovoInteractions({ endpoint, run })` → configure the controller's
56
+ interaction transport, then call `useNovoInteractions(run.controller)`.
57
+ - `resolveNovoInteractions` → `createHttpInteractionResolveTransport`.
58
+
59
+ There are no compatibility aliases for the alpha API.
27
60
 
28
61
  ## Publishing
29
62
 
30
- Publish with `pnpm publish` (not `npm publish`): the `workspace:*` dependency
31
- on `novoagents` is rewritten to the real version only by pnpm's pack step.
63
+ Publish with `pnpm publish`: pnpm rewrites the `workspace:*` dependency on
64
+ `novoagents` to the packed version.
package/dist/index.d.ts CHANGED
@@ -1,2 +1,4 @@
1
- export { createNovoRunStore, useNovoRun, type NovoRunStore, type UseNovoRunOptions, type UseNovoRunResult, } from './useNovoRun.js';
2
- export { resolveNovoInteractions, useNovoInteractions, type NovoInteractionResolutionItem, type NovoInteractionResolutionOutcome, type NovoInteractionRunSource, type NovoPendingInteraction, } from './useNovoInteractions.js';
1
+ export { NOVO_IDLE_RUN_STATE, useNovoRun, type UseNovoRunOptions, type UseNovoRunResult, } from './useNovoRun.js';
2
+ export { useNovoRunSelector } from './useNovoRunSelector.js';
3
+ export { useNovoInteractions, type NovoPendingInteraction, } from './useNovoInteractions.js';
4
+ export type { NovoInteractionResolutionItem, NovoInteractionResolutionOutcome, NovoRunController, NovoRunControllerState, } from 'novoagents/browser';
package/dist/index.js CHANGED
@@ -1,2 +1,4 @@
1
- export { createNovoRunStore, useNovoRun, } from './useNovoRun.js';
2
- export { resolveNovoInteractions, useNovoInteractions, } from './useNovoInteractions.js';
1
+ 'use client';
2
+ export { NOVO_IDLE_RUN_STATE, useNovoRun, } from './useNovoRun.js';
3
+ export { useNovoRunSelector } from './useNovoRunSelector.js';
4
+ export { useNovoInteractions, } from './useNovoInteractions.js';
@@ -1,48 +1,9 @@
1
- import type { NovoUiPendingInteraction } from 'novoagents/browser';
2
- export type NovoPendingInteraction = {
3
- id: string;
4
- kind: 'approval_request' | 'question';
5
- status: 'pending' | 'resuming';
6
- } & Record<string, unknown>;
7
- export type NovoInteractionResolutionItem = {
8
- id: string;
9
- inputResponses?: Array<Record<string, unknown>>;
10
- cancel?: boolean;
11
- reason?: string;
1
+ import type { NovoInteractionResolutionItem, NovoInteractionResolutionOutcome, NovoRunController, NovoUiPendingInteraction } from 'novoagents/browser';
2
+ export type NovoPendingInteraction = NovoUiPendingInteraction & {
3
+ status: 'pending' | 'resolving';
12
4
  };
13
- export type NovoInteractionResolutionOutcome = {
14
- id: string;
15
- status: 'resolved' | 'already_resolved' | 'conflict' | 'not_found' | 'invalid_body';
16
- };
17
- /**
18
- * The slice of a `useNovoRun` result (or `NovoRunStore`-shaped object) that
19
- * interactions can be sourced from: the reducer's pending queue plus the
20
- * resume path invoked after a successful resolution.
21
- */
22
- export type NovoInteractionRunSource = {
23
- pendingInteractions: readonly NovoUiPendingInteraction[];
24
- resume?: () => Promise<void>;
25
- };
26
- export declare function resolveNovoInteractions(input: {
27
- endpoint: string;
28
- items: NovoInteractionResolutionItem[];
29
- fetch?: typeof globalThis.fetch;
30
- }): Promise<NovoInteractionResolutionOutcome[]>;
31
- /**
32
- * Track and resolve the run's pending approvals/questions through your proxied
33
- * batch resolve route. Zero-config source: pass the `useNovoRun` result as
34
- * `run` and pending interactions come from the stream reducer, with the run's
35
- * `resume()` invoked automatically once a resolution lands. An explicit
36
- * `interactions` array (e.g. from your own discovery endpoint) overrides the
37
- * run source.
38
- */
39
- export declare function useNovoInteractions(input: {
40
- endpoint: string;
41
- run?: NovoInteractionRunSource;
42
- interactions?: NovoPendingInteraction[];
43
- fetch?: typeof globalThis.fetch;
44
- }): {
45
- interactions: NovoPendingInteraction[];
46
- resolve(items: NovoInteractionResolutionItem[]): Promise<NovoInteractionResolutionOutcome[]>;
5
+ export declare function useNovoInteractions<TMetadata = unknown, TDataPart = unknown>(controller: NovoRunController<TMetadata, TDataPart> | undefined): {
6
+ interactions: readonly NovoPendingInteraction[];
7
+ resolve(items: readonly NovoInteractionResolutionItem[]): Promise<readonly NovoInteractionResolutionOutcome[]>;
47
8
  error: Error | undefined;
48
9
  };
@@ -1,68 +1,27 @@
1
- import { useCallback, useState } from 'react';
2
- export async function resolveNovoInteractions(input) {
3
- const request = input.fetch ?? globalThis.fetch;
4
- const response = await request(input.endpoint, {
5
- method: 'POST',
6
- headers: { 'Content-Type': 'application/json' },
7
- body: JSON.stringify({ items: input.items }),
8
- });
9
- const body = (await response.json());
10
- if (!response.ok) {
11
- throw new Error(body.error?.message ?? `Interaction resolve failed (${response.status}).`);
12
- }
13
- return body.items ?? [];
14
- }
15
- /**
16
- * Track and resolve the run's pending approvals/questions through your proxied
17
- * batch resolve route. Zero-config source: pass the `useNovoRun` result as
18
- * `run` and pending interactions come from the stream reducer, with the run's
19
- * `resume()` invoked automatically once a resolution lands. An explicit
20
- * `interactions` array (e.g. from your own discovery endpoint) overrides the
21
- * run source.
22
- */
23
- export function useNovoInteractions(input) {
24
- const [resolvedIds, setResolvedIds] = useState(() => new Set());
25
- const [error, setError] = useState();
26
- const resume = input.run?.resume;
27
- const resolve = useCallback(async (items) => {
28
- try {
29
- const outcomes = await resolveNovoInteractions({
30
- endpoint: input.endpoint,
31
- items,
32
- ...(input.fetch ? { fetch: input.fetch } : {}),
33
- });
34
- const resolved = new Set(outcomes
35
- .filter((item) => item.status === 'resolved' ||
36
- item.status === 'already_resolved')
37
- .map((item) => item.id));
38
- if (resolved.size > 0) {
39
- setResolvedIds((current) => new Set([...current, ...resolved]));
40
- // The run resumed (or will resume) server-side; restart stream
41
- // consumption so the resolved chunk and subsequent output arrive.
42
- void resume?.();
43
- }
44
- setError(undefined);
45
- return outcomes;
46
- }
47
- catch (caught) {
48
- const next = caught instanceof Error
49
- ? caught
50
- : new Error('Interaction resolve failed.');
51
- setError(next);
52
- throw next;
1
+ 'use client';
2
+ import { useCallback } from 'react';
3
+ import { useNovoRunSelector } from './useNovoRunSelector.js';
4
+ export function useNovoInteractions(controller) {
5
+ const selected = useNovoRunSelector(controller, (state) => ({
6
+ pending: state.interactions.pending,
7
+ resolving: state.interactions.resolving,
8
+ error: state.interactions.error,
9
+ }), (left, right) => left.pending === right.pending &&
10
+ left.resolving === right.resolving &&
11
+ left.error === right.error);
12
+ const resolving = new Set(selected.resolving);
13
+ const resolve = useCallback((items) => {
14
+ if (!controller) {
15
+ return Promise.reject(new Error('No Novo run controller is mounted.'));
53
16
  }
54
- }, [input.endpoint, input.fetch, resume]);
55
- const sourced = input.interactions ??
56
- (input.run?.pendingInteractions ?? []).map((interaction) => ({
57
- ...interaction,
58
- kind: interaction.request.kind,
59
- status: 'pending',
60
- }));
17
+ return controller.resolveInteractions(items);
18
+ }, [controller]);
61
19
  return {
62
- // resolvedIds bridges the gap between the resolve acknowledgement and the
63
- // stream's own data-novo-input-resolved chunk removing the entry.
64
- interactions: sourced.filter((interaction) => !resolvedIds.has(interaction.id)),
20
+ interactions: selected.pending.map((interaction) => ({
21
+ ...interaction,
22
+ status: resolving.has(interaction.id) ? 'resolving' : 'pending',
23
+ })),
65
24
  resolve,
66
- error,
25
+ error: selected.error,
67
26
  };
68
27
  }
@@ -1,41 +1,23 @@
1
- import { type BrowserRunTransport, type NovoAgentEvent, type NovoRunUiState } from 'novoagents/browser';
2
- export type NovoRunStore = {
3
- subscribe(listener: () => void): () => void;
4
- getSnapshot(): NovoRunUiState;
5
- /**
6
- * Begin consuming the run stream. Idempotent: repeat calls return the
7
- * in-flight (or last) consumption instead of starting another.
8
- */
9
- start(): Promise<void>;
10
- /**
11
- * Restart consumption from the last seen event id. No-op while consumption
12
- * is active or after the terminal chunk; the restart path exists because a
13
- * pause (`waiting_for_approval` / `waiting_for_input`) ends consumption —
14
- * call this after resolving interactions so the resumed run streams again.
15
- * Also retries after a stream failure.
16
- */
17
- resume(): Promise<void>;
18
- };
19
- export declare function createNovoRunStore(transport: BrowserRunTransport<NovoAgentEvent> | (() => BrowserRunTransport<NovoAgentEvent>)): NovoRunStore;
20
- export type UseNovoRunOptions = {
21
- transport: BrowserRunTransport<NovoAgentEvent>;
1
+ import type { NovoRunController, NovoRunControllerState } from 'novoagents/browser';
2
+ export declare const NOVO_IDLE_RUN_STATE: NovoRunControllerState;
3
+ export type UseNovoRunOptions<TMetadata = unknown, TDataPart = unknown> = {
4
+ /** A fresh controller factory. It is invoked only from the owning effect. */
5
+ createController: () => NovoRunController<TMetadata, TDataPart>;
22
6
  autoStart?: boolean;
23
- /**
24
- * Escape hatch for intentional resets: a new key discards the store and
25
- * consumes the stream from scratch. The abandoned consumption is not
26
- * cancelled — it stops at the run's next pause or terminal.
27
- */
28
- runKey?: string | number;
7
+ /** Changing this key stops/disposes the old controller and creates a new one. */
8
+ runKey?: string | number | null;
9
+ enabled?: boolean;
29
10
  };
30
- export type UseNovoRunResult = NovoRunUiState & {
31
- /** See {@link NovoRunStore.resume}. */
32
- resume(): Promise<void>;
33
- store: NovoRunStore;
11
+ export type UseNovoRunResult<TMetadata = unknown, TDataPart = unknown> = NovoRunControllerState<TMetadata, TDataPart> & {
12
+ controller: NovoRunController<TMetadata, TDataPart> | undefined;
13
+ start(): Promise<NovoRunControllerState<TMetadata, TDataPart>>;
14
+ resume(): Promise<NovoRunControllerState<TMetadata, TDataPart>>;
15
+ stop(): void;
16
+ cancelRun(): Promise<NovoRunControllerState<TMetadata, TDataPart>>;
34
17
  };
35
18
  /**
36
- * Consume a run stream through your proxy transport and expose the reduced UI
37
- * state. One store (one stream consumption) per component instance: passing an
38
- * inline `transport` object literal is safe across re-renders — the latest
39
- * transport is read through a ref without re-creating the store.
19
+ * Own exactly one controller per enabled `runKey`. Creation happens inside the
20
+ * effect so Strict Mode's mount/cleanup/remount cycle leaves no live transport
21
+ * behind; cleanup stops and permanently disposes the effect-owned controller.
40
22
  */
41
- export declare function useNovoRun(input: UseNovoRunOptions): UseNovoRunResult;
23
+ export declare function useNovoRun<TMetadata = unknown, TDataPart = unknown>(options: UseNovoRunOptions<TMetadata, TDataPart>): UseNovoRunResult<TMetadata, TDataPart>;
@@ -1,107 +1,101 @@
1
- import { useEffect, useMemo, useRef, useState, useSyncExternalStore, } from 'react';
2
- import { consumeRunWithReconnect, createNovoRunUiReducer, } from 'novoagents/browser';
3
- export function createNovoRunStore(transport) {
4
- const currentTransport = typeof transport === 'function' ? transport : () => transport;
5
- const reducer = createNovoRunUiReducer();
1
+ 'use client';
2
+ import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
3
+ export const NOVO_IDLE_RUN_STATE = Object.freeze({
4
+ messages: Object.freeze([]),
5
+ status: 'queued',
6
+ pendingInteractions: Object.freeze([]),
7
+ tasks: Object.freeze([]),
8
+ steps: Object.freeze([]),
9
+ browserSessions: Object.freeze([]),
10
+ warnings: Object.freeze([]),
11
+ warningCounts: Object.freeze({}),
12
+ connection: 'idle',
13
+ persistence: Object.freeze({ state: 'idle', pendingWrites: 0 }),
14
+ interactions: Object.freeze({
15
+ pending: Object.freeze([]),
16
+ resolving: Object.freeze([]),
17
+ }),
18
+ });
19
+ function createControllerSlot() {
6
20
  const listeners = new Set();
7
- let state = reducer.getState();
8
- let lastEventId;
9
- let active;
10
- let latest;
11
- let reachedTerminal = false;
12
- const publish = (next) => {
13
- state = next;
21
+ let controller;
22
+ let unsubscribe;
23
+ const publish = () => {
14
24
  for (const listener of listeners)
15
25
  listener();
16
26
  };
17
- const consume = () => {
18
- const promise = consumeRunWithReconnect({
19
- // Read the transport per call so the latest closure is always used.
20
- transport: {
21
- connect: (fromEventId) => currentTransport().connect(fromEventId),
22
- pollRun: () => currentTransport().pollRun(),
23
- },
24
- ...(lastEventId !== undefined ? { initialLastEventId: lastEventId } : {}),
25
- isTerminalEvent: (event) => event.type === 'data-novo-terminal',
26
- onFrame: ({ id, event }) => {
27
- if (id !== undefined)
28
- lastEventId = id;
29
- if (event)
30
- publish(reducer.reduce(event));
31
- },
32
- onPoll: (run) => {
33
- if (run.status === 'waiting_for_approval' ||
34
- run.status === 'waiting_for_input') {
35
- publish({ ...state, status: run.status });
36
- }
37
- },
38
- })
39
- .then((result) => {
40
- if (result.reachedTerminal)
41
- reachedTerminal = true;
42
- }, (error) => {
43
- publish({
44
- ...state,
45
- status: 'failed',
46
- error: {
47
- type: 'api_error',
48
- code: 'stream_error',
49
- message: error instanceof Error ? error.message : 'Stream failed.',
50
- },
51
- });
52
- })
53
- .then(() => {
54
- if (active === promise)
55
- active = undefined;
56
- });
57
- active = promise;
58
- latest = promise;
59
- return promise;
60
- };
61
27
  return {
62
28
  subscribe(listener) {
63
29
  listeners.add(listener);
64
30
  return () => listeners.delete(listener);
65
31
  },
66
- getSnapshot: () => state,
67
- start() {
68
- return latest ?? consume();
69
- },
70
- resume() {
71
- if (active)
72
- return active;
73
- if (reachedTerminal)
74
- return latest ?? Promise.resolve();
75
- return consume();
32
+ getSnapshot: () => controller?.getSnapshot() ??
33
+ NOVO_IDLE_RUN_STATE,
34
+ getController: () => controller,
35
+ setController(next) {
36
+ if (controller === next)
37
+ return;
38
+ unsubscribe?.();
39
+ controller = next;
40
+ unsubscribe = next?.subscribe(publish);
41
+ publish();
76
42
  },
77
43
  };
78
44
  }
79
45
  /**
80
- * Consume a run stream through your proxy transport and expose the reduced UI
81
- * state. One store (one stream consumption) per component instance: passing an
82
- * inline `transport` object literal is safe across re-renders — the latest
83
- * transport is read through a ref without re-creating the store.
46
+ * Own exactly one controller per enabled `runKey`. Creation happens inside the
47
+ * effect so Strict Mode's mount/cleanup/remount cycle leaves no live transport
48
+ * behind; cleanup stops and permanently disposes the effect-owned controller.
84
49
  */
85
- export function useNovoRun(input) {
86
- const transportRef = useRef(input.transport);
87
- const [entry, setEntry] = useState(() => ({
88
- key: input.runKey,
89
- store: createNovoRunStore(() => transportRef.current),
90
- }));
91
- if (!Object.is(entry.key, input.runKey)) {
92
- setEntry({
93
- key: input.runKey,
94
- store: createNovoRunStore(() => transportRef.current),
95
- });
96
- }
97
- const store = entry.store;
50
+ export function useNovoRun(options) {
51
+ const factoryRef = useRef(options.createController);
52
+ factoryRef.current = options.createController;
53
+ const slotRef = useRef(undefined);
54
+ slotRef.current ??= createControllerSlot();
55
+ const slot = slotRef.current;
56
+ const ownedRunKeyRef = useRef(UNOWNED_RUN_KEY);
98
57
  useEffect(() => {
99
- transportRef.current = input.transport;
100
- });
58
+ if (options.enabled === false) {
59
+ slot.setController(undefined);
60
+ return;
61
+ }
62
+ const controller = factoryRef.current();
63
+ ownedRunKeyRef.current = options.runKey ?? null;
64
+ slot.setController(controller);
65
+ return () => {
66
+ controller.stop();
67
+ controller.dispose();
68
+ if (ownedRunKeyRef.current === (options.runKey ?? null)) {
69
+ ownedRunKeyRef.current = UNOWNED_RUN_KEY;
70
+ }
71
+ if (slot.getController() === controller)
72
+ slot.setController(undefined);
73
+ };
74
+ }, [options.enabled, options.runKey, slot]);
101
75
  useEffect(() => {
102
- if (input.autoStart !== false)
103
- void store.start();
104
- }, [input.autoStart, store]);
105
- const state = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
106
- return useMemo(() => ({ ...state, resume: store.resume, store }), [state, store]);
76
+ if (options.enabled !== false &&
77
+ options.autoStart !== false &&
78
+ ownedRunKeyRef.current === (options.runKey ?? null)) {
79
+ void slot.getController()?.start();
80
+ }
81
+ }, [options.autoStart, options.enabled, options.runKey, slot]);
82
+ const observedState = useSyncExternalStore(slot.subscribe, slot.getSnapshot, () => NOVO_IDLE_RUN_STATE);
83
+ const ownsRenderedKey = options.enabled !== false &&
84
+ ownedRunKeyRef.current === (options.runKey ?? null);
85
+ const state = ownsRenderedKey
86
+ ? observedState
87
+ : NOVO_IDLE_RUN_STATE;
88
+ const controller = ownsRenderedKey ? slot.getController() : undefined;
89
+ return useMemo(() => ({
90
+ ...state,
91
+ controller,
92
+ start: () => controller?.start() ??
93
+ Promise.resolve(NOVO_IDLE_RUN_STATE),
94
+ resume: () => controller?.resume() ??
95
+ Promise.resolve(NOVO_IDLE_RUN_STATE),
96
+ stop: () => controller?.stop(),
97
+ cancelRun: () => controller?.cancelRun() ??
98
+ Promise.resolve(NOVO_IDLE_RUN_STATE),
99
+ }), [controller, state]);
107
100
  }
101
+ const UNOWNED_RUN_KEY = Symbol('unowned Novo run key');
@@ -0,0 +1,3 @@
1
+ import type { NovoRunController, NovoRunControllerState } from 'novoagents/browser';
2
+ /** Concurrent-safe selector binding with selector/equality identity support. */
3
+ export declare function useNovoRunSelector<T, TMetadata = unknown, TDataPart = unknown>(controller: NovoRunController<TMetadata, TDataPart> | undefined, selector: (state: NovoRunControllerState<TMetadata, TDataPart>) => T, isEqual?: (left: T, right: T) => boolean): T;
@@ -0,0 +1,47 @@
1
+ 'use client';
2
+ import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore, } from 'react';
3
+ import { NOVO_IDLE_RUN_STATE } from './useNovoRun.js';
4
+ /** Concurrent-safe selector binding with selector/equality identity support. */
5
+ export function useNovoRunSelector(controller, selector, isEqual = Object.is) {
6
+ const committedSelection = useRef({
7
+ hasValue: false,
8
+ });
9
+ const idleState = NOVO_IDLE_RUN_STATE;
10
+ const [getSnapshot, getServerSnapshot] = useMemo(() => {
11
+ let hasMemo = false;
12
+ let memoizedState;
13
+ let memoizedSelection;
14
+ const select = (state) => {
15
+ if (!hasMemo) {
16
+ hasMemo = true;
17
+ memoizedState = state;
18
+ const nextSelection = selector(state);
19
+ const committed = committedSelection.current;
20
+ memoizedSelection =
21
+ committed.hasValue && isEqual(committed.value, nextSelection)
22
+ ? committed.value
23
+ : nextSelection;
24
+ return memoizedSelection;
25
+ }
26
+ if (Object.is(memoizedState, state))
27
+ return memoizedSelection;
28
+ const nextSelection = selector(state);
29
+ memoizedState = state;
30
+ if (isEqual(memoizedSelection, nextSelection))
31
+ return memoizedSelection;
32
+ memoizedSelection = nextSelection;
33
+ return memoizedSelection;
34
+ };
35
+ const serverSelection = select(idleState);
36
+ return [
37
+ () => select(controller?.getSnapshot() ?? idleState),
38
+ () => serverSelection,
39
+ ];
40
+ }, [controller, isEqual, selector]);
41
+ const subscribe = useCallback((listener) => controller?.subscribe(listener) ?? (() => { }), [controller]);
42
+ const selection = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
43
+ useEffect(() => {
44
+ committedSelection.current = { hasValue: true, value: selection };
45
+ }, [selection]);
46
+ return selection;
47
+ }
package/package.json CHANGED
@@ -1,15 +1,17 @@
1
1
  {
2
2
  "name": "@novoagents/react",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.2.0-alpha.0",
4
4
  "description": "Browser-safe React hooks for Novo Agents streams and interactions.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
7
+ "sideEffects": false,
7
8
  "main": "./dist/index.js",
8
9
  "types": "./dist/index.d.ts",
9
10
  "files": [
10
11
  "dist/**/*.js",
11
12
  "dist/**/*.d.ts",
12
- "README.md"
13
+ "README.md",
14
+ "CHANGELOG.md"
13
15
  ],
14
16
  "exports": {
15
17
  ".": {
@@ -21,10 +23,10 @@
21
23
  "access": "public"
22
24
  },
23
25
  "dependencies": {
24
- "novoagents": "0.3.0-alpha.51"
26
+ "novoagents": "0.4.0-alpha.0"
25
27
  },
26
28
  "peerDependencies": {
27
- "react": ">=18"
29
+ "react": "^18.0.0 || ^19.0.0"
28
30
  },
29
31
  "devDependencies": {
30
32
  "@types/react": "19.2.17",
@@ -37,7 +39,7 @@
37
39
  "vitest": "5.0.0-beta.6"
38
40
  },
39
41
  "scripts": {
40
- "build": "tsc -p tsconfig.json",
42
+ "build": "tsc -p tsconfig.json && node scripts/check-dist.mjs",
41
43
  "clean": "rm -rf dist",
42
44
  "test": "vitest run",
43
45
  "typecheck": "tsc -p tsconfig.json --noEmit"