@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,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
|
|
@@ -22,6 +22,7 @@ import { displayFileChangeSets } from "@stigmer/sdk";
|
|
|
22
22
|
import { cn } from "@stigmer/theme";
|
|
23
23
|
import { isTerminalPhase } from "./execution-phases.js";
|
|
24
24
|
import { MessageEntry, type MessageEntryProps } from "./MessageEntry.js";
|
|
25
|
+
import type { MessageAttachmentView } from "./MessageAttachments.js";
|
|
25
26
|
import { ToolCallGroup } from "./ToolCallGroup.js";
|
|
26
27
|
import { SubAgentSection } from "./SubAgentSection.js";
|
|
27
28
|
import { ExecutionPhaseBadge } from "./ExecutionPhaseBadge.js";
|
|
@@ -124,6 +125,16 @@ export interface MessageThreadProps {
|
|
|
124
125
|
* first snapshot.
|
|
125
126
|
*/
|
|
126
127
|
readonly pendingUserMessage?: string | null;
|
|
128
|
+
/**
|
|
129
|
+
* Attachments submitted with the pending user message, rendered as chips
|
|
130
|
+
* on the optimistic bubble so the files never vanish between submit and
|
|
131
|
+
* the stream's first snapshot (stigmer/stigmer#372). Supply
|
|
132
|
+
* {@link useSessionConversation}'s `pendingAttachments`; clear together
|
|
133
|
+
* with {@link pendingUserMessage}. Chips are inert on the pending bubble
|
|
134
|
+
* (no execution record yet); previews and downloads light up the moment
|
|
135
|
+
* the real turn replaces it.
|
|
136
|
+
*/
|
|
137
|
+
readonly pendingAttachments?: readonly MessageAttachmentView[] | null;
|
|
127
138
|
/**
|
|
128
139
|
* Marks the pending user message as failed-to-send. The optimistic
|
|
129
140
|
* bubble renders an inline "Couldn't send — Retry" affordance instead
|
|
@@ -339,7 +350,7 @@ export function threadContentColumnClass(
|
|
|
339
350
|
* part of the public API.
|
|
340
351
|
*/
|
|
341
352
|
export type ThreadItem =
|
|
342
|
-
| { readonly kind: "message"; readonly message: AgentMessage; readonly key: string; readonly isPending?: boolean; readonly isFailed?: boolean; readonly isEditable?: boolean; readonly isPlanDocument?: boolean; readonly interactionMode?: InteractionMode }
|
|
353
|
+
| { readonly kind: "message"; readonly message: AgentMessage; readonly key: string; readonly isPending?: boolean; readonly isFailed?: boolean; readonly isEditable?: boolean; readonly isPlanDocument?: boolean; readonly interactionMode?: InteractionMode; readonly attachments?: readonly MessageAttachmentView[]; readonly executionId?: string }
|
|
343
354
|
| { readonly kind: "tool-group"; readonly toolCalls: readonly ToolCall[]; readonly subAgentExecutions: readonly SubAgentExecution[]; readonly key: string }
|
|
344
355
|
| { readonly kind: "sub-agent"; readonly subAgentExecution: SubAgentExecution; readonly key: string }
|
|
345
356
|
| { readonly kind: "phase-badge"; readonly phase: ExecutionPhase; readonly key: string }
|
|
@@ -557,6 +568,7 @@ export function buildThreadItems(
|
|
|
557
568
|
editableActiveTurn = false,
|
|
558
569
|
includeFileReviewRecords = false,
|
|
559
570
|
collapseStreamingPlan = false,
|
|
571
|
+
pendingAttachments?: readonly MessageAttachmentView[] | null,
|
|
560
572
|
): ThreadItem[] {
|
|
561
573
|
const items: ThreadItem[] = [];
|
|
562
574
|
// Tool-call ids that render as an inline-approval-capable ToolCallItem (a
|
|
@@ -717,6 +729,12 @@ export function buildThreadItems(
|
|
|
717
729
|
pendingUserMessage != null &&
|
|
718
730
|
specMessage === pendingUserMessage;
|
|
719
731
|
|
|
732
|
+
// The turn's submitted files (spec.attachments, by reference — the spec
|
|
733
|
+
// never mutates and structural sharing keeps the ref stable, so the
|
|
734
|
+
// memoized bubble skips re-renders). The execution id rides along for
|
|
735
|
+
// the presigned-URL affordances; the `_e${ei}` synthetic fallback is
|
|
736
|
+
// display-only and must never be presigned against.
|
|
737
|
+
const specAttachments = exec.spec?.attachments;
|
|
720
738
|
items.push({
|
|
721
739
|
kind: "message",
|
|
722
740
|
message: syntheticHumanMsg,
|
|
@@ -727,6 +745,11 @@ export function buildThreadItems(
|
|
|
727
745
|
// The turn's mode marks the prompt bubble (a "Plan" pill on Plan
|
|
728
746
|
// turns) so the transcript reads unambiguously after mode switches.
|
|
729
747
|
interactionMode: exec.spec?.executionConfig?.interactionMode,
|
|
748
|
+
attachments:
|
|
749
|
+
specAttachments && specAttachments.length > 0
|
|
750
|
+
? specAttachments
|
|
751
|
+
: undefined,
|
|
752
|
+
executionId: exec.metadata?.id,
|
|
730
753
|
});
|
|
731
754
|
}
|
|
732
755
|
|
|
@@ -1023,6 +1046,14 @@ export function buildThreadItems(
|
|
|
1023
1046
|
// sending state — the two are mutually exclusive.
|
|
1024
1047
|
isPending: !pendingMessageFailed,
|
|
1025
1048
|
isFailed: pendingMessageFailed,
|
|
1049
|
+
// The submit context's attachments: evidence the files rode with the
|
|
1050
|
+
// turn, kept on failure so retry shows what will be re-sent. No
|
|
1051
|
+
// executionId yet — chips render inert until the real turn (same
|
|
1052
|
+
// bridge key) replaces this bubble and brings the presign seam.
|
|
1053
|
+
attachments:
|
|
1054
|
+
pendingAttachments && pendingAttachments.length > 0
|
|
1055
|
+
? pendingAttachments
|
|
1056
|
+
: undefined,
|
|
1026
1057
|
});
|
|
1027
1058
|
}
|
|
1028
1059
|
}
|
|
@@ -1057,6 +1088,7 @@ export function MessageThread({
|
|
|
1057
1088
|
executions,
|
|
1058
1089
|
activeStreamExecution,
|
|
1059
1090
|
pendingUserMessage,
|
|
1091
|
+
pendingAttachments,
|
|
1060
1092
|
pendingMessageFailed = false,
|
|
1061
1093
|
onRetrySend,
|
|
1062
1094
|
onRetryExecution,
|
|
@@ -1089,8 +1121,8 @@ export function MessageThread({
|
|
|
1089
1121
|
// the plan streaming inline, where it remains readable (DD-011).
|
|
1090
1122
|
const collapseStreamingPlan = onOpenPlan != null;
|
|
1091
1123
|
const items = useMemo(
|
|
1092
|
-
() => buildThreadItems(executions, activeStreamExecution, pendingUserMessage, includeApprovals, workspaceEntries, summarizationEvents, pendingMessageFailed, editableActiveTurn, showFileReviewRecords, collapseStreamingPlan),
|
|
1093
|
-
[executions, activeStreamExecution, pendingUserMessage, includeApprovals, workspaceEntries, summarizationEvents, pendingMessageFailed, editableActiveTurn, showFileReviewRecords, collapseStreamingPlan],
|
|
1124
|
+
() => buildThreadItems(executions, activeStreamExecution, pendingUserMessage, includeApprovals, workspaceEntries, summarizationEvents, pendingMessageFailed, editableActiveTurn, showFileReviewRecords, collapseStreamingPlan, pendingAttachments),
|
|
1125
|
+
[executions, activeStreamExecution, pendingUserMessage, includeApprovals, workspaceEntries, summarizationEvents, pendingMessageFailed, editableActiveTurn, showFileReviewRecords, collapseStreamingPlan, pendingAttachments],
|
|
1094
1126
|
);
|
|
1095
1127
|
|
|
1096
1128
|
useKeyStability(items);
|
|
@@ -1413,6 +1445,7 @@ export function ThreadItemRenderer({
|
|
|
1413
1445
|
return (
|
|
1414
1446
|
<FailedUserMessage
|
|
1415
1447
|
message={item.message}
|
|
1448
|
+
attachments={item.attachments}
|
|
1416
1449
|
onRetry={onRetrySend}
|
|
1417
1450
|
MessageEntryComponent={Entry}
|
|
1418
1451
|
/>
|
|
@@ -1424,6 +1457,8 @@ export function ThreadItemRenderer({
|
|
|
1424
1457
|
className={item.isPending ? "opacity-70" : undefined}
|
|
1425
1458
|
isPlanDocument={item.isPlanDocument}
|
|
1426
1459
|
interactionMode={item.interactionMode}
|
|
1460
|
+
attachments={item.attachments}
|
|
1461
|
+
executionId={item.executionId}
|
|
1427
1462
|
onEdit={
|
|
1428
1463
|
item.isEditable && onEditMessage
|
|
1429
1464
|
? () => onEditMessage(item.message.content)
|
|
@@ -1555,16 +1590,21 @@ export function ThreadItemRenderer({
|
|
|
1555
1590
|
*/
|
|
1556
1591
|
function FailedUserMessage({
|
|
1557
1592
|
message,
|
|
1593
|
+
attachments,
|
|
1558
1594
|
onRetry,
|
|
1559
1595
|
MessageEntryComponent,
|
|
1560
1596
|
}: {
|
|
1561
1597
|
message: AgentMessage;
|
|
1598
|
+
attachments?: readonly MessageAttachmentView[];
|
|
1562
1599
|
onRetry?: () => void;
|
|
1563
1600
|
MessageEntryComponent: ComponentType<MessageEntryProps>;
|
|
1564
1601
|
}) {
|
|
1565
1602
|
return (
|
|
1566
1603
|
<div className="flex flex-col gap-1">
|
|
1567
|
-
|
|
1604
|
+
{/* Attachments stay visible on the failed bubble — the one turn where
|
|
1605
|
+
the user most needs evidence of what they tried to send (and what
|
|
1606
|
+
Retry will re-send). No executionId: the send never created one. */}
|
|
1607
|
+
<MessageEntryComponent message={message} attachments={attachments} />
|
|
1568
1608
|
<div
|
|
1569
1609
|
role="alert"
|
|
1570
1610
|
className="mx-4 flex items-center gap-2 text-xs text-destructive"
|