@comergehq/studio 0.1.13 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,9 @@
1
1
  import * as React from 'react';
2
2
  import * as FileSystem from 'expo-file-system/legacy';
3
3
  import { Asset } from 'expo-asset';
4
+ import { unzip } from 'react-native-zip-archive';
4
5
 
5
- import type { Platform as BundlePlatform, Bundle } from '../../data/apps/bundles/types';
6
+ import type { Platform as BundlePlatform, Bundle, BundleAsset } from '../../data/apps/bundles/types';
6
7
  import { bundlesRepository } from '../../data/apps/bundles/repository';
7
8
 
8
9
  function sleep(ms: number): Promise<void> {
@@ -90,6 +91,8 @@ export type UseBundleManagerResult = BundleLoadState & {
90
91
  export type EmbeddedBaseBundle = {
91
92
  module: number;
92
93
  meta?: BaseBundleMeta | null;
94
+ assetsModule?: number;
95
+ assetsMeta?: EmbeddedAssetsMeta | null;
93
96
  };
94
97
 
95
98
  export type EmbeddedBaseBundles = {
@@ -97,6 +100,11 @@ export type EmbeddedBaseBundles = {
97
100
  android?: EmbeddedBaseBundle;
98
101
  };
99
102
 
103
+ type EmbeddedAssetsMeta = {
104
+ checksumSha256: string | null;
105
+ size: number | null;
106
+ };
107
+
100
108
  function safeName(s: string) {
101
109
  return s.replace(/[^a-zA-Z0-9._-]/g, '_');
102
110
  }
@@ -114,6 +122,11 @@ async function ensureDir(path: string) {
114
122
  await FileSystem.makeDirectoryAsync(path, { intermediates: true });
115
123
  }
116
124
 
125
+ async function ensureBundleDir(key: string) {
126
+ await ensureDir(bundlesCacheDir());
127
+ await ensureDir(bundleDir(key));
128
+ }
129
+
117
130
  function baseBundleKey(appId: string, platform: BundlePlatform): string {
118
131
  return `base:${appId}:${platform}`;
119
132
  }
@@ -122,16 +135,37 @@ function testBundleKey(appId: string, commitId: string | null | undefined, platf
122
135
  return `test:${appId}:${commitId ?? 'head'}:${platform}:${bundleId}`;
123
136
  }
124
137
 
125
- function toBundleFileUri(key: string): string {
138
+ function legacyBundleFileUri(key: string): string {
126
139
  const dir = bundlesCacheDir();
127
140
  return `${dir}${safeName(key)}.jsbundle`;
128
141
  }
129
142
 
130
- function toBundleMetaFileUri(key: string): string {
143
+ function legacyBundleMetaFileUri(key: string): string {
131
144
  const dir = bundlesCacheDir();
132
145
  return `${dir}${safeName(key)}.meta.json`;
133
146
  }
134
147
 
148
+ function bundleDir(key: string): string {
149
+ const dir = bundlesCacheDir();
150
+ return `${dir}${safeName(key)}/`;
151
+ }
152
+
153
+ function toBundleFileUri(key: string, platform: BundlePlatform): string {
154
+ return `${bundleDir(key)}index.${platform}.jsbundle`;
155
+ }
156
+
157
+ function toBundleMetaFileUri(key: string): string {
158
+ return `${bundleDir(key)}bundle.meta.json`;
159
+ }
160
+
161
+ function toAssetsMetaFileUri(key: string): string {
162
+ return `${bundleDir(key)}assets.meta.json`;
163
+ }
164
+
165
+ function toAssetsDir(key: string): string {
166
+ return `${bundleDir(key)}assets/`;
167
+ }
168
+
135
169
  type BaseBundleMeta = {
136
170
  fingerprint: string;
137
171
  bundleId: string;
@@ -156,7 +190,7 @@ async function writeJsonFile(fileUri: string, value: unknown): Promise<void> {
156
190
  try {
157
191
  await (FileSystem as any).writeAsStringAsync(fileUri, JSON.stringify(value));
158
192
  } catch {
159
-
193
+
160
194
  }
161
195
  }
162
196
 
@@ -170,6 +204,15 @@ async function getExistingNonEmptyFileUri(fileUri: string): Promise<string | nul
170
204
  }
171
205
  }
172
206
 
207
+ async function getExistingBundleFileUri(key: string, platform: BundlePlatform): Promise<string | null> {
208
+ const nextPath = toBundleFileUri(key, platform);
209
+ const next = await getExistingNonEmptyFileUri(nextPath);
210
+ if (next) return next;
211
+ const legacyPath = legacyBundleFileUri(key);
212
+ const legacy = await getExistingNonEmptyFileUri(legacyPath);
213
+ return legacy;
214
+ }
215
+
173
216
  async function downloadIfMissing(url: string, fileUri: string): Promise<string> {
174
217
  const existing = await getExistingNonEmptyFileUri(fileUri);
175
218
  if (existing) return existing;
@@ -189,9 +232,9 @@ async function deleteFileIfExists(fileUri: string) {
189
232
  try {
190
233
  const info = await FileSystem.getInfoAsync(fileUri);
191
234
  if (!info.exists) return;
192
- await FileSystem.deleteAsync(fileUri).catch(() => {});
235
+ await FileSystem.deleteAsync(fileUri).catch(() => { });
193
236
  } catch {
194
-
237
+
195
238
  }
196
239
  }
197
240
 
@@ -202,9 +245,12 @@ async function hydrateBaseFromEmbeddedAsset(
202
245
  ): Promise<{ bundlePath: string; meta?: BaseBundleMeta | null } | null> {
203
246
  if (!embedded?.module) return null;
204
247
  const key = baseBundleKey(appId, platform);
205
- const targetUri = toBundleFileUri(key);
206
- const existing = await getExistingNonEmptyFileUri(targetUri);
207
- if (existing) return { bundlePath: existing, meta: embedded.meta ?? null };
248
+ const existing = await getExistingBundleFileUri(key, platform);
249
+ if (existing) {
250
+ return { bundlePath: existing, meta: embedded.meta ?? null };
251
+ }
252
+ await ensureBundleDir(key);
253
+ const targetUri = toBundleFileUri(key, platform);
208
254
 
209
255
  const asset = Asset.fromModule(embedded.module);
210
256
  await asset.downloadAsync();
@@ -220,8 +266,69 @@ async function hydrateBaseFromEmbeddedAsset(
220
266
  return { bundlePath: finalUri, meta: embedded.meta ?? null };
221
267
  }
222
268
 
223
- async function safeReplaceFileFromUrl(url: string, targetUri: string, tmpKey: string): Promise<string> {
224
- const tmpUri = toBundleFileUri(`tmp:${tmpKey}:${Date.now()}`);
269
+ async function hydrateAssetsFromEmbeddedAsset(
270
+ appId: string,
271
+ platform: BundlePlatform,
272
+ key: string,
273
+ embedded: EmbeddedBaseBundle | undefined
274
+ ): Promise<boolean> {
275
+ const moduleId = embedded?.assetsModule;
276
+ if (!moduleId) return false;
277
+
278
+ const assetsMeta = embedded?.assetsMeta ?? null;
279
+ const assetsDir = toAssetsDir(key);
280
+ const metaUri = toAssetsMetaFileUri(key);
281
+
282
+ const existingMeta = await readJsonFile<AssetsMeta>(metaUri);
283
+ const assetsDirInfo = await FileSystem.getInfoAsync(assetsDir);
284
+ const assetsDirExists = assetsDirInfo.exists && assetsDirInfo.isDirectory;
285
+ const checksumMatches =
286
+ Boolean(existingMeta?.checksumSha256) &&
287
+ Boolean(assetsMeta?.checksumSha256) &&
288
+ existingMeta?.checksumSha256 === assetsMeta?.checksumSha256;
289
+ const embeddedMetaMatches = existingMeta?.storageKey?.startsWith('embedded:');
290
+ if (assetsDirExists && checksumMatches && embeddedMetaMatches) {
291
+ return true;
292
+ }
293
+
294
+ await ensureBundleDir(key);
295
+ await ensureDir(assetsDir);
296
+
297
+ const asset = Asset.fromModule(moduleId);
298
+ await asset.downloadAsync();
299
+ const sourceUri = asset.localUri ?? asset.uri;
300
+ if (!sourceUri) return false;
301
+ const info = await FileSystem.getInfoAsync(sourceUri);
302
+ if (!info.exists) return false;
303
+
304
+ const zipUri = `${bundleDir(key)}assets.zip`;
305
+ await deleteFileIfExists(zipUri);
306
+ await FileSystem.copyAsync({ from: sourceUri, to: zipUri });
307
+
308
+ try {
309
+ await FileSystem.deleteAsync(assetsDir, { idempotent: true }).catch(() => { });
310
+ } catch {
311
+ }
312
+ await ensureDir(assetsDir);
313
+ await unzipArchive(zipUri, assetsDir);
314
+
315
+ await writeJsonFile(metaUri, {
316
+ checksumSha256: assetsMeta?.checksumSha256 ?? null,
317
+ storageKey: `embedded:${assetsMeta?.checksumSha256 ?? 'unknown'}`,
318
+ updatedAt: new Date().toISOString(),
319
+ } satisfies AssetsMeta);
320
+ return true;
321
+ }
322
+
323
+ async function safeReplaceFileFromUrl(
324
+ url: string,
325
+ targetUri: string,
326
+ tmpKey: string,
327
+ platform: BundlePlatform
328
+ ): Promise<string> {
329
+ const tmpKeySafe = `tmp:${tmpKey}:${Date.now()}`;
330
+ const tmpUri = toBundleFileUri(tmpKeySafe, platform);
331
+ await ensureDir(bundleDir(tmpKeySafe));
225
332
  try {
226
333
  await withRetry(
227
334
  async () => {
@@ -244,6 +351,107 @@ async function safeReplaceFileFromUrl(url: string, targetUri: string, tmpKey: st
244
351
  }
245
352
  }
246
353
 
354
+ async function safeReplaceFileFromUrlToPath(url: string, targetUri: string, tmpKey: string): Promise<string> {
355
+ const tmpDir = `${bundlesCacheDir()}tmp/`;
356
+ await ensureDir(tmpDir);
357
+ const tmpUri = `${tmpDir}${safeName(tmpKey)}.tmp`;
358
+ try {
359
+ await withRetry(
360
+ async () => {
361
+ await deleteFileIfExists(tmpUri);
362
+ await FileSystem.downloadAsync(url, tmpUri);
363
+ const tmpOk = await getExistingNonEmptyFileUri(tmpUri);
364
+ if (!tmpOk) throw new Error('Downloaded file is empty.');
365
+ },
366
+ { attempts: 3, baseDelayMs: 500, maxDelayMs: 4000 }
367
+ );
368
+ await deleteFileIfExists(targetUri);
369
+ await FileSystem.moveAsync({ from: tmpUri, to: targetUri });
370
+ const finalOk = await getExistingNonEmptyFileUri(targetUri);
371
+ if (!finalOk) throw new Error('File replacement failed.');
372
+ return targetUri;
373
+ } finally {
374
+ await deleteFileIfExists(tmpUri);
375
+ }
376
+ }
377
+
378
+ function getMetroAssets(bundle: Bundle): BundleAsset | null {
379
+ const assets = bundle.assets ?? [];
380
+ return assets.find((asset) => asset.kind === 'metro-assets') ?? null;
381
+ }
382
+
383
+ type AssetsMeta = {
384
+ checksumSha256: string | null;
385
+ storageKey: string;
386
+ updatedAt: string;
387
+ };
388
+
389
+ async function ensureAssetsForBundle(
390
+ appId: string,
391
+ bundle: Bundle,
392
+ key: string,
393
+ platform: BundlePlatform
394
+ ): Promise<void> {
395
+ const asset = getMetroAssets(bundle);
396
+ if (!asset?.storageKey) return;
397
+
398
+ await ensureBundleDir(key);
399
+ const assetsDir = toAssetsDir(key);
400
+ await ensureDir(assetsDir);
401
+
402
+ const metaUri = toAssetsMetaFileUri(key);
403
+ const existingMeta = await readJsonFile<AssetsMeta>(metaUri);
404
+ const assetsDirInfo = await FileSystem.getInfoAsync(assetsDir);
405
+ const assetsDirExists = assetsDirInfo.exists && assetsDirInfo.isDirectory;
406
+ if (
407
+ existingMeta?.checksumSha256 &&
408
+ asset.checksumSha256 &&
409
+ existingMeta.checksumSha256 === asset.checksumSha256 &&
410
+ (existingMeta.storageKey === asset.storageKey || existingMeta.storageKey?.startsWith('embedded:')) &&
411
+ assetsDirExists
412
+ ) {
413
+ return;
414
+ }
415
+
416
+ const signed = await withRetry(
417
+ async () => {
418
+ return await bundlesRepository.getSignedAssetsDownloadUrl(appId, bundle.id, {
419
+ redirect: false,
420
+ kind: asset.kind,
421
+ });
422
+ },
423
+ { attempts: 3, baseDelayMs: 500, maxDelayMs: 4000 }
424
+ );
425
+
426
+ const zipUri = `${bundleDir(key)}assets.zip`;
427
+ await safeReplaceFileFromUrlToPath(signed.url, zipUri, `${appId}:${bundle.id}:${platform}:${asset.kind}`);
428
+
429
+ try {
430
+ await FileSystem.deleteAsync(assetsDir, { idempotent: true }).catch(() => { });
431
+ } catch {
432
+
433
+ }
434
+ await ensureDir(assetsDir);
435
+ await unzipArchive(zipUri, assetsDir);
436
+ await writeJsonFile(metaUri, {
437
+ checksumSha256: asset.checksumSha256 ?? null,
438
+ storageKey: asset.storageKey,
439
+ updatedAt: new Date().toISOString(),
440
+ } satisfies AssetsMeta);
441
+ }
442
+
443
+ async function unzipArchive(sourceUri: string, destDir: string) {
444
+ try {
445
+ await unzip(sourceUri, destDir);
446
+ } catch (e) {
447
+ throw new Error(
448
+ `Failed to extract assets archive. Ensure 'react-native-zip-archive' is installed in the host app. ${String(
449
+ (e as Error)?.message ?? e
450
+ )}`
451
+ );
452
+ }
453
+ }
454
+
247
455
  async function pollBundle(appId: string, bundleId: string, opts: { timeoutMs: number; intervalMs: number }): Promise<Bundle> {
248
456
  const start = Date.now();
249
457
  while (true) {
@@ -291,21 +499,41 @@ async function resolveBundlePath(
291
499
  throw new Error('Bundle build failed.');
292
500
  }
293
501
 
502
+ let bundleWithAssets = finalBundle;
503
+ if (finalBundle.status === 'succeeded' && (!finalBundle.assets || finalBundle.assets.length === 0)) {
504
+ try {
505
+ bundleWithAssets = await bundlesRepository.getById(appId, finalBundle.id);
506
+ } catch {
507
+ bundleWithAssets = finalBundle;
508
+ }
509
+ }
510
+
294
511
  const signed = await withRetry(
295
512
  async () => {
296
513
  return await bundlesRepository.getSignedDownloadUrl(appId, finalBundle.id, { redirect: false });
297
514
  },
298
515
  { attempts: 3, baseDelayMs: 500, maxDelayMs: 4000 }
299
516
  );
517
+ const key =
518
+ mode === 'base'
519
+ ? baseBundleKey(appId, platform)
520
+ : testBundleKey(appId, commitId, platform, finalBundle.id);
521
+ await ensureBundleDir(key);
300
522
  const bundlePath =
301
523
  mode === 'base'
302
524
  ? await safeReplaceFileFromUrl(
303
- signed.url,
304
- toBundleFileUri(baseBundleKey(appId, platform)),
305
- `${appId}:${commitId ?? 'head'}:${platform}:${finalBundle.id}`
306
- )
307
- : await downloadIfMissing(signed.url, toBundleFileUri(testBundleKey(appId, commitId, platform, finalBundle.id)));
308
- return { bundlePath, label: 'Ready', bundle: finalBundle };
525
+ signed.url,
526
+ toBundleFileUri(key, platform),
527
+ `${appId}:${commitId ?? 'head'}:${platform}:${finalBundle.id}`,
528
+ platform
529
+ )
530
+ : await downloadIfMissing(signed.url, toBundleFileUri(key, platform));
531
+ try {
532
+ await ensureAssetsForBundle(appId, bundleWithAssets, key, platform);
533
+ } catch {
534
+
535
+ }
536
+ return { bundlePath, label: 'Ready', bundle: bundleWithAssets };
309
537
  }
310
538
 
311
539
  export function useBundleManager({
@@ -360,8 +588,7 @@ export function useBundleManager({
360
588
  const dir = bundlesCacheDir();
361
589
  await ensureDir(dir);
362
590
  const key = baseBundleKey(appId, platform);
363
- const uri = toBundleFileUri(key);
364
- let existing = await getExistingNonEmptyFileUri(uri);
591
+ let existing = await getExistingBundleFileUri(key, platform);
365
592
  let embeddedMeta: BaseBundleMeta | null = null;
366
593
  if (!existing) {
367
594
  const embedded = embeddedBaseBundlesRef.current?.[platform];
@@ -370,14 +597,29 @@ export function useBundleManager({
370
597
  existing = hydrated.bundlePath;
371
598
  embeddedMeta = hydrated.meta ?? null;
372
599
  if (embeddedMeta) {
600
+ await ensureBundleDir(key);
373
601
  await writeJsonFile(toBundleMetaFileUri(key), embeddedMeta);
602
+ await writeJsonFile(legacyBundleMetaFileUri(key), embeddedMeta);
374
603
  }
375
604
  }
376
605
  }
377
606
  if (existing) {
378
607
  lastBaseBundlePathRef.current = existing;
379
608
  setBundlePath(existing);
380
- const meta = embeddedMeta ?? (await readJsonFile<BaseBundleMeta>(toBundleMetaFileUri(key)));
609
+ const meta =
610
+ embeddedMeta ??
611
+ (await readJsonFile<BaseBundleMeta>(toBundleMetaFileUri(key))) ??
612
+ (await readJsonFile<BaseBundleMeta>(legacyBundleMetaFileUri(key)));
613
+ const embedded = embeddedBaseBundlesRef.current?.[platform];
614
+ const embeddedFingerprint = embedded?.meta?.fingerprint ?? null;
615
+ const actualFingerprint = meta?.fingerprint ?? embeddedMeta?.fingerprint ?? null;
616
+ if (embedded?.assetsModule && embeddedFingerprint && actualFingerprint === embeddedFingerprint) {
617
+ try {
618
+ await hydrateAssetsFromEmbeddedAsset(appId, platform, key, embedded);
619
+ } catch {
620
+
621
+ }
622
+ }
381
623
  if (meta?.fingerprint) {
382
624
  lastBaseFingerprintRef.current = meta.fingerprint;
383
625
  }
@@ -387,7 +629,7 @@ export function useBundleManager({
387
629
  }
388
630
  }
389
631
  } catch {
390
-
632
+
391
633
  }
392
634
  },
393
635
  [platform]
@@ -461,7 +703,16 @@ export function useBundleManager({
461
703
  lastBaseFingerprintRef.current = fingerprint;
462
704
  hasCompletedFirstNetworkBaseLoadRef.current = true;
463
705
  initialHydratedBaseFromDiskRef.current = false;
464
- void writeJsonFile(toBundleMetaFileUri(baseBundleKey(src.appId, platform)), {
706
+ const metaKey = baseBundleKey(src.appId, platform);
707
+ await ensureBundleDir(metaKey);
708
+ void writeJsonFile(toBundleMetaFileUri(metaKey), {
709
+ fingerprint,
710
+ bundleId: bundle.id,
711
+ checksumSha256: bundle.checksumSha256 ?? null,
712
+ size: bundle.size ?? null,
713
+ updatedAt: new Date().toISOString(),
714
+ } satisfies BaseBundleMeta);
715
+ void writeJsonFile(legacyBundleMetaFileUri(metaKey), {
465
716
  fingerprint,
466
717
  bundleId: bundle.id,
467
718
  checksumSha256: bundle.checksumSha256 ?? null,
@@ -0,0 +1,71 @@
1
+ import * as React from 'react';
2
+
3
+ import type { EditQueueItem } from '../../data/apps/edit-queue/types';
4
+ import { editQueueRepository } from '../../data/apps/edit-queue/repository';
5
+ import { useForegroundSignal } from './useForegroundSignal';
6
+
7
+ export type UseEditQueueResult = {
8
+ items: EditQueueItem[];
9
+ loading: boolean;
10
+ error: Error | null;
11
+ refetch: () => Promise<void>;
12
+ };
13
+
14
+ export function useEditQueue(appId: string): UseEditQueueResult {
15
+ const [items, setItems] = React.useState<EditQueueItem[]>([]);
16
+ const [loading, setLoading] = React.useState(false);
17
+ const [error, setError] = React.useState<Error | null>(null);
18
+ const activeRequestIdRef = React.useRef(0);
19
+ const foregroundSignal = useForegroundSignal(Boolean(appId));
20
+
21
+ const upsertSorted = React.useCallback((prev: EditQueueItem[], nextItem: EditQueueItem) => {
22
+ const next = prev.some((x) => x.id === nextItem.id)
23
+ ? prev.map((x) => (x.id === nextItem.id ? nextItem : x))
24
+ : [...prev, nextItem];
25
+ next.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
26
+ return next;
27
+ }, []);
28
+
29
+ const refetch = React.useCallback(async () => {
30
+ if (!appId) {
31
+ setItems([]);
32
+ return;
33
+ }
34
+ const requestId = ++activeRequestIdRef.current;
35
+ setLoading(true);
36
+ setError(null);
37
+ try {
38
+ const list = await editQueueRepository.list(appId);
39
+ if (activeRequestIdRef.current !== requestId) return;
40
+ setItems([...list].sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt))));
41
+ } catch (e) {
42
+ if (activeRequestIdRef.current !== requestId) return;
43
+ setError(e instanceof Error ? e : new Error(String(e)));
44
+ setItems([]);
45
+ } finally {
46
+ if (activeRequestIdRef.current === requestId) setLoading(false);
47
+ }
48
+ }, [appId]);
49
+
50
+ React.useEffect(() => {
51
+ void refetch();
52
+ }, [refetch]);
53
+
54
+ React.useEffect(() => {
55
+ if (!appId) return;
56
+ const unsubscribe = editQueueRepository.subscribeEditQueue(appId, {
57
+ onInsert: (item) => setItems((prev) => upsertSorted(prev, item)),
58
+ onUpdate: (item) => setItems((prev) => upsertSorted(prev, item)),
59
+ onDelete: (item) => setItems((prev) => prev.filter((x) => x.id !== item.id)),
60
+ });
61
+ return unsubscribe;
62
+ }, [appId, upsertSorted, foregroundSignal]);
63
+
64
+ React.useEffect(() => {
65
+ if (!appId) return;
66
+ if (foregroundSignal <= 0) return;
67
+ void refetch();
68
+ }, [appId, foregroundSignal, refetch]);
69
+
70
+ return { items, loading, error, refetch };
71
+ }
@@ -0,0 +1,29 @@
1
+ import * as React from 'react';
2
+
3
+ import type { UpdateEditQueueItemRequest } from '../../data/apps/edit-queue/types';
4
+ import { editQueueRepository } from '../../data/apps/edit-queue/repository';
5
+
6
+ export type UseEditQueueActionsResult = {
7
+ update: (queueItemId: string, payload: UpdateEditQueueItemRequest) => Promise<void>;
8
+ cancel: (queueItemId: string) => Promise<void>;
9
+ };
10
+
11
+ export function useEditQueueActions(appId: string): UseEditQueueActionsResult {
12
+ const update = React.useCallback(
13
+ async (queueItemId: string, payload: UpdateEditQueueItemRequest) => {
14
+ if (!appId) return;
15
+ await editQueueRepository.update(appId, queueItemId, payload);
16
+ },
17
+ [appId]
18
+ );
19
+
20
+ const cancel = React.useCallback(
21
+ async (queueItemId: string) => {
22
+ if (!appId) return;
23
+ await editQueueRepository.cancel(appId, queueItemId);
24
+ },
25
+ [appId]
26
+ );
27
+
28
+ return { update, cancel };
29
+ }
@@ -5,6 +5,7 @@ import type { ChatMessage } from '../../components/models/types';
5
5
  export type UseOptimisticChatMessagesParams = {
6
6
  threadId: string | null;
7
7
  shouldForkOnEdit: boolean;
8
+ disableOptimistic?: boolean;
8
9
  chatMessages: ChatMessage[];
9
10
  onSendChat: (text: string, attachments?: string[]) => void | Promise<void>;
10
11
  };
@@ -63,6 +64,7 @@ function isOptimisticResolvedByServer(chatMessages: ChatMessage[], o: Optimistic
63
64
  export function useOptimisticChatMessages({
64
65
  threadId,
65
66
  shouldForkOnEdit,
67
+ disableOptimistic = false,
66
68
  chatMessages,
67
69
  onSendChat,
68
70
  }: UseOptimisticChatMessagesParams): UseOptimisticChatMessagesResult {
@@ -105,7 +107,7 @@ export function useOptimisticChatMessages({
105
107
 
106
108
  const onSend = React.useCallback(
107
109
  async (text: string, attachments?: string[]) => {
108
- if (shouldForkOnEdit) {
110
+ if (shouldForkOnEdit || disableOptimistic) {
109
111
  await onSendChat(text, attachments);
110
112
  return;
111
113
  }
@@ -120,7 +122,7 @@ export function useOptimisticChatMessages({
120
122
  setOptimisticChat((prev) => prev.map((m) => (m.id === id ? { ...m, failed: true } : m)));
121
123
  });
122
124
  },
123
- [chatMessages, onSendChat, shouldForkOnEdit]
125
+ [chatMessages, disableOptimistic, onSendChat, shouldForkOnEdit]
124
126
  );
125
127
 
126
128
  return { messages, onSend };
@@ -15,6 +15,9 @@ export type UseStudioActionsParams = {
15
15
  * Called when we fork and should switch to the new app.
16
16
  */
17
17
  onForkedApp?: (appId: string, opts?: { keepRenderingAppId?: string }) => void;
18
+ onEditStart?: () => void;
19
+ onEditQueued?: (info: { queueItemId?: string | null; queuePosition?: number | null }) => void;
20
+ onEditFinished?: () => void;
18
21
  /**
19
22
  * Upload function used to convert attachments.
20
23
  */
@@ -34,6 +37,9 @@ export function useStudioActions({
34
37
  userId,
35
38
  app,
36
39
  onForkedApp,
40
+ onEditStart,
41
+ onEditQueued,
42
+ onEditFinished,
37
43
  uploadAttachments,
38
44
  }: UseStudioActionsParams): UseStudioActionsResult {
39
45
  const [forking, setForking] = React.useState(false);
@@ -52,6 +58,7 @@ export function useStudioActions({
52
58
  setSending(true);
53
59
  setError(null);
54
60
  try {
61
+ onEditStart?.();
55
62
  let targetApp = app;
56
63
 
57
64
  if (shouldForkOnEdit) {
@@ -72,12 +79,16 @@ export function useStudioActions({
72
79
  attachmentMetas = await uploadAttachments({ threadId, appId: targetApp.id, dataUrls: attachments });
73
80
  }
74
81
 
75
- await agentRepository.editApp({
82
+ const editResult = await agentRepository.editApp({
76
83
  prompt,
77
84
  thread_id: threadId,
78
85
  app_id: targetApp.id,
79
86
  attachments: attachmentMetas && attachmentMetas.length > 0 ? attachmentMetas : undefined,
80
87
  });
88
+ onEditQueued?.({
89
+ queueItemId: editResult.queueItemId ?? null,
90
+ queuePosition: editResult.queuePosition ?? null,
91
+ });
81
92
  } catch (e) {
82
93
  const err = e instanceof Error ? e : new Error(String(e));
83
94
  setError(err);
@@ -85,9 +96,10 @@ export function useStudioActions({
85
96
  } finally {
86
97
  setForking(false);
87
98
  setSending(false);
99
+ onEditFinished?.();
88
100
  }
89
101
  },
90
- [app, onForkedApp, sending, shouldForkOnEdit, uploadAttachments, userId]
102
+ [app, onEditFinished, onEditQueued, onEditStart, onForkedApp, sending, shouldForkOnEdit, uploadAttachments, userId]
91
103
  );
92
104
 
93
105
  return { isOwner, shouldForkOnEdit, forking, sending, error, sendEdit };
@@ -29,6 +29,40 @@ function extractMeta(payload: unknown): ChatMessage['meta'] {
29
29
  };
30
30
  }
31
31
 
32
+ function getPayloadMeta(payload: Message['payload']): Record<string, unknown> | null {
33
+ const meta = (payload as any)?.meta;
34
+ if (!meta || typeof meta !== 'object') return null;
35
+ return meta as Record<string, unknown>;
36
+ }
37
+
38
+ function isQueuedHiddenMessage(m: Message): boolean {
39
+ if (m.authorType !== 'human') return false;
40
+ const meta = getPayloadMeta(m.payload);
41
+ return meta?.visibility === 'queued';
42
+ }
43
+
44
+ function toEpochMs(value: unknown): number {
45
+ if (value == null) return 0;
46
+ if (typeof value === 'number') return value;
47
+ if (value instanceof Date) return value.getTime();
48
+ const parsed = Date.parse(String(value));
49
+ return Number.isFinite(parsed) ? parsed : 0;
50
+ }
51
+
52
+ function getEffectiveSortMs(m: Message): number {
53
+ const meta = getPayloadMeta(m.payload);
54
+ const runStartedAt = meta?.runStartedAt;
55
+ const runMs = toEpochMs(runStartedAt);
56
+ return runMs > 0 ? runMs : toEpochMs(m.createdAt);
57
+ }
58
+
59
+ function compareMessages(a: Message, b: Message): number {
60
+ const aMs = getEffectiveSortMs(a);
61
+ const bMs = getEffectiveSortMs(b);
62
+ if (aMs !== bMs) return aMs - bMs;
63
+ return String(a.createdAt).localeCompare(String(b.createdAt));
64
+ }
65
+
32
66
  function mapMessageToChatMessage(m: Message): ChatMessage {
33
67
  const kind = typeof (m.payload as any)?.type === 'string' ? String((m.payload as any).type) : null;
34
68
  return {
@@ -49,9 +83,10 @@ export function useThreadMessages(threadId: string): UseThreadMessagesResult {
49
83
  const foregroundSignal = useForegroundSignal(Boolean(threadId));
50
84
 
51
85
  const upsertSorted = React.useCallback((prev: Message[], m: Message) => {
52
- const next = prev.some((x) => x.id === m.id) ? prev.map((x) => (x.id === m.id ? m : x)) : [...prev, m];
53
- // Keep ordering stable for the UI (chat scrolling is very sensitive to reorders).
54
- next.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
86
+ const include = !isQueuedHiddenMessage(m);
87
+ const next = prev.filter((x) => x.id !== m.id);
88
+ if (include) next.push(m);
89
+ next.sort(compareMessages);
55
90
  return next;
56
91
  }, []);
57
92
 
@@ -66,8 +101,7 @@ export function useThreadMessages(threadId: string): UseThreadMessagesResult {
66
101
  try {
67
102
  const list = await messagesRepository.list(threadId);
68
103
  if (activeRequestIdRef.current !== requestId) return;
69
- // Ensure stable ordering for downstream scrolling behavior.
70
- setRaw([...list].sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt))));
104
+ setRaw([...list].filter((m) => !isQueuedHiddenMessage(m)).sort(compareMessages));
71
105
  } catch (e) {
72
106
  if (activeRequestIdRef.current !== requestId) return;
73
107
  setError(e instanceof Error ? e : new Error(String(e)));