@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
@@ -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}
@@ -1,8 +1,10 @@
1
- import { describe, it, expect, vi, afterEach } from "vitest";
2
- import { cleanup, render, screen } from "@testing-library/react";
1
+ import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
2
+ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
3
3
  import userEvent from "@testing-library/user-event";
4
+ import type { ReactElement } from "react";
4
5
  import { create } from "@bufbuild/protobuf";
5
6
  import { timestampFromDate } from "@bufbuild/protobuf/wkt";
7
+ import type { Stigmer } from "@stigmer/sdk";
6
8
  import {
7
9
  ConversationItemAuthor,
8
10
  ConversationLane,
@@ -10,6 +12,7 @@ import {
10
12
  } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
11
13
  import { ChannelDeliveryStatus } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/delivery_pb";
12
14
  import { ChannelReceiptState } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/outbound_pb";
15
+ import { StigmerContext } from "../../context";
13
16
  import { ConversationTimelineView } from "../ConversationTimelineView";
14
17
 
15
18
  const NOW = new Date("2026-08-07T12:00:00Z");
@@ -308,10 +311,14 @@ describe("ConversationTimelineView", () => {
308
311
  expect(screen.getByText("Held")).toBeDefined();
309
312
  expect(screen.getByText("Delivery failed")).toBeDefined();
310
313
  // Zero new tab stops: seven focusable glyphs per screen of messages
311
- // would be an accessibility regression dressed as a fix. Nothing
312
- // inside a message row may be focusable — the Jump-to-latest
313
- // affordance (which manages its own tabIndex) lives outside the
314
- // rows and stays the surface's only interactive element.
314
+ // would be an accessibility regression dressed as a fix. No status
315
+ // or metadata element inside a message row may be focusable — the
316
+ // Jump-to-latest affordance (which manages its own tabIndex) lives
317
+ // outside the rows. Media affordances (thumbnail preview, document
318
+ // open) are the one deliberate exception: they are the row's
319
+ // CONTENT action, which keyboard users must reach — covered in the
320
+ // "inbound media" suite below. These fixtures carry none, so the
321
+ // row-wide assertion holds here.
315
322
  expect(container.querySelectorAll("li [tabindex]")).toHaveLength(0);
316
323
  expect(container.querySelectorAll("li button")).toHaveLength(0);
317
324
  });
@@ -403,3 +410,223 @@ describe("ConversationTimelineView", () => {
403
410
  expect(screen.getByText("stitch failed")).toBeDefined();
404
411
  });
405
412
  });
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // Inbound media (stigmer/stigmer#367): items carrying ingested media render
416
+ // the real thing — an inline thumbnail or a document chip whose bytes are
417
+ // fetched through getMediaDownloadUrl — instead of the typed placeholder.
418
+ // The real hook runs against a mocked Stigmer client, so these cover the
419
+ // full wiring: the item address on the RPC, the presigned URL on the
420
+ // <img>, and the click-time mint on documents.
421
+ // ---------------------------------------------------------------------------
422
+
423
+ describe("ConversationTimelineView inbound media", () => {
424
+ // happy-dom does not implement the native dialog show/close methods.
425
+ beforeAll(() => {
426
+ HTMLDialogElement.prototype.showModal = function showModal() {
427
+ this.open = true;
428
+ };
429
+ HTMLDialogElement.prototype.close = function close() {
430
+ this.open = false;
431
+ };
432
+ });
433
+
434
+ afterEach(() => cleanup());
435
+
436
+ const identityProps = {
437
+ agentChannelId: "ach-1",
438
+ conversationKey: "15551234567",
439
+ };
440
+
441
+ const photoMedia = {
442
+ filename: "kitchen-leak.jpg",
443
+ contentType: "image/jpeg",
444
+ sizeBytes: BigInt(204800),
445
+ };
446
+
447
+ const documentMedia = {
448
+ filename: "lease-agreement.pdf",
449
+ contentType: "application/pdf",
450
+ sizeBytes: BigInt(1048576),
451
+ };
452
+
453
+ function renderWithClient(ui: ReactElement, getMediaDownloadUrl: ReturnType<typeof vi.fn>) {
454
+ const stigmer = { agentChannel: { getMediaDownloadUrl } } as unknown as Stigmer;
455
+ return render(
456
+ <StigmerContext.Provider value={stigmer}>{ui}</StigmerContext.Provider>,
457
+ );
458
+ }
459
+
460
+ it("renders an inbound photo as a thumbnail with click-to-open lightbox", async () => {
461
+ const mint = vi.fn().mockResolvedValue({ url: "https://r2.example/presigned/photo" });
462
+ const { container } = renderWithClient(
463
+ <ConversationTimelineView
464
+ {...baseProps()}
465
+ {...identityProps}
466
+ items={[
467
+ item("wa:1", {
468
+ author: ConversationItemAuthor.author_customer,
469
+ providerMessageType: "image",
470
+ media: photoMedia,
471
+ }),
472
+ ]}
473
+ />,
474
+ mint,
475
+ );
476
+
477
+ // The URL is minted from the item's full conversation address —
478
+ // never a storage key (whatsapp-media DD-001 D4).
479
+ await waitFor(() => {
480
+ const img = container.querySelector("img[aria-hidden='true']");
481
+ expect(img?.getAttribute("src")).toBe("https://r2.example/presigned/photo");
482
+ });
483
+ const req = mint.mock.calls[0][0];
484
+ expect(req.agentChannelId).toBe("ach-1");
485
+ expect(req.conversationKey).toBe("15551234567");
486
+ expect(req.itemId).toBe("wa:1");
487
+
488
+ // The placeholder gave way to the real thing.
489
+ expect(screen.queryByText("Photo")).toBeNull();
490
+
491
+ // Click-to-open: the shared lightbox mounts with the full image.
492
+ fireEvent.click(screen.getByRole("button", { name: "Preview kitchen-leak.jpg" }));
493
+ const dialog = container.querySelector("dialog");
494
+ expect(dialog).toBeTruthy();
495
+ expect(dialog!.getAttribute("aria-label")).toBe("Preview kitchen-leak.jpg");
496
+ });
497
+
498
+ it("renders a photo's caption beside it, and no apology when there is none", async () => {
499
+ const mint = vi.fn().mockResolvedValue({ url: "https://r2.example/presigned/photo" });
500
+ renderWithClient(
501
+ <ConversationTimelineView
502
+ {...baseProps()}
503
+ {...identityProps}
504
+ items={[
505
+ item("wa:1", {
506
+ author: ConversationItemAuthor.author_customer,
507
+ providerMessageType: "image",
508
+ text: "here's the leak under the sink",
509
+ media: photoMedia,
510
+ }),
511
+ ]}
512
+ />,
513
+ mint,
514
+ );
515
+
516
+ expect(screen.getByText("here's the leak under the sink")).toBeDefined();
517
+ // A caption-less media item is complete as its media — never the
518
+ // "Message content unavailable" apology (that copy is for items
519
+ // with neither text nor media nor a typed placeholder).
520
+ expect(screen.queryByText("Message content unavailable")).toBeNull();
521
+ });
522
+
523
+ it("renders an inbound document as a chip; a click mints a fresh URL and opens it", async () => {
524
+ const mint = vi.fn().mockResolvedValue({ url: "https://r2.example/presigned/doc" });
525
+ const anchorClick = vi
526
+ .spyOn(HTMLAnchorElement.prototype, "click")
527
+ .mockImplementation(() => {});
528
+ renderWithClient(
529
+ <ConversationTimelineView
530
+ {...baseProps()}
531
+ {...identityProps}
532
+ items={[
533
+ item("wa:2", {
534
+ author: ConversationItemAuthor.author_customer,
535
+ providerMessageType: "document",
536
+ media: documentMedia,
537
+ }),
538
+ ]}
539
+ />,
540
+ mint,
541
+ );
542
+
543
+ // The chip carries the operator's decision facts: name and size.
544
+ expect(screen.getByText("lease-agreement.pdf")).toBeDefined();
545
+ expect(screen.getByText("1.0 MB")).toBeDefined();
546
+ // No render-time mint for documents — the URL is minted fresh at
547
+ // click time (presigned URLs expire; the item address does not).
548
+ expect(mint).not.toHaveBeenCalled();
549
+
550
+ fireEvent.click(screen.getByRole("button", { name: "Open lease-agreement.pdf" }));
551
+ await waitFor(() => expect(anchorClick).toHaveBeenCalled());
552
+ const req = mint.mock.calls[0][0];
553
+ expect(req.agentChannelId).toBe("ach-1");
554
+ expect(req.conversationKey).toBe("15551234567");
555
+ expect(req.itemId).toBe("wa:2");
556
+ anchorClick.mockRestore();
557
+ });
558
+
559
+ it("degrades a photo whose URL cannot be minted to the document treatment", async () => {
560
+ const mint = vi
561
+ .fn()
562
+ .mockRejectedValue(new Error("no downloadable media at this timeline item"));
563
+ const { container } = renderWithClient(
564
+ <ConversationTimelineView
565
+ {...baseProps()}
566
+ {...identityProps}
567
+ items={[
568
+ item("wa:1", {
569
+ author: ConversationItemAuthor.author_customer,
570
+ providerMessageType: "image",
571
+ media: photoMedia,
572
+ }),
573
+ ]}
574
+ />,
575
+ mint,
576
+ );
577
+
578
+ // Never a broken-image glyph — the file stays reachable as a chip.
579
+ await waitFor(() =>
580
+ expect(screen.getByRole("button", { name: "Open kitchen-leak.jpg" })).toBeDefined(),
581
+ );
582
+ expect(container.querySelector("img")).toBeNull();
583
+ });
584
+
585
+ it("reports a failed document open under the chip, never a silent dead click", async () => {
586
+ const mint = vi
587
+ .fn()
588
+ .mockRejectedValue(new Error("no downloadable media at this timeline item"));
589
+ renderWithClient(
590
+ <ConversationTimelineView
591
+ {...baseProps()}
592
+ {...identityProps}
593
+ items={[
594
+ item("wa:2", {
595
+ author: ConversationItemAuthor.author_customer,
596
+ providerMessageType: "document",
597
+ media: documentMedia,
598
+ }),
599
+ ]}
600
+ />,
601
+ mint,
602
+ );
603
+
604
+ fireEvent.click(screen.getByRole("button", { name: "Open lease-agreement.pdf" }));
605
+ await waitFor(() =>
606
+ expect(screen.getByText(/no downloadable media/)).toBeDefined(),
607
+ );
608
+ });
609
+
610
+ it("keeps the typed placeholder without the conversation address — and stays provider-free", () => {
611
+ // Deliberately NO StigmerProvider: without the identity props the
612
+ // media arm must not mount anything that needs one — the view's
613
+ // presentational contract for fixture-driven hosts (documentation
614
+ // tours, visual tests) predates media and must survive it.
615
+ const { container } = render(
616
+ <ConversationTimelineView
617
+ {...baseProps()}
618
+ items={[
619
+ item("wa:1", {
620
+ author: ConversationItemAuthor.author_customer,
621
+ providerMessageType: "image",
622
+ media: photoMedia,
623
+ }),
624
+ ]}
625
+ />,
626
+ );
627
+
628
+ expect(screen.getByText("Photo")).toBeDefined();
629
+ expect(container.querySelector("img")).toBeNull();
630
+ expect(container.querySelectorAll("li button")).toHaveLength(0);
631
+ });
632
+ });
@@ -9,9 +9,11 @@
9
9
  // states including the filtered one — each in light + dark against the
10
10
  // shipped stylesheet.
11
11
 
12
- import { describe, it, afterEach, vi } from "vitest";
12
+ import { describe, it, afterEach, expect, vi } from "vitest";
13
+ import { waitFor } from "@testing-library/react";
13
14
  import { create } from "@bufbuild/protobuf";
14
15
  import { timestampFromDate } from "@bufbuild/protobuf/wkt";
16
+ import type { Stigmer } from "@stigmer/sdk";
15
17
  import { AgentChannelSchema } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/api_pb";
16
18
  import {
17
19
  ChannelConversationListFilter,
@@ -23,6 +25,7 @@ import {
23
25
  } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
24
26
  import { ChannelDeliveryStatus } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/delivery_pb";
25
27
  import { ChannelReceiptState } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/outbound_pb";
28
+ import { StigmerContext } from "../../../context.js";
26
29
  import { ConversationComposer } from "../../ConversationComposer.js";
27
30
  import { ConversationListPane } from "../../ConversationListPane.js";
28
31
  import { ConversationTimelineView } from "../../ConversationTimelineView.js";
@@ -125,6 +128,49 @@ const timelineItems = [
125
128
  }),
126
129
  ];
127
130
 
131
+ // Inbound-media fixtures (stigmer/stigmer#367): a captioned photo and a
132
+ // document. Media rendering needs the conversation address AND a client
133
+ // that answers getMediaDownloadUrl — the inner provider below overrides
134
+ // the harness stub with one minting a data-URI image, so the audit sees
135
+ // the loaded thumbnail, not just the pulse placeholder.
136
+ const mediaTimelineItems = [
137
+ create(ConversationTimelineItemSchema, {
138
+ itemId: "wa:7",
139
+ lane: ConversationLane.lane_public,
140
+ author: ConversationItemAuthor.author_customer,
141
+ providerMessageType: "image",
142
+ text: "here's the leak under the sink",
143
+ media: {
144
+ filename: "kitchen-leak.jpg",
145
+ contentType: "image/jpeg",
146
+ sizeBytes: BigInt(204800),
147
+ },
148
+ at: timestampFromDate(new Date("2026-08-07T09:04:00Z")),
149
+ }),
150
+ create(ConversationTimelineItemSchema, {
151
+ itemId: "wa:8",
152
+ lane: ConversationLane.lane_public,
153
+ author: ConversationItemAuthor.author_customer,
154
+ providerMessageType: "document",
155
+ media: {
156
+ filename: "lease-agreement.pdf",
157
+ contentType: "application/pdf",
158
+ sizeBytes: BigInt(1048576),
159
+ },
160
+ at: timestampFromDate(new Date("2026-08-07T09:05:00Z")),
161
+ }),
162
+ ];
163
+
164
+ // A 1×1 transparent PNG — enough for a real <img> load in the browser.
165
+ const ONE_PX_PNG =
166
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
167
+
168
+ const mediaClient = {
169
+ agentChannel: {
170
+ getMediaDownloadUrl: () => Promise.resolve({ url: ONE_PX_PNG }),
171
+ },
172
+ } as unknown as Stigmer;
173
+
128
174
  const listBaseProps = {
129
175
  isLoading: false,
130
176
  error: null,
@@ -207,6 +253,26 @@ describe("Conversation surfaces a11y", () => {
207
253
  await auditA11y(container, `conversation timeline · ${mode}`);
208
254
  });
209
255
 
256
+ it.each(COLOR_MODES)("timeline with inbound media: thumbnail and document chip (%s)", async (mode) => {
257
+ const container = renderAudited(
258
+ <StigmerContext.Provider value={mediaClient}>
259
+ <ConversationTimelineView
260
+ {...timelineBaseProps}
261
+ agentChannelId="ach_wa"
262
+ conversationKey="15550001111"
263
+ items={mediaTimelineItems}
264
+ hasOlder={false}
265
+ provider="whatsapp"
266
+ />
267
+ </StigmerContext.Provider>,
268
+ mode,
269
+ );
270
+ // Audit the LOADED thumbnail (the interactive state operators use),
271
+ // not just the pulse placeholder the first frame shows.
272
+ await waitFor(() => expect(container.querySelector("img")).toBeTruthy());
273
+ await auditA11y(container, `conversation timeline media · ${mode}`);
274
+ });
275
+
210
276
  it.each(COLOR_MODES)("composer with the closed-window advisory (%s)", async (mode) => {
211
277
  const container = renderAudited(
212
278
  <ConversationComposer
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { renderHook, waitFor } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+ import type { Stigmer } from "@stigmer/sdk";
5
+ import { StigmerContext } from "../../context";
6
+ import { useConversationMediaUrl } from "../useConversationMediaUrl";
7
+
8
+ function wrapperFor(stigmer: Stigmer) {
9
+ return ({ children }: { children: ReactNode }) => (
10
+ <StigmerContext.Provider value={stigmer}>{children}</StigmerContext.Provider>
11
+ );
12
+ }
13
+
14
+ function makeStigmer(getMediaDownloadUrl = vi.fn()) {
15
+ return { agentChannel: { getMediaDownloadUrl } } as unknown as Stigmer;
16
+ }
17
+
18
+ describe("useConversationMediaUrl", () => {
19
+ it("mints a fresh URL from the item's conversation address", async () => {
20
+ const fn = vi.fn().mockResolvedValue({ url: "https://fresh/1" });
21
+ const { result } = renderHook(
22
+ () => useConversationMediaUrl("ach-1", "15551234567", "wa:abc"),
23
+ { wrapper: wrapperFor(makeStigmer(fn)) },
24
+ );
25
+
26
+ await waitFor(() => expect(result.current.url).toBe("https://fresh/1"));
27
+ expect(fn).toHaveBeenCalledTimes(1);
28
+ const req = fn.mock.calls[0][0];
29
+ expect(req.agentChannelId).toBe("ach-1");
30
+ expect(req.conversationKey).toBe("15551234567");
31
+ expect(req.itemId).toBe("wa:abc");
32
+ });
33
+
34
+ it("stays idle when disabled", () => {
35
+ const fn = vi.fn();
36
+ const { result } = renderHook(
37
+ () => useConversationMediaUrl("ach-1", "15551234567", "wa:abc", { enabled: false }),
38
+ { wrapper: wrapperFor(makeStigmer(fn)) },
39
+ );
40
+ expect(result.current.url).toBeNull();
41
+ expect(fn).not.toHaveBeenCalled();
42
+ });
43
+
44
+ it("skips fetching while any address part is empty", () => {
45
+ const fn = vi.fn();
46
+ const { result } = renderHook(
47
+ () => useConversationMediaUrl("ach-1", "", "wa:abc"),
48
+ { wrapper: wrapperFor(makeStigmer(fn)) },
49
+ );
50
+ expect(result.current.url).toBeNull();
51
+ expect(fn).not.toHaveBeenCalled();
52
+ });
53
+
54
+ it("reports a failed mint as an error, never a broken URL", async () => {
55
+ const fn = vi
56
+ .fn()
57
+ .mockRejectedValue(new Error("no downloadable media at this timeline item"));
58
+ const { result } = renderHook(
59
+ () => useConversationMediaUrl("ach-1", "15551234567", "wa:abc"),
60
+ { wrapper: wrapperFor(makeStigmer(fn)) },
61
+ );
62
+
63
+ await waitFor(() => expect(result.current.error).not.toBeNull());
64
+ expect(result.current.url).toBeNull();
65
+ });
66
+ });
@@ -108,9 +108,16 @@ export function receiptOf(item: ConversationTimelineItem): ReceiptKind | null {
108
108
 
109
109
  /**
110
110
  * Placeholder copy for inbound items whose body the platform cannot
111
- * render (`text` empty, `provider_message_type` names the kind). Media
112
- * retrieval is a named non-goal of this surface the placeholder tells
113
- * staff something arrived and what shape it was.
111
+ * render (`text` empty, `provider_message_type` names the kind) — the
112
+ * placeholder tells staff something arrived and what shape it was.
113
+ *
114
+ * Since stigmer/stigmer#367 this is the FALLBACK arm of the media
115
+ * treatment, not the whole of it: items carrying ingested `media`
116
+ * render the real thing (thumbnail or document chip) when the timeline
117
+ * view holds the conversation address. The placeholder still covers
118
+ * media the platform declined to ingest (disallowed type, over the size
119
+ * cap — `media` unset by design) and renders without an address, where
120
+ * the bytes are unreachable.
114
121
  *
115
122
  * Returns `null` when the item needs no placeholder (it has text, or it
116
123
  * is not a provider-typed inbound item — e.g. an attention-clear event,
@@ -40,6 +40,12 @@ export type {
40
40
  UseConversationTimelineReturn,
41
41
  } from "./useConversationTimeline.js";
42
42
 
43
+ export { useConversationMediaUrl } from "./useConversationMediaUrl.js";
44
+ export type {
45
+ UseConversationMediaUrlOptions,
46
+ UseConversationMediaUrlReturn,
47
+ } from "./useConversationMediaUrl.js";
48
+
43
49
  export { useConversationParticipation } from "./useConversationParticipation.js";
44
50
  export type {
45
51
  ConversationCommand,