canopy-ui 0.15.2 → 0.16.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/LICENSE ADDED
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Dimagi Inc., and individual contributors.
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "canopy-ui",
3
- "version": "0.15.2",
3
+ "version": "0.16.0",
4
+ "license": "BSD-3-Clause",
4
5
  "type": "module",
5
6
  "repository": {
6
7
  "type": "git",
@@ -186,7 +186,7 @@ export interface SessionMenu {
186
186
  * any of it and simply showed an empty panel.
187
187
  */
188
188
  export interface TurnStatus {
189
- /** `picking_up` | `waiting_runner` | `unrouted` | `working` | `blocked`
189
+ /** `picking_up` | `queued_behind` | `waiting_runner` | `unrouted` | `working` | `blocked`
190
190
  * | `paused` | `done` | `cancelled` | `missed` | `failed` | `lost`.
191
191
  *
192
192
  * Deliberately a string rather than a union: an older client meeting a
@@ -212,6 +212,10 @@ export interface TurnStatus {
212
212
  menu_pending: boolean;
213
213
  /** Why a `failed` turn failed, in the runner's words. Null for every other
214
214
  * state. Absent on an older server. */
215
+ /** For `queued_behind`: what it waits behind — `session` (the previous
216
+ * message in this conversation) or `agent` (another turn of the same
217
+ * agent). Absent on an older server. */
218
+ behind?: "session" | "agent" | null;
215
219
  detail?: string | null;
216
220
  /** When a settled turn finished — lets the kit drop a failure the
217
221
  * conversation has since moved past. Absent on an older server. */
@@ -72,6 +72,16 @@ describe("pendingLabel", () => {
72
72
  expect(pendingLabel({ status: status({ state: "working" }) })).toBe("Thinking on jj-mbp…");
73
73
  });
74
74
 
75
+ it("says a follow-up is queued behind the turn ahead, not being picked up (#1147)", () => {
76
+ const s = status({ state: "queued_behind", behind: "session", claimed_by: null });
77
+ expect(agentHasFloor({ status: s })).toBe(true);
78
+ expect(pendingLabel({ status: s })).toBe("Queued behind the previous message on jj-mbp…");
79
+ expect(pendingLabel({ status: { ...s, behind: "agent" } })).toBe(
80
+ "Queued behind the agent's current turn on jj-mbp…",
81
+ );
82
+ expect(turnNotice(s)).toBeNull();
83
+ });
84
+
75
85
  it("names a pinned runner as a decision, not a queue position", () => {
76
86
  const s = status({ state: "picking_up", pinned: true, runners: ["cloud-1"] });
77
87
  expect(pendingLabel({ status: s })).toBe("Sent to cloud-1…");
@@ -41,8 +41,8 @@ export function agentHasFloor({
41
41
  if (activity === "blocked") return false;
42
42
  if (status) {
43
43
  if (status.settled || status.stuck) return false;
44
- // `picking_up` and `working` are the two live states, and both mean a
45
- // reply is genuinely coming.
44
+ // `picking_up`, `queued_behind` and `working` are the live states, and
45
+ // each means a reply is genuinely coming.
46
46
  return true;
47
47
  }
48
48
  return Boolean(awaitingReply) || activity === "working";
@@ -65,6 +65,13 @@ export function pendingLabel({
65
65
  if (status?.state === "working") {
66
66
  return status.claimed_by ? `Thinking on ${status.claimed_by}…` : "Thinking…";
67
67
  }
68
+ if (status?.state === "queued_behind") {
69
+ // A live runner will take it, but only once the turn ahead ends — saying
70
+ // "picking this up" made a follow-up sent mid-turn look lost (#1147).
71
+ const [first] = status.runners;
72
+ const ahead = status.behind === "session" ? "the previous message" : "the agent's current turn";
73
+ return first ? `Queued behind ${ahead} on ${first}…` : `Queued behind ${ahead}…`;
74
+ }
68
75
  if (status?.state === "picking_up") {
69
76
  const [first] = status.runners;
70
77
  if (status.pinned && first) return `Sent to ${first}…`;
@@ -0,0 +1,75 @@
1
+ // @vitest-environment jsdom
2
+ import { act, renderHook } from '@testing-library/react'
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import { useSessionSocket } from './useSessionSocket'
6
+
7
+ /**
8
+ * An async URL builder: a fresh one-time ticket per connection, so a token
9
+ * never rides the socket URL (URLs land in access logs). A ticket works once,
10
+ * so a reconnect must ask again rather than reuse the first URL.
11
+ */
12
+
13
+ class FakeSocket {
14
+ static opened: FakeSocket[] = []
15
+ onopen: (() => void) | null = null
16
+ onmessage: ((e: { data: string }) => void) | null = null
17
+ onclose: (() => void) | null = null
18
+ onerror: (() => void) | null = null
19
+ readyState = 0
20
+ constructor(public url: string) {
21
+ FakeSocket.opened.push(this)
22
+ }
23
+ send() {}
24
+ close() {}
25
+ }
26
+
27
+ beforeEach(() => {
28
+ FakeSocket.opened = []
29
+ vi.stubGlobal('WebSocket', FakeSocket)
30
+ vi.useFakeTimers()
31
+ })
32
+ afterEach(() => {
33
+ vi.useRealTimers()
34
+ vi.unstubAllGlobals()
35
+ })
36
+
37
+ describe('an async socket URL', () => {
38
+ it('opens the socket once the ticket arrives, and asks again on reconnect', async () => {
39
+ let n = 0
40
+ const wsUrl = vi.fn(async () => `wss://h/ws/canopy-sessions/s1/?ticket=t${++n}`)
41
+ renderHook(() => useSessionSocket({ sessionId: 's1', wsUrl }))
42
+ await act(async () => { await Promise.resolve() })
43
+ expect(FakeSocket.opened.map((s) => s.url)).toEqual(['wss://h/ws/canopy-sessions/s1/?ticket=t1'])
44
+
45
+ await act(async () => {
46
+ FakeSocket.opened[0].onclose?.()
47
+ await vi.runAllTimersAsync()
48
+ })
49
+ expect(FakeSocket.opened.map((s) => s.url)).toEqual([
50
+ 'wss://h/ws/canopy-sessions/s1/?ticket=t1',
51
+ 'wss://h/ws/canopy-sessions/s1/?ticket=t2',
52
+ ])
53
+ })
54
+
55
+ it('opens nothing for a session it has already left', async () => {
56
+ let release: (url: string) => void = () => {}
57
+ const wsUrl = () => new Promise<string>((resolve) => { release = resolve })
58
+ const { unmount } = renderHook(() => useSessionSocket({ sessionId: 's1', wsUrl }))
59
+ unmount()
60
+ await act(async () => { release('wss://h/late'); await Promise.resolve() })
61
+ expect(FakeSocket.opened).toHaveLength(0)
62
+ })
63
+
64
+ it('retries when fetching the ticket fails', async () => {
65
+ let calls = 0
66
+ const wsUrl = async () => {
67
+ calls += 1
68
+ if (calls === 1) throw new Error('network')
69
+ return 'wss://h/ok'
70
+ }
71
+ renderHook(() => useSessionSocket({ sessionId: 's1', wsUrl }))
72
+ await act(async () => { await vi.runAllTimersAsync() })
73
+ expect(FakeSocket.opened.map((s) => s.url)).toEqual(['wss://h/ok'])
74
+ })
75
+ })
@@ -69,8 +69,13 @@ export interface UseSessionSocketOptions {
69
69
  * App-injected WebSocket URL builder. The kit never imports app routing/base
70
70
  * helpers; the container passes one (e.g. canopy's `wsUrl`). Called with the
71
71
  * relative path `ws/canopy-sessions/${sessionId}/`.
72
+ *
73
+ * May return a Promise: it is called before EVERY connection, reconnects
74
+ * included, so a container can fetch a one-time ticket each time and keep
75
+ * its token off the URL (canopy-client's `sessionSocketTicketUrl`). A
76
+ * rejected promise is treated like a dropped connection and retried.
72
77
  */
73
- wsUrl: (path: string) => string;
78
+ wsUrl: (path: string) => string | Promise<string>;
74
79
  /**
75
80
  * Optional side-effect callback fired when the server broadcasts a
76
81
  * `session.title_updated` (replaces ace's `notifySessionsUpdated`). The kit
@@ -238,6 +243,7 @@ export function useSessionSocket({
238
243
  const draftDebounceRef = useRef<number | null>(null);
239
244
  const pendingDraftBodyRef = useRef<string | null>(null);
240
245
  const closedByUserRef = useRef(false);
246
+ const generationRef = useRef(0);
241
247
  const onTitleUpdatedRef = useRef(onTitleUpdated);
242
248
  const onUnknownEventRef = useRef(onUnknownEvent);
243
249
  // A ref, like the callbacks above: `connect` is a stable callback with empty
@@ -426,11 +432,36 @@ export function useSessionSocket({
426
432
 
427
433
  const connect = useCallback(() => {
428
434
  if (closedByUserRef.current) return;
435
+ // Which mount this attempt belongs to. An async URL may resolve after the
436
+ // hook re-mounted for another session; it must not open a socket then.
437
+ const generation = generationRef.current;
429
438
  // A half-read tool call from the previous connection must not be completed
430
439
  // by an ARGS event from this one — the ids are per-stream.
431
440
  resetAguiState();
432
441
  const path = `ws/canopy-sessions/${sessionId}/`;
433
442
  const built = wsUrl(path);
443
+ if (typeof built !== "string") {
444
+ // An async builder (a fresh one-time ticket per connection). If it fails,
445
+ // retry on the same backoff a dropped socket uses; if the hook was torn
446
+ // down meanwhile, open nothing.
447
+ built.then(
448
+ (url) => {
449
+ if (!closedByUserRef.current && generation === generationRef.current) openSocket(url);
450
+ },
451
+ () => {
452
+ if (closedByUserRef.current || generation !== generationRef.current) return;
453
+ const attempt = reconnectAttemptRef.current;
454
+ reconnectAttemptRef.current = attempt + 1;
455
+ window.setTimeout(connect,
456
+ RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)]);
457
+ },
458
+ );
459
+ return;
460
+ }
461
+ openSocket(built);
462
+
463
+ // Declared inside `connect` so it shares exactly this render's handlers.
464
+ function openSocket(built: string) {
434
465
  const ws = new WebSocket(
435
466
  protocolRef.current === "ag-ui" ? withAguiProtocol(built) : built,
436
467
  );
@@ -506,6 +537,7 @@ export function useSessionSocket({
506
537
  ws.onerror = () => {
507
538
  // onclose will fire next; nothing to do here.
508
539
  };
540
+ }
509
541
  }, [applyEvent, rescueSend, send, sessionId, wsUrl]);
510
542
 
511
543
  useEffect(() => {
@@ -513,6 +545,7 @@ export function useSessionSocket({
513
545
  reconnectAttemptRef.current = 0;
514
546
  connect();
515
547
  return () => {
548
+ generationRef.current += 1;
516
549
  closedByUserRef.current = true;
517
550
  if (heartbeatTimerRef.current != null) {
518
551
  window.clearInterval(heartbeatTimerRef.current);