@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
|
@@ -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
|
+
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
TooltipTrigger,
|
|
18
18
|
} from "../internal/tooltip.js";
|
|
19
19
|
import { useAutoScroll } from "../internal/useAutoScroll.js";
|
|
20
|
+
import { ConversationMediaAttachment } from "./ConversationMediaAttachment.js";
|
|
20
21
|
import {
|
|
21
22
|
authorKindOf,
|
|
22
23
|
inboundPlaceholderOf,
|
|
@@ -30,6 +31,23 @@ import {
|
|
|
30
31
|
export interface ConversationTimelineViewProps {
|
|
31
32
|
/** Timeline items in chronological order (from `useConversationTimeline`). */
|
|
32
33
|
readonly items: readonly ConversationTimelineItem[];
|
|
34
|
+
/**
|
|
35
|
+
* AgentChannel the conversation belongs to. Together with
|
|
36
|
+
* `conversationKey`, this is the opt-in for media rendering: items
|
|
37
|
+
* carrying `media` render an inline thumbnail or document chip whose
|
|
38
|
+
* bytes are fetched via `getMediaDownloadUrl` — which is addressed by
|
|
39
|
+
* (channel, conversation, item), so the view cannot fetch media
|
|
40
|
+
* without the address.
|
|
41
|
+
*
|
|
42
|
+
* When either identity prop is absent, media items keep the typed
|
|
43
|
+
* placeholder ("Photo", "Document") — and, load-bearing for
|
|
44
|
+
* provider-less hosts (documentation fixtures, visual tests): nothing
|
|
45
|
+
* that needs a `StigmerProvider` mounts, so the view stays purely
|
|
46
|
+
* presentational exactly as before these props existed.
|
|
47
|
+
*/
|
|
48
|
+
readonly agentChannelId?: string;
|
|
49
|
+
/** Conversation key within the channel (see `agentChannelId`). */
|
|
50
|
+
readonly conversationKey?: string;
|
|
33
51
|
/** `true` only during the first load. */
|
|
34
52
|
readonly isLoading: boolean;
|
|
35
53
|
/** Error from the timeline read, or `null`. */
|
|
@@ -71,6 +89,8 @@ export interface ConversationTimelineViewProps {
|
|
|
71
89
|
*/
|
|
72
90
|
export function ConversationTimelineView({
|
|
73
91
|
items,
|
|
92
|
+
agentChannelId,
|
|
93
|
+
conversationKey,
|
|
74
94
|
isLoading,
|
|
75
95
|
error,
|
|
76
96
|
hasOlder,
|
|
@@ -146,7 +166,12 @@ export function ConversationTimelineView({
|
|
|
146
166
|
</div>
|
|
147
167
|
<ul className="space-y-1.5">
|
|
148
168
|
{day.items.map((item) => (
|
|
149
|
-
<TimelineItemRow
|
|
169
|
+
<TimelineItemRow
|
|
170
|
+
key={item.itemId}
|
|
171
|
+
item={item}
|
|
172
|
+
agentChannelId={agentChannelId}
|
|
173
|
+
conversationKey={conversationKey}
|
|
174
|
+
/>
|
|
150
175
|
))}
|
|
151
176
|
</ul>
|
|
152
177
|
</div>
|
|
@@ -170,8 +195,12 @@ const BUSINESS_CAPTION: Record<Exclude<ConversationAuthorKind, "customer">, stri
|
|
|
170
195
|
|
|
171
196
|
const TimelineItemRow = memo(function TimelineItemRow({
|
|
172
197
|
item,
|
|
198
|
+
agentChannelId,
|
|
199
|
+
conversationKey,
|
|
173
200
|
}: {
|
|
174
201
|
readonly item: ConversationTimelineItem;
|
|
202
|
+
readonly agentChannelId?: string;
|
|
203
|
+
readonly conversationKey?: string;
|
|
175
204
|
}) {
|
|
176
205
|
if (isInternalItem(item)) {
|
|
177
206
|
return <InternalEventRow item={item} />;
|
|
@@ -179,7 +208,17 @@ const TimelineItemRow = memo(function TimelineItemRow({
|
|
|
179
208
|
|
|
180
209
|
const author = authorKindOf(item);
|
|
181
210
|
const isCustomer = author === "customer";
|
|
182
|
-
|
|
211
|
+
|
|
212
|
+
// Ingested media renders as the real thing — an inline thumbnail or
|
|
213
|
+
// document chip — ONLY when the view holds the conversation address
|
|
214
|
+
// (getMediaDownloadUrl's addressing; see the identity props' contract).
|
|
215
|
+
// Without the address, and on media the platform declined to ingest,
|
|
216
|
+
// the typed placeholder keeps telling staff what arrived.
|
|
217
|
+
const mediaAddress =
|
|
218
|
+
item.media !== undefined && agentChannelId && conversationKey
|
|
219
|
+
? { agentChannelId, conversationKey, itemId: item.itemId }
|
|
220
|
+
: null;
|
|
221
|
+
const body = item.text || (mediaAddress !== null ? "" : inboundPlaceholderOf(item));
|
|
183
222
|
|
|
184
223
|
// DD-014 D-c (amended at the Sitting 2 gate, R-1): the provider's
|
|
185
224
|
// failure explanation is decision-bearing — window closed vs bad
|
|
@@ -211,14 +250,25 @@ const TimelineItemRow = memo(function TimelineItemRow({
|
|
|
211
250
|
{BUSINESS_CAPTION[author]}
|
|
212
251
|
</p>
|
|
213
252
|
)}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
253
|
+
{mediaAddress !== null && item.media !== undefined && (
|
|
254
|
+
<ConversationMediaAttachment
|
|
255
|
+
media={item.media}
|
|
256
|
+
address={mediaAddress}
|
|
257
|
+
className={item.text ? "mb-1.5" : undefined}
|
|
258
|
+
/>
|
|
259
|
+
)}
|
|
260
|
+
{/* A media item without a caption is complete as its media —
|
|
261
|
+
no body, and no "unavailable" apology under the thumbnail. */}
|
|
262
|
+
{(body || mediaAddress === null) && (
|
|
263
|
+
<p
|
|
264
|
+
className={cn(
|
|
265
|
+
"whitespace-pre-wrap break-words text-sm text-foreground",
|
|
266
|
+
!item.text && "italic text-muted-foreground",
|
|
267
|
+
)}
|
|
268
|
+
>
|
|
269
|
+
{body || "Message content unavailable"}
|
|
270
|
+
</p>
|
|
271
|
+
)}
|
|
222
272
|
{receiptExplanation !== null && (
|
|
223
273
|
<p className="mt-1 break-words text-xs text-destructive">
|
|
224
274
|
{receiptExplanation}
|
|
@@ -354,6 +354,8 @@ export function ConversationsWorkbench({
|
|
|
354
354
|
|
|
355
355
|
<ConversationTimelineView
|
|
356
356
|
items={timeline.items}
|
|
357
|
+
agentChannelId={selected.agentChannelId}
|
|
358
|
+
conversationKey={selected.conversationKey}
|
|
357
359
|
isLoading={timeline.isLoading}
|
|
358
360
|
error={timeline.error}
|
|
359
361
|
hasOlder={timeline.hasOlder}
|