@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,308 @@
1
+ "use client";
2
+
3
+ import { useCallback, useMemo, useState } from "react";
4
+ import { create } from "@bufbuild/protobuf";
5
+ import { cn } from "@stigmer/theme";
6
+ import { getUserMessage } from "@stigmer/sdk";
7
+ import type { ConversationMediaRef } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
8
+ import { GetConversationMediaDownloadUrlInputSchema } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
9
+ import { AttachmentImageLightbox } from "../attachment/AttachmentImageLightbox.js";
10
+ import { formatFileSize } from "../attachment/attachment-utils.js";
11
+ import { UNSTYLED_BUTTON } from "../internal/form-primitives.js";
12
+ import { toError } from "../internal/toError.js";
13
+ import { useStigmer } from "../hooks.js";
14
+ import { useConversationMediaUrl } from "./useConversationMediaUrl.js";
15
+
16
+ /**
17
+ * The full address of one timeline item's media — what
18
+ * `getMediaDownloadUrl` is addressed by. The storage key deliberately
19
+ * never rides the wire (whatsapp-media DD-001 D4); the server resolves
20
+ * it from its own row, so this address is all a client ever holds.
21
+ */
22
+ export interface ConversationMediaAddress {
23
+ /** AgentChannel the conversation belongs to. */
24
+ readonly agentChannelId: string;
25
+ /** Conversation key within the channel (WhatsApp: the customer's wa_id). */
26
+ readonly conversationKey: string;
27
+ /** The timeline item carrying the media (its item_id, e.g. `"wa:<wamid>"`). */
28
+ readonly itemId: string;
29
+ }
30
+
31
+ /** Props for {@link ConversationMediaAttachment}. */
32
+ export interface ConversationMediaAttachmentProps {
33
+ /** The item's media reference (filename, content type, size). */
34
+ readonly media: ConversationMediaRef;
35
+ /** Where to fetch the bytes from. */
36
+ readonly address: ConversationMediaAddress;
37
+ /** Additional CSS classes for the container. */
38
+ readonly className?: string;
39
+ }
40
+
41
+ /**
42
+ * One inbound timeline item's ingested media (stigmer/stigmer#367):
43
+ * images as a WhatsApp-style inline thumbnail (click opens the shared
44
+ * {@link AttachmentImageLightbox}), everything else as a document chip
45
+ * (filename + size, click opens the file in a new tab).
46
+ *
47
+ * This component calls `useStigmer()`, so it must only be MOUNTED when
48
+ * the timeline view holds a conversation address — that conditional
49
+ * mount is what keeps `ConversationTimelineView` renderable without a
50
+ * `StigmerProvider` (its presentational contract; see the view's
51
+ * `agentChannelId` prop).
52
+ *
53
+ * Deliberately a sibling of the execution thread's `MessageAttachments`,
54
+ * not a reuse of it: that row renders storage-key-addressed artifacts of
55
+ * an execution the viewer owns, while this one renders
56
+ * conversation-viewer-scoped media addressed by timeline position — the
57
+ * two read paths have different trust models (DD-001 D4) and different
58
+ * bubble treatments (chat thumbnail vs. attachment chip row).
59
+ */
60
+ export function ConversationMediaAttachment({
61
+ media,
62
+ address,
63
+ className,
64
+ }: ConversationMediaAttachmentProps) {
65
+ const isImage = media.contentType.startsWith("image/");
66
+ return isImage ? (
67
+ <MediaImageThumbnail media={media} address={address} className={className} />
68
+ ) : (
69
+ <MediaDocumentChip media={media} address={address} className={className} />
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Display name for the media: the ingest-recorded filename (documents
75
+ * keep the provider's name; camera images get a platform-synthesized
76
+ * one, so this is never empty in practice), with a defensive fallback.
77
+ */
78
+ function displayName(media: ConversationMediaRef): string {
79
+ return media.filename || "attachment";
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Image — WhatsApp-style inline thumbnail, click-to-open lightbox
84
+ // ---------------------------------------------------------------------------
85
+
86
+ /**
87
+ * A bounded preview frame in the bubble, minted from the item address at
88
+ * view time. The frame is fixed-size for BOTH the pulse placeholder and
89
+ * the loaded image, so the load causes no layout shift (the
90
+ * content-agnostic auto-scroll then needs no special case). If the URL
91
+ * can't be minted (revoked media, transient API failure, the server's
92
+ * uniform NOT_FOUND), the treatment degrades to the document chip —
93
+ * never a broken-image glyph (the `MessageAttachments` precedent).
94
+ */
95
+ function MediaImageThumbnail({
96
+ media,
97
+ address,
98
+ className,
99
+ }: {
100
+ readonly media: ConversationMediaRef;
101
+ readonly address: ConversationMediaAddress;
102
+ readonly className?: string;
103
+ }) {
104
+ const { url, error } = useConversationMediaUrl(
105
+ address.agentChannelId,
106
+ address.conversationKey,
107
+ address.itemId,
108
+ );
109
+ const [previewOpen, setPreviewOpen] = useState(false);
110
+ const name = displayName(media);
111
+
112
+ if (error) {
113
+ return <MediaDocumentChip media={media} address={address} className={className} />;
114
+ }
115
+
116
+ return (
117
+ <>
118
+ {/* UNSTYLED_BUTTON is load-bearing: without it the thumbnail shows
119
+ the UA's default button box in preflight-less hosts. */}
120
+ <button
121
+ type="button"
122
+ onClick={() => setPreviewOpen(true)}
123
+ aria-label={`Preview ${name}`}
124
+ className={cn(
125
+ UNSTYLED_BUTTON,
126
+ "block rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
127
+ className,
128
+ )}
129
+ >
130
+ <span className="relative block h-40 w-56 max-w-full overflow-hidden rounded-md">
131
+ {url ? (
132
+ <img
133
+ src={url}
134
+ alt=""
135
+ aria-hidden="true"
136
+ loading="lazy"
137
+ className="h-full w-full object-cover"
138
+ />
139
+ ) : (
140
+ // Pulse placeholder while the presigned URL is minted; same
141
+ // footprint as the image, so no layout shift.
142
+ <span
143
+ className="block h-full w-full animate-pulse bg-muted"
144
+ aria-hidden="true"
145
+ />
146
+ )}
147
+ </span>
148
+ </button>
149
+ {previewOpen && (
150
+ <AttachmentImageLightbox
151
+ src={url}
152
+ filename={name}
153
+ open
154
+ onClose={() => setPreviewOpen(false)}
155
+ />
156
+ )}
157
+ </>
158
+ );
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Document — compact chip, click-to-open in a new tab
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /**
166
+ * The compact treatment for documents (and the degraded arm for images
167
+ * whose URL failed): file glyph, filename, size. A click mints a FRESH
168
+ * URL — presigned URLs expire, so the stable item address is the thing
169
+ * to hold, not a URL (the `useArtifactDownload` rationale) — and opens
170
+ * it in a new tab: the media contract presigns inline disposition only,
171
+ * so the browser previews what it can (PDFs) and downloads the rest.
172
+ * A failed mint reports below the chip in the surface's own error
173
+ * grammar (DD-006 — never a silent dead click).
174
+ */
175
+ function MediaDocumentChip({
176
+ media,
177
+ address,
178
+ className,
179
+ }: {
180
+ readonly media: ConversationMediaRef;
181
+ readonly address: ConversationMediaAddress;
182
+ readonly className?: string;
183
+ }) {
184
+ const { open, isOpening, error } = useOpenConversationMedia(address);
185
+ const name = displayName(media);
186
+ const size = media.sizeBytes > BigInt(0)
187
+ ? // WhatsApp caps media far below 2^53 bytes, so the narrowing is exact.
188
+ formatFileSize(Number(media.sizeBytes))
189
+ : null;
190
+
191
+ return (
192
+ <div className={className}>
193
+ <span
194
+ title={name}
195
+ className="inline-flex max-w-[240px] items-center rounded-md bg-muted-subtle px-2 py-1 text-xs text-foreground"
196
+ >
197
+ <button
198
+ type="button"
199
+ onClick={() => void open()}
200
+ disabled={isOpening}
201
+ aria-label={`Open ${name}`}
202
+ className={cn(
203
+ UNSTYLED_BUTTON,
204
+ "flex min-w-0 items-center gap-1.5 rounded-sm text-inherit [font:inherit] hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
205
+ isOpening && "opacity-70",
206
+ )}
207
+ >
208
+ <FileGlyph />
209
+ <span className="min-w-0 truncate">{name}</span>
210
+ {size !== null && (
211
+ <span className="shrink-0 text-muted-foreground">{size}</span>
212
+ )}
213
+ </button>
214
+ </span>
215
+ {error !== null && (
216
+ <p className="mt-1 break-words text-xs text-destructive">
217
+ {getUserMessage(error)}
218
+ </p>
219
+ )}
220
+ </div>
221
+ );
222
+ }
223
+
224
+ /**
225
+ * Click → mint fresh URL → open in a new tab. Kept local to this file:
226
+ * unlike {@link useConversationMediaUrl} (render data), an open is a
227
+ * user action — and it must complete within the browser's
228
+ * user-activation window, which the single fast unary mint does.
229
+ */
230
+ function useOpenConversationMedia(address: ConversationMediaAddress): {
231
+ readonly open: () => Promise<void>;
232
+ readonly isOpening: boolean;
233
+ readonly error: Error | null;
234
+ } {
235
+ const stigmer = useStigmer();
236
+ const [isOpening, setIsOpening] = useState(false);
237
+ const [error, setError] = useState<Error | null>(null);
238
+
239
+ const open = useCallback(async () => {
240
+ if (isOpening) return;
241
+ setIsOpening(true);
242
+ setError(null);
243
+ try {
244
+ const result = await stigmer.agentChannel.getMediaDownloadUrl(
245
+ create(GetConversationMediaDownloadUrlInputSchema, {
246
+ agentChannelId: address.agentChannelId,
247
+ conversationKey: address.conversationKey,
248
+ itemId: address.itemId,
249
+ }),
250
+ );
251
+ if (result.url) {
252
+ openInNewTab(result.url);
253
+ }
254
+ } catch (err) {
255
+ setError(toError(err));
256
+ } finally {
257
+ setIsOpening(false);
258
+ }
259
+ }, [stigmer, address.agentChannelId, address.conversationKey, address.itemId, isOpening]);
260
+
261
+ return useMemo(() => ({ open, isOpening, error }), [open, isOpening, error]);
262
+ }
263
+
264
+ /**
265
+ * Open a URL in a new tab via a transient anchor click (the
266
+ * `triggerBrowserDownload` shape, minus the `download` hint — the URL's
267
+ * disposition is inline and cross-origin anchors ignore the attribute
268
+ * anyway). Falls back to `window.open` in non-DOM environments.
269
+ */
270
+ function openInNewTab(url: string): void {
271
+ if (typeof document === "undefined") {
272
+ if (typeof window !== "undefined") {
273
+ window.open(url, "_blank", "noopener,noreferrer");
274
+ }
275
+ return;
276
+ }
277
+ const anchor = document.createElement("a");
278
+ anchor.href = url;
279
+ anchor.rel = "noopener noreferrer";
280
+ anchor.target = "_blank";
281
+ document.body.appendChild(anchor);
282
+ anchor.click();
283
+ document.body.removeChild(anchor);
284
+ }
285
+
286
+ /**
287
+ * Generic document glyph — the same geometry as the attachment chips'
288
+ * icon so the surfaces read as one family.
289
+ */
290
+ function FileGlyph() {
291
+ return (
292
+ <svg
293
+ width="10"
294
+ height="10"
295
+ viewBox="0 0 14 14"
296
+ fill="none"
297
+ stroke="currentColor"
298
+ strokeWidth="1.5"
299
+ strokeLinecap="round"
300
+ strokeLinejoin="round"
301
+ className="shrink-0 text-muted-foreground"
302
+ aria-hidden="true"
303
+ >
304
+ <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" />
305
+ <path d="M8 1V4H11" />
306
+ </svg>
307
+ );
308
+ }
@@ -17,6 +17,7 @@ import {
17
17
  TooltipTrigger,
18
18
  } from "../internal/tooltip.js";
19
19
  import { useAutoScroll } from "../internal/useAutoScroll.js";
20
+ import { ConversationMediaAttachment } from "./ConversationMediaAttachment.js";
20
21
  import {
21
22
  authorKindOf,
22
23
  inboundPlaceholderOf,
@@ -30,6 +31,23 @@ import {
30
31
  export interface ConversationTimelineViewProps {
31
32
  /** Timeline items in chronological order (from `useConversationTimeline`). */
32
33
  readonly items: readonly ConversationTimelineItem[];
34
+ /**
35
+ * AgentChannel the conversation belongs to. Together with
36
+ * `conversationKey`, this is the opt-in for media rendering: items
37
+ * carrying `media` render an inline thumbnail or document chip whose
38
+ * bytes are fetched via `getMediaDownloadUrl` — which is addressed by
39
+ * (channel, conversation, item), so the view cannot fetch media
40
+ * without the address.
41
+ *
42
+ * When either identity prop is absent, media items keep the typed
43
+ * placeholder ("Photo", "Document") — and, load-bearing for
44
+ * provider-less hosts (documentation fixtures, visual tests): nothing
45
+ * that needs a `StigmerProvider` mounts, so the view stays purely
46
+ * presentational exactly as before these props existed.
47
+ */
48
+ readonly agentChannelId?: string;
49
+ /** Conversation key within the channel (see `agentChannelId`). */
50
+ readonly conversationKey?: string;
33
51
  /** `true` only during the first load. */
34
52
  readonly isLoading: boolean;
35
53
  /** Error from the timeline read, or `null`. */
@@ -71,6 +89,8 @@ export interface ConversationTimelineViewProps {
71
89
  */
72
90
  export function ConversationTimelineView({
73
91
  items,
92
+ agentChannelId,
93
+ conversationKey,
74
94
  isLoading,
75
95
  error,
76
96
  hasOlder,
@@ -146,7 +166,12 @@ export function ConversationTimelineView({
146
166
  </div>
147
167
  <ul className="space-y-1.5">
148
168
  {day.items.map((item) => (
149
- <TimelineItemRow key={item.itemId} item={item} />
169
+ <TimelineItemRow
170
+ key={item.itemId}
171
+ item={item}
172
+ agentChannelId={agentChannelId}
173
+ conversationKey={conversationKey}
174
+ />
150
175
  ))}
151
176
  </ul>
152
177
  </div>
@@ -170,8 +195,12 @@ const BUSINESS_CAPTION: Record<Exclude<ConversationAuthorKind, "customer">, stri
170
195
 
171
196
  const TimelineItemRow = memo(function TimelineItemRow({
172
197
  item,
198
+ agentChannelId,
199
+ conversationKey,
173
200
  }: {
174
201
  readonly item: ConversationTimelineItem;
202
+ readonly agentChannelId?: string;
203
+ readonly conversationKey?: string;
175
204
  }) {
176
205
  if (isInternalItem(item)) {
177
206
  return <InternalEventRow item={item} />;
@@ -179,7 +208,17 @@ const TimelineItemRow = memo(function TimelineItemRow({
179
208
 
180
209
  const author = authorKindOf(item);
181
210
  const isCustomer = author === "customer";
182
- const body = item.text || inboundPlaceholderOf(item);
211
+
212
+ // Ingested media renders as the real thing — an inline thumbnail or
213
+ // document chip — ONLY when the view holds the conversation address
214
+ // (getMediaDownloadUrl's addressing; see the identity props' contract).
215
+ // Without the address, and on media the platform declined to ingest,
216
+ // the typed placeholder keeps telling staff what arrived.
217
+ const mediaAddress =
218
+ item.media !== undefined && agentChannelId && conversationKey
219
+ ? { agentChannelId, conversationKey, itemId: item.itemId }
220
+ : null;
221
+ const body = item.text || (mediaAddress !== null ? "" : inboundPlaceholderOf(item));
183
222
 
184
223
  // DD-014 D-c (amended at the Sitting 2 gate, R-1): the provider's
185
224
  // failure explanation is decision-bearing — window closed vs bad
@@ -211,14 +250,25 @@ const TimelineItemRow = memo(function TimelineItemRow({
211
250
  {BUSINESS_CAPTION[author]}
212
251
  </p>
213
252
  )}
214
- <p
215
- className={cn(
216
- "whitespace-pre-wrap break-words text-sm text-foreground",
217
- !item.text && "italic text-muted-foreground",
218
- )}
219
- >
220
- {body || "Message content unavailable"}
221
- </p>
253
+ {mediaAddress !== null && item.media !== undefined && (
254
+ <ConversationMediaAttachment
255
+ media={item.media}
256
+ address={mediaAddress}
257
+ className={item.text ? "mb-1.5" : undefined}
258
+ />
259
+ )}
260
+ {/* A media item without a caption is complete as its media —
261
+ no body, and no "unavailable" apology under the thumbnail. */}
262
+ {(body || mediaAddress === null) && (
263
+ <p
264
+ className={cn(
265
+ "whitespace-pre-wrap break-words text-sm text-foreground",
266
+ !item.text && "italic text-muted-foreground",
267
+ )}
268
+ >
269
+ {body || "Message content unavailable"}
270
+ </p>
271
+ )}
222
272
  {receiptExplanation !== null && (
223
273
  <p className="mt-1 break-words text-xs text-destructive">
224
274
  {receiptExplanation}
@@ -354,6 +354,8 @@ export function ConversationsWorkbench({
354
354
 
355
355
  <ConversationTimelineView
356
356
  items={timeline.items}
357
+ agentChannelId={selected.agentChannelId}
358
+ conversationKey={selected.conversationKey}
357
359
  isLoading={timeline.isLoading}
358
360
  error={timeline.error}
359
361
  hasOlder={timeline.hasOlder}