@novoagents/react 0.3.0-alpha.34 → 0.3.0-alpha.36
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 +20 -0
- package/dist/useNovoRun.js +35 -20
- package/dist/useNovoRunSelector.js +58 -38
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0-alpha.36 - 2026-09-01
|
|
4
|
+
|
|
5
|
+
Companion release for `novoagents` 0.5.0-alpha.32.
|
|
6
|
+
|
|
7
|
+
- Controller ownership now rides the external-store snapshot (run key and
|
|
8
|
+
controller travel with the subscribed state), so an abandoned concurrent
|
|
9
|
+
render can no longer strand ownership in a render-written ref. Selector
|
|
10
|
+
readers isolate speculative renders while preserving equal object identity
|
|
11
|
+
across inline selector changes: the committed selection lives in a
|
|
12
|
+
`useState`-held cell that is still read during render, by design, mirroring
|
|
13
|
+
React's own `useSyncExternalStoreWithSelector` shim. Behavior is unchanged
|
|
14
|
+
across React 18 and 19, and the package now passes the canonical hooks lint
|
|
15
|
+
rules.
|
|
16
|
+
|
|
17
|
+
## 0.3.0-alpha.35 - 2026-08-26
|
|
18
|
+
|
|
19
|
+
- Companion release for `novoagents` 0.5.0-alpha.31. GitHub connection and
|
|
20
|
+
agent repository grants now carry exact immutable repository identities
|
|
21
|
+
end to end; an explicit empty agent selection grants no repositories.
|
|
22
|
+
|
|
3
23
|
## 0.3.0-alpha.34 - 2026-08-26
|
|
4
24
|
|
|
5
25
|
- Companion release for `novoagents` 0.5.0-alpha.30. The React runtime is
|
package/dist/useNovoRun.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
|
2
|
+
import { useEffect, useMemo, useRef, useState, useSyncExternalStore, } from 'react';
|
|
3
3
|
export const NOVO_IDLE_RUN_STATE = Object.freeze({
|
|
4
4
|
messages: Object.freeze([]),
|
|
5
5
|
status: 'queued',
|
|
@@ -22,7 +22,17 @@ function createControllerSlot() {
|
|
|
22
22
|
const listeners = new Set();
|
|
23
23
|
let controller;
|
|
24
24
|
let unsubscribe;
|
|
25
|
+
let snapshot = {
|
|
26
|
+
controller: undefined,
|
|
27
|
+
runKey: UNOWNED_RUN_KEY,
|
|
28
|
+
state: NOVO_IDLE_RUN_STATE,
|
|
29
|
+
};
|
|
25
30
|
const publish = () => {
|
|
31
|
+
snapshot = {
|
|
32
|
+
...snapshot,
|
|
33
|
+
state: controller?.getSnapshot() ??
|
|
34
|
+
NOVO_IDLE_RUN_STATE,
|
|
35
|
+
};
|
|
26
36
|
for (const listener of listeners)
|
|
27
37
|
listener();
|
|
28
38
|
};
|
|
@@ -31,15 +41,20 @@ function createControllerSlot() {
|
|
|
31
41
|
listeners.add(listener);
|
|
32
42
|
return () => listeners.delete(listener);
|
|
33
43
|
},
|
|
34
|
-
getSnapshot: () =>
|
|
35
|
-
NOVO_IDLE_RUN_STATE,
|
|
44
|
+
getSnapshot: () => snapshot,
|
|
36
45
|
getController: () => controller,
|
|
37
|
-
setController(next) {
|
|
38
|
-
if (controller === next)
|
|
46
|
+
setController(next, runKey = UNOWNED_RUN_KEY) {
|
|
47
|
+
if (controller === next && snapshot.runKey === runKey)
|
|
39
48
|
return;
|
|
40
49
|
unsubscribe?.();
|
|
41
50
|
controller = next;
|
|
42
51
|
unsubscribe = next?.subscribe(publish);
|
|
52
|
+
snapshot = {
|
|
53
|
+
controller: next,
|
|
54
|
+
runKey,
|
|
55
|
+
state: next?.getSnapshot() ??
|
|
56
|
+
NOVO_IDLE_RUN_STATE,
|
|
57
|
+
};
|
|
43
58
|
publish();
|
|
44
59
|
},
|
|
45
60
|
};
|
|
@@ -51,11 +66,10 @@ function createControllerSlot() {
|
|
|
51
66
|
*/
|
|
52
67
|
export function useNovoRun(options) {
|
|
53
68
|
const factoryRef = useRef(options.createController);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const slot =
|
|
58
|
-
const ownedRunKeyRef = useRef(UNOWNED_RUN_KEY);
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
factoryRef.current = options.createController;
|
|
71
|
+
}, [options.createController]);
|
|
72
|
+
const [slot] = useState(() => createControllerSlot());
|
|
59
73
|
// Normalize before any dependency array: `enabled: undefined <-> true` and
|
|
60
74
|
// `runKey: undefined <-> null` toggles are identity changes and must not
|
|
61
75
|
// dispose and recreate a live controller.
|
|
@@ -67,14 +81,10 @@ export function useNovoRun(options) {
|
|
|
67
81
|
return;
|
|
68
82
|
}
|
|
69
83
|
const controller = factoryRef.current();
|
|
70
|
-
|
|
71
|
-
slot.setController(controller);
|
|
84
|
+
slot.setController(controller, runKey);
|
|
72
85
|
return () => {
|
|
73
86
|
controller.stop();
|
|
74
87
|
controller.dispose();
|
|
75
|
-
if (ownedRunKeyRef.current === runKey) {
|
|
76
|
-
ownedRunKeyRef.current = UNOWNED_RUN_KEY;
|
|
77
|
-
}
|
|
78
88
|
if (slot.getController() === controller)
|
|
79
89
|
slot.setController(undefined);
|
|
80
90
|
};
|
|
@@ -82,16 +92,16 @@ export function useNovoRun(options) {
|
|
|
82
92
|
useEffect(() => {
|
|
83
93
|
if (enabled &&
|
|
84
94
|
options.autoStart !== false &&
|
|
85
|
-
|
|
95
|
+
slot.getSnapshot().runKey === runKey) {
|
|
86
96
|
void slot.getController()?.start();
|
|
87
97
|
}
|
|
88
98
|
}, [options.autoStart, enabled, runKey, slot]);
|
|
89
|
-
const
|
|
90
|
-
const ownsRenderedKey = enabled &&
|
|
99
|
+
const observed = useSyncExternalStore(slot.subscribe, slot.getSnapshot, () => IDLE_SLOT_SNAPSHOT);
|
|
100
|
+
const ownsRenderedKey = enabled && observed.runKey === runKey;
|
|
91
101
|
const state = ownsRenderedKey
|
|
92
|
-
?
|
|
102
|
+
? observed.state
|
|
93
103
|
: NOVO_IDLE_RUN_STATE;
|
|
94
|
-
const controller = ownsRenderedKey ?
|
|
104
|
+
const controller = ownsRenderedKey ? observed.controller : undefined;
|
|
95
105
|
return useMemo(() => ({
|
|
96
106
|
...state,
|
|
97
107
|
controller,
|
|
@@ -105,3 +115,8 @@ export function useNovoRun(options) {
|
|
|
105
115
|
}), [controller, state]);
|
|
106
116
|
}
|
|
107
117
|
const UNOWNED_RUN_KEY = Symbol('unowned Novo run key');
|
|
118
|
+
const IDLE_SLOT_SNAPSHOT = Object.freeze({
|
|
119
|
+
controller: undefined,
|
|
120
|
+
runKey: UNOWNED_RUN_KEY,
|
|
121
|
+
state: NOVO_IDLE_RUN_STATE,
|
|
122
|
+
});
|
|
@@ -1,47 +1,67 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { useCallback, useEffect, useMemo,
|
|
2
|
+
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, } from 'react';
|
|
3
3
|
import { NOVO_IDLE_RUN_STATE } from './useNovoRun.js';
|
|
4
4
|
/** Concurrent-safe selector binding with selector/equality identity support. */
|
|
5
5
|
export function useNovoRunSelector(controller, selector, isEqual = Object.is) {
|
|
6
|
-
const committedSelection =
|
|
7
|
-
|
|
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]);
|
|
6
|
+
const [committedSelection] = useState(() => createCommittedSelection());
|
|
7
|
+
const [getSnapshot, getServerSnapshot] = useMemo(() => createSelectionReader(controller, selector, isEqual, NOVO_IDLE_RUN_STATE, committedSelection), [committedSelection, controller, isEqual, selector]);
|
|
41
8
|
const subscribe = useCallback((listener) => controller?.subscribe(listener) ?? (() => { }), [controller]);
|
|
42
9
|
const selection = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
43
10
|
useEffect(() => {
|
|
44
|
-
committedSelection.
|
|
45
|
-
}, [selection]);
|
|
11
|
+
committedSelection.write(selection);
|
|
12
|
+
}, [committedSelection, selection]);
|
|
46
13
|
return selection;
|
|
47
14
|
}
|
|
15
|
+
// Deliberately a mutable cell read during render (via getSnapshot) and
|
|
16
|
+
// written from the commit effect, mirroring React's own
|
|
17
|
+
// useSyncExternalStoreWithSelector shim. The hooks lint layer cannot see a
|
|
18
|
+
// closure-held cell; do not "fix" this back into a ref, which it would flag.
|
|
19
|
+
function createCommittedSelection() {
|
|
20
|
+
let committed = {
|
|
21
|
+
hasValue: false,
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
read: () => committed,
|
|
25
|
+
write: (value) => {
|
|
26
|
+
committed = { hasValue: true, value };
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function createSelectionReader(controller, selector, isEqual, idleState, committedSelection) {
|
|
31
|
+
let hasValue = false;
|
|
32
|
+
let value;
|
|
33
|
+
let memoizedState;
|
|
34
|
+
let hasServerValue = false;
|
|
35
|
+
let serverValue;
|
|
36
|
+
const select = (state) => {
|
|
37
|
+
if (Object.is(memoizedState, state) && hasValue)
|
|
38
|
+
return value;
|
|
39
|
+
const next = selector(state);
|
|
40
|
+
memoizedState = state;
|
|
41
|
+
if (!hasValue) {
|
|
42
|
+
hasValue = true;
|
|
43
|
+
const committed = committedSelection.read();
|
|
44
|
+
value =
|
|
45
|
+
committed.hasValue && isEqual(committed.value, next)
|
|
46
|
+
? committed.value
|
|
47
|
+
: next;
|
|
48
|
+
}
|
|
49
|
+
else if (!isEqual(value, next)) {
|
|
50
|
+
value = next;
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
};
|
|
54
|
+
const selectServer = () => {
|
|
55
|
+
if (hasServerValue)
|
|
56
|
+
return serverValue;
|
|
57
|
+
const next = selector(idleState);
|
|
58
|
+
const committed = committedSelection.read();
|
|
59
|
+
serverValue =
|
|
60
|
+
committed.hasValue && isEqual(committed.value, next)
|
|
61
|
+
? committed.value
|
|
62
|
+
: next;
|
|
63
|
+
hasServerValue = true;
|
|
64
|
+
return serverValue;
|
|
65
|
+
};
|
|
66
|
+
return [() => select(controller?.getSnapshot() ?? idleState), selectServer];
|
|
67
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novoagents/react",
|
|
3
|
-
"version": "0.3.0-alpha.
|
|
3
|
+
"version": "0.3.0-alpha.36",
|
|
4
4
|
"description": "Browser-safe React hooks for Novo Agents streams and interactions.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -23,14 +23,14 @@
|
|
|
23
23
|
"access": "public"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
|
-
"novoagents": "0.5.0-alpha.
|
|
26
|
+
"novoagents": "0.5.0-alpha.32",
|
|
27
27
|
"react": "^18.0.0 || ^19.0.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@types/react": "19.2.18",
|
|
31
31
|
"@types/react-dom": "19.2.4",
|
|
32
32
|
"happy-dom": "20.11.6",
|
|
33
|
-
"novoagents": "0.5.0-alpha.
|
|
33
|
+
"novoagents": "0.5.0-alpha.32",
|
|
34
34
|
"react": "19.2.8",
|
|
35
35
|
"react-dom": "19.2.8",
|
|
36
36
|
"typescript": "6.0.3",
|