@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.
- package/attachment/AttachmentChipList.d.ts +16 -4
- package/attachment/AttachmentChipList.d.ts.map +1 -1
- package/attachment/AttachmentChipList.js +106 -11
- 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/attachment-utils.d.ts +11 -0
- package/attachment/attachment-utils.d.ts.map +1 -1
- package/attachment/attachment-utils.js +23 -0
- package/attachment/attachment-utils.js.map +1 -1
- package/attachment/clipboard.d.ts +51 -0
- package/attachment/clipboard.d.ts.map +1 -0
- package/attachment/clipboard.js +89 -0
- package/attachment/clipboard.js.map +1 -0
- package/attachment/index.d.ts +6 -1
- package/attachment/index.d.ts.map +1 -1
- package/attachment/index.js +4 -1
- package/attachment/index.js.map +1 -1
- package/attachment/prepare-image.d.ts +43 -0
- package/attachment/prepare-image.d.ts.map +1 -0
- package/attachment/prepare-image.js +162 -0
- package/attachment/prepare-image.js.map +1 -0
- package/attachment/useAttachments.d.ts.map +1 -1
- package/attachment/useAttachments.js +23 -3
- package/attachment/useAttachments.js.map +1 -1
- 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/attachment/vision-fit.d.ts +50 -0
- package/attachment/vision-fit.d.ts.map +1 -0
- package/attachment/vision-fit.js +76 -0
- package/attachment/vision-fit.js.map +1 -0
- package/composer/SessionComposer.d.ts +3 -1
- package/composer/SessionComposer.d.ts.map +1 -1
- package/composer/SessionComposer.js +50 -4
- package/composer/SessionComposer.js.map +1 -1
- 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 +5 -5
- package/index.d.ts.map +1 -1
- package/index.js +5 -4
- 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 +330 -40
- package/src/attachment/AttachmentImageLightbox.tsx +156 -0
- package/src/attachment/__tests__/AttachmentChipList.test.tsx +326 -0
- package/src/attachment/__tests__/attachment-utils.test.ts +54 -0
- package/src/attachment/__tests__/clipboard.test.ts +110 -0
- package/src/attachment/__tests__/prepare-image.browser.test.ts +166 -0
- package/src/attachment/__tests__/prepare-image.test.ts +32 -0
- package/src/attachment/__tests__/vision-fit.test.ts +93 -0
- package/src/attachment/attachment-utils.ts +27 -0
- package/src/attachment/clipboard.ts +102 -0
- package/src/attachment/index.ts +13 -0
- package/src/attachment/prepare-image.ts +179 -0
- package/src/attachment/useAttachments.ts +26 -2
- package/src/attachment/useObjectUrl.ts +28 -0
- package/src/attachment/vision-fit.ts +90 -0
- package/src/composer/SessionComposer.tsx +73 -4
- package/src/composer/__tests__/SessionComposer-paste.test.tsx +263 -0
- package/src/composer/__tests__/SessionComposer-uploadGate.test.tsx +239 -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 +16 -1
- 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,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.
|
|
312
|
-
// inside a message row may be focusable — the
|
|
313
|
-
// affordance (which manages its own tabIndex) lives
|
|
314
|
-
//
|
|
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)
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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
|
+
}
|