canopy-ui 0.8.0 → 0.10.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.
@@ -58,6 +58,27 @@ export interface UseSessionSocketOptions {
58
58
  protocol?: "canopy" | "ag-ui";
59
59
  }
60
60
 
61
+ /**
62
+ * Ask for AG-UI on the URL the caller built — whatever it looks like.
63
+ *
64
+ * The hook OWNS this flag rather than handing the caller a path with it already
65
+ * attached, because that contract was invisible and two of the first three
66
+ * callers broke it: canopy-web's widget builds `client.sessionSocketUrl(id)` and
67
+ * ace-web builds `buildCanopyWsUrl(base, id)`, and both ignore the path they are
68
+ * given (they need their own token in the query). The flag was dropped, the
69
+ * server answered in native frames, and every one of them decoded as AG-UI to
70
+ * nothing — a blank chat with no error, caught only in review before it shipped.
71
+ *
72
+ * Exported for the test, which asserts on the URL a socket really opens.
73
+ */
74
+ export function withAguiProtocol(url: string): string {
75
+ // Empty is a caller saying "no URL yet" (the widget before it has a
76
+ // session); decorating it would turn a deliberate no-op into a bad request.
77
+ if (!url) return url;
78
+ if (/[?&]protocol=/.test(url)) return url;
79
+ return `${url}${url.includes("?") ? "&" : "?"}protocol=ag-ui`;
80
+ }
81
+
61
82
  export interface UseSessionSocketResult {
62
83
  state: SessionState;
63
84
  connected: boolean;
@@ -72,6 +93,21 @@ export interface UseSessionSocketResult {
72
93
  takeOverDraft: () => void;
73
94
  discardDraft: () => void;
74
95
  prependMessages: (older: Message[]) => void;
96
+ /** A message was sent for this session over HTTP rather than through this
97
+ * socket — show it, and start waiting for a reply.
98
+ *
99
+ * Two callers need it and both are REST sends: the embedded widget's FIRST
100
+ * message (the session does not exist until it is sent, so there is no
101
+ * socket to send it on) and every message from a CONTACT (whose socket
102
+ * listens only — presence and the co-edited draft are keyed on a user id
103
+ * they do not have).
104
+ *
105
+ * Without it those sends changed nothing on screen: `awaitingReply` is set
106
+ * by `sendChat` alone, and the user's own line is not a server row until
107
+ * the agent's transcript ships it back. So you typed, pressed send, and got
108
+ * an empty panel — for as long as the reply took, and forever if its runner
109
+ * was offline. */
110
+ noteLocalSend: (text: string) => void;
75
111
  lastError: string | null;
76
112
  }
77
113
 
@@ -121,6 +157,7 @@ export function useSessionSocket({
121
157
  // vocabulary mid-session.
122
158
  const protocolRef = useRef(protocol);
123
159
  protocolRef.current = protocol;
160
+ const warnedNativeRef = useRef(false);
124
161
  // Control frames that must not be lost across a reconnect (currently
125
162
  // only chat.stop). The WS-world analogue of an abortable chat transport.
126
163
  const pendingFramesRef = useRef<{ action: string; data: unknown }[]>([]);
@@ -202,8 +239,9 @@ export function useSessionSocket({
202
239
  // by an ARGS event from this one — the ids are per-stream.
203
240
  resetAguiState();
204
241
  const path = `ws/canopy-sessions/${sessionId}/`;
242
+ const built = wsUrl(path);
205
243
  const ws = new WebSocket(
206
- wsUrl(protocolRef.current === "ag-ui" ? `${path}?protocol=ag-ui` : path),
244
+ protocolRef.current === "ag-ui" ? withAguiProtocol(built) : built,
207
245
  );
208
246
  socketRef.current = ws;
209
247
 
@@ -229,6 +267,22 @@ export function useSessionSocket({
229
267
  try {
230
268
  const raw = JSON.parse(e.data);
231
269
  if (protocolRef.current === "ag-ui") {
270
+ // We asked for AG-UI and the server answered in canopy's own frames:
271
+ // a server that predates the negotiation, or a URL that lost the flag
272
+ // on the way (see `withAguiProtocol`). The two are unambiguous — every
273
+ // AG-UI event has a `type`, and no canopy frame does — so apply it as
274
+ // what it is. Decoding it as AG-UI yields nothing, which is a blank
275
+ // chat with no error: the worst possible way to find out.
276
+ if (typeof raw?.type !== "string" && typeof raw?.event === "string") {
277
+ if (!warnedNativeRef.current) {
278
+ warnedNativeRef.current = true;
279
+ console.warn(
280
+ "canopy-ui: asked for protocol=ag-ui but the server sent canopy frames; handling them as canopy frames.",
281
+ );
282
+ }
283
+ applyEvent(raw as WsEvent);
284
+ return;
285
+ }
232
286
  // One AG-UI event can be several canopy frames (a tool call is three
233
287
  // events) or none, so this is a fan-out rather than a rename.
234
288
  for (const frame of fromAgui(raw)) applyEvent(frame);
@@ -388,6 +442,29 @@ export function useSessionSocket({
388
442
  });
389
443
  }, []);
390
444
 
445
+ const noteLocalSend = useCallback((text: string) => {
446
+ setAwaitingReply(true);
447
+ const body = text.trim();
448
+ if (!body) return;
449
+ // Routed through the ordinary `chat.user_message` case rather than a new
450
+ // one, so the optimistic row goes in with the SAME dedupe the transcript
451
+ // echo already relies on: when the agent reads the message and the runner
452
+ // ships it back at its durable composite ordinal, the reducer matches on
453
+ // recent identical text and merges instead of rendering it twice.
454
+ setState((prev) =>
455
+ sessionReducer(prev, {
456
+ event: "chat.user_message",
457
+ data: {
458
+ // Local, and replaced by the server's id the moment the real row
459
+ // arrives. Namespaced so it can never collide with one.
460
+ message_id: `local:${Date.now()}`,
461
+ turn_index: prev.messages.reduce((acc, m) => Math.max(acc, m.turn_index), 0) + 1,
462
+ plaintext: body,
463
+ },
464
+ }),
465
+ );
466
+ }, []);
467
+
391
468
  return {
392
469
  state,
393
470
  connected,
@@ -398,6 +475,7 @@ export function useSessionSocket({
398
475
  takeOverDraft,
399
476
  discardDraft,
400
477
  prependMessages,
478
+ noteLocalSend,
401
479
  lastError,
402
480
  };
403
481
  }