@stigmer/react 3.8.0 → 3.10.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 (157) hide show
  1. package/attachment/AttachmentChipList.d.ts +16 -4
  2. package/attachment/AttachmentChipList.d.ts.map +1 -1
  3. package/attachment/AttachmentChipList.js +106 -11
  4. package/attachment/AttachmentChipList.js.map +1 -1
  5. package/attachment/AttachmentImageLightbox.d.ts +39 -0
  6. package/attachment/AttachmentImageLightbox.d.ts.map +1 -0
  7. package/attachment/AttachmentImageLightbox.js +57 -0
  8. package/attachment/AttachmentImageLightbox.js.map +1 -0
  9. package/attachment/attachment-utils.d.ts +11 -0
  10. package/attachment/attachment-utils.d.ts.map +1 -1
  11. package/attachment/attachment-utils.js +23 -0
  12. package/attachment/attachment-utils.js.map +1 -1
  13. package/attachment/clipboard.d.ts +51 -0
  14. package/attachment/clipboard.d.ts.map +1 -0
  15. package/attachment/clipboard.js +89 -0
  16. package/attachment/clipboard.js.map +1 -0
  17. package/attachment/index.d.ts +6 -1
  18. package/attachment/index.d.ts.map +1 -1
  19. package/attachment/index.js +4 -1
  20. package/attachment/index.js.map +1 -1
  21. package/attachment/prepare-image.d.ts +43 -0
  22. package/attachment/prepare-image.d.ts.map +1 -0
  23. package/attachment/prepare-image.js +162 -0
  24. package/attachment/prepare-image.js.map +1 -0
  25. package/attachment/useAttachments.d.ts.map +1 -1
  26. package/attachment/useAttachments.js +23 -3
  27. package/attachment/useAttachments.js.map +1 -1
  28. package/attachment/useObjectUrl.d.ts +15 -0
  29. package/attachment/useObjectUrl.d.ts.map +1 -0
  30. package/attachment/useObjectUrl.js +25 -0
  31. package/attachment/useObjectUrl.js.map +1 -0
  32. package/attachment/vision-fit.d.ts +50 -0
  33. package/attachment/vision-fit.d.ts.map +1 -0
  34. package/attachment/vision-fit.js +76 -0
  35. package/attachment/vision-fit.js.map +1 -0
  36. package/composer/SessionComposer.d.ts +3 -1
  37. package/composer/SessionComposer.d.ts.map +1 -1
  38. package/composer/SessionComposer.js +50 -4
  39. package/composer/SessionComposer.js.map +1 -1
  40. package/conversation/ConversationMediaAttachment.d.ts +45 -0
  41. package/conversation/ConversationMediaAttachment.d.ts.map +1 -0
  42. package/conversation/ConversationMediaAttachment.js +153 -0
  43. package/conversation/ConversationMediaAttachment.js.map +1 -0
  44. package/conversation/ConversationTimelineView.d.ts +18 -1
  45. package/conversation/ConversationTimelineView.d.ts.map +1 -1
  46. package/conversation/ConversationTimelineView.js +14 -5
  47. package/conversation/ConversationTimelineView.js.map +1 -1
  48. package/conversation/ConversationsWorkbench.d.ts.map +1 -1
  49. package/conversation/ConversationsWorkbench.js +1 -1
  50. package/conversation/ConversationsWorkbench.js.map +1 -1
  51. package/conversation/conversationPresentation.d.ts +10 -3
  52. package/conversation/conversationPresentation.d.ts.map +1 -1
  53. package/conversation/conversationPresentation.js +10 -3
  54. package/conversation/conversationPresentation.js.map +1 -1
  55. package/conversation/index.d.ts +2 -0
  56. package/conversation/index.d.ts.map +1 -1
  57. package/conversation/index.js +1 -0
  58. package/conversation/index.js.map +1 -1
  59. package/conversation/useConversationMediaUrl.d.ts +65 -0
  60. package/conversation/useConversationMediaUrl.d.ts.map +1 -0
  61. package/conversation/useConversationMediaUrl.js +61 -0
  62. package/conversation/useConversationMediaUrl.js.map +1 -0
  63. package/execution/MessageAttachments.d.ts +52 -0
  64. package/execution/MessageAttachments.d.ts.map +1 -0
  65. package/execution/MessageAttachments.js +142 -0
  66. package/execution/MessageAttachments.js.map +1 -0
  67. package/execution/MessageEntry.d.ts +18 -0
  68. package/execution/MessageEntry.d.ts.map +1 -1
  69. package/execution/MessageEntry.js +5 -4
  70. package/execution/MessageEntry.js.map +1 -1
  71. package/execution/MessageThread.d.ts +15 -2
  72. package/execution/MessageThread.d.ts.map +1 -1
  73. package/execution/MessageThread.js +24 -7
  74. package/execution/MessageThread.js.map +1 -1
  75. package/execution/index.d.ts +2 -0
  76. package/execution/index.d.ts.map +1 -1
  77. package/execution/index.js +1 -0
  78. package/execution/index.js.map +1 -1
  79. package/github/decodeGitHubContent.d.ts +10 -5
  80. package/github/decodeGitHubContent.d.ts.map +1 -1
  81. package/github/decodeGitHubContent.js +21 -7
  82. package/github/decodeGitHubContent.js.map +1 -1
  83. package/github/useGitHubFileReader.d.ts.map +1 -1
  84. package/github/useGitHubFileReader.js +5 -5
  85. package/github/useGitHubFileReader.js.map +1 -1
  86. package/index.d.ts +5 -5
  87. package/index.d.ts.map +1 -1
  88. package/index.js +5 -4
  89. package/index.js.map +1 -1
  90. package/internal/form-primitives.d.ts +13 -0
  91. package/internal/form-primitives.d.ts.map +1 -1
  92. package/internal/form-primitives.js +13 -0
  93. package/internal/form-primitives.js.map +1 -1
  94. package/package.json +4 -4
  95. package/session/SessionViewer.js +1 -1
  96. package/session/SessionViewer.js.map +1 -1
  97. package/session/useSessionConversation.d.ts +8 -0
  98. package/session/useSessionConversation.d.ts.map +1 -1
  99. package/session/useSessionConversation.js +12 -2
  100. package/session/useSessionConversation.js.map +1 -1
  101. package/src/attachment/AttachmentChipList.tsx +330 -40
  102. package/src/attachment/AttachmentImageLightbox.tsx +156 -0
  103. package/src/attachment/__tests__/AttachmentChipList.test.tsx +326 -0
  104. package/src/attachment/__tests__/attachment-utils.test.ts +54 -0
  105. package/src/attachment/__tests__/clipboard.test.ts +110 -0
  106. package/src/attachment/__tests__/prepare-image.browser.test.ts +166 -0
  107. package/src/attachment/__tests__/prepare-image.test.ts +32 -0
  108. package/src/attachment/__tests__/vision-fit.test.ts +93 -0
  109. package/src/attachment/attachment-utils.ts +27 -0
  110. package/src/attachment/clipboard.ts +102 -0
  111. package/src/attachment/index.ts +13 -0
  112. package/src/attachment/prepare-image.ts +179 -0
  113. package/src/attachment/useAttachments.ts +26 -2
  114. package/src/attachment/useObjectUrl.ts +28 -0
  115. package/src/attachment/vision-fit.ts +90 -0
  116. package/src/composer/SessionComposer.tsx +73 -4
  117. package/src/composer/__tests__/SessionComposer-paste.test.tsx +263 -0
  118. package/src/composer/__tests__/SessionComposer-uploadGate.test.tsx +239 -0
  119. package/src/conversation/ConversationMediaAttachment.tsx +308 -0
  120. package/src/conversation/ConversationTimelineView.tsx +60 -10
  121. package/src/conversation/ConversationsWorkbench.tsx +2 -0
  122. package/src/conversation/__tests__/ConversationTimelineView.test.tsx +233 -6
  123. package/src/conversation/__tests__/a11y/conversation-surfaces.a11y.test.tsx +67 -1
  124. package/src/conversation/__tests__/useConversationMediaUrl.test.tsx +66 -0
  125. package/src/conversation/conversationPresentation.ts +10 -3
  126. package/src/conversation/index.ts +6 -0
  127. package/src/conversation/useConversationMediaUrl.ts +121 -0
  128. package/src/execution/MessageAttachments.tsx +364 -0
  129. package/src/execution/MessageEntry.tsx +38 -0
  130. package/src/execution/MessageThread.tsx +44 -4
  131. package/src/execution/__tests__/MessageEntry-attachments.test.tsx +228 -0
  132. package/src/execution/__tests__/buildThreadItems-attachments.test.ts +174 -0
  133. package/src/execution/index.ts +6 -0
  134. package/src/github/__tests__/decodeGitHubContent.test.ts +42 -1
  135. package/src/github/decodeGitHubContent.ts +22 -5
  136. package/src/github/useGitHubFileReader.ts +13 -3
  137. package/src/index.ts +16 -1
  138. package/src/internal/form-primitives.tsx +14 -0
  139. package/src/session/SessionViewer.tsx +1 -0
  140. package/src/session/__tests__/useSessionConversation.test.tsx +62 -0
  141. package/src/session/useSessionConversation.ts +21 -2
  142. package/src/workspace/FileViewer.tsx +109 -0
  143. package/src/workspace/WorkspaceFileReader.ts +55 -1
  144. package/src/workspace/__tests__/FileViewer.test.tsx +98 -0
  145. package/src/workspace/index.ts +2 -0
  146. package/styles.css +1 -1
  147. package/workspace/FileViewer.d.ts.map +1 -1
  148. package/workspace/FileViewer.js +40 -2
  149. package/workspace/FileViewer.js.map +1 -1
  150. package/workspace/WorkspaceFileReader.d.ts +29 -1
  151. package/workspace/WorkspaceFileReader.d.ts.map +1 -1
  152. package/workspace/WorkspaceFileReader.js +42 -0
  153. package/workspace/WorkspaceFileReader.js.map +1 -1
  154. package/workspace/index.d.ts +1 -1
  155. package/workspace/index.d.ts.map +1 -1
  156. package/workspace/index.js +1 -1
  157. package/workspace/index.js.map +1 -1
@@ -0,0 +1,364 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { cn } from "@stigmer/theme";
5
+ import { AttachmentImageLightbox } from "../attachment/AttachmentImageLightbox.js";
6
+ import { UNSTYLED_BUTTON } from "../internal/form-primitives.js";
7
+ import { useArtifactDownload } from "./useArtifactDownload.js";
8
+ import { useArtifactDownloadUrl } from "./useArtifactDownloadUrl.js";
9
+
10
+ /**
11
+ * The minimal display shape of a submitted attachment.
12
+ *
13
+ * Structurally satisfied by BOTH sources a human turn can carry — the
14
+ * `Attachment` proto on a persisted execution's `spec.attachments` and the
15
+ * `AttachmentInput` a just-submitted (pending) turn holds — so the thread
16
+ * renders either without conversion code or a duplicated type (DD-007).
17
+ */
18
+ export interface MessageAttachmentView {
19
+ /** Original filename, e.g. `"screenshot.png"`. */
20
+ readonly filename?: string;
21
+ /** MIME type, e.g. `"image/png"`. Drives the image-vs-document treatment. */
22
+ readonly contentType?: string;
23
+ /** Storage key (`attachments/{ulid}/{filename}`) minted at upload time. */
24
+ readonly storageKey?: string;
25
+ }
26
+
27
+ /** Props for {@link MessageAttachments}. */
28
+ export interface MessageAttachmentsProps {
29
+ /** The turn's submitted attachments, in submission order. */
30
+ readonly attachments: readonly MessageAttachmentView[];
31
+ /**
32
+ * The execution the attachments were submitted to. Enables the byte-backed
33
+ * affordances — image previews (presigned URL) and document downloads.
34
+ * Omit for the optimistic pending bubble (no execution record yet) and the
35
+ * failed-send bubble (no record ever): images render as inert glyph tiles
36
+ * and documents as inert chips until a real turn brings the presign seam.
37
+ */
38
+ readonly executionId?: string;
39
+ /** Additional CSS class names for the row container. */
40
+ readonly className?: string;
41
+ }
42
+
43
+ /**
44
+ * The attachment row on a human turn: the durable evidence of what files were
45
+ * sent with the message (stigmer/stigmer#372).
46
+ *
47
+ * Image attachments render as preview-only thumbnail tiles — the image IS the
48
+ * chip, no filename text (the name stays in the tooltip, the accessible
49
+ * label, and the lightbox header), click-to-open in the shared
50
+ * {@link AttachmentImageLightbox} — and documents as compact click-to-download
51
+ * filename chips. Both follow the composer chip grammar
52
+ * ({@link AttachmentChipList}) so a file looks the same before and after
53
+ * send, but this is deliberately a separate component: the composer chip is
54
+ * interactive and upload-phase-bound over a local `File`, while this row is
55
+ * read-only over the execution record, with bytes resolved on demand from
56
+ * the stable storage key via presigned URLs (the `OutputRefImage` pattern —
57
+ * the local `File` is gone after submit).
58
+ *
59
+ * Purely presentational plus on-demand URL minting — no required wiring.
60
+ * All visual properties flow through `--stgm-*` tokens.
61
+ */
62
+ export function MessageAttachments({
63
+ attachments,
64
+ executionId,
65
+ className,
66
+ }: MessageAttachmentsProps) {
67
+ // One lightbox instance serves the whole row, tracked by storage key
68
+ // (direct identity). It mounts OUTSIDE the role="list" container — a
69
+ // <dialog> is not valid inside the chips' <span> elements, and a
70
+ // non-listitem child inside the list would break its a11y ownership
71
+ // (same reasoning as the composer's chip list).
72
+ const [previewKey, setPreviewKey] = useState<string | null>(null);
73
+ const previewed =
74
+ previewKey !== null
75
+ ? (attachments.find((a) => a.storageKey === previewKey) ?? null)
76
+ : null;
77
+
78
+ if (attachments.length === 0) return null;
79
+
80
+ return (
81
+ <>
82
+ {/* items-center is load-bearing: image tiles (h-14) and document chips
83
+ (~h-6) share this row, and flexbox's default stretch alignment
84
+ would balloon the document chips to tile height. */}
85
+ <div
86
+ className={cn("flex flex-wrap items-center gap-1.5", className)}
87
+ role="list"
88
+ aria-label="Submitted attachments"
89
+ >
90
+ {attachments.map((attachment, i) => {
91
+ const isImage = attachment.contentType?.startsWith("image/") ?? false;
92
+ const key = attachment.storageKey ?? `${displayName(attachment)}-${i}`;
93
+ if (isImage && executionId && attachment.storageKey) {
94
+ return (
95
+ <ImagePreviewChip
96
+ key={key}
97
+ attachment={attachment}
98
+ executionId={executionId}
99
+ onPreview={() => setPreviewKey(attachment.storageKey!)}
100
+ />
101
+ );
102
+ }
103
+ if (isImage && !executionId) {
104
+ // No execution record — the optimistic pending bubble or a
105
+ // failed send. The tile keeps the image grammar but stays
106
+ // STATIC (a glyph, not a pulse): a failed send never gets an
107
+ // executionId, so a pulse here would be a permanent false
108
+ // "loading" signal. On the pending bubble the real turn lands
109
+ // under the same bridge key and brings the presign seam.
110
+ return <ImageGlyphTile key={key} name={displayName(attachment)} />;
111
+ }
112
+ return (
113
+ <DocumentChip
114
+ key={key}
115
+ attachment={attachment}
116
+ executionId={executionId}
117
+ />
118
+ );
119
+ })}
120
+ </div>
121
+ {previewed && executionId && previewed.storageKey && (
122
+ <AttachmentPreviewLightbox
123
+ attachment={previewed}
124
+ executionId={executionId}
125
+ onClose={() => setPreviewKey(null)}
126
+ />
127
+ )}
128
+ </>
129
+ );
130
+ }
131
+
132
+ /**
133
+ * Display name for an attachment: the filename, falling back to the storage
134
+ * key's basename (`attachments/{ulid}/{filename}` always ends in it).
135
+ */
136
+ function displayName(attachment: MessageAttachmentView): string {
137
+ if (attachment.filename) return attachment.filename;
138
+ const key = attachment.storageKey ?? "";
139
+ const base = key.slice(key.lastIndexOf("/") + 1);
140
+ return base || "attachment";
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Image attachment — a preview-only tile mirroring the composer's grammar
145
+ // ---------------------------------------------------------------------------
146
+
147
+ /**
148
+ * The preview-only image tile: the thumbnail IS the chip, minted from the
149
+ * stable storage key at view time — no filename text (the name stays in the
150
+ * tooltip, the accessible label, and the lightbox header). If the URL can't
151
+ * be resolved (revoked storage, transient API failure), the chip degrades to
152
+ * the document treatment — a generic icon with the download affordance —
153
+ * never a broken-image glyph.
154
+ */
155
+ function ImagePreviewChip({
156
+ attachment,
157
+ executionId,
158
+ onPreview,
159
+ }: {
160
+ readonly attachment: MessageAttachmentView;
161
+ readonly executionId: string;
162
+ readonly onPreview: () => void;
163
+ }) {
164
+ const { url, error } = useArtifactDownloadUrl(
165
+ executionId,
166
+ attachment.storageKey ?? null,
167
+ );
168
+ const name = displayName(attachment);
169
+
170
+ if (error) {
171
+ return <DocumentChip attachment={attachment} executionId={executionId} />;
172
+ }
173
+
174
+ return (
175
+ <span role="listitem" aria-label={name} title={name} className="inline-flex">
176
+ {/* The whole tile is the preview target. UNSTYLED_BUTTON is
177
+ load-bearing: without it the tile shows the UA's default button
178
+ box in preflight-less hosts (see the constant). */}
179
+ <button
180
+ type="button"
181
+ onClick={onPreview}
182
+ aria-label={`Preview ${name}`}
183
+ className={cn(
184
+ UNSTYLED_BUTTON,
185
+ "relative block h-14 w-14 overflow-hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
186
+ )}
187
+ >
188
+ {url ? (
189
+ <img
190
+ src={url}
191
+ alt=""
192
+ aria-hidden="true"
193
+ loading="lazy"
194
+ className="h-full w-full object-cover"
195
+ />
196
+ ) : (
197
+ // Pulse placeholder while the presigned URL is minted — pulse
198
+ // means work is genuinely in flight (contrast ImageGlyphTile).
199
+ // Same footprint as the image, so no layout shift.
200
+ <span
201
+ className="block h-full w-full animate-pulse bg-muted"
202
+ aria-hidden="true"
203
+ />
204
+ )}
205
+ </button>
206
+ </span>
207
+ );
208
+ }
209
+
210
+ /**
211
+ * The inert image tile for a turn with no execution record yet (optimistic
212
+ * pending bubble) or ever (failed send). Keeps the image grammar — same
213
+ * footprint as {@link ImagePreviewChip} — but shows a STATIC glyph, not a
214
+ * pulse: a pulse promises bytes that, on a failed send, will never arrive.
215
+ */
216
+ function ImageGlyphTile({ name }: { readonly name: string }) {
217
+ return (
218
+ <span
219
+ role="listitem"
220
+ aria-label={name}
221
+ title={name}
222
+ className="inline-flex h-14 w-14 items-center justify-center rounded-md bg-muted-subtle"
223
+ >
224
+ <ImageGlyph />
225
+ </span>
226
+ );
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Document attachment — compact chip, click-to-download
231
+ // ---------------------------------------------------------------------------
232
+
233
+ /**
234
+ * The compact treatment for documents (and for any attachment before its
235
+ * execution record exists). With an execution id, the chip is a download
236
+ * button — a fresh URL is minted at click time via {@link useArtifactDownload},
237
+ * saved under the original filename. Without one (pending bubble), it is an
238
+ * inert label: evidence the file was sent, affordances once the record lands.
239
+ */
240
+ function DocumentChip({
241
+ attachment,
242
+ executionId,
243
+ }: {
244
+ readonly attachment: MessageAttachmentView;
245
+ readonly executionId?: string;
246
+ }) {
247
+ const { download, isDownloading } = useArtifactDownload(executionId ?? null);
248
+ const name = displayName(attachment);
249
+ const downloadable = executionId != null && !!attachment.storageKey;
250
+
251
+ const content = (
252
+ <>
253
+ <FileGlyph />
254
+ <span className="min-w-0 truncate">{name}</span>
255
+ </>
256
+ );
257
+
258
+ return (
259
+ <span
260
+ role="listitem"
261
+ aria-label={name}
262
+ title={name}
263
+ className="inline-flex max-w-[200px] items-center rounded-md bg-muted-subtle px-2 py-0.5 text-xs text-foreground"
264
+ >
265
+ {downloadable ? (
266
+ <button
267
+ type="button"
268
+ onClick={() => void download(attachment.storageKey!, name)}
269
+ disabled={isDownloading}
270
+ aria-label={`Download ${name}`}
271
+ className={cn(
272
+ UNSTYLED_BUTTON,
273
+ "flex min-w-0 items-center gap-1 rounded-sm text-inherit [font:inherit] hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
274
+ isDownloading && "opacity-70",
275
+ )}
276
+ >
277
+ {content}
278
+ </button>
279
+ ) : (
280
+ <span className="flex min-w-0 items-center gap-1">{content}</span>
281
+ )}
282
+ </span>
283
+ );
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Lightbox bridge
288
+ // ---------------------------------------------------------------------------
289
+
290
+ /**
291
+ * Feeds the previewed attachment's presigned URL to the shared
292
+ * {@link AttachmentImageLightbox}. The URL hook is cross-mount cached by
293
+ * storage key (DD-014), so opening the lightbox reuses the thumbnail's minted
294
+ * URL instead of a second RPC; `src` is `null` for at most the first frame.
295
+ */
296
+ function AttachmentPreviewLightbox({
297
+ attachment,
298
+ executionId,
299
+ onClose,
300
+ }: {
301
+ readonly attachment: MessageAttachmentView;
302
+ readonly executionId: string;
303
+ readonly onClose: () => void;
304
+ }) {
305
+ const { url } = useArtifactDownloadUrl(
306
+ executionId,
307
+ attachment.storageKey ?? null,
308
+ );
309
+
310
+ return (
311
+ <AttachmentImageLightbox
312
+ src={url}
313
+ filename={displayName(attachment)}
314
+ open
315
+ onClose={onClose}
316
+ />
317
+ );
318
+ }
319
+
320
+ /** Generic picture glyph for the inert {@link ImageGlyphTile}. */
321
+ function ImageGlyph() {
322
+ return (
323
+ <svg
324
+ width="18"
325
+ height="18"
326
+ viewBox="0 0 24 24"
327
+ fill="none"
328
+ stroke="currentColor"
329
+ strokeWidth="1.5"
330
+ strokeLinecap="round"
331
+ strokeLinejoin="round"
332
+ className="shrink-0 text-muted-foreground"
333
+ aria-hidden="true"
334
+ >
335
+ <rect x="3" y="3" width="18" height="18" rx="2" />
336
+ <circle cx="8.5" cy="8.5" r="1.5" />
337
+ <path d="M21 15l-5-5L5 21" />
338
+ </svg>
339
+ );
340
+ }
341
+
342
+ /**
343
+ * Generic document glyph — the same geometry as the composer chip's icon so
344
+ * the two surfaces read as one family.
345
+ */
346
+ function FileGlyph() {
347
+ return (
348
+ <svg
349
+ width="10"
350
+ height="10"
351
+ viewBox="0 0 14 14"
352
+ fill="none"
353
+ stroke="currentColor"
354
+ strokeWidth="1.5"
355
+ strokeLinecap="round"
356
+ strokeLinejoin="round"
357
+ className="shrink-0 text-muted-foreground"
358
+ aria-hidden="true"
359
+ >
360
+ <path d="M8 1H4C3.45 1 3 1.45 3 2V12C3 12.55 3.45 13 4 13H10C10.55 13 11 12.55 11 12V4L8 1Z" />
361
+ <path d="M8 1V4H11" />
362
+ </svg>
363
+ );
364
+ }
@@ -13,6 +13,10 @@ import {
13
13
  unwrapEnclosingMarkdownFence,
14
14
  } from "../internal/markdown-components.js";
15
15
  import { InteractionModeBadge } from "./InteractionModeBadge.js";
16
+ import {
17
+ MessageAttachments,
18
+ type MessageAttachmentView,
19
+ } from "./MessageAttachments.js";
16
20
  import { PlanDocumentMessage } from "./PlanDocumentMessage.js";
17
21
  import { useRenderTracer } from "../internal/dev/index.js";
18
22
 
@@ -48,6 +52,23 @@ export interface MessageEntryProps {
48
52
  * non-human messages.
49
53
  */
50
54
  readonly interactionMode?: InteractionMode;
55
+ /**
56
+ * The turn's submitted attachments, stamped by the thread builder on
57
+ * `MESSAGE_HUMAN` bubbles from the execution's `spec.attachments` (or the
58
+ * pending submit context). Renders as a {@link MessageAttachments} row
59
+ * above the prose. Ignored for non-human messages.
60
+ *
61
+ * A `MessageEntry` slot override that delegates to the built-in inherits
62
+ * attachment rendering; one that ignores this prop renders text only.
63
+ */
64
+ readonly attachments?: readonly MessageAttachmentView[];
65
+ /**
66
+ * The execution the attachments belong to — enables their byte-backed
67
+ * affordances (image previews, document downloads) via presigned URLs.
68
+ * Absent on the optimistic pending bubble, where attachments render as
69
+ * inert chips until the real execution record replaces it.
70
+ */
71
+ readonly executionId?: string;
51
72
  }
52
73
 
53
74
  /**
@@ -80,6 +101,8 @@ export const MessageEntry = memo(function MessageEntry({
80
101
  onEdit,
81
102
  isPlanDocument,
82
103
  interactionMode,
104
+ attachments,
105
+ executionId,
83
106
  }: MessageEntryProps) {
84
107
  useRenderTracer("MessageEntry", {
85
108
  messageType: message.type,
@@ -95,6 +118,8 @@ export const MessageEntry = memo(function MessageEntry({
95
118
  className={className}
96
119
  onEdit={onEdit}
97
120
  interactionMode={interactionMode}
121
+ attachments={attachments}
122
+ executionId={executionId}
98
123
  />
99
124
  );
100
125
  case MessageType.MESSAGE_AI:
@@ -132,11 +157,15 @@ function HumanMessage({
132
157
  className,
133
158
  onEdit,
134
159
  interactionMode,
160
+ attachments,
161
+ executionId,
135
162
  }: {
136
163
  content: string;
137
164
  className?: string;
138
165
  onEdit?: () => void;
139
166
  interactionMode?: InteractionMode;
167
+ attachments?: readonly MessageAttachmentView[];
168
+ executionId?: string;
140
169
  }) {
141
170
  return (
142
171
  <div
@@ -152,6 +181,15 @@ function HumanMessage({
152
181
  {interactionMode !== undefined && (
153
182
  <InteractionModeBadge mode={interactionMode} className="mb-1.5" />
154
183
  )}
184
+ {/* The evidence a file rode with this turn (#372) — above the prose,
185
+ mirroring the composer's chips-above-input layout. */}
186
+ {attachments && attachments.length > 0 && (
187
+ <MessageAttachments
188
+ attachments={attachments}
189
+ executionId={executionId}
190
+ className="mb-2"
191
+ />
192
+ )}
155
193
  <p className="text-sm text-foreground whitespace-pre-wrap">{content}</p>
156
194
  {onEdit && (
157
195
  <button
@@ -22,6 +22,7 @@ import { displayFileChangeSets } from "@stigmer/sdk";
22
22
  import { cn } from "@stigmer/theme";
23
23
  import { isTerminalPhase } from "./execution-phases.js";
24
24
  import { MessageEntry, type MessageEntryProps } from "./MessageEntry.js";
25
+ import type { MessageAttachmentView } from "./MessageAttachments.js";
25
26
  import { ToolCallGroup } from "./ToolCallGroup.js";
26
27
  import { SubAgentSection } from "./SubAgentSection.js";
27
28
  import { ExecutionPhaseBadge } from "./ExecutionPhaseBadge.js";
@@ -124,6 +125,16 @@ export interface MessageThreadProps {
124
125
  * first snapshot.
125
126
  */
126
127
  readonly pendingUserMessage?: string | null;
128
+ /**
129
+ * Attachments submitted with the pending user message, rendered as chips
130
+ * on the optimistic bubble so the files never vanish between submit and
131
+ * the stream's first snapshot (stigmer/stigmer#372). Supply
132
+ * {@link useSessionConversation}'s `pendingAttachments`; clear together
133
+ * with {@link pendingUserMessage}. Chips are inert on the pending bubble
134
+ * (no execution record yet); previews and downloads light up the moment
135
+ * the real turn replaces it.
136
+ */
137
+ readonly pendingAttachments?: readonly MessageAttachmentView[] | null;
127
138
  /**
128
139
  * Marks the pending user message as failed-to-send. The optimistic
129
140
  * bubble renders an inline "Couldn't send — Retry" affordance instead
@@ -339,7 +350,7 @@ export function threadContentColumnClass(
339
350
  * part of the public API.
340
351
  */
341
352
  export type ThreadItem =
342
- | { readonly kind: "message"; readonly message: AgentMessage; readonly key: string; readonly isPending?: boolean; readonly isFailed?: boolean; readonly isEditable?: boolean; readonly isPlanDocument?: boolean; readonly interactionMode?: InteractionMode }
353
+ | { readonly kind: "message"; readonly message: AgentMessage; readonly key: string; readonly isPending?: boolean; readonly isFailed?: boolean; readonly isEditable?: boolean; readonly isPlanDocument?: boolean; readonly interactionMode?: InteractionMode; readonly attachments?: readonly MessageAttachmentView[]; readonly executionId?: string }
343
354
  | { readonly kind: "tool-group"; readonly toolCalls: readonly ToolCall[]; readonly subAgentExecutions: readonly SubAgentExecution[]; readonly key: string }
344
355
  | { readonly kind: "sub-agent"; readonly subAgentExecution: SubAgentExecution; readonly key: string }
345
356
  | { readonly kind: "phase-badge"; readonly phase: ExecutionPhase; readonly key: string }
@@ -557,6 +568,7 @@ export function buildThreadItems(
557
568
  editableActiveTurn = false,
558
569
  includeFileReviewRecords = false,
559
570
  collapseStreamingPlan = false,
571
+ pendingAttachments?: readonly MessageAttachmentView[] | null,
560
572
  ): ThreadItem[] {
561
573
  const items: ThreadItem[] = [];
562
574
  // Tool-call ids that render as an inline-approval-capable ToolCallItem (a
@@ -717,6 +729,12 @@ export function buildThreadItems(
717
729
  pendingUserMessage != null &&
718
730
  specMessage === pendingUserMessage;
719
731
 
732
+ // The turn's submitted files (spec.attachments, by reference — the spec
733
+ // never mutates and structural sharing keeps the ref stable, so the
734
+ // memoized bubble skips re-renders). The execution id rides along for
735
+ // the presigned-URL affordances; the `_e${ei}` synthetic fallback is
736
+ // display-only and must never be presigned against.
737
+ const specAttachments = exec.spec?.attachments;
720
738
  items.push({
721
739
  kind: "message",
722
740
  message: syntheticHumanMsg,
@@ -727,6 +745,11 @@ export function buildThreadItems(
727
745
  // The turn's mode marks the prompt bubble (a "Plan" pill on Plan
728
746
  // turns) so the transcript reads unambiguously after mode switches.
729
747
  interactionMode: exec.spec?.executionConfig?.interactionMode,
748
+ attachments:
749
+ specAttachments && specAttachments.length > 0
750
+ ? specAttachments
751
+ : undefined,
752
+ executionId: exec.metadata?.id,
730
753
  });
731
754
  }
732
755
 
@@ -1023,6 +1046,14 @@ export function buildThreadItems(
1023
1046
  // sending state — the two are mutually exclusive.
1024
1047
  isPending: !pendingMessageFailed,
1025
1048
  isFailed: pendingMessageFailed,
1049
+ // The submit context's attachments: evidence the files rode with the
1050
+ // turn, kept on failure so retry shows what will be re-sent. No
1051
+ // executionId yet — chips render inert until the real turn (same
1052
+ // bridge key) replaces this bubble and brings the presign seam.
1053
+ attachments:
1054
+ pendingAttachments && pendingAttachments.length > 0
1055
+ ? pendingAttachments
1056
+ : undefined,
1026
1057
  });
1027
1058
  }
1028
1059
  }
@@ -1057,6 +1088,7 @@ export function MessageThread({
1057
1088
  executions,
1058
1089
  activeStreamExecution,
1059
1090
  pendingUserMessage,
1091
+ pendingAttachments,
1060
1092
  pendingMessageFailed = false,
1061
1093
  onRetrySend,
1062
1094
  onRetryExecution,
@@ -1089,8 +1121,8 @@ export function MessageThread({
1089
1121
  // the plan streaming inline, where it remains readable (DD-011).
1090
1122
  const collapseStreamingPlan = onOpenPlan != null;
1091
1123
  const items = useMemo(
1092
- () => buildThreadItems(executions, activeStreamExecution, pendingUserMessage, includeApprovals, workspaceEntries, summarizationEvents, pendingMessageFailed, editableActiveTurn, showFileReviewRecords, collapseStreamingPlan),
1093
- [executions, activeStreamExecution, pendingUserMessage, includeApprovals, workspaceEntries, summarizationEvents, pendingMessageFailed, editableActiveTurn, showFileReviewRecords, collapseStreamingPlan],
1124
+ () => buildThreadItems(executions, activeStreamExecution, pendingUserMessage, includeApprovals, workspaceEntries, summarizationEvents, pendingMessageFailed, editableActiveTurn, showFileReviewRecords, collapseStreamingPlan, pendingAttachments),
1125
+ [executions, activeStreamExecution, pendingUserMessage, includeApprovals, workspaceEntries, summarizationEvents, pendingMessageFailed, editableActiveTurn, showFileReviewRecords, collapseStreamingPlan, pendingAttachments],
1094
1126
  );
1095
1127
 
1096
1128
  useKeyStability(items);
@@ -1413,6 +1445,7 @@ export function ThreadItemRenderer({
1413
1445
  return (
1414
1446
  <FailedUserMessage
1415
1447
  message={item.message}
1448
+ attachments={item.attachments}
1416
1449
  onRetry={onRetrySend}
1417
1450
  MessageEntryComponent={Entry}
1418
1451
  />
@@ -1424,6 +1457,8 @@ export function ThreadItemRenderer({
1424
1457
  className={item.isPending ? "opacity-70" : undefined}
1425
1458
  isPlanDocument={item.isPlanDocument}
1426
1459
  interactionMode={item.interactionMode}
1460
+ attachments={item.attachments}
1461
+ executionId={item.executionId}
1427
1462
  onEdit={
1428
1463
  item.isEditable && onEditMessage
1429
1464
  ? () => onEditMessage(item.message.content)
@@ -1555,16 +1590,21 @@ export function ThreadItemRenderer({
1555
1590
  */
1556
1591
  function FailedUserMessage({
1557
1592
  message,
1593
+ attachments,
1558
1594
  onRetry,
1559
1595
  MessageEntryComponent,
1560
1596
  }: {
1561
1597
  message: AgentMessage;
1598
+ attachments?: readonly MessageAttachmentView[];
1562
1599
  onRetry?: () => void;
1563
1600
  MessageEntryComponent: ComponentType<MessageEntryProps>;
1564
1601
  }) {
1565
1602
  return (
1566
1603
  <div className="flex flex-col gap-1">
1567
- <MessageEntryComponent message={message} />
1604
+ {/* Attachments stay visible on the failed bubble — the one turn where
1605
+ the user most needs evidence of what they tried to send (and what
1606
+ Retry will re-send). No executionId: the send never created one. */}
1607
+ <MessageEntryComponent message={message} attachments={attachments} />
1568
1608
  <div
1569
1609
  role="alert"
1570
1610
  className="mx-4 flex items-center gap-2 text-xs text-destructive"