@unoverse-platform/studio 0.1.37 → 0.1.39
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/package.json
CHANGED
|
@@ -315,7 +315,9 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
|
|
|
315
315
|
if (!current) return;
|
|
316
316
|
// Width from the def; height stays AUTO — the canvas node hugs the rendered
|
|
317
317
|
// component's height (components flex width, own height).
|
|
318
|
-
|
|
318
|
+
// 768 = tablet, this view's own default stage width: a component copied to the
|
|
319
|
+
// canvas lands at the width it was just designed at, not narrower.
|
|
320
|
+
const size = (current as any).nodeSize ?? { width: 768 };
|
|
319
321
|
const node = {
|
|
320
322
|
id: `${current.name.toLowerCase()}1`,
|
|
321
323
|
type: "Component",
|
|
@@ -323,7 +325,11 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
|
|
|
323
325
|
data: {
|
|
324
326
|
label: current.name,
|
|
325
327
|
status: "ready",
|
|
326
|
-
config
|
|
328
|
+
// IDENTITY ONLY — config is what the AUTHOR wires, never a copy of the sample
|
|
329
|
+
// data. UIComponentNode layers props [schema defaults] < [executed] < [config], so
|
|
330
|
+
// anything written here outranks the live definition permanently: spreading the
|
|
331
|
+
// previews in froze them into the workflow at copy time.
|
|
332
|
+
config: { component: current.name },
|
|
327
333
|
},
|
|
328
334
|
style: { width: size.width },
|
|
329
335
|
draggable: true,
|
|
@@ -30,8 +30,10 @@ export function NativeAppHost({
|
|
|
30
30
|
userId: string;
|
|
31
31
|
conversationId: string;
|
|
32
32
|
/** The iframe app owns the ONLY live connection — it posts `unoverse:status`
|
|
33
|
-
* (appEngine) and this relays it, so the host's badge can tell the truth.
|
|
34
|
-
|
|
33
|
+
* (appEngine) and this relays it, so the host's badge can tell the truth.
|
|
34
|
+
* `refused` carries the server's terminal refusal text (register_session refused
|
|
35
|
+
* the session — public-entry gate, requires.role, stale token); undefined otherwise. */
|
|
36
|
+
onStatus?: (s: { ready: boolean; connected: boolean; refused?: string }) => void;
|
|
35
37
|
}) {
|
|
36
38
|
const [html, setHtml] = useState<string | null>(null);
|
|
37
39
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -44,7 +46,8 @@ export function NativeAppHost({
|
|
|
44
46
|
const onSize = (e: MessageEvent) => {
|
|
45
47
|
if (e.source !== frame.current?.contentWindow) return;
|
|
46
48
|
if (e.data?.type === "unoverse:size" && e.data.width) setAppWidth(String(e.data.width));
|
|
47
|
-
if (e.data?.type === "unoverse:status")
|
|
49
|
+
if (e.data?.type === "unoverse:status")
|
|
50
|
+
onStatus?.({ ready: !!e.data.ready, connected: !!e.data.connected, refused: typeof e.data.refused === "string" ? e.data.refused : undefined });
|
|
48
51
|
};
|
|
49
52
|
window.addEventListener("message", onSize);
|
|
50
53
|
return () => window.removeEventListener("message", onSize);
|
|
@@ -208,7 +208,7 @@ export function TemplatesView({
|
|
|
208
208
|
// connection and posts `unoverse:status` up; the in-page `conn` below is never
|
|
209
209
|
// opened in native mode, so a badge reading it said "Offline" while the app
|
|
210
210
|
// streamed (observed live). Native status wins whenever the iframe has reported.
|
|
211
|
-
const [nativeStatus, setNativeStatus] = useState<{ ready: boolean; connected: boolean } | null>(null);
|
|
211
|
+
const [nativeStatus, setNativeStatus] = useState<{ ready: boolean; connected: boolean; refused?: string } | null>(null);
|
|
212
212
|
useEffect(() => setNativeStatus(null), [selected, live]);
|
|
213
213
|
// LIVE output inspector — the last submitted app output, projected through the
|
|
214
214
|
// component's declared `outputs` (the same object the held tool call returns to
|
|
@@ -774,11 +774,13 @@ export function TemplatesView({
|
|
|
774
774
|
style={{ borderColor: theme.color["border.subtle"], color: theme.color["text.secondary"] }}
|
|
775
775
|
title="Live connection settings"
|
|
776
776
|
>
|
|
777
|
+
{/* Refused wins over connected: the transport is up, but the server has
|
|
778
|
+
terminally refused the session — "Connecting…" would be a lie. */}
|
|
777
779
|
<span
|
|
778
780
|
className="inline-block h-2 w-2 shrink-0 rounded-full"
|
|
779
|
-
style={{ background: (nativeStatus?.ready ?? conn.isReady) ? "#22c55e" : (nativeStatus?.connected ?? conn.isConnected) ? "#eab308" : "#9ca3af" }}
|
|
781
|
+
style={{ background: nativeStatus?.refused ? "#ef4444" : (nativeStatus?.ready ?? conn.isReady) ? "#22c55e" : (nativeStatus?.connected ?? conn.isConnected) ? "#eab308" : "#9ca3af" }}
|
|
780
782
|
/>
|
|
781
|
-
<span className="font-medium">{(nativeStatus?.ready ?? conn.isReady) ? "Connected" : (nativeStatus?.connected ?? conn.isConnected) ? "Connecting…" : "Offline"}</span>
|
|
783
|
+
<span className="font-medium">{nativeStatus?.refused ? "Refused" : (nativeStatus?.ready ?? conn.isReady) ? "Connected" : (nativeStatus?.connected ?? conn.isConnected) ? "Connecting…" : "Offline"}</span>
|
|
782
784
|
{binding?.workflow && <span className="font-mono text-[10px] opacity-60">· {binding.workflow}</span>}
|
|
783
785
|
<span className="opacity-60">▾</span>
|
|
784
786
|
</button>
|
|
@@ -791,10 +793,17 @@ export function TemplatesView({
|
|
|
791
793
|
>
|
|
792
794
|
<div className="mb-2 flex items-center justify-between">
|
|
793
795
|
<span className="text-[10px] font-semibold uppercase tracking-wider opacity-60">Live connection</span>
|
|
794
|
-
<span className="text-[10px]" style={{ color: (nativeStatus?.ready ?? conn.isReady) ? "#22c55e" : (nativeStatus?.connected ?? conn.isConnected) ? "#eab308" : theme.color["text.tertiary"] }}>
|
|
795
|
-
{(nativeStatus?.ready ?? conn.isReady) ? "ready" : (nativeStatus?.connected ?? conn.isConnected) ? "connecting…" : "offline"}
|
|
796
|
+
<span className="text-[10px]" style={{ color: nativeStatus?.refused ? "#ef4444" : (nativeStatus?.ready ?? conn.isReady) ? "#22c55e" : (nativeStatus?.connected ?? conn.isConnected) ? "#eab308" : theme.color["text.tertiary"] }}>
|
|
797
|
+
{nativeStatus?.refused ? "refused" : (nativeStatus?.ready ?? conn.isReady) ? "ready" : (nativeStatus?.connected ?? conn.isConnected) ? "connecting…" : "offline"}
|
|
796
798
|
</span>
|
|
797
799
|
</div>
|
|
800
|
+
{nativeStatus?.refused && (
|
|
801
|
+
/* The server's own refusal text, verbatim — it already names the fix
|
|
802
|
+
(sign in again, toggle the trigger public, …). */
|
|
803
|
+
<div className="mb-2 rounded border px-2 py-1.5 text-[11px] leading-snug" style={{ borderColor: "#ef4444", color: "#ef4444" }}>
|
|
804
|
+
{nativeStatus.refused}
|
|
805
|
+
</div>
|
|
806
|
+
)}
|
|
798
807
|
{/* Read-only: the app decides. Server is environment; Workflow + Trigger
|
|
799
808
|
come from the selected app's manifest binding (no manual entry). */}
|
|
800
809
|
{(
|
package/vendor/sdk/appEngine.tsx
CHANGED
|
@@ -55,6 +55,19 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
55
55
|
[serverUrl],
|
|
56
56
|
);
|
|
57
57
|
const [binding, setBinding] = useState<{ workflow?: string; trigger?: string; service?: string }>({});
|
|
58
|
+
// RENDER-TIME reset (React's derived-state-from-props pattern), not an effect: on an
|
|
59
|
+
// app switch the connection effect and a reset effect share one commit, and the
|
|
60
|
+
// connection runs first — so an effect-based reset still let one session open with the
|
|
61
|
+
// OLD app's trigger against the NEW app's conversation before correcting (observed
|
|
62
|
+
// live 2026-08-16, twice: first with no reset, then with the reset as an effect).
|
|
63
|
+
// Setting state during render makes React re-render BEFORE the commit, so the
|
|
64
|
+
// connection effect only ever sees either the old app intact or an empty binding —
|
|
65
|
+
// never the cross-app hybrid.
|
|
66
|
+
const [boundFor, setBoundFor] = useState(templateId);
|
|
67
|
+
if (boundFor !== templateId) {
|
|
68
|
+
setBoundFor(templateId);
|
|
69
|
+
setBinding({});
|
|
70
|
+
}
|
|
58
71
|
// STATE-OWNED WIDTH (rx data): the layout root's `appWidth` = the core surface's
|
|
59
72
|
// constant width; an occupied panel slot's `appWidth` adds to it (computeAppWidth).
|
|
60
73
|
// No declared width → the host keeps its own default. There is no other path.
|
|
@@ -81,6 +94,9 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
81
94
|
// The app owns which workflow it runs — read it from its manifest (§4b), not from config.
|
|
82
95
|
useEffect(() => {
|
|
83
96
|
let alive = true;
|
|
97
|
+
// The stale-binding reset lives at RENDER time (boundFor, above) — resetting here,
|
|
98
|
+
// in an effect, ran too late: the connection effect had already opened a session on
|
|
99
|
+
// the previous app's trigger within the same commit.
|
|
84
100
|
client
|
|
85
101
|
.connect()
|
|
86
102
|
.then(() => (client as any).client.readResource({ uri: `unoverse://apps/${templateId.toLowerCase()}` }))
|
|
@@ -173,8 +189,10 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
173
189
|
// reads "offline" forever while this session streams (observed live). Report the
|
|
174
190
|
// truth the same way size is reported; hosts that don't listen lose nothing.
|
|
175
191
|
useEffect(() => {
|
|
176
|
-
|
|
177
|
-
|
|
192
|
+
// `refused` carries the server's terminal refusal text (register_session isError) —
|
|
193
|
+
// the one state a "Connecting…" badge must not sit on forever.
|
|
194
|
+
postToEmbedHost({ type: "unoverse:status", ready: !!conn.isReady, connected: !!conn.isConnected, refused: conn.refusal ?? undefined });
|
|
195
|
+
}, [conn.isReady, conn.isConnected, conn.refusal]);
|
|
178
196
|
|
|
179
197
|
// VOICE (§2e-1): a `service: "voice"` app runs the native voice service — which now lives
|
|
180
198
|
// in the shared renderer (StreamedUnoverseTemplate), identical to Studio. The host only
|
|
@@ -45,6 +45,12 @@ export interface UnoverseSessionParams {
|
|
|
45
45
|
export interface UnoverseConnection {
|
|
46
46
|
isConnected: boolean;
|
|
47
47
|
isReady: boolean;
|
|
48
|
+
/** The server's refusal text when register_session answered isError (public-entry
|
|
49
|
+
* gate, requires.role, stale token) — null while unrefused. A refusal is TERMINAL
|
|
50
|
+
* for the attempt (the server has answered; retrying the same identity loops), so
|
|
51
|
+
* hosts must surface this instead of showing "connecting" forever. Cleared when a
|
|
52
|
+
* new connect sequence starts. */
|
|
53
|
+
refusal: string | null;
|
|
48
54
|
/** Composer target: optimistic user turn + REST workflow trigger. */
|
|
49
55
|
sendMessage: (text: string) => void;
|
|
50
56
|
/** Fire the bound workflow WITHOUT a user turn — the "load → run the workflow" ping
|
|
@@ -112,6 +118,7 @@ export function useUnoverseConnection(
|
|
|
112
118
|
|
|
113
119
|
const [isConnected, setIsConnected] = useState(false);
|
|
114
120
|
const [isReady, setIsReady] = useState(false);
|
|
121
|
+
const [refusal, setRefusal] = useState<string | null>(null);
|
|
115
122
|
const clientRef = useRef<Client | null>(null);
|
|
116
123
|
// First-seen component keys for this connection (repeat INIT → props merge).
|
|
117
124
|
const seenRef = useRef<Set<string>>(new Set());
|
|
@@ -128,62 +135,108 @@ export function useUnoverseConnection(
|
|
|
128
135
|
return;
|
|
129
136
|
}
|
|
130
137
|
let cancelled = false;
|
|
138
|
+
let attempt = 0;
|
|
139
|
+
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
140
|
+
// Kept ACROSS reconnects on purpose: after a re-bind the server may re-push
|
|
141
|
+
// components this session already placed, and a repeat INIT on a seen key merges
|
|
142
|
+
// props instead of re-placing (applyServerMessage) — the screen never jumps.
|
|
131
143
|
seenRef.current = new Set();
|
|
132
144
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
// the
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
145
|
+
/**
|
|
146
|
+
* SESSION-DEATH RECOVERY (the MCP spec's own rule): a session id the server no
|
|
147
|
+
* longer knows answers 404, and the client MUST start a NEW session rather than
|
|
148
|
+
* mourn the old one. Sessions are process memory server-side, so every restart —
|
|
149
|
+
* a dev edit, a prod deploy — orphans every open channel; before this, the error
|
|
150
|
+
* was logged once ("stream connect failed" / "client error") and the surface sat
|
|
151
|
+
* disconnected forever. Now ANY client error recycles the whole client and
|
|
152
|
+
* re-runs the connect sequence with backoff: register_session is idempotent, a
|
|
153
|
+
* re-bind emits SESSION_READY, and mount-reads re-hydrate state, so downstream
|
|
154
|
+
* already copes. Blunt on purpose — a transport error we fail to recognise as
|
|
155
|
+
* session death costs one spurious reconnect, while one we miss is a dead screen.
|
|
156
|
+
*/
|
|
157
|
+
const recycle = (dead: Client, why: unknown) => {
|
|
158
|
+
// Only the CURRENT client may trigger recovery: a dying client's teardown fires
|
|
159
|
+
// stray errors (observed: four AbortErrors in one restart) and each would
|
|
160
|
+
// otherwise schedule its own storm of reconnects.
|
|
161
|
+
if (cancelled || clientRef.current !== dead) return;
|
|
162
|
+
clientRef.current = null;
|
|
163
|
+
void dead.close().catch(() => {});
|
|
164
|
+
setIsConnected(false);
|
|
165
|
+
setIsReady(false);
|
|
166
|
+
const delay = Math.min(15_000, 500 * 2 ** Math.min(attempt++, 5));
|
|
167
|
+
console.warn(`[unoverse] session lost — reconnecting in ${delay}ms (attempt ${attempt})`, why);
|
|
168
|
+
retryTimer = setTimeout(() => void connect(), delay);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const connect = async () => {
|
|
172
|
+
if (cancelled) return;
|
|
173
|
+
// Declare the ELICITATION capability (form mode) — the native collection path
|
|
174
|
+
// (UNOVERSE_MCP_TEMPLATE_PROTOCOL §3.3): the server pauses inside an app tool
|
|
175
|
+
// call and asks THIS session for the user's structured input. The interactive
|
|
176
|
+
// component already on screen IS the form; its `submit` answers the elicitation.
|
|
177
|
+
const client = new Client(
|
|
178
|
+
{ name: "unoverse-channel", version: "0.0.1" },
|
|
179
|
+
{ capabilities: { elicitation: {} }, jsonSchemaValidator: new CfWorkerJsonSchemaValidator() },
|
|
180
|
+
);
|
|
181
|
+
client.setRequestHandler(ElicitRequestSchema, async (req) => {
|
|
182
|
+
// Pend until the user submits the component (the channel calls resolveElicitation
|
|
183
|
+
// from its submit branch). The branded UI renders the request — never a generic form.
|
|
184
|
+
// The accept content MUST match the requestedSchema (spec: flat primitives only) —
|
|
185
|
+
// project the submitted component state to the REQUESTED keys, or the SDK rejects
|
|
186
|
+
// the response (view-state like a `courses` array fails the primitive union).
|
|
187
|
+
const requested =
|
|
188
|
+
(req.params as unknown as { requestedSchema?: { properties?: Record<string, unknown> } }).requestedSchema?.properties ?? {};
|
|
189
|
+
return await new Promise((resolve) => {
|
|
190
|
+
pendingElicitRef.current = (state: Record<string, unknown>) => {
|
|
191
|
+
pendingElicitRef.current = null;
|
|
192
|
+
const content: Record<string, unknown> = {};
|
|
193
|
+
for (const k of Object.keys(requested)) if (state[k] !== undefined) content[k] = state[k];
|
|
194
|
+
resolve({ action: "accept", content } as never);
|
|
195
|
+
};
|
|
196
|
+
});
|
|
156
197
|
});
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
198
|
+
// The SDK fire-and-forgets the standalone GET SSE open on `initialized`; a failure
|
|
199
|
+
// lands ONLY here. This is also where a dead session surfaces mid-life (the SDK's
|
|
200
|
+
// own GET reconnect meets the restarted server's 404) — so it feeds recovery.
|
|
201
|
+
client.onerror = (e) => {
|
|
202
|
+
// TEARDOWN NOISE, not an error: closing a client aborts its SSE streams and the
|
|
203
|
+
// SDK reports every abort here. When the close was OURS (effect cleanup on an
|
|
204
|
+
// app switch, or a recycle that already replaced this client), a red error log
|
|
205
|
+
// reads as breakage — it made a clean app switch look like a failure (observed
|
|
206
|
+
// live 2026-08-16). Quiet log, no recovery; a live client's errors still recycle.
|
|
207
|
+
if (cancelled || clientRef.current !== client) {
|
|
208
|
+
// eslint-disable-next-line no-console
|
|
209
|
+
console.debug("[unoverse] client closed:", (e as Error)?.message ?? e);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
console.error("[unoverse] client error:", e);
|
|
213
|
+
recycle(client, e);
|
|
214
|
+
};
|
|
215
|
+
clientRef.current = client;
|
|
162
216
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
217
|
+
// The server pushes the live stream as `notifications/unoverse/event` over the
|
|
218
|
+
// GET SSE channel. Custom notification methods land on the fallback handler —
|
|
219
|
+
// route their `params` (one ServerMessage) into the store.
|
|
220
|
+
client.fallbackNotificationHandler = async (notification) => {
|
|
221
|
+
if (notification.method !== UNOVERSE_EVENT) return;
|
|
222
|
+
const msg = (notification.params ?? {}) as unknown as ServerMessage;
|
|
223
|
+
if ((msg as { type?: string }).type === "SESSION_READY") {
|
|
224
|
+
setIsReady(true);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// A manifest-declared analytics moment, resolved SERVER-side beside the lifecycle
|
|
228
|
+
// hook that shares it (componentLifecycle.ts). The SDK adds nothing and decides
|
|
229
|
+
// nothing here: it hands the event to the same sink every node-attached emit uses,
|
|
230
|
+
// and the host realm delivers or drops it. News, never instruction — it does not
|
|
231
|
+
// touch the store.
|
|
232
|
+
const evt = msg as unknown as { type?: string; event?: string; params?: Record<string, unknown> };
|
|
233
|
+
if (evt.type === "ANALYTICS_EVENT") {
|
|
234
|
+
if (typeof evt.event === "string" && evt.event) emitAnalytics(evt.event, evt.params ?? {});
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
applyServerMessage(store, msg, seenRef.current);
|
|
238
|
+
};
|
|
185
239
|
|
|
186
|
-
(async () => {
|
|
187
240
|
// Inject the bearer on every underlying request (fresh per call → refresh-safe).
|
|
188
241
|
const authFetch = getAccessToken
|
|
189
242
|
? async (url: string | URL, init?: RequestInit): Promise<Response> => {
|
|
@@ -199,11 +252,12 @@ export function useUnoverseConnection(
|
|
|
199
252
|
authFetch ? { fetch: authFetch } : undefined,
|
|
200
253
|
);
|
|
201
254
|
await client.connect(transport);
|
|
202
|
-
if (cancelled) {
|
|
255
|
+
if (cancelled || clientRef.current !== client) {
|
|
203
256
|
await client.close().catch(() => {});
|
|
204
257
|
return;
|
|
205
258
|
}
|
|
206
259
|
setIsConnected(true);
|
|
260
|
+
setRefusal(null); // a fresh attempt owns a fresh verdict
|
|
207
261
|
// Bind this stream to the session — the server then pushes the live component
|
|
208
262
|
// stream and an immediate SESSION_READY.
|
|
209
263
|
const bind = await client.callTool({
|
|
@@ -211,21 +265,35 @@ export function useUnoverseConnection(
|
|
|
211
265
|
arguments: { userId, conversationId, workflowId, targetTriggerNode, chatId },
|
|
212
266
|
});
|
|
213
267
|
// A refusal (public-entry gate, requires.role, …) is an isError tool RESULT,
|
|
214
|
-
// not a thrown error — surface it
|
|
215
|
-
//
|
|
268
|
+
// not a thrown error — surface it and STOP, never retry: the server has
|
|
269
|
+
// answered, and re-asking with the same identity is a polite way to loop
|
|
270
|
+
// (the server logs the same refusal as REFUSED:). The reason is kept as state,
|
|
271
|
+
// not just logged: a console.error alone left the host chip on "Connecting…"
|
|
272
|
+
// forever with the cause visible to nobody (observed live 2026-08-16).
|
|
216
273
|
if ((bind as { isError?: boolean }).isError) {
|
|
217
274
|
const c = (bind as { content?: Array<{ type?: string; text?: string }> }).content;
|
|
218
|
-
|
|
275
|
+
const reason = c?.[0]?.text ?? "unauthorized";
|
|
276
|
+
console.error("[unoverse] session refused:", reason);
|
|
277
|
+
setRefusal(reason);
|
|
278
|
+
return;
|
|
219
279
|
}
|
|
280
|
+
// A completed re-bind is health — the next death starts its backoff fresh.
|
|
281
|
+
attempt = 0;
|
|
220
282
|
} catch (e) {
|
|
221
|
-
if (
|
|
283
|
+
if (cancelled) return;
|
|
284
|
+
console.error("[unoverse] stream connect failed", e);
|
|
285
|
+
recycle(client, e);
|
|
222
286
|
}
|
|
223
|
-
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
void connect();
|
|
224
290
|
|
|
225
291
|
return () => {
|
|
226
292
|
cancelled = true;
|
|
293
|
+
if (retryTimer !== null) clearTimeout(retryTimer);
|
|
227
294
|
setIsConnected(false);
|
|
228
295
|
setIsReady(false);
|
|
296
|
+
setRefusal(null);
|
|
229
297
|
void clientRef.current?.close().catch(() => {});
|
|
230
298
|
clientRef.current = null;
|
|
231
299
|
};
|
|
@@ -357,5 +425,5 @@ export function useUnoverseConnection(
|
|
|
357
425
|
return true;
|
|
358
426
|
}, []);
|
|
359
427
|
|
|
360
|
-
return { isConnected, isReady, sendMessage, trigger, sendAction, resolveElicitation, activeTemplate };
|
|
428
|
+
return { isConnected, isReady, refusal, sendMessage, trigger, sendAction, resolveElicitation, activeTemplate };
|
|
361
429
|
}
|