@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
|
@@ -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"
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
|
2
|
+
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
|
3
|
+
import { create } from "@bufbuild/protobuf";
|
|
4
|
+
import { AgentMessageSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
5
|
+
import { MessageType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
6
|
+
|
|
7
|
+
import { MessageEntry } from "../MessageEntry";
|
|
8
|
+
import type { MessageAttachmentView } from "../MessageAttachments";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// MessageEntry attachment rendering (stigmer/stigmer#372): the human bubble
|
|
12
|
+
// shows the turn's submitted files — image previews and document downloads
|
|
13
|
+
// when the execution record exists, inert chips on the pending bubble, and
|
|
14
|
+
// nothing at all when the turn had no attachments.
|
|
15
|
+
//
|
|
16
|
+
// The presign hooks are mocked at the module boundary: these are rendering
|
|
17
|
+
// tests, and the URL-minting contract has its own coverage.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
const downloadSpy = vi.fn(() => Promise.resolve());
|
|
21
|
+
let urlState: { url: string | null; error: Error | null } = {
|
|
22
|
+
url: null,
|
|
23
|
+
error: null,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
vi.mock("../useArtifactDownloadUrl", () => ({
|
|
27
|
+
useArtifactDownloadUrl: (executionId: string | null, storageKey: string | null) => ({
|
|
28
|
+
url: executionId && storageKey ? urlState.url : null,
|
|
29
|
+
isLoading: false,
|
|
30
|
+
isRefetching: false,
|
|
31
|
+
error: urlState.error,
|
|
32
|
+
refetch: vi.fn(),
|
|
33
|
+
}),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
vi.mock("../useArtifactDownload", () => ({
|
|
37
|
+
useArtifactDownload: (executionId: string | null) => ({
|
|
38
|
+
download: executionId ? downloadSpy : vi.fn(() => Promise.resolve()),
|
|
39
|
+
isDownloading: false,
|
|
40
|
+
error: null,
|
|
41
|
+
}),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
// happy-dom does not implement the native dialog show/close methods.
|
|
45
|
+
beforeAll(() => {
|
|
46
|
+
HTMLDialogElement.prototype.showModal = function showModal() {
|
|
47
|
+
this.open = true;
|
|
48
|
+
};
|
|
49
|
+
HTMLDialogElement.prototype.close = function close() {
|
|
50
|
+
this.open = false;
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
cleanup();
|
|
56
|
+
downloadSpy.mockClear();
|
|
57
|
+
urlState = { url: null, error: null };
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function humanMessage(content: string) {
|
|
61
|
+
const msg = create(AgentMessageSchema);
|
|
62
|
+
msg.type = MessageType.MESSAGE_HUMAN;
|
|
63
|
+
msg.content = content;
|
|
64
|
+
return msg;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const imageAttachment: MessageAttachmentView = {
|
|
68
|
+
filename: "screenshot.png",
|
|
69
|
+
contentType: "image/png",
|
|
70
|
+
storageKey: "attachments/01AAA/screenshot.png",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const documentAttachment: MessageAttachmentView = {
|
|
74
|
+
filename: "notes.txt",
|
|
75
|
+
contentType: "text/plain",
|
|
76
|
+
storageKey: "attachments/01BBB/notes.txt",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
describe("MessageEntry attachment rendering", () => {
|
|
80
|
+
it("renders no attachment row when the turn has none", () => {
|
|
81
|
+
render(<MessageEntry message={humanMessage("Just text.")} />);
|
|
82
|
+
|
|
83
|
+
expect(screen.queryByRole("list", { name: "Submitted attachments" })).toBeNull();
|
|
84
|
+
expect(screen.getByText("Just text.")).toBeTruthy();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("renders an image preview chip with the presigned URL and click-to-open lightbox", () => {
|
|
88
|
+
urlState = { url: "https://r2.example/presigned/screenshot", error: null };
|
|
89
|
+
|
|
90
|
+
const { container } = render(
|
|
91
|
+
<MessageEntry
|
|
92
|
+
message={humanMessage("See the screenshot.")}
|
|
93
|
+
attachments={[imageAttachment]}
|
|
94
|
+
executionId="exec-1"
|
|
95
|
+
/>,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(screen.getByRole("list", { name: "Submitted attachments" })).toBeTruthy();
|
|
99
|
+
const img = container.querySelector("img[aria-hidden='true']");
|
|
100
|
+
expect(img?.getAttribute("src")).toBe("https://r2.example/presigned/screenshot");
|
|
101
|
+
|
|
102
|
+
// Click-to-open: the shared lightbox mounts with the full image.
|
|
103
|
+
fireEvent.click(screen.getByRole("button", { name: "Preview screenshot.png" }));
|
|
104
|
+
const dialog = container.querySelector("dialog");
|
|
105
|
+
expect(dialog).toBeTruthy();
|
|
106
|
+
expect(dialog!.getAttribute("aria-label")).toBe("Preview screenshot.png");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("renders a document chip that downloads under the original filename", () => {
|
|
110
|
+
render(
|
|
111
|
+
<MessageEntry
|
|
112
|
+
message={humanMessage("The notes are attached.")}
|
|
113
|
+
attachments={[documentAttachment]}
|
|
114
|
+
executionId="exec-1"
|
|
115
|
+
/>,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
fireEvent.click(screen.getByRole("button", { name: "Download notes.txt" }));
|
|
119
|
+
expect(downloadSpy).toHaveBeenCalledWith(
|
|
120
|
+
"attachments/01BBB/notes.txt",
|
|
121
|
+
"notes.txt",
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("renders no visible filename on image tiles — identity lives in the tooltip and accessible label", () => {
|
|
126
|
+
urlState = { url: "https://r2.example/presigned/screenshot", error: null };
|
|
127
|
+
|
|
128
|
+
render(
|
|
129
|
+
<MessageEntry
|
|
130
|
+
message={humanMessage("See the screenshot.")}
|
|
131
|
+
attachments={[imageAttachment]}
|
|
132
|
+
executionId="exec-1"
|
|
133
|
+
/>,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// The tile is preview-only: no "screenshot.png" text node anywhere…
|
|
137
|
+
expect(screen.queryByText("screenshot.png")).toBeNull();
|
|
138
|
+
// …but the name stays reachable — tooltip on the listitem, aria on both.
|
|
139
|
+
const tile = screen.getByRole("listitem", { name: "screenshot.png" });
|
|
140
|
+
expect(tile.getAttribute("title")).toBe("screenshot.png");
|
|
141
|
+
expect(
|
|
142
|
+
screen.getByRole("button", { name: "Preview screenshot.png" }),
|
|
143
|
+
).toBeTruthy();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("pulses the image tile only while the presigned URL is being minted", () => {
|
|
147
|
+
urlState = { url: null, error: null };
|
|
148
|
+
|
|
149
|
+
const { container } = render(
|
|
150
|
+
<MessageEntry
|
|
151
|
+
message={humanMessage("See the screenshot.")}
|
|
152
|
+
attachments={[imageAttachment]}
|
|
153
|
+
executionId="exec-1"
|
|
154
|
+
/>,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// Mint in flight: pulse placeholder, no image yet.
|
|
158
|
+
expect(container.querySelector(".animate-pulse")).toBeTruthy();
|
|
159
|
+
expect(container.querySelector("img")).toBeNull();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("renders inert chips on the pending bubble (no executionId): no preview, no download", () => {
|
|
163
|
+
const { container } = render(
|
|
164
|
+
<MessageEntry
|
|
165
|
+
message={humanMessage("Sending files…")}
|
|
166
|
+
attachments={[imageAttachment, documentAttachment]}
|
|
167
|
+
/>,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// Both files are present as evidence — the image as a glyph tile
|
|
171
|
+
// (named via aria/tooltip), the document as a filename chip…
|
|
172
|
+
expect(screen.getByRole("list", { name: "Submitted attachments" })).toBeTruthy();
|
|
173
|
+
expect(screen.getByRole("listitem", { name: "screenshot.png" })).toBeTruthy();
|
|
174
|
+
expect(screen.getByText("notes.txt")).toBeTruthy();
|
|
175
|
+
// …but no byte-backed affordances exist yet.
|
|
176
|
+
expect(container.querySelector("img")).toBeNull();
|
|
177
|
+
expect(screen.queryByRole("button", { name: /Preview|Download/ })).toBeNull();
|
|
178
|
+
// The tile is STATIC — a pulse would promise bytes that a failed send
|
|
179
|
+
// (the other no-executionId bubble) never delivers.
|
|
180
|
+
expect(container.querySelector(".animate-pulse")).toBeNull();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("degrades an image chip to the document treatment when the URL cannot be minted", () => {
|
|
184
|
+
urlState = { url: null, error: new Error("presign failed") };
|
|
185
|
+
|
|
186
|
+
const { container } = render(
|
|
187
|
+
<MessageEntry
|
|
188
|
+
message={humanMessage("See the screenshot.")}
|
|
189
|
+
attachments={[imageAttachment]}
|
|
190
|
+
executionId="exec-1"
|
|
191
|
+
/>,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// Never a broken-image glyph — the file stays reachable via download.
|
|
195
|
+
expect(container.querySelector("img")).toBeNull();
|
|
196
|
+
expect(
|
|
197
|
+
screen.getByRole("button", { name: "Download screenshot.png" }),
|
|
198
|
+
).toBeTruthy();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("falls back to the storage key's basename when the filename is missing", () => {
|
|
202
|
+
render(
|
|
203
|
+
<MessageEntry
|
|
204
|
+
message={humanMessage("Unnamed attachment.")}
|
|
205
|
+
attachments={[{ storageKey: "attachments/01CCC/data.bin" }]}
|
|
206
|
+
executionId="exec-1"
|
|
207
|
+
/>,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
expect(screen.getByText("data.bin")).toBeTruthy();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("ignores attachments on non-human messages", () => {
|
|
214
|
+
const aiMsg = create(AgentMessageSchema);
|
|
215
|
+
aiMsg.type = MessageType.MESSAGE_AI;
|
|
216
|
+
aiMsg.content = "Assistant reply.";
|
|
217
|
+
|
|
218
|
+
render(
|
|
219
|
+
<MessageEntry
|
|
220
|
+
message={aiMsg}
|
|
221
|
+
attachments={[documentAttachment]}
|
|
222
|
+
executionId="exec-1"
|
|
223
|
+
/>,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
expect(screen.queryByRole("list", { name: "Submitted attachments" })).toBeNull();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { create } from "@bufbuild/protobuf";
|
|
3
|
+
import {
|
|
4
|
+
AgentExecutionSchema,
|
|
5
|
+
AgentExecutionStatusSchema,
|
|
6
|
+
type AgentExecution,
|
|
7
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
8
|
+
import {
|
|
9
|
+
AgentExecutionSpecSchema,
|
|
10
|
+
AttachmentSchema,
|
|
11
|
+
type Attachment,
|
|
12
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
|
|
13
|
+
import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
|
|
14
|
+
import { ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
15
|
+
import { buildThreadItems, type ThreadItem } from "../MessageThread";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// buildThreadItems attachment stamping (stigmer/stigmer#372)
|
|
19
|
+
//
|
|
20
|
+
// The human turn's submitted files come from two sources: the execution
|
|
21
|
+
// record's spec.attachments (durable, presign-capable) and the submit
|
|
22
|
+
// context's pendingAttachments (optimistic bubble, no execution yet). These
|
|
23
|
+
// tests pin both stamps, the empty-list omission, and the reference
|
|
24
|
+
// stability the memoized bubble depends on.
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
function makeAttachment(filename: string, storageKey: string): Attachment {
|
|
28
|
+
return create(AttachmentSchema, {
|
|
29
|
+
filename,
|
|
30
|
+
storageKey,
|
|
31
|
+
contentType: filename.endsWith(".png") ? "image/png" : "text/plain",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeExecution(opts: {
|
|
36
|
+
id: string;
|
|
37
|
+
specMessage: string;
|
|
38
|
+
attachments?: Attachment[];
|
|
39
|
+
}): AgentExecution {
|
|
40
|
+
const exec = create(AgentExecutionSchema);
|
|
41
|
+
exec.metadata = create(ApiResourceMetadataSchema, { id: opts.id });
|
|
42
|
+
const spec = create(AgentExecutionSpecSchema);
|
|
43
|
+
spec.message = opts.specMessage;
|
|
44
|
+
if (opts.attachments) spec.attachments = opts.attachments;
|
|
45
|
+
exec.spec = spec;
|
|
46
|
+
const status = create(AgentExecutionStatusSchema);
|
|
47
|
+
status.phase = ExecutionPhase.EXECUTION_COMPLETED;
|
|
48
|
+
exec.status = status;
|
|
49
|
+
return exec;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function promptItem(items: readonly ThreadItem[], execId: string) {
|
|
53
|
+
return items.find(
|
|
54
|
+
(i): i is Extract<ThreadItem, { kind: "message" }> =>
|
|
55
|
+
i.kind === "message" && i.key === `${execId}-spec`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function pendingItem(items: readonly ThreadItem[]) {
|
|
60
|
+
return items.find(
|
|
61
|
+
(i): i is Extract<ThreadItem, { kind: "message" }> =>
|
|
62
|
+
i.kind === "message" && i.key === "pending-user-turn",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("buildThreadItems spec.attachments stamping", () => {
|
|
67
|
+
it("stamps the synthetic human turn with the spec's attachments and the execution id", () => {
|
|
68
|
+
const attachments = [
|
|
69
|
+
makeAttachment("notes.png", "attachments/01AAA/notes.png"),
|
|
70
|
+
makeAttachment("report.txt", "attachments/01BBB/report.txt"),
|
|
71
|
+
];
|
|
72
|
+
const exec = makeExecution({
|
|
73
|
+
id: "exec-attach",
|
|
74
|
+
specMessage: "Look at these files.",
|
|
75
|
+
attachments,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const items = buildThreadItems([exec], null, null, false, undefined);
|
|
79
|
+
const prompt = promptItem(items, "exec-attach");
|
|
80
|
+
|
|
81
|
+
expect(prompt).toBeDefined();
|
|
82
|
+
expect(prompt!.executionId).toBe("exec-attach");
|
|
83
|
+
// Stamped BY REFERENCE: structural sharing keeps spec.attachments stable
|
|
84
|
+
// across streaming frames, so the memoized bubble must receive the same
|
|
85
|
+
// array object, not a copy (DD-010).
|
|
86
|
+
expect(prompt!.attachments).toBe(exec.spec!.attachments);
|
|
87
|
+
expect(prompt!.attachments).toHaveLength(2);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("stamps NO attachments field when the spec has none (empty list omitted)", () => {
|
|
91
|
+
const exec = makeExecution({
|
|
92
|
+
id: "exec-plain",
|
|
93
|
+
specMessage: "Just text.",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const items = buildThreadItems([exec], null, null, false, undefined);
|
|
97
|
+
const prompt = promptItem(items, "exec-plain");
|
|
98
|
+
|
|
99
|
+
expect(prompt).toBeDefined();
|
|
100
|
+
expect(prompt!.attachments).toBeUndefined();
|
|
101
|
+
// The execution id still rides along — other affordances may need it.
|
|
102
|
+
expect(prompt!.executionId).toBe("exec-plain");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("buildThreadItems pendingAttachments stamping", () => {
|
|
107
|
+
const pendingAttachments = [
|
|
108
|
+
{ filename: "sketch.png", contentType: "image/png", storageKey: "attachments/01CCC/sketch.png" },
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
it("carries the submit context's attachments on the pending bubble, without an execution id", () => {
|
|
112
|
+
const items = buildThreadItems(
|
|
113
|
+
[],
|
|
114
|
+
null,
|
|
115
|
+
"Here is a sketch.",
|
|
116
|
+
false,
|
|
117
|
+
undefined,
|
|
118
|
+
undefined,
|
|
119
|
+
false,
|
|
120
|
+
false,
|
|
121
|
+
false,
|
|
122
|
+
false,
|
|
123
|
+
pendingAttachments,
|
|
124
|
+
);
|
|
125
|
+
const pending = pendingItem(items);
|
|
126
|
+
|
|
127
|
+
expect(pending).toBeDefined();
|
|
128
|
+
expect(pending!.isPending).toBe(true);
|
|
129
|
+
expect(pending!.attachments).toBe(pendingAttachments);
|
|
130
|
+
// No execution record yet — presigning must not be attempted.
|
|
131
|
+
expect(pending!.executionId).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("keeps the attachments on a FAILED pending bubble (evidence of what Retry re-sends)", () => {
|
|
135
|
+
const items = buildThreadItems(
|
|
136
|
+
[],
|
|
137
|
+
null,
|
|
138
|
+
"Here is a sketch.",
|
|
139
|
+
false,
|
|
140
|
+
undefined,
|
|
141
|
+
undefined,
|
|
142
|
+
true, // pendingMessageFailed
|
|
143
|
+
false,
|
|
144
|
+
false,
|
|
145
|
+
false,
|
|
146
|
+
pendingAttachments,
|
|
147
|
+
);
|
|
148
|
+
const pending = pendingItem(items);
|
|
149
|
+
|
|
150
|
+
expect(pending).toBeDefined();
|
|
151
|
+
expect(pending!.isFailed).toBe(true);
|
|
152
|
+
expect(pending!.attachments).toBe(pendingAttachments);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("stamps NO attachments on the pending bubble when the submit had none", () => {
|
|
156
|
+
const items = buildThreadItems(
|
|
157
|
+
[],
|
|
158
|
+
null,
|
|
159
|
+
"Plain follow-up.",
|
|
160
|
+
false,
|
|
161
|
+
undefined,
|
|
162
|
+
undefined,
|
|
163
|
+
false,
|
|
164
|
+
false,
|
|
165
|
+
false,
|
|
166
|
+
false,
|
|
167
|
+
[],
|
|
168
|
+
);
|
|
169
|
+
const pending = pendingItem(items);
|
|
170
|
+
|
|
171
|
+
expect(pending).toBeDefined();
|
|
172
|
+
expect(pending!.attachments).toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
});
|
package/src/execution/index.ts
CHANGED
|
@@ -191,6 +191,12 @@ export type { SubAgentSectionProps } from "./SubAgentSection.js";
|
|
|
191
191
|
export { MessageEntry } from "./MessageEntry.js";
|
|
192
192
|
export type { MessageEntryProps } from "./MessageEntry.js";
|
|
193
193
|
|
|
194
|
+
export { MessageAttachments } from "./MessageAttachments.js";
|
|
195
|
+
export type {
|
|
196
|
+
MessageAttachmentsProps,
|
|
197
|
+
MessageAttachmentView,
|
|
198
|
+
} from "./MessageAttachments.js";
|
|
199
|
+
|
|
194
200
|
export { MessageThread } from "./MessageThread.js";
|
|
195
201
|
export type {
|
|
196
202
|
MessageThreadProps,
|
|
@@ -5,7 +5,10 @@ import {
|
|
|
5
5
|
bytesToText,
|
|
6
6
|
normalizeGitHubContent,
|
|
7
7
|
} from "../decodeGitHubContent";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
MAX_WORKSPACE_FILE_READ_BYTES,
|
|
10
|
+
MAX_WORKSPACE_IMAGE_READ_BYTES,
|
|
11
|
+
} from "../../workspace/WorkspaceFileReader";
|
|
9
12
|
|
|
10
13
|
function utf8(s: string): Uint8Array {
|
|
11
14
|
return new TextEncoder().encode(s);
|
|
@@ -99,4 +102,42 @@ describe("normalizeGitHubContent", () => {
|
|
|
99
102
|
expect(result.truncated).toBeUndefined();
|
|
100
103
|
expect(result.text).toHaveLength(MAX_WORKSPACE_FILE_READ_BYTES);
|
|
101
104
|
});
|
|
105
|
+
|
|
106
|
+
// -------------------------------------------------------------------
|
|
107
|
+
// Whole-image delivery (stigmer/stigmer#379)
|
|
108
|
+
// -------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
it("delivers whole image bytes when the binary file has an image MIME", () => {
|
|
111
|
+
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00]);
|
|
112
|
+
const result = normalizeGitHubContent(bytes, bytes.length, "image/png");
|
|
113
|
+
|
|
114
|
+
expect(result.isBinary).toBe(true);
|
|
115
|
+
expect(result.encoding).toBe("base64");
|
|
116
|
+
expect(result.bytes).toBe(bytes);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("withholds bytes from binary content without an image MIME", () => {
|
|
120
|
+
const result = normalizeGitHubContent(new Uint8Array([0x00, 0x01]), 2, null);
|
|
121
|
+
expect(result.isBinary).toBe(true);
|
|
122
|
+
expect(result.bytes).toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("withholds bytes from an image beyond the whole-image ceiling", () => {
|
|
126
|
+
// All-zero bytes trip the NUL sniff; one byte over the ceiling means the
|
|
127
|
+
// image cannot be delivered whole, so no bytes ride along.
|
|
128
|
+
const oversized = new Uint8Array(MAX_WORKSPACE_IMAGE_READ_BYTES + 1);
|
|
129
|
+
const result = normalizeGitHubContent(oversized, oversized.length, "image/png");
|
|
130
|
+
|
|
131
|
+
expect(result.isBinary).toBe(true);
|
|
132
|
+
expect(result.bytes).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("keeps a text file that merely claims an image extension on the text path", () => {
|
|
136
|
+
const bytes = utf8("not really a png");
|
|
137
|
+
const result = normalizeGitHubContent(bytes, bytes.length, "image/png");
|
|
138
|
+
|
|
139
|
+
expect(result.text).toBe("not really a png");
|
|
140
|
+
expect(result.isBinary).toBe(false);
|
|
141
|
+
expect(result.bytes).toBeUndefined();
|
|
142
|
+
});
|
|
102
143
|
});
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
MAX_WORKSPACE_FILE_READ_BYTES,
|
|
7
|
+
MAX_WORKSPACE_IMAGE_READ_BYTES,
|
|
7
8
|
type WorkspaceFileContent,
|
|
8
9
|
} from "../workspace/WorkspaceFileReader.js";
|
|
9
10
|
|
|
@@ -51,17 +52,33 @@ export function bytesToText(bytes: Uint8Array): string | null {
|
|
|
51
52
|
* Turn decoded bytes into a {@link WorkspaceFileContent}, applying binary
|
|
52
53
|
* detection and the shared 1 MB display cap.
|
|
53
54
|
*
|
|
54
|
-
* @param bytes
|
|
55
|
-
* @param fullSize
|
|
56
|
-
*
|
|
57
|
-
*
|
|
55
|
+
* @param bytes the full decoded bytes as delivered by GitHub
|
|
56
|
+
* @param fullSize the file's true size in bytes (GitHub's `size` field), which
|
|
57
|
+
* may exceed `bytes.length` when the caller has already capped
|
|
58
|
+
* the download — reported verbatim as `size`.
|
|
59
|
+
* @param imageMime the file's image MIME type per `workspaceImageMimeType`, or
|
|
60
|
+
* `null`. When set and the bytes are binary, the *complete*
|
|
61
|
+
* bytes ride along for the viewer's image arm
|
|
62
|
+
* (stigmer/stigmer#379) — GitHub always delivers whole files
|
|
63
|
+
* on this path, so the bytes in hand ARE the image.
|
|
58
64
|
*/
|
|
59
65
|
export function normalizeGitHubContent(
|
|
60
66
|
bytes: Uint8Array,
|
|
61
67
|
fullSize: number,
|
|
68
|
+
imageMime: string | null = null,
|
|
62
69
|
): WorkspaceFileContent {
|
|
63
70
|
if (detectBinary(bytes)) {
|
|
64
|
-
|
|
71
|
+
// Gate on the binary sniff, not extension alone: a text file that merely
|
|
72
|
+
// *claims* .png keeps rendering as text below, exactly as before.
|
|
73
|
+
const deliverImage =
|
|
74
|
+
imageMime !== null && bytes.length <= MAX_WORKSPACE_IMAGE_READ_BYTES;
|
|
75
|
+
return {
|
|
76
|
+
text: null,
|
|
77
|
+
isBinary: true,
|
|
78
|
+
size: fullSize,
|
|
79
|
+
encoding: "base64",
|
|
80
|
+
...(deliverImage ? { bytes } : {}),
|
|
81
|
+
};
|
|
65
82
|
}
|
|
66
83
|
|
|
67
84
|
// Decode the full buffer, then cap the resulting *string* — capping the bytes
|