@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.
Files changed (116) hide show
  1. package/attachment/AttachmentChipList.d.ts +16 -4
  2. package/attachment/AttachmentChipList.d.ts.map +1 -1
  3. package/attachment/AttachmentChipList.js +101 -35
  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/useObjectUrl.d.ts +15 -0
  10. package/attachment/useObjectUrl.d.ts.map +1 -0
  11. package/attachment/useObjectUrl.js +25 -0
  12. package/attachment/useObjectUrl.js.map +1 -0
  13. package/conversation/ConversationMediaAttachment.d.ts +45 -0
  14. package/conversation/ConversationMediaAttachment.d.ts.map +1 -0
  15. package/conversation/ConversationMediaAttachment.js +153 -0
  16. package/conversation/ConversationMediaAttachment.js.map +1 -0
  17. package/conversation/ConversationTimelineView.d.ts +18 -1
  18. package/conversation/ConversationTimelineView.d.ts.map +1 -1
  19. package/conversation/ConversationTimelineView.js +14 -5
  20. package/conversation/ConversationTimelineView.js.map +1 -1
  21. package/conversation/ConversationsWorkbench.d.ts.map +1 -1
  22. package/conversation/ConversationsWorkbench.js +1 -1
  23. package/conversation/ConversationsWorkbench.js.map +1 -1
  24. package/conversation/conversationPresentation.d.ts +10 -3
  25. package/conversation/conversationPresentation.d.ts.map +1 -1
  26. package/conversation/conversationPresentation.js +10 -3
  27. package/conversation/conversationPresentation.js.map +1 -1
  28. package/conversation/index.d.ts +2 -0
  29. package/conversation/index.d.ts.map +1 -1
  30. package/conversation/index.js +1 -0
  31. package/conversation/index.js.map +1 -1
  32. package/conversation/useConversationMediaUrl.d.ts +65 -0
  33. package/conversation/useConversationMediaUrl.d.ts.map +1 -0
  34. package/conversation/useConversationMediaUrl.js +61 -0
  35. package/conversation/useConversationMediaUrl.js.map +1 -0
  36. package/execution/MessageAttachments.d.ts +52 -0
  37. package/execution/MessageAttachments.d.ts.map +1 -0
  38. package/execution/MessageAttachments.js +142 -0
  39. package/execution/MessageAttachments.js.map +1 -0
  40. package/execution/MessageEntry.d.ts +18 -0
  41. package/execution/MessageEntry.d.ts.map +1 -1
  42. package/execution/MessageEntry.js +5 -4
  43. package/execution/MessageEntry.js.map +1 -1
  44. package/execution/MessageThread.d.ts +15 -2
  45. package/execution/MessageThread.d.ts.map +1 -1
  46. package/execution/MessageThread.js +24 -7
  47. package/execution/MessageThread.js.map +1 -1
  48. package/execution/index.d.ts +2 -0
  49. package/execution/index.d.ts.map +1 -1
  50. package/execution/index.js +1 -0
  51. package/execution/index.js.map +1 -1
  52. package/github/decodeGitHubContent.d.ts +10 -5
  53. package/github/decodeGitHubContent.d.ts.map +1 -1
  54. package/github/decodeGitHubContent.js +21 -7
  55. package/github/decodeGitHubContent.js.map +1 -1
  56. package/github/useGitHubFileReader.d.ts.map +1 -1
  57. package/github/useGitHubFileReader.js +5 -5
  58. package/github/useGitHubFileReader.js.map +1 -1
  59. package/index.d.ts +3 -3
  60. package/index.d.ts.map +1 -1
  61. package/index.js +2 -2
  62. package/index.js.map +1 -1
  63. package/internal/form-primitives.d.ts +13 -0
  64. package/internal/form-primitives.d.ts.map +1 -1
  65. package/internal/form-primitives.js +13 -0
  66. package/internal/form-primitives.js.map +1 -1
  67. package/package.json +4 -4
  68. package/session/SessionViewer.js +1 -1
  69. package/session/SessionViewer.js.map +1 -1
  70. package/session/useSessionConversation.d.ts +8 -0
  71. package/session/useSessionConversation.d.ts.map +1 -1
  72. package/session/useSessionConversation.js +12 -2
  73. package/session/useSessionConversation.js.map +1 -1
  74. package/src/attachment/AttachmentChipList.tsx +318 -72
  75. package/src/attachment/AttachmentImageLightbox.tsx +156 -0
  76. package/src/attachment/__tests__/AttachmentChipList.test.tsx +240 -17
  77. package/src/attachment/useObjectUrl.ts +28 -0
  78. package/src/conversation/ConversationMediaAttachment.tsx +308 -0
  79. package/src/conversation/ConversationTimelineView.tsx +60 -10
  80. package/src/conversation/ConversationsWorkbench.tsx +2 -0
  81. package/src/conversation/__tests__/ConversationTimelineView.test.tsx +233 -6
  82. package/src/conversation/__tests__/a11y/conversation-surfaces.a11y.test.tsx +67 -1
  83. package/src/conversation/__tests__/useConversationMediaUrl.test.tsx +66 -0
  84. package/src/conversation/conversationPresentation.ts +10 -3
  85. package/src/conversation/index.ts +6 -0
  86. package/src/conversation/useConversationMediaUrl.ts +121 -0
  87. package/src/execution/MessageAttachments.tsx +364 -0
  88. package/src/execution/MessageEntry.tsx +38 -0
  89. package/src/execution/MessageThread.tsx +44 -4
  90. package/src/execution/__tests__/MessageEntry-attachments.test.tsx +228 -0
  91. package/src/execution/__tests__/buildThreadItems-attachments.test.ts +174 -0
  92. package/src/execution/index.ts +6 -0
  93. package/src/github/__tests__/decodeGitHubContent.test.ts +42 -1
  94. package/src/github/decodeGitHubContent.ts +22 -5
  95. package/src/github/useGitHubFileReader.ts +13 -3
  96. package/src/index.ts +5 -0
  97. package/src/internal/form-primitives.tsx +14 -0
  98. package/src/session/SessionViewer.tsx +1 -0
  99. package/src/session/__tests__/useSessionConversation.test.tsx +62 -0
  100. package/src/session/useSessionConversation.ts +21 -2
  101. package/src/workspace/FileViewer.tsx +109 -0
  102. package/src/workspace/WorkspaceFileReader.ts +55 -1
  103. package/src/workspace/__tests__/FileViewer.test.tsx +98 -0
  104. package/src/workspace/index.ts +2 -0
  105. package/styles.css +1 -1
  106. package/workspace/FileViewer.d.ts.map +1 -1
  107. package/workspace/FileViewer.js +40 -2
  108. package/workspace/FileViewer.js.map +1 -1
  109. package/workspace/WorkspaceFileReader.d.ts +29 -1
  110. package/workspace/WorkspaceFileReader.d.ts.map +1 -1
  111. package/workspace/WorkspaceFileReader.js +42 -0
  112. package/workspace/WorkspaceFileReader.js.map +1 -1
  113. package/workspace/index.d.ts +1 -1
  114. package/workspace/index.d.ts.map +1 -1
  115. package/workspace/index.js +1 -1
  116. 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
- <MessageEntryComponent message={message} />
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
+ });
@@ -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