canopy-ui 0.15.1 → 0.15.3
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 +1 -1
- package/src/chat/SendBox.test.tsx +71 -0
- package/src/chat/SendBox.tsx +12 -4
- package/src/chat/protocol.ts +5 -1
- package/src/chat/turnStatus.test.ts +10 -0
- package/src/chat/turnStatus.ts +9 -2
package/package.json
CHANGED
|
@@ -424,6 +424,77 @@ describe("SendBox — attaching files", () => {
|
|
|
424
424
|
expect(screen.getByText(/10MB limit/)).toBeTruthy();
|
|
425
425
|
});
|
|
426
426
|
|
|
427
|
+
it("blocks send while an attachment is still uploading", () => {
|
|
428
|
+
// A send issued in this window has nothing server-side to claim yet
|
|
429
|
+
// (services.claim_pending_attachments runs synchronously at send time) —
|
|
430
|
+
// the file lands moments later as an orphaned row, unattached to anything.
|
|
431
|
+
const onSend = vi.fn();
|
|
432
|
+
render(
|
|
433
|
+
<SendBox
|
|
434
|
+
{...attachProps}
|
|
435
|
+
onSend={onSend}
|
|
436
|
+
onAttach={vi.fn()}
|
|
437
|
+
attachments={[{ id: "a2", filename: "slow.png", uploading: true }]}
|
|
438
|
+
/>,
|
|
439
|
+
);
|
|
440
|
+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "look at this" } });
|
|
441
|
+
|
|
442
|
+
expect(screen.getByRole("button", { name: /send/i }).hasAttribute("disabled")).toBe(true);
|
|
443
|
+
fireEvent.click(screen.getByRole("button", { name: /send/i }));
|
|
444
|
+
expect(onSend).not.toHaveBeenCalled();
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("blocks Enter too while an attachment is still uploading", () => {
|
|
448
|
+
const onSend = vi.fn();
|
|
449
|
+
render(
|
|
450
|
+
<SendBox
|
|
451
|
+
{...attachProps}
|
|
452
|
+
onSend={onSend}
|
|
453
|
+
onAttach={vi.fn()}
|
|
454
|
+
attachments={[{ id: "a2", filename: "slow.png", uploading: true }]}
|
|
455
|
+
/>,
|
|
456
|
+
);
|
|
457
|
+
const textarea = screen.getByRole("textbox");
|
|
458
|
+
fireEvent.change(textarea, { target: { value: "look at this" } });
|
|
459
|
+
fireEvent.keyDown(textarea, { key: "Enter" });
|
|
460
|
+
|
|
461
|
+
expect(onSend).not.toHaveBeenCalled();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("allows send again once every attachment has finished uploading", () => {
|
|
465
|
+
const onSend = vi.fn();
|
|
466
|
+
render(
|
|
467
|
+
<SendBox
|
|
468
|
+
{...attachProps}
|
|
469
|
+
onSend={onSend}
|
|
470
|
+
onAttach={vi.fn()}
|
|
471
|
+
attachments={[{ id: "a2", filename: "slow.png" }]}
|
|
472
|
+
/>,
|
|
473
|
+
);
|
|
474
|
+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "look at this" } });
|
|
475
|
+
|
|
476
|
+
fireEvent.click(screen.getByRole("button", { name: /send/i }));
|
|
477
|
+
expect(onSend).toHaveBeenCalled();
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("a failed upload does not block sending the rest of the message", () => {
|
|
481
|
+
// Unlike `uploading`, `error` means the upload is DONE (unsuccessfully) —
|
|
482
|
+
// nothing is pending, so there is nothing a send would race against.
|
|
483
|
+
const onSend = vi.fn();
|
|
484
|
+
render(
|
|
485
|
+
<SendBox
|
|
486
|
+
{...attachProps}
|
|
487
|
+
onSend={onSend}
|
|
488
|
+
onAttach={vi.fn()}
|
|
489
|
+
attachments={[{ id: "a3", filename: "huge.png", error: "file is larger than the 10MB limit" }]}
|
|
490
|
+
/>,
|
|
491
|
+
);
|
|
492
|
+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "never mind the file" } });
|
|
493
|
+
|
|
494
|
+
fireEvent.click(screen.getByRole("button", { name: /send/i }));
|
|
495
|
+
expect(onSend).toHaveBeenCalled();
|
|
496
|
+
});
|
|
497
|
+
|
|
427
498
|
it("does not offer attaching while sending is blocked", () => {
|
|
428
499
|
// Attaching used to be gated on a teammate's draft lock, which no longer
|
|
429
500
|
// exists; the one real reason to withhold it now is `disabledReason`.
|
package/src/chat/SendBox.tsx
CHANGED
|
@@ -161,6 +161,13 @@ export function SendBox({
|
|
|
161
161
|
// flushed when the draft exists (see useSessionSocket.sendChat).
|
|
162
162
|
const body = localBody;
|
|
163
163
|
const blocked = Boolean(disabledReason);
|
|
164
|
+
const staged = attachments ?? [];
|
|
165
|
+
// An upload still in flight has no server-side Attachment row yet, so a send
|
|
166
|
+
// issued in that window finds nothing to claim (services.claim_pending_attachments
|
|
167
|
+
// runs synchronously at send time, scoped to the sender) — the file lands
|
|
168
|
+
// moments later as an orphaned row, and the message that was supposed to
|
|
169
|
+
// carry it has none.
|
|
170
|
+
const attachmentsUploading = staged.some((a) => a.uploading);
|
|
164
171
|
// Sending needs a draft (`chat.send` commits the SERVER's copy, so there must
|
|
165
172
|
// be one) AND a live socket. The socket check is load-bearing now that the
|
|
166
173
|
// composer clears optimistically: `send()` drops every frame but chat.stop
|
|
@@ -172,7 +179,8 @@ export function SendBox({
|
|
|
172
179
|
connected &&
|
|
173
180
|
draft != null &&
|
|
174
181
|
body.trim().length > 0 &&
|
|
175
|
-
!blocked
|
|
182
|
+
!blocked &&
|
|
183
|
+
!attachmentsUploading;
|
|
176
184
|
|
|
177
185
|
const handleChange = (value: string) => {
|
|
178
186
|
setLocalBody(value);
|
|
@@ -230,9 +238,9 @@ export function SendBox({
|
|
|
230
238
|
? "Type a message… (connecting…)"
|
|
231
239
|
: isStreaming
|
|
232
240
|
? "Type a message — it will be sent after the current reply"
|
|
233
|
-
:
|
|
234
|
-
|
|
235
|
-
|
|
241
|
+
: attachmentsUploading
|
|
242
|
+
? "Waiting for attachment to finish uploading…"
|
|
243
|
+
: "Type a message… (Enter to send, Shift+Enter for newline)";
|
|
236
244
|
|
|
237
245
|
return (
|
|
238
246
|
<div
|
package/src/chat/protocol.ts
CHANGED
|
@@ -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…");
|
package/src/chat/turnStatus.ts
CHANGED
|
@@ -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
|
|
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}…`;
|