canopy-ui 0.9.0 → 0.11.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.9.0",
3
+ "version": "0.11.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": "[]"
@@ -273,11 +273,27 @@
273
273
  7
274
274
  ],
275
275
  "current_user_id": 7,
276
- "menu": null
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
+ }
277
292
  }
278
293
  },
279
294
  "agui": [
280
295
  {
296
+ "type": "MESSAGES_SNAPSHOT",
281
297
  "metadata": {
282
298
  "canopy": {
283
299
  "frame": {
@@ -320,12 +336,26 @@
320
336
  7
321
337
  ],
322
338
  "current_user_id": 7,
323
- "menu": null
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
+ }
324
355
  }
325
356
  }
326
357
  }
327
358
  },
328
- "type": "MESSAGES_SNAPSHOT",
329
359
  "messages": [
330
360
  {
331
361
  "id": "41",
@@ -366,6 +396,51 @@
366
396
  }
367
397
  ]
368
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
+ },
369
444
  {
370
445
  "canopy": {
371
446
  "event": "session.stop",
@@ -486,6 +561,7 @@
486
561
  },
487
562
  "agui": [
488
563
  {
564
+ "type": "RUN_ERROR",
489
565
  "metadata": {
490
566
  "canopy": {
491
567
  "frame": {
@@ -497,7 +573,6 @@
497
573
  }
498
574
  }
499
575
  },
500
- "type": "RUN_ERROR",
501
576
  "message": "runner went away",
502
577
  "code": "stream_error"
503
578
  }
package/src/chat/index.ts CHANGED
@@ -13,6 +13,7 @@ export type {
13
13
  Participant,
14
14
  SessionMenu,
15
15
  SessionState,
16
+ TurnStatus,
16
17
  WsAction,
17
18
  WsEvent,
18
19
  } from "./protocol";
@@ -33,6 +34,17 @@ export {
33
34
  } from "./useSessionSocket";
34
35
  export { useStickyBottom } from "./useStickyBottom";
35
36
 
37
+ // Where the ask stands — the kit's voice for the server-side turn status that
38
+ // Slack, this panel and the embedded widget all render from. Exported because
39
+ // a host may want the same wording outside the panel (a session list, a
40
+ // placement banner) rather than inventing a second vocabulary for it.
41
+ export {
42
+ agentHasFloor,
43
+ pendingLabel,
44
+ turnNotice,
45
+ type TurnNotice,
46
+ } from "./turnStatus";
47
+
36
48
  // Draft idle helpers
37
49
  export { IDLE_THRESHOLD_MS, isDraftIdle, msUntilDraftIdle } from "./drafts";
38
50
 
@@ -120,6 +120,51 @@ export interface SessionMenu {
120
120
  observed_at?: number;
121
121
  }
122
122
 
123
+ /**
124
+ * Where the ask this session is waiting on currently stands.
125
+ *
126
+ * Computed on the SERVER (`apps/harness/turn_status.py`) and rendered
127
+ * identically by every channel — a Slack thread, this chat kit, the embedded
128
+ * widget. It exists because three of its states cannot be derived by a client
129
+ * at all: its runner is offline, nothing is routed that could run it, the box
130
+ * it was on died mid-turn. canopy's chat page used to guess at the first by
131
+ * fetching the whole fleet and matching the runner BY NAME, which failed open
132
+ * whenever a retired runner was omitted from that list; the widget never had
133
+ * any of it and simply showed an empty panel.
134
+ */
135
+ export interface TurnStatus {
136
+ /** `picking_up` | `waiting_runner` | `unrouted` | `working` | `blocked`
137
+ * | `paused` | `done` | `cancelled` | `missed` | `failed` | `lost`.
138
+ *
139
+ * Deliberately a string rather than a union: an older client meeting a
140
+ * state a newer server added should fall back to its generic wording, not
141
+ * fail to type-check. `settled`/`stuck` are what code should branch on. */
142
+ state: string;
143
+ agent_slug: string | null;
144
+ /** The runners this state is ABOUT — the ones that would pick it up while it
145
+ * is queued, or the one holding it. */
146
+ runners: string[];
147
+ claimed_by: string | null;
148
+ /** Directed at this runner rather than offered to the cascade. */
149
+ pinned: boolean;
150
+ /** A runner it could be moved to right now, when moving it would help. Only
151
+ * ever an offer; acting on it re-checks everything server-side. */
152
+ cloud_runner: string | null;
153
+ cloud_runner_id: string | null;
154
+ /** When the holding runner was last heard from. Only meaningful for
155
+ * `paused`, where it is what tells you whether to keep waiting. */
156
+ last_seen_at: string | null;
157
+ /** The agent is sitting on a dialog. A separate axis from `state`: a turn
158
+ * blocked on a question is still RUNNING as far as the turn is concerned. */
159
+ menu_pending: boolean;
160
+ /** Nothing more will happen without someone asking for it. */
161
+ settled: boolean;
162
+ /** Nothing is moving and only a person can change that. The one question a
163
+ * "waiting on you" surface actually asks — derived once on the server so
164
+ * four clients cannot disagree about it. */
165
+ stuck: boolean;
166
+ }
167
+
123
168
  export interface SessionState {
124
169
  messages: Message[];
125
170
  /** Live agent activity, from the runner's turn-boundary hooks. Undefined when
@@ -146,6 +191,12 @@ export interface SessionState {
146
191
  * only if it was already connected when the agent blocked — which is exactly
147
192
  * the case that fails, because you go and look BECAUSE it stopped. */
148
193
  menu?: SessionMenu;
194
+ /** Where the ask stands. In the CONNECT SNAPSHOT as well as in live frames,
195
+ * for the same reason `menu` is: you open the page BECAUSE it went quiet, so
196
+ * the client that most needs this is the one that was not connected when it
197
+ * changed. Null/undefined when nothing has been asked on this session yet —
198
+ * which is distinct from "finished", and must stay distinct. */
199
+ turn_status?: TurnStatus | null;
149
200
  active_draft: Draft | null;
150
201
  participants: Participant[];
151
202
  presence_user_ids: number[];
@@ -179,6 +230,13 @@ export type WsEvent =
179
230
  // to carry a menu would report an agent as idle or blocked on the wrong one.
180
231
  // `menu: null` is the retraction — somebody answered at the keyboard.
181
232
  | { event: "session.menu"; data: { menu: SessionMenu | null } }
233
+ // Where the ask stands, re-derived server-side on every transition. Its own
234
+ // frame rather than a field on `session.activity`: activity is the runner's
235
+ // fast hook clock reporting whether the agent is PRODUCING, while this is the
236
+ // turn's placement in the fleet — they change on different clocks and for
237
+ // different reasons, and the states that matter most here (queued behind an
238
+ // offline box, unroutable) are ones activity has no way to spell.
239
+ | { event: "session.turn_status"; data: { status: TurnStatus | null } }
182
240
  // A human typed into emdash rather than into this page. No client echoed it,
183
241
  // so this is the only way it reaches the browser before a reload.
184
242
  | { event: "chat.user_message"; data: { message_id: string; turn_index: number; plaintext: string } }
@@ -105,6 +105,18 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
105
105
  // retraction, and has to be honoured — somebody answered at the keyboard.
106
106
  return { ...prev, menu: frame.data.menu ?? undefined };
107
107
 
108
+ case "session.turn_status":
109
+ // Wholesale, never merged. The server re-derives the whole status from
110
+ // the turn on every transition, so a partial update could only ever
111
+ // describe a state the server does not believe in — and a client that
112
+ // just connected has no correct prior to merge INTO. Same argument as
113
+ // the ACP rule in reverse: `tool_call_update` is a patch because it is
114
+ // specified as one; this is not.
115
+ //
116
+ // A null status is a real answer ("nothing has been asked here"), not a
117
+ // gap to preserve, so it is stored as such.
118
+ return { ...prev, turn_status: frame.data.status ?? null };
119
+
108
120
  case "chat.user_message": {
109
121
  // Someone typed into emdash, OR into this page. Both reach here, and that
110
122
  // is why matching on turn_index alone is not enough: a web send writes its
@@ -0,0 +1,139 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { TurnStatus } from "./protocol";
4
+ import { agentHasFloor, pendingLabel, turnNotice } from "./turnStatus";
5
+
6
+ /** A status in the shape the server actually sends (`TurnStatus.as_dict`). */
7
+ function status(over: Partial<TurnStatus> = {}): TurnStatus {
8
+ return {
9
+ state: "working",
10
+ agent_slug: "hal",
11
+ runners: ["jj-mbp"],
12
+ claimed_by: "jj-mbp",
13
+ pinned: false,
14
+ cloud_runner: null,
15
+ cloud_runner_id: null,
16
+ last_seen_at: null,
17
+ menu_pending: false,
18
+ settled: false,
19
+ stuck: false,
20
+ ...over,
21
+ };
22
+ }
23
+
24
+ describe("agentHasFloor", () => {
25
+ it("shows the bubble the instant you press send, before any frame comes back", () => {
26
+ // Nothing server-side can answer here yet — the turn is not enqueued.
27
+ expect(agentHasFloor({ awaitingReply: true })).toBe(true);
28
+ });
29
+
30
+ it("keeps it up across the gaps between an assistant's text blocks", () => {
31
+ expect(agentHasFloor({ activity: "working", awaitingReply: false })).toBe(true);
32
+ });
33
+
34
+ it("never shows it for an agent waiting on YOU", () => {
35
+ expect(agentHasFloor({ activity: "blocked", awaitingReply: true })).toBe(false);
36
+ });
37
+
38
+ it("withdraws it once the server says nothing will pick the turn up", () => {
39
+ // The regression this whole projection exists for: a spinner over an ask
40
+ // queued behind a closed laptop, which used to spin until the tab closed.
41
+ const s = status({ state: "waiting_runner", stuck: true, claimed_by: null });
42
+ expect(agentHasFloor({ status: s, awaitingReply: true })).toBe(false);
43
+ });
44
+
45
+ it("withdraws it for an unroutable turn even while the client still thinks it is waiting", () => {
46
+ const s = status({ state: "unrouted", stuck: true, runners: [], claimed_by: null });
47
+ expect(agentHasFloor({ status: s, awaitingReply: true })).toBe(false);
48
+ });
49
+
50
+ it("withdraws it once the turn is settled, even if a stream frame was missed", () => {
51
+ // `awaitingReply` only clears on a stream frame. A dropped socket used to
52
+ // leave it true forever; the server's settled flag is the backstop.
53
+ const s = status({ state: "done", settled: true });
54
+ expect(agentHasFloor({ status: s, awaitingReply: true })).toBe(false);
55
+ });
56
+
57
+ it("shows it for a queued turn a live runner is taking", () => {
58
+ expect(agentHasFloor({ status: status({ state: "picking_up" }) })).toBe(true);
59
+ });
60
+
61
+ it("lets the server override a stale client belief that nothing is happening", () => {
62
+ // Someone else sent on this session from another device.
63
+ expect(agentHasFloor({ status: status({ state: "working" }), awaitingReply: false })).toBe(true);
64
+ });
65
+ });
66
+
67
+ describe("pendingLabel", () => {
68
+ it("says where the delay IS, not merely that there is one", () => {
69
+ expect(pendingLabel({ status: status({ state: "picking_up" }) })).toBe(
70
+ "Picking this up on jj-mbp…",
71
+ );
72
+ expect(pendingLabel({ status: status({ state: "working" }) })).toBe("Thinking on jj-mbp…");
73
+ });
74
+
75
+ it("names a pinned runner as a decision, not a queue position", () => {
76
+ const s = status({ state: "picking_up", pinned: true, runners: ["cloud-1"] });
77
+ expect(pendingLabel({ status: s })).toBe("Sent to cloud-1…");
78
+ });
79
+
80
+ it("falls back to the coarse wording with no status in hand", () => {
81
+ expect(pendingLabel({ activity: "working" })).toBe("Thinking…");
82
+ expect(pendingLabel({})).toBe("Queued…");
83
+ });
84
+ });
85
+
86
+ describe("turnNotice", () => {
87
+ it("says nothing when there is nothing a person needs to do", () => {
88
+ expect(turnNotice(null)).toBeNull();
89
+ expect(turnNotice(status({ state: "working" }))).toBeNull();
90
+ expect(turnNotice(status({ state: "picking_up" }))).toBeNull();
91
+ expect(turnNotice(status({ state: "done", settled: true }))).toBeNull();
92
+ });
93
+
94
+ it("leaves a blocked agent to the menu, which has buttons", () => {
95
+ // A sentence beside the dialog would say the same thing twice and compete
96
+ // with the thing you can actually press.
97
+ expect(turnNotice(status({ state: "blocked", stuck: true }))).toBeNull();
98
+ });
99
+
100
+ it("names the offline box, because the fix is to go and open it", () => {
101
+ const n = turnNotice(status({ state: "waiting_runner", stuck: true, claimed_by: null }));
102
+ expect(n?.tone).toBe("warn");
103
+ expect(n?.text).toContain("jj-mbp is offline");
104
+ });
105
+
106
+ it("reads differently for the state waiting cannot fix", () => {
107
+ const n = turnNotice(status({ state: "unrouted", stuck: true, runners: [], claimed_by: null }));
108
+ // Not a warning you can wait out — an error somebody has to go and fix.
109
+ expect(n?.tone).toBe("error");
110
+ expect(n?.text).toContain("no runner is set up to run hal");
111
+ });
112
+
113
+ it("carries the rescue offer when moving it would help", () => {
114
+ const n = turnNotice(
115
+ status({
116
+ state: "waiting_runner",
117
+ stuck: true,
118
+ cloud_runner: "cloud-1",
119
+ cloud_runner_id: "8f2e",
120
+ }),
121
+ );
122
+ expect(n?.offer).toEqual({ name: "cloud-1", id: "8f2e" });
123
+ });
124
+
125
+ it("offers nothing when the server named no runner to move it to", () => {
126
+ const n = turnNotice(status({ state: "waiting_runner", stuck: true }));
127
+ expect(n?.offer).toBeNull();
128
+ });
129
+
130
+ it("reports a box that died mid-turn, which the turn itself still calls running", () => {
131
+ const n = turnNotice(status({ state: "paused", stuck: true }));
132
+ expect(n?.text).toContain("jj-mbp went offline mid-turn");
133
+ });
134
+
135
+ it("degrades to no notice for a state it has never heard of", () => {
136
+ // An older client meeting a newer server must fall silent, not crash.
137
+ expect(turnNotice(status({ state: "some_future_state" }))).toBeNull();
138
+ });
139
+ });
@@ -0,0 +1,148 @@
1
+ import type { TurnStatus } from "./protocol";
2
+
3
+ /**
4
+ * The chat kit's VOICE for `harness.turn_status` — the counterpart of
5
+ * `apps/slack/status.py::render`, which says the same eleven states in Slack's
6
+ * words. The states are computed once on the server; only the wording is here.
7
+ *
8
+ * Pure functions, no React, so both the wording and the spinner-vs-statement
9
+ * decision are unit-testable without mounting a panel.
10
+ */
11
+
12
+ /**
13
+ * Does the agent have the floor — i.e. should a "working on it" bubble show?
14
+ *
15
+ * Three sources, in order of authority:
16
+ *
17
+ * 1. `status` is the server's answer and wins whenever it exists. It is the
18
+ * only one that knows a turn is queued behind an offline box, and the only
19
+ * one that survives a reload.
20
+ * 2. `awaitingReply` is client-side and answers INSTANTLY, with no round trip.
21
+ * Nothing server-side can: the turn has to be enqueued before anything
22
+ * could report on it. It covers the gap between pressing send and the
23
+ * first frame coming back.
24
+ * 3. `activity` is the runner's hook clock, which keeps the bubble up across
25
+ * the gaps between an assistant's separate text blocks.
26
+ *
27
+ * A `blocked` agent never has the floor: one waiting on YOU must not render as
28
+ * one working. Neither does a stuck or settled turn — a spinner over an ask
29
+ * nothing is going to pick up is the exact lie this whole projection exists to
30
+ * stop.
31
+ */
32
+ export function agentHasFloor({
33
+ status,
34
+ activity,
35
+ awaitingReply,
36
+ }: {
37
+ status?: TurnStatus | null;
38
+ activity?: "working" | "idle" | "blocked";
39
+ awaitingReply?: boolean;
40
+ }): boolean {
41
+ if (activity === "blocked") return false;
42
+ if (status) {
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.
46
+ return true;
47
+ }
48
+ return Boolean(awaitingReply) || activity === "working";
49
+ }
50
+
51
+ /**
52
+ * The label inside the pending bubble.
53
+ *
54
+ * "Queued" vs "Thinking" is the useful distinction — where the delay IS, not
55
+ * that there is one — and with a status in hand it can name the box, which
56
+ * turns a spinner into something you can act on when it lasts too long.
57
+ */
58
+ export function pendingLabel({
59
+ status,
60
+ activity,
61
+ }: {
62
+ status?: TurnStatus | null;
63
+ activity?: "working" | "idle" | "blocked";
64
+ }): string {
65
+ if (status?.state === "working") {
66
+ return status.claimed_by ? `Thinking on ${status.claimed_by}…` : "Thinking…";
67
+ }
68
+ if (status?.state === "picking_up") {
69
+ const [first] = status.runners;
70
+ if (status.pinned && first) return `Sent to ${first}…`;
71
+ return first ? `Picking this up on ${first}…` : "Queued…";
72
+ }
73
+ return activity === "working" ? "Thinking…" : "Queued…";
74
+ }
75
+
76
+ export interface TurnNotice {
77
+ /** `warn` — nothing is moving but waiting may still resolve it.
78
+ * `error` — waiting will never resolve it; something has to change. */
79
+ tone: "warn" | "error";
80
+ text: string;
81
+ /** The runner this could be moved to, when moving it would help. The kit
82
+ * only reports the offer; acting on it belongs to the host, which knows
83
+ * whether this user may place a turn. */
84
+ offer: { name: string; id: string } | null;
85
+ }
86
+
87
+ /**
88
+ * The sentence for a turn that is going nowhere, or null when there is nothing
89
+ * a person needs to know.
90
+ *
91
+ * Only the states a spinner would MISREPRESENT get one. `picking_up` and
92
+ * `working` are covered by the bubble; the terminal states are covered by the
93
+ * reply (or its absence) already being on screen.
94
+ *
95
+ * `blocked` deliberately returns null: the dialog itself is the notice, and
96
+ * the host renders it as a menu with buttons. A sentence beside it would say
97
+ * the same thing twice and compete with the thing you can actually press.
98
+ */
99
+ export function turnNotice(status?: TurnStatus | null): TurnNotice | null {
100
+ if (!status) return null;
101
+ const offer = status.cloud_runner && status.cloud_runner_id
102
+ ? { name: status.cloud_runner, id: status.cloud_runner_id }
103
+ : null;
104
+ const runners = status.runners.join(", ");
105
+ switch (status.state) {
106
+ case "waiting_runner":
107
+ return {
108
+ tone: "warn",
109
+ // Names the box, because the fix is physical: go and open it.
110
+ text: runners
111
+ ? `Queued — ${runners} is offline, so nothing is working on this yet. It runs when the runner is back.`
112
+ : "Queued — its runner is offline, so nothing is working on this yet.",
113
+ offer,
114
+ };
115
+ case "unrouted":
116
+ return {
117
+ tone: "error",
118
+ // The one state waiting cannot fix, and it has to READ differently
119
+ // from the one waiting can.
120
+ text: status.agent_slug
121
+ ? `Queued, but no runner is set up to run ${status.agent_slug} — nothing will pick this up until its routing is fixed.`
122
+ : "Queued, but no runner is set up to run this — nothing will pick it up until its routing is fixed.",
123
+ offer,
124
+ };
125
+ case "paused":
126
+ return {
127
+ tone: "warn",
128
+ text: status.claimed_by
129
+ ? `Paused — ${status.claimed_by} went offline mid-turn. It carries on if the runner comes back.`
130
+ : "Paused — the runner went offline mid-turn.",
131
+ offer,
132
+ };
133
+ case "lost":
134
+ return {
135
+ tone: "error",
136
+ text: status.claimed_by
137
+ ? `Could not finish — ${status.claimed_by} went away before it was done.`
138
+ : "Could not finish — the runner went away before it was done.",
139
+ offer,
140
+ };
141
+ case "failed":
142
+ return { tone: "error", text: "Could not finish.", offer: null };
143
+ case "missed":
144
+ return { tone: "warn", text: "Missed — nothing picked it up in time.", offer: null };
145
+ default:
146
+ return null;
147
+ }
148
+ }
@@ -0,0 +1,84 @@
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
+ * A read-only principal — a canopy CONTACT — chats through the same hook: the
9
+ * message goes out over HTTP, the draft stays local, and nothing is written to
10
+ * the socket (which would refuse it).
11
+ */
12
+
13
+ class FakeSocket {
14
+ static OPEN = 1
15
+ static sent: string[] = []
16
+ onopen: (() => void) | null = null
17
+ onmessage: ((e: { data: string }) => void) | null = null
18
+ onclose: (() => void) | null = null
19
+ onerror: (() => void) | null = null
20
+ readyState = 1
21
+ constructor(public url: string) {}
22
+ send(data: string) {
23
+ FakeSocket.sent.push(data)
24
+ }
25
+ close() {}
26
+ }
27
+
28
+ beforeEach(() => {
29
+ FakeSocket.sent = []
30
+ vi.stubGlobal('WebSocket', FakeSocket)
31
+ })
32
+ afterEach(() => vi.unstubAllGlobals())
33
+
34
+ function contact(sendOverHttp = vi.fn().mockResolvedValue({})) {
35
+ const hook = renderHook(() =>
36
+ useSessionSocket({ sessionId: 's1', wsUrl: () => 'wss://h/x', sendOverHttp }),
37
+ )
38
+ return { hook, sendOverHttp }
39
+ }
40
+
41
+ describe('sending over HTTP', () => {
42
+ it('types into a local draft and sends it over HTTP, never the socket', async () => {
43
+ const { hook, sendOverHttp } = contact()
44
+ act(() => hook.result.current.updateDraft('hello ace'))
45
+ expect(hook.result.current.state.active_draft?.body).toBe('hello ace')
46
+ await act(async () => hook.result.current.sendChat())
47
+ expect(sendOverHttp).toHaveBeenCalledWith('hello ace')
48
+ expect(FakeSocket.sent.filter((f) => f.includes('chat.send') || f.includes('draft.update'))).toEqual([])
49
+ })
50
+
51
+ it('shows the line and waits for the reply, as a user send does', async () => {
52
+ const { hook } = contact()
53
+ act(() => hook.result.current.updateDraft('are we on track?'))
54
+ await act(async () => hook.result.current.sendChat())
55
+ expect(hook.result.current.awaitingReply).toBe(true)
56
+ expect(hook.result.current.state.messages.at(-1)?.plaintext).toBe('are we on track?')
57
+ expect(hook.result.current.state.active_draft).toBeNull()
58
+ })
59
+
60
+ it('a failed send puts the words back and says why', async () => {
61
+ const { hook } = contact(vi.fn().mockRejectedValue(new Error('canopy said 429')))
62
+ act(() => hook.result.current.updateDraft('keep me'))
63
+ await act(async () => {
64
+ hook.result.current.sendChat()
65
+ await Promise.resolve()
66
+ })
67
+ expect(hook.result.current.state.active_draft?.body).toBe('keep me')
68
+ expect(hook.result.current.lastError).toBe('canopy said 429')
69
+ expect(hook.result.current.awaitingReply).toBe(false)
70
+ })
71
+
72
+ it('an empty draft sends nothing', async () => {
73
+ const { hook, sendOverHttp } = contact()
74
+ act(() => hook.result.current.updateDraft(' '))
75
+ await act(async () => hook.result.current.sendChat())
76
+ expect(sendOverHttp).not.toHaveBeenCalled()
77
+ })
78
+
79
+ it('without it, a user sends over the socket exactly as before', async () => {
80
+ const hook = renderHook(() => useSessionSocket({ sessionId: 's1', wsUrl: () => 'wss://h/x' }))
81
+ await act(async () => hook.result.current.sendChat())
82
+ expect(FakeSocket.sent.some((f) => f.includes('chat.send'))).toBe(true)
83
+ })
84
+ })
@@ -1,7 +1,7 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
 
3
3
  import { fromAgui, resetAguiState } from "./agui";
4
- import type { Message, SessionState, WsEvent } from "./protocol";
4
+ import type { Draft, Message, SessionState, WsEvent } from "./protocol";
5
5
  import { shouldSyncDraftLive } from "./drafts";
6
6
  import { prependHistory } from "./history";
7
7
  import { sessionReducer } from "./sessionReducer";
@@ -56,6 +56,18 @@ export interface UseSessionSocketOptions {
56
56
  * that against a fixture the server generates).
57
57
  */
58
58
  protocol?: "canopy" | "ag-ui";
59
+ /**
60
+ * For a principal whose socket is READ-ONLY — a canopy CONTACT, someone a
61
+ * site vouched for who has no canopy account. They can watch the conversation
62
+ * over the socket but not write to it, so their message goes out over HTTP
63
+ * (canopy-client's `rest.send`, which routes to `/api/contact/…`) and their
64
+ * draft stays local: there is no shared draft to co-edit with nobody else.
65
+ *
66
+ * Everything else is unchanged — the same `state`, the same pending row, the
67
+ * same "waiting for a reply" — so one chat UI serves a user and a contact
68
+ * without a second send path in every host. Omit for a user (the default).
69
+ */
70
+ sendOverHttp?: (text: string) => Promise<unknown>;
59
71
  }
60
72
 
61
73
  /**
@@ -93,6 +105,21 @@ export interface UseSessionSocketResult {
93
105
  takeOverDraft: () => void;
94
106
  discardDraft: () => void;
95
107
  prependMessages: (older: Message[]) => void;
108
+ /** A message was sent for this session over HTTP rather than through this
109
+ * socket — show it, and start waiting for a reply.
110
+ *
111
+ * Two callers need it and both are REST sends: the embedded widget's FIRST
112
+ * message (the session does not exist until it is sent, so there is no
113
+ * socket to send it on) and every message from a CONTACT (whose socket
114
+ * listens only — presence and the co-edited draft are keyed on a user id
115
+ * they do not have).
116
+ *
117
+ * Without it those sends changed nothing on screen: `awaitingReply` is set
118
+ * by `sendChat` alone, and the user's own line is not a server row until
119
+ * the agent's transcript ships it back. So you typed, pressed send, and got
120
+ * an empty panel — for as long as the reply took, and forever if its runner
121
+ * was offline. */
122
+ noteLocalSend: (text: string) => void;
96
123
  lastError: string | null;
97
124
  }
98
125
 
@@ -117,7 +144,10 @@ export function useSessionSocket({
117
144
  onTitleUpdated,
118
145
  onUnknownEvent,
119
146
  protocol = "canopy",
147
+ sendOverHttp,
120
148
  }: UseSessionSocketOptions): UseSessionSocketResult {
149
+ // The local draft a read-only principal types into (see `sendOverHttp`).
150
+ const [localDraft, setLocalDraft] = useState<Draft | null>(null);
121
151
  const [state, setState] = useState<SessionState>(INITIAL_STATE);
122
152
  const [connected, setConnected] = useState(false);
123
153
  const [lastError, setLastError] = useState<string | null>(null);
@@ -427,16 +457,67 @@ export function useSessionSocket({
427
457
  });
428
458
  }, []);
429
459
 
460
+ // `sendOverHttp`'s half of the hook: typing is local, sending is HTTP. Defined
461
+ // here, after noteLocalSend, which it reuses for the pending row.
462
+ const updateLocalDraft = useCallback((body: string) => {
463
+ setLocalDraft({
464
+ id: "local", slot: "next", status: "open", body, version: 0, last_editor: 0,
465
+ last_edit_at: new Date().toISOString(),
466
+ });
467
+ }, []);
468
+
469
+ const noteLocalSend = useCallback((text: string) => {
470
+ setAwaitingReply(true);
471
+ const body = text.trim();
472
+ if (!body) return;
473
+ // Routed through the ordinary `chat.user_message` case rather than a new
474
+ // one, so the optimistic row goes in with the SAME dedupe the transcript
475
+ // echo already relies on: when the agent reads the message and the runner
476
+ // ships it back at its durable composite ordinal, the reducer matches on
477
+ // recent identical text and merges instead of rendering it twice.
478
+ setState((prev) =>
479
+ sessionReducer(prev, {
480
+ event: "chat.user_message",
481
+ data: {
482
+ // Local, and replaced by the server's id the moment the real row
483
+ // arrives. Namespaced so it can never collide with one.
484
+ message_id: `local:${Date.now()}`,
485
+ turn_index: prev.messages.reduce((acc, m) => Math.max(acc, m.turn_index), 0) + 1,
486
+ plaintext: body,
487
+ },
488
+ }),
489
+ );
490
+ }, []);
491
+
492
+ const sendChatOverHttp = useCallback(() => {
493
+ const body = (localDraft?.body ?? "").trim();
494
+ if (!body || !sendOverHttp) return;
495
+ noteLocalSend(body);
496
+ setLocalDraft(null);
497
+ sendOverHttp(body).catch((err: unknown) => {
498
+ setAwaitingReply(false);
499
+ setLastError(err instanceof Error ? err.message : "the message could not be sent");
500
+ // Put the words back, so a failed send never costs what was typed.
501
+ updateLocalDraft(body);
502
+ });
503
+ }, [localDraft, sendOverHttp, noteLocalSend, updateLocalDraft]);
504
+
505
+ const exposedState = useMemo(
506
+ () => (sendOverHttp ? { ...state, active_draft: localDraft } : state),
507
+ [sendOverHttp, state, localDraft],
508
+ );
509
+
430
510
  return {
431
- state,
511
+ state: exposedState,
432
512
  connected,
433
513
  awaitingReply,
434
- sendChat,
514
+ sendChat: sendOverHttp ? sendChatOverHttp : sendChat,
435
515
  stopChat,
436
- updateDraft,
516
+ updateDraft: sendOverHttp ? updateLocalDraft : updateDraft,
437
517
  takeOverDraft,
438
518
  discardDraft,
439
519
  prependMessages,
520
+ noteLocalSend,
440
521
  lastError,
441
522
  };
442
523
  }