@comergehq/studio 0.1.34 → 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.
@@ -1,4 +1,5 @@
1
1
  import * as React from 'react';
2
+ import { Image } from 'react-native';
2
3
 
3
4
  import type { ChatMessage } from '../../components/models/types';
4
5
 
@@ -20,13 +21,19 @@ export type UseOptimisticChatMessagesResult = {
20
21
  type OptimisticChatMessage = {
21
22
  id: string;
22
23
  content: string;
23
- attachments?: string[];
24
+ attachments?: OptimisticAttachment[];
24
25
  createdAtIso: string;
25
26
  baseServerLastId: string | null;
26
27
  failed: boolean;
27
28
  retrying: boolean;
28
29
  };
29
30
 
31
+ type OptimisticAttachment = {
32
+ uri: string;
33
+ width?: number;
34
+ height?: number;
35
+ };
36
+
30
37
  function makeOptimisticId() {
31
38
  return `optimistic:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 10)}`;
32
39
  }
@@ -39,6 +46,28 @@ function toEpochMs(createdAt: ChatMessage['createdAt']): number {
39
46
  return Number.isFinite(t) ? t : 0;
40
47
  }
41
48
 
49
+ async function resolveAttachmentDimensions(uris: string[]): Promise<OptimisticAttachment[]> {
50
+ return Promise.all(
51
+ uris.map(
52
+ async (uri): Promise<OptimisticAttachment> => {
53
+ try {
54
+ const { width, height } = await new Promise<{ width: number; height: number }>((resolve, reject) => {
55
+ Image.getSize(
56
+ uri,
57
+ (w, h) => resolve({ width: w, height: h }),
58
+ (err) => reject(err)
59
+ );
60
+ });
61
+ if (width > 0 && height > 0) {
62
+ return { uri, width: Math.round(width), height: Math.round(height) };
63
+ }
64
+ } catch {}
65
+ return { uri };
66
+ }
67
+ )
68
+ );
69
+ }
70
+
42
71
  function isOptimisticResolvedByServer(chatMessages: ChatMessage[], o: OptimisticChatMessage) {
43
72
  if (o.failed) return false;
44
73
 
@@ -90,6 +119,15 @@ export function useOptimisticChatMessages({
90
119
  content: o.content,
91
120
  createdAt: o.createdAtIso,
92
121
  kind: 'optimistic',
122
+ attachments: (o.attachments ?? []).map((attachment, index) => ({
123
+ id: `${o.id}:attachment:${index}`,
124
+ name: `attachment-${index + 1}.png`,
125
+ mimeType: 'image/png',
126
+ size: 1,
127
+ uri: attachment.uri,
128
+ width: attachment.width,
129
+ height: attachment.height,
130
+ })),
93
131
  meta: o.failed
94
132
  ? { kind: 'optimistic', event: 'send.failed', status: 'error' }
95
133
  : { kind: 'optimistic', event: 'send.pending', status: 'info' },
@@ -119,7 +157,10 @@ export function useOptimisticChatMessages({
119
157
  const createdAtIso = new Date().toISOString();
120
158
  const baseServerLastId = chatMessages.length > 0 ? chatMessages[chatMessages.length - 1]!.id : null;
121
159
  const id = makeOptimisticId();
122
- const normalizedAttachments = attachments && attachments.length > 0 ? [...attachments] : undefined;
160
+ const normalizedAttachments =
161
+ attachments && attachments.length > 0
162
+ ? await resolveAttachmentDimensions(attachments)
163
+ : undefined;
123
164
 
124
165
  setOptimisticChat((prev) => [
125
166
  ...prev,
@@ -157,7 +198,10 @@ export function useOptimisticChatMessages({
157
198
  );
158
199
 
159
200
  try {
160
- await onSendChat(target.content, target.attachments);
201
+ await onSendChat(
202
+ target.content,
203
+ target.attachments?.map((att) => att.uri)
204
+ );
161
205
  setOptimisticChat((prev) =>
162
206
  prev.map((m) => (m.id === messageId ? { ...m, retrying: false } : m))
163
207
  );
@@ -2,7 +2,7 @@ import * as React from 'react';
2
2
 
3
3
  import type { Message } from '../../data/messages/types';
4
4
  import { messagesRepository } from '../../data/messages/repository';
5
- import type { ChatMessage } from '../../components/models/types';
5
+ import type { ChatAttachment, ChatMessage } from '../../components/models/types';
6
6
  import { useForegroundSignal } from './useForegroundSignal';
7
7
 
8
8
  export type UseThreadMessagesResult = {
@@ -12,6 +12,7 @@ export type UseThreadMessagesResult = {
12
12
  refreshing: boolean;
13
13
  error: Error | null;
14
14
  refetch: () => Promise<void>;
15
+ recoverAttachmentUrls: () => void;
15
16
  };
16
17
 
17
18
  function extractMeta(payload: unknown): ChatMessage['meta'] {
@@ -64,6 +65,36 @@ function compareMessages(a: Message, b: Message): number {
64
65
  return String(a.createdAt).localeCompare(String(b.createdAt));
65
66
  }
66
67
 
68
+ function parseAttachments(payload: Message['payload']): ChatAttachment[] {
69
+ const raw = (payload as any)?.attachments;
70
+ if (!Array.isArray(raw)) return [];
71
+ const out: ChatAttachment[] = [];
72
+ for (const item of raw) {
73
+ if (!item || typeof item !== 'object') continue;
74
+ const id = typeof (item as any).id === 'string' ? String((item as any).id) : '';
75
+ const name = typeof (item as any).name === 'string' ? String((item as any).name) : '';
76
+ const mimeType = typeof (item as any).mimeType === 'string' ? String((item as any).mimeType) : '';
77
+ const size = typeof (item as any).size === 'number' ? Number((item as any).size) : 0;
78
+ if (!id || !name || !mimeType || !Number.isFinite(size) || size <= 0) continue;
79
+ out.push({
80
+ id,
81
+ name,
82
+ mimeType,
83
+ size,
84
+ uri: typeof (item as any).downloadUrl === 'string' ? String((item as any).downloadUrl) : undefined,
85
+ width: typeof (item as any).width === 'number' ? Number((item as any).width) : undefined,
86
+ height: typeof (item as any).height === 'number' ? Number((item as any).height) : undefined,
87
+ });
88
+ }
89
+ return out;
90
+ }
91
+
92
+ function hasAttachmentWithoutUrl(payload: Message['payload']): boolean {
93
+ const attachments = parseAttachments(payload);
94
+ if (attachments.length === 0) return false;
95
+ return attachments.some((att) => !att.uri);
96
+ }
97
+
67
98
  function mapMessageToChatMessage(m: Message): ChatMessage {
68
99
  const kind = typeof (m.payload as any)?.type === 'string' ? String((m.payload as any).type) : null;
69
100
  return {
@@ -73,6 +104,7 @@ function mapMessageToChatMessage(m: Message): ChatMessage {
73
104
  createdAt: m.createdAt,
74
105
  kind,
75
106
  meta: extractMeta(m.payload),
107
+ attachments: parseAttachments(m.payload),
76
108
  };
77
109
  }
78
110
 
@@ -84,11 +116,22 @@ export function useThreadMessages(threadId: string): UseThreadMessagesResult {
84
116
  const activeRequestIdRef = React.useRef(0);
85
117
  const foregroundSignal = useForegroundSignal(Boolean(threadId));
86
118
  const hasLoadedOnceRef = React.useRef(false);
119
+ const attachmentRecoveryTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
120
+ const lastAttachmentRecoveryAtRef = React.useRef(0);
87
121
 
88
122
  React.useEffect(() => {
89
123
  hasLoadedOnceRef.current = false;
90
124
  }, [threadId]);
91
125
 
126
+ React.useEffect(() => {
127
+ return () => {
128
+ if (attachmentRecoveryTimerRef.current) {
129
+ clearTimeout(attachmentRecoveryTimerRef.current);
130
+ attachmentRecoveryTimerRef.current = null;
131
+ }
132
+ };
133
+ }, []);
134
+
92
135
  const upsertSorted = React.useCallback((prev: Message[], m: Message) => {
93
136
  const next = prev.filter((x) => x.id !== m.id);
94
137
  next.push(m);
@@ -131,6 +174,20 @@ export function useThreadMessages(threadId: string): UseThreadMessagesResult {
131
174
  }
132
175
  }, [threadId]);
133
176
 
177
+ const recoverAttachmentUrls = React.useCallback(() => {
178
+ if (!threadId) return;
179
+ if (attachmentRecoveryTimerRef.current) return;
180
+
181
+ const now = Date.now();
182
+ if (now - lastAttachmentRecoveryAtRef.current < 2000) return;
183
+
184
+ attachmentRecoveryTimerRef.current = setTimeout(() => {
185
+ attachmentRecoveryTimerRef.current = null;
186
+ lastAttachmentRecoveryAtRef.current = Date.now();
187
+ void refetch({ background: true });
188
+ }, 250);
189
+ }, [refetch, threadId]);
190
+
134
191
  React.useEffect(() => {
135
192
  void refetch();
136
193
  }, [refetch]);
@@ -138,12 +195,22 @@ export function useThreadMessages(threadId: string): UseThreadMessagesResult {
138
195
  React.useEffect(() => {
139
196
  if (!threadId) return;
140
197
  const unsubscribe = messagesRepository.subscribeThread(threadId, {
141
- onInsert: (m) => setRaw((prev) => upsertSorted(prev, m)),
142
- onUpdate: (m) => setRaw((prev) => upsertSorted(prev, m)),
198
+ onInsert: (m) => {
199
+ setRaw((prev) => upsertSorted(prev, m));
200
+ if (hasAttachmentWithoutUrl(m.payload)) {
201
+ recoverAttachmentUrls();
202
+ }
203
+ },
204
+ onUpdate: (m) => {
205
+ setRaw((prev) => upsertSorted(prev, m));
206
+ if (hasAttachmentWithoutUrl(m.payload)) {
207
+ recoverAttachmentUrls();
208
+ }
209
+ },
143
210
  onDelete: (m) => setRaw((prev) => prev.filter((x) => x.id !== m.id)),
144
211
  });
145
212
  return unsubscribe;
146
- }, [threadId, upsertSorted, foregroundSignal]);
213
+ }, [threadId, upsertSorted, foregroundSignal, recoverAttachmentUrls]);
147
214
 
148
215
  React.useEffect(() => {
149
216
  if (!threadId) return;
@@ -151,13 +218,20 @@ export function useThreadMessages(threadId: string): UseThreadMessagesResult {
151
218
  void refetch({ background: true });
152
219
  }, [foregroundSignal, refetch, threadId]);
153
220
 
221
+ React.useEffect(() => {
222
+ if (!threadId) return;
223
+ if (raw.length === 0) return;
224
+ if (!raw.some((m) => hasAttachmentWithoutUrl(m.payload))) return;
225
+ recoverAttachmentUrls();
226
+ }, [raw, recoverAttachmentUrls, threadId]);
227
+
154
228
  const messages = React.useMemo(() => {
155
229
  const visible = raw.filter((m) => !isQueuedHiddenMessage(m));
156
230
  const resolved = visible.length > 0 ? visible : raw;
157
231
  return resolved.map(mapMessageToChatMessage);
158
232
  }, [raw]);
159
233
 
160
- return { raw, messages, loading, refreshing, error, refetch };
234
+ return { raw, messages, loading, refreshing, error, refetch, recoverAttachmentUrls };
161
235
  }
162
236
 
163
237
 
@@ -36,6 +36,7 @@ export type ChatPanelProps = {
36
36
  onSend: (text: string, attachments?: string[]) => void | Promise<void>;
37
37
  onRetryMessage?: (messageId: string) => void | Promise<void>;
38
38
  isRetryingMessage?: (messageId: string) => boolean;
39
+ onAttachmentLoadError?: (messageId: string, attachmentId: string) => void;
39
40
  queueItems?: EditQueueItem[];
40
41
  onRemoveQueueItem?: (id: string) => void;
41
42
  progress?: AgentRunProgressView | null;
@@ -60,6 +61,7 @@ export function ChatPanel({
60
61
  onSend,
61
62
  onRetryMessage,
62
63
  isRetryingMessage,
64
+ onAttachmentLoadError,
63
65
  queueItems = [],
64
66
  onRemoveQueueItem,
65
67
  progress = null,
@@ -153,6 +155,7 @@ export function ChatPanel({
153
155
  showTypingIndicator={showTypingIndicator}
154
156
  onRetryMessage={onRetryMessage}
155
157
  isRetryingMessage={isRetryingMessage}
158
+ onAttachmentLoadError={onAttachmentLoadError}
156
159
  topBanner={topBanner}
157
160
  composerTop={queueTop}
158
161
  composerHorizontalPadding={0}
@@ -64,6 +64,7 @@ export type StudioOverlayProps = {
64
64
  chatSending?: boolean;
65
65
  chatShowTypingIndicator?: boolean;
66
66
  onSendChat: (text: string, attachments?: string[]) => void | Promise<void>;
67
+ onChatAttachmentLoadError?: (messageId: string, attachmentId: string) => void;
67
68
  chatQueueItems?: import('../../data/apps/edit-queue/types').EditQueueItem[];
68
69
  onRemoveQueueItem?: (id: string) => void;
69
70
  chatProgress?: import('../hooks/useAgentRunProgress').AgentRunProgressView | null;
@@ -111,6 +112,7 @@ export function StudioOverlay({
111
112
  chatSending,
112
113
  chatShowTypingIndicator,
113
114
  onSendChat,
115
+ onChatAttachmentLoadError,
114
116
  chatQueueItems,
115
117
  onRemoveQueueItem,
116
118
  chatProgress,
@@ -350,6 +352,7 @@ export function StudioOverlay({
350
352
  onSend={optimistic.onSend}
351
353
  onRetryMessage={optimistic.onRetry}
352
354
  isRetryingMessage={optimistic.isRetrying}
355
+ onAttachmentLoadError={onChatAttachmentLoadError}
353
356
  queueItems={queueItemsForChat}
354
357
  onRemoveQueueItem={onRemoveQueueItem}
355
358
  progress={chatProgress}
@@ -1,10 +1,9 @@
1
1
  import * as React from 'react';
2
- import { ActivityIndicator, Pressable, ScrollView, View } from 'react-native';
2
+ import { ActivityIndicator, Pressable, ScrollView, View, useWindowDimensions } from 'react-native';
3
3
 
4
4
  import type { RelatedApp, RelatedApps } from '../../../data/apps/types';
5
5
  import { Modal } from '../../../components/primitives/Modal';
6
6
  import { Text } from '../../../components/primitives/Text';
7
- import { PreviewStatusBadge } from '../../../components/preview/PreviewStatusBadge';
8
7
  import { withAlpha } from '../../../components/utils/color';
9
8
  import { useTheme } from '../../../theme';
10
9
  import { SectionTitle } from './SectionTitle';
@@ -53,7 +52,10 @@ export function PreviewRelatedAppsSection({
53
52
  onSwitchRelatedApp,
54
53
  }: PreviewRelatedAppsSectionProps) {
55
54
  const theme = useTheme();
55
+ const { height: windowHeight } = useWindowDimensions();
56
56
  const [relatedAppsOpen, setRelatedAppsOpen] = React.useState(false);
57
+ const modalMaxHeight = Math.max(240, windowHeight * 0.5);
58
+ const modalScrollMaxHeight = Math.max(140, modalMaxHeight - 96);
57
59
 
58
60
  const relatedAppItems = React.useMemo((): RelatedAppListItem[] => {
59
61
  if (!relatedApps) return [];
@@ -203,9 +205,6 @@ export function PreviewRelatedAppsSection({
203
205
  </View>
204
206
 
205
207
  <View style={{ alignItems: 'flex-end', gap: 6 }}>
206
- <View style={{ minHeight: 20, justifyContent: 'center' }}>
207
- {item.app.status !== 'ready' ? <PreviewStatusBadge status={item.app.status} /> : null}
208
- </View>
209
208
  {isSwitching ? <ActivityIndicator size="small" color={theme.colors.primary} /> : null}
210
209
  </View>
211
210
  </View>
@@ -258,25 +257,35 @@ export function PreviewRelatedAppsSection({
258
257
  </ScrollView>
259
258
  )}
260
259
 
261
- <Modal visible={relatedAppsOpen} onRequestClose={closeRelatedApps}>
262
- <View style={{ gap: theme.spacing.sm }}>
260
+ <Modal
261
+ visible={relatedAppsOpen}
262
+ onRequestClose={closeRelatedApps}
263
+ contentStyle={{ maxHeight: modalMaxHeight, overflow: 'hidden' }}
264
+ >
265
+ <View style={{ gap: theme.spacing.sm, minHeight: 0 }}>
263
266
  <Text style={{ color: theme.colors.text, fontSize: 18, fontWeight: theme.typography.fontWeight.semibold }}>
264
267
  Related apps
265
268
  </Text>
266
269
 
267
- {sectionedRelatedApps.original.length > 0 ? (
268
- <View>
269
- <Text style={{ color: theme.colors.textMuted, marginBottom: theme.spacing.xs }}>Original</Text>
270
- {sectionedRelatedApps.original.map((item) => renderRelatedCard(item, { fullWidth: true }))}
271
- </View>
272
- ) : null}
270
+ <ScrollView
271
+ style={{ maxHeight: modalScrollMaxHeight }}
272
+ contentContainerStyle={{ paddingBottom: theme.spacing.xs, gap: theme.spacing.sm }}
273
+ showsVerticalScrollIndicator
274
+ >
275
+ {sectionedRelatedApps.original.length > 0 ? (
276
+ <View>
277
+ <Text style={{ color: theme.colors.textMuted, marginBottom: theme.spacing.xs }}>Original</Text>
278
+ {sectionedRelatedApps.original.map((item) => renderRelatedCard(item, { fullWidth: true }))}
279
+ </View>
280
+ ) : null}
273
281
 
274
- {sectionedRelatedApps.remixes.length > 0 ? (
275
- <View>
276
- <Text style={{ color: theme.colors.textMuted, marginBottom: theme.spacing.xs }}>Remixes</Text>
277
- {sectionedRelatedApps.remixes.map((item) => renderRelatedCard(item, { fullWidth: true }))}
278
- </View>
279
- ) : null}
282
+ {sectionedRelatedApps.remixes.length > 0 ? (
283
+ <View>
284
+ <Text style={{ color: theme.colors.textMuted, marginBottom: theme.spacing.xs }}>Remixes</Text>
285
+ {sectionedRelatedApps.remixes.map((item) => renderRelatedCard(item, { fullWidth: true }))}
286
+ </View>
287
+ ) : null}
288
+ </ScrollView>
280
289
  </View>
281
290
  </Modal>
282
291
  </>