@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,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
|
|
@@ -4,6 +4,7 @@ import { useCallback } from "react";
|
|
|
4
4
|
import type { WorkspaceEntry } from "../workspace/useWorkspaceEntries.js";
|
|
5
5
|
import {
|
|
6
6
|
WorkspaceFileNotFoundError,
|
|
7
|
+
workspaceImageMimeType,
|
|
7
8
|
type WorkspaceFileContent,
|
|
8
9
|
type WorkspaceFileReader,
|
|
9
10
|
} from "../workspace/WorkspaceFileReader.js";
|
|
@@ -116,12 +117,16 @@ export function useGitHubFileReader(
|
|
|
116
117
|
|
|
117
118
|
const file = data as GitHubContentsFile;
|
|
118
119
|
if (file.encoding === "base64") {
|
|
119
|
-
return normalizeGitHubContent(
|
|
120
|
+
return normalizeGitHubContent(
|
|
121
|
+
base64ToBytes(file.content),
|
|
122
|
+
file.size,
|
|
123
|
+
workspaceImageMimeType(path),
|
|
124
|
+
);
|
|
120
125
|
}
|
|
121
126
|
|
|
122
127
|
// encoding: "none" is GitHub's signal that the file exceeds the Contents
|
|
123
128
|
// API's ~1 MB cap; the bytes live only behind the Blob API.
|
|
124
|
-
return readViaBlob(parsed, file, token);
|
|
129
|
+
return readViaBlob(parsed, file, path, token);
|
|
125
130
|
},
|
|
126
131
|
[token],
|
|
127
132
|
);
|
|
@@ -138,6 +143,7 @@ export function useGitHubFileReader(
|
|
|
138
143
|
async function readViaBlob(
|
|
139
144
|
parsed: { owner: string; repo: string },
|
|
140
145
|
file: GitHubContentsFile,
|
|
146
|
+
path: string,
|
|
141
147
|
token: string,
|
|
142
148
|
): Promise<WorkspaceFileContent> {
|
|
143
149
|
// Above the ceiling we never download — GitHub already told us the size, so
|
|
@@ -161,5 +167,9 @@ async function readViaBlob(
|
|
|
161
167
|
}
|
|
162
168
|
|
|
163
169
|
const blob: GitHubBlob = await resp.json();
|
|
164
|
-
return normalizeGitHubContent(
|
|
170
|
+
return normalizeGitHubContent(
|
|
171
|
+
base64ToBytes(blob.content),
|
|
172
|
+
blob.size,
|
|
173
|
+
workspaceImageMimeType(path),
|
|
174
|
+
);
|
|
165
175
|
}
|
package/src/index.ts
CHANGED
|
@@ -71,7 +71,9 @@ export {
|
|
|
71
71
|
useWorkspaceContentSearch,
|
|
72
72
|
useWorkspaceSources,
|
|
73
73
|
MAX_WORKSPACE_FILE_READ_BYTES,
|
|
74
|
+
MAX_WORKSPACE_IMAGE_READ_BYTES,
|
|
74
75
|
WorkspaceFileNotFoundError,
|
|
76
|
+
workspaceImageMimeType,
|
|
75
77
|
WorkspaceEditor,
|
|
76
78
|
WorkspaceFileSearch,
|
|
77
79
|
WorkspaceContentSearch,
|
|
@@ -463,21 +465,31 @@ export type {
|
|
|
463
465
|
export type { ExecutionArtifact } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/artifact_pb";
|
|
464
466
|
export { ExecutionArtifactKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
465
467
|
|
|
466
|
-
// Attachment — file upload behavior hook
|
|
468
|
+
// Attachment — file upload behavior hook, styled chip list, clipboard paste,
|
|
469
|
+
// and vision-resolution image preparation
|
|
467
470
|
export {
|
|
468
471
|
useAttachments,
|
|
469
472
|
AttachmentChipList,
|
|
470
473
|
MAX_ATTACHMENT_BYTES,
|
|
474
|
+
MAX_VISION_LONG_EDGE_PX,
|
|
475
|
+
MAX_VISION_PIXELS,
|
|
471
476
|
detectContentType,
|
|
477
|
+
exceedsVisionResolution,
|
|
478
|
+
extractClipboardFiles,
|
|
479
|
+
fitToVisionResolution,
|
|
472
480
|
formatFileSize,
|
|
481
|
+
prepareImageForVision,
|
|
482
|
+
uniquifyFilename,
|
|
473
483
|
validateAttachmentSize,
|
|
474
484
|
} from "./attachment/index.js";
|
|
475
485
|
export type {
|
|
476
486
|
AttachmentPhase,
|
|
477
487
|
AttachmentEntry,
|
|
488
|
+
ClipboardFilesSource,
|
|
478
489
|
UseAttachmentsOptions,
|
|
479
490
|
UseAttachmentsReturn,
|
|
480
491
|
AttachmentChipListProps,
|
|
492
|
+
VisionFitSize,
|
|
481
493
|
} from "./attachment/index.js";
|
|
482
494
|
|
|
483
495
|
// File Reference — workspace file-reference behavior hook and styled chip list
|
|
@@ -1369,6 +1381,7 @@ export {
|
|
|
1369
1381
|
ConversationComposer,
|
|
1370
1382
|
useConversation,
|
|
1371
1383
|
useConversationList,
|
|
1384
|
+
useConversationMediaUrl,
|
|
1372
1385
|
useConversationTimeline,
|
|
1373
1386
|
useConversationParticipation,
|
|
1374
1387
|
useConversationsWantsHumanCount,
|
|
@@ -1399,6 +1412,8 @@ export type {
|
|
|
1399
1412
|
UseConversationReturn,
|
|
1400
1413
|
UseConversationListOptions,
|
|
1401
1414
|
UseConversationListReturn,
|
|
1415
|
+
UseConversationMediaUrlOptions,
|
|
1416
|
+
UseConversationMediaUrlReturn,
|
|
1402
1417
|
UseConversationTimelineOptions,
|
|
1403
1418
|
UseConversationTimelineReturn,
|
|
1404
1419
|
ConversationCommand,
|
|
@@ -8,6 +8,20 @@ import { cn } from "@stigmer/theme";
|
|
|
8
8
|
// Internal — never exported from the package barrel.
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Neutralizes the UA's default `<button>` styling (buttonface background,
|
|
13
|
+
* padding, default cursor). The SDK's scoped preflight in `styles.css`
|
|
14
|
+
* resets only borders/box-sizing; in the console and desktop app the host's
|
|
15
|
+
* global Tailwind preflight covers the rest, but a preflight-less host
|
|
16
|
+
* (docs tours, third-party embeds) would otherwise render every bare button
|
|
17
|
+
* as a gray UA box. Font is NOT reset here — callers that render text set
|
|
18
|
+
* `[font:inherit]` or explicit text utilities themselves.
|
|
19
|
+
*
|
|
20
|
+
* Applies to buttons that are styled entirely by their content (chips,
|
|
21
|
+
* icon-only affordances), not to the themed button components.
|
|
22
|
+
*/
|
|
23
|
+
export const UNSTYLED_BUTTON = "cursor-pointer bg-transparent p-0";
|
|
24
|
+
|
|
11
25
|
/** Standard text-input styling for the operator consoles. */
|
|
12
26
|
export const INPUT_CLASSES = cn(
|
|
13
27
|
"w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-xs text-foreground",
|
|
@@ -795,6 +795,7 @@ const ConversationColumn = memo(function ConversationColumn({
|
|
|
795
795
|
executions={conv.completedExecutions}
|
|
796
796
|
activeStreamExecution={conv.activeStreamExecution}
|
|
797
797
|
pendingUserMessage={conv.pendingUserMessage}
|
|
798
|
+
pendingAttachments={conv.pendingAttachments}
|
|
798
799
|
pendingMessageFailed={!!conv.sendError && !!conv.pendingUserMessage}
|
|
799
800
|
// Observers get a pure transcript: every interaction affordance
|
|
800
801
|
// (retry, approvals, edit, build-from-plan) is opt-in via its
|