@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.
- package/dist/index.js +840 -399
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +849 -400
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
- package/src/components/chat/AgentProgressCard.tsx +6 -1
- package/src/components/chat/ChatMessageAttachments.tsx +243 -0
- package/src/components/chat/ChatMessageBubble.tsx +74 -24
- package/src/components/chat/ChatMessageList.tsx +4 -1
- package/src/components/chat/ChatPage.tsx +3 -0
- package/src/components/draw/DrawModeOverlay.tsx +7 -1
- package/src/components/models/types.ts +11 -0
- package/src/components/overlays/EdgeGlowFrame.tsx +7 -2
- package/src/data/attachment/types.ts +2 -0
- package/src/studio/ComergeStudio.tsx +3 -0
- package/src/studio/hooks/useAttachmentUpload.ts +37 -6
- package/src/studio/hooks/useOptimisticChatMessages.ts +47 -3
- package/src/studio/hooks/useThreadMessages.ts +79 -5
- package/src/studio/ui/ChatPanel.tsx +3 -0
- package/src/studio/ui/StudioOverlay.tsx +3 -0
- package/src/studio/ui/preview-panel/PreviewRelatedAppsSection.tsx +28 -19
|
@@ -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?:
|
|
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 =
|
|
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(
|
|
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) =>
|
|
142
|
-
|
|
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
|
|
262
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
</>
|