@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { cn } from "@stigmer/theme";
|
|
5
|
+
import { AttachmentImageLightbox } from "../attachment/AttachmentImageLightbox.js";
|
|
6
|
+
import { UNSTYLED_BUTTON } from "../internal/form-primitives.js";
|
|
7
|
+
import { useArtifactDownload } from "./useArtifactDownload.js";
|
|
8
|
+
import { useArtifactDownloadUrl } from "./useArtifactDownloadUrl.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The minimal display shape of a submitted attachment.
|
|
12
|
+
*
|
|
13
|
+
* Structurally satisfied by BOTH sources a human turn can carry — the
|
|
14
|
+
* `Attachment` proto on a persisted execution's `spec.attachments` and the
|
|
15
|
+
* `AttachmentInput` a just-submitted (pending) turn holds — so the thread
|
|
16
|
+
* renders either without conversion code or a duplicated type (DD-007).
|
|
17
|
+
*/
|
|
18
|
+
export interface MessageAttachmentView {
|
|
19
|
+
/** Original filename, e.g. `"screenshot.png"`. */
|
|
20
|
+
readonly filename?: string;
|
|
21
|
+
/** MIME type, e.g. `"image/png"`. Drives the image-vs-document treatment. */
|
|
22
|
+
readonly contentType?: string;
|
|
23
|
+
/** Storage key (`attachments/{ulid}/{filename}`) minted at upload time. */
|
|
24
|
+
readonly storageKey?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Props for {@link MessageAttachments}. */
|
|
28
|
+
export interface MessageAttachmentsProps {
|
|
29
|
+
/** The turn's submitted attachments, in submission order. */
|
|
30
|
+
readonly attachments: readonly MessageAttachmentView[];
|
|
31
|
+
/**
|
|
32
|
+
* The execution the attachments were submitted to. Enables the byte-backed
|
|
33
|
+
* affordances — image previews (presigned URL) and document downloads.
|
|
34
|
+
* Omit for the optimistic pending bubble (no execution record yet) and the
|
|
35
|
+
* failed-send bubble (no record ever): images render as inert glyph tiles
|
|
36
|
+
* and documents as inert chips until a real turn brings the presign seam.
|
|
37
|
+
*/
|
|
38
|
+
readonly executionId?: string;
|
|
39
|
+
/** Additional CSS class names for the row container. */
|
|
40
|
+
readonly className?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The attachment row on a human turn: the durable evidence of what files were
|
|
45
|
+
* sent with the message (stigmer/stigmer#372).
|
|
46
|
+
*
|
|
47
|
+
* Image attachments render as preview-only thumbnail tiles — the image IS the
|
|
48
|
+
* chip, no filename text (the name stays in the tooltip, the accessible
|
|
49
|
+
* label, and the lightbox header), click-to-open in the shared
|
|
50
|
+
* {@link AttachmentImageLightbox} — and documents as compact click-to-download
|
|
51
|
+
* filename chips. Both follow the composer chip grammar
|
|
52
|
+
* ({@link AttachmentChipList}) so a file looks the same before and after
|
|
53
|
+
* send, but this is deliberately a separate component: the composer chip is
|
|
54
|
+
* interactive and upload-phase-bound over a local `File`, while this row is
|
|
55
|
+
* read-only over the execution record, with bytes resolved on demand from
|
|
56
|
+
* the stable storage key via presigned URLs (the `OutputRefImage` pattern —
|
|
57
|
+
* the local `File` is gone after submit).
|
|
58
|
+
*
|
|
59
|
+
* Purely presentational plus on-demand URL minting — no required wiring.
|
|
60
|
+
* All visual properties flow through `--stgm-*` tokens.
|
|
61
|
+
*/
|
|
62
|
+
export function MessageAttachments({
|
|
63
|
+
attachments,
|
|
64
|
+
executionId,
|
|
65
|
+
className,
|
|
66
|
+
}: MessageAttachmentsProps) {
|
|
67
|
+
// One lightbox instance serves the whole row, tracked by storage key
|
|
68
|
+
// (direct identity). It mounts OUTSIDE the role="list" container — a
|
|
69
|
+
// <dialog> is not valid inside the chips' <span> elements, and a
|
|
70
|
+
// non-listitem child inside the list would break its a11y ownership
|
|
71
|
+
// (same reasoning as the composer's chip list).
|
|
72
|
+
const [previewKey, setPreviewKey] = useState<string | null>(null);
|
|
73
|
+
const previewed =
|
|
74
|
+
previewKey !== null
|
|
75
|
+
? (attachments.find((a) => a.storageKey === previewKey) ?? null)
|
|
76
|
+
: null;
|
|
77
|
+
|
|
78
|
+
if (attachments.length === 0) return null;
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<>
|
|
82
|
+
{/* items-center is load-bearing: image tiles (h-14) and document chips
|
|
83
|
+
(~h-6) share this row, and flexbox's default stretch alignment
|
|
84
|
+
would balloon the document chips to tile height. */}
|
|
85
|
+
<div
|
|
86
|
+
className={cn("flex flex-wrap items-center gap-1.5", className)}
|
|
87
|
+
role="list"
|
|
88
|
+
aria-label="Submitted attachments"
|
|
89
|
+
>
|
|
90
|
+
{attachments.map((attachment, i) => {
|
|
91
|
+
const isImage = attachment.contentType?.startsWith("image/") ?? false;
|
|
92
|
+
const key = attachment.storageKey ?? `${displayName(attachment)}-${i}`;
|
|
93
|
+
if (isImage && executionId && attachment.storageKey) {
|
|
94
|
+
return (
|
|
95
|
+
<ImagePreviewChip
|
|
96
|
+
key={key}
|
|
97
|
+
attachment={attachment}
|
|
98
|
+
executionId={executionId}
|
|
99
|
+
onPreview={() => setPreviewKey(attachment.storageKey!)}
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (isImage && !executionId) {
|
|
104
|
+
// No execution record — the optimistic pending bubble or a
|
|
105
|
+
// failed send. The tile keeps the image grammar but stays
|
|
106
|
+
// STATIC (a glyph, not a pulse): a failed send never gets an
|
|
107
|
+
// executionId, so a pulse here would be a permanent false
|
|
108
|
+
// "loading" signal. On the pending bubble the real turn lands
|
|
109
|
+
// under the same bridge key and brings the presign seam.
|
|
110
|
+
return <ImageGlyphTile key={key} name={displayName(attachment)} />;
|
|
111
|
+
}
|
|
112
|
+
return (
|
|
113
|
+
<DocumentChip
|
|
114
|
+
key={key}
|
|
115
|
+
attachment={attachment}
|
|
116
|
+
executionId={executionId}
|
|
117
|
+
/>
|
|
118
|
+
);
|
|
119
|
+
})}
|
|
120
|
+
</div>
|
|
121
|
+
{previewed && executionId && previewed.storageKey && (
|
|
122
|
+
<AttachmentPreviewLightbox
|
|
123
|
+
attachment={previewed}
|
|
124
|
+
executionId={executionId}
|
|
125
|
+
onClose={() => setPreviewKey(null)}
|
|
126
|
+
/>
|
|
127
|
+
)}
|
|
128
|
+
</>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Display name for an attachment: the filename, falling back to the storage
|
|
134
|
+
* key's basename (`attachments/{ulid}/{filename}` always ends in it).
|
|
135
|
+
*/
|
|
136
|
+
function displayName(attachment: MessageAttachmentView): string {
|
|
137
|
+
if (attachment.filename) return attachment.filename;
|
|
138
|
+
const key = attachment.storageKey ?? "";
|
|
139
|
+
const base = key.slice(key.lastIndexOf("/") + 1);
|
|
140
|
+
return base || "attachment";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Image attachment — a preview-only tile mirroring the composer's grammar
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The preview-only image tile: the thumbnail IS the chip, minted from the
|
|
149
|
+
* stable storage key at view time — no filename text (the name stays in the
|
|
150
|
+
* tooltip, the accessible label, and the lightbox header). If the URL can't
|
|
151
|
+
* be resolved (revoked storage, transient API failure), the chip degrades to
|
|
152
|
+
* the document treatment — a generic icon with the download affordance —
|
|
153
|
+
* never a broken-image glyph.
|
|
154
|
+
*/
|
|
155
|
+
function ImagePreviewChip({
|
|
156
|
+
attachment,
|
|
157
|
+
executionId,
|
|
158
|
+
onPreview,
|
|
159
|
+
}: {
|
|
160
|
+
readonly attachment: MessageAttachmentView;
|
|
161
|
+
readonly executionId: string;
|
|
162
|
+
readonly onPreview: () => void;
|
|
163
|
+
}) {
|
|
164
|
+
const { url, error } = useArtifactDownloadUrl(
|
|
165
|
+
executionId,
|
|
166
|
+
attachment.storageKey ?? null,
|
|
167
|
+
);
|
|
168
|
+
const name = displayName(attachment);
|
|
169
|
+
|
|
170
|
+
if (error) {
|
|
171
|
+
return <DocumentChip attachment={attachment} executionId={executionId} />;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<span role="listitem" aria-label={name} title={name} className="inline-flex">
|
|
176
|
+
{/* The whole tile is the preview target. UNSTYLED_BUTTON is
|
|
177
|
+
load-bearing: without it the tile shows the UA's default button
|
|
178
|
+
box in preflight-less hosts (see the constant). */}
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
onClick={onPreview}
|
|
182
|
+
aria-label={`Preview ${name}`}
|
|
183
|
+
className={cn(
|
|
184
|
+
UNSTYLED_BUTTON,
|
|
185
|
+
"relative block h-14 w-14 overflow-hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
186
|
+
)}
|
|
187
|
+
>
|
|
188
|
+
{url ? (
|
|
189
|
+
<img
|
|
190
|
+
src={url}
|
|
191
|
+
alt=""
|
|
192
|
+
aria-hidden="true"
|
|
193
|
+
loading="lazy"
|
|
194
|
+
className="h-full w-full object-cover"
|
|
195
|
+
/>
|
|
196
|
+
) : (
|
|
197
|
+
// Pulse placeholder while the presigned URL is minted — pulse
|
|
198
|
+
// means work is genuinely in flight (contrast ImageGlyphTile).
|
|
199
|
+
// Same footprint as the image, so no layout shift.
|
|
200
|
+
<span
|
|
201
|
+
className="block h-full w-full animate-pulse bg-muted"
|
|
202
|
+
aria-hidden="true"
|
|
203
|
+
/>
|
|
204
|
+
)}
|
|
205
|
+
</button>
|
|
206
|
+
</span>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The inert image tile for a turn with no execution record yet (optimistic
|
|
212
|
+
* pending bubble) or ever (failed send). Keeps the image grammar — same
|
|
213
|
+
* footprint as {@link ImagePreviewChip} — but shows a STATIC glyph, not a
|
|
214
|
+
* pulse: a pulse promises bytes that, on a failed send, will never arrive.
|
|
215
|
+
*/
|
|
216
|
+
function ImageGlyphTile({ name }: { readonly name: string }) {
|
|
217
|
+
return (
|
|
218
|
+
<span
|
|
219
|
+
role="listitem"
|
|
220
|
+
aria-label={name}
|
|
221
|
+
title={name}
|
|
222
|
+
className="inline-flex h-14 w-14 items-center justify-center rounded-md bg-muted-subtle"
|
|
223
|
+
>
|
|
224
|
+
<ImageGlyph />
|
|
225
|
+
</span>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// Document attachment — compact chip, click-to-download
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The compact treatment for documents (and for any attachment before its
|
|
235
|
+
* execution record exists). With an execution id, the chip is a download
|
|
236
|
+
* button — a fresh URL is minted at click time via {@link useArtifactDownload},
|
|
237
|
+
* saved under the original filename. Without one (pending bubble), it is an
|
|
238
|
+
* inert label: evidence the file was sent, affordances once the record lands.
|
|
239
|
+
*/
|
|
240
|
+
function DocumentChip({
|
|
241
|
+
attachment,
|
|
242
|
+
executionId,
|
|
243
|
+
}: {
|
|
244
|
+
readonly attachment: MessageAttachmentView;
|
|
245
|
+
readonly executionId?: string;
|
|
246
|
+
}) {
|
|
247
|
+
const { download, isDownloading } = useArtifactDownload(executionId ?? null);
|
|
248
|
+
const name = displayName(attachment);
|
|
249
|
+
const downloadable = executionId != null && !!attachment.storageKey;
|
|
250
|
+
|
|
251
|
+
const content = (
|
|
252
|
+
<>
|
|
253
|
+
<FileGlyph />
|
|
254
|
+
<span className="min-w-0 truncate">{name}</span>
|
|
255
|
+
</>
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<span
|
|
260
|
+
role="listitem"
|
|
261
|
+
aria-label={name}
|
|
262
|
+
title={name}
|
|
263
|
+
className="inline-flex max-w-[200px] items-center rounded-md bg-muted-subtle px-2 py-0.5 text-xs text-foreground"
|
|
264
|
+
>
|
|
265
|
+
{downloadable ? (
|
|
266
|
+
<button
|
|
267
|
+
type="button"
|
|
268
|
+
onClick={() => void download(attachment.storageKey!, name)}
|
|
269
|
+
disabled={isDownloading}
|
|
270
|
+
aria-label={`Download ${name}`}
|
|
271
|
+
className={cn(
|
|
272
|
+
UNSTYLED_BUTTON,
|
|
273
|
+
"flex min-w-0 items-center gap-1 rounded-sm text-inherit [font:inherit] hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
274
|
+
isDownloading && "opacity-70",
|
|
275
|
+
)}
|
|
276
|
+
>
|
|
277
|
+
{content}
|
|
278
|
+
</button>
|
|
279
|
+
) : (
|
|
280
|
+
<span className="flex min-w-0 items-center gap-1">{content}</span>
|
|
281
|
+
)}
|
|
282
|
+
</span>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Lightbox bridge
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Feeds the previewed attachment's presigned URL to the shared
|
|
292
|
+
* {@link AttachmentImageLightbox}. The URL hook is cross-mount cached by
|
|
293
|
+
* storage key (DD-014), so opening the lightbox reuses the thumbnail's minted
|
|
294
|
+
* URL instead of a second RPC; `src` is `null` for at most the first frame.
|
|
295
|
+
*/
|
|
296
|
+
function AttachmentPreviewLightbox({
|
|
297
|
+
attachment,
|
|
298
|
+
executionId,
|
|
299
|
+
onClose,
|
|
300
|
+
}: {
|
|
301
|
+
readonly attachment: MessageAttachmentView;
|
|
302
|
+
readonly executionId: string;
|
|
303
|
+
readonly onClose: () => void;
|
|
304
|
+
}) {
|
|
305
|
+
const { url } = useArtifactDownloadUrl(
|
|
306
|
+
executionId,
|
|
307
|
+
attachment.storageKey ?? null,
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<AttachmentImageLightbox
|
|
312
|
+
src={url}
|
|
313
|
+
filename={displayName(attachment)}
|
|
314
|
+
open
|
|
315
|
+
onClose={onClose}
|
|
316
|
+
/>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Generic picture glyph for the inert {@link ImageGlyphTile}. */
|
|
321
|
+
function ImageGlyph() {
|
|
322
|
+
return (
|
|
323
|
+
<svg
|
|
324
|
+
width="18"
|
|
325
|
+
height="18"
|
|
326
|
+
viewBox="0 0 24 24"
|
|
327
|
+
fill="none"
|
|
328
|
+
stroke="currentColor"
|
|
329
|
+
strokeWidth="1.5"
|
|
330
|
+
strokeLinecap="round"
|
|
331
|
+
strokeLinejoin="round"
|
|
332
|
+
className="shrink-0 text-muted-foreground"
|
|
333
|
+
aria-hidden="true"
|
|
334
|
+
>
|
|
335
|
+
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
336
|
+
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
337
|
+
<path d="M21 15l-5-5L5 21" />
|
|
338
|
+
</svg>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Generic document glyph — the same geometry as the composer chip's icon so
|
|
344
|
+
* the two surfaces read as one family.
|
|
345
|
+
*/
|
|
346
|
+
function FileGlyph() {
|
|
347
|
+
return (
|
|
348
|
+
<svg
|
|
349
|
+
width="10"
|
|
350
|
+
height="10"
|
|
351
|
+
viewBox="0 0 14 14"
|
|
352
|
+
fill="none"
|
|
353
|
+
stroke="currentColor"
|
|
354
|
+
strokeWidth="1.5"
|
|
355
|
+
strokeLinecap="round"
|
|
356
|
+
strokeLinejoin="round"
|
|
357
|
+
className="shrink-0 text-muted-foreground"
|
|
358
|
+
aria-hidden="true"
|
|
359
|
+
>
|
|
360
|
+
<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" />
|
|
361
|
+
<path d="M8 1V4H11" />
|
|
362
|
+
</svg>
|
|
363
|
+
);
|
|
364
|
+
}
|
|
@@ -13,6 +13,10 @@ import {
|
|
|
13
13
|
unwrapEnclosingMarkdownFence,
|
|
14
14
|
} from "../internal/markdown-components.js";
|
|
15
15
|
import { InteractionModeBadge } from "./InteractionModeBadge.js";
|
|
16
|
+
import {
|
|
17
|
+
MessageAttachments,
|
|
18
|
+
type MessageAttachmentView,
|
|
19
|
+
} from "./MessageAttachments.js";
|
|
16
20
|
import { PlanDocumentMessage } from "./PlanDocumentMessage.js";
|
|
17
21
|
import { useRenderTracer } from "../internal/dev/index.js";
|
|
18
22
|
|
|
@@ -48,6 +52,23 @@ export interface MessageEntryProps {
|
|
|
48
52
|
* non-human messages.
|
|
49
53
|
*/
|
|
50
54
|
readonly interactionMode?: InteractionMode;
|
|
55
|
+
/**
|
|
56
|
+
* The turn's submitted attachments, stamped by the thread builder on
|
|
57
|
+
* `MESSAGE_HUMAN` bubbles from the execution's `spec.attachments` (or the
|
|
58
|
+
* pending submit context). Renders as a {@link MessageAttachments} row
|
|
59
|
+
* above the prose. Ignored for non-human messages.
|
|
60
|
+
*
|
|
61
|
+
* A `MessageEntry` slot override that delegates to the built-in inherits
|
|
62
|
+
* attachment rendering; one that ignores this prop renders text only.
|
|
63
|
+
*/
|
|
64
|
+
readonly attachments?: readonly MessageAttachmentView[];
|
|
65
|
+
/**
|
|
66
|
+
* The execution the attachments belong to — enables their byte-backed
|
|
67
|
+
* affordances (image previews, document downloads) via presigned URLs.
|
|
68
|
+
* Absent on the optimistic pending bubble, where attachments render as
|
|
69
|
+
* inert chips until the real execution record replaces it.
|
|
70
|
+
*/
|
|
71
|
+
readonly executionId?: string;
|
|
51
72
|
}
|
|
52
73
|
|
|
53
74
|
/**
|
|
@@ -80,6 +101,8 @@ export const MessageEntry = memo(function MessageEntry({
|
|
|
80
101
|
onEdit,
|
|
81
102
|
isPlanDocument,
|
|
82
103
|
interactionMode,
|
|
104
|
+
attachments,
|
|
105
|
+
executionId,
|
|
83
106
|
}: MessageEntryProps) {
|
|
84
107
|
useRenderTracer("MessageEntry", {
|
|
85
108
|
messageType: message.type,
|
|
@@ -95,6 +118,8 @@ export const MessageEntry = memo(function MessageEntry({
|
|
|
95
118
|
className={className}
|
|
96
119
|
onEdit={onEdit}
|
|
97
120
|
interactionMode={interactionMode}
|
|
121
|
+
attachments={attachments}
|
|
122
|
+
executionId={executionId}
|
|
98
123
|
/>
|
|
99
124
|
);
|
|
100
125
|
case MessageType.MESSAGE_AI:
|
|
@@ -132,11 +157,15 @@ function HumanMessage({
|
|
|
132
157
|
className,
|
|
133
158
|
onEdit,
|
|
134
159
|
interactionMode,
|
|
160
|
+
attachments,
|
|
161
|
+
executionId,
|
|
135
162
|
}: {
|
|
136
163
|
content: string;
|
|
137
164
|
className?: string;
|
|
138
165
|
onEdit?: () => void;
|
|
139
166
|
interactionMode?: InteractionMode;
|
|
167
|
+
attachments?: readonly MessageAttachmentView[];
|
|
168
|
+
executionId?: string;
|
|
140
169
|
}) {
|
|
141
170
|
return (
|
|
142
171
|
<div
|
|
@@ -152,6 +181,15 @@ function HumanMessage({
|
|
|
152
181
|
{interactionMode !== undefined && (
|
|
153
182
|
<InteractionModeBadge mode={interactionMode} className="mb-1.5" />
|
|
154
183
|
)}
|
|
184
|
+
{/* The evidence a file rode with this turn (#372) — above the prose,
|
|
185
|
+
mirroring the composer's chips-above-input layout. */}
|
|
186
|
+
{attachments && attachments.length > 0 && (
|
|
187
|
+
<MessageAttachments
|
|
188
|
+
attachments={attachments}
|
|
189
|
+
executionId={executionId}
|
|
190
|
+
className="mb-2"
|
|
191
|
+
/>
|
|
192
|
+
)}
|
|
155
193
|
<p className="text-sm text-foreground whitespace-pre-wrap">{content}</p>
|
|
156
194
|
{onEdit && (
|
|
157
195
|
<button
|