@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comergehq/studio",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Comerge studio",
5
5
  "main": "src/index.ts",
6
6
  "module": "dist/index.mjs",
@@ -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,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
- data={messages}
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
- paddingTop: theme.spacing.sm,
131
- paddingBottom: theme.spacing.sm,
118
+ paddingVertical: theme.spacing.sm,
132
119
  },
133
120
  contentStyle,
134
121
  ]}
135
- renderItem={({ item, index }: { item: ChatMessage; index: number }) => (
136
- <View style={{ marginTop: index === 0 ? 0 : theme.spacing.sm }}>
137
- <ChatMessageBubble message={item} renderContent={renderMessageContent} />
138
- </View>
122
+ ItemSeparatorComponent={() => <View style={{ height: theme.spacing.sm }} />}
123
+ renderItem={({ item }: { item: ChatMessage }) => (
124
+ <ChatMessageBubble message={item} renderContent={renderMessageContent} />
139
125
  )}
140
- ListFooterComponent={
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.lg,
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 = ['80%', '100%'],
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
- keyboardBehavior="interactive"
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 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
 
@@ -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
+
@@ -129,6 +129,7 @@ export function ChatPanel({
129
129
  messages={messages}
130
130
  showTypingIndicator={showTypingIndicator}
131
131
  topBanner={topBanner}
132
+ composerHorizontalPadding={0}
132
133
  listRef={listRef}
133
134
  onNearBottomChange={setNearBottom}
134
135
  overlay={
@@ -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={chatMessages}
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={onSendChat}
240
+ onSend={optimistic.onSend}
232
241
  />
233
242
  }
234
243
  />