@comergehq/studio 0.1.9 → 0.1.10
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 +608 -386
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +328 -106
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/ChatMessageList.tsx +21 -35
- package/src/components/chat/ChatPage.tsx +3 -1
- package/src/components/studio-sheet/StudioBottomSheet.tsx +3 -3
- package/src/studio/ComergeStudio.tsx +34 -1
- package/src/studio/hooks/useAttachmentUpload.ts +51 -5
- package/src/studio/hooks/useBundleManager.ts +91 -17
- package/src/studio/hooks/useOptimisticChatMessages.ts +128 -0
- package/src/studio/ui/ChatPanel.tsx +1 -0
- package/src/studio/ui/RuntimeRenderer.tsx +7 -2
- package/src/studio/ui/StudioOverlay.tsx +11 -2
package/package.json
CHANGED
|
@@ -46,10 +46,13 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
|
|
|
46
46
|
const initialScrollDoneRef = React.useRef(false);
|
|
47
47
|
const lastMessageIdRef = React.useRef<string | null>(null);
|
|
48
48
|
|
|
49
|
+
const data = React.useMemo(() => {
|
|
50
|
+
return [...messages].reverse();
|
|
51
|
+
}, [messages]);
|
|
52
|
+
|
|
49
53
|
const scrollToBottom = React.useCallback((options?: { animated?: boolean }) => {
|
|
50
54
|
const animated = options?.animated ?? true;
|
|
51
|
-
|
|
52
|
-
listRef.current?.scrollToEnd({ animated });
|
|
55
|
+
listRef.current?.scrollToOffset({ offset: 0, animated });
|
|
53
56
|
}, []);
|
|
54
57
|
|
|
55
58
|
React.useImperativeHandle(ref, () => ({ scrollToBottom }), [scrollToBottom]);
|
|
@@ -57,12 +60,7 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
|
|
|
57
60
|
const handleScroll = React.useCallback(
|
|
58
61
|
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
59
62
|
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
|
|
60
|
-
|
|
61
|
-
// so "near bottom" still means "near the latest message", not "deep into empty space".
|
|
62
|
-
const distanceFromBottom = Math.max(
|
|
63
|
-
contentSize.height - Math.max(bottomInset, 0) - (contentOffset.y + layoutMeasurement.height),
|
|
64
|
-
0
|
|
65
|
-
);
|
|
63
|
+
const distanceFromBottom = Math.max(contentOffset.y - Math.max(bottomInset, 0), 0);
|
|
66
64
|
const isNear = distanceFromBottom <= nearBottomThreshold;
|
|
67
65
|
|
|
68
66
|
if (nearBottomRef.current !== isNear) {
|
|
@@ -73,16 +71,6 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
|
|
|
73
71
|
[bottomInset, nearBottomThreshold, onNearBottomChange]
|
|
74
72
|
);
|
|
75
73
|
|
|
76
|
-
// On first load, start at the bottom
|
|
77
|
-
React.useEffect(() => {
|
|
78
|
-
if (initialScrollDoneRef.current) return;
|
|
79
|
-
if (messages.length === 0) return;
|
|
80
|
-
|
|
81
|
-
initialScrollDoneRef.current = true;
|
|
82
|
-
lastMessageIdRef.current = messages[messages.length - 1]?.id ?? null;
|
|
83
|
-
const id = requestAnimationFrame(() => scrollToBottom({ animated: false }));
|
|
84
|
-
return () => cancelAnimationFrame(id);
|
|
85
|
-
}, [messages, scrollToBottom]);
|
|
86
74
|
|
|
87
75
|
// When new messages arrive, keep the user pinned to the bottom only if they already were near it.
|
|
88
76
|
React.useEffect(() => {
|
|
@@ -106,38 +94,36 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
|
|
|
106
94
|
return undefined;
|
|
107
95
|
}, [showTypingIndicator, scrollToBottom]);
|
|
108
96
|
|
|
109
|
-
// When the bottom inset grows/shrinks (e.g. composer height changes), keep pinned users at bottom.
|
|
110
|
-
React.useEffect(() => {
|
|
111
|
-
if (!initialScrollDoneRef.current) return;
|
|
112
|
-
if (!nearBottomRef.current) return;
|
|
113
|
-
const id = requestAnimationFrame(() => scrollToBottom({ animated: false }));
|
|
114
|
-
return () => cancelAnimationFrame(id);
|
|
115
|
-
}, [bottomInset, scrollToBottom]);
|
|
116
|
-
|
|
117
97
|
return (
|
|
118
98
|
<BottomSheetFlatList
|
|
119
99
|
ref={listRef}
|
|
120
|
-
|
|
100
|
+
inverted
|
|
101
|
+
data={data}
|
|
121
102
|
keyExtractor={(m: ChatMessage) => m.id}
|
|
122
|
-
keyboardDismissMode={Platform.OS === 'ios' ? 'interactive' : 'on-drag'}
|
|
123
103
|
keyboardShouldPersistTaps="handled"
|
|
124
104
|
onScroll={handleScroll}
|
|
125
105
|
scrollEventThrottle={16}
|
|
126
106
|
showsVerticalScrollIndicator={false}
|
|
107
|
+
onContentSizeChange={() => {
|
|
108
|
+
if (initialScrollDoneRef.current) return;
|
|
109
|
+
initialScrollDoneRef.current = true;
|
|
110
|
+
lastMessageIdRef.current = messages.length > 0 ? messages[messages.length - 1]!.id : null;
|
|
111
|
+
nearBottomRef.current = true;
|
|
112
|
+
onNearBottomChange?.(true);
|
|
113
|
+
requestAnimationFrame(() => scrollToBottom({ animated: false }));
|
|
114
|
+
}}
|
|
127
115
|
contentContainerStyle={[
|
|
128
116
|
{
|
|
129
117
|
paddingHorizontal: theme.spacing.lg,
|
|
130
|
-
|
|
131
|
-
paddingBottom: theme.spacing.sm,
|
|
118
|
+
paddingVertical: theme.spacing.sm,
|
|
132
119
|
},
|
|
133
120
|
contentStyle,
|
|
134
121
|
]}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
</View>
|
|
122
|
+
ItemSeparatorComponent={() => <View style={{ height: theme.spacing.sm }} />}
|
|
123
|
+
renderItem={({ item }: { item: ChatMessage }) => (
|
|
124
|
+
<ChatMessageBubble message={item} renderContent={renderMessageContent} />
|
|
139
125
|
)}
|
|
140
|
-
|
|
126
|
+
ListHeaderComponent={
|
|
141
127
|
<View>
|
|
142
128
|
{showTypingIndicator ? (
|
|
143
129
|
<View style={{ marginTop: theme.spacing.sm, alignSelf: 'flex-start', paddingHorizontal: theme.spacing.lg }}>
|
|
@@ -21,6 +21,7 @@ export type ChatPageProps = {
|
|
|
21
21
|
*/
|
|
22
22
|
overlay?: React.ReactNode;
|
|
23
23
|
style?: ViewStyle;
|
|
24
|
+
composerHorizontalPadding?: number;
|
|
24
25
|
onNearBottomChange?: ChatMessageListProps['onNearBottomChange'];
|
|
25
26
|
listRef?: React.RefObject<ChatMessageListRef | null>;
|
|
26
27
|
};
|
|
@@ -34,6 +35,7 @@ export function ChatPage({
|
|
|
34
35
|
composer,
|
|
35
36
|
overlay,
|
|
36
37
|
style,
|
|
38
|
+
composerHorizontalPadding,
|
|
37
39
|
onNearBottomChange,
|
|
38
40
|
listRef,
|
|
39
41
|
}: ChatPageProps) {
|
|
@@ -92,7 +94,7 @@ export function ChatPage({
|
|
|
92
94
|
left: 0,
|
|
93
95
|
right: 0,
|
|
94
96
|
bottom: 0,
|
|
95
|
-
paddingHorizontal: theme.spacing.
|
|
97
|
+
paddingHorizontal: composerHorizontalPadding ?? theme.spacing.md,
|
|
96
98
|
paddingTop: theme.spacing.sm,
|
|
97
99
|
paddingBottom: footerBottomPadding,
|
|
98
100
|
}}
|
|
@@ -55,7 +55,7 @@ export type StudioBottomSheetProps = {
|
|
|
55
55
|
export function StudioBottomSheet({
|
|
56
56
|
open,
|
|
57
57
|
onOpenChange,
|
|
58
|
-
snapPoints = ['
|
|
58
|
+
snapPoints = ['100%'],
|
|
59
59
|
sheetRef,
|
|
60
60
|
background,
|
|
61
61
|
children,
|
|
@@ -117,9 +117,9 @@ export function StudioBottomSheet({
|
|
|
117
117
|
ref={resolvedSheetRef}
|
|
118
118
|
index={open ? snapPoints.length - 1 : -1}
|
|
119
119
|
snapPoints={snapPoints}
|
|
120
|
+
enableDynamicSizing={false}
|
|
120
121
|
enablePanDownToClose
|
|
121
|
-
|
|
122
|
-
keyboardBlurBehavior="restore"
|
|
122
|
+
enableContentPanningGesture={false}
|
|
123
123
|
android_keyboardInputMode="adjustResize"
|
|
124
124
|
backgroundComponent={(props: BottomSheetBackgroundProps) => (
|
|
125
125
|
<StudioSheetBackground {...props} renderBackground={background?.renderBackground} />
|
|
@@ -125,6 +125,34 @@ function ComergeStudioInner({
|
|
|
125
125
|
canRequestLatest: runtimeApp?.status === 'ready',
|
|
126
126
|
});
|
|
127
127
|
|
|
128
|
+
const sawEditingOnActiveAppRef = React.useRef(false);
|
|
129
|
+
const [showPostEditPreparing, setShowPostEditPreparing] = React.useState(false);
|
|
130
|
+
React.useEffect(() => {
|
|
131
|
+
sawEditingOnActiveAppRef.current = false;
|
|
132
|
+
setShowPostEditPreparing(false);
|
|
133
|
+
}, [activeAppId]);
|
|
134
|
+
|
|
135
|
+
React.useEffect(() => {
|
|
136
|
+
if (!app?.id) return;
|
|
137
|
+
if (app.status === 'editing') {
|
|
138
|
+
sawEditingOnActiveAppRef.current = true;
|
|
139
|
+
setShowPostEditPreparing(false);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (app.status === 'ready' && sawEditingOnActiveAppRef.current) {
|
|
143
|
+
setShowPostEditPreparing(true);
|
|
144
|
+
sawEditingOnActiveAppRef.current = false;
|
|
145
|
+
}
|
|
146
|
+
}, [app?.id, app?.status]);
|
|
147
|
+
|
|
148
|
+
React.useEffect(() => {
|
|
149
|
+
if (!showPostEditPreparing) return;
|
|
150
|
+
const stillProcessingBaseBundle = bundle.loading && bundle.loadingMode === 'base' && !bundle.isTesting;
|
|
151
|
+
if (!stillProcessingBaseBundle) {
|
|
152
|
+
setShowPostEditPreparing(false);
|
|
153
|
+
}
|
|
154
|
+
}, [showPostEditPreparing, bundle.loading, bundle.loadingMode, bundle.isTesting]);
|
|
155
|
+
|
|
128
156
|
const threadId = app?.threadId ?? '';
|
|
129
157
|
const thread = useThreadMessages(threadId);
|
|
130
158
|
|
|
@@ -173,7 +201,12 @@ function ComergeStudioInner({
|
|
|
173
201
|
return (
|
|
174
202
|
<View style={[{ flex: 1 }, style]}>
|
|
175
203
|
<View ref={captureTargetRef} style={{ flex: 1 }} collapsable={false}>
|
|
176
|
-
<RuntimeRenderer
|
|
204
|
+
<RuntimeRenderer
|
|
205
|
+
appKey={appKey}
|
|
206
|
+
bundlePath={bundle.bundlePath}
|
|
207
|
+
forcePreparing={showPostEditPreparing}
|
|
208
|
+
renderToken={bundle.renderToken}
|
|
209
|
+
/>
|
|
177
210
|
|
|
178
211
|
<StudioOverlay
|
|
179
212
|
captureTargetRef={captureTargetRef}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
+
import { Platform } from 'react-native';
|
|
3
|
+
import * as FileSystem from 'expo-file-system/legacy';
|
|
2
4
|
|
|
3
5
|
import { attachmentRepository } from '../../data/attachment/repository';
|
|
4
6
|
import type { AttachmentMeta } from '../../data/attachment/types';
|
|
@@ -15,6 +17,47 @@ export type UseAttachmentUploadResult = {
|
|
|
15
17
|
error: Error | null;
|
|
16
18
|
};
|
|
17
19
|
|
|
20
|
+
async function dataUrlToBlobAndroid(dataUrl: string): Promise<Blob> {
|
|
21
|
+
const normalized = dataUrl.startsWith('data:') ? dataUrl : `data:image/png;base64,${dataUrl}`;
|
|
22
|
+
const comma = normalized.indexOf(',');
|
|
23
|
+
if (comma === -1) {
|
|
24
|
+
throw new Error('Invalid data URL (missing comma separator)');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const header = normalized.slice(0, comma);
|
|
28
|
+
const base64 = normalized.slice(comma + 1);
|
|
29
|
+
|
|
30
|
+
const mimeMatch = header.match(/data:(.*?);base64/i);
|
|
31
|
+
const mimeType = mimeMatch?.[1] ?? 'application/octet-stream';
|
|
32
|
+
|
|
33
|
+
const cacheDir = FileSystem.cacheDirectory;
|
|
34
|
+
if (!cacheDir) {
|
|
35
|
+
throw new Error('expo-file-system cacheDirectory is unavailable');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const fileUri = `${cacheDir}attachment-${Date.now()}-${Math.random().toString(16).slice(2)}.bin`;
|
|
39
|
+
|
|
40
|
+
await FileSystem.writeAsStringAsync(fileUri, base64, {
|
|
41
|
+
encoding: FileSystem.EncodingType.Base64,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const resp = await fetch(fileUri);
|
|
46
|
+
const blob = await resp.blob();
|
|
47
|
+
return blob.type ? blob : new Blob([blob], { type: mimeType });
|
|
48
|
+
} finally {
|
|
49
|
+
void FileSystem.deleteAsync(fileUri, { idempotent: true }).catch(() => {});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getMimeTypeFromDataUrl(dataUrl: string): string {
|
|
54
|
+
const normalized = dataUrl.startsWith('data:') ? dataUrl : `data:image/png;base64,${dataUrl}`;
|
|
55
|
+
const comma = normalized.indexOf(',');
|
|
56
|
+
const header = comma === -1 ? normalized : normalized.slice(0, comma);
|
|
57
|
+
const mimeMatch = header.match(/data:(.*?);base64/i);
|
|
58
|
+
return mimeMatch?.[1] ?? 'image/png';
|
|
59
|
+
}
|
|
60
|
+
|
|
18
61
|
export function useAttachmentUpload(): UseAttachmentUploadResult {
|
|
19
62
|
const [uploading, setUploading] = React.useState(false);
|
|
20
63
|
const [error, setError] = React.useState<Error | null>(null);
|
|
@@ -29,16 +72,19 @@ export function useAttachmentUpload(): UseAttachmentUploadResult {
|
|
|
29
72
|
const blobs = await Promise.all(
|
|
30
73
|
dataUrls.map(async (dataUrl, idx) => {
|
|
31
74
|
const normalized = dataUrl.startsWith('data:') ? dataUrl : `data:image/png;base64,${dataUrl}`;
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
75
|
+
const blob =
|
|
76
|
+
Platform.OS === 'android'
|
|
77
|
+
? await dataUrlToBlobAndroid(normalized)
|
|
78
|
+
: await (await fetch(normalized)).blob();
|
|
79
|
+
const mimeType = getMimeTypeFromDataUrl(normalized);
|
|
80
|
+
return { blob, idx, mimeType };
|
|
35
81
|
})
|
|
36
82
|
);
|
|
37
83
|
|
|
38
|
-
const files = blobs.map(({ blob }, idx) => ({
|
|
84
|
+
const files = blobs.map(({ blob, mimeType }, idx) => ({
|
|
39
85
|
name: `attachment-${Date.now()}-${idx}.png`,
|
|
40
86
|
size: blob.size,
|
|
41
|
-
mimeType
|
|
87
|
+
mimeType,
|
|
42
88
|
}));
|
|
43
89
|
|
|
44
90
|
const presign = await attachmentRepository.presign({ threadId, appId, files });
|
|
@@ -4,6 +4,47 @@ import * as FileSystem from 'expo-file-system/legacy';
|
|
|
4
4
|
import type { Platform as BundlePlatform, Bundle } from '../../data/apps/bundles/types';
|
|
5
5
|
import { bundlesRepository } from '../../data/apps/bundles/repository';
|
|
6
6
|
|
|
7
|
+
function sleep(ms: number): Promise<void> {
|
|
8
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isRetryableNetworkError(e: unknown): boolean {
|
|
12
|
+
const err = e as any;
|
|
13
|
+
const code = typeof err?.code === 'string' ? err.code : '';
|
|
14
|
+
const message = typeof err?.message === 'string' ? err.message : '';
|
|
15
|
+
|
|
16
|
+
if (code === 'ERR_NETWORK' || code === 'ECONNABORTED') return true;
|
|
17
|
+
if (message.toLowerCase().includes('network error')) return true;
|
|
18
|
+
if (message.toLowerCase().includes('timeout')) return true;
|
|
19
|
+
|
|
20
|
+
const status = typeof err?.response?.status === 'number' ? err.response.status : undefined;
|
|
21
|
+
if (status && (status === 429 || status >= 500)) return true;
|
|
22
|
+
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function withRetry<T>(
|
|
27
|
+
fn: () => Promise<T>,
|
|
28
|
+
opts: { attempts: number; baseDelayMs: number; maxDelayMs: number }
|
|
29
|
+
): Promise<T> {
|
|
30
|
+
let lastErr: unknown = null;
|
|
31
|
+
for (let attempt = 1; attempt <= opts.attempts; attempt += 1) {
|
|
32
|
+
try {
|
|
33
|
+
return await fn();
|
|
34
|
+
} catch (e) {
|
|
35
|
+
lastErr = e;
|
|
36
|
+
const retryable = isRetryableNetworkError(e);
|
|
37
|
+
if (!retryable || attempt >= opts.attempts) {
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
const exp = Math.min(opts.maxDelayMs, opts.baseDelayMs * Math.pow(2, attempt - 1));
|
|
41
|
+
const jitter = Math.floor(Math.random() * 250);
|
|
42
|
+
await sleep(exp + jitter);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
throw lastErr;
|
|
46
|
+
}
|
|
47
|
+
|
|
7
48
|
type BundleSource = {
|
|
8
49
|
appId: string;
|
|
9
50
|
commitId?: string | null;
|
|
@@ -29,6 +70,7 @@ export type BundleLoadState = {
|
|
|
29
70
|
*/
|
|
30
71
|
renderToken: number;
|
|
31
72
|
loading: boolean;
|
|
73
|
+
loadingMode: 'base' | 'test' | null;
|
|
32
74
|
statusLabel: string | null;
|
|
33
75
|
error: string | null;
|
|
34
76
|
/**
|
|
@@ -119,8 +161,16 @@ async function getExistingNonEmptyFileUri(fileUri: string): Promise<string | nul
|
|
|
119
161
|
async function downloadIfMissing(url: string, fileUri: string): Promise<string> {
|
|
120
162
|
const existing = await getExistingNonEmptyFileUri(fileUri);
|
|
121
163
|
if (existing) return existing;
|
|
122
|
-
|
|
123
|
-
|
|
164
|
+
return await withRetry(
|
|
165
|
+
async () => {
|
|
166
|
+
await deleteFileIfExists(fileUri);
|
|
167
|
+
const res = await FileSystem.downloadAsync(url, fileUri);
|
|
168
|
+
const ok = await getExistingNonEmptyFileUri(res.uri);
|
|
169
|
+
if (!ok) throw new Error('Downloaded bundle is empty.');
|
|
170
|
+
return res.uri;
|
|
171
|
+
},
|
|
172
|
+
{ attempts: 3, baseDelayMs: 500, maxDelayMs: 4000 }
|
|
173
|
+
);
|
|
124
174
|
}
|
|
125
175
|
|
|
126
176
|
async function deleteFileIfExists(fileUri: string) {
|
|
@@ -136,11 +186,15 @@ async function deleteFileIfExists(fileUri: string) {
|
|
|
136
186
|
async function safeReplaceFileFromUrl(url: string, targetUri: string, tmpKey: string): Promise<string> {
|
|
137
187
|
const tmpUri = toBundleFileUri(`tmp:${tmpKey}:${Date.now()}`);
|
|
138
188
|
try {
|
|
139
|
-
await
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
189
|
+
await withRetry(
|
|
190
|
+
async () => {
|
|
191
|
+
await deleteFileIfExists(tmpUri);
|
|
192
|
+
await FileSystem.downloadAsync(url, tmpUri);
|
|
193
|
+
const tmpOk = await getExistingNonEmptyFileUri(tmpUri);
|
|
194
|
+
if (!tmpOk) throw new Error('Downloaded bundle is empty.');
|
|
195
|
+
},
|
|
196
|
+
{ attempts: 3, baseDelayMs: 500, maxDelayMs: 4000 }
|
|
197
|
+
);
|
|
144
198
|
|
|
145
199
|
await deleteFileIfExists(targetUri);
|
|
146
200
|
await FileSystem.moveAsync({ from: tmpUri, to: targetUri });
|
|
@@ -156,12 +210,18 @@ async function safeReplaceFileFromUrl(url: string, targetUri: string, tmpKey: st
|
|
|
156
210
|
async function pollBundle(appId: string, bundleId: string, opts: { timeoutMs: number; intervalMs: number }): Promise<Bundle> {
|
|
157
211
|
const start = Date.now();
|
|
158
212
|
while (true) {
|
|
159
|
-
|
|
160
|
-
|
|
213
|
+
try {
|
|
214
|
+
const bundle = await bundlesRepository.getById(appId, bundleId);
|
|
215
|
+
if (bundle.status === 'succeeded' || bundle.status === 'failed') return bundle;
|
|
216
|
+
} catch (e) {
|
|
217
|
+
if (!isRetryableNetworkError(e)) {
|
|
218
|
+
throw e;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
161
221
|
if (Date.now() - start > opts.timeoutMs) {
|
|
162
222
|
throw new Error('Bundle build timed out.');
|
|
163
223
|
}
|
|
164
|
-
await
|
|
224
|
+
await sleep(opts.intervalMs);
|
|
165
225
|
}
|
|
166
226
|
}
|
|
167
227
|
|
|
@@ -174,11 +234,16 @@ async function resolveBundlePath(
|
|
|
174
234
|
const dir = bundlesCacheDir();
|
|
175
235
|
await ensureDir(dir);
|
|
176
236
|
|
|
177
|
-
const initiate = await
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
237
|
+
const initiate = await withRetry(
|
|
238
|
+
async () => {
|
|
239
|
+
return await bundlesRepository.initiate(appId, {
|
|
240
|
+
platform,
|
|
241
|
+
commitId: commitId ?? undefined,
|
|
242
|
+
idempotencyKey: `${appId}:${commitId ?? 'head'}:${platform}`,
|
|
243
|
+
});
|
|
244
|
+
},
|
|
245
|
+
{ attempts: 3, baseDelayMs: 500, maxDelayMs: 4000 }
|
|
246
|
+
);
|
|
182
247
|
|
|
183
248
|
const finalBundle =
|
|
184
249
|
initiate.status === 'succeeded' || initiate.status === 'failed'
|
|
@@ -189,7 +254,12 @@ async function resolveBundlePath(
|
|
|
189
254
|
throw new Error('Bundle build failed.');
|
|
190
255
|
}
|
|
191
256
|
|
|
192
|
-
const signed = await
|
|
257
|
+
const signed = await withRetry(
|
|
258
|
+
async () => {
|
|
259
|
+
return await bundlesRepository.getSignedDownloadUrl(appId, finalBundle.id, { redirect: false });
|
|
260
|
+
},
|
|
261
|
+
{ attempts: 3, baseDelayMs: 500, maxDelayMs: 4000 }
|
|
262
|
+
);
|
|
193
263
|
const bundlePath =
|
|
194
264
|
mode === 'base'
|
|
195
265
|
? await safeReplaceFileFromUrl(
|
|
@@ -209,6 +279,7 @@ export function useBundleManager({
|
|
|
209
279
|
const [bundlePath, setBundlePath] = React.useState<string | null>(null);
|
|
210
280
|
const [renderToken, setRenderToken] = React.useState(0);
|
|
211
281
|
const [loading, setLoading] = React.useState(false);
|
|
282
|
+
const [loadingMode, setLoadingMode] = React.useState<'base' | 'test' | null>(null);
|
|
212
283
|
const [statusLabel, setStatusLabel] = React.useState<string | null>(null);
|
|
213
284
|
const [error, setError] = React.useState<string | null>(null);
|
|
214
285
|
const [isTesting, setIsTesting] = React.useState(false);
|
|
@@ -229,6 +300,7 @@ export function useBundleManager({
|
|
|
229
300
|
baseOpIdRef.current += 1;
|
|
230
301
|
if (activeLoadModeRef.current === 'base') {
|
|
231
302
|
setLoading(false);
|
|
303
|
+
setLoadingMode(null);
|
|
232
304
|
setStatusLabel(null);
|
|
233
305
|
activeLoadModeRef.current = null;
|
|
234
306
|
}
|
|
@@ -303,6 +375,7 @@ export function useBundleManager({
|
|
|
303
375
|
const opId = mode === 'base' ? ++baseOpIdRef.current : ++testOpIdRef.current;
|
|
304
376
|
activeLoadModeRef.current = mode;
|
|
305
377
|
setLoading(true);
|
|
378
|
+
setLoadingMode(mode);
|
|
306
379
|
setError(null);
|
|
307
380
|
setStatusLabel(mode === 'test' ? 'Loading test bundle…' : 'Loading latest build…');
|
|
308
381
|
|
|
@@ -357,6 +430,7 @@ export function useBundleManager({
|
|
|
357
430
|
if (mode === 'base' && opId !== baseOpIdRef.current) return;
|
|
358
431
|
if (mode === 'test' && opId !== testOpIdRef.current) return;
|
|
359
432
|
setLoading(false);
|
|
433
|
+
setLoadingMode(null);
|
|
360
434
|
if (activeLoadModeRef.current === mode) activeLoadModeRef.current = null;
|
|
361
435
|
}
|
|
362
436
|
}, [activateCachedBase, platform]);
|
|
@@ -383,7 +457,7 @@ export function useBundleManager({
|
|
|
383
457
|
void loadBase();
|
|
384
458
|
}, [base.appId, base.commitId, platform, canRequestLatest, loadBase]);
|
|
385
459
|
|
|
386
|
-
return { bundlePath, renderToken, loading, statusLabel, error, isTesting, loadBase, loadTest, restoreBase };
|
|
460
|
+
return { bundlePath, renderToken, loading, loadingMode, statusLabel, error, isTesting, loadBase, loadTest, restoreBase };
|
|
387
461
|
}
|
|
388
462
|
|
|
389
463
|
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import type { ChatMessage } from '../../components/models/types';
|
|
4
|
+
|
|
5
|
+
export type UseOptimisticChatMessagesParams = {
|
|
6
|
+
threadId: string | null;
|
|
7
|
+
shouldForkOnEdit: boolean;
|
|
8
|
+
chatMessages: ChatMessage[];
|
|
9
|
+
onSendChat: (text: string, attachments?: string[]) => void | Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type UseOptimisticChatMessagesResult = {
|
|
13
|
+
messages: ChatMessage[];
|
|
14
|
+
onSend: (text: string, attachments?: string[]) => Promise<void>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type OptimisticChatMessage = {
|
|
18
|
+
id: string;
|
|
19
|
+
content: string;
|
|
20
|
+
createdAtIso: string;
|
|
21
|
+
baseServerLastId: string | null;
|
|
22
|
+
failed: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function makeOptimisticId() {
|
|
26
|
+
return `optimistic:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 10)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toEpochMs(createdAt: ChatMessage['createdAt']): number {
|
|
30
|
+
if (createdAt == null) return 0;
|
|
31
|
+
if (typeof createdAt === 'number') return createdAt;
|
|
32
|
+
if (createdAt instanceof Date) return createdAt.getTime();
|
|
33
|
+
const t = Date.parse(String(createdAt));
|
|
34
|
+
return Number.isFinite(t) ? t : 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isOptimisticResolvedByServer(chatMessages: ChatMessage[], o: OptimisticChatMessage) {
|
|
38
|
+
if (o.failed) return false;
|
|
39
|
+
|
|
40
|
+
const normalize = (s: string) => s.trim();
|
|
41
|
+
|
|
42
|
+
let startIndex = -1;
|
|
43
|
+
if (o.baseServerLastId) {
|
|
44
|
+
startIndex = chatMessages.findIndex((m) => m.id === o.baseServerLastId);
|
|
45
|
+
}
|
|
46
|
+
const candidates = startIndex >= 0 ? chatMessages.slice(startIndex + 1) : chatMessages;
|
|
47
|
+
|
|
48
|
+
const target = normalize(o.content);
|
|
49
|
+
for (const m of candidates) {
|
|
50
|
+
if (m.author !== 'human') continue;
|
|
51
|
+
if (normalize(m.content) !== target) continue;
|
|
52
|
+
|
|
53
|
+
const serverMs = toEpochMs(m.createdAt);
|
|
54
|
+
const optimisticMs = Date.parse(o.createdAtIso);
|
|
55
|
+
if (Number.isFinite(optimisticMs) && optimisticMs > 0 && serverMs > 0) {
|
|
56
|
+
if (serverMs + 120_000 < optimisticMs) continue;
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function useOptimisticChatMessages({
|
|
64
|
+
threadId,
|
|
65
|
+
shouldForkOnEdit,
|
|
66
|
+
chatMessages,
|
|
67
|
+
onSendChat,
|
|
68
|
+
}: UseOptimisticChatMessagesParams): UseOptimisticChatMessagesResult {
|
|
69
|
+
const [optimisticChat, setOptimisticChat] = React.useState<OptimisticChatMessage[]>([]);
|
|
70
|
+
|
|
71
|
+
React.useEffect(() => {
|
|
72
|
+
setOptimisticChat([]);
|
|
73
|
+
}, [threadId]);
|
|
74
|
+
|
|
75
|
+
const messages = React.useMemo(() => {
|
|
76
|
+
if (!optimisticChat || optimisticChat.length === 0) return chatMessages;
|
|
77
|
+
|
|
78
|
+
const unresolved = optimisticChat.filter((o) => !isOptimisticResolvedByServer(chatMessages, o));
|
|
79
|
+
if (unresolved.length === 0) return chatMessages;
|
|
80
|
+
|
|
81
|
+
const optimisticAsChat = unresolved.map<ChatMessage>((o) => ({
|
|
82
|
+
id: o.id,
|
|
83
|
+
author: 'human',
|
|
84
|
+
content: o.content,
|
|
85
|
+
createdAt: o.createdAtIso,
|
|
86
|
+
kind: 'optimistic',
|
|
87
|
+
meta: o.failed
|
|
88
|
+
? { kind: 'optimistic', event: 'send.failed', status: 'error' }
|
|
89
|
+
: { kind: 'optimistic', event: 'send.pending', status: 'info' },
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
const merged = [...chatMessages, ...optimisticAsChat];
|
|
93
|
+
merged.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
|
|
94
|
+
return merged;
|
|
95
|
+
}, [chatMessages, optimisticChat]);
|
|
96
|
+
|
|
97
|
+
React.useEffect(() => {
|
|
98
|
+
if (optimisticChat.length === 0) return;
|
|
99
|
+
setOptimisticChat((prev) => {
|
|
100
|
+
if (prev.length === 0) return prev;
|
|
101
|
+
const next = prev.filter((o) => !isOptimisticResolvedByServer(chatMessages, o) || o.failed);
|
|
102
|
+
return next.length === prev.length ? prev : next;
|
|
103
|
+
});
|
|
104
|
+
}, [chatMessages, optimisticChat.length]);
|
|
105
|
+
|
|
106
|
+
const onSend = React.useCallback(
|
|
107
|
+
async (text: string, attachments?: string[]) => {
|
|
108
|
+
if (shouldForkOnEdit) {
|
|
109
|
+
await onSendChat(text, attachments);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const createdAtIso = new Date().toISOString();
|
|
114
|
+
const baseServerLastId = chatMessages.length > 0 ? chatMessages[chatMessages.length - 1]!.id : null;
|
|
115
|
+
const id = makeOptimisticId();
|
|
116
|
+
|
|
117
|
+
setOptimisticChat((prev) => [...prev, { id, content: text, createdAtIso, baseServerLastId, failed: false }]);
|
|
118
|
+
|
|
119
|
+
void Promise.resolve(onSendChat(text, attachments)).catch(() => {
|
|
120
|
+
setOptimisticChat((prev) => prev.map((m) => (m.id === id ? { ...m, failed: true } : m)));
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
[chatMessages, onSendChat, shouldForkOnEdit]
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return { messages, onSend };
|
|
127
|
+
}
|
|
128
|
+
|
|
@@ -8,6 +8,11 @@ import { Text } from '../../components/primitives/Text';
|
|
|
8
8
|
export type RuntimeRendererProps = {
|
|
9
9
|
appKey: string;
|
|
10
10
|
bundlePath: string | null;
|
|
11
|
+
/**
|
|
12
|
+
* When true, show the "Preparing app…" UI even if a previous bundle is available.
|
|
13
|
+
* Used to avoid briefly rendering an outdated bundle during post-edit base refresh.
|
|
14
|
+
*/
|
|
15
|
+
forcePreparing?: boolean;
|
|
11
16
|
/**
|
|
12
17
|
* Used to force a runtime remount even when bundlePath stays constant
|
|
13
18
|
* (e.g. base bundle replaced in-place).
|
|
@@ -16,8 +21,8 @@ export type RuntimeRendererProps = {
|
|
|
16
21
|
style?: ViewStyle;
|
|
17
22
|
};
|
|
18
23
|
|
|
19
|
-
export function RuntimeRenderer({ appKey, bundlePath, renderToken, style }: RuntimeRendererProps) {
|
|
20
|
-
if (!bundlePath) {
|
|
24
|
+
export function RuntimeRenderer({ appKey, bundlePath, forcePreparing, renderToken, style }: RuntimeRendererProps) {
|
|
25
|
+
if (!bundlePath || forcePreparing) {
|
|
21
26
|
return (
|
|
22
27
|
<View style={[{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 }, style]}>
|
|
23
28
|
<Text variant="bodyMuted">Preparing app…</Text>
|
|
@@ -14,6 +14,7 @@ import { ChatPanel } from './ChatPanel';
|
|
|
14
14
|
import { ConfirmMergeFlow } from './ConfirmMergeFlow';
|
|
15
15
|
import type { MergeRequestSummary } from '../../components/models/types';
|
|
16
16
|
import { useTheme } from '../../theme';
|
|
17
|
+
import { useOptimisticChatMessages } from '../hooks/useOptimisticChatMessages';
|
|
17
18
|
|
|
18
19
|
import { MergeIcon } from '../../components/icons/MergeIcon';
|
|
19
20
|
|
|
@@ -98,6 +99,14 @@ export function StudioOverlay({
|
|
|
98
99
|
const [commentsAppId, setCommentsAppId] = React.useState<string | null>(null);
|
|
99
100
|
const [commentsCount, setCommentsCount] = React.useState<number | null>(null);
|
|
100
101
|
|
|
102
|
+
const threadId = app?.threadId ?? null;
|
|
103
|
+
const optimistic = useOptimisticChatMessages({
|
|
104
|
+
threadId,
|
|
105
|
+
shouldForkOnEdit,
|
|
106
|
+
chatMessages,
|
|
107
|
+
onSendChat,
|
|
108
|
+
});
|
|
109
|
+
|
|
101
110
|
const [confirmMrId, setConfirmMrId] = React.useState<string | null>(null);
|
|
102
111
|
const confirmMr = React.useMemo(
|
|
103
112
|
() => (confirmMrId ? incomingMergeRequests.find((m) => m.id === confirmMrId) ?? null : null),
|
|
@@ -213,7 +222,7 @@ export function StudioOverlay({
|
|
|
213
222
|
}
|
|
214
223
|
chat={
|
|
215
224
|
<ChatPanel
|
|
216
|
-
messages={
|
|
225
|
+
messages={optimistic.messages}
|
|
217
226
|
showTypingIndicator={chatShowTypingIndicator}
|
|
218
227
|
loading={chatLoading}
|
|
219
228
|
sendDisabled={chatSendDisabled}
|
|
@@ -228,7 +237,7 @@ export function StudioOverlay({
|
|
|
228
237
|
onClose={closeSheet}
|
|
229
238
|
onNavigateHome={onNavigateHome}
|
|
230
239
|
onStartDraw={startDraw}
|
|
231
|
-
onSend={
|
|
240
|
+
onSend={optimistic.onSend}
|
|
232
241
|
/>
|
|
233
242
|
}
|
|
234
243
|
/>
|