@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
@@ -1,9 +1,12 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useState } from "react";
3
+ import { useState } from "react";
4
4
  import { cn } from "@stigmer/theme";
5
5
  import type { AttachmentEntry } from "./useAttachments.js";
6
6
  import { formatFileSize } from "./attachment-utils.js";
7
+ import { useObjectUrl } from "./useObjectUrl.js";
8
+ import { AttachmentImageLightbox } from "./AttachmentImageLightbox.js";
9
+ import { UNSTYLED_BUTTON } from "../internal/form-primitives.js";
7
10
 
8
11
  /** Props for {@link AttachmentChipList}. */
9
12
  export interface AttachmentChipListProps {
@@ -13,15 +16,27 @@ export interface AttachmentChipListProps {
13
16
  readonly onRemove: (id: string) => void;
14
17
  /** Retry callback for failed uploads (by entry ID). */
15
18
  readonly onRetry: (id: string) => void;
16
- /** Disables remove and retry actions. */
19
+ /**
20
+ * Called when the user clicks an image chip's preview target. When
21
+ * provided, it **replaces** the built-in lightbox — platform builders
22
+ * route the click to their own viewer. Omit for the default behavior:
23
+ * a full-size image lightbox over the locally-held file bytes.
24
+ */
25
+ readonly onPreview?: (entry: AttachmentEntry) => void;
26
+ /** Disables remove and retry actions. Preview stays active — it is read-only. */
17
27
  readonly disabled?: boolean;
18
28
  /** Additional CSS class names for the root container. */
19
29
  readonly className?: string;
20
30
  }
21
31
 
22
32
  /**
23
- * Renders a horizontal list of compact attachment chips showing
24
- * filename, size, upload status, and remove/retry actions.
33
+ * Renders a horizontal list of attachment chips: image attachments as
34
+ * preview-only thumbnail tiles (the image IS the chip — no filename
35
+ * text; visible from the moment of paste, through upload, and on error
36
+ * — stigmer/stigmer#371, #372), other files as compact filename chips.
37
+ * Filename, size, and upload status stay available on every chip via
38
+ * the tooltip and the accessible label; clicking an image tile opens
39
+ * the full image.
25
40
  *
26
41
  * Designed to sit between the context chips and toolbar in
27
42
  * {@link SessionComposer}, but usable standalone by platform builders
@@ -48,82 +63,216 @@ export function AttachmentChipList({
48
63
  entries,
49
64
  onRemove,
50
65
  onRetry,
66
+ onPreview,
51
67
  disabled,
52
68
  className,
53
69
  }: AttachmentChipListProps) {
70
+ // The built-in lightbox tracks the previewed entry by ID (direct identity,
71
+ // never object reference), and the entry itself is derived from `entries`
72
+ // on render — so removing an attachment while its preview is open closes
73
+ // the lightbox without any effect-based synchronization.
74
+ const [previewId, setPreviewId] = useState<string | null>(null);
75
+ const previewEntry =
76
+ previewId !== null ? (entries.find((e) => e.id === previewId) ?? null) : null;
77
+
54
78
  if (entries.length === 0) return null;
55
79
 
56
80
  return (
57
- <div
58
- className={cn("flex flex-wrap gap-1.5", className)}
59
- role="list"
60
- aria-label="Attached files"
61
- >
62
- {entries.map((entry) => (
63
- <AttachmentChip
64
- key={entry.id}
65
- entry={entry}
66
- onRemove={() => onRemove(entry.id)}
67
- onRetry={() => onRetry(entry.id)}
68
- disabled={disabled}
81
+ <>
82
+ {/* items-center is load-bearing: image tiles (h-14) and file chips
83
+ (~h-6) share this row, and flexbox's default stretch alignment
84
+ would balloon the file chips to tile height. */}
85
+ <div
86
+ className={cn("flex flex-wrap items-center gap-1.5", className)}
87
+ role="list"
88
+ aria-label="Attached files"
89
+ >
90
+ {entries.map((entry) => (
91
+ <AttachmentChip
92
+ key={entry.id}
93
+ entry={entry}
94
+ onRemove={() => onRemove(entry.id)}
95
+ onRetry={() => onRetry(entry.id)}
96
+ onPreview={
97
+ onPreview
98
+ ? () => onPreview(entry)
99
+ : () => setPreviewId(entry.id)
100
+ }
101
+ disabled={disabled}
102
+ />
103
+ ))}
104
+ </div>
105
+ {/* The lightbox mounts OUTSIDE the role="list" container (and outside
106
+ any chip): a <dialog> is not valid inside the chips' <span>
107
+ elements, and a non-listitem child inside the list would break its
108
+ accessibility ownership. One instance serves the whole list. */}
109
+ {previewEntry && (
110
+ <ChipPreviewLightbox
111
+ entry={previewEntry}
112
+ onClose={() => setPreviewId(null)}
69
113
  />
70
- ))}
71
- </div>
114
+ )}
115
+ </>
72
116
  );
73
117
  }
74
118
 
75
119
  // ---------------------------------------------------------------------------
76
- // Individual chip
120
+ // Individual chip — dispatches on content type
77
121
  // ---------------------------------------------------------------------------
78
122
 
79
123
  function AttachmentChip({
80
124
  entry,
81
125
  onRemove,
82
126
  onRetry,
127
+ onPreview,
128
+ disabled,
129
+ }: {
130
+ readonly entry: AttachmentEntry;
131
+ readonly onRemove: () => void;
132
+ readonly onRetry: () => void;
133
+ readonly onPreview: () => void;
134
+ readonly disabled?: boolean;
135
+ }) {
136
+ return entry.contentType.startsWith("image/") ? (
137
+ <ImageAttachmentChip
138
+ entry={entry}
139
+ onRemove={onRemove}
140
+ onRetry={onRetry}
141
+ onPreview={onPreview}
142
+ disabled={disabled}
143
+ />
144
+ ) : (
145
+ <FileAttachmentChip
146
+ entry={entry}
147
+ onRemove={onRemove}
148
+ onRetry={onRetry}
149
+ disabled={disabled}
150
+ />
151
+ );
152
+ }
153
+
154
+ /** Shared accessible name: filename, size, and upload status. */
155
+ function chipAriaLabel(entry: AttachmentEntry): string {
156
+ const isUploading = entry.phase === "uploading";
157
+ const isError = entry.phase === "error";
158
+ return `${entry.file.name}, ${formatFileSize(entry.file.size)}${isUploading ? ", uploading" : ""}${isError ? ", upload failed" : ""}`;
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Image chip — a preview-only thumbnail tile (#371, #372)
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /**
166
+ * The preview-only image tile: the thumbnail IS the chip. No filename
167
+ * text, no size caption — the image is its own identity (the grammar
168
+ * users know from Cursor/ChatGPT attachment tiles), while filename,
169
+ * size, and upload status stay one hover away in the tooltip and are
170
+ * always in the accessible label.
171
+ *
172
+ * The tile is alive through every upload phase (the spinner overlays
173
+ * the image during upload, never replaces it — the bytes are local, so
174
+ * the preview never waits on the network) and is one large click
175
+ * target that opens the full image. Remove is an always-visible corner
176
+ * badge — never hover-revealed, so it exists on touch devices and is
177
+ * discoverable without a pointer.
178
+ *
179
+ * On upload error the tile stays loud: destructive ring, dimmed image,
180
+ * and a centered Retry pill — failure never hides behind hover.
181
+ *
182
+ * If the file claims `image/*` but the browser can't decode it (corrupt,
183
+ * zero-byte), the chip degrades to the compact file-chip treatment — a
184
+ * generic icon and no preview affordance, never a broken-image glyph.
185
+ */
186
+ function ImageAttachmentChip({
187
+ entry,
188
+ onRemove,
189
+ onRetry,
190
+ onPreview,
83
191
  disabled,
84
192
  }: {
85
193
  readonly entry: AttachmentEntry;
86
194
  readonly onRemove: () => void;
87
195
  readonly onRetry: () => void;
196
+ readonly onPreview: () => void;
88
197
  readonly disabled?: boolean;
89
198
  }) {
199
+ const url = useObjectUrl(entry.file);
200
+ const [loadFailed, setLoadFailed] = useState(false);
201
+
202
+ if (loadFailed) {
203
+ return (
204
+ <FileAttachmentChip
205
+ entry={entry}
206
+ onRemove={onRemove}
207
+ onRetry={onRetry}
208
+ disabled={disabled}
209
+ />
210
+ );
211
+ }
212
+
90
213
  const isError = entry.phase === "error";
91
214
  const isUploading = entry.phase === "uploading";
92
215
 
93
216
  return (
217
+ // The remove badge and retry pill are SIBLINGS of the preview button,
218
+ // absolutely positioned over it — buttons cannot nest inside buttons.
94
219
  <span
95
220
  role="listitem"
96
- aria-label={`${entry.file.name}, ${formatFileSize(entry.file.size)}${isUploading ? ", uploading" : ""}${isError ? ", upload failed" : ""}`}
97
- className={cn(
98
- "inline-flex max-w-[200px] items-center gap-1 rounded-md px-2 py-0.5 text-xs",
99
- isError
100
- ? "border border-destructive/30 bg-destructive-subtle text-destructive"
101
- : "bg-muted-subtle text-foreground",
102
- isUploading && "opacity-70",
103
- )}
221
+ aria-label={chipAriaLabel(entry)}
222
+ title={entry.file.name}
223
+ className="relative inline-flex"
104
224
  >
105
- {isUploading && <ChipSpinner />}
106
- {isError && <ErrorDot />}
107
- {!isUploading && !isError &&
108
- (entry.contentType.startsWith("image/") ? (
109
- <ChipThumbnail file={entry.file} />
225
+ {/* The whole tile is the preview target. Preview stays enabled while
226
+ `disabled` (that prop gates the mutating remove/retry actions) and
227
+ in every phase: the object URL wraps in-memory bytes, no fetch.
228
+ UNSTYLED_BUTTON is load-bearing: without it the tile shows the
229
+ UA's default button box in preflight-less hosts (see the constant). */}
230
+ <button
231
+ type="button"
232
+ onClick={onPreview}
233
+ aria-label={`Preview ${entry.file.name}`}
234
+ className={cn(
235
+ UNSTYLED_BUTTON,
236
+ "relative block h-14 w-14 overflow-hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
237
+ isError && "ring-1 ring-destructive",
238
+ )}
239
+ >
240
+ {url ? (
241
+ <img
242
+ src={url}
243
+ alt=""
244
+ aria-hidden="true"
245
+ onError={() => setLoadFailed(true)}
246
+ className={cn(
247
+ "h-full w-full object-cover",
248
+ (isUploading || isError) && "opacity-50",
249
+ )}
250
+ />
110
251
  ) : (
111
- <FileIcon />
112
- ))}
113
-
114
- <span className="truncate">{entry.file.name}</span>
115
-
116
- <span className="shrink-0 text-[0.6rem] tabular-nums text-muted-foreground">
117
- {formatFileSize(entry.file.size)}
118
- </span>
252
+ // One-frame placeholder until the object-URL effect runs; same
253
+ // footprint as the image, so no layout shift.
254
+ <span
255
+ className="block h-full w-full animate-pulse bg-muted"
256
+ aria-hidden="true"
257
+ />
258
+ )}
259
+ {isUploading && (
260
+ <span className="absolute inset-0 flex items-center justify-center">
261
+ <ChipSpinner size={16} />
262
+ </span>
263
+ )}
264
+ </button>
119
265
 
120
266
  {isError && (
121
267
  <button
122
268
  type="button"
123
269
  onClick={onRetry}
124
270
  disabled={disabled}
125
- className="shrink-0 text-[0.6rem] font-medium text-destructive underline hover:text-destructive-muted disabled:pointer-events-none"
126
271
  aria-label={`Retry uploading ${entry.file.name}`}
272
+ className={cn(
273
+ UNSTYLED_BUTTON,
274
+ "absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-destructive px-1.5 py-0.5 text-[0.6rem] font-medium leading-none text-destructive-foreground shadow-sm [font-family:inherit] hover:bg-destructive-hover disabled:pointer-events-none",
275
+ )}
127
276
  >
128
277
  Retry
129
278
  </button>
@@ -133,8 +282,14 @@ function AttachmentChip({
133
282
  type="button"
134
283
  onClick={onRemove}
135
284
  disabled={disabled}
136
- className="ml-0.5 shrink-0 text-muted-foreground hover:text-destructive disabled:pointer-events-none"
137
285
  aria-label={`Remove ${entry.file.name}`}
286
+ className={cn(
287
+ UNSTYLED_BUTTON,
288
+ // The established corner-badge geometry (ContextPopover,
289
+ // ComposerToolbar). Solid bg + border keep it legible over any
290
+ // image without opacity-modified tokens (Dont-Do #4).
291
+ "absolute -right-1.5 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full border border-border bg-background text-muted-foreground shadow-sm hover:text-destructive disabled:pointer-events-none",
292
+ )}
138
293
  >
139
294
  <XIcon />
140
295
  </button>
@@ -143,39 +298,130 @@ function AttachmentChip({
143
298
  }
144
299
 
145
300
  // ---------------------------------------------------------------------------
146
- // Image thumbnaila #284 ask: a pasted screenshot's chip shows the image,
147
- // not a generic file glyph
301
+ // File chipthe compact treatment for non-image attachments
148
302
  // ---------------------------------------------------------------------------
149
303
 
150
- /**
151
- * Tiny preview of an image attachment, driven by an object URL over the
152
- * locally-held `File` (no fetch — the bytes are already in memory).
153
- *
154
- * The URL is created in an effect, not a memo: StrictMode double-invokes
155
- * memos and would leak the first URL without a revoke. The effect cleanup
156
- * revokes on removal, unmount, and file change. Until the effect runs (or
157
- * if it never gets a URL) the generic icon renders instead.
158
- *
159
- * `aria-hidden`: the chip's own `aria-label` already names the file; a
160
- * 16px thumbnail adds nothing for screen readers.
161
- */
162
- function ChipThumbnail({ file }: { readonly file: File }) {
163
- const [url, setUrl] = useState<string | null>(null);
304
+ function FileAttachmentChip({
305
+ entry,
306
+ onRemove,
307
+ onRetry,
308
+ disabled,
309
+ }: {
310
+ readonly entry: AttachmentEntry;
311
+ readonly onRemove: () => void;
312
+ readonly onRetry: () => void;
313
+ readonly disabled?: boolean;
314
+ }) {
315
+ const isError = entry.phase === "error";
316
+ const isUploading = entry.phase === "uploading";
317
+
318
+ return (
319
+ <span
320
+ role="listitem"
321
+ aria-label={chipAriaLabel(entry)}
322
+ className={cn(
323
+ "inline-flex max-w-[200px] items-center gap-1 rounded-md px-2 py-0.5 text-xs",
324
+ isError
325
+ ? "border border-destructive/30 bg-destructive-subtle text-destructive"
326
+ : "bg-muted-subtle text-foreground",
327
+ isUploading && "opacity-70",
328
+ )}
329
+ >
330
+ {isUploading && <ChipSpinner />}
331
+ {isError && <ErrorDot />}
332
+ {!isUploading && !isError && <FileIcon />}
333
+
334
+ <span className="truncate">{entry.file.name}</span>
335
+
336
+ <span className="shrink-0 text-[0.6rem] tabular-nums text-muted-foreground">
337
+ {formatFileSize(entry.file.size)}
338
+ </span>
339
+
340
+ {isError && <RetryButton filename={entry.file.name} onRetry={onRetry} disabled={disabled} />}
341
+ <RemoveButton filename={entry.file.name} onRemove={onRemove} disabled={disabled} />
342
+ </span>
343
+ );
344
+ }
345
+
346
+ // ---------------------------------------------------------------------------
347
+ // Shared chip actions
348
+ // ---------------------------------------------------------------------------
349
+
350
+ function RetryButton({
351
+ filename,
352
+ onRetry,
353
+ disabled,
354
+ }: {
355
+ readonly filename: string;
356
+ readonly onRetry: () => void;
357
+ readonly disabled?: boolean;
358
+ }) {
359
+ return (
360
+ <button
361
+ type="button"
362
+ onClick={onRetry}
363
+ disabled={disabled}
364
+ className={cn(
365
+ UNSTYLED_BUTTON,
366
+ "shrink-0 text-[0.6rem] font-medium text-destructive underline [font-family:inherit] hover:text-destructive-muted disabled:pointer-events-none",
367
+ )}
368
+ aria-label={`Retry uploading ${filename}`}
369
+ >
370
+ Retry
371
+ </button>
372
+ );
373
+ }
374
+
375
+ function RemoveButton({
376
+ filename,
377
+ onRemove,
378
+ disabled,
379
+ }: {
380
+ readonly filename: string;
381
+ readonly onRemove: () => void;
382
+ readonly disabled?: boolean;
383
+ }) {
384
+ return (
385
+ <button
386
+ type="button"
387
+ onClick={onRemove}
388
+ disabled={disabled}
389
+ className={cn(
390
+ UNSTYLED_BUTTON,
391
+ "ml-0.5 shrink-0 text-muted-foreground hover:text-destructive disabled:pointer-events-none",
392
+ )}
393
+ aria-label={`Remove ${filename}`}
394
+ >
395
+ <XIcon />
396
+ </button>
397
+ );
398
+ }
164
399
 
165
- useEffect(() => {
166
- const objectUrl = URL.createObjectURL(file);
167
- setUrl(objectUrl);
168
- return () => URL.revokeObjectURL(objectUrl);
169
- }, [file]);
400
+ // ---------------------------------------------------------------------------
401
+ // Built-in lightbox mount
402
+ // ---------------------------------------------------------------------------
170
403
 
171
- if (!url) return <FileIcon />;
404
+ /**
405
+ * Bridges the previewed entry to the dumb {@link AttachmentImageLightbox}:
406
+ * mounted only while a preview is open, so its object URL is created on
407
+ * open and revoked on close (independent of the chip's own URL, whose
408
+ * lifetime tracks the chip).
409
+ */
410
+ function ChipPreviewLightbox({
411
+ entry,
412
+ onClose,
413
+ }: {
414
+ readonly entry: AttachmentEntry;
415
+ readonly onClose: () => void;
416
+ }) {
417
+ const src = useObjectUrl(entry.file);
172
418
 
173
419
  return (
174
- <img
175
- src={url}
176
- alt=""
177
- aria-hidden="true"
178
- className="h-4 w-4 shrink-0 rounded-sm object-cover"
420
+ <AttachmentImageLightbox
421
+ src={src}
422
+ filename={entry.file.name}
423
+ open
424
+ onClose={onClose}
179
425
  />
180
426
  );
181
427
  }
@@ -184,11 +430,11 @@ function ChipThumbnail({ file }: { readonly file: File }) {
184
430
  // Inline SVG icons — kept minimal and consistent with SessionComposer icons
185
431
  // ---------------------------------------------------------------------------
186
432
 
187
- function ChipSpinner() {
433
+ function ChipSpinner({ size = 10 }: { readonly size?: number }) {
188
434
  return (
189
435
  <svg
190
- width="10"
191
- height="10"
436
+ width={size}
437
+ height={size}
192
438
  viewBox="0 0 16 16"
193
439
  fill="none"
194
440
  stroke="currentColor"
@@ -0,0 +1,156 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef } from "react";
4
+ import { cn } from "@stigmer/theme";
5
+
6
+ /** Props for {@link AttachmentImageLightbox}. */
7
+ export interface AttachmentImageLightboxProps {
8
+ /**
9
+ * Image source URL. `null` for the first-paint frame before the caller's
10
+ * object URL exists — the lightbox shows a pulse placeholder, never a
11
+ * broken-image glyph.
12
+ */
13
+ readonly src: string | null;
14
+ /** Filename shown in the header and used as the image alt text. */
15
+ readonly filename: string;
16
+ /** Controls visibility. `true` opens the dialog via `showModal()`. */
17
+ readonly open: boolean;
18
+ /** Called when the lightbox should close (Escape, backdrop click, close button). */
19
+ readonly onClose: () => void;
20
+ /** Additional CSS classes for the dialog element. */
21
+ readonly className?: string;
22
+ }
23
+
24
+ /**
25
+ * Full-size image preview in a native `<dialog>` — the click-to-open target
26
+ * for composer attachment chips (stigmer/stigmer#371).
27
+ *
28
+ * A thin modal shell following the SDK's established dialog pattern
29
+ * ({@link ArtifactPreviewModal}): `showModal()`/`close()` driven by the
30
+ * `open` prop, Escape via the intercepted `cancel` event, focus trap and
31
+ * top layer native — no portal plumbing, no `@base-ui/react` dependency.
32
+ * Backdrop click closes via the canonical `e.target === dialog` check
33
+ * (clicks on content children never match the dialog element itself).
34
+ *
35
+ * Deliberately dumb (`src` + `filename`, no attachment types): liftable to
36
+ * `internal/` unchanged if other image surfaces (e.g. tool-output
37
+ * screenshots in `ResultView`) later want in-app preview instead of
38
+ * open-in-new-tab.
39
+ *
40
+ * The backdrop uses the `--stgm-backdrop` theme token ("modal backdrop
41
+ * overlay behind dialogs") rather than the `bg-black/50` hardcode found in
42
+ * older dialogs — the token is the designed value (Dont-Do #3).
43
+ */
44
+ export function AttachmentImageLightbox({
45
+ src,
46
+ filename,
47
+ open,
48
+ onClose,
49
+ className,
50
+ }: AttachmentImageLightboxProps) {
51
+ const dialogRef = useRef<HTMLDialogElement>(null);
52
+
53
+ useEffect(() => {
54
+ const dialog = dialogRef.current;
55
+ if (!dialog) return;
56
+
57
+ if (open && !dialog.open) {
58
+ dialog.showModal();
59
+ } else if (!open && dialog.open) {
60
+ dialog.close();
61
+ }
62
+ }, [open]);
63
+
64
+ // Escape arrives as the native `cancel` event; intercept it so the
65
+ // caller's `open` prop stays the single source of truth for visibility.
66
+ const handleCancel = useCallback(
67
+ (e: React.SyntheticEvent) => {
68
+ e.preventDefault();
69
+ onClose();
70
+ },
71
+ [onClose],
72
+ );
73
+
74
+ const handleBackdropClick = useCallback(
75
+ (e: React.MouseEvent<HTMLDialogElement>) => {
76
+ if (e.target === dialogRef.current) {
77
+ onClose();
78
+ }
79
+ },
80
+ [onClose],
81
+ );
82
+
83
+ return (
84
+ <dialog
85
+ ref={dialogRef}
86
+ onCancel={handleCancel}
87
+ onClick={handleBackdropClick}
88
+ aria-label={`Preview ${filename}`}
89
+ className={cn(
90
+ "fixed inset-0 m-auto max-h-[85vh] max-w-[85vw] rounded-lg border border-border bg-background p-0 text-foreground shadow-lg outline-none",
91
+ "backdrop:bg-backdrop",
92
+ className,
93
+ )}
94
+ >
95
+ {open && (
96
+ <div className="flex flex-col">
97
+ <div className="flex items-center gap-2 px-3 py-2">
98
+ <span
99
+ className="min-w-0 flex-1 truncate text-xs font-semibold text-foreground"
100
+ title={filename}
101
+ >
102
+ {filename}
103
+ </span>
104
+ {/* `bg-transparent` neutralizes the UA button box in
105
+ preflight-less hosts (see UNSTYLED_BUTTON in the chip list). */}
106
+ <button
107
+ type="button"
108
+ onClick={onClose}
109
+ aria-label="Close image preview"
110
+ className="shrink-0 cursor-pointer rounded bg-transparent p-0.5 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
111
+ >
112
+ <CloseIcon />
113
+ </button>
114
+ </div>
115
+ <div className="min-h-0 overflow-auto border-t border-border">
116
+ {src ? (
117
+ <img
118
+ src={src}
119
+ alt={filename}
120
+ className="block max-h-[75vh] max-w-full object-contain"
121
+ />
122
+ ) : (
123
+ <div
124
+ className="h-48 w-72 animate-pulse bg-muted"
125
+ aria-hidden="true"
126
+ />
127
+ )}
128
+ </div>
129
+ </div>
130
+ )}
131
+ </dialog>
132
+ );
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Inline SVG icons (SDK pattern: no external icon dependency)
137
+ // ---------------------------------------------------------------------------
138
+
139
+ function CloseIcon() {
140
+ return (
141
+ <svg
142
+ width="14"
143
+ height="14"
144
+ viewBox="0 0 14 14"
145
+ fill="none"
146
+ stroke="currentColor"
147
+ strokeWidth="1.5"
148
+ strokeLinecap="round"
149
+ strokeLinejoin="round"
150
+ aria-hidden="true"
151
+ >
152
+ <path d="M3 3L11 11" />
153
+ <path d="M11 3L3 11" />
154
+ </svg>
155
+ );
156
+ }