@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
@@ -1,6 +1,13 @@
1
- import { describe, it, expect, vi, afterEach } from "vitest";
2
- import { render, cleanup, waitFor } from "@testing-library/react";
1
+ import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
2
+ import {
3
+ render,
4
+ cleanup,
5
+ waitFor,
6
+ fireEvent,
7
+ screen,
8
+ } from "@testing-library/react";
3
9
  import { AttachmentChipList } from "../AttachmentChipList.js";
10
+ import type { AttachmentChipListProps } from "../AttachmentChipList.js";
4
11
  import type { AttachmentEntry } from "../useAttachments.js";
5
12
 
6
13
  // ---------------------------------------------------------------------------
@@ -22,30 +29,52 @@ function entry(overrides: Partial<AttachmentEntry> = {}): AttachmentEntry {
22
29
  };
23
30
  }
24
31
 
25
- function renderChips(entries: AttachmentEntry[]) {
32
+ function renderChips(
33
+ entries: AttachmentEntry[],
34
+ props: Partial<AttachmentChipListProps> = {},
35
+ ) {
26
36
  return render(
27
- <AttachmentChipList entries={entries} onRemove={vi.fn()} onRetry={vi.fn()} />,
37
+ <AttachmentChipList
38
+ entries={entries}
39
+ onRemove={vi.fn()}
40
+ onRetry={vi.fn()}
41
+ {...props}
42
+ />,
28
43
  );
29
44
  }
30
45
 
46
+ /** The chip's miniature `<img>` (decorative, `aria-hidden`) — never the lightbox image. */
47
+ function chipImage(container: HTMLElement): HTMLImageElement | null {
48
+ return container.querySelector('img[aria-hidden="true"]');
49
+ }
50
+
51
+ // happy-dom does not implement the native dialog show/close methods.
52
+ beforeAll(() => {
53
+ HTMLDialogElement.prototype.showModal = function showModal() {
54
+ this.open = true;
55
+ };
56
+ HTMLDialogElement.prototype.close = function close() {
57
+ this.open = false;
58
+ };
59
+ });
60
+
31
61
  afterEach(() => {
32
62
  cleanup();
33
63
  vi.restoreAllMocks();
34
64
  });
35
65
 
36
66
  // ---------------------------------------------------------------------------
37
- // Tests
67
+ // Image miniature — visible in every phase (#371 gap 2)
38
68
  // ---------------------------------------------------------------------------
39
69
 
40
- describe("AttachmentChipList — image thumbnails", () => {
41
- it("renders an object-URL thumbnail for a ready image entry", async () => {
70
+ describe("AttachmentChipList — image miniature", () => {
71
+ it("renders an object-URL miniature for a ready image entry", async () => {
42
72
  const { container } = renderChips([entry()]);
43
73
 
44
74
  await waitFor(() => {
45
- const img = container.querySelector("img");
75
+ const img = chipImage(container);
46
76
  expect(img).toBeTruthy();
47
77
  expect(img!.getAttribute("src")).toContain("blob:");
48
- expect(img!.getAttribute("aria-hidden")).toBe("true");
49
78
  expect(img!.getAttribute("alt")).toBe("");
50
79
  });
51
80
  });
@@ -62,21 +91,63 @@ describe("AttachmentChipList — image thumbnails", () => {
62
91
  expect(container.querySelector("svg")).toBeTruthy();
63
92
  });
64
93
 
65
- it("shows no thumbnail while uploading or errored (spinner / error dot own the slot)", () => {
94
+ it("keeps the miniature visible while uploading the spinner overlays it, never replaces it", async () => {
66
95
  const uploading = entry({ id: "up", phase: "uploading", storageKey: null });
67
- const errored = entry({ id: "err", phase: "error", error: "boom", storageKey: null });
68
96
 
69
- const { container } = renderChips([uploading, errored]);
97
+ const { container } = renderChips([uploading]);
70
98
 
71
- expect(container.querySelector("img")).toBeNull();
99
+ await waitFor(() => expect(chipImage(container)).toBeTruthy());
100
+ // Spinner present alongside the image, not instead of it.
101
+ expect(container.querySelector("svg.animate-spin")).toBeTruthy();
102
+ });
103
+
104
+ it("renders image chips preview-only: no visible filename or size text", async () => {
105
+ const { container } = renderChips([entry()]);
106
+
107
+ await waitFor(() => expect(chipImage(container)).toBeTruthy());
108
+
109
+ // The tile is the chip — the name lives in the tooltip and accessible
110
+ // label, never as a text node (file chips, by contrast, keep theirs).
111
+ expect(screen.queryByText("shot.png")).toBeNull();
112
+ const tile = screen.getByRole("listitem", { name: /^shot\.png/ });
113
+ expect(tile.getAttribute("title")).toBe("shot.png");
114
+ // Remove is an always-visible corner badge, not hover-revealed.
115
+ expect(screen.getByRole("button", { name: "Remove shot.png" })).toBeTruthy();
116
+ });
117
+
118
+ it("keeps visible filename and size text on non-image chips", () => {
119
+ const md = entry({
120
+ file: new File(["# notes"], "notes.md", { type: "text/markdown" }),
121
+ contentType: "text/markdown",
122
+ });
123
+
124
+ renderChips([md]);
125
+
126
+ expect(screen.getByText("notes.md")).toBeTruthy();
127
+ });
128
+
129
+ it("keeps the miniature visible on upload error, alongside the retry action", async () => {
130
+ const errored = entry({
131
+ id: "err",
132
+ phase: "error",
133
+ error: "boom",
134
+ storageKey: null,
135
+ });
136
+
137
+ const { container } = renderChips([errored]);
138
+
139
+ await waitFor(() => expect(chipImage(container)).toBeTruthy());
140
+ expect(
141
+ screen.getByRole("button", { name: "Retry uploading shot.png" }),
142
+ ).toBeTruthy();
72
143
  });
73
144
 
74
145
  it("revokes the object URL on unmount", async () => {
75
146
  const revokeSpy = vi.spyOn(URL, "revokeObjectURL");
76
147
  const { container, unmount } = renderChips([entry()]);
77
148
 
78
- await waitFor(() => expect(container.querySelector("img")).toBeTruthy());
79
- const src = container.querySelector("img")!.getAttribute("src")!;
149
+ await waitFor(() => expect(chipImage(container)).toBeTruthy());
150
+ const src = chipImage(container)!.getAttribute("src")!;
80
151
 
81
152
  unmount();
82
153
 
@@ -90,8 +161,8 @@ describe("AttachmentChipList — image thumbnails", () => {
90
161
  <AttachmentChipList entries={[image]} onRemove={vi.fn()} onRetry={vi.fn()} />,
91
162
  );
92
163
 
93
- await waitFor(() => expect(container.querySelector("img")).toBeTruthy());
94
- const src = container.querySelector("img")!.getAttribute("src")!;
164
+ await waitFor(() => expect(chipImage(container)).toBeTruthy());
165
+ const src = chipImage(container)!.getAttribute("src")!;
95
166
 
96
167
  rerender(
97
168
  <AttachmentChipList entries={[]} onRemove={vi.fn()} onRetry={vi.fn()} />,
@@ -100,4 +171,156 @@ describe("AttachmentChipList — image thumbnails", () => {
100
171
  expect(container.querySelector("img")).toBeNull();
101
172
  expect(revokeSpy).toHaveBeenCalledWith(src);
102
173
  });
174
+
175
+ it("degrades to the compact file chip when the image fails to decode", async () => {
176
+ const { container } = renderChips([entry()]);
177
+
178
+ await waitFor(() => expect(chipImage(container)).toBeTruthy());
179
+ fireEvent.error(chipImage(container)!);
180
+
181
+ // No broken-image glyph, no preview affordance — just the file treatment.
182
+ expect(container.querySelector("img")).toBeNull();
183
+ expect(screen.queryByRole("button", { name: "Preview shot.png" })).toBeNull();
184
+ expect(container.querySelector("svg")).toBeTruthy();
185
+ expect(
186
+ screen.getByRole("button", { name: "Remove shot.png" }),
187
+ ).toBeTruthy();
188
+ });
189
+ });
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Click-to-open — built-in lightbox and the onPreview seam (#371 gap 3)
193
+ // ---------------------------------------------------------------------------
194
+
195
+ describe("AttachmentChipList — preview", () => {
196
+ it("opens the built-in lightbox with the full image when the chip body is clicked", async () => {
197
+ renderChips([entry()]);
198
+
199
+ expect(document.querySelector("dialog")).toBeNull();
200
+
201
+ fireEvent.click(screen.getByRole("button", { name: "Preview shot.png" }));
202
+
203
+ const dialog = document.querySelector("dialog");
204
+ expect(dialog).toBeTruthy();
205
+ expect(dialog!.getAttribute("aria-label")).toBe("Preview shot.png");
206
+ // The lightbox derives its own object URL; the full image carries the
207
+ // filename as meaningful alt text (unlike the decorative miniature).
208
+ await waitFor(() => {
209
+ const full = dialog!.querySelector("img");
210
+ expect(full).toBeTruthy();
211
+ expect(full!.getAttribute("src")).toContain("blob:");
212
+ expect(full!.getAttribute("alt")).toBe("shot.png");
213
+ });
214
+ });
215
+
216
+ it("closes the lightbox on Escape (the dialog's cancel event)", () => {
217
+ renderChips([entry()]);
218
+
219
+ fireEvent.click(screen.getByRole("button", { name: "Preview shot.png" }));
220
+ const dialog = document.querySelector("dialog")!;
221
+
222
+ fireEvent(dialog, new Event("cancel"));
223
+
224
+ expect(document.querySelector("dialog")).toBeNull();
225
+ });
226
+
227
+ it("closes the lightbox on backdrop click, but not on content clicks", async () => {
228
+ renderChips([entry()]);
229
+
230
+ fireEvent.click(screen.getByRole("button", { name: "Preview shot.png" }));
231
+ const dialog = document.querySelector("dialog")!;
232
+ await waitFor(() => expect(dialog.querySelector("img")).toBeTruthy());
233
+
234
+ // A click landing on content (the image) must not close.
235
+ fireEvent.click(dialog.querySelector("img")!);
236
+ expect(document.querySelector("dialog")).toBeTruthy();
237
+
238
+ // A click landing on the dialog element itself is the backdrop.
239
+ fireEvent.click(dialog);
240
+ expect(document.querySelector("dialog")).toBeNull();
241
+ });
242
+
243
+ it("closes the lightbox via its close button", () => {
244
+ renderChips([entry()]);
245
+
246
+ fireEvent.click(screen.getByRole("button", { name: "Preview shot.png" }));
247
+ fireEvent.click(
248
+ screen.getByRole("button", { name: "Close image preview" }),
249
+ );
250
+
251
+ expect(document.querySelector("dialog")).toBeNull();
252
+ });
253
+
254
+ it("closes the lightbox when the previewed entry is removed from the list", () => {
255
+ const image = entry();
256
+ const { rerender } = renderChips([image]);
257
+
258
+ fireEvent.click(screen.getByRole("button", { name: "Preview shot.png" }));
259
+ expect(document.querySelector("dialog")).toBeTruthy();
260
+
261
+ rerender(
262
+ <AttachmentChipList entries={[]} onRemove={vi.fn()} onRetry={vi.fn()} />,
263
+ );
264
+
265
+ expect(document.querySelector("dialog")).toBeNull();
266
+ });
267
+
268
+ it("routes the click to onPreview instead of opening the built-in lightbox", () => {
269
+ const onPreview = vi.fn();
270
+ const image = entry();
271
+ renderChips([image], { onPreview });
272
+
273
+ fireEvent.click(screen.getByRole("button", { name: "Preview shot.png" }));
274
+
275
+ expect(onPreview).toHaveBeenCalledTimes(1);
276
+ expect(onPreview).toHaveBeenCalledWith(image);
277
+ expect(document.querySelector("dialog")).toBeNull();
278
+ });
279
+
280
+ it("offers no preview affordance on non-image entries", () => {
281
+ const md = entry({
282
+ file: new File(["# notes"], "notes.md", { type: "text/markdown" }),
283
+ contentType: "text/markdown",
284
+ });
285
+
286
+ renderChips([md]);
287
+
288
+ expect(screen.queryByRole("button", { name: /^Preview/ })).toBeNull();
289
+ });
290
+
291
+ it("keeps preview clickable while remove/retry are disabled (preview is read-only)", async () => {
292
+ const { container } = renderChips([entry()], { disabled: true });
293
+
294
+ await waitFor(() => expect(chipImage(container)).toBeTruthy());
295
+
296
+ const preview = screen.getByRole("button", {
297
+ name: "Preview shot.png",
298
+ }) as HTMLButtonElement;
299
+ const remove = screen.getByRole("button", {
300
+ name: "Remove shot.png",
301
+ }) as HTMLButtonElement;
302
+
303
+ expect(preview.disabled).toBe(false);
304
+ expect(remove.disabled).toBe(true);
305
+
306
+ fireEvent.click(preview);
307
+ expect(document.querySelector("dialog")).toBeTruthy();
308
+ });
309
+
310
+ it("handles unicode filenames across the chip, preview target, and lightbox", () => {
311
+ const name = "スクリーンショット 2026-08-10.png";
312
+ const unicode = entry({
313
+ file: new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], name, {
314
+ type: "image/png",
315
+ }),
316
+ });
317
+
318
+ renderChips([unicode]);
319
+
320
+ fireEvent.click(screen.getByRole("button", { name: `Preview ${name}` }));
321
+
322
+ const dialog = document.querySelector("dialog");
323
+ expect(dialog).toBeTruthy();
324
+ expect(dialog!.getAttribute("aria-label")).toBe(`Preview ${name}`);
325
+ });
103
326
  });
@@ -0,0 +1,28 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ /**
6
+ * Object URL over a locally-held `Blob` (or `File`), alive for the lifetime
7
+ * of the mounted component (no fetch — the bytes are already in memory).
8
+ *
9
+ * The URL is created in an effect, not a memo: StrictMode double-invokes
10
+ * memos and would leak the first URL without a revoke. The effect cleanup
11
+ * revokes on unmount and on blob change. Returns `null` until the effect
12
+ * runs (first paint) — callers render a fallback for that frame.
13
+ *
14
+ * Deliberately display-oriented: the SDK's other `createObjectURL` calls
15
+ * (`useExportResource`, `useExportCSV`) are fire-and-forget download
16
+ * helpers with an immediate revoke, a different lifecycle from this one.
17
+ */
18
+ export function useObjectUrl(blob: Blob): string | null {
19
+ const [url, setUrl] = useState<string | null>(null);
20
+
21
+ useEffect(() => {
22
+ const objectUrl = URL.createObjectURL(blob);
23
+ setUrl(objectUrl);
24
+ return () => URL.revokeObjectURL(objectUrl);
25
+ }, [blob]);
26
+
27
+ return url;
28
+ }
@@ -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
+ }