@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.
- package/attachment/AttachmentChipList.d.ts.map +1 -1
- package/attachment/AttachmentChipList.js +30 -1
- package/attachment/AttachmentChipList.js.map +1 -1
- package/attachment/attachment-utils.d.ts +11 -0
- package/attachment/attachment-utils.d.ts.map +1 -1
- package/attachment/attachment-utils.js +23 -0
- package/attachment/attachment-utils.js.map +1 -1
- package/attachment/clipboard.d.ts +51 -0
- package/attachment/clipboard.d.ts.map +1 -0
- package/attachment/clipboard.js +89 -0
- package/attachment/clipboard.js.map +1 -0
- package/attachment/index.d.ts +6 -1
- package/attachment/index.d.ts.map +1 -1
- package/attachment/index.js +4 -1
- package/attachment/index.js.map +1 -1
- package/attachment/prepare-image.d.ts +43 -0
- package/attachment/prepare-image.d.ts.map +1 -0
- package/attachment/prepare-image.js +162 -0
- package/attachment/prepare-image.js.map +1 -0
- package/attachment/useAttachments.d.ts.map +1 -1
- package/attachment/useAttachments.js +23 -3
- package/attachment/useAttachments.js.map +1 -1
- package/attachment/vision-fit.d.ts +50 -0
- package/attachment/vision-fit.d.ts.map +1 -0
- package/attachment/vision-fit.js +76 -0
- package/attachment/vision-fit.js.map +1 -0
- package/composer/SessionComposer.d.ts +3 -1
- package/composer/SessionComposer.d.ts.map +1 -1
- package/composer/SessionComposer.js +50 -4
- package/composer/SessionComposer.js.map +1 -1
- package/index.d.ts +2 -2
- package/index.d.ts.map +1 -1
- package/index.js +3 -2
- package/index.js.map +1 -1
- package/package.json +4 -4
- package/src/attachment/AttachmentChipList.tsx +45 -1
- package/src/attachment/__tests__/AttachmentChipList.test.tsx +103 -0
- package/src/attachment/__tests__/attachment-utils.test.ts +54 -0
- package/src/attachment/__tests__/clipboard.test.ts +110 -0
- package/src/attachment/__tests__/prepare-image.browser.test.ts +166 -0
- package/src/attachment/__tests__/prepare-image.test.ts +32 -0
- package/src/attachment/__tests__/vision-fit.test.ts +93 -0
- package/src/attachment/attachment-utils.ts +27 -0
- package/src/attachment/clipboard.ts +102 -0
- package/src/attachment/index.ts +13 -0
- package/src/attachment/prepare-image.ts +179 -0
- package/src/attachment/useAttachments.ts +26 -2
- package/src/attachment/vision-fit.ts +90 -0
- package/src/composer/SessionComposer.tsx +73 -4
- package/src/composer/__tests__/SessionComposer-paste.test.tsx +263 -0
- package/src/composer/__tests__/SessionComposer-uploadGate.test.tsx +239 -0
- 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
|
|
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
|
-
|
|
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
|
|
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
|