@comergehq/studio 0.1.8 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comergehq/studio",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Comerge studio",
5
5
  "main": "src/index.ts",
6
6
  "module": "dist/index.mjs",
@@ -1,5 +1,5 @@
1
1
  import * as React from 'react';
2
- import { View, type NativeScrollEvent, type NativeSyntheticEvent, type ViewStyle } from 'react-native';
2
+ import { Platform, View, type NativeScrollEvent, type NativeSyntheticEvent, type ViewStyle } from 'react-native';
3
3
  import { BottomSheetFlatList } from '@gorhom/bottom-sheet';
4
4
 
5
5
  import type { ChatMessage } from '../models/types';
@@ -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
- // Scroll to visual bottom (latest messages) in a normal (non-inverted) list.
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
- // Treat "bottom" as the end of actual messages (excluding the intentional footer spacer),
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,36 +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
- data={messages}
100
+ inverted
101
+ data={data}
121
102
  keyExtractor={(m: ChatMessage) => m.id}
103
+ keyboardShouldPersistTaps="handled"
122
104
  onScroll={handleScroll}
123
105
  scrollEventThrottle={16}
124
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
+ }}
125
115
  contentContainerStyle={[
126
116
  {
127
117
  paddingHorizontal: theme.spacing.lg,
128
- paddingTop: theme.spacing.sm,
129
- paddingBottom: theme.spacing.sm,
118
+ paddingVertical: theme.spacing.sm,
130
119
  },
131
120
  contentStyle,
132
121
  ]}
133
- renderItem={({ item, index }: { item: ChatMessage; index: number }) => (
134
- <View style={{ marginTop: index === 0 ? 0 : theme.spacing.sm }}>
135
- <ChatMessageBubble message={item} renderContent={renderMessageContent} />
136
- </View>
122
+ ItemSeparatorComponent={() => <View style={{ height: theme.spacing.sm }} />}
123
+ renderItem={({ item }: { item: ChatMessage }) => (
124
+ <ChatMessageBubble message={item} renderContent={renderMessageContent} />
137
125
  )}
138
- ListFooterComponent={
126
+ ListHeaderComponent={
139
127
  <View>
140
128
  {showTypingIndicator ? (
141
129
  <View style={{ marginTop: theme.spacing.sm, alignSelf: 'flex-start', paddingHorizontal: theme.spacing.lg }}>
@@ -1,7 +1,6 @@
1
1
  import * as React from 'react';
2
2
  import { Keyboard, Platform, View, type ViewStyle } from 'react-native';
3
3
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
4
- import Animated, { useAnimatedKeyboard, useAnimatedStyle } from 'react-native-reanimated';
5
4
 
6
5
  import type { ChatMessage } from '../models/types';
7
6
  import { useTheme } from '../../theme';
@@ -22,6 +21,7 @@ export type ChatPageProps = {
22
21
  */
23
22
  overlay?: React.ReactNode;
24
23
  style?: ViewStyle;
24
+ composerHorizontalPadding?: number;
25
25
  onNearBottomChange?: ChatMessageListProps['onNearBottomChange'];
26
26
  listRef?: React.RefObject<ChatMessageListRef | null>;
27
27
  };
@@ -35,6 +35,7 @@ export function ChatPage({
35
35
  composer,
36
36
  overlay,
37
37
  style,
38
+ composerHorizontalPadding,
38
39
  onNearBottomChange,
39
40
  listRef,
40
41
  }: ChatPageProps) {
@@ -42,11 +43,9 @@ export function ChatPage({
42
43
  const insets = useSafeAreaInsets();
43
44
  const [composerHeight, setComposerHeight] = React.useState(0);
44
45
  const [keyboardVisible, setKeyboardVisible] = React.useState(false);
45
- const animatedKeyboard = useAnimatedKeyboard();
46
46
 
47
47
  React.useEffect(() => {
48
48
  if (Platform.OS !== 'ios') return;
49
-
50
49
  const show = Keyboard.addListener('keyboardWillShow', () => setKeyboardVisible(true));
51
50
  const hide = Keyboard.addListener('keyboardWillHide', () => setKeyboardVisible(false));
52
51
  return () => {
@@ -56,10 +55,6 @@ export function ChatPage({
56
55
  }, []);
57
56
 
58
57
  const footerBottomPadding = Platform.OS === 'ios' ? (keyboardVisible ? 0 : insets.bottom) : insets.bottom + 10;
59
- const footerAnimatedStyle = useAnimatedStyle(() => {
60
- if (Platform.OS !== 'ios') return { paddingBottom: insets.bottom + 10 };
61
- return { paddingBottom: animatedKeyboard.height.value > 0 ? 0 : insets.bottom };
62
- });
63
58
  const overlayBottom = composerHeight + footerBottomPadding + theme.spacing.lg;
64
59
  const bottomInset = composerHeight + footerBottomPadding + theme.spacing.xl;
65
60
 
@@ -80,35 +75,36 @@ export function ChatPage({
80
75
  </View>
81
76
  ) : null}
82
77
  <View style={{ flex: 1 }}>
83
- <ChatMessageList
84
- ref={listRef}
85
- messages={messages}
86
- showTypingIndicator={showTypingIndicator}
87
- renderMessageContent={renderMessageContent}
88
- onNearBottomChange={onNearBottomChange}
89
- bottomInset={bottomInset}
90
- />
91
- {resolvedOverlay}
92
-
93
- <Animated.View
94
- style={[
95
- {
96
- position: 'absolute',
97
- left: 0,
98
- right: 0,
99
- bottom: 0,
100
- paddingHorizontal: theme.spacing.lg,
101
- paddingTop: theme.spacing.sm,
102
- },
103
- footerAnimatedStyle,
104
- ]}
78
+ <View
79
+ style={{ flex: 1 }}
80
+ >
81
+ <ChatMessageList
82
+ ref={listRef}
83
+ messages={messages}
84
+ showTypingIndicator={showTypingIndicator}
85
+ renderMessageContent={renderMessageContent}
86
+ onNearBottomChange={onNearBottomChange}
87
+ bottomInset={bottomInset}
88
+ />
89
+ {resolvedOverlay}
90
+ </View>
91
+ <View
92
+ style={{
93
+ position: 'absolute',
94
+ left: 0,
95
+ right: 0,
96
+ bottom: 0,
97
+ paddingHorizontal: composerHorizontalPadding ?? theme.spacing.md,
98
+ paddingTop: theme.spacing.sm,
99
+ paddingBottom: footerBottomPadding,
100
+ }}
105
101
  >
106
102
  <ChatComposer
107
103
  {...composer}
108
104
  attachments={composer.attachments ?? []}
109
105
  onLayout={({ height }) => setComposerHeight(height)}
110
106
  />
111
- </Animated.View>
107
+ </View>
112
108
  </View>
113
109
  </View>
114
110
  );
@@ -35,8 +35,7 @@ export type StudioBottomSheetProps = {
35
35
  children: React.ReactNode;
36
36
 
37
37
  /**
38
- * Additional BottomSheet props, for advanced tuning.
39
- * We intentionally do not expose everything as first-class props to keep SRP.
38
+ * Additional BottomSheet props
40
39
  */
41
40
  bottomSheetProps?: Omit<
42
41
  BottomSheetProps,
@@ -56,7 +55,7 @@ export type StudioBottomSheetProps = {
56
55
  export function StudioBottomSheet({
57
56
  open,
58
57
  onOpenChange,
59
- snapPoints = ['80%', '100%'],
58
+ snapPoints = ['100%'],
60
59
  sheetRef,
61
60
  background,
62
61
  children,
@@ -93,20 +92,6 @@ export function StudioBottomSheet({
93
92
  return () => sub.remove();
94
93
  }, [open, resolvedSheetRef]);
95
94
 
96
- React.useEffect(() => {
97
- if (Platform.OS !== 'ios') return;
98
- const sub = Keyboard.addListener('keyboardDidHide', () => {
99
- const sheet = resolvedSheetRef.current;
100
- if (!sheet || !open) return;
101
- const targetIndex = snapPoints.length - 1;
102
- // Only "re-snap" if we're already at the highest snap point.
103
- if (currentIndexRef.current === targetIndex) {
104
- setTimeout(() => sheet.snapToIndex(targetIndex), 10);
105
- }
106
- });
107
- return () => sub.remove();
108
- }, [open, resolvedSheetRef, snapPoints.length]);
109
-
110
95
  React.useEffect(() => {
111
96
  const sheet = resolvedSheetRef.current;
112
97
  if (!sheet) return;
@@ -132,9 +117,9 @@ export function StudioBottomSheet({
132
117
  ref={resolvedSheetRef}
133
118
  index={open ? snapPoints.length - 1 : -1}
134
119
  snapPoints={snapPoints}
120
+ enableDynamicSizing={false}
135
121
  enablePanDownToClose
136
- keyboardBehavior="interactive"
137
- keyboardBlurBehavior="restore"
122
+ enableContentPanningGesture={false}
138
123
  android_keyboardInputMode="adjustResize"
139
124
  backgroundComponent={(props: BottomSheetBackgroundProps) => (
140
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 appKey={appKey} bundlePath={bundle.bundlePath} renderToken={bundle.renderToken} />
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 resp = await fetch(normalized);
33
- const blob = await resp.blob();
34
- return { blob, idx };
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: blob.type || 'image/png',
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
- const res = await FileSystem.downloadAsync(url, fileUri);
123
- return res.uri;
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 FileSystem.downloadAsync(url, tmpUri);
140
- const tmpOk = await getExistingNonEmptyFileUri(tmpUri);
141
- if (!tmpOk) {
142
- throw new Error('Downloaded bundle is empty.');
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
- const bundle = await bundlesRepository.getById(appId, bundleId);
160
- if (bundle.status === 'succeeded' || bundle.status === 'failed') return bundle;
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 new Promise((r) => setTimeout(r, opts.intervalMs));
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 bundlesRepository.initiate(appId, {
178
- platform,
179
- commitId: commitId ?? undefined,
180
- idempotencyKey: `${appId}:${commitId ?? 'head'}:${platform}`,
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 bundlesRepository.getSignedDownloadUrl(appId, finalBundle.id, { redirect: false });
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