@stigmer/react 3.8.0 → 3.9.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.
Files changed (52) hide show
  1. package/attachment/AttachmentChipList.d.ts.map +1 -1
  2. package/attachment/AttachmentChipList.js +30 -1
  3. package/attachment/AttachmentChipList.js.map +1 -1
  4. package/attachment/attachment-utils.d.ts +11 -0
  5. package/attachment/attachment-utils.d.ts.map +1 -1
  6. package/attachment/attachment-utils.js +23 -0
  7. package/attachment/attachment-utils.js.map +1 -1
  8. package/attachment/clipboard.d.ts +51 -0
  9. package/attachment/clipboard.d.ts.map +1 -0
  10. package/attachment/clipboard.js +89 -0
  11. package/attachment/clipboard.js.map +1 -0
  12. package/attachment/index.d.ts +6 -1
  13. package/attachment/index.d.ts.map +1 -1
  14. package/attachment/index.js +4 -1
  15. package/attachment/index.js.map +1 -1
  16. package/attachment/prepare-image.d.ts +43 -0
  17. package/attachment/prepare-image.d.ts.map +1 -0
  18. package/attachment/prepare-image.js +162 -0
  19. package/attachment/prepare-image.js.map +1 -0
  20. package/attachment/useAttachments.d.ts.map +1 -1
  21. package/attachment/useAttachments.js +23 -3
  22. package/attachment/useAttachments.js.map +1 -1
  23. package/attachment/vision-fit.d.ts +50 -0
  24. package/attachment/vision-fit.d.ts.map +1 -0
  25. package/attachment/vision-fit.js +76 -0
  26. package/attachment/vision-fit.js.map +1 -0
  27. package/composer/SessionComposer.d.ts +3 -1
  28. package/composer/SessionComposer.d.ts.map +1 -1
  29. package/composer/SessionComposer.js +50 -4
  30. package/composer/SessionComposer.js.map +1 -1
  31. package/index.d.ts +2 -2
  32. package/index.d.ts.map +1 -1
  33. package/index.js +3 -2
  34. package/index.js.map +1 -1
  35. package/package.json +4 -4
  36. package/src/attachment/AttachmentChipList.tsx +45 -1
  37. package/src/attachment/__tests__/AttachmentChipList.test.tsx +103 -0
  38. package/src/attachment/__tests__/attachment-utils.test.ts +54 -0
  39. package/src/attachment/__tests__/clipboard.test.ts +110 -0
  40. package/src/attachment/__tests__/prepare-image.browser.test.ts +166 -0
  41. package/src/attachment/__tests__/prepare-image.test.ts +32 -0
  42. package/src/attachment/__tests__/vision-fit.test.ts +93 -0
  43. package/src/attachment/attachment-utils.ts +27 -0
  44. package/src/attachment/clipboard.ts +102 -0
  45. package/src/attachment/index.ts +13 -0
  46. package/src/attachment/prepare-image.ts +179 -0
  47. package/src/attachment/useAttachments.ts +26 -2
  48. package/src/attachment/vision-fit.ts +90 -0
  49. package/src/composer/SessionComposer.tsx +73 -4
  50. package/src/composer/__tests__/SessionComposer-paste.test.tsx +263 -0
  51. package/src/composer/__tests__/SessionComposer-uploadGate.test.tsx +239 -0
  52. package/src/index.ts +11 -1
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from "react";
3
+ import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type ClipboardEvent, type DragEvent, type KeyboardEvent } from "react";
4
4
  import { cn } from "@stigmer/theme";
5
5
  import { getUserMessage, type AttachmentInput, type EnvVarInput, type McpServerUsageInput, type ResourceRef } from "@stigmer/sdk";
6
6
  import { useComposer } from "./useComposer.js";
@@ -25,6 +25,8 @@ import type { UseWorkspaceEntriesReturn } from "../workspace/useWorkspaceEntries
25
25
  import type { UseGitHubConnectionReturn } from "../github/useGitHubConnection.js";
26
26
  import { useAttachments } from "../attachment/useAttachments.js";
27
27
  import { AttachmentChipList } from "../attachment/AttachmentChipList.js";
28
+ import { extractClipboardFiles } from "../attachment/clipboard.js";
29
+ import { prepareImageForVision } from "../attachment/prepare-image.js";
28
30
  import { useFileReferences } from "../file-reference/useFileReferences.js";
29
31
  import { FileReferenceChipList } from "../file-reference/FileReferenceChipList.js";
30
32
  import { FILE_REF_MIME } from "../internal/file-tree/index.js";
@@ -42,6 +44,7 @@ import {
42
44
  SkillIcon,
43
45
  SecretsIcon,
44
46
  AlertTriangleIcon,
47
+ ChipSpinner,
45
48
  ResolveSpinner,
46
49
  XIcon,
47
50
  } from "./icons.js";
@@ -439,7 +442,9 @@ export interface SessionComposerProps {
439
442
  * Enable file attachment support in the composer.
440
443
  *
441
444
  * When `true`, renders an attach button in the toolbar and enables
442
- * drag-and-drop file upload on the textarea. Attachments are uploaded
445
+ * drag-and-drop file upload plus clipboard paste on the textarea
446
+ * pasting a screenshot attaches it exactly like a picked file, with
447
+ * a generated `pasted-image-*` filename. Attachments are uploaded
443
448
  * immediately via `agentExecution.uploadAttachment()` and included
444
449
  * in `context.attachments` on submit.
445
450
  *
@@ -794,6 +799,34 @@ const SessionComposerInner = forwardRef<SessionComposerHandle, SessionComposerPr
794
799
  [enableAttachments, enableFileReferences, isDisabled, attachments, fileRefs],
795
800
  );
796
801
 
802
+ // Clipboard paste — the Cursor-grade "screenshot straight into the chat"
803
+ // gesture (#284). Scoped to the textarea, never the document: a global
804
+ // clipboard listener would hijack paste for the host application.
805
+ const handlePaste = useCallback(
806
+ (e: ClipboardEvent<HTMLTextAreaElement>) => {
807
+ if (!enableAttachments || isDisabled) return;
808
+
809
+ // Must stay synchronous through preventDefault: clipboard file
810
+ // handles are only reliable during event dispatch (see clipboard.ts).
811
+ const files = extractClipboardFiles(e);
812
+ if (files.length === 0) return;
813
+
814
+ // Files replace the default insert — a copied image usually carries
815
+ // an HTML/text flavor that would paste as junk markup. A text-only
816
+ // paste never reaches this point and proceeds untouched.
817
+ e.preventDefault();
818
+
819
+ // Pasted images (and only pasted — see prepare-image.ts) are bounded
820
+ // to provider resolution before upload. prepareImageForVision never
821
+ // throws; every failure path yields the original file.
822
+ const addFiles = attachments.addFiles;
823
+ void Promise.all(files.map(prepareImageForVision)).then((prepared) => {
824
+ addFiles(prepared);
825
+ });
826
+ },
827
+ [enableAttachments, isDisabled, attachments.addFiles],
828
+ );
829
+
797
830
  // ---------------------------------------------------------------------------
798
831
  // Submit — aggregates one-time runtimeEnv from all setup flows
799
832
  // ---------------------------------------------------------------------------
@@ -1191,7 +1224,23 @@ const SessionComposerInner = forwardRef<SessionComposerHandle, SessionComposerPr
1191
1224
  // ---------------------------------------------------------------------------
1192
1225
 
1193
1226
  const mcpBlocked = showMcp && !mcpSetup.allReady;
1194
- const canSend = composer.canSubmit && !mcpBlocked;
1227
+ // Send waits for in-flight uploads: toAttachmentInputs() carries only
1228
+ // "ready" entries, so a send racing an upload would silently drop the
1229
+ // file the user just pasted — the worst failure for "what's wrong in
1230
+ // this screenshot?". Errored entries never gate (their chip offers
1231
+ // retry/remove); only the uploading phase blocks.
1232
+ const uploadsBlocked = enableAttachments && attachments.isUploading;
1233
+ const canSend = composer.canSubmit && !mcpBlocked && !uploadsBlocked;
1234
+
1235
+ // True when the upload gate is the OPERATIVE blocker — the message would
1236
+ // send right now if uploads were done. Drives the attempt-triggered wait
1237
+ // notice so it never claims "waiting for attachments" when the real
1238
+ // blocker is an empty message or MCP setup.
1239
+ const uploadWaitOperative = composer.canSubmit && !mcpBlocked && uploadsBlocked;
1240
+ const [showUploadWaitNotice, setShowUploadWaitNotice] = useState(false);
1241
+ useEffect(() => {
1242
+ if (!uploadsBlocked) setShowUploadWaitNotice(false);
1243
+ }, [uploadsBlocked]);
1195
1244
 
1196
1245
  const handleTextareaKeyDown = useCallback(
1197
1246
  (e: KeyboardEvent<HTMLTextAreaElement>) => {
@@ -1205,11 +1254,15 @@ const SessionComposerInner = forwardRef<SessionComposerHandle, SessionComposerPr
1205
1254
  }
1206
1255
  if (!canSend && e.key === "Enter" && !e.shiftKey) {
1207
1256
  e.preventDefault();
1257
+ // Disclose the block only on an actual send attempt: a sub-second
1258
+ // picker upload should never flash UI, and the per-chip spinner
1259
+ // already covers passive awareness.
1260
+ if (uploadWaitOperative) setShowUploadWaitNotice(true);
1208
1261
  return;
1209
1262
  }
1210
1263
  composer.textareaProps.onKeyDown(e);
1211
1264
  },
1212
- [canSend, composer.textareaProps, isEditing, onCancelEdit],
1265
+ [canSend, uploadWaitOperative, composer.textareaProps, isEditing, onCancelEdit],
1213
1266
  );
1214
1267
 
1215
1268
  const workspaceCount = workspace?.entries.length ?? 0;
@@ -1487,6 +1540,7 @@ const SessionComposerInner = forwardRef<SessionComposerHandle, SessionComposerPr
1487
1540
  <textarea
1488
1541
  {...composer.textareaProps}
1489
1542
  onKeyDown={handleTextareaKeyDown}
1543
+ onPaste={handlePaste}
1490
1544
  placeholder={placeholder}
1491
1545
  rows={initialRows}
1492
1546
  autoFocus={autoFocus}
@@ -1535,6 +1589,21 @@ const SessionComposerInner = forwardRef<SessionComposerHandle, SessionComposerPr
1535
1589
  </div>
1536
1590
  ) : null}
1537
1591
 
1592
+ {/* Zone 2.6: Upload-wait notice — shown only after an attempted send
1593
+ while attachments are still uploading (attempt-triggered; see
1594
+ uploadWaitOperative). Muted status styling, not warning: waiting
1595
+ is progress, not a problem. role="status" announces the row to
1596
+ screen readers when it appears. */}
1597
+ {showUploadWaitNotice && (
1598
+ <div
1599
+ role="status"
1600
+ className="mx-3 mb-2 flex items-center gap-2 rounded-md bg-muted px-2.5 py-1.5 text-xs text-muted-foreground"
1601
+ >
1602
+ <ChipSpinner />
1603
+ <span>Waiting for attachments to finish uploading…</span>
1604
+ </div>
1605
+ )}
1606
+
1538
1607
  {/* Zone 2.7: Agent setup warning */}
1539
1608
  {showAgent &&
1540
1609
  agentSetup.state.status === "needsEnvVars" &&
@@ -0,0 +1,263 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import {
3
+ render,
4
+ screen,
5
+ cleanup,
6
+ fireEvent,
7
+ createEvent,
8
+ act,
9
+ waitFor,
10
+ } from "@testing-library/react";
11
+ import type { ReactNode } from "react";
12
+ import type { Stigmer } from "@stigmer/sdk";
13
+ import { StigmerContext } from "../../context";
14
+ import { ModelRegistryContext } from "../../models/ModelRegistryContext";
15
+ import { SessionComposer } from "../SessionComposer";
16
+ import { MAX_ATTACHMENT_BYTES } from "../../attachment/attachment-utils";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Helpers
20
+ // ---------------------------------------------------------------------------
21
+
22
+ function createMinimalStigmerMock(): Stigmer {
23
+ return {
24
+ agentExecution: {
25
+ uploadAttachment: vi
26
+ .fn()
27
+ .mockResolvedValue({ storageKey: "attachments/test-ulid/file" }),
28
+ },
29
+ environment: { getPersonal: vi.fn().mockResolvedValue(null) },
30
+ baseUrl: "http://localhost:8080",
31
+ getAuthCredential: vi.fn().mockResolvedValue("test-token"),
32
+ config: {
33
+ baseUrl: "http://localhost:8080",
34
+ getAccessToken: vi.fn().mockResolvedValue(""),
35
+ },
36
+ } as unknown as Stigmer;
37
+ }
38
+
39
+ function createWrapper(client: Stigmer) {
40
+ return function Wrapper({ children }: { children: ReactNode }) {
41
+ return (
42
+ <StigmerContext.Provider value={client}>
43
+ <ModelRegistryContext.Provider
44
+ value={{ models: [], isLoading: false, error: null, refetch: vi.fn() }}
45
+ >
46
+ {children}
47
+ </ModelRegistryContext.Provider>
48
+ </StigmerContext.Provider>
49
+ );
50
+ };
51
+ }
52
+
53
+ function renderComposer(
54
+ props?: Partial<React.ComponentProps<typeof SessionComposer>>,
55
+ ) {
56
+ const client = createMinimalStigmerMock();
57
+ const onSubmit = vi.fn();
58
+ const onAttachmentValidationError = vi.fn();
59
+
60
+ const result = render(
61
+ <SessionComposer
62
+ onSubmit={onSubmit}
63
+ onAttachmentValidationError={onAttachmentValidationError}
64
+ {...props}
65
+ />,
66
+ { wrapper: createWrapper(client) },
67
+ );
68
+
69
+ return { ...result, onSubmit, onAttachmentValidationError, client };
70
+ }
71
+
72
+ function pngFile(
73
+ name: string,
74
+ bytes: Uint8Array<ArrayBuffer> = new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
75
+ ): File {
76
+ return new File([bytes], name, { type: "image/png" });
77
+ }
78
+
79
+ /**
80
+ * dom-testing-library special-cases `clipboardData` in event init, so the
81
+ * handler receives it exactly as a real paste would deliver it. Files are
82
+ * a plain array cast to FileList — the code only reads it array-like.
83
+ */
84
+ function pasteInit(files: File[], text = "") {
85
+ return {
86
+ clipboardData: {
87
+ files: files as unknown as FileList,
88
+ getData: (type: string) => (type === "text/plain" ? text : ""),
89
+ types: files.length > 0 ? ["Files"] : ["text/plain"],
90
+ },
91
+ };
92
+ }
93
+
94
+ /** Wait until no chip reports "uploading" in its accessible label. */
95
+ async function waitForUploadsSettled(container: HTMLElement) {
96
+ await waitFor(() => {
97
+ const uploading = container.querySelector('[aria-label*=", uploading"]');
98
+ expect(uploading).toBeNull();
99
+ });
100
+ }
101
+
102
+ afterEach(cleanup);
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Tests
106
+ // ---------------------------------------------------------------------------
107
+
108
+ describe("SessionComposer — clipboard paste", () => {
109
+ it("attaches a pasted screenshot as a chip and uploads its bytes", async () => {
110
+ const { container, client } = renderComposer();
111
+ const textarea = screen.getByRole("textbox");
112
+
113
+ await act(async () => {
114
+ fireEvent.paste(textarea, pasteInit([pngFile("image.png")]));
115
+ });
116
+
117
+ // The generic clipboard name is replaced by a synthesized unique one.
118
+ const chip = container.querySelector('[role="listitem"]');
119
+ expect(chip).toBeTruthy();
120
+ expect(chip!.getAttribute("aria-label")).toMatch(/^pasted-image-\d{6}-\d+\.png/);
121
+
122
+ await waitFor(() => {
123
+ expect(
124
+ (client as unknown as { agentExecution: { uploadAttachment: ReturnType<typeof vi.fn> } })
125
+ .agentExecution.uploadAttachment,
126
+ ).toHaveBeenCalledTimes(1);
127
+ });
128
+ });
129
+
130
+ it("suppresses the default text insert when the clipboard carries files", async () => {
131
+ renderComposer();
132
+ const textarea = screen.getByRole("textbox");
133
+
134
+ const event = createEvent.paste(textarea, pasteInit([pngFile("image.png")], "<img src=...>"));
135
+ await act(async () => {
136
+ fireEvent(textarea, event);
137
+ });
138
+
139
+ expect(event.defaultPrevented).toBe(true);
140
+ });
141
+
142
+ it("leaves a text-only paste completely untouched", async () => {
143
+ const { container } = renderComposer();
144
+ const textarea = screen.getByRole("textbox");
145
+
146
+ const event = createEvent.paste(textarea, pasteInit([], "plain words"));
147
+ await act(async () => {
148
+ fireEvent(textarea, event);
149
+ });
150
+
151
+ expect(event.defaultPrevented).toBe(false);
152
+ expect(container.querySelector('[role="listitem"]')).toBeNull();
153
+ });
154
+
155
+ it("gives two screenshots pasted in one turn distinct filenames", async () => {
156
+ const { container } = renderComposer();
157
+ const textarea = screen.getByRole("textbox");
158
+
159
+ await act(async () => {
160
+ fireEvent.paste(textarea, pasteInit([pngFile("image.png")]));
161
+ });
162
+ await act(async () => {
163
+ fireEvent.paste(textarea, pasteInit([pngFile("image.png")]));
164
+ });
165
+
166
+ const chips = Array.from(container.querySelectorAll('[role="listitem"]'));
167
+ expect(chips).toHaveLength(2);
168
+ const labels = chips.map((c) => c.getAttribute("aria-label"));
169
+ expect(labels[0]).not.toBe(labels[1]);
170
+ });
171
+
172
+ it("is inert when attachments are disabled (guest mode)", async () => {
173
+ const { container } = renderComposer({ enableAttachments: false });
174
+ const textarea = screen.getByRole("textbox");
175
+
176
+ const event = createEvent.paste(textarea, pasteInit([pngFile("image.png")]));
177
+ await act(async () => {
178
+ fireEvent(textarea, event);
179
+ });
180
+
181
+ expect(event.defaultPrevented).toBe(false);
182
+ expect(container.querySelector('[role="listitem"]')).toBeNull();
183
+ });
184
+
185
+ it("is inert while the composer is disabled", async () => {
186
+ const { container } = renderComposer({ disabled: true });
187
+ const textarea = screen.getByRole("textbox");
188
+
189
+ const event = createEvent.paste(textarea, pasteInit([pngFile("image.png")]));
190
+ await act(async () => {
191
+ fireEvent(textarea, event);
192
+ });
193
+
194
+ expect(event.defaultPrevented).toBe(false);
195
+ expect(container.querySelector('[role="listitem"]')).toBeNull();
196
+ });
197
+
198
+ it("routes an oversized paste through onAttachmentValidationError with no chip", async () => {
199
+ const { container, onAttachmentValidationError } = renderComposer();
200
+ const textarea = screen.getByRole("textbox");
201
+
202
+ const huge = pngFile("image.png", new Uint8Array(MAX_ATTACHMENT_BYTES + 1));
203
+ await act(async () => {
204
+ fireEvent.paste(textarea, pasteInit([huge]));
205
+ });
206
+
207
+ expect(onAttachmentValidationError).toHaveBeenCalledTimes(1);
208
+ expect(onAttachmentValidationError.mock.calls[0][0]).toContain("10 MB");
209
+ expect(container.querySelector('[role="listitem"]')).toBeNull();
210
+ });
211
+
212
+ it("includes the pasted attachment in the submit context", async () => {
213
+ const { container, onSubmit } = renderComposer();
214
+ const textarea = screen.getByRole("textbox");
215
+
216
+ await act(async () => {
217
+ fireEvent.paste(textarea, pasteInit([pngFile("image.png")]));
218
+ });
219
+ await waitForUploadsSettled(container);
220
+
221
+ await act(async () => {
222
+ fireEvent.change(textarea, { target: { value: "What is wrong in this screenshot?" } });
223
+ });
224
+ await act(async () => {
225
+ fireEvent.keyDown(textarea, { key: "Enter", shiftKey: false });
226
+ });
227
+
228
+ expect(onSubmit).toHaveBeenCalledTimes(1);
229
+ const [, , context] = onSubmit.mock.calls[0];
230
+ expect(context?.attachments).toHaveLength(1);
231
+ expect(context.attachments[0].filename).toMatch(/^pasted-image-/);
232
+ expect(context.attachments[0].storageKey).toBe("attachments/test-ulid/file");
233
+ expect(context.attachments[0].contentType).toBe("image/png");
234
+ });
235
+
236
+ it("keeps the drop path working: same-named drops are uniquified, not clobbered", async () => {
237
+ const { container } = renderComposer();
238
+ const dropTarget = container.querySelector("[class*='rounded-xl']")!;
239
+
240
+ const dropEvent = (files: File[]) => ({
241
+ dataTransfer: {
242
+ types: ["Files"],
243
+ getData: () => "",
244
+ files: files as unknown as FileList,
245
+ },
246
+ preventDefault: vi.fn(),
247
+ stopPropagation: vi.fn(),
248
+ });
249
+
250
+ await act(async () => {
251
+ fireEvent.drop(
252
+ dropTarget,
253
+ dropEvent([
254
+ new File(["a"], "notes.md", { type: "text/markdown" }),
255
+ new File(["b"], "notes.md", { type: "text/markdown" }),
256
+ ]),
257
+ );
258
+ });
259
+
260
+ expect(screen.getByText("notes.md")).toBeTruthy();
261
+ expect(screen.getByText("notes-2.md")).toBeTruthy();
262
+ });
263
+ });
@@ -0,0 +1,239 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import {
3
+ render,
4
+ screen,
5
+ cleanup,
6
+ fireEvent,
7
+ act,
8
+ waitFor,
9
+ } from "@testing-library/react";
10
+ import type { ReactNode } from "react";
11
+ import type { Stigmer } from "@stigmer/sdk";
12
+ import { StigmerContext } from "../../context";
13
+ import { ModelRegistryContext } from "../../models/ModelRegistryContext";
14
+ import { SessionComposer } from "../SessionComposer";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const WAIT_NOTICE = "Waiting for attachments to finish uploading…";
21
+
22
+ /**
23
+ * Stigmer mock whose uploadAttachment resolution is controlled by the test,
24
+ * so a test can hold the composer in the "uploading" phase deliberately.
25
+ */
26
+ function createDeferredUploadMock() {
27
+ const pending: Array<(value: { storageKey: string }) => void> = [];
28
+ const rejectors: Array<(err: Error) => void> = [];
29
+
30
+ const uploadAttachment = vi.fn(
31
+ () =>
32
+ new Promise<{ storageKey: string }>((resolve, reject) => {
33
+ pending.push(resolve);
34
+ rejectors.push(reject);
35
+ }),
36
+ );
37
+
38
+ const client = {
39
+ agentExecution: { uploadAttachment },
40
+ environment: { getPersonal: vi.fn().mockResolvedValue(null) },
41
+ baseUrl: "http://localhost:8080",
42
+ getAuthCredential: vi.fn().mockResolvedValue("test-token"),
43
+ config: {
44
+ baseUrl: "http://localhost:8080",
45
+ getAccessToken: vi.fn().mockResolvedValue(""),
46
+ },
47
+ } as unknown as Stigmer;
48
+
49
+ return {
50
+ client,
51
+ uploadAttachment,
52
+ resolveAll: () => {
53
+ for (const resolve of pending.splice(0)) {
54
+ resolve({ storageKey: "attachments/test-ulid/file" });
55
+ }
56
+ rejectors.length = 0;
57
+ },
58
+ rejectAll: () => {
59
+ for (const reject of rejectors.splice(0)) {
60
+ reject(new Error("upload failed for test"));
61
+ }
62
+ pending.length = 0;
63
+ },
64
+ };
65
+ }
66
+
67
+ function createWrapper(client: Stigmer) {
68
+ return function Wrapper({ children }: { children: ReactNode }) {
69
+ return (
70
+ <StigmerContext.Provider value={client}>
71
+ <ModelRegistryContext.Provider
72
+ value={{ models: [], isLoading: false, error: null, refetch: vi.fn() }}
73
+ >
74
+ {children}
75
+ </ModelRegistryContext.Provider>
76
+ </StigmerContext.Provider>
77
+ );
78
+ };
79
+ }
80
+
81
+ function renderComposer(client: Stigmer) {
82
+ const onSubmit = vi.fn();
83
+ const result = render(<SessionComposer onSubmit={onSubmit} />, {
84
+ wrapper: createWrapper(client),
85
+ });
86
+ return { ...result, onSubmit };
87
+ }
88
+
89
+ function pasteImage(textarea: Element) {
90
+ fireEvent.paste(textarea, {
91
+ clipboardData: {
92
+ files: [
93
+ new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "image.png", {
94
+ type: "image/png",
95
+ }),
96
+ ] as unknown as FileList,
97
+ getData: () => "",
98
+ types: ["Files"],
99
+ },
100
+ });
101
+ }
102
+
103
+ function typeAndEnter(textarea: Element, message: string) {
104
+ fireEvent.change(textarea, { target: { value: message } });
105
+ fireEvent.keyDown(textarea, { key: "Enter", shiftKey: false });
106
+ }
107
+
108
+ afterEach(cleanup);
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Tests
112
+ // ---------------------------------------------------------------------------
113
+
114
+ describe("SessionComposer — mid-upload send gate", () => {
115
+ it("blocks Enter while an upload is in flight and shows the wait notice", async () => {
116
+ const mock = createDeferredUploadMock();
117
+ const { onSubmit } = renderComposer(mock.client);
118
+ const textarea = screen.getByRole("textbox");
119
+
120
+ await act(async () => {
121
+ pasteImage(textarea);
122
+ });
123
+ await act(async () => {
124
+ typeAndEnter(textarea, "What is wrong in this screenshot?");
125
+ });
126
+
127
+ expect(onSubmit).not.toHaveBeenCalled();
128
+ expect(screen.getByText(WAIT_NOTICE)).toBeTruthy();
129
+ expect(screen.getByText(WAIT_NOTICE).closest('[role="status"]')).toBeTruthy();
130
+ });
131
+
132
+ it("clears the notice and sends WITH the attachment once uploads settle", async () => {
133
+ const mock = createDeferredUploadMock();
134
+ const { onSubmit } = renderComposer(mock.client);
135
+ const textarea = screen.getByRole("textbox");
136
+
137
+ await act(async () => {
138
+ pasteImage(textarea);
139
+ });
140
+ await act(async () => {
141
+ typeAndEnter(textarea, "What is wrong here?");
142
+ });
143
+ expect(onSubmit).not.toHaveBeenCalled();
144
+
145
+ await act(async () => {
146
+ mock.resolveAll();
147
+ });
148
+
149
+ await waitFor(() => {
150
+ expect(screen.queryByText(WAIT_NOTICE)).toBeNull();
151
+ });
152
+
153
+ await act(async () => {
154
+ fireEvent.keyDown(textarea, { key: "Enter", shiftKey: false });
155
+ });
156
+
157
+ expect(onSubmit).toHaveBeenCalledTimes(1);
158
+ const [message, , context] = onSubmit.mock.calls[0];
159
+ expect(message).toBe("What is wrong here?");
160
+ expect(context?.attachments).toHaveLength(1);
161
+ expect(context.attachments[0].storageKey).toBe("attachments/test-ulid/file");
162
+ });
163
+
164
+ it("does not show the notice when Enter is blocked for another reason (empty message)", async () => {
165
+ const mock = createDeferredUploadMock();
166
+ renderComposer(mock.client);
167
+ const textarea = screen.getByRole("textbox");
168
+
169
+ await act(async () => {
170
+ pasteImage(textarea);
171
+ });
172
+ // Enter with an empty message: blocked by canSubmit, not by uploads —
173
+ // claiming "waiting for attachments" here would be a lie.
174
+ await act(async () => {
175
+ fireEvent.keyDown(textarea, { key: "Enter", shiftKey: false });
176
+ });
177
+
178
+ expect(screen.queryByText(WAIT_NOTICE)).toBeNull();
179
+ });
180
+
181
+ it("does not wedge the composer when an upload errors — send proceeds without the failed file", async () => {
182
+ const mock = createDeferredUploadMock();
183
+ const { onSubmit } = renderComposer(mock.client);
184
+ const textarea = screen.getByRole("textbox");
185
+
186
+ await act(async () => {
187
+ pasteImage(textarea);
188
+ });
189
+ await act(async () => {
190
+ mock.rejectAll();
191
+ });
192
+
193
+ // The errored chip stays visible with retry/remove, but must not gate.
194
+ await waitFor(() => {
195
+ const chip = document.querySelector('[aria-label*="upload failed"]');
196
+ expect(chip).toBeTruthy();
197
+ });
198
+
199
+ await act(async () => {
200
+ typeAndEnter(textarea, "Send it anyway");
201
+ });
202
+
203
+ expect(onSubmit).toHaveBeenCalledTimes(1);
204
+ // The failed upload has no storage key — it must not ride the send.
205
+ const [, , context] = onSubmit.mock.calls[0];
206
+ expect(context?.attachments ?? []).toHaveLength(0);
207
+ });
208
+
209
+ it("gates a fast follow-up paste too: Enter between two uploads still waits for the second", async () => {
210
+ const mock = createDeferredUploadMock();
211
+ const { onSubmit } = renderComposer(mock.client);
212
+ const textarea = screen.getByRole("textbox");
213
+
214
+ await act(async () => {
215
+ pasteImage(textarea);
216
+ });
217
+ await act(async () => {
218
+ mock.resolveAll();
219
+ });
220
+ await act(async () => {
221
+ pasteImage(textarea);
222
+ });
223
+
224
+ await act(async () => {
225
+ typeAndEnter(textarea, "Compare these two");
226
+ });
227
+ expect(onSubmit).not.toHaveBeenCalled();
228
+
229
+ await act(async () => {
230
+ mock.resolveAll();
231
+ });
232
+ await act(async () => {
233
+ fireEvent.keyDown(textarea, { key: "Enter", shiftKey: false });
234
+ });
235
+
236
+ expect(onSubmit).toHaveBeenCalledTimes(1);
237
+ expect(onSubmit.mock.calls[0][2]?.attachments).toHaveLength(2);
238
+ });
239
+ });
package/src/index.ts CHANGED
@@ -463,21 +463,31 @@ export type {
463
463
  export type { ExecutionArtifact } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/artifact_pb";
464
464
  export { ExecutionArtifactKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
465
465
 
466
- // Attachment — file upload behavior hook and styled chip list
466
+ // Attachment — file upload behavior hook, styled chip list, clipboard paste,
467
+ // and vision-resolution image preparation
467
468
  export {
468
469
  useAttachments,
469
470
  AttachmentChipList,
470
471
  MAX_ATTACHMENT_BYTES,
472
+ MAX_VISION_LONG_EDGE_PX,
473
+ MAX_VISION_PIXELS,
471
474
  detectContentType,
475
+ exceedsVisionResolution,
476
+ extractClipboardFiles,
477
+ fitToVisionResolution,
472
478
  formatFileSize,
479
+ prepareImageForVision,
480
+ uniquifyFilename,
473
481
  validateAttachmentSize,
474
482
  } from "./attachment/index.js";
475
483
  export type {
476
484
  AttachmentPhase,
477
485
  AttachmentEntry,
486
+ ClipboardFilesSource,
478
487
  UseAttachmentsOptions,
479
488
  UseAttachmentsReturn,
480
489
  AttachmentChipListProps,
490
+ VisionFitSize,
481
491
  } from "./attachment/index.js";
482
492
 
483
493
  // File Reference — workspace file-reference behavior hook and styled chip list