@stigmer/react 3.9.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 (116) hide show
  1. package/attachment/AttachmentChipList.d.ts +16 -4
  2. package/attachment/AttachmentChipList.d.ts.map +1 -1
  3. package/attachment/AttachmentChipList.js +101 -35
  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/useObjectUrl.d.ts +15 -0
  10. package/attachment/useObjectUrl.d.ts.map +1 -0
  11. package/attachment/useObjectUrl.js +25 -0
  12. package/attachment/useObjectUrl.js.map +1 -0
  13. package/conversation/ConversationMediaAttachment.d.ts +45 -0
  14. package/conversation/ConversationMediaAttachment.d.ts.map +1 -0
  15. package/conversation/ConversationMediaAttachment.js +153 -0
  16. package/conversation/ConversationMediaAttachment.js.map +1 -0
  17. package/conversation/ConversationTimelineView.d.ts +18 -1
  18. package/conversation/ConversationTimelineView.d.ts.map +1 -1
  19. package/conversation/ConversationTimelineView.js +14 -5
  20. package/conversation/ConversationTimelineView.js.map +1 -1
  21. package/conversation/ConversationsWorkbench.d.ts.map +1 -1
  22. package/conversation/ConversationsWorkbench.js +1 -1
  23. package/conversation/ConversationsWorkbench.js.map +1 -1
  24. package/conversation/conversationPresentation.d.ts +10 -3
  25. package/conversation/conversationPresentation.d.ts.map +1 -1
  26. package/conversation/conversationPresentation.js +10 -3
  27. package/conversation/conversationPresentation.js.map +1 -1
  28. package/conversation/index.d.ts +2 -0
  29. package/conversation/index.d.ts.map +1 -1
  30. package/conversation/index.js +1 -0
  31. package/conversation/index.js.map +1 -1
  32. package/conversation/useConversationMediaUrl.d.ts +65 -0
  33. package/conversation/useConversationMediaUrl.d.ts.map +1 -0
  34. package/conversation/useConversationMediaUrl.js +61 -0
  35. package/conversation/useConversationMediaUrl.js.map +1 -0
  36. package/execution/MessageAttachments.d.ts +52 -0
  37. package/execution/MessageAttachments.d.ts.map +1 -0
  38. package/execution/MessageAttachments.js +142 -0
  39. package/execution/MessageAttachments.js.map +1 -0
  40. package/execution/MessageEntry.d.ts +18 -0
  41. package/execution/MessageEntry.d.ts.map +1 -1
  42. package/execution/MessageEntry.js +5 -4
  43. package/execution/MessageEntry.js.map +1 -1
  44. package/execution/MessageThread.d.ts +15 -2
  45. package/execution/MessageThread.d.ts.map +1 -1
  46. package/execution/MessageThread.js +24 -7
  47. package/execution/MessageThread.js.map +1 -1
  48. package/execution/index.d.ts +2 -0
  49. package/execution/index.d.ts.map +1 -1
  50. package/execution/index.js +1 -0
  51. package/execution/index.js.map +1 -1
  52. package/github/decodeGitHubContent.d.ts +10 -5
  53. package/github/decodeGitHubContent.d.ts.map +1 -1
  54. package/github/decodeGitHubContent.js +21 -7
  55. package/github/decodeGitHubContent.js.map +1 -1
  56. package/github/useGitHubFileReader.d.ts.map +1 -1
  57. package/github/useGitHubFileReader.js +5 -5
  58. package/github/useGitHubFileReader.js.map +1 -1
  59. package/index.d.ts +3 -3
  60. package/index.d.ts.map +1 -1
  61. package/index.js +2 -2
  62. package/index.js.map +1 -1
  63. package/internal/form-primitives.d.ts +13 -0
  64. package/internal/form-primitives.d.ts.map +1 -1
  65. package/internal/form-primitives.js +13 -0
  66. package/internal/form-primitives.js.map +1 -1
  67. package/package.json +4 -4
  68. package/session/SessionViewer.js +1 -1
  69. package/session/SessionViewer.js.map +1 -1
  70. package/session/useSessionConversation.d.ts +8 -0
  71. package/session/useSessionConversation.d.ts.map +1 -1
  72. package/session/useSessionConversation.js +12 -2
  73. package/session/useSessionConversation.js.map +1 -1
  74. package/src/attachment/AttachmentChipList.tsx +318 -72
  75. package/src/attachment/AttachmentImageLightbox.tsx +156 -0
  76. package/src/attachment/__tests__/AttachmentChipList.test.tsx +240 -17
  77. package/src/attachment/useObjectUrl.ts +28 -0
  78. package/src/conversation/ConversationMediaAttachment.tsx +308 -0
  79. package/src/conversation/ConversationTimelineView.tsx +60 -10
  80. package/src/conversation/ConversationsWorkbench.tsx +2 -0
  81. package/src/conversation/__tests__/ConversationTimelineView.test.tsx +233 -6
  82. package/src/conversation/__tests__/a11y/conversation-surfaces.a11y.test.tsx +67 -1
  83. package/src/conversation/__tests__/useConversationMediaUrl.test.tsx +66 -0
  84. package/src/conversation/conversationPresentation.ts +10 -3
  85. package/src/conversation/index.ts +6 -0
  86. package/src/conversation/useConversationMediaUrl.ts +121 -0
  87. package/src/execution/MessageAttachments.tsx +364 -0
  88. package/src/execution/MessageEntry.tsx +38 -0
  89. package/src/execution/MessageThread.tsx +44 -4
  90. package/src/execution/__tests__/MessageEntry-attachments.test.tsx +228 -0
  91. package/src/execution/__tests__/buildThreadItems-attachments.test.ts +174 -0
  92. package/src/execution/index.ts +6 -0
  93. package/src/github/__tests__/decodeGitHubContent.test.ts +42 -1
  94. package/src/github/decodeGitHubContent.ts +22 -5
  95. package/src/github/useGitHubFileReader.ts +13 -3
  96. package/src/index.ts +5 -0
  97. package/src/internal/form-primitives.tsx +14 -0
  98. package/src/session/SessionViewer.tsx +1 -0
  99. package/src/session/__tests__/useSessionConversation.test.tsx +62 -0
  100. package/src/session/useSessionConversation.ts +21 -2
  101. package/src/workspace/FileViewer.tsx +109 -0
  102. package/src/workspace/WorkspaceFileReader.ts +55 -1
  103. package/src/workspace/__tests__/FileViewer.test.tsx +98 -0
  104. package/src/workspace/index.ts +2 -0
  105. package/styles.css +1 -1
  106. package/workspace/FileViewer.d.ts.map +1 -1
  107. package/workspace/FileViewer.js +40 -2
  108. package/workspace/FileViewer.js.map +1 -1
  109. package/workspace/WorkspaceFileReader.d.ts +29 -1
  110. package/workspace/WorkspaceFileReader.d.ts.map +1 -1
  111. package/workspace/WorkspaceFileReader.js +42 -0
  112. package/workspace/WorkspaceFileReader.js.map +1 -1
  113. package/workspace/index.d.ts +1 -1
  114. package/workspace/index.d.ts.map +1 -1
  115. package/workspace/index.js +1 -1
  116. package/workspace/index.js.map +1 -1
@@ -0,0 +1,121 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import { create } from "@bufbuild/protobuf";
5
+ import { GetConversationMediaDownloadUrlInputSchema } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
6
+ import { useStigmer } from "../hooks.js";
7
+ import { useFetch } from "../internal/useFetch.js";
8
+
9
+ /** Return value of {@link useConversationMediaUrl}. */
10
+ export interface UseConversationMediaUrlReturn {
11
+ /**
12
+ * A freshly minted presigned download URL for the timeline item's media,
13
+ * or `null` when:
14
+ * - Fetching is skipped (`enabled` is `false`, or any address part is empty)
15
+ * - The request is in-flight
16
+ * - The request failed (including the server's uniform NOT_FOUND for
17
+ * items without ingested media)
18
+ */
19
+ readonly url: string | null;
20
+
21
+ /** `true` while the URL request is in-flight (first load). */
22
+ readonly isLoading: boolean;
23
+
24
+ /** `true` while a background refetch is in flight. */
25
+ readonly isRefetching: boolean;
26
+
27
+ /** Error from the last failed attempt, or `null` when healthy. */
28
+ readonly error: Error | null;
29
+
30
+ /** Re-fetch the URL (e.g. to mint a fresh one after the previous expired). */
31
+ readonly refetch: () => void;
32
+ }
33
+
34
+ interface UrlData {
35
+ readonly url: string | null;
36
+ }
37
+
38
+ const EMPTY: UrlData = { url: null };
39
+
40
+ /** Options for {@link useConversationMediaUrl}. */
41
+ export interface UseConversationMediaUrlOptions {
42
+ /**
43
+ * Gate the request. When `false`, the hook stays idle and returns `null` —
44
+ * use this to defer minting a URL until it is actually needed.
45
+ * Defaults to `true`.
46
+ */
47
+ readonly enabled?: boolean;
48
+ }
49
+
50
+ /**
51
+ * Data hook that resolves a **fresh** presigned download URL for one
52
+ * timeline item's ingested media (an image or document the customer sent)
53
+ * via `stigmer.agentChannel.getMediaDownloadUrl()`.
54
+ *
55
+ * Addressed by `(channel, conversation, item_id)` — never a storage key:
56
+ * the server resolves the blob from its own row, so the read path stays
57
+ * conversation-viewer-scoped by construction and blob capabilities never
58
+ * ride the wire (whatsapp-media DD-001 D4). The URL expires (about an
59
+ * hour); minting at view time keeps it always valid, the
60
+ * `useArtifactDownloadUrl` rationale.
61
+ *
62
+ * The URL is cached cross-mount by the item's full address (DD-014), so
63
+ * the 5s timeline poll's re-renders and a lightbox opened from a
64
+ * thumbnail reuse the minted URL instead of re-hitting the API.
65
+ *
66
+ * Pass empty strings (or `enabled: false`) to skip fetching — the
67
+ * conversation surface's established skip convention.
68
+ *
69
+ * @param agentChannelId - AgentChannel the conversation belongs to, or `""` to skip.
70
+ * @param conversationKey - Conversation key within the channel, or `""` to skip.
71
+ * @param itemId - The timeline item whose media to fetch (e.g. `"wa:<wamid>"`), or `""` to skip.
72
+ * @param options - Optional gating (`enabled`).
73
+ *
74
+ * @example
75
+ * ```tsx
76
+ * // Render an inbound photo with an always-fresh URL.
77
+ * const { url, isLoading } = useConversationMediaUrl(channelId, key, item.itemId);
78
+ * if (isLoading) return <Skeleton />;
79
+ * return url ? <img src={url} alt={item.media?.filename} /> : null;
80
+ * ```
81
+ *
82
+ * @see useArtifactDownloadUrl — the execution-artifact counterpart this mirrors
83
+ */
84
+ export function useConversationMediaUrl(
85
+ agentChannelId: string,
86
+ conversationKey: string,
87
+ itemId: string,
88
+ options?: UseConversationMediaUrlOptions,
89
+ ): UseConversationMediaUrlReturn {
90
+ const stigmer = useStigmer();
91
+ const enabled = options?.enabled ?? true;
92
+ const active =
93
+ enabled && agentChannelId !== "" && conversationKey !== "" && itemId !== "";
94
+
95
+ const { data, isLoading, isRefetching, error, refetch } = useFetch(
96
+ active
97
+ ? () =>
98
+ stigmer.agentChannel
99
+ .getMediaDownloadUrl(
100
+ create(GetConversationMediaDownloadUrlInputSchema, {
101
+ agentChannelId,
102
+ conversationKey,
103
+ itemId,
104
+ }),
105
+ )
106
+ .then((result): UrlData => ({ url: result.url || null }))
107
+ : null,
108
+ [agentChannelId, conversationKey, itemId, active, stigmer],
109
+ EMPTY,
110
+ {
111
+ cacheKey: active
112
+ ? `conversation-media-url:${agentChannelId}:${conversationKey}:${itemId}`
113
+ : undefined,
114
+ },
115
+ );
116
+
117
+ return useMemo(
118
+ () => ({ url: data.url, isLoading, isRefetching, error, refetch }),
119
+ [data.url, isLoading, isRefetching, error, refetch],
120
+ );
121
+ }
@@ -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