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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canopy-ui",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -15,7 +15,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
15
15
  import { cleanup, render, screen } from "@testing-library/react";
16
16
 
17
17
  import { ChatPanel } from "./ChatPanel";
18
- import type { Message, SessionState } from "./protocol";
18
+ import type { Message, SessionState, TurnStatus } from "./protocol";
19
19
 
20
20
  afterEach(cleanup);
21
21
 
@@ -134,3 +134,81 @@ describe("the pending-reply row", () => {
134
134
  expect(screen.queryByText("no messages yet")).toBeNull();
135
135
  });
136
136
  });
137
+
138
+ function status(over: Partial<TurnStatus> = {}): TurnStatus {
139
+ return {
140
+ state: "working",
141
+ agent_slug: "hal",
142
+ runners: ["jj-mbp"],
143
+ claimed_by: "jj-mbp",
144
+ pinned: false,
145
+ cloud_runner: null,
146
+ cloud_runner_id: null,
147
+ last_seen_at: null,
148
+ menu_pending: false,
149
+ settled: false,
150
+ stuck: false,
151
+ ...over,
152
+ };
153
+ }
154
+
155
+ describe("the server's turn status", () => {
156
+ it("replaces the spinner with a reason when nothing will pick the turn up", () => {
157
+ // The regression the whole projection exists for. A client cannot know
158
+ // this: it pressed send, and from here an offline runner and a slow one
159
+ // are identical.
160
+ panel({
161
+ state: state({
162
+ turn_status: status({ state: "waiting_runner", stuck: true, claimed_by: null }),
163
+ }),
164
+ awaitingReply: true,
165
+ });
166
+ expect(screen.queryByTestId("pending-reply")).toBeNull();
167
+ expect(screen.getByRole("status").textContent).toContain("jj-mbp is offline");
168
+ });
169
+
170
+ it("reads differently when waiting cannot possibly help", () => {
171
+ panel({
172
+ state: state({
173
+ turn_status: status({ state: "unrouted", stuck: true, runners: [], claimed_by: null }),
174
+ }),
175
+ awaitingReply: true,
176
+ });
177
+ expect(screen.getByRole("status").textContent).toContain("no runner is set up to run hal");
178
+ });
179
+
180
+ it("names the box in the spinner once a runner really has it", () => {
181
+ panel({ state: state({ turn_status: status({ state: "working" }) }) });
182
+ expect(screen.getByText("Thinking on jj-mbp…")).not.toBeNull();
183
+ });
184
+
185
+ it("clears a stuck spinner even if the stream frame that ends it never arrived", () => {
186
+ // `awaitingReply` only clears on a stream frame; a dropped socket used to
187
+ // leave it spinning forever. The server's settled flag is the backstop.
188
+ panel({
189
+ state: state({ turn_status: status({ state: "done", settled: true }) }),
190
+ awaitingReply: true,
191
+ });
192
+ expect(screen.queryByTestId("pending-reply")).toBeNull();
193
+ });
194
+
195
+ it("stays silent when the host has its own, richer banner for the same fact", () => {
196
+ // canopy's chat page raises <PlacementBanner> here, with buttons to wait
197
+ // or continue elsewhere. Our sentence underneath would restate it as a
198
+ // dead end directly below the thing you can actually press.
199
+ panel({
200
+ state: state({
201
+ turn_status: status({ state: "waiting_runner", stuck: true, claimed_by: null }),
202
+ }),
203
+ banner: <div>continue on another runner?</div>,
204
+ });
205
+ expect(screen.queryByRole("status")).toBeNull();
206
+ expect(screen.getByText("continue on another runner?")).not.toBeNull();
207
+ });
208
+
209
+ it("leaves a blocked agent entirely to the menu", () => {
210
+ panel({ state: state({ turn_status: status({ state: "blocked", stuck: true }) }) });
211
+ expect(screen.queryByRole("status")).toBeNull();
212
+ expect(screen.queryByTestId("pending-reply")).toBeNull();
213
+ });
214
+ });
@@ -7,6 +7,7 @@ import { MessageList } from "./MessageList";
7
7
  import { PresenceChips } from "./PresenceChips";
8
8
  import { SendBox, type PendingAttachment } from "./SendBox";
9
9
  import { isDraftIdle, msUntilDraftIdle, type DraftStorage } from "./drafts";
10
+ import { agentHasFloor as computeAgentHasFloor, pendingLabel as computePendingLabel, turnNotice } from "./turnStatus";
10
11
  import { useStickyBottom } from "./useStickyBottom";
11
12
 
12
13
  export interface ChatPanelProps {
@@ -119,19 +120,31 @@ export function ChatPanel({
119
120
  // the real runner path, and the only signal was a 12px chip in the header,
120
121
  // which on a phone is the far corner of the screen from your thumb.
121
122
  //
122
- // Two sources, deliberately: `awaitingReply` is client-side and answers
123
- // INSTANTLY with no round trip (nothing server-side can — the turn has to be
124
- // enqueued, claimed and driven into the agent before anything could report),
125
- // and `activity === "working"` keeps it up for the rest of the turn, across
126
- // the gaps between the assistant's separate text blocks. `blocked` withdraws
127
- // it: an agent waiting on YOU must never render as an agent working.
128
- const agentHasFloor =
129
- state.activity !== "blocked" &&
130
- (awaitingReply || state.activity === "working");
123
+ // Three sources now, in `turnStatus.ts` — see `agentHasFloor` there for the
124
+ // order of authority. The server's `turn_status` wins when present because
125
+ // it is the only one that can say a turn is queued behind an offline box or
126
+ // routed nowhere at all; `awaitingReply` still covers the instant between
127
+ // pressing send and the first frame, which nothing server-side can.
128
+ const status = state.turn_status ?? null;
129
+ const agentHasFloor = computeAgentHasFloor({
130
+ status,
131
+ activity: state.activity,
132
+ awaitingReply,
133
+ });
131
134
  const showPendingReply = inFlightMessage == null && agentHasFloor;
132
- // "Queued" until something reports the agent actually started — the useful
133
- // distinction is where the delay is, not that there is one.
134
- const pendingLabel = state.activity === "working" ? "Thinking…" : "Queued…";
135
+ const pendingLabel = computePendingLabel({ status, activity: state.activity });
136
+ // The sentence for an ask that is going NOWHERE. Rendered instead of the
137
+ // bubble, never beside it: a spinner over a turn nothing will pick up is the
138
+ // precise lie this projection exists to remove.
139
+ //
140
+ // Suppressed entirely when the host supplies its own `banner`. A host banner
141
+ // IS this host's richer answer for the same situation — canopy's chat page
142
+ // raises <PlacementBanner> for an offline runner, with buttons to wait or
143
+ // continue elsewhere, and <MenuPrompt> for a dialog. Rendering our sentence
144
+ // underneath would state the same fact twice and put a dead-end restatement
145
+ // directly below the thing you can actually press. The fallback is for hosts
146
+ // with no banner of their own, which is exactly the embedded widget.
147
+ const notice = banner ? null : turnNotice(status);
135
148
 
136
149
  // Sticky-bottom scroll: dep changes on (a) new message arrival and (b)
137
150
  // streaming text growth on the last message. length-only (cheap) instead
@@ -188,7 +201,23 @@ export function ChatPanel({
188
201
  onStop={onStop}
189
202
  stopState={state.stopState}
190
203
  onTakeOver={onTakeOver}
191
- banner={banner}
204
+ banner={
205
+ notice ? (
206
+ <p
207
+ role="status"
208
+ className={
209
+ notice.tone === "error"
210
+ ? "text-[12px] text-destructive"
211
+ : "text-[12px] text-warning"
212
+ }
213
+ >
214
+ {notice.text}
215
+ {notice.offer ? ` A runner admin can send it to ${notice.offer.name}.` : ""}
216
+ </p>
217
+ ) : (
218
+ banner
219
+ )
220
+ }
192
221
  disabledReason={disabledReason}
193
222
  attachments={attachments}
194
223
  onAttach={onAttach}
@@ -9,12 +9,12 @@
9
9
  },
10
10
  "agui": [
11
11
  {
12
+ "type": "TEXT_MESSAGE_START",
12
13
  "metadata": {
13
14
  "canopy": {
14
15
  "turn_index": 4
15
16
  }
16
17
  },
17
- "type": "TEXT_MESSAGE_START",
18
18
  "messageId": "m1",
19
19
  "role": "assistant"
20
20
  }
@@ -46,12 +46,12 @@
46
46
  },
47
47
  "agui": [
48
48
  {
49
+ "type": "TEXT_MESSAGE_END",
49
50
  "metadata": {
50
51
  "canopy": {
51
52
  "plaintext": "hello"
52
53
  }
53
54
  },
54
- "type": "TEXT_MESSAGE_END",
55
55
  "messageId": "m1"
56
56
  }
57
57
  ]
@@ -67,12 +67,12 @@
67
67
  },
68
68
  "agui": [
69
69
  {
70
+ "type": "TEXT_MESSAGE_CHUNK",
70
71
  "metadata": {
71
72
  "canopy": {
72
73
  "turn_index": 3
73
74
  }
74
75
  },
75
- "type": "TEXT_MESSAGE_CHUNK",
76
76
  "messageId": "u1",
77
77
  "role": "user",
78
78
  "delta": "hi there"
@@ -96,6 +96,7 @@
96
96
  },
97
97
  "agui": [
98
98
  {
99
+ "type": "TOOL_CALL_START",
99
100
  "metadata": {
100
101
  "canopy": {
101
102
  "turn_index": 5,
@@ -107,7 +108,6 @@
107
108
  }
108
109
  }
109
110
  },
110
- "type": "TOOL_CALL_START",
111
111
  "toolCallId": "t9",
112
112
  "toolCallName": "list_insights",
113
113
  "parentMessageId": "m1"
@@ -139,6 +139,7 @@
139
139
  },
140
140
  "agui": [
141
141
  {
142
+ "type": "TOOL_CALL_RESULT",
142
143
  "metadata": {
143
144
  "canopy": {
144
145
  "turn_index": 6,
@@ -150,7 +151,6 @@
150
151
  }
151
152
  }
152
153
  },
153
- "type": "TOOL_CALL_RESULT",
154
154
  "messageId": "t9",
155
155
  "toolCallId": "t9",
156
156
  "content": "[]"
@@ -231,5 +231,425 @@
231
231
  }
232
232
  }
233
233
  ]
234
+ },
235
+ {
236
+ "canopy": {
237
+ "event": "session.state",
238
+ "data": {
239
+ "messages": [
240
+ {
241
+ "id": "41",
242
+ "turn_index": 0,
243
+ "role": "user",
244
+ "content": {},
245
+ "plaintext": "what is stale?",
246
+ "status": "complete",
247
+ "error_detail": null,
248
+ "started_at": null,
249
+ "completed_at": null,
250
+ "created_at": "2026-09-18T12:00:00+00:00"
251
+ },
252
+ {
253
+ "id": "42",
254
+ "turn_index": 1,
255
+ "role": "assistant",
256
+ "content": {},
257
+ "plaintext": "Three insights are.",
258
+ "status": "complete",
259
+ "error_detail": null,
260
+ "started_at": null,
261
+ "completed_at": null,
262
+ "created_at": "2026-09-18T12:00:05+00:00"
263
+ }
264
+ ],
265
+ "active_draft": null,
266
+ "participants": [
267
+ {
268
+ "user_id": 7,
269
+ "role": "editor"
270
+ }
271
+ ],
272
+ "presence_user_ids": [
273
+ 7
274
+ ],
275
+ "current_user_id": 7,
276
+ "menu": null,
277
+ "turn_status": {
278
+ "state": "working",
279
+ "agent_slug": "hal",
280
+ "runners": [
281
+ "jj-mbp"
282
+ ],
283
+ "claimed_by": "jj-mbp",
284
+ "pinned": false,
285
+ "cloud_runner": null,
286
+ "cloud_runner_id": null,
287
+ "last_seen_at": null,
288
+ "menu_pending": false,
289
+ "settled": false,
290
+ "stuck": false
291
+ }
292
+ }
293
+ },
294
+ "agui": [
295
+ {
296
+ "type": "MESSAGES_SNAPSHOT",
297
+ "metadata": {
298
+ "canopy": {
299
+ "frame": {
300
+ "event": "session.state",
301
+ "data": {
302
+ "messages": [
303
+ {
304
+ "id": "41",
305
+ "turn_index": 0,
306
+ "role": "user",
307
+ "content": {},
308
+ "plaintext": "what is stale?",
309
+ "status": "complete",
310
+ "error_detail": null,
311
+ "started_at": null,
312
+ "completed_at": null,
313
+ "created_at": "2026-09-18T12:00:00+00:00"
314
+ },
315
+ {
316
+ "id": "42",
317
+ "turn_index": 1,
318
+ "role": "assistant",
319
+ "content": {},
320
+ "plaintext": "Three insights are.",
321
+ "status": "complete",
322
+ "error_detail": null,
323
+ "started_at": null,
324
+ "completed_at": null,
325
+ "created_at": "2026-09-18T12:00:05+00:00"
326
+ }
327
+ ],
328
+ "active_draft": null,
329
+ "participants": [
330
+ {
331
+ "user_id": 7,
332
+ "role": "editor"
333
+ }
334
+ ],
335
+ "presence_user_ids": [
336
+ 7
337
+ ],
338
+ "current_user_id": 7,
339
+ "menu": null,
340
+ "turn_status": {
341
+ "state": "working",
342
+ "agent_slug": "hal",
343
+ "runners": [
344
+ "jj-mbp"
345
+ ],
346
+ "claimed_by": "jj-mbp",
347
+ "pinned": false,
348
+ "cloud_runner": null,
349
+ "cloud_runner_id": null,
350
+ "last_seen_at": null,
351
+ "menu_pending": false,
352
+ "settled": false,
353
+ "stuck": false
354
+ }
355
+ }
356
+ }
357
+ }
358
+ },
359
+ "messages": [
360
+ {
361
+ "id": "41",
362
+ "role": "user",
363
+ "content": "what is stale?"
364
+ },
365
+ {
366
+ "id": "42",
367
+ "role": "assistant",
368
+ "content": "Three insights are."
369
+ }
370
+ ]
371
+ }
372
+ ]
373
+ },
374
+ {
375
+ "canopy": {
376
+ "event": "session.page_action",
377
+ "data": {
378
+ "id": "9f1c",
379
+ "name": "scrollToRow",
380
+ "args": {
381
+ "id": 4471
382
+ }
383
+ }
384
+ },
385
+ "agui": [
386
+ {
387
+ "type": "CUSTOM",
388
+ "name": "canopy.session.page_action",
389
+ "value": {
390
+ "id": "9f1c",
391
+ "name": "scrollToRow",
392
+ "args": {
393
+ "id": 4471
394
+ }
395
+ }
396
+ }
397
+ ]
398
+ },
399
+ {
400
+ "canopy": {
401
+ "event": "session.turn_status",
402
+ "data": {
403
+ "status": {
404
+ "state": "waiting_runner",
405
+ "agent_slug": "hal",
406
+ "runners": [
407
+ "jj-mbp"
408
+ ],
409
+ "claimed_by": null,
410
+ "pinned": false,
411
+ "cloud_runner": "cloud-1",
412
+ "cloud_runner_id": "8f2e",
413
+ "last_seen_at": null,
414
+ "menu_pending": false,
415
+ "settled": false,
416
+ "stuck": true
417
+ }
418
+ }
419
+ },
420
+ "agui": [
421
+ {
422
+ "type": "CUSTOM",
423
+ "name": "canopy.session.turn_status",
424
+ "value": {
425
+ "status": {
426
+ "state": "waiting_runner",
427
+ "agent_slug": "hal",
428
+ "runners": [
429
+ "jj-mbp"
430
+ ],
431
+ "claimed_by": null,
432
+ "pinned": false,
433
+ "cloud_runner": "cloud-1",
434
+ "cloud_runner_id": "8f2e",
435
+ "last_seen_at": null,
436
+ "menu_pending": false,
437
+ "settled": false,
438
+ "stuck": true
439
+ }
440
+ }
441
+ }
442
+ ]
443
+ },
444
+ {
445
+ "canopy": {
446
+ "event": "session.stop",
447
+ "data": {
448
+ "state": "failed"
449
+ }
450
+ },
451
+ "agui": [
452
+ {
453
+ "type": "CUSTOM",
454
+ "name": "canopy.session.stop",
455
+ "value": {
456
+ "state": "failed"
457
+ }
458
+ }
459
+ ]
460
+ },
461
+ {
462
+ "canopy": {
463
+ "event": "session.activity",
464
+ "data": {
465
+ "state": "working"
466
+ }
467
+ },
468
+ "agui": [
469
+ {
470
+ "type": "ACTIVITY_SNAPSHOT",
471
+ "messageId": "t1",
472
+ "activityType": "canopy.session",
473
+ "content": {
474
+ "state": "working"
475
+ },
476
+ "replace": true
477
+ }
478
+ ]
479
+ },
480
+ {
481
+ "canopy": {
482
+ "event": "session.menu",
483
+ "data": {
484
+ "menu": {
485
+ "source": "hook",
486
+ "question": "Proceed?",
487
+ "observed_at": 1758196800,
488
+ "options": [
489
+ {
490
+ "label": "Yes"
491
+ },
492
+ {
493
+ "label": "No"
494
+ }
495
+ ]
496
+ }
497
+ }
498
+ },
499
+ "agui": [
500
+ {
501
+ "type": "CUSTOM",
502
+ "name": "canopy.menu",
503
+ "value": {
504
+ "source": "hook",
505
+ "question": "Proceed?",
506
+ "observed_at": 1758196800,
507
+ "options": [
508
+ {
509
+ "label": "Yes"
510
+ },
511
+ {
512
+ "label": "No"
513
+ }
514
+ ]
515
+ }
516
+ }
517
+ ]
518
+ },
519
+ {
520
+ "canopy": {
521
+ "event": "session.error",
522
+ "data": {
523
+ "code": "draft_conflict",
524
+ "message": "stale version"
525
+ }
526
+ },
527
+ "agui": [
528
+ {
529
+ "type": "RUN_ERROR",
530
+ "message": "stale version",
531
+ "code": "draft_conflict"
532
+ }
533
+ ]
534
+ },
535
+ {
536
+ "canopy": {
537
+ "event": "chat.stream_cancelled",
538
+ "data": {
539
+ "message_id": "m1",
540
+ "partial_len": 12
541
+ }
542
+ },
543
+ "agui": [
544
+ {
545
+ "type": "CUSTOM",
546
+ "name": "canopy.chat.stream_cancelled",
547
+ "value": {
548
+ "message_id": "m1",
549
+ "partial_len": 12
550
+ }
551
+ }
552
+ ]
553
+ },
554
+ {
555
+ "canopy": {
556
+ "event": "chat.stream_error",
557
+ "data": {
558
+ "message_id": "m1",
559
+ "detail": "runner went away"
560
+ }
561
+ },
562
+ "agui": [
563
+ {
564
+ "type": "RUN_ERROR",
565
+ "metadata": {
566
+ "canopy": {
567
+ "frame": {
568
+ "event": "chat.stream_error",
569
+ "data": {
570
+ "message_id": "m1",
571
+ "detail": "runner went away"
572
+ }
573
+ }
574
+ }
575
+ },
576
+ "message": "runner went away",
577
+ "code": "stream_error"
578
+ }
579
+ ]
580
+ },
581
+ {
582
+ "canopy": {
583
+ "event": "draft.committed",
584
+ "data": {
585
+ "draft_id": "d1",
586
+ "user_message_id": "u2"
587
+ }
588
+ },
589
+ "agui": [
590
+ {
591
+ "type": "CUSTOM",
592
+ "name": "canopy.draft.committed",
593
+ "value": {
594
+ "draft_id": "d1",
595
+ "user_message_id": "u2"
596
+ }
597
+ }
598
+ ]
599
+ },
600
+ {
601
+ "canopy": {
602
+ "event": "draft.discarded",
603
+ "data": {
604
+ "draft_id": "d1"
605
+ }
606
+ },
607
+ "agui": [
608
+ {
609
+ "type": "CUSTOM",
610
+ "name": "canopy.draft.discarded",
611
+ "value": {
612
+ "draft_id": "d1"
613
+ }
614
+ }
615
+ ]
616
+ },
617
+ {
618
+ "canopy": {
619
+ "event": "draft.lock_changed",
620
+ "data": {
621
+ "draft_id": "d1",
622
+ "holder_user_id": 7,
623
+ "expires_at": "2026-09-18T12:01:00+00:00"
624
+ }
625
+ },
626
+ "agui": [
627
+ {
628
+ "type": "CUSTOM",
629
+ "name": "canopy.draft.lock_changed",
630
+ "value": {
631
+ "draft_id": "d1",
632
+ "holder_user_id": 7,
633
+ "expires_at": "2026-09-18T12:01:00+00:00"
634
+ }
635
+ }
636
+ ]
637
+ },
638
+ {
639
+ "canopy": {
640
+ "event": "page.invalidate",
641
+ "data": {
642
+ "uri": "item://"
643
+ }
644
+ },
645
+ "agui": [
646
+ {
647
+ "type": "CUSTOM",
648
+ "name": "canopy.page.invalidate",
649
+ "value": {
650
+ "uri": "item://"
651
+ }
652
+ }
653
+ ]
234
654
  }
235
655
  ]
@@ -277,7 +277,11 @@ describe('the socket actually asks for it', () => {
277
277
  it('is wired to the hook’s public option', () => {
278
278
  const source = hookSource()
279
279
 
280
- expect(source).toContain('protocol=ag-ui')
280
+ // NOT `expect(source).toContain('protocol=ag-ui')`, which this used to say.
281
+ // It held the whole time the flag was being dropped by any URL builder that
282
+ // ignores its path — i.e. by canopy-web's widget and by ace-web. Whether the
283
+ // flag reaches the socket is asserted where it can be seen, against the URL
284
+ // a socket really opens: `useSessionSocket.protocol.test.tsx`.
281
285
  expect(source).toContain('fromAgui')
282
286
  // And the default stays canopy's own vocabulary, which is what lets an
283
287
  // existing consumer — ace-web installs this package from npm — notice