@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.
- package/attachment/AttachmentChipList.d.ts +16 -4
- package/attachment/AttachmentChipList.d.ts.map +1 -1
- package/attachment/AttachmentChipList.js +101 -35
- package/attachment/AttachmentChipList.js.map +1 -1
- package/attachment/AttachmentImageLightbox.d.ts +39 -0
- package/attachment/AttachmentImageLightbox.d.ts.map +1 -0
- package/attachment/AttachmentImageLightbox.js +57 -0
- package/attachment/AttachmentImageLightbox.js.map +1 -0
- package/attachment/useObjectUrl.d.ts +15 -0
- package/attachment/useObjectUrl.d.ts.map +1 -0
- package/attachment/useObjectUrl.js +25 -0
- package/attachment/useObjectUrl.js.map +1 -0
- package/conversation/ConversationMediaAttachment.d.ts +45 -0
- package/conversation/ConversationMediaAttachment.d.ts.map +1 -0
- package/conversation/ConversationMediaAttachment.js +153 -0
- package/conversation/ConversationMediaAttachment.js.map +1 -0
- package/conversation/ConversationTimelineView.d.ts +18 -1
- package/conversation/ConversationTimelineView.d.ts.map +1 -1
- package/conversation/ConversationTimelineView.js +14 -5
- package/conversation/ConversationTimelineView.js.map +1 -1
- package/conversation/ConversationsWorkbench.d.ts.map +1 -1
- package/conversation/ConversationsWorkbench.js +1 -1
- package/conversation/ConversationsWorkbench.js.map +1 -1
- package/conversation/conversationPresentation.d.ts +10 -3
- package/conversation/conversationPresentation.d.ts.map +1 -1
- package/conversation/conversationPresentation.js +10 -3
- package/conversation/conversationPresentation.js.map +1 -1
- package/conversation/index.d.ts +2 -0
- package/conversation/index.d.ts.map +1 -1
- package/conversation/index.js +1 -0
- package/conversation/index.js.map +1 -1
- package/conversation/useConversationMediaUrl.d.ts +65 -0
- package/conversation/useConversationMediaUrl.d.ts.map +1 -0
- package/conversation/useConversationMediaUrl.js +61 -0
- package/conversation/useConversationMediaUrl.js.map +1 -0
- package/execution/MessageAttachments.d.ts +52 -0
- package/execution/MessageAttachments.d.ts.map +1 -0
- package/execution/MessageAttachments.js +142 -0
- package/execution/MessageAttachments.js.map +1 -0
- package/execution/MessageEntry.d.ts +18 -0
- package/execution/MessageEntry.d.ts.map +1 -1
- package/execution/MessageEntry.js +5 -4
- package/execution/MessageEntry.js.map +1 -1
- package/execution/MessageThread.d.ts +15 -2
- package/execution/MessageThread.d.ts.map +1 -1
- package/execution/MessageThread.js +24 -7
- package/execution/MessageThread.js.map +1 -1
- package/execution/index.d.ts +2 -0
- package/execution/index.d.ts.map +1 -1
- package/execution/index.js +1 -0
- package/execution/index.js.map +1 -1
- package/github/decodeGitHubContent.d.ts +10 -5
- package/github/decodeGitHubContent.d.ts.map +1 -1
- package/github/decodeGitHubContent.js +21 -7
- package/github/decodeGitHubContent.js.map +1 -1
- package/github/useGitHubFileReader.d.ts.map +1 -1
- package/github/useGitHubFileReader.js +5 -5
- package/github/useGitHubFileReader.js.map +1 -1
- package/index.d.ts +3 -3
- package/index.d.ts.map +1 -1
- package/index.js +2 -2
- package/index.js.map +1 -1
- package/internal/form-primitives.d.ts +13 -0
- package/internal/form-primitives.d.ts.map +1 -1
- package/internal/form-primitives.js +13 -0
- package/internal/form-primitives.js.map +1 -1
- package/package.json +4 -4
- package/session/SessionViewer.js +1 -1
- package/session/SessionViewer.js.map +1 -1
- package/session/useSessionConversation.d.ts +8 -0
- package/session/useSessionConversation.d.ts.map +1 -1
- package/session/useSessionConversation.js +12 -2
- package/session/useSessionConversation.js.map +1 -1
- package/src/attachment/AttachmentChipList.tsx +318 -72
- package/src/attachment/AttachmentImageLightbox.tsx +156 -0
- package/src/attachment/__tests__/AttachmentChipList.test.tsx +240 -17
- package/src/attachment/useObjectUrl.ts +28 -0
- package/src/conversation/ConversationMediaAttachment.tsx +308 -0
- package/src/conversation/ConversationTimelineView.tsx +60 -10
- package/src/conversation/ConversationsWorkbench.tsx +2 -0
- package/src/conversation/__tests__/ConversationTimelineView.test.tsx +233 -6
- package/src/conversation/__tests__/a11y/conversation-surfaces.a11y.test.tsx +67 -1
- package/src/conversation/__tests__/useConversationMediaUrl.test.tsx +66 -0
- package/src/conversation/conversationPresentation.ts +10 -3
- package/src/conversation/index.ts +6 -0
- package/src/conversation/useConversationMediaUrl.ts +121 -0
- package/src/execution/MessageAttachments.tsx +364 -0
- package/src/execution/MessageEntry.tsx +38 -0
- package/src/execution/MessageThread.tsx +44 -4
- package/src/execution/__tests__/MessageEntry-attachments.test.tsx +228 -0
- package/src/execution/__tests__/buildThreadItems-attachments.test.ts +174 -0
- package/src/execution/index.ts +6 -0
- package/src/github/__tests__/decodeGitHubContent.test.ts +42 -1
- package/src/github/decodeGitHubContent.ts +22 -5
- package/src/github/useGitHubFileReader.ts +13 -3
- package/src/index.ts +5 -0
- package/src/internal/form-primitives.tsx +14 -0
- package/src/session/SessionViewer.tsx +1 -0
- package/src/session/__tests__/useSessionConversation.test.tsx +62 -0
- package/src/session/useSessionConversation.ts +21 -2
- package/src/workspace/FileViewer.tsx +109 -0
- package/src/workspace/WorkspaceFileReader.ts +55 -1
- package/src/workspace/__tests__/FileViewer.test.tsx +98 -0
- package/src/workspace/index.ts +2 -0
- package/styles.css +1 -1
- package/workspace/FileViewer.d.ts.map +1 -1
- package/workspace/FileViewer.js +40 -2
- package/workspace/FileViewer.js.map +1 -1
- package/workspace/WorkspaceFileReader.d.ts +29 -1
- package/workspace/WorkspaceFileReader.d.ts.map +1 -1
- package/workspace/WorkspaceFileReader.js +42 -0
- package/workspace/WorkspaceFileReader.js.map +1 -1
- package/workspace/index.d.ts +1 -1
- package/workspace/index.d.ts.map +1 -1
- package/workspace/index.js +1 -1
- package/workspace/index.js.map +1 -1
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
-
import {
|
|
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(
|
|
32
|
+
function renderChips(
|
|
33
|
+
entries: AttachmentEntry[],
|
|
34
|
+
props: Partial<AttachmentChipListProps> = {},
|
|
35
|
+
) {
|
|
26
36
|
return render(
|
|
27
|
-
<AttachmentChipList
|
|
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
|
-
//
|
|
67
|
+
// Image miniature — visible in every phase (#371 gap 2)
|
|
38
68
|
// ---------------------------------------------------------------------------
|
|
39
69
|
|
|
40
|
-
describe("AttachmentChipList — image
|
|
41
|
-
it("renders an object-URL
|
|
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
|
|
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("
|
|
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
|
|
97
|
+
const { container } = renderChips([uploading]);
|
|
70
98
|
|
|
71
|
-
expect(container
|
|
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
|
|
79
|
-
const src = container
|
|
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
|
|
94
|
-
const src = container
|
|
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
|
+
}
|