@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.
Files changed (219) 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/channel/AgentChannelsPanel.d.ts.map +1 -1
  28. package/channel/AgentChannelsPanel.js +21 -2
  29. package/channel/AgentChannelsPanel.js.map +1 -1
  30. package/channel/ChannelConversationsDialog.d.ts +12 -4
  31. package/channel/ChannelConversationsDialog.d.ts.map +1 -1
  32. package/channel/ChannelConversationsDialog.js +18 -24
  33. package/channel/ChannelConversationsDialog.js.map +1 -1
  34. package/channel/providers.d.ts +23 -0
  35. package/channel/providers.d.ts.map +1 -1
  36. package/channel/providers.js +4 -0
  37. package/channel/providers.js.map +1 -1
  38. package/channel/useSaveAgentChannel.d.ts +5 -4
  39. package/channel/useSaveAgentChannel.d.ts.map +1 -1
  40. package/channel/useSaveAgentChannel.js +18 -4
  41. package/channel/useSaveAgentChannel.js.map +1 -1
  42. package/composer/ComposerToolbar.d.ts +1 -1
  43. package/composer/ComposerToolbar.d.ts.map +1 -1
  44. package/composer/SessionComposer.d.ts +4 -2
  45. package/composer/SessionComposer.d.ts.map +1 -1
  46. package/composer/SessionComposer.js +51 -5
  47. package/composer/SessionComposer.js.map +1 -1
  48. package/conversation/ConversationAttentionBanner.d.ts +22 -0
  49. package/conversation/ConversationAttentionBanner.d.ts.map +1 -0
  50. package/conversation/ConversationAttentionBanner.js +25 -0
  51. package/conversation/ConversationAttentionBanner.js.map +1 -0
  52. package/conversation/ConversationComposer.d.ts +41 -0
  53. package/conversation/ConversationComposer.d.ts.map +1 -0
  54. package/conversation/ConversationComposer.js +57 -0
  55. package/conversation/ConversationComposer.js.map +1 -0
  56. package/conversation/ConversationControlBanner.d.ts +52 -0
  57. package/conversation/ConversationControlBanner.d.ts.map +1 -0
  58. package/conversation/ConversationControlBanner.js +59 -0
  59. package/conversation/ConversationControlBanner.js.map +1 -0
  60. package/conversation/ConversationListPane.d.ts +68 -0
  61. package/conversation/ConversationListPane.d.ts.map +1 -0
  62. package/conversation/ConversationListPane.js +120 -0
  63. package/conversation/ConversationListPane.js.map +1 -0
  64. package/conversation/ConversationTimelineView.d.ts +46 -0
  65. package/conversation/ConversationTimelineView.d.ts.map +1 -0
  66. package/conversation/ConversationTimelineView.js +166 -0
  67. package/conversation/ConversationTimelineView.js.map +1 -0
  68. package/conversation/ConversationsWorkbench.d.ts +63 -0
  69. package/conversation/ConversationsWorkbench.d.ts.map +1 -0
  70. package/conversation/ConversationsWorkbench.js +165 -0
  71. package/conversation/ConversationsWorkbench.js.map +1 -0
  72. package/conversation/conversationPresentation.d.ts +137 -0
  73. package/conversation/conversationPresentation.d.ts.map +1 -0
  74. package/conversation/conversationPresentation.js +230 -0
  75. package/conversation/conversationPresentation.js.map +1 -0
  76. package/conversation/index.d.ts +26 -0
  77. package/conversation/index.d.ts.map +1 -0
  78. package/conversation/index.js +14 -0
  79. package/conversation/index.js.map +1 -0
  80. package/conversation/polling.d.ts +39 -0
  81. package/conversation/polling.d.ts.map +1 -0
  82. package/conversation/polling.js +39 -0
  83. package/conversation/polling.js.map +1 -0
  84. package/conversation/useConversation.d.ts +61 -0
  85. package/conversation/useConversation.d.ts.map +1 -0
  86. package/conversation/useConversation.js +153 -0
  87. package/conversation/useConversation.js.map +1 -0
  88. package/conversation/useConversationList.d.ts +98 -0
  89. package/conversation/useConversationList.d.ts.map +1 -0
  90. package/conversation/useConversationList.js +200 -0
  91. package/conversation/useConversationList.js.map +1 -0
  92. package/conversation/useConversationParticipation.d.ts +63 -0
  93. package/conversation/useConversationParticipation.d.ts.map +1 -0
  94. package/conversation/useConversationParticipation.js +84 -0
  95. package/conversation/useConversationParticipation.js.map +1 -0
  96. package/conversation/useConversationTimeline.d.ts +71 -0
  97. package/conversation/useConversationTimeline.d.ts.map +1 -0
  98. package/conversation/useConversationTimeline.js +152 -0
  99. package/conversation/useConversationTimeline.js.map +1 -0
  100. package/conversation/useConversationsWantsHumanCount.d.ts +42 -0
  101. package/conversation/useConversationsWantsHumanCount.d.ts.map +1 -0
  102. package/conversation/useConversationsWantsHumanCount.js +40 -0
  103. package/conversation/useConversationsWantsHumanCount.js.map +1 -0
  104. package/index.d.ts +6 -2
  105. package/index.d.ts.map +1 -1
  106. package/index.js +11 -2
  107. package/index.js.map +1 -1
  108. package/internal/tooltip.d.ts.map +1 -1
  109. package/internal/tooltip.js +7 -1
  110. package/internal/tooltip.js.map +1 -1
  111. package/internal/useAutoScroll.d.ts +25 -4
  112. package/internal/useAutoScroll.d.ts.map +1 -1
  113. package/internal/useAutoScroll.js +55 -15
  114. package/internal/useAutoScroll.js.map +1 -1
  115. package/internal/useFetch.d.ts.map +1 -1
  116. package/internal/useFetch.js +14 -4
  117. package/internal/useFetch.js.map +1 -1
  118. package/models/ModelSelector.d.ts +24 -5
  119. package/models/ModelSelector.d.ts.map +1 -1
  120. package/models/ModelSelector.js +31 -9
  121. package/models/ModelSelector.js.map +1 -1
  122. package/models/registry.d.ts +3 -2
  123. package/models/registry.d.ts.map +1 -1
  124. package/models/registry.js.map +1 -1
  125. package/models/service-tier.d.ts +1 -1
  126. package/models/service-tier.d.ts.map +1 -1
  127. package/models/service-tier.js +1 -1
  128. package/models/service-tier.js.map +1 -1
  129. package/package.json +4 -4
  130. package/schedule/ScheduleDetailView.js +1 -1
  131. package/schedule/ScheduleDetailView.js.map +1 -1
  132. package/schedule/ScheduleForm.d.ts.map +1 -1
  133. package/schedule/ScheduleForm.js +7 -6
  134. package/schedule/ScheduleForm.js.map +1 -1
  135. package/sharing/useSaveAgentShare.d.ts +10 -1
  136. package/sharing/useSaveAgentShare.d.ts.map +1 -1
  137. package/sharing/useSaveAgentShare.js +9 -0
  138. package/sharing/useSaveAgentShare.js.map +1 -1
  139. package/sidebar/WorkspaceSidebar.d.ts +12 -4
  140. package/sidebar/WorkspaceSidebar.d.ts.map +1 -1
  141. package/sidebar/WorkspaceSidebar.js +19 -7
  142. package/sidebar/WorkspaceSidebar.js.map +1 -1
  143. package/src/attachment/AttachmentChipList.tsx +45 -1
  144. package/src/attachment/__tests__/AttachmentChipList.test.tsx +103 -0
  145. package/src/attachment/__tests__/attachment-utils.test.ts +54 -0
  146. package/src/attachment/__tests__/clipboard.test.ts +110 -0
  147. package/src/attachment/__tests__/prepare-image.browser.test.ts +166 -0
  148. package/src/attachment/__tests__/prepare-image.test.ts +32 -0
  149. package/src/attachment/__tests__/vision-fit.test.ts +93 -0
  150. package/src/attachment/attachment-utils.ts +27 -0
  151. package/src/attachment/clipboard.ts +102 -0
  152. package/src/attachment/index.ts +13 -0
  153. package/src/attachment/prepare-image.ts +179 -0
  154. package/src/attachment/useAttachments.ts +26 -2
  155. package/src/attachment/vision-fit.ts +90 -0
  156. package/src/channel/AgentChannelsPanel.tsx +33 -2
  157. package/src/channel/ChannelConversationsDialog.tsx +22 -25
  158. package/src/channel/__tests__/AgentChannelsPanel.test.tsx +36 -5
  159. package/src/channel/__tests__/ChannelConversationsDialog.test.tsx +38 -1
  160. package/src/channel/__tests__/useSaveAgentChannel.test.tsx +26 -0
  161. package/src/channel/providers.ts +27 -0
  162. package/src/channel/useSaveAgentChannel.ts +18 -4
  163. package/src/composer/ComposerToolbar.tsx +1 -1
  164. package/src/composer/SessionComposer.tsx +75 -6
  165. package/src/composer/__tests__/SessionComposer-paste.test.tsx +263 -0
  166. package/src/composer/__tests__/SessionComposer-serviceTier.test.tsx +4 -3
  167. package/src/composer/__tests__/SessionComposer-uploadGate.test.tsx +239 -0
  168. package/src/conversation/ConversationAttentionBanner.tsx +77 -0
  169. package/src/conversation/ConversationComposer.tsx +156 -0
  170. package/src/conversation/ConversationControlBanner.tsx +193 -0
  171. package/src/conversation/ConversationListPane.tsx +459 -0
  172. package/src/conversation/ConversationTimelineView.tsx +435 -0
  173. package/src/conversation/ConversationsWorkbench.tsx +378 -0
  174. package/src/conversation/__tests__/ConversationAttentionBanner.test.tsx +82 -0
  175. package/src/conversation/__tests__/ConversationComposer.test.tsx +160 -0
  176. package/src/conversation/__tests__/ConversationControlBanner.test.tsx +190 -0
  177. package/src/conversation/__tests__/ConversationListPane.test.tsx +372 -0
  178. package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +160 -0
  179. package/src/conversation/__tests__/ConversationTimelineView.test.tsx +405 -0
  180. package/src/conversation/__tests__/ConversationsWorkbench.test.tsx +860 -0
  181. package/src/conversation/__tests__/a11y/conversation-surfaces.a11y.test.tsx +235 -0
  182. package/src/conversation/__tests__/a11y/harness.tsx +100 -0
  183. package/src/conversation/__tests__/conversation-tooltips.layout.test.tsx +167 -0
  184. package/src/conversation/__tests__/conversationPollingComposition.test.tsx +138 -0
  185. package/src/conversation/__tests__/conversationPresentation.test.ts +289 -0
  186. package/src/conversation/__tests__/useConversation.test.tsx +243 -0
  187. package/src/conversation/__tests__/useConversationList.test.tsx +492 -0
  188. package/src/conversation/__tests__/useConversationParticipation.test.tsx +190 -0
  189. package/src/conversation/__tests__/useConversationTimeline.test.tsx +258 -0
  190. package/src/conversation/__tests__/useConversationsWantsHumanCount.test.tsx +102 -0
  191. package/src/conversation/conversationPresentation.ts +305 -0
  192. package/src/conversation/index.ts +80 -0
  193. package/src/conversation/polling.ts +41 -0
  194. package/src/conversation/useConversation.ts +221 -0
  195. package/src/conversation/useConversationList.ts +328 -0
  196. package/src/conversation/useConversationParticipation.ts +178 -0
  197. package/src/conversation/useConversationTimeline.ts +244 -0
  198. package/src/conversation/useConversationsWantsHumanCount.ts +86 -0
  199. package/src/index.ts +78 -1
  200. package/src/internal/__tests__/fake-timer-slices.ts +29 -0
  201. package/src/internal/__tests__/useAutoScroll.layout.test.tsx +143 -0
  202. package/src/internal/__tests__/useAutoScroll.test.tsx +62 -5
  203. package/src/internal/__tests__/useFetch-poll-under-render-pressure.test.tsx +79 -0
  204. package/src/internal/tooltip.tsx +6 -1
  205. package/src/internal/useAutoScroll.ts +66 -20
  206. package/src/internal/useFetch.ts +15 -4
  207. package/src/models/ModelSelector.tsx +81 -49
  208. package/src/models/__tests__/ModelSelector-serviceTier.test.tsx +105 -21
  209. package/src/models/registry.ts +3 -2
  210. package/src/models/service-tier.ts +1 -1
  211. package/src/schedule/ScheduleDetailView.tsx +1 -1
  212. package/src/schedule/ScheduleForm.tsx +7 -6
  213. package/src/schedule/__tests__/scheduleCreation.test.tsx +2 -2
  214. package/src/sharing/__tests__/useSaveAgentShare.test.tsx +55 -1
  215. package/src/sharing/__tests__/useShareToolReadiness.test.tsx +1 -0
  216. package/src/sharing/useSaveAgentShare.ts +19 -1
  217. package/src/sidebar/WorkspaceSidebar.tsx +62 -3
  218. package/src/sidebar/__tests__/WorkspaceSidebar.test.tsx +56 -1
  219. 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 toggle means
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. 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
  // ---------------------------------------------------------------------------
@@ -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
- // toggle means "platform default" (which resolves to standard in
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
- 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
+ });
@@ -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 toggle.
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 toggle through the toolbar for a fast-capable model", async () => {
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 toggle is untouched", async () => {
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
+ });