@stigmer/react 3.7.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/channel/AgentChannelsPanel.d.ts.map +1 -1
- package/channel/AgentChannelsPanel.js +21 -2
- package/channel/AgentChannelsPanel.js.map +1 -1
- package/channel/ChannelConversationsDialog.d.ts +12 -4
- package/channel/ChannelConversationsDialog.d.ts.map +1 -1
- package/channel/ChannelConversationsDialog.js +18 -24
- package/channel/ChannelConversationsDialog.js.map +1 -1
- package/channel/providers.d.ts +23 -0
- package/channel/providers.d.ts.map +1 -1
- package/channel/providers.js +4 -0
- package/channel/providers.js.map +1 -1
- package/channel/useSaveAgentChannel.d.ts +5 -4
- package/channel/useSaveAgentChannel.d.ts.map +1 -1
- package/channel/useSaveAgentChannel.js +18 -4
- package/channel/useSaveAgentChannel.js.map +1 -1
- package/composer/ComposerToolbar.d.ts +1 -1
- package/composer/ComposerToolbar.d.ts.map +1 -1
- package/composer/SessionComposer.d.ts +4 -2
- package/composer/SessionComposer.d.ts.map +1 -1
- package/composer/SessionComposer.js +51 -5
- package/composer/SessionComposer.js.map +1 -1
- package/conversation/ConversationAttentionBanner.d.ts +22 -0
- package/conversation/ConversationAttentionBanner.d.ts.map +1 -0
- package/conversation/ConversationAttentionBanner.js +25 -0
- package/conversation/ConversationAttentionBanner.js.map +1 -0
- package/conversation/ConversationComposer.d.ts +41 -0
- package/conversation/ConversationComposer.d.ts.map +1 -0
- package/conversation/ConversationComposer.js +57 -0
- package/conversation/ConversationComposer.js.map +1 -0
- package/conversation/ConversationControlBanner.d.ts +52 -0
- package/conversation/ConversationControlBanner.d.ts.map +1 -0
- package/conversation/ConversationControlBanner.js +59 -0
- package/conversation/ConversationControlBanner.js.map +1 -0
- package/conversation/ConversationListPane.d.ts +68 -0
- package/conversation/ConversationListPane.d.ts.map +1 -0
- package/conversation/ConversationListPane.js +120 -0
- package/conversation/ConversationListPane.js.map +1 -0
- package/conversation/ConversationTimelineView.d.ts +46 -0
- package/conversation/ConversationTimelineView.d.ts.map +1 -0
- package/conversation/ConversationTimelineView.js +166 -0
- package/conversation/ConversationTimelineView.js.map +1 -0
- package/conversation/ConversationsWorkbench.d.ts +63 -0
- package/conversation/ConversationsWorkbench.d.ts.map +1 -0
- package/conversation/ConversationsWorkbench.js +165 -0
- package/conversation/ConversationsWorkbench.js.map +1 -0
- package/conversation/conversationPresentation.d.ts +137 -0
- package/conversation/conversationPresentation.d.ts.map +1 -0
- package/conversation/conversationPresentation.js +230 -0
- package/conversation/conversationPresentation.js.map +1 -0
- package/conversation/index.d.ts +26 -0
- package/conversation/index.d.ts.map +1 -0
- package/conversation/index.js +14 -0
- package/conversation/index.js.map +1 -0
- package/conversation/polling.d.ts +39 -0
- package/conversation/polling.d.ts.map +1 -0
- package/conversation/polling.js +39 -0
- package/conversation/polling.js.map +1 -0
- package/conversation/useConversation.d.ts +61 -0
- package/conversation/useConversation.d.ts.map +1 -0
- package/conversation/useConversation.js +153 -0
- package/conversation/useConversation.js.map +1 -0
- package/conversation/useConversationList.d.ts +98 -0
- package/conversation/useConversationList.d.ts.map +1 -0
- package/conversation/useConversationList.js +200 -0
- package/conversation/useConversationList.js.map +1 -0
- package/conversation/useConversationParticipation.d.ts +63 -0
- package/conversation/useConversationParticipation.d.ts.map +1 -0
- package/conversation/useConversationParticipation.js +84 -0
- package/conversation/useConversationParticipation.js.map +1 -0
- package/conversation/useConversationTimeline.d.ts +71 -0
- package/conversation/useConversationTimeline.d.ts.map +1 -0
- package/conversation/useConversationTimeline.js +152 -0
- package/conversation/useConversationTimeline.js.map +1 -0
- package/conversation/useConversationsWantsHumanCount.d.ts +42 -0
- package/conversation/useConversationsWantsHumanCount.d.ts.map +1 -0
- package/conversation/useConversationsWantsHumanCount.js +40 -0
- package/conversation/useConversationsWantsHumanCount.js.map +1 -0
- package/index.d.ts +6 -2
- package/index.d.ts.map +1 -1
- package/index.js +11 -2
- package/index.js.map +1 -1
- package/internal/tooltip.d.ts.map +1 -1
- package/internal/tooltip.js +7 -1
- package/internal/tooltip.js.map +1 -1
- package/internal/useAutoScroll.d.ts +25 -4
- package/internal/useAutoScroll.d.ts.map +1 -1
- package/internal/useAutoScroll.js +55 -15
- package/internal/useAutoScroll.js.map +1 -1
- package/internal/useFetch.d.ts.map +1 -1
- package/internal/useFetch.js +14 -4
- package/internal/useFetch.js.map +1 -1
- package/models/ModelSelector.d.ts +24 -5
- package/models/ModelSelector.d.ts.map +1 -1
- package/models/ModelSelector.js +31 -9
- package/models/ModelSelector.js.map +1 -1
- package/models/registry.d.ts +3 -2
- package/models/registry.d.ts.map +1 -1
- package/models/registry.js.map +1 -1
- package/models/service-tier.d.ts +1 -1
- package/models/service-tier.d.ts.map +1 -1
- package/models/service-tier.js +1 -1
- package/models/service-tier.js.map +1 -1
- package/package.json +4 -4
- package/schedule/ScheduleDetailView.js +1 -1
- package/schedule/ScheduleDetailView.js.map +1 -1
- package/schedule/ScheduleForm.d.ts.map +1 -1
- package/schedule/ScheduleForm.js +7 -6
- package/schedule/ScheduleForm.js.map +1 -1
- package/sharing/useSaveAgentShare.d.ts +10 -1
- package/sharing/useSaveAgentShare.d.ts.map +1 -1
- package/sharing/useSaveAgentShare.js +9 -0
- package/sharing/useSaveAgentShare.js.map +1 -1
- package/sidebar/WorkspaceSidebar.d.ts +12 -4
- package/sidebar/WorkspaceSidebar.d.ts.map +1 -1
- package/sidebar/WorkspaceSidebar.js +19 -7
- package/sidebar/WorkspaceSidebar.js.map +1 -1
- 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/channel/AgentChannelsPanel.tsx +33 -2
- package/src/channel/ChannelConversationsDialog.tsx +22 -25
- package/src/channel/__tests__/AgentChannelsPanel.test.tsx +36 -5
- package/src/channel/__tests__/ChannelConversationsDialog.test.tsx +38 -1
- package/src/channel/__tests__/useSaveAgentChannel.test.tsx +26 -0
- package/src/channel/providers.ts +27 -0
- package/src/channel/useSaveAgentChannel.ts +18 -4
- package/src/composer/ComposerToolbar.tsx +1 -1
- package/src/composer/SessionComposer.tsx +75 -6
- package/src/composer/__tests__/SessionComposer-paste.test.tsx +263 -0
- package/src/composer/__tests__/SessionComposer-serviceTier.test.tsx +4 -3
- package/src/composer/__tests__/SessionComposer-uploadGate.test.tsx +239 -0
- package/src/conversation/ConversationAttentionBanner.tsx +77 -0
- package/src/conversation/ConversationComposer.tsx +156 -0
- package/src/conversation/ConversationControlBanner.tsx +193 -0
- package/src/conversation/ConversationListPane.tsx +459 -0
- package/src/conversation/ConversationTimelineView.tsx +435 -0
- package/src/conversation/ConversationsWorkbench.tsx +378 -0
- package/src/conversation/__tests__/ConversationAttentionBanner.test.tsx +82 -0
- package/src/conversation/__tests__/ConversationComposer.test.tsx +160 -0
- package/src/conversation/__tests__/ConversationControlBanner.test.tsx +190 -0
- package/src/conversation/__tests__/ConversationListPane.test.tsx +372 -0
- package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +160 -0
- package/src/conversation/__tests__/ConversationTimelineView.test.tsx +405 -0
- package/src/conversation/__tests__/ConversationsWorkbench.test.tsx +860 -0
- package/src/conversation/__tests__/a11y/conversation-surfaces.a11y.test.tsx +235 -0
- package/src/conversation/__tests__/a11y/harness.tsx +100 -0
- package/src/conversation/__tests__/conversation-tooltips.layout.test.tsx +167 -0
- package/src/conversation/__tests__/conversationPollingComposition.test.tsx +138 -0
- package/src/conversation/__tests__/conversationPresentation.test.ts +289 -0
- package/src/conversation/__tests__/useConversation.test.tsx +243 -0
- package/src/conversation/__tests__/useConversationList.test.tsx +492 -0
- package/src/conversation/__tests__/useConversationParticipation.test.tsx +190 -0
- package/src/conversation/__tests__/useConversationTimeline.test.tsx +258 -0
- package/src/conversation/__tests__/useConversationsWantsHumanCount.test.tsx +102 -0
- package/src/conversation/conversationPresentation.ts +305 -0
- package/src/conversation/index.ts +80 -0
- package/src/conversation/polling.ts +41 -0
- package/src/conversation/useConversation.ts +221 -0
- package/src/conversation/useConversationList.ts +328 -0
- package/src/conversation/useConversationParticipation.ts +178 -0
- package/src/conversation/useConversationTimeline.ts +244 -0
- package/src/conversation/useConversationsWantsHumanCount.ts +86 -0
- package/src/index.ts +78 -1
- package/src/internal/__tests__/fake-timer-slices.ts +29 -0
- package/src/internal/__tests__/useAutoScroll.layout.test.tsx +143 -0
- package/src/internal/__tests__/useAutoScroll.test.tsx +62 -5
- package/src/internal/__tests__/useFetch-poll-under-render-pressure.test.tsx +79 -0
- package/src/internal/tooltip.tsx +6 -1
- package/src/internal/useAutoScroll.ts +66 -20
- package/src/internal/useFetch.ts +15 -4
- package/src/models/ModelSelector.tsx +81 -49
- package/src/models/__tests__/ModelSelector-serviceTier.test.tsx +105 -21
- package/src/models/registry.ts +3 -2
- package/src/models/service-tier.ts +1 -1
- package/src/schedule/ScheduleDetailView.tsx +1 -1
- package/src/schedule/ScheduleForm.tsx +7 -6
- package/src/schedule/__tests__/scheduleCreation.test.tsx +2 -2
- package/src/sharing/__tests__/useSaveAgentShare.test.tsx +55 -1
- package/src/sharing/__tests__/useShareToolReadiness.test.tsx +1 -0
- package/src/sharing/useSaveAgentShare.ts +19 -1
- package/src/sidebar/WorkspaceSidebar.tsx +62 -3
- package/src/sidebar/__tests__/WorkspaceSidebar.test.tsx +56 -1
- package/styles.css +1 -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";
|
|
@@ -150,7 +153,7 @@ export interface SessionComposerSubmitContext {
|
|
|
150
153
|
readonly interactionMode?: InteractionModeOption;
|
|
151
154
|
/**
|
|
152
155
|
* Service tier the user actively selected for this execution
|
|
153
|
-
* (stigmer/stigmer#357). Only ever `"fast"` — an untouched
|
|
156
|
+
* (stigmer/stigmer#357). Only ever `"fast"` — an untouched tier means
|
|
154
157
|
* "platform default" and is carried as `undefined`, preserving the
|
|
155
158
|
* unspecified-vs-explicit distinction all the way to the ledger.
|
|
156
159
|
* Pass to execution creation as `execution_config.service_tier`.
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -865,7 +898,7 @@ const SessionComposerInner = forwardRef<SessionComposerHandle, SessionComposerPr
|
|
|
865
898
|
const hasFileRefs = enableFileReferences && fileRefs.hasRefs;
|
|
866
899
|
const buildFromPlan = overrides?.buildFromPlan;
|
|
867
900
|
// Carried only when the user actively chose fast: an untouched
|
|
868
|
-
//
|
|
901
|
+
// tier means "platform default" (which resolves to standard in
|
|
869
902
|
// the runner), and the UNSPECIFIED-vs-explicit distinction is
|
|
870
903
|
// load-bearing telemetry (#357).
|
|
871
904
|
const effectiveServiceTier = serviceTier === "fast" ? serviceTier : undefined;
|
|
@@ -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
|
+
});
|
|
@@ -102,7 +102,8 @@ function submitMessage(message: string) {
|
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
104
|
* Open the model selector popover (Base UI portals mount asynchronously in
|
|
105
|
-
* jsdom — the switch is awaited) and flip the fast-tier
|
|
105
|
+
* jsdom — the switch is awaited) and flip the fast-tier switch in the
|
|
106
|
+
* options area, then close the popover (toggling keeps it open).
|
|
106
107
|
*/
|
|
107
108
|
async function toggleFastTier() {
|
|
108
109
|
fireEvent.click(screen.getByRole("button", { name: /Composer 2\.5/ }));
|
|
@@ -114,7 +115,7 @@ async function toggleFastTier() {
|
|
|
114
115
|
afterEach(cleanup);
|
|
115
116
|
|
|
116
117
|
describe("SessionComposer — service tier submit contract", () => {
|
|
117
|
-
it("renders the tier
|
|
118
|
+
it("renders the tier switch through the toolbar for a fast-capable model", async () => {
|
|
118
119
|
renderComposer();
|
|
119
120
|
|
|
120
121
|
fireEvent.click(screen.getByRole("button", { name: /Composer 2\.5/ }));
|
|
@@ -132,7 +133,7 @@ describe("SessionComposer — service tier submit contract", () => {
|
|
|
132
133
|
expect(context?.serviceTier).toBe("fast");
|
|
133
134
|
});
|
|
134
135
|
|
|
135
|
-
it("leaves serviceTier undefined when the
|
|
136
|
+
it("leaves serviceTier undefined when the switch is untouched", async () => {
|
|
136
137
|
const { onSubmit } = renderComposer();
|
|
137
138
|
|
|
138
139
|
submitMessage("Run it normally");
|
|
@@ -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
|
+
});
|