@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.
Files changed (157) hide show
  1. package/attachment/AttachmentChipList.d.ts +16 -4
  2. package/attachment/AttachmentChipList.d.ts.map +1 -1
  3. package/attachment/AttachmentChipList.js +106 -11
  4. package/attachment/AttachmentChipList.js.map +1 -1
  5. package/attachment/AttachmentImageLightbox.d.ts +39 -0
  6. package/attachment/AttachmentImageLightbox.d.ts.map +1 -0
  7. package/attachment/AttachmentImageLightbox.js +57 -0
  8. package/attachment/AttachmentImageLightbox.js.map +1 -0
  9. package/attachment/attachment-utils.d.ts +11 -0
  10. package/attachment/attachment-utils.d.ts.map +1 -1
  11. package/attachment/attachment-utils.js +23 -0
  12. package/attachment/attachment-utils.js.map +1 -1
  13. package/attachment/clipboard.d.ts +51 -0
  14. package/attachment/clipboard.d.ts.map +1 -0
  15. package/attachment/clipboard.js +89 -0
  16. package/attachment/clipboard.js.map +1 -0
  17. package/attachment/index.d.ts +6 -1
  18. package/attachment/index.d.ts.map +1 -1
  19. package/attachment/index.js +4 -1
  20. package/attachment/index.js.map +1 -1
  21. package/attachment/prepare-image.d.ts +43 -0
  22. package/attachment/prepare-image.d.ts.map +1 -0
  23. package/attachment/prepare-image.js +162 -0
  24. package/attachment/prepare-image.js.map +1 -0
  25. package/attachment/useAttachments.d.ts.map +1 -1
  26. package/attachment/useAttachments.js +23 -3
  27. package/attachment/useAttachments.js.map +1 -1
  28. package/attachment/useObjectUrl.d.ts +15 -0
  29. package/attachment/useObjectUrl.d.ts.map +1 -0
  30. package/attachment/useObjectUrl.js +25 -0
  31. package/attachment/useObjectUrl.js.map +1 -0
  32. package/attachment/vision-fit.d.ts +50 -0
  33. package/attachment/vision-fit.d.ts.map +1 -0
  34. package/attachment/vision-fit.js +76 -0
  35. package/attachment/vision-fit.js.map +1 -0
  36. package/composer/SessionComposer.d.ts +3 -1
  37. package/composer/SessionComposer.d.ts.map +1 -1
  38. package/composer/SessionComposer.js +50 -4
  39. package/composer/SessionComposer.js.map +1 -1
  40. package/conversation/ConversationMediaAttachment.d.ts +45 -0
  41. package/conversation/ConversationMediaAttachment.d.ts.map +1 -0
  42. package/conversation/ConversationMediaAttachment.js +153 -0
  43. package/conversation/ConversationMediaAttachment.js.map +1 -0
  44. package/conversation/ConversationTimelineView.d.ts +18 -1
  45. package/conversation/ConversationTimelineView.d.ts.map +1 -1
  46. package/conversation/ConversationTimelineView.js +14 -5
  47. package/conversation/ConversationTimelineView.js.map +1 -1
  48. package/conversation/ConversationsWorkbench.d.ts.map +1 -1
  49. package/conversation/ConversationsWorkbench.js +1 -1
  50. package/conversation/ConversationsWorkbench.js.map +1 -1
  51. package/conversation/conversationPresentation.d.ts +10 -3
  52. package/conversation/conversationPresentation.d.ts.map +1 -1
  53. package/conversation/conversationPresentation.js +10 -3
  54. package/conversation/conversationPresentation.js.map +1 -1
  55. package/conversation/index.d.ts +2 -0
  56. package/conversation/index.d.ts.map +1 -1
  57. package/conversation/index.js +1 -0
  58. package/conversation/index.js.map +1 -1
  59. package/conversation/useConversationMediaUrl.d.ts +65 -0
  60. package/conversation/useConversationMediaUrl.d.ts.map +1 -0
  61. package/conversation/useConversationMediaUrl.js +61 -0
  62. package/conversation/useConversationMediaUrl.js.map +1 -0
  63. package/execution/MessageAttachments.d.ts +52 -0
  64. package/execution/MessageAttachments.d.ts.map +1 -0
  65. package/execution/MessageAttachments.js +142 -0
  66. package/execution/MessageAttachments.js.map +1 -0
  67. package/execution/MessageEntry.d.ts +18 -0
  68. package/execution/MessageEntry.d.ts.map +1 -1
  69. package/execution/MessageEntry.js +5 -4
  70. package/execution/MessageEntry.js.map +1 -1
  71. package/execution/MessageThread.d.ts +15 -2
  72. package/execution/MessageThread.d.ts.map +1 -1
  73. package/execution/MessageThread.js +24 -7
  74. package/execution/MessageThread.js.map +1 -1
  75. package/execution/index.d.ts +2 -0
  76. package/execution/index.d.ts.map +1 -1
  77. package/execution/index.js +1 -0
  78. package/execution/index.js.map +1 -1
  79. package/github/decodeGitHubContent.d.ts +10 -5
  80. package/github/decodeGitHubContent.d.ts.map +1 -1
  81. package/github/decodeGitHubContent.js +21 -7
  82. package/github/decodeGitHubContent.js.map +1 -1
  83. package/github/useGitHubFileReader.d.ts.map +1 -1
  84. package/github/useGitHubFileReader.js +5 -5
  85. package/github/useGitHubFileReader.js.map +1 -1
  86. package/index.d.ts +5 -5
  87. package/index.d.ts.map +1 -1
  88. package/index.js +5 -4
  89. package/index.js.map +1 -1
  90. package/internal/form-primitives.d.ts +13 -0
  91. package/internal/form-primitives.d.ts.map +1 -1
  92. package/internal/form-primitives.js +13 -0
  93. package/internal/form-primitives.js.map +1 -1
  94. package/package.json +4 -4
  95. package/session/SessionViewer.js +1 -1
  96. package/session/SessionViewer.js.map +1 -1
  97. package/session/useSessionConversation.d.ts +8 -0
  98. package/session/useSessionConversation.d.ts.map +1 -1
  99. package/session/useSessionConversation.js +12 -2
  100. package/session/useSessionConversation.js.map +1 -1
  101. package/src/attachment/AttachmentChipList.tsx +330 -40
  102. package/src/attachment/AttachmentImageLightbox.tsx +156 -0
  103. package/src/attachment/__tests__/AttachmentChipList.test.tsx +326 -0
  104. package/src/attachment/__tests__/attachment-utils.test.ts +54 -0
  105. package/src/attachment/__tests__/clipboard.test.ts +110 -0
  106. package/src/attachment/__tests__/prepare-image.browser.test.ts +166 -0
  107. package/src/attachment/__tests__/prepare-image.test.ts +32 -0
  108. package/src/attachment/__tests__/vision-fit.test.ts +93 -0
  109. package/src/attachment/attachment-utils.ts +27 -0
  110. package/src/attachment/clipboard.ts +102 -0
  111. package/src/attachment/index.ts +13 -0
  112. package/src/attachment/prepare-image.ts +179 -0
  113. package/src/attachment/useAttachments.ts +26 -2
  114. package/src/attachment/useObjectUrl.ts +28 -0
  115. package/src/attachment/vision-fit.ts +90 -0
  116. package/src/composer/SessionComposer.tsx +73 -4
  117. package/src/composer/__tests__/SessionComposer-paste.test.tsx +263 -0
  118. package/src/composer/__tests__/SessionComposer-uploadGate.test.tsx +239 -0
  119. package/src/conversation/ConversationMediaAttachment.tsx +308 -0
  120. package/src/conversation/ConversationTimelineView.tsx +60 -10
  121. package/src/conversation/ConversationsWorkbench.tsx +2 -0
  122. package/src/conversation/__tests__/ConversationTimelineView.test.tsx +233 -6
  123. package/src/conversation/__tests__/a11y/conversation-surfaces.a11y.test.tsx +67 -1
  124. package/src/conversation/__tests__/useConversationMediaUrl.test.tsx +66 -0
  125. package/src/conversation/conversationPresentation.ts +10 -3
  126. package/src/conversation/index.ts +6 -0
  127. package/src/conversation/useConversationMediaUrl.ts +121 -0
  128. package/src/execution/MessageAttachments.tsx +364 -0
  129. package/src/execution/MessageEntry.tsx +38 -0
  130. package/src/execution/MessageThread.tsx +44 -4
  131. package/src/execution/__tests__/MessageEntry-attachments.test.tsx +228 -0
  132. package/src/execution/__tests__/buildThreadItems-attachments.test.ts +174 -0
  133. package/src/execution/index.ts +6 -0
  134. package/src/github/__tests__/decodeGitHubContent.test.ts +42 -1
  135. package/src/github/decodeGitHubContent.ts +22 -5
  136. package/src/github/useGitHubFileReader.ts +13 -3
  137. package/src/index.ts +16 -1
  138. package/src/internal/form-primitives.tsx +14 -0
  139. package/src/session/SessionViewer.tsx +1 -0
  140. package/src/session/__tests__/useSessionConversation.test.tsx +62 -0
  141. package/src/session/useSessionConversation.ts +21 -2
  142. package/src/workspace/FileViewer.tsx +109 -0
  143. package/src/workspace/WorkspaceFileReader.ts +55 -1
  144. package/src/workspace/__tests__/FileViewer.test.tsx +98 -0
  145. package/src/workspace/index.ts +2 -0
  146. package/styles.css +1 -1
  147. package/workspace/FileViewer.d.ts.map +1 -1
  148. package/workspace/FileViewer.js +40 -2
  149. package/workspace/FileViewer.js.map +1 -1
  150. package/workspace/WorkspaceFileReader.d.ts +29 -1
  151. package/workspace/WorkspaceFileReader.d.ts.map +1 -1
  152. package/workspace/WorkspaceFileReader.js +42 -0
  153. package/workspace/WorkspaceFileReader.js.map +1 -1
  154. package/workspace/index.d.ts +1 -1
  155. package/workspace/index.d.ts.map +1 -1
  156. package/workspace/index.js +1 -1
  157. 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
+ });
@@ -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 { MAX_WORKSPACE_FILE_READ_BYTES } from "../../workspace/WorkspaceFileReader";
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 the full decoded bytes as delivered by GitHub
55
- * @param fullSize the file's true size in bytes (GitHub's `size` field), which
56
- * may exceed `bytes.length` when the caller has already capped
57
- * the download — reported verbatim as `size`.
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
- return { text: null, isBinary: true, size: fullSize, encoding: "base64" };
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(base64ToBytes(file.content), file.size);
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(base64ToBytes(blob.content), blob.size);
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 and styled chip list
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