@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 +11 -0
- package/README.md +59 -26
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/useNovoInteractions.d.ts +6 -45
- package/dist/useNovoInteractions.js +22 -63
- package/dist/useNovoRun.d.ts +18 -36
- package/dist/useNovoRun.js +84 -90
- package/dist/useNovoRunSelector.d.ts +3 -0
- package/dist/useNovoRunSelector.js +47 -0
- package/package.json +7 -5
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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
31
|
-
|
|
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 {
|
|
2
|
-
export {
|
|
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
|
-
|
|
2
|
-
export {
|
|
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
|
-
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
}
|
package/dist/useNovoRun.d.ts
CHANGED
|
@@ -1,41 +1,23 @@
|
|
|
1
|
-
import
|
|
2
|
-
export
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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 =
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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(
|
|
23
|
+
export declare function useNovoRun<TMetadata = unknown, TDataPart = unknown>(options: UseNovoRunOptions<TMetadata, TDataPart>): UseNovoRunResult<TMetadata, TDataPart>;
|
package/dist/useNovoRun.js
CHANGED
|
@@ -1,107 +1,101 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
8
|
-
let
|
|
9
|
-
|
|
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: () =>
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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(
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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 (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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.
|
|
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.
|
|
26
|
+
"novoagents": "0.4.0-alpha.0"
|
|
25
27
|
},
|
|
26
28
|
"peerDependencies": {
|
|
27
|
-
"react": "
|
|
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"
|