@economic/agents-react 1.5.0 → 1.6.1

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
@@ -123,22 +123,27 @@ function Chat({ userId, authToken }: { userId: string; authToken: string }) {
123
123
  | Field | Type | Description |
124
124
  | ----------------- | ----------------------- | --------------------------------------------------------------------------- |
125
125
  | `status` | `AgentConnectionStatus` | Connection status of the assistant, or of the active chat once one is open. |
126
- | `chats` | `ChatSummary[]` | The user's chats, most recently updated first. |
126
+ | `chats` | `ChatSummary[]` | Started chats plus the active empty draft, most recently updated first. |
127
127
  | `currentChatName` | `string \| undefined` | Id of the active chat, if any. |
128
- | `assistant` | `AssistantActions` | `getChats`, `createChat`, `openChat`, `deleteChat`. |
128
+ | `assistant` | `AssistantActions` | `getChats`, `createChat`, `openChat`, `deleteChat`, `renameChat`. |
129
129
  | `chat` | `useChat` result | The active chat — `chat.chat` is the message interface (see below). |
130
130
 
131
131
  `assistant` actions:
132
132
 
133
- | Action | Signature | Description |
134
- | -------------------- | ----------------------------------- | ------------------------------------------------ |
135
- | `createChat()` | `() => Promise<string>` | Creates a chat, makes it active, returns its id. |
136
- | `openChat(chatId)` | `(chatId: string) => void` | Switches the active chat. |
137
- | `getChats()` | `() => Promise<ChatSummary[]>` | Refreshes and returns the chat list. |
138
- | `deleteChat(chatId)` | `(chatId: string) => Promise<void>` | Deletes a chat and clears the active selection. |
133
+ | Action | Signature | Description |
134
+ | -------------------- | -------------------------------------------------- | ------------------------------------------------ |
135
+ | `createChat()` | `() => Promise<string>` | Creates a chat, makes it active, returns its id. |
136
+ | `openChat(chatId)` | `(chatId: string) => void` | Switches the active chat. |
137
+ | `getChats()` | `() => Promise<ChatSummary[]>` | Refreshes and returns the chat list. |
138
+ | `deleteChat(chatId)` | `(chatId: string) => Promise<void>` | Deletes a chat and clears the active selection. |
139
+ | `renameChat(chatId)` | `(chatId: string, title: string) => Promise<void>` | Renames a registered chat. |
139
140
 
140
141
  > Titles and summaries are generated server-side a few seconds after the first message, so refetch with `getChats()` shortly after sending the opening message if you want the new title to appear.
141
142
 
143
+ The active empty chat is represented locally until its first turn registers it on the server.
144
+
145
+ After the active draft's first turn finishes, `useAssistant` refreshes the chat list automatically so the temporary entry is replaced by its persisted title and summary.
146
+
142
147
  ---
143
148
 
144
149
  ## Options
@@ -174,6 +179,7 @@ type AgentConnectionState = {
174
179
 
175
180
  type ChatSummary = {
176
181
  name: string;
182
+ isTemporary?: boolean; // true for the active chat before its first turn is registered
177
183
  title?: string;
178
184
  summary?: string;
179
185
  created_at: number;
package/dist/index.d.mts CHANGED
@@ -10,7 +10,8 @@ type Chat = ReturnType<typeof useAgentChat>;
10
10
  type AgentConnectionStatus = "connecting" | "connected" | "disconnected" | "unauthorized" | "error";
11
11
  type AgentConnectionType = "agent" | "chat" | "assistant";
12
12
  type ChatSummary = {
13
- name: string;
13
+ name: string; /** True when this entry only represents the active, unregistered chat facet. */
14
+ isTemporary?: boolean;
14
15
  title?: string;
15
16
  summary?: string;
16
17
  title_set_by_user?: number;
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
2
2
  import { useAgent as useAgent$1 } from "agents/react";
3
3
  import { getToolApproval, getToolCallId, getToolPartState, useAgentChat } from "@cloudflare/ai-chat/react";
4
4
  //#region src/util.ts
@@ -11,6 +11,16 @@ function getTokenArgs(authToken) {
11
11
  }
12
12
  //#endregion
13
13
  //#region src/useAgent.ts
14
+ function getAgentConnectionStatus(state, connectionId) {
15
+ return state.connectionId === connectionId ? state.status : "connecting";
16
+ }
17
+ function getNextAgentConnectionState(state, connectionId, status) {
18
+ if (status === "disconnected" && state.connectionId === connectionId && state.status === "error") return state;
19
+ return {
20
+ connectionId,
21
+ status
22
+ };
23
+ }
14
24
  function getCloseReason(code) {
15
25
  return {
16
26
  1e3: "normal-closure",
@@ -29,46 +39,168 @@ function getCloseReason(code) {
29
39
  1015: "tls-handshake-failure"
30
40
  }[code] ?? "unknown";
31
41
  }
42
+ function getDefaultHost() {
43
+ return globalThis.window?.location?.host || "dummy-domain.com";
44
+ }
45
+ function normalizeAgentName(name) {
46
+ if (name === name.toUpperCase() && name !== name.toLowerCase()) return name.toLowerCase().replace(/_/g, "-");
47
+ const kebabified = name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
48
+ return (kebabified.startsWith("-") ? kebabified.slice(1) : kebabified).replace(/_/g, "-").replace(/-$/, "");
49
+ }
50
+ function getSubPath(sub) {
51
+ if (!sub?.length) return void 0;
52
+ return sub.flatMap(({ agent, name }) => [
53
+ "sub",
54
+ normalizeAgentName(agent),
55
+ encodeURIComponent(name)
56
+ ]).join("/");
57
+ }
58
+ function getAgentConnectionTarget(options) {
59
+ const effectiveName = options.actorId ? `${options.actorId}:${options.name}` : options.name;
60
+ return {
61
+ enabled: options.enabled ?? true,
62
+ host: options.host || getDefaultHost(),
63
+ agentName: normalizeAgentName(options.agentName),
64
+ name: effectiveName || "default",
65
+ subPath: getSubPath(options.sub),
66
+ authToken: options.authToken || void 0
67
+ };
68
+ }
69
+ function getAgentSocketTarget(agent) {
70
+ const options = agent.partySocketOptions;
71
+ if (typeof options?.host !== "string" || typeof options.party !== "string" || typeof options.room !== "string") return;
72
+ const protocols = options.protocols;
73
+ const authToken = Array.isArray(protocols) && protocols[0] === "bearer" && typeof protocols[1] === "string" ? protocols[1] : void 0;
74
+ return {
75
+ host: options.host,
76
+ agentName: options.party,
77
+ name: options.room,
78
+ subPath: typeof options.path === "string" && options.path ? options.path : void 0,
79
+ authToken
80
+ };
81
+ }
82
+ function isSameSocketTarget(a, b) {
83
+ return a.host === b.host && a.agentName === b.agentName && a.name === b.name && a.subPath === b.subPath && a.authToken === b.authToken;
84
+ }
85
+ function isSameConnectionTarget(a, b) {
86
+ return a.enabled === b.enabled && isSameSocketTarget(a, b);
87
+ }
88
+ function getNextAgentConnection(connection, target) {
89
+ return isSameConnectionTarget(connection.target, target) ? connection : {
90
+ id: connection.id + 1,
91
+ target
92
+ };
93
+ }
94
+ function isCurrentAgentSocketTarget(eventConnection, connection) {
95
+ if (!connection.target.enabled || !eventConnection) return false;
96
+ const socketTarget = getAgentSocketTarget(eventConnection);
97
+ return socketTarget !== void 0 && isSameSocketTarget(socketTarget, connection.target);
98
+ }
32
99
  function useAgent(options) {
33
100
  const { enabled, host, agentName, actorId, name, sub, authToken, onError, onOpen, onClose, onMessage } = options;
34
- const [type, setType] = useState(void 0);
35
- const [status, setStatus] = useState("connecting");
101
+ const target = getAgentConnectionTarget(options);
102
+ const [connection, setConnection] = useState({
103
+ id: 0,
104
+ target
105
+ });
106
+ const nextConnection = getNextAgentConnection(connection, target);
107
+ if (nextConnection !== connection) setConnection(nextConnection);
108
+ const connectionId = nextConnection.id;
109
+ const [connectionType, setConnectionType] = useState();
110
+ const type = connectionType?.connectionId === connectionId ? connectionType.type : void 0;
111
+ const [connectionStatus, setConnectionStatus] = useState({
112
+ connectionId,
113
+ status: "connecting"
114
+ });
115
+ const status = getAgentConnectionStatus(connectionStatus, connectionId);
116
+ const committedConnection = useRef({
117
+ connection: nextConnection,
118
+ callbacks: {
119
+ onOpen,
120
+ onClose,
121
+ onError,
122
+ onMessage
123
+ }
124
+ });
125
+ const setStatus = useCallback((nextStatus) => {
126
+ const committedConnectionId = committedConnection.current.connection.id;
127
+ setConnectionStatus((current) => getNextAgentConnectionState(current, committedConnectionId, nextStatus));
128
+ }, []);
129
+ const setType = useCallback((nextType) => {
130
+ setConnectionType({
131
+ connectionId: committedConnection.current.connection.id,
132
+ type: nextType
133
+ });
134
+ }, []);
135
+ const isCurrentConnectionEvent = useCallback((event) => {
136
+ const committed = committedConnection.current;
137
+ return isCurrentAgentSocketTarget(event.target, committed.connection);
138
+ }, []);
139
+ const handleOpen = useCallback((event) => {
140
+ if (!isCurrentConnectionEvent(event)) return;
141
+ committedConnection.current.callbacks.onOpen?.(event);
142
+ }, [isCurrentConnectionEvent]);
143
+ const handleClose = useCallback((event) => {
144
+ if (!isCurrentConnectionEvent(event)) return;
145
+ if (event.code >= 3e3) setStatus("unauthorized");
146
+ else {
147
+ setStatus("disconnected");
148
+ if (!event.wasClean) console.warn("[useAgent] WebSocket closed unexpectedly", {
149
+ code: event.code,
150
+ reason: event.reason,
151
+ closeReason: getCloseReason(event.code),
152
+ wasClean: event.wasClean
153
+ });
154
+ }
155
+ committedConnection.current.callbacks.onClose?.(event);
156
+ }, [isCurrentConnectionEvent, setStatus]);
157
+ const handleMessage = useCallback((event) => {
158
+ if (!isCurrentConnectionEvent(event)) return;
159
+ const msg = JSON.parse(event.data);
160
+ if (msg.type === "connection_status") {
161
+ if (msg.status === "unauthorized") event.target.close(3e3);
162
+ setStatus(msg.status);
163
+ } else if (msg.type === "agent_type") setType(msg.agentType);
164
+ committedConnection.current.callbacks.onMessage?.(event);
165
+ }, [
166
+ isCurrentConnectionEvent,
167
+ setStatus,
168
+ setType
169
+ ]);
170
+ const handleError = useCallback((event) => {
171
+ if (!isCurrentConnectionEvent(event)) return;
172
+ setStatus("error");
173
+ committedConnection.current.callbacks.onError?.(event);
174
+ }, [isCurrentConnectionEvent, setStatus]);
36
175
  const agent = useAgent$1({
37
176
  enabled,
38
177
  host,
39
178
  agent: agentName,
40
179
  name: actorId ? `${actorId}:${name}` : name,
41
180
  sub,
42
- onOpen: (event) => {
43
- onOpen?.(event);
44
- },
45
- onClose: (event) => {
46
- if (event.code >= 3e3) setStatus("unauthorized");
47
- else {
48
- setStatus("disconnected");
49
- if (!event.wasClean) console.warn("[useAgent] WebSocket closed unexpectedly", {
50
- code: event.code,
51
- reason: event.reason,
52
- closeReason: getCloseReason(event.code),
53
- wasClean: event.wasClean
54
- });
55
- }
56
- onClose?.(event);
57
- },
58
- onMessage: (event) => {
59
- const msg = JSON.parse(event.data);
60
- if (msg.type === "connection_status") {
61
- if (msg.status === "unauthorized") agent.close(3e3);
62
- setStatus(msg.status);
63
- } else if (msg.type === "agent_type") setType(msg.agentType);
64
- onMessage?.(event);
65
- },
66
- onError: (event) => {
67
- setStatus("error");
68
- onError?.(event);
69
- },
181
+ onOpen: handleOpen,
182
+ onClose: handleClose,
183
+ onMessage: handleMessage,
184
+ onError: handleError,
70
185
  ...getTokenArgs(authToken)
71
186
  });
187
+ useLayoutEffect(() => {
188
+ committedConnection.current = {
189
+ connection: nextConnection,
190
+ callbacks: {
191
+ onOpen,
192
+ onClose,
193
+ onError,
194
+ onMessage
195
+ }
196
+ };
197
+ }, [
198
+ nextConnection,
199
+ onOpen,
200
+ onClose,
201
+ onError,
202
+ onMessage
203
+ ]);
72
204
  return {
73
205
  agent,
74
206
  status,
@@ -126,10 +258,25 @@ function useChat(options) {
126
258
  }
127
259
  //#endregion
128
260
  //#region src/useAssistant.ts
261
+ function getAssistantConnectionStatus(currentChatName, isChatEnabled, assistantStatus, chatStatus) {
262
+ if (currentChatName === void 0) return assistantStatus;
263
+ if (!isChatEnabled) return assistantStatus === "connected" ? "connecting" : assistantStatus;
264
+ return chatStatus;
265
+ }
129
266
  function useAssistant(options) {
130
267
  const { toolContext, welcomeMessage, initialChatId, ...agentOptions } = options;
131
268
  const [chatId, setChatId] = useState(initialChatId);
132
269
  const [chats, setChats] = useState([]);
270
+ const [draftChat, setDraftChat] = useState(() => {
271
+ if (!initialChatId) return void 0;
272
+ const now = Date.now();
273
+ return {
274
+ name: initialChatId,
275
+ isTemporary: true,
276
+ created_at: now,
277
+ updated_at: now
278
+ };
279
+ });
133
280
  const [subAgentName, setSubAgentName] = useState(void 0);
134
281
  const { agent: assistant, status: assistantStatus } = useAgent({
135
282
  ...agentOptions,
@@ -148,8 +295,29 @@ function useAssistant(options) {
148
295
  return c;
149
296
  });
150
297
  setChats(fetched);
298
+ setDraftChat((current) => {
299
+ if (current && fetched.some((chat) => chat.name === current.name)) return;
300
+ return current;
301
+ });
151
302
  return fetched;
152
303
  }, [assistant]);
304
+ const refreshChatsRef = useRef(refreshChats);
305
+ refreshChatsRef.current = refreshChats;
306
+ const draftRefreshTimeouts = useRef(/* @__PURE__ */ new Map());
307
+ const scheduleDraftRefresh = useCallback((draftId) => {
308
+ const existingTimeout = draftRefreshTimeouts.current.get(draftId);
309
+ if (existingTimeout) clearTimeout(existingTimeout);
310
+ const timeout = setTimeout(() => {
311
+ draftRefreshTimeouts.current.delete(draftId);
312
+ refreshChatsRef.current().catch(console.error);
313
+ }, 3e3);
314
+ draftRefreshTimeouts.current.set(draftId, timeout);
315
+ }, []);
316
+ useEffect(() => {
317
+ return () => {
318
+ for (const timeout of draftRefreshTimeouts.current.values()) clearTimeout(timeout);
319
+ };
320
+ }, []);
153
321
  useEffect(() => {
154
322
  if (assistantStatus === "connected") refreshChats().catch(console.error);
155
323
  }, [assistantStatus, refreshChats]);
@@ -157,17 +325,26 @@ function useAssistant(options) {
157
325
  return refreshChats();
158
326
  };
159
327
  const createChat = async () => {
328
+ if (draftChat && draftChat.name === chatId) scheduleDraftRefresh(draftChat.name);
160
329
  const newChatId = await assistant.call("createChat");
330
+ const now = Date.now();
161
331
  setChatId(newChatId);
162
- await refreshChats();
332
+ setDraftChat({
333
+ name: newChatId,
334
+ isTemporary: true,
335
+ created_at: now,
336
+ updated_at: now
337
+ });
163
338
  return newChatId;
164
339
  };
165
- const openChat = (chatId) => {
166
- setChatId(chatId);
340
+ const openChat = (nextChatId) => {
341
+ if (draftChat && draftChat.name === chatId) scheduleDraftRefresh(draftChat.name);
342
+ setChatId(nextChatId);
167
343
  };
168
344
  const deleteChat = async (chatId) => {
169
345
  await assistant.call("deleteChat", [chatId]);
170
346
  setChatId(void 0);
347
+ setDraftChat((current) => current?.name === chatId ? void 0 : current);
171
348
  await refreshChats();
172
349
  };
173
350
  const renameChat = async (chatId, title) => {
@@ -175,7 +352,6 @@ function useAssistant(options) {
175
352
  await refreshChats();
176
353
  };
177
354
  const isChatEnabled = !!chatId && !!subAgentName;
178
- const isChatReady = isChatEnabled && assistantStatus === "connected";
179
355
  const chat = useChat({
180
356
  ...agentOptions,
181
357
  enabled: isChatEnabled,
@@ -183,14 +359,27 @@ function useAssistant(options) {
183
359
  agent: subAgentName,
184
360
  name: chatId
185
361
  }] : void 0,
186
- getInitialMessages: isChatReady ? void 0 : null,
187
362
  toolContext,
188
363
  welcomeMessage
189
364
  });
365
+ const previousChatStatus = useRef(chat.chat.status);
366
+ useEffect(() => {
367
+ const wasRunning = previousChatStatus.current === "submitted" || previousChatStatus.current === "streaming";
368
+ const hasFinished = chat.chat.status === "ready" || chat.chat.status === "error";
369
+ previousChatStatus.current = chat.chat.status;
370
+ if (!draftChat || draftChat.name !== chatId || !wasRunning || !hasFinished) return;
371
+ scheduleDraftRefresh(draftChat.name);
372
+ }, [
373
+ chat.chat.status,
374
+ chatId,
375
+ draftChat,
376
+ scheduleDraftRefresh
377
+ ]);
378
+ const status = getAssistantConnectionStatus(chatId, isChatEnabled, assistantStatus, chat.status);
190
379
  return {
191
380
  currentChatName: chatId,
192
- chats,
193
- status: isChatEnabled && chat.status !== "connecting" ? chat.status : assistantStatus,
381
+ chats: draftChat && draftChat.name === chatId && !chats.some((persistedChat) => persistedChat.name === draftChat.name) ? [draftChat, ...chats] : chats,
382
+ status,
194
383
  assistant: {
195
384
  agent: assistant,
196
385
  getChats,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@economic/agents-react",
3
- "version": "1.5.0",
3
+ "version": "1.6.1",
4
4
  "license": "MIT",
5
5
  "files": [
6
6
  "dist"