@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
@@ -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,
@@ -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
+ }