canopy-ui 0.15.1 → 0.15.2

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.15.1",
3
+ "version": "0.15.2",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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`.
@@ -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
- : "Type a message… (Enter to send, Shift+Enter for newline)";
234
-
235
- const staged = attachments ?? [];
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