@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
@@ -260,6 +260,68 @@ describe("useSessionConversation", () => {
260
260
  expect(result.current.pendingUserMessage).toBe("Hello agent");
261
261
  });
262
262
 
263
+ it("sendFollowUp carries the submit's attachments as pendingAttachments, cleared with the first snapshot", async () => {
264
+ methods.listBySession.mockResolvedValue({ entries: [] });
265
+ const newExec = makeExecution("new-exec", ExecutionPhase.EXECUTION_PENDING);
266
+ newExec.metadata!.id = "new-exec";
267
+ methods.executionCreate.mockResolvedValue(newExec);
268
+
269
+ const newStream = createControllableStream<AgentExecution>();
270
+ methods.subscribe.mockReturnValue(newStream.generator);
271
+
272
+ const attachments = [
273
+ { filename: "sketch.png", storageKey: "attachments/01AAA/sketch.png", contentType: "image/png" },
274
+ ];
275
+
276
+ const { result } = renderHook(
277
+ () => useSessionConversation("session-1", "org"),
278
+ { wrapper: createWrapper(mockStigmer) },
279
+ );
280
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
281
+
282
+ await act(async () => {
283
+ await result.current.sendFollowUp("Here is a sketch", { attachments });
284
+ });
285
+
286
+ // The optimistic turn carries BOTH facets — text and files (#372).
287
+ expect(result.current.pendingUserMessage).toBe("Here is a sketch");
288
+ expect(result.current.pendingAttachments).toBe(attachments);
289
+
290
+ act(() => {
291
+ newStream.push(
292
+ makeExecution("new-exec", ExecutionPhase.EXECUTION_IN_PROGRESS),
293
+ );
294
+ });
295
+
296
+ // Both clear together once the real execution record takes over.
297
+ await waitFor(() => {
298
+ expect(result.current.pendingUserMessage).toBeNull();
299
+ expect(result.current.pendingAttachments).toBeNull();
300
+ });
301
+ });
302
+
303
+ it("keeps pendingAttachments when the send fails (the chips are what Retry re-sends)", async () => {
304
+ methods.listBySession.mockResolvedValue({ entries: [] });
305
+ methods.executionCreate.mockRejectedValue(new Error("create boom"));
306
+
307
+ const attachments = [
308
+ { filename: "notes.txt", storageKey: "attachments/01BBB/notes.txt", contentType: "text/plain" },
309
+ ];
310
+
311
+ const { result } = renderHook(
312
+ () => useSessionConversation("session-1", "org"),
313
+ { wrapper: createWrapper(mockStigmer) },
314
+ );
315
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
316
+
317
+ await act(async () => {
318
+ await result.current.sendFollowUp("keep my files", { attachments });
319
+ });
320
+
321
+ expect(result.current.sendError?.message).toBe("create boom");
322
+ expect(result.current.pendingAttachments).toBe(attachments);
323
+ });
324
+
263
325
  it("sendFollowUp creates execution via the SDK", async () => {
264
326
  methods.listBySession.mockResolvedValue({ entries: [] });
265
327
  const newExec = makeExecution("new-exec", ExecutionPhase.EXECUTION_PENDING);
@@ -213,6 +213,14 @@ export interface UseSessionConversationReturn {
213
213
  * {@link sendError} to render the turn as failed with a retry control.
214
214
  */
215
215
  readonly pendingUserMessage: string | null;
216
+ /**
217
+ * The attachments submitted with {@link pendingUserMessage}, so the
218
+ * optimistic bubble shows the turn's files, not just its text
219
+ * (stigmer/stigmer#372). Set and cleared in lockstep with the message —
220
+ * including retention on a failed send, where the chips are the evidence
221
+ * of what Retry will re-send. Wire to `MessageThread.pendingAttachments`.
222
+ */
223
+ readonly pendingAttachments: readonly AttachmentInput[] | null;
216
224
 
217
225
  /** Current workspace entries from the session spec. Empty array when session is not loaded. */
218
226
  readonly workspaceEntries: readonly ProtoWorkspaceEntry[];
@@ -439,6 +447,12 @@ export function useSessionConversation(
439
447
  const [pendingUserMessage, setPendingUserMessage] = useState<string | null>(
440
448
  null,
441
449
  );
450
+ // The pending message's attachments, kept in lockstep with the text: one
451
+ // pending turn, two facets. Held separately (not an object) so existing
452
+ // pendingUserMessage consumers keep their string contract.
453
+ const [pendingAttachments, setPendingAttachments] = useState<
454
+ readonly AttachmentInput[] | null
455
+ >(null);
442
456
  // Dedicated send-failure state, distinct from the create hook's internal
443
457
  // error so it can also cover the session.update() path. The last send's
444
458
  // arguments are captured for an exact retry.
@@ -507,8 +521,9 @@ export function useSessionConversation(
507
521
  useEffect(() => {
508
522
  if (!stream.execution) return;
509
523
  if (pendingUserMessage) setPendingUserMessage(null);
524
+ if (pendingAttachments) setPendingAttachments(null);
510
525
  if (sendError) setSendError(null);
511
- }, [pendingUserMessage, sendError, stream.execution]);
526
+ }, [pendingUserMessage, pendingAttachments, sendError, stream.execution]);
512
527
 
513
528
  // Refetch executions when stream reaches a terminal phase so the
514
529
  // fetched list reflects the completed status and listActiveId clears.
@@ -620,6 +635,7 @@ export function useSessionConversation(
620
635
  // renders the failed-with-retry bubble instead of vanishing.
621
636
  if (!options?.buildFromPlan) {
622
637
  setPendingUserMessage(message);
638
+ setPendingAttachments(options?.attachments ?? null);
623
639
  }
624
640
 
625
641
  try {
@@ -666,8 +682,10 @@ export function useSessionConversation(
666
682
  // pendingUserMessage) so the turn renders as failed-with-retry instead
667
683
  // of vanishing. Covers both the update() and create() paths. For a
668
684
  // build turn this is the FIRST time the message is set — failure is
669
- // the one case where its label must become visible.
685
+ // the one case where its label must become visible. The attachments
686
+ // ride along: the failed bubble's chips are what Retry will re-send.
670
687
  setPendingUserMessage(message);
688
+ setPendingAttachments(options?.attachments ?? null);
671
689
  setSendError(toError(err));
672
690
  if (process.env.NODE_ENV !== "production") {
673
691
  console.error("[useSessionConversation] sendFollowUp failed:", err);
@@ -753,6 +771,7 @@ export function useSessionConversation(
753
771
  retryLastSend,
754
772
 
755
773
  pendingUserMessage,
774
+ pendingAttachments,
756
775
 
757
776
  workspaceEntries,
758
777
  mcpServerUsages,
@@ -5,6 +5,7 @@ import {
5
5
  useCallback,
6
6
  useEffect,
7
7
  useImperativeHandle,
8
+ useMemo,
8
9
  useRef,
9
10
  useState,
10
11
  type KeyboardEvent,
@@ -15,15 +16,19 @@ import {
15
16
  FileChangeCaptureLevel,
16
17
  FileChangeType,
17
18
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
19
+ import { AttachmentImageLightbox } from "../attachment/AttachmentImageLightbox.js";
20
+ import { useObjectUrl } from "../attachment/useObjectUrl.js";
18
21
  import { ArtifactContentRenderer } from "../execution/ArtifactContentRenderer.js";
19
22
  import { FileChangeDiff } from "../execution/FileChangesView.js";
20
23
  import { useFileChangeContent } from "../execution/useFileChangeContent.js";
24
+ import { UNSTYLED_BUTTON } from "../internal/form-primitives.js";
21
25
  import type { RevealTarget } from "../internal/useRevealLine.js";
22
26
  import type { SelectedWorkspaceFile } from "../internal/store/workspace-file-selection-store.js";
23
27
  import { useWorkspaceFileContent } from "./useWorkspaceFileContent.js";
24
28
  import type { WorkspaceEntry } from "./useWorkspaceEntries.js";
25
29
  import {
26
30
  WorkspaceFileNotFoundError,
31
+ workspaceImageMimeType,
27
32
  type WorkspaceFileReader,
28
33
  } from "./WorkspaceFileReader.js";
29
34
 
@@ -431,6 +436,23 @@ function FileViewerBody({
431
436
  return <MessageState>Content not available for preview.</MessageState>;
432
437
  }
433
438
 
439
+ // A binary that is a renderable image, delivered whole by the reader,
440
+ // shows the picture instead of the binary notice (stigmer/stigmer#379).
441
+ // The MIME gate re-derives from the path: bytes without a recognized image
442
+ // extension still fall through to the notice.
443
+ const imageMime = workspaceImageMimeType(basename);
444
+ if (content.bytes && imageMime) {
445
+ return (
446
+ <ImageFileContent
447
+ bytes={content.bytes}
448
+ mime={imageMime}
449
+ basename={basename}
450
+ size={content.size}
451
+ change={change}
452
+ />
453
+ );
454
+ }
455
+
434
456
  if (content.isBinary) {
435
457
  return (
436
458
  <MessageState>
@@ -469,6 +491,93 @@ function FileViewerBody({
469
491
  );
470
492
  }
471
493
 
494
+ /**
495
+ * Inline image rendering for a workspace file whose reader delivered whole
496
+ * image bytes (stigmer/stigmer#379). The bytes become an object URL for the
497
+ * component's lifetime; clicking the image opens the shared
498
+ * {@link AttachmentImageLightbox} at full size — the same viewer attachment
499
+ * chips and conversation media use, so pictures behave identically across
500
+ * surfaces. If the browser cannot decode the bytes (a corrupt file behind an
501
+ * image extension), the component degrades to the binary notice — never a
502
+ * broken-image glyph.
503
+ */
504
+ function ImageFileContent({
505
+ bytes,
506
+ mime,
507
+ basename,
508
+ size,
509
+ change,
510
+ }: {
511
+ readonly bytes: Uint8Array;
512
+ readonly mime: string;
513
+ readonly basename: string;
514
+ readonly size: number;
515
+ readonly change?: FileChange;
516
+ }) {
517
+ // Memo (not effect) is correct for the Blob — it holds no OS resource; the
518
+ // object URL over it is effect-managed inside useObjectUrl.
519
+ const blob = useMemo(() => new Blob([bytes as BlobPart], { type: mime }), [bytes, mime]);
520
+ const url = useObjectUrl(blob);
521
+ const [loadFailed, setLoadFailed] = useState(false);
522
+ const [lightboxOpen, setLightboxOpen] = useState(false);
523
+
524
+ if (loadFailed) {
525
+ return (
526
+ <MessageState>
527
+ Binary file — not shown{size ? ` (${formatBytes(size)})` : ""}.
528
+ </MessageState>
529
+ );
530
+ }
531
+
532
+ return (
533
+ <>
534
+ {change && (
535
+ <p className="border-b border-border-muted px-4 py-1.5 text-[0.65rem] text-muted-foreground">
536
+ Live — may differ from the reviewed change.
537
+ </p>
538
+ )}
539
+ <div className="flex flex-col items-center gap-2 p-4">
540
+ {url ? (
541
+ // UNSTYLED_BUTTON is load-bearing: without it the button shows the
542
+ // UA's default box in preflight-less hosts (see the constant).
543
+ <button
544
+ type="button"
545
+ onClick={() => setLightboxOpen(true)}
546
+ aria-label={`Preview ${basename} at full size`}
547
+ className={cn(
548
+ UNSTYLED_BUTTON,
549
+ "max-w-full rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
550
+ )}
551
+ >
552
+ <img
553
+ src={url}
554
+ alt={basename}
555
+ onError={() => setLoadFailed(true)}
556
+ className="max-h-[70vh] max-w-full rounded-md border border-border object-contain"
557
+ />
558
+ </button>
559
+ ) : (
560
+ // One-frame placeholder until the object-URL effect runs.
561
+ <div
562
+ className="h-48 w-72 max-w-full animate-pulse rounded-md bg-muted"
563
+ aria-hidden="true"
564
+ />
565
+ )}
566
+ <p className="text-[0.65rem] tabular-nums text-muted-foreground">
567
+ {mime}
568
+ {size ? ` · ${formatBytes(size)}` : ""}
569
+ </p>
570
+ </div>
571
+ <AttachmentImageLightbox
572
+ src={url}
573
+ filename={basename}
574
+ open={lightboxOpen}
575
+ onClose={() => setLightboxOpen(false)}
576
+ />
577
+ </>
578
+ );
579
+ }
580
+
472
581
  /**
473
582
  * The captured-content fallback: renders a change's after-side text (the
474
583
  * bytes the session already holds — inline or offloaded to artifact storage)
@@ -12,12 +12,57 @@ import type { WorkspaceEntry } from "./useWorkspaceEntries.js";
12
12
  */
13
13
  export const MAX_WORKSPACE_FILE_READ_BYTES = 1_048_576; // 1 MiB
14
14
 
15
+ /**
16
+ * Byte ceiling for delivering a workspace *image* in full (stigmer/stigmer#379).
17
+ *
18
+ * Deliberately larger than {@link MAX_WORKSPACE_FILE_READ_BYTES}: text
19
+ * degrades gracefully when truncated, an image does not — a partial PNG is
20
+ * useless, so readers deliver image bytes whole or not at all. 10 MiB matches
21
+ * the GitHub reader's existing blob-fetch ceiling; the desktop native reader
22
+ * mirrors it as `MAX_IMAGE_READ_BYTES` in `src-tauri/src/workspace.rs`.
23
+ */
24
+ export const MAX_WORKSPACE_IMAGE_READ_BYTES = 10 * 1024 * 1024; // 10 MiB
25
+
26
+ /**
27
+ * Raster image formats the viewer renders inline, keyed by file extension.
28
+ *
29
+ * SVG is deliberately absent: it decodes as UTF-8 and flows through the text
30
+ * path, where a workspace browser wants it — it is source to inspect, not a
31
+ * picture (matching editor convention). The desktop native reader mirrors
32
+ * this list as `IMAGE_EXTENSIONS` in `src-tauri/src/workspace.rs`.
33
+ */
34
+ const IMAGE_MIME_BY_EXTENSION: Readonly<Record<string, string>> = {
35
+ png: "image/png",
36
+ jpg: "image/jpeg",
37
+ jpeg: "image/jpeg",
38
+ gif: "image/gif",
39
+ webp: "image/webp",
40
+ avif: "image/avif",
41
+ bmp: "image/bmp",
42
+ ico: "image/x-icon",
43
+ };
44
+
45
+ /**
46
+ * MIME type for a workspace path the viewer can render as an inline image,
47
+ * or `null` for everything else. The single policy point shared by readers
48
+ * (deciding whether to attach {@link WorkspaceFileContent.bytes}) and the
49
+ * viewer (deciding whether to render an image arm) — extension-based, since
50
+ * workspace reads have no server-provided content type.
51
+ */
52
+ export function workspaceImageMimeType(path: string): string | null {
53
+ const dot = path.lastIndexOf(".");
54
+ if (dot === -1) return null;
55
+ const ext = path.slice(dot + 1).toLowerCase();
56
+ return IMAGE_MIME_BY_EXTENSION[ext] ?? null;
57
+ }
58
+
15
59
  /**
16
60
  * Decoded content (plus metadata) for a single workspace file.
17
61
  *
18
62
  * `text` is `null` whenever the bytes are not displayable as UTF-8 — either
19
63
  * binary (`isBinary`) or an undecodable byte sequence (`encoding: "unknown"`).
20
- * The renderer uses `isBinary`/`text === null` for its fallback state.
64
+ * The renderer uses `isBinary`/`text === null` for its fallback state, except
65
+ * when `bytes` carries a renderable image.
21
66
  */
22
67
  export interface WorkspaceFileContent {
23
68
  /** Decoded UTF-8 text, or `null` when binary or undecodable. */
@@ -30,6 +75,15 @@ export interface WorkspaceFileContent {
30
75
  readonly encoding: "utf-8" | "base64" | "none" | "unknown";
31
76
  /** `true` when `text` holds only the first {@link MAX_WORKSPACE_FILE_READ_BYTES} bytes. */
32
77
  readonly truncated?: boolean;
78
+ /**
79
+ * The complete raw bytes, populated **only** for binary files that are
80
+ * renderable images ({@link workspaceImageMimeType}) within
81
+ * {@link MAX_WORKSPACE_IMAGE_READ_BYTES} — never a channel for arbitrary
82
+ * binaries, and never partial (a truncated image cannot render). Optional
83
+ * and additive: readers that predate it remain valid, and the viewer falls
84
+ * back to the binary notice when it is absent (stigmer/stigmer#379).
85
+ */
86
+ readonly bytes?: Uint8Array;
33
87
  }
34
88
 
35
89
  /**
@@ -159,6 +159,104 @@ describe("FileViewer", () => {
159
159
  );
160
160
  });
161
161
 
162
+ // ---------------------------------------------------------------------
163
+ // Image arm (stigmer/stigmer#379) — bytes delivered whole by the reader
164
+ // render as a picture; every gate failure falls back to the binary notice.
165
+ // ---------------------------------------------------------------------
166
+
167
+ const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]);
168
+
169
+ function imageContent(): WorkspaceFileContent {
170
+ return {
171
+ text: null,
172
+ isBinary: true,
173
+ size: PNG_BYTES.length,
174
+ encoding: "base64",
175
+ bytes: PNG_BYTES,
176
+ };
177
+ }
178
+
179
+ it("renders an image from delivered bytes instead of the binary notice", async () => {
180
+ render(
181
+ <FileViewer
182
+ selectedFile={{ entryId: "e1", path: "assets/logo.png" }}
183
+ entries={ENTRIES}
184
+ reader={readerFor(imageContent())}
185
+ />,
186
+ );
187
+
188
+ await waitFor(() => {
189
+ const img = document.querySelector("img");
190
+ expect(img).toBeTruthy();
191
+ expect(img!.getAttribute("src")).toContain("blob:");
192
+ expect(img!.getAttribute("alt")).toBe("logo.png");
193
+ });
194
+ expect(screen.queryByText(/Binary file/i)).toBeNull();
195
+ // The click-to-open affordance is present and named.
196
+ expect(
197
+ screen.getByRole("button", { name: "Preview logo.png at full size" }),
198
+ ).toBeTruthy();
199
+ });
200
+
201
+ it("opens the shared lightbox at full size when the image is clicked", async () => {
202
+ // happy-dom does not implement the native dialog show/close methods.
203
+ HTMLDialogElement.prototype.showModal = function showModal() {
204
+ this.open = true;
205
+ };
206
+ HTMLDialogElement.prototype.close = function close() {
207
+ this.open = false;
208
+ };
209
+
210
+ render(
211
+ <FileViewer
212
+ selectedFile={{ entryId: "e1", path: "assets/logo.png" }}
213
+ entries={ENTRIES}
214
+ reader={readerFor(imageContent())}
215
+ />,
216
+ );
217
+
218
+ await waitFor(() => expect(document.querySelector("img")).toBeTruthy());
219
+ fireEvent.click(
220
+ screen.getByRole("button", { name: "Preview logo.png at full size" }),
221
+ );
222
+
223
+ const dialog = document.querySelector("dialog");
224
+ expect(dialog).toBeTruthy();
225
+ expect(dialog!.getAttribute("aria-label")).toBe("Preview logo.png");
226
+ });
227
+
228
+ it("keeps the binary notice when bytes arrive for a non-image path", async () => {
229
+ // The viewer re-derives the MIME gate from the path: bytes on something
230
+ // without a recognized image extension never render as a picture.
231
+ render(
232
+ <FileViewer
233
+ selectedFile={{ entryId: "e1", path: "data.bin" }}
234
+ entries={ENTRIES}
235
+ reader={readerFor(imageContent())}
236
+ />,
237
+ );
238
+
239
+ await waitFor(() => expect(screen.getByText(/Binary file/i)).toBeTruthy());
240
+ expect(document.querySelector("img")).toBeNull();
241
+ });
242
+
243
+ it("degrades to the binary notice when the browser cannot decode the bytes", async () => {
244
+ render(
245
+ <FileViewer
246
+ selectedFile={{ entryId: "e1", path: "assets/logo.png" }}
247
+ entries={ENTRIES}
248
+ reader={readerFor(imageContent())}
249
+ />,
250
+ );
251
+
252
+ await waitFor(() => expect(document.querySelector("img")).toBeTruthy());
253
+ fireEvent.error(document.querySelector("img")!);
254
+
255
+ // Never a broken-image glyph — the honest binary state instead.
256
+ await waitFor(() => expect(screen.getByText(/Binary file/i)).toBeTruthy());
257
+ expect(document.querySelector("img")).toBeNull();
258
+ });
259
+
162
260
  it("shows the error state with a working Retry", async () => {
163
261
  let attempt = 0;
164
262
  const reader: WorkspaceFileReader = vi.fn(async () => {
@@ -9,7 +9,9 @@ export type {
9
9
  } from "./WorkspaceFileLister.js";
10
10
  export {
11
11
  MAX_WORKSPACE_FILE_READ_BYTES,
12
+ MAX_WORKSPACE_IMAGE_READ_BYTES,
12
13
  WorkspaceFileNotFoundError,
14
+ workspaceImageMimeType,
13
15
  } from "./WorkspaceFileReader.js";
14
16
  export type {
15
17
  WorkspaceFileContent,