@comergehq/studio 0.1.35 → 0.1.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comergehq/studio",
3
- "version": "0.1.35",
3
+ "version": "0.1.36",
4
4
  "description": "Comerge studio",
5
5
  "main": "src/index.ts",
6
6
  "module": "dist/index.mjs",
@@ -59,6 +59,7 @@
59
59
  "expo": "*",
60
60
  "expo-asset": "*",
61
61
  "expo-file-system": "*",
62
+ "expo-image": "*",
62
63
  "expo-haptics": "*",
63
64
  "expo-linear-gradient": "*",
64
65
  "lottie-react-native": "*",
@@ -0,0 +1,243 @@
1
+ import * as React from 'react';
2
+ import {
3
+ Animated,
4
+ Dimensions,
5
+ FlatList,
6
+ Modal,
7
+ Pressable,
8
+ StyleSheet,
9
+ View,
10
+ } from 'react-native';
11
+ import { Image as ExpoImage } from 'expo-image';
12
+
13
+ import type { ChatAttachment } from '../models/types';
14
+ import { useTheme } from '../../theme';
15
+ import { Text } from '../primitives/Text';
16
+ import { IconClose } from '../icons/StudioIcons';
17
+
18
+ export type ChatMessageAttachmentsProps = {
19
+ messageId: string;
20
+ attachments: ChatAttachment[];
21
+ align?: 'left' | 'right';
22
+ onAttachmentLoadError?: (messageId: string, attachmentId: string) => void;
23
+ };
24
+
25
+ export function ChatMessageAttachments({
26
+ messageId,
27
+ attachments,
28
+ align = 'left',
29
+ onAttachmentLoadError,
30
+ }: ChatMessageAttachmentsProps) {
31
+ const theme = useTheme();
32
+ const [viewerVisible, setViewerVisible] = React.useState(false);
33
+ const [viewerIndex, setViewerIndex] = React.useState(0);
34
+ const failedKeysRef = React.useRef<Set<string>>(new Set());
35
+ const [loadingById, setLoadingById] = React.useState<Record<string, boolean>>({});
36
+ const [modalLoadingById, setModalLoadingById] = React.useState<Record<string, boolean>>({});
37
+ const pulse = React.useRef(new Animated.Value(0.45)).current;
38
+
39
+ const imageAttachments = React.useMemo(
40
+ () =>
41
+ attachments.filter(
42
+ (att) =>
43
+ att.mimeType.startsWith('image/') &&
44
+ typeof att.uri === 'string' &&
45
+ att.uri.length > 0
46
+ ),
47
+ [attachments]
48
+ );
49
+
50
+ const itemHeight = imageAttachments.length === 1 ? 180 : 124;
51
+ const maxItemWidth = imageAttachments.length === 1 ? 280 : 180;
52
+
53
+ const getAspectRatio = (att: ChatAttachment) => {
54
+ const width = typeof att.width === 'number' ? att.width : 0;
55
+ const height = typeof att.height === 'number' ? att.height : 0;
56
+ if (width > 0 && height > 0) {
57
+ return Math.max(0.35, Math.min(2.4, width / height));
58
+ }
59
+ return 0.8;
60
+ };
61
+
62
+ React.useEffect(() => {
63
+ Animated.loop(
64
+ Animated.sequence([
65
+ Animated.timing(pulse, { toValue: 0.85, duration: 650, useNativeDriver: true }),
66
+ Animated.timing(pulse, { toValue: 0.45, duration: 650, useNativeDriver: true }),
67
+ ])
68
+ ).start();
69
+ }, [pulse]);
70
+
71
+ React.useEffect(() => {
72
+ if (imageAttachments.length === 0) {
73
+ setLoadingById({});
74
+ setModalLoadingById({});
75
+ return;
76
+ }
77
+ setLoadingById((prev) => {
78
+ const next: Record<string, boolean> = {};
79
+ for (const att of imageAttachments) {
80
+ next[att.id] = prev[att.id] ?? true;
81
+ }
82
+ return next;
83
+ });
84
+ }, [imageAttachments]);
85
+
86
+ React.useEffect(() => {
87
+ if (!viewerVisible) return;
88
+ if (imageAttachments.length === 0) {
89
+ setModalLoadingById({});
90
+ return;
91
+ }
92
+ setModalLoadingById(() => {
93
+ const next: Record<string, boolean> = {};
94
+ for (const att of imageAttachments) {
95
+ next[att.id] = true;
96
+ }
97
+ return next;
98
+ });
99
+ }, [viewerVisible, imageAttachments]);
100
+
101
+ if (imageAttachments.length === 0) return null;
102
+
103
+ const handleError = (attachmentId: string) => {
104
+ const key = `${messageId}:${attachmentId}`;
105
+ if (failedKeysRef.current.has(key)) return;
106
+ failedKeysRef.current.add(key);
107
+ onAttachmentLoadError?.(messageId, attachmentId);
108
+ };
109
+
110
+ return (
111
+ <>
112
+ <View
113
+ style={{
114
+ flexDirection: 'row',
115
+ flexWrap: 'wrap',
116
+ justifyContent: align === 'right' ? 'flex-end' : 'flex-start',
117
+ alignSelf: align === 'right' ? 'flex-end' : 'flex-start',
118
+ gap: theme.spacing.sm,
119
+ marginBottom: theme.spacing.sm,
120
+ }}
121
+ >
122
+ {imageAttachments.map((att, index) => (
123
+ <Pressable
124
+ key={att.id}
125
+ onPress={() => {
126
+ setViewerIndex(index);
127
+ setViewerVisible(true);
128
+ }}
129
+ accessibilityRole="button"
130
+ accessibilityLabel={`Attachment ${index + 1} of ${imageAttachments.length}`}
131
+ style={{
132
+ height: itemHeight,
133
+ aspectRatio: getAspectRatio(att),
134
+ maxWidth: maxItemWidth,
135
+ borderRadius: theme.radii.lg,
136
+ overflow: 'hidden',
137
+ }}
138
+ >
139
+ {loadingById[att.id] ? (
140
+ <Animated.View
141
+ style={{
142
+ ...StyleSheet.absoluteFillObject,
143
+ opacity: pulse,
144
+ backgroundColor: theme.colors.border,
145
+ }}
146
+ />
147
+ ) : null}
148
+ <ExpoImage
149
+ source={{ uri: att.uri }}
150
+ style={{ width: '100%', height: '100%' }}
151
+ contentFit="contain"
152
+ transition={140}
153
+ cachePolicy="memory-disk"
154
+ onLoadStart={() => {
155
+ setLoadingById((prev) => ({ ...prev, [att.id]: true }));
156
+ }}
157
+ onLoadEnd={() => {
158
+ setLoadingById((prev) => ({ ...prev, [att.id]: false }));
159
+ }}
160
+ onError={() => handleError(att.id)}
161
+ />
162
+ </Pressable>
163
+ ))}
164
+ </View>
165
+
166
+ <Modal visible={viewerVisible} transparent animationType="fade" onRequestClose={() => setViewerVisible(false)}>
167
+ <View style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.95)' }}>
168
+ <View
169
+ style={{
170
+ position: 'absolute',
171
+ top: 56,
172
+ right: 16,
173
+ zIndex: 2,
174
+ }}
175
+ >
176
+ <Pressable
177
+ accessibilityRole="button"
178
+ accessibilityLabel="Close attachment viewer"
179
+ onPress={() => setViewerVisible(false)}
180
+ style={{
181
+ width: 44,
182
+ height: 44,
183
+ borderRadius: 22,
184
+ alignItems: 'center',
185
+ justifyContent: 'center',
186
+ backgroundColor: 'rgba(255,255,255,0.15)',
187
+ }}
188
+ >
189
+ <IconClose size={18} colorToken="onPrimary" />
190
+ </Pressable>
191
+ </View>
192
+ <FlatList
193
+ data={imageAttachments}
194
+ horizontal
195
+ pagingEnabled
196
+ initialScrollIndex={viewerIndex}
197
+ keyExtractor={(item) => item.id}
198
+ showsHorizontalScrollIndicator={false}
199
+ getItemLayout={(_, index) => {
200
+ const width = Dimensions.get('window').width;
201
+ return { length: width, offset: width * index, index };
202
+ }}
203
+ renderItem={({ item, index }) => (
204
+ <View style={{ width: Dimensions.get('window').width, height: '100%', justifyContent: 'center' }}>
205
+ {modalLoadingById[item.id] ? (
206
+ <Animated.View
207
+ style={{
208
+ ...StyleSheet.absoluteFillObject,
209
+ opacity: pulse,
210
+ backgroundColor: theme.colors.border,
211
+ }}
212
+ />
213
+ ) : null}
214
+ <ExpoImage
215
+ source={{ uri: item.uri }}
216
+ style={{ width: '100%', height: '78%' }}
217
+ contentFit="contain"
218
+ transition={140}
219
+ cachePolicy="memory-disk"
220
+ onLoadStart={() => {
221
+ setModalLoadingById((prev) => ({ ...prev, [item.id]: true }));
222
+ }}
223
+ onLoadEnd={() => {
224
+ setModalLoadingById((prev) => ({ ...prev, [item.id]: false }));
225
+ }}
226
+ onError={() => handleError(item.id)}
227
+ />
228
+ <Text
229
+ variant="caption"
230
+ color="#FFFFFF"
231
+ style={{ textAlign: 'center', marginTop: theme.spacing.sm }}
232
+ >
233
+ {index + 1} / {imageAttachments.length}
234
+ </Text>
235
+ </View>
236
+ )}
237
+ />
238
+ </View>
239
+ </Modal>
240
+ </>
241
+ );
242
+ }
243
+
@@ -8,6 +8,7 @@ import { Button } from '../primitives/Button';
8
8
  import { MarkdownText } from '../primitives/MarkdownText';
9
9
  import { Surface } from '../primitives/Surface';
10
10
  import { Text } from '../primitives/Text';
11
+ import { ChatMessageAttachments } from './ChatMessageAttachments';
11
12
 
12
13
  export type ChatMessageBubbleProps = {
13
14
  message: ChatMessage;
@@ -18,6 +19,7 @@ export type ChatMessageBubbleProps = {
18
19
  isLast?: boolean;
19
20
  retrying?: boolean;
20
21
  onRetryMessage?: (messageId: string) => void;
22
+ onAttachmentLoadError?: (messageId: string, attachmentId: string) => void;
21
23
  style?: ViewStyle;
22
24
  };
23
25
 
@@ -36,12 +38,34 @@ function areMessageMetaEqual(a: ChatMessage['meta'], b: ChatMessage['meta']): bo
36
38
  );
37
39
  }
38
40
 
41
+ function areMessageAttachmentsEqual(a: ChatMessage['attachments'], b: ChatMessage['attachments']): boolean {
42
+ if (a === b) return true;
43
+ const left = a ?? [];
44
+ const right = b ?? [];
45
+ if (left.length !== right.length) return false;
46
+ for (let i = 0; i < left.length; i += 1) {
47
+ if (
48
+ left[i].id !== right[i].id ||
49
+ left[i].name !== right[i].name ||
50
+ left[i].mimeType !== right[i].mimeType ||
51
+ left[i].size !== right[i].size ||
52
+ left[i].uri !== right[i].uri ||
53
+ left[i].width !== right[i].width ||
54
+ left[i].height !== right[i].height
55
+ ) {
56
+ return false;
57
+ }
58
+ }
59
+ return true;
60
+ }
61
+
39
62
  function ChatMessageBubbleInner({
40
63
  message,
41
64
  renderContent,
42
65
  isLast,
43
66
  retrying,
44
67
  onRetryMessage,
68
+ onAttachmentLoadError,
45
69
  style,
46
70
  }: ChatMessageBubbleProps) {
47
71
  const theme = useTheme();
@@ -65,38 +89,62 @@ function ChatMessageBubbleInner({
65
89
  metaStatus === 'success' ? theme.colors.success : metaStatus === 'error' ? theme.colors.danger : undefined;
66
90
  const showRetry = Boolean(onRetryMessage) && isLast && metaStatus === 'error' && message.author === 'human';
67
91
  const retryLabel = retrying ? 'Retrying...' : 'Retry';
92
+ const hasText = message.content.trim().length > 0;
93
+ const attachments = message.attachments ?? [];
94
+ const hasAttachments = attachments.length > 0;
95
+ const hasStatusIcon = isMergeCompleted || isSyncCompleted || isMergeApproved || isSyncStarted;
96
+ const shouldRenderBubble = hasText || hasStatusIcon;
68
97
  const handleRetryPress = React.useCallback(() => {
69
98
  onRetryMessage?.(message.id);
70
99
  }, [message.id, onRetryMessage]);
71
100
 
72
101
  return (
73
102
  <View style={[align, style]}>
74
- <Surface
75
- variant={bubbleVariant}
76
- style={[
77
- {
103
+ {hasAttachments ? (
104
+ <View
105
+ style={{
78
106
  maxWidth: '85%',
79
- borderRadius: theme.radii.lg,
80
- paddingHorizontal: theme.spacing.lg,
81
- paddingVertical: theme.spacing.md,
82
- borderWidth: 1,
83
- borderColor: theme.colors.border,
84
- },
85
- cornerStyle,
86
- ]}
87
- >
88
- <View style={{ flexDirection: 'row', alignItems: 'center' }}>
89
- {isMergeCompleted || isSyncCompleted ? (
90
- <CheckCheck size={16} color={theme.colors.success} style={{ marginRight: theme.spacing.sm }} />
91
- ) : null}
92
- {isMergeApproved || isSyncStarted ? (
93
- <GitMerge size={16} color={theme.colors.text} style={{ marginRight: theme.spacing.sm }} />
94
- ) : null}
95
- <View style={{ flexShrink: 1, minWidth: 0 }}>
96
- {renderContent ? renderContent(message) : <MarkdownText markdown={message.content} variant="chat" bodyColor={bodyColor} />}
97
- </View>
107
+ marginBottom: shouldRenderBubble ? theme.spacing.xs : 0,
108
+ alignSelf: isHuman ? 'flex-end' : 'flex-start',
109
+ alignItems: isHuman ? 'flex-end' : 'flex-start',
110
+ }}
111
+ >
112
+ <ChatMessageAttachments
113
+ messageId={message.id}
114
+ attachments={attachments}
115
+ align={isHuman ? 'right' : 'left'}
116
+ onAttachmentLoadError={onAttachmentLoadError}
117
+ />
98
118
  </View>
99
- </Surface>
119
+ ) : null}
120
+ {shouldRenderBubble ? (
121
+ <Surface
122
+ variant={bubbleVariant}
123
+ style={[
124
+ {
125
+ maxWidth: '85%',
126
+ borderRadius: theme.radii.lg,
127
+ paddingHorizontal: theme.spacing.lg,
128
+ paddingVertical: theme.spacing.md,
129
+ borderWidth: 1,
130
+ borderColor: theme.colors.border,
131
+ },
132
+ cornerStyle,
133
+ ]}
134
+ >
135
+ <View style={{ flexDirection: 'row', alignItems: 'center' }}>
136
+ {isMergeCompleted || isSyncCompleted ? (
137
+ <CheckCheck size={16} color={theme.colors.success} style={{ marginRight: theme.spacing.sm }} />
138
+ ) : null}
139
+ {isMergeApproved || isSyncStarted ? (
140
+ <GitMerge size={16} color={theme.colors.text} style={{ marginRight: theme.spacing.sm }} />
141
+ ) : null}
142
+ <View style={{ flexShrink: 1, minWidth: 0 }}>
143
+ {renderContent ? renderContent(message) : <MarkdownText markdown={message.content} variant="chat" bodyColor={bodyColor} />}
144
+ </View>
145
+ </View>
146
+ </Surface>
147
+ ) : null}
100
148
  {showRetry ? (
101
149
  <View style={{ marginTop: theme.spacing.xs, alignSelf: align.alignSelf }}>
102
150
  <Button
@@ -130,6 +178,7 @@ function areEqual(prev: ChatMessageBubbleProps, next: ChatMessageBubbleProps): b
130
178
  prev.message.id === next.message.id &&
131
179
  prev.message.author === next.message.author &&
132
180
  prev.message.content === next.message.content &&
181
+ areMessageAttachmentsEqual(prev.message.attachments, next.message.attachments) &&
133
182
  prev.message.kind === next.message.kind &&
134
183
  String(prev.message.createdAt) === String(next.message.createdAt) &&
135
184
  areMessageMetaEqual(prev.message.meta, next.message.meta) &&
@@ -137,6 +186,7 @@ function areEqual(prev: ChatMessageBubbleProps, next: ChatMessageBubbleProps): b
137
186
  prev.isLast === next.isLast &&
138
187
  prev.retrying === next.retrying &&
139
188
  prev.onRetryMessage === next.onRetryMessage &&
189
+ prev.onAttachmentLoadError === next.onAttachmentLoadError &&
140
190
  prev.style === next.style
141
191
  );
142
192
  }
@@ -17,6 +17,7 @@ export type ChatMessageListProps = {
17
17
  renderMessageContent?: ChatMessageBubbleProps['renderContent'];
18
18
  onRetryMessage?: (messageId: string) => void;
19
19
  isRetryingMessage?: (messageId: string) => boolean;
20
+ onAttachmentLoadError?: (messageId: string, attachmentId: string) => void;
20
21
  contentStyle?: ViewStyle;
21
22
  bottomInset?: number;
22
23
  /**
@@ -37,6 +38,7 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
37
38
  renderMessageContent,
38
39
  onRetryMessage,
39
40
  isRetryingMessage,
41
+ onAttachmentLoadError,
40
42
  contentStyle,
41
43
  bottomInset = 0,
42
44
  onNearBottomChange,
@@ -144,9 +146,10 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
144
146
  isLast={Boolean(lastMessageId && item.id === lastMessageId)}
145
147
  retrying={isRetryingMessage?.(item.id) ?? false}
146
148
  onRetryMessage={onRetryMessage}
149
+ onAttachmentLoadError={onAttachmentLoadError}
147
150
  />
148
151
  ),
149
- [isRetryingMessage, lastMessageId, onRetryMessage, renderMessageContent]
152
+ [isRetryingMessage, lastMessageId, onAttachmentLoadError, onRetryMessage, renderMessageContent]
150
153
  );
151
154
 
152
155
  return (
@@ -14,6 +14,7 @@ export type ChatPageProps = {
14
14
  renderMessageContent?: ChatMessageListProps['renderMessageContent'];
15
15
  onRetryMessage?: ChatMessageListProps['onRetryMessage'];
16
16
  isRetryingMessage?: ChatMessageListProps['isRetryingMessage'];
17
+ onAttachmentLoadError?: ChatMessageListProps['onAttachmentLoadError'];
17
18
  topBanner?: React.ReactNode;
18
19
  composerTop?: React.ReactNode;
19
20
  composer: Omit<ChatComposerProps, 'attachments'> & {
@@ -36,6 +37,7 @@ export function ChatPage({
36
37
  renderMessageContent,
37
38
  onRetryMessage,
38
39
  isRetryingMessage,
40
+ onAttachmentLoadError,
39
41
  topBanner,
40
42
  composerTop,
41
43
  composer,
@@ -86,6 +88,7 @@ export function ChatPage({
86
88
  renderMessageContent={renderMessageContent}
87
89
  onRetryMessage={onRetryMessage}
88
90
  isRetryingMessage={isRetryingMessage}
91
+ onAttachmentLoadError={onAttachmentLoadError}
89
92
  onNearBottomChange={onNearBottomChange}
90
93
  bottomInset={bottomInset}
91
94
  />
@@ -107,7 +107,13 @@ export function DrawModeOverlay({
107
107
 
108
108
  return (
109
109
  <View style={[StyleSheet.absoluteFill, styles.root, style]} pointerEvents="box-none">
110
- <EdgeGlowFrame visible={!hideUi} role="danger" thickness={50} intensity={1} />
110
+ <EdgeGlowFrame
111
+ visible={!hideUi}
112
+ role="danger"
113
+ thickness={50}
114
+ intensity={1}
115
+ animationDurationMs={hideUi ? 0 : 300}
116
+ />
111
117
 
112
118
  <DrawSurface
113
119
  color={selectedColor}
@@ -31,6 +31,16 @@ export type ChatMessageMeta = {
31
31
  threadId?: string;
32
32
  };
33
33
 
34
+ export type ChatAttachment = {
35
+ id: string;
36
+ name: string;
37
+ mimeType: string;
38
+ size: number;
39
+ uri?: string;
40
+ width?: number;
41
+ height?: number;
42
+ };
43
+
34
44
  export type ChatMessage = {
35
45
  id: string;
36
46
  author: ChatAuthor;
@@ -38,6 +48,7 @@ export type ChatMessage = {
38
48
  createdAt?: string | number | Date | null;
39
49
  kind?: string | null;
40
50
  meta?: ChatMessageMeta | null;
51
+ attachments?: ChatAttachment[];
41
52
  };
42
53
 
43
54
 
@@ -19,6 +19,10 @@ export type EdgeGlowFrameProps = {
19
19
  * Optional intensity multiplier for alpha (0..1).
20
20
  */
21
21
  intensity?: number;
22
+ /**
23
+ * Opacity animation duration in ms.
24
+ */
25
+ animationDurationMs?: number;
22
26
  style?: ViewStyle;
23
27
  };
24
28
 
@@ -41,6 +45,7 @@ export function EdgeGlowFrame({
41
45
  role = 'accent',
42
46
  thickness = 40,
43
47
  intensity = 1,
48
+ animationDurationMs = 300,
44
49
  style,
45
50
  }: EdgeGlowFrameProps) {
46
51
  const theme = useTheme();
@@ -51,10 +56,10 @@ export function EdgeGlowFrame({
51
56
  React.useEffect(() => {
52
57
  Animated.timing(anim, {
53
58
  toValue: visible ? 1 : 0,
54
- duration: 300,
59
+ duration: animationDurationMs,
55
60
  useNativeDriver: true,
56
61
  }).start();
57
- }, [anim, visible]);
62
+ }, [anim, visible, animationDurationMs]);
58
63
 
59
64
  const c = baseColor(role, theme);
60
65
  const strong = withAlpha(c, 0.6 * alpha);
@@ -18,6 +18,8 @@ export type PresignFile = {
18
18
  size: number;
19
19
  mimeType: string;
20
20
  checksum?: string;
21
+ width?: number;
22
+ height?: number;
21
23
  };
22
24
 
23
25
  export type PresignAttachmentsRequest = {
@@ -550,6 +550,9 @@ function ComergeStudioInner({
550
550
  chatSending={actions.sending}
551
551
  chatShowTypingIndicator={chatShowTypingIndicator}
552
552
  onSendChat={(text, attachments) => actions.sendEdit({ prompt: text, attachments })}
553
+ onChatAttachmentLoadError={() => {
554
+ thread.recoverAttachmentUrls();
555
+ }}
553
556
  chatQueueItems={chatQueueItems}
554
557
  onRemoveQueueItem={(id) => editQueueActions.cancel(id)}
555
558
  chatProgress={showChatProgress ? agentProgress.view : null}
@@ -1,5 +1,5 @@
1
1
  import * as React from 'react';
2
- import { Platform } from 'react-native';
2
+ import { Image, Platform } from 'react-native';
3
3
  import * as FileSystem from 'expo-file-system/legacy';
4
4
 
5
5
  import { attachmentRepository } from '../../data/attachment/repository';
@@ -59,6 +59,23 @@ function getMimeTypeFromDataUrl(dataUrl: string): string {
59
59
  return mimeMatch?.[1] ?? 'image/png';
60
60
  }
61
61
 
62
+ async function getImageDimensionsFromDataUrl(dataUrl: string): Promise<{ width?: number; height?: number }> {
63
+ try {
64
+ const normalized = dataUrl.startsWith('data:') ? dataUrl : `data:image/png;base64,${dataUrl}`;
65
+ const dims = await new Promise<{ width: number; height: number }>((resolve, reject) => {
66
+ Image.getSize(
67
+ normalized,
68
+ (width, height) => resolve({ width, height }),
69
+ (err) => reject(err)
70
+ );
71
+ });
72
+ if (dims.width > 0 && dims.height > 0) {
73
+ return { width: Math.round(dims.width), height: Math.round(dims.height) };
74
+ }
75
+ } catch {}
76
+ return {};
77
+ }
78
+
62
79
  export function useAttachmentUpload(): UseAttachmentUploadResult {
63
80
  const [uploading, setUploading] = React.useState(false);
64
81
  const [error, setError] = React.useState<Error | null>(null);
@@ -78,19 +95,28 @@ export function useAttachmentUpload(): UseAttachmentUploadResult {
78
95
  ? await dataUrlToBlobAndroid(normalized)
79
96
  : await (await fetch(normalized)).blob();
80
97
  const mimeType = getMimeTypeFromDataUrl(normalized);
81
- return { blob, idx, mimeType };
98
+ const dimensions = mimeType.startsWith('image/')
99
+ ? await getImageDimensionsFromDataUrl(normalized)
100
+ : {};
101
+ return { blob, idx, mimeType, ...dimensions };
82
102
  })
83
103
  );
84
104
 
85
- const files = blobs.map(({ blob, mimeType }, idx) => ({
105
+ const files = blobs.map(({ blob, mimeType, width, height }, idx) => ({
86
106
  name: `attachment-${Date.now()}-${idx}.png`,
87
107
  size: blob.size,
88
108
  mimeType,
109
+ width,
110
+ height,
89
111
  }));
90
112
 
91
113
  const presign = await attachmentRepository.presign({ threadId, appId, files });
92
114
  await Promise.all(presign.uploads.map((u, index) => attachmentRepository.upload(u, blobs[index].blob)));
93
- return presign.uploads.map((u) => u.attachment);
115
+ return presign.uploads.map((u, index) => ({
116
+ ...u.attachment,
117
+ width: blobs[index].width,
118
+ height: blobs[index].height,
119
+ }));
94
120
  } catch (e) {
95
121
  const err = e instanceof Error ? e : new Error(String(e));
96
122
  setError(err);
@@ -114,14 +140,19 @@ export function useAttachmentUpload(): UseAttachmentUploadResult {
114
140
  ? await dataUrlToBlobAndroid(normalized)
115
141
  : await (await fetch(normalized)).blob();
116
142
  const mimeType = getMimeTypeFromDataUrl(normalized);
117
- return { blob, mimeType };
143
+ const dimensions = mimeType.startsWith('image/')
144
+ ? await getImageDimensionsFromDataUrl(normalized)
145
+ : {};
146
+ return { blob, mimeType, ...dimensions };
118
147
  })
119
148
  );
120
149
 
121
- const files = blobs.map(({ blob, mimeType }, idx) => ({
150
+ const files = blobs.map(({ blob, mimeType, width, height }, idx) => ({
122
151
  name: `attachment-${Date.now()}-${idx}.png`,
123
152
  size: blob.size,
124
153
  mimeType,
154
+ width,
155
+ height,
125
156
  }));
126
157
 
127
158
  const presign = await attachmentRepository.stagePresign({ files });