@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/dist/index.mjs CHANGED
@@ -6,7 +6,7 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
6
6
  });
7
7
 
8
8
  // src/studio/ComergeStudio.tsx
9
- import * as React39 from "react";
9
+ import * as React40 from "react";
10
10
  import { Platform as RNPlatform, View as View45 } from "react-native";
11
11
  import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
12
12
 
@@ -952,6 +952,39 @@ var BundlesRepositoryImpl = class extends BaseRepository {
952
952
  var bundlesRepository = new BundlesRepositoryImpl(bundlesRemoteDataSource);
953
953
 
954
954
  // src/studio/hooks/useBundleManager.ts
955
+ function sleep(ms) {
956
+ return new Promise((r) => setTimeout(r, ms));
957
+ }
958
+ function isRetryableNetworkError(e) {
959
+ var _a;
960
+ const err = e;
961
+ const code = typeof (err == null ? void 0 : err.code) === "string" ? err.code : "";
962
+ const message = typeof (err == null ? void 0 : err.message) === "string" ? err.message : "";
963
+ if (code === "ERR_NETWORK" || code === "ECONNABORTED") return true;
964
+ if (message.toLowerCase().includes("network error")) return true;
965
+ if (message.toLowerCase().includes("timeout")) return true;
966
+ const status = typeof ((_a = err == null ? void 0 : err.response) == null ? void 0 : _a.status) === "number" ? err.response.status : void 0;
967
+ if (status && (status === 429 || status >= 500)) return true;
968
+ return false;
969
+ }
970
+ async function withRetry(fn, opts) {
971
+ let lastErr = null;
972
+ for (let attempt = 1; attempt <= opts.attempts; attempt += 1) {
973
+ try {
974
+ return await fn();
975
+ } catch (e) {
976
+ lastErr = e;
977
+ const retryable = isRetryableNetworkError(e);
978
+ if (!retryable || attempt >= opts.attempts) {
979
+ throw e;
980
+ }
981
+ const exp = Math.min(opts.maxDelayMs, opts.baseDelayMs * Math.pow(2, attempt - 1));
982
+ const jitter = Math.floor(Math.random() * 250);
983
+ await sleep(exp + jitter);
984
+ }
985
+ }
986
+ throw lastErr;
987
+ }
955
988
  function safeName(s) {
956
989
  return s.replace(/[^a-zA-Z0-9._-]/g, "_");
957
990
  }
@@ -1009,8 +1042,16 @@ async function getExistingNonEmptyFileUri(fileUri) {
1009
1042
  async function downloadIfMissing(url, fileUri) {
1010
1043
  const existing = await getExistingNonEmptyFileUri(fileUri);
1011
1044
  if (existing) return existing;
1012
- const res = await FileSystem.downloadAsync(url, fileUri);
1013
- return res.uri;
1045
+ return await withRetry(
1046
+ async () => {
1047
+ await deleteFileIfExists(fileUri);
1048
+ const res = await FileSystem.downloadAsync(url, fileUri);
1049
+ const ok = await getExistingNonEmptyFileUri(res.uri);
1050
+ if (!ok) throw new Error("Downloaded bundle is empty.");
1051
+ return res.uri;
1052
+ },
1053
+ { attempts: 3, baseDelayMs: 500, maxDelayMs: 4e3 }
1054
+ );
1014
1055
  }
1015
1056
  async function deleteFileIfExists(fileUri) {
1016
1057
  try {
@@ -1024,11 +1065,15 @@ async function deleteFileIfExists(fileUri) {
1024
1065
  async function safeReplaceFileFromUrl(url, targetUri, tmpKey) {
1025
1066
  const tmpUri = toBundleFileUri(`tmp:${tmpKey}:${Date.now()}`);
1026
1067
  try {
1027
- await FileSystem.downloadAsync(url, tmpUri);
1028
- const tmpOk = await getExistingNonEmptyFileUri(tmpUri);
1029
- if (!tmpOk) {
1030
- throw new Error("Downloaded bundle is empty.");
1031
- }
1068
+ await withRetry(
1069
+ async () => {
1070
+ await deleteFileIfExists(tmpUri);
1071
+ await FileSystem.downloadAsync(url, tmpUri);
1072
+ const tmpOk = await getExistingNonEmptyFileUri(tmpUri);
1073
+ if (!tmpOk) throw new Error("Downloaded bundle is empty.");
1074
+ },
1075
+ { attempts: 3, baseDelayMs: 500, maxDelayMs: 4e3 }
1076
+ );
1032
1077
  await deleteFileIfExists(targetUri);
1033
1078
  await FileSystem.moveAsync({ from: tmpUri, to: targetUri });
1034
1079
  const finalOk = await getExistingNonEmptyFileUri(targetUri);
@@ -1041,28 +1086,44 @@ async function safeReplaceFileFromUrl(url, targetUri, tmpKey) {
1041
1086
  async function pollBundle(appId, bundleId, opts) {
1042
1087
  const start = Date.now();
1043
1088
  while (true) {
1044
- const bundle = await bundlesRepository.getById(appId, bundleId);
1045
- if (bundle.status === "succeeded" || bundle.status === "failed") return bundle;
1089
+ try {
1090
+ const bundle = await bundlesRepository.getById(appId, bundleId);
1091
+ if (bundle.status === "succeeded" || bundle.status === "failed") return bundle;
1092
+ } catch (e) {
1093
+ if (!isRetryableNetworkError(e)) {
1094
+ throw e;
1095
+ }
1096
+ }
1046
1097
  if (Date.now() - start > opts.timeoutMs) {
1047
1098
  throw new Error("Bundle build timed out.");
1048
1099
  }
1049
- await new Promise((r) => setTimeout(r, opts.intervalMs));
1100
+ await sleep(opts.intervalMs);
1050
1101
  }
1051
1102
  }
1052
1103
  async function resolveBundlePath(src, platform, mode) {
1053
1104
  const { appId, commitId } = src;
1054
1105
  const dir = bundlesCacheDir();
1055
1106
  await ensureDir(dir);
1056
- const initiate = await bundlesRepository.initiate(appId, {
1057
- platform,
1058
- commitId: commitId ?? void 0,
1059
- idempotencyKey: `${appId}:${commitId ?? "head"}:${platform}`
1060
- });
1107
+ const initiate = await withRetry(
1108
+ async () => {
1109
+ return await bundlesRepository.initiate(appId, {
1110
+ platform,
1111
+ commitId: commitId ?? void 0,
1112
+ idempotencyKey: `${appId}:${commitId ?? "head"}:${platform}`
1113
+ });
1114
+ },
1115
+ { attempts: 3, baseDelayMs: 500, maxDelayMs: 4e3 }
1116
+ );
1061
1117
  const finalBundle = initiate.status === "succeeded" || initiate.status === "failed" ? initiate : await pollBundle(appId, initiate.id, { timeoutMs: 3 * 60 * 1e3, intervalMs: 1200 });
1062
1118
  if (finalBundle.status === "failed") {
1063
1119
  throw new Error("Bundle build failed.");
1064
1120
  }
1065
- const signed = await bundlesRepository.getSignedDownloadUrl(appId, finalBundle.id, { redirect: false });
1121
+ const signed = await withRetry(
1122
+ async () => {
1123
+ return await bundlesRepository.getSignedDownloadUrl(appId, finalBundle.id, { redirect: false });
1124
+ },
1125
+ { attempts: 3, baseDelayMs: 500, maxDelayMs: 4e3 }
1126
+ );
1066
1127
  const bundlePath = mode === "base" ? await safeReplaceFileFromUrl(
1067
1128
  signed.url,
1068
1129
  toBundleFileUri(baseBundleKey(appId, platform)),
@@ -1078,6 +1139,7 @@ function useBundleManager({
1078
1139
  const [bundlePath, setBundlePath] = React5.useState(null);
1079
1140
  const [renderToken, setRenderToken] = React5.useState(0);
1080
1141
  const [loading, setLoading] = React5.useState(false);
1142
+ const [loadingMode, setLoadingMode] = React5.useState(null);
1081
1143
  const [statusLabel, setStatusLabel] = React5.useState(null);
1082
1144
  const [error, setError] = React5.useState(null);
1083
1145
  const [isTesting, setIsTesting] = React5.useState(false);
@@ -1093,6 +1155,7 @@ function useBundleManager({
1093
1155
  baseOpIdRef.current += 1;
1094
1156
  if (activeLoadModeRef.current === "base") {
1095
1157
  setLoading(false);
1158
+ setLoadingMode(null);
1096
1159
  setStatusLabel(null);
1097
1160
  activeLoadModeRef.current = null;
1098
1161
  }
@@ -1157,6 +1220,7 @@ function useBundleManager({
1157
1220
  const opId = mode === "base" ? ++baseOpIdRef.current : ++testOpIdRef.current;
1158
1221
  activeLoadModeRef.current = mode;
1159
1222
  setLoading(true);
1223
+ setLoadingMode(mode);
1160
1224
  setError(null);
1161
1225
  setStatusLabel(mode === "test" ? "Loading test bundle\u2026" : "Loading latest build\u2026");
1162
1226
  if (mode === "base") {
@@ -1199,6 +1263,7 @@ function useBundleManager({
1199
1263
  if (mode === "base" && opId !== baseOpIdRef.current) return;
1200
1264
  if (mode === "test" && opId !== testOpIdRef.current) return;
1201
1265
  setLoading(false);
1266
+ setLoadingMode(null);
1202
1267
  if (activeLoadModeRef.current === mode) activeLoadModeRef.current = null;
1203
1268
  }
1204
1269
  }, [activateCachedBase, platform]);
@@ -1220,7 +1285,7 @@ function useBundleManager({
1220
1285
  if (!canRequestLatest) return;
1221
1286
  void loadBase();
1222
1287
  }, [base.appId, base.commitId, platform, canRequestLatest, loadBase]);
1223
- return { bundlePath, renderToken, loading, statusLabel, error, isTesting, loadBase, loadTest, restoreBase };
1288
+ return { bundlePath, renderToken, loading, loadingMode, statusLabel, error, isTesting, loadBase, loadTest, restoreBase };
1224
1289
  }
1225
1290
 
1226
1291
  // src/studio/hooks/useMergeRequests.ts
@@ -1479,6 +1544,8 @@ function useMergeRequests(params) {
1479
1544
 
1480
1545
  // src/studio/hooks/useAttachmentUpload.ts
1481
1546
  import * as React7 from "react";
1547
+ import { Platform } from "react-native";
1548
+ import * as FileSystem2 from "expo-file-system/legacy";
1482
1549
 
1483
1550
  // src/data/attachment/remote.ts
1484
1551
  var AttachmentRemoteDataSourceImpl = class extends BaseRemote {
@@ -1518,6 +1585,40 @@ var attachmentRepository = new AttachmentRepositoryImpl(
1518
1585
  );
1519
1586
 
1520
1587
  // src/studio/hooks/useAttachmentUpload.ts
1588
+ async function dataUrlToBlobAndroid(dataUrl) {
1589
+ const normalized = dataUrl.startsWith("data:") ? dataUrl : `data:image/png;base64,${dataUrl}`;
1590
+ const comma = normalized.indexOf(",");
1591
+ if (comma === -1) {
1592
+ throw new Error("Invalid data URL (missing comma separator)");
1593
+ }
1594
+ const header = normalized.slice(0, comma);
1595
+ const base64 = normalized.slice(comma + 1);
1596
+ const mimeMatch = header.match(/data:(.*?);base64/i);
1597
+ const mimeType = (mimeMatch == null ? void 0 : mimeMatch[1]) ?? "application/octet-stream";
1598
+ const cacheDir = FileSystem2.cacheDirectory;
1599
+ if (!cacheDir) {
1600
+ throw new Error("expo-file-system cacheDirectory is unavailable");
1601
+ }
1602
+ const fileUri = `${cacheDir}attachment-${Date.now()}-${Math.random().toString(16).slice(2)}.bin`;
1603
+ await FileSystem2.writeAsStringAsync(fileUri, base64, {
1604
+ encoding: FileSystem2.EncodingType.Base64
1605
+ });
1606
+ try {
1607
+ const resp = await fetch(fileUri);
1608
+ const blob = await resp.blob();
1609
+ return blob.type ? blob : new Blob([blob], { type: mimeType });
1610
+ } finally {
1611
+ void FileSystem2.deleteAsync(fileUri, { idempotent: true }).catch(() => {
1612
+ });
1613
+ }
1614
+ }
1615
+ function getMimeTypeFromDataUrl(dataUrl) {
1616
+ const normalized = dataUrl.startsWith("data:") ? dataUrl : `data:image/png;base64,${dataUrl}`;
1617
+ const comma = normalized.indexOf(",");
1618
+ const header = comma === -1 ? normalized : normalized.slice(0, comma);
1619
+ const mimeMatch = header.match(/data:(.*?);base64/i);
1620
+ return (mimeMatch == null ? void 0 : mimeMatch[1]) ?? "image/png";
1621
+ }
1521
1622
  function useAttachmentUpload() {
1522
1623
  const [uploading, setUploading] = React7.useState(false);
1523
1624
  const [error, setError] = React7.useState(null);
@@ -1530,15 +1631,15 @@ function useAttachmentUpload() {
1530
1631
  const blobs = await Promise.all(
1531
1632
  dataUrls.map(async (dataUrl, idx) => {
1532
1633
  const normalized = dataUrl.startsWith("data:") ? dataUrl : `data:image/png;base64,${dataUrl}`;
1533
- const resp = await fetch(normalized);
1534
- const blob = await resp.blob();
1535
- return { blob, idx };
1634
+ const blob = Platform.OS === "android" ? await dataUrlToBlobAndroid(normalized) : await (await fetch(normalized)).blob();
1635
+ const mimeType = getMimeTypeFromDataUrl(normalized);
1636
+ return { blob, idx, mimeType };
1536
1637
  })
1537
1638
  );
1538
- const files = blobs.map(({ blob }, idx) => ({
1639
+ const files = blobs.map(({ blob, mimeType }, idx) => ({
1539
1640
  name: `attachment-${Date.now()}-${idx}.png`,
1540
1641
  size: blob.size,
1541
- mimeType: blob.type || "image/png"
1642
+ mimeType
1542
1643
  }));
1543
1644
  const presign = await attachmentRepository.presign({ threadId, appId, files });
1544
1645
  await Promise.all(presign.uploads.map((u, index) => attachmentRepository.upload(u, blobs[index].blob)));
@@ -1665,8 +1766,8 @@ function hasNoOutcomeAfterLastHuman(messages) {
1665
1766
  import { View as View2 } from "react-native";
1666
1767
  import { ComergeRuntimeRenderer } from "@comergehq/runtime";
1667
1768
  import { jsx as jsx3 } from "react/jsx-runtime";
1668
- function RuntimeRenderer({ appKey, bundlePath, renderToken, style }) {
1669
- if (!bundlePath) {
1769
+ function RuntimeRenderer({ appKey, bundlePath, forcePreparing, renderToken, style }) {
1770
+ if (!bundlePath || forcePreparing) {
1670
1771
  return /* @__PURE__ */ jsx3(View2, { style: [{ flex: 1, justifyContent: "center", alignItems: "center", padding: 24 }, style], children: /* @__PURE__ */ jsx3(Text, { variant: "bodyMuted", children: "Preparing app\u2026" }) });
1671
1772
  }
1672
1773
  return /* @__PURE__ */ jsx3(View2, { style: [{ flex: 1 }, style], children: /* @__PURE__ */ jsx3(
@@ -1681,17 +1782,17 @@ function RuntimeRenderer({ appKey, bundlePath, renderToken, style }) {
1681
1782
  }
1682
1783
 
1683
1784
  // src/studio/ui/StudioOverlay.tsx
1684
- import * as React38 from "react";
1685
- import { Keyboard as Keyboard5, Platform as Platform7, View as View44, useWindowDimensions as useWindowDimensions4 } from "react-native";
1785
+ import * as React39 from "react";
1786
+ import { Keyboard as Keyboard5, Platform as Platform9, View as View44, useWindowDimensions as useWindowDimensions4 } from "react-native";
1686
1787
 
1687
1788
  // src/components/studio-sheet/StudioBottomSheet.tsx
1688
1789
  import * as React9 from "react";
1689
- import { AppState as AppState2, Keyboard, Platform as Platform2, View as View4 } from "react-native";
1790
+ import { AppState as AppState2, Keyboard, View as View4 } from "react-native";
1690
1791
  import BottomSheet from "@gorhom/bottom-sheet";
1691
1792
  import { useSafeAreaInsets } from "react-native-safe-area-context";
1692
1793
 
1693
1794
  // src/components/studio-sheet/StudioSheetBackground.tsx
1694
- import { Platform, View as View3 } from "react-native";
1795
+ import { Platform as Platform2, View as View3 } from "react-native";
1695
1796
  import { LiquidGlassView, isLiquidGlassSupported } from "@callstack/liquid-glass";
1696
1797
  import { Fragment as Fragment2, jsx as jsx4, jsxs } from "react/jsx-runtime";
1697
1798
  function StudioSheetBackground({
@@ -1699,7 +1800,7 @@ function StudioSheetBackground({
1699
1800
  renderBackground
1700
1801
  }) {
1701
1802
  const theme = useTheme();
1702
- const radius = Platform.OS === "ios" ? 39 : 16;
1803
+ const radius = Platform2.OS === "ios" ? 39 : 16;
1703
1804
  const fallbackBgColor = theme.scheme === "dark" ? "rgba(11, 8, 15, 0.85)" : "rgba(255, 255, 255, 0.85)";
1704
1805
  const secondaryBgBaseColor = theme.scheme === "dark" ? "rgb(24, 24, 27)" : "rgb(173, 173, 173)";
1705
1806
  const containerStyle = {
@@ -1745,7 +1846,7 @@ import { jsx as jsx5 } from "react/jsx-runtime";
1745
1846
  function StudioBottomSheet({
1746
1847
  open,
1747
1848
  onOpenChange,
1748
- snapPoints = ["80%", "100%"],
1849
+ snapPoints = ["100%"],
1749
1850
  sheetRef,
1750
1851
  background,
1751
1852
  children,
@@ -1777,18 +1878,6 @@ function StudioBottomSheet({
1777
1878
  });
1778
1879
  return () => sub.remove();
1779
1880
  }, [open, resolvedSheetRef]);
1780
- React9.useEffect(() => {
1781
- if (Platform2.OS !== "ios") return;
1782
- const sub = Keyboard.addListener("keyboardDidHide", () => {
1783
- const sheet = resolvedSheetRef.current;
1784
- if (!sheet || !open) return;
1785
- const targetIndex = snapPoints.length - 1;
1786
- if (currentIndexRef.current === targetIndex) {
1787
- setTimeout(() => sheet.snapToIndex(targetIndex), 10);
1788
- }
1789
- });
1790
- return () => sub.remove();
1791
- }, [open, resolvedSheetRef, snapPoints.length]);
1792
1881
  React9.useEffect(() => {
1793
1882
  const sheet = resolvedSheetRef.current;
1794
1883
  if (!sheet) return;
@@ -1811,9 +1900,9 @@ function StudioBottomSheet({
1811
1900
  ref: resolvedSheetRef,
1812
1901
  index: open ? snapPoints.length - 1 : -1,
1813
1902
  snapPoints,
1903
+ enableDynamicSizing: false,
1814
1904
  enablePanDownToClose: true,
1815
- keyboardBehavior: "interactive",
1816
- keyboardBlurBehavior: "restore",
1905
+ enableContentPanningGesture: false,
1817
1906
  android_keyboardInputMode: "adjustResize",
1818
1907
  backgroundComponent: (props) => /* @__PURE__ */ jsx5(StudioSheetBackground, { ...props, renderBackground: background == null ? void 0 : background.renderBackground }),
1819
1908
  topInset: insets.top,
@@ -2791,7 +2880,7 @@ var styles3 = StyleSheet3.create({
2791
2880
 
2792
2881
  // src/components/comments/AppCommentsSheet.tsx
2793
2882
  import * as React21 from "react";
2794
- import { ActivityIndicator as ActivityIndicator3, Keyboard as Keyboard3, Platform as Platform4, Pressable as Pressable5, View as View14 } from "react-native";
2883
+ import { ActivityIndicator as ActivityIndicator3, Keyboard as Keyboard3, Platform as Platform5, Pressable as Pressable5, View as View14 } from "react-native";
2795
2884
  import {
2796
2885
  BottomSheetBackdrop,
2797
2886
  BottomSheetModal,
@@ -3396,11 +3485,11 @@ function useAppDetails(appId) {
3396
3485
 
3397
3486
  // src/components/comments/useIosKeyboardSnapFix.ts
3398
3487
  import * as React20 from "react";
3399
- import { Keyboard as Keyboard2, Platform as Platform3 } from "react-native";
3488
+ import { Keyboard as Keyboard2, Platform as Platform4 } from "react-native";
3400
3489
  function useIosKeyboardSnapFix(sheetRef, options) {
3401
3490
  const [keyboardVisible, setKeyboardVisible] = React20.useState(false);
3402
3491
  React20.useEffect(() => {
3403
- if (Platform3.OS !== "ios") return;
3492
+ if (Platform4.OS !== "ios") return;
3404
3493
  const show = Keyboard2.addListener("keyboardWillShow", () => setKeyboardVisible(true));
3405
3494
  const hide = Keyboard2.addListener("keyboardWillHide", () => {
3406
3495
  var _a;
@@ -3478,8 +3567,8 @@ function AppCommentsSheet({ appId, onClose, onCountChange, onPlayApp }) {
3478
3567
  onChange: handleChange,
3479
3568
  backgroundStyle: {
3480
3569
  backgroundColor: theme.scheme === "dark" ? "#0B080F" : "#FFFFFF",
3481
- borderTopLeftRadius: Platform4.OS === "ios" ? 39 : 16,
3482
- borderTopRightRadius: Platform4.OS === "ios" ? 39 : 16
3570
+ borderTopLeftRadius: Platform5.OS === "ios" ? 39 : 16,
3571
+ borderTopRightRadius: Platform5.OS === "ios" ? 39 : 16
3483
3572
  },
3484
3573
  handleIndicatorStyle: { backgroundColor: theme.colors.handleIndicator },
3485
3574
  keyboardBehavior: "interactive",
@@ -3586,7 +3675,7 @@ function AppCommentsSheet({ appId, onClose, onCountChange, onPlayApp }) {
3586
3675
  bottom: 0,
3587
3676
  paddingHorizontal: theme.spacing.lg,
3588
3677
  paddingTop: theme.spacing.sm,
3589
- paddingBottom: Platform4.OS === "ios" ? keyboardVisible ? theme.spacing.lg : insets.bottom : insets.bottom + 10,
3678
+ paddingBottom: Platform5.OS === "ios" ? keyboardVisible ? theme.spacing.lg : insets.bottom : insets.bottom + 10,
3590
3679
  borderTopWidth: 1,
3591
3680
  borderTopColor: withAlpha(theme.colors.border, 0.1),
3592
3681
  backgroundColor: withAlpha(theme.colors.background, 0.8)
@@ -4485,7 +4574,7 @@ import { Animated as Animated7, Pressable as Pressable9, View as View28 } from "
4485
4574
  import { Ban, Check as Check3, CheckCheck, ChevronDown as ChevronDown2 } from "lucide-react-native";
4486
4575
 
4487
4576
  // src/components/primitives/MarkdownText.tsx
4488
- import { Platform as Platform5, View as View27 } from "react-native";
4577
+ import { Platform as Platform6, View as View27 } from "react-native";
4489
4578
  import Markdown from "react-native-markdown-display";
4490
4579
  import { jsx as jsx37 } from "react/jsx-runtime";
4491
4580
  function MarkdownText({ markdown, variant = "chat", bodyColor, style }) {
@@ -4511,7 +4600,7 @@ function MarkdownText({ markdown, variant = "chat", bodyColor, style }) {
4511
4600
  paddingHorizontal: variant === "mergeRequest" ? 6 : 4,
4512
4601
  paddingVertical: variant === "mergeRequest" ? 2 : 0,
4513
4602
  borderRadius: variant === "mergeRequest" ? 6 : 4,
4514
- fontFamily: Platform5.OS === "ios" ? "Menlo" : "monospace",
4603
+ fontFamily: Platform6.OS === "ios" ? "Menlo" : "monospace",
4515
4604
  fontSize: 13
4516
4605
  },
4517
4606
  code_block: {
@@ -5524,9 +5613,8 @@ import { ActivityIndicator as ActivityIndicator8, View as View41 } from "react-n
5524
5613
 
5525
5614
  // src/components/chat/ChatPage.tsx
5526
5615
  import * as React34 from "react";
5527
- import { Keyboard as Keyboard4, Platform as Platform6, View as View37 } from "react-native";
5616
+ import { Keyboard as Keyboard4, Platform as Platform8, View as View37 } from "react-native";
5528
5617
  import { useSafeAreaInsets as useSafeAreaInsets4 } from "react-native-safe-area-context";
5529
- import Animated11, { useAnimatedKeyboard, useAnimatedStyle as useAnimatedStyle2 } from "react-native-reanimated";
5530
5618
 
5531
5619
  // src/components/chat/ChatMessageList.tsx
5532
5620
  import * as React33 from "react";
@@ -5634,19 +5722,19 @@ var ChatMessageList = React33.forwardRef(
5634
5722
  const nearBottomRef = React33.useRef(true);
5635
5723
  const initialScrollDoneRef = React33.useRef(false);
5636
5724
  const lastMessageIdRef = React33.useRef(null);
5725
+ const data = React33.useMemo(() => {
5726
+ return [...messages].reverse();
5727
+ }, [messages]);
5637
5728
  const scrollToBottom = React33.useCallback((options) => {
5638
5729
  var _a;
5639
5730
  const animated = (options == null ? void 0 : options.animated) ?? true;
5640
- (_a = listRef.current) == null ? void 0 : _a.scrollToEnd({ animated });
5731
+ (_a = listRef.current) == null ? void 0 : _a.scrollToOffset({ offset: 0, animated });
5641
5732
  }, []);
5642
5733
  React33.useImperativeHandle(ref, () => ({ scrollToBottom }), [scrollToBottom]);
5643
5734
  const handleScroll = React33.useCallback(
5644
5735
  (e) => {
5645
5736
  const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
5646
- const distanceFromBottom = Math.max(
5647
- contentSize.height - Math.max(bottomInset, 0) - (contentOffset.y + layoutMeasurement.height),
5648
- 0
5649
- );
5737
+ const distanceFromBottom = Math.max(contentOffset.y - Math.max(bottomInset, 0), 0);
5650
5738
  const isNear = distanceFromBottom <= nearBottomThreshold;
5651
5739
  if (nearBottomRef.current !== isNear) {
5652
5740
  nearBottomRef.current = isNear;
@@ -5655,15 +5743,6 @@ var ChatMessageList = React33.forwardRef(
5655
5743
  },
5656
5744
  [bottomInset, nearBottomThreshold, onNearBottomChange]
5657
5745
  );
5658
- React33.useEffect(() => {
5659
- var _a;
5660
- if (initialScrollDoneRef.current) return;
5661
- if (messages.length === 0) return;
5662
- initialScrollDoneRef.current = true;
5663
- lastMessageIdRef.current = ((_a = messages[messages.length - 1]) == null ? void 0 : _a.id) ?? null;
5664
- const id = requestAnimationFrame(() => scrollToBottom({ animated: false }));
5665
- return () => cancelAnimationFrame(id);
5666
- }, [messages, scrollToBottom]);
5667
5746
  React33.useEffect(() => {
5668
5747
  if (!initialScrollDoneRef.current) return;
5669
5748
  const lastId = messages.length > 0 ? messages[messages.length - 1].id : null;
@@ -5681,31 +5760,35 @@ var ChatMessageList = React33.forwardRef(
5681
5760
  }
5682
5761
  return void 0;
5683
5762
  }, [showTypingIndicator, scrollToBottom]);
5684
- React33.useEffect(() => {
5685
- if (!initialScrollDoneRef.current) return;
5686
- if (!nearBottomRef.current) return;
5687
- const id = requestAnimationFrame(() => scrollToBottom({ animated: false }));
5688
- return () => cancelAnimationFrame(id);
5689
- }, [bottomInset, scrollToBottom]);
5690
5763
  return /* @__PURE__ */ jsx46(
5691
5764
  BottomSheetFlatList,
5692
5765
  {
5693
5766
  ref: listRef,
5694
- data: messages,
5767
+ inverted: true,
5768
+ data,
5695
5769
  keyExtractor: (m) => m.id,
5770
+ keyboardShouldPersistTaps: "handled",
5696
5771
  onScroll: handleScroll,
5697
5772
  scrollEventThrottle: 16,
5698
5773
  showsVerticalScrollIndicator: false,
5774
+ onContentSizeChange: () => {
5775
+ if (initialScrollDoneRef.current) return;
5776
+ initialScrollDoneRef.current = true;
5777
+ lastMessageIdRef.current = messages.length > 0 ? messages[messages.length - 1].id : null;
5778
+ nearBottomRef.current = true;
5779
+ onNearBottomChange == null ? void 0 : onNearBottomChange(true);
5780
+ requestAnimationFrame(() => scrollToBottom({ animated: false }));
5781
+ },
5699
5782
  contentContainerStyle: [
5700
5783
  {
5701
5784
  paddingHorizontal: theme.spacing.lg,
5702
- paddingTop: theme.spacing.sm,
5703
- paddingBottom: theme.spacing.sm
5785
+ paddingVertical: theme.spacing.sm
5704
5786
  },
5705
5787
  contentStyle
5706
5788
  ],
5707
- renderItem: ({ item, index }) => /* @__PURE__ */ jsx46(View36, { style: { marginTop: index === 0 ? 0 : theme.spacing.sm }, children: /* @__PURE__ */ jsx46(ChatMessageBubble, { message: item, renderContent: renderMessageContent }) }),
5708
- ListFooterComponent: /* @__PURE__ */ jsxs28(View36, { children: [
5789
+ ItemSeparatorComponent: () => /* @__PURE__ */ jsx46(View36, { style: { height: theme.spacing.sm } }),
5790
+ renderItem: ({ item }) => /* @__PURE__ */ jsx46(ChatMessageBubble, { message: item, renderContent: renderMessageContent }),
5791
+ ListHeaderComponent: /* @__PURE__ */ jsxs28(View36, { children: [
5709
5792
  showTypingIndicator ? /* @__PURE__ */ jsx46(View36, { style: { marginTop: theme.spacing.sm, alignSelf: "flex-start", paddingHorizontal: theme.spacing.lg }, children: /* @__PURE__ */ jsx46(TypingIndicator, {}) }) : null,
5710
5793
  bottomInset > 0 ? /* @__PURE__ */ jsx46(View36, { style: { height: bottomInset } }) : null
5711
5794
  ] })
@@ -5726,6 +5809,7 @@ function ChatPage({
5726
5809
  composer,
5727
5810
  overlay,
5728
5811
  style,
5812
+ composerHorizontalPadding,
5729
5813
  onNearBottomChange,
5730
5814
  listRef
5731
5815
  }) {
@@ -5733,9 +5817,8 @@ function ChatPage({
5733
5817
  const insets = useSafeAreaInsets4();
5734
5818
  const [composerHeight, setComposerHeight] = React34.useState(0);
5735
5819
  const [keyboardVisible, setKeyboardVisible] = React34.useState(false);
5736
- const animatedKeyboard = useAnimatedKeyboard();
5737
5820
  React34.useEffect(() => {
5738
- if (Platform6.OS !== "ios") return;
5821
+ if (Platform8.OS !== "ios") return;
5739
5822
  const show = Keyboard4.addListener("keyboardWillShow", () => setKeyboardVisible(true));
5740
5823
  const hide = Keyboard4.addListener("keyboardWillHide", () => setKeyboardVisible(false));
5741
5824
  return () => {
@@ -5743,11 +5826,7 @@ function ChatPage({
5743
5826
  hide.remove();
5744
5827
  };
5745
5828
  }, []);
5746
- const footerBottomPadding = Platform6.OS === "ios" ? keyboardVisible ? 0 : insets.bottom : insets.bottom + 10;
5747
- const footerAnimatedStyle = useAnimatedStyle2(() => {
5748
- if (Platform6.OS !== "ios") return { paddingBottom: insets.bottom + 10 };
5749
- return { paddingBottom: animatedKeyboard.height.value > 0 ? 0 : insets.bottom };
5750
- });
5829
+ const footerBottomPadding = Platform8.OS === "ios" ? keyboardVisible ? 0 : insets.bottom : insets.bottom + 10;
5751
5830
  const overlayBottom = composerHeight + footerBottomPadding + theme.spacing.lg;
5752
5831
  const bottomInset = composerHeight + footerBottomPadding + theme.spacing.xl;
5753
5832
  const resolvedOverlay = React34.useMemo(() => {
@@ -5763,32 +5842,38 @@ function ChatPage({
5763
5842
  header ? /* @__PURE__ */ jsx47(View37, { children: header }) : null,
5764
5843
  topBanner ? /* @__PURE__ */ jsx47(View37, { style: { paddingHorizontal: theme.spacing.lg, paddingTop: theme.spacing.sm }, children: topBanner }) : null,
5765
5844
  /* @__PURE__ */ jsxs29(View37, { style: { flex: 1 }, children: [
5766
- /* @__PURE__ */ jsx47(
5767
- ChatMessageList,
5845
+ /* @__PURE__ */ jsxs29(
5846
+ View37,
5768
5847
  {
5769
- ref: listRef,
5770
- messages,
5771
- showTypingIndicator,
5772
- renderMessageContent,
5773
- onNearBottomChange,
5774
- bottomInset
5848
+ style: { flex: 1 },
5849
+ children: [
5850
+ /* @__PURE__ */ jsx47(
5851
+ ChatMessageList,
5852
+ {
5853
+ ref: listRef,
5854
+ messages,
5855
+ showTypingIndicator,
5856
+ renderMessageContent,
5857
+ onNearBottomChange,
5858
+ bottomInset
5859
+ }
5860
+ ),
5861
+ resolvedOverlay
5862
+ ]
5775
5863
  }
5776
5864
  ),
5777
- resolvedOverlay,
5778
5865
  /* @__PURE__ */ jsx47(
5779
- Animated11.View,
5866
+ View37,
5780
5867
  {
5781
- style: [
5782
- {
5783
- position: "absolute",
5784
- left: 0,
5785
- right: 0,
5786
- bottom: 0,
5787
- paddingHorizontal: theme.spacing.lg,
5788
- paddingTop: theme.spacing.sm
5789
- },
5790
- footerAnimatedStyle
5791
- ],
5868
+ style: {
5869
+ position: "absolute",
5870
+ left: 0,
5871
+ right: 0,
5872
+ bottom: 0,
5873
+ paddingHorizontal: composerHorizontalPadding ?? theme.spacing.md,
5874
+ paddingTop: theme.spacing.sm,
5875
+ paddingBottom: footerBottomPadding
5876
+ },
5792
5877
  children: /* @__PURE__ */ jsx47(
5793
5878
  ChatComposer,
5794
5879
  {
@@ -5806,7 +5891,7 @@ function ChatPage({
5806
5891
  // src/components/chat/ScrollToBottomButton.tsx
5807
5892
  import * as React35 from "react";
5808
5893
  import { Pressable as Pressable12, View as View38 } from "react-native";
5809
- import Animated12, { Easing as Easing2, useAnimatedStyle as useAnimatedStyle3, useSharedValue as useSharedValue2, withTiming as withTiming2 } from "react-native-reanimated";
5894
+ import Animated11, { Easing as Easing2, useAnimatedStyle as useAnimatedStyle2, useSharedValue as useSharedValue2, withTiming as withTiming2 } from "react-native-reanimated";
5810
5895
  import { jsx as jsx48 } from "react/jsx-runtime";
5811
5896
  function ScrollToBottomButton({ visible, onPress, children, style }) {
5812
5897
  const theme = useTheme();
@@ -5815,14 +5900,14 @@ function ScrollToBottomButton({ visible, onPress, children, style }) {
5815
5900
  React35.useEffect(() => {
5816
5901
  progress.value = withTiming2(visible ? 1 : 0, { duration: 200, easing: Easing2.out(Easing2.ease) });
5817
5902
  }, [progress, visible]);
5818
- const animStyle = useAnimatedStyle3(() => ({
5903
+ const animStyle = useAnimatedStyle2(() => ({
5819
5904
  opacity: progress.value,
5820
5905
  transform: [{ translateY: (1 - progress.value) * 20 }]
5821
5906
  }));
5822
5907
  const bg = theme.scheme === "dark" ? "rgba(39,39,42,0.9)" : "rgba(244,244,245,0.95)";
5823
5908
  const border = theme.scheme === "dark" ? withAlpha("#FFFFFF", 0.12) : withAlpha("#000000", 0.08);
5824
5909
  return /* @__PURE__ */ jsx48(
5825
- Animated12.View,
5910
+ Animated11.View,
5826
5911
  {
5827
5912
  pointerEvents: visible ? "auto" : "none",
5828
5913
  style: [
@@ -6027,6 +6112,7 @@ function ChatPanel({
6027
6112
  messages,
6028
6113
  showTypingIndicator,
6029
6114
  topBanner,
6115
+ composerHorizontalPadding: 0,
6030
6116
  listRef,
6031
6117
  onNearBottomChange: setNearBottom,
6032
6118
  overlay: /* @__PURE__ */ jsx51(
@@ -6263,6 +6349,92 @@ function ConfirmMergeFlow({
6263
6349
  );
6264
6350
  }
6265
6351
 
6352
+ // src/studio/hooks/useOptimisticChatMessages.ts
6353
+ import * as React38 from "react";
6354
+ function makeOptimisticId() {
6355
+ return `optimistic:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 10)}`;
6356
+ }
6357
+ function toEpochMs(createdAt) {
6358
+ if (createdAt == null) return 0;
6359
+ if (typeof createdAt === "number") return createdAt;
6360
+ if (createdAt instanceof Date) return createdAt.getTime();
6361
+ const t = Date.parse(String(createdAt));
6362
+ return Number.isFinite(t) ? t : 0;
6363
+ }
6364
+ function isOptimisticResolvedByServer(chatMessages, o) {
6365
+ if (o.failed) return false;
6366
+ const normalize = (s) => s.trim();
6367
+ let startIndex = -1;
6368
+ if (o.baseServerLastId) {
6369
+ startIndex = chatMessages.findIndex((m) => m.id === o.baseServerLastId);
6370
+ }
6371
+ const candidates = startIndex >= 0 ? chatMessages.slice(startIndex + 1) : chatMessages;
6372
+ const target = normalize(o.content);
6373
+ for (const m of candidates) {
6374
+ if (m.author !== "human") continue;
6375
+ if (normalize(m.content) !== target) continue;
6376
+ const serverMs = toEpochMs(m.createdAt);
6377
+ const optimisticMs = Date.parse(o.createdAtIso);
6378
+ if (Number.isFinite(optimisticMs) && optimisticMs > 0 && serverMs > 0) {
6379
+ if (serverMs + 12e4 < optimisticMs) continue;
6380
+ }
6381
+ return true;
6382
+ }
6383
+ return false;
6384
+ }
6385
+ function useOptimisticChatMessages({
6386
+ threadId,
6387
+ shouldForkOnEdit,
6388
+ chatMessages,
6389
+ onSendChat
6390
+ }) {
6391
+ const [optimisticChat, setOptimisticChat] = React38.useState([]);
6392
+ React38.useEffect(() => {
6393
+ setOptimisticChat([]);
6394
+ }, [threadId]);
6395
+ const messages = React38.useMemo(() => {
6396
+ if (!optimisticChat || optimisticChat.length === 0) return chatMessages;
6397
+ const unresolved = optimisticChat.filter((o) => !isOptimisticResolvedByServer(chatMessages, o));
6398
+ if (unresolved.length === 0) return chatMessages;
6399
+ const optimisticAsChat = unresolved.map((o) => ({
6400
+ id: o.id,
6401
+ author: "human",
6402
+ content: o.content,
6403
+ createdAt: o.createdAtIso,
6404
+ kind: "optimistic",
6405
+ meta: o.failed ? { kind: "optimistic", event: "send.failed", status: "error" } : { kind: "optimistic", event: "send.pending", status: "info" }
6406
+ }));
6407
+ const merged = [...chatMessages, ...optimisticAsChat];
6408
+ merged.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
6409
+ return merged;
6410
+ }, [chatMessages, optimisticChat]);
6411
+ React38.useEffect(() => {
6412
+ if (optimisticChat.length === 0) return;
6413
+ setOptimisticChat((prev) => {
6414
+ if (prev.length === 0) return prev;
6415
+ const next = prev.filter((o) => !isOptimisticResolvedByServer(chatMessages, o) || o.failed);
6416
+ return next.length === prev.length ? prev : next;
6417
+ });
6418
+ }, [chatMessages, optimisticChat.length]);
6419
+ const onSend = React38.useCallback(
6420
+ async (text, attachments) => {
6421
+ if (shouldForkOnEdit) {
6422
+ await onSendChat(text, attachments);
6423
+ return;
6424
+ }
6425
+ const createdAtIso = (/* @__PURE__ */ new Date()).toISOString();
6426
+ const baseServerLastId = chatMessages.length > 0 ? chatMessages[chatMessages.length - 1].id : null;
6427
+ const id = makeOptimisticId();
6428
+ setOptimisticChat((prev) => [...prev, { id, content: text, createdAtIso, baseServerLastId, failed: false }]);
6429
+ void Promise.resolve(onSendChat(text, attachments)).catch(() => {
6430
+ setOptimisticChat((prev) => prev.map((m) => m.id === id ? { ...m, failed: true } : m));
6431
+ });
6432
+ },
6433
+ [chatMessages, onSendChat, shouldForkOnEdit]
6434
+ );
6435
+ return { messages, onSend };
6436
+ }
6437
+
6266
6438
  // src/studio/ui/StudioOverlay.tsx
6267
6439
  import { Fragment as Fragment6, jsx as jsx55, jsxs as jsxs34 } from "react/jsx-runtime";
6268
6440
  function StudioOverlay({
@@ -6295,28 +6467,38 @@ function StudioOverlay({
6295
6467
  }) {
6296
6468
  const theme = useTheme();
6297
6469
  const { width } = useWindowDimensions4();
6298
- const [sheetOpen, setSheetOpen] = React38.useState(false);
6299
- const [activePage, setActivePage] = React38.useState("preview");
6300
- const [drawing, setDrawing] = React38.useState(false);
6301
- const [chatAttachments, setChatAttachments] = React38.useState([]);
6302
- const [commentsAppId, setCommentsAppId] = React38.useState(null);
6303
- const [commentsCount, setCommentsCount] = React38.useState(null);
6304
- const [confirmMrId, setConfirmMrId] = React38.useState(null);
6305
- const confirmMr = React38.useMemo(
6470
+ const [sheetOpen, setSheetOpen] = React39.useState(false);
6471
+ const [activePage, setActivePage] = React39.useState("preview");
6472
+ const [drawing, setDrawing] = React39.useState(false);
6473
+ const [chatAttachments, setChatAttachments] = React39.useState([]);
6474
+ const [commentsAppId, setCommentsAppId] = React39.useState(null);
6475
+ const [commentsCount, setCommentsCount] = React39.useState(null);
6476
+ const threadId = (app == null ? void 0 : app.threadId) ?? null;
6477
+ const optimistic = useOptimisticChatMessages({
6478
+ threadId,
6479
+ shouldForkOnEdit,
6480
+ chatMessages,
6481
+ onSendChat
6482
+ });
6483
+ const [confirmMrId, setConfirmMrId] = React39.useState(null);
6484
+ const confirmMr = React39.useMemo(
6306
6485
  () => confirmMrId ? incomingMergeRequests.find((m) => m.id === confirmMrId) ?? null : null,
6307
6486
  [confirmMrId, incomingMergeRequests]
6308
6487
  );
6309
- const closeSheet = React38.useCallback(() => {
6310
- setSheetOpen(false);
6311
- Keyboard5.dismiss();
6488
+ const handleSheetOpenChange = React39.useCallback((open) => {
6489
+ setSheetOpen(open);
6490
+ if (!open) Keyboard5.dismiss();
6312
6491
  }, []);
6313
- const openSheet = React38.useCallback(() => setSheetOpen(true), []);
6314
- const goToChat = React38.useCallback(() => {
6492
+ const closeSheet = React39.useCallback(() => {
6493
+ handleSheetOpenChange(false);
6494
+ }, [handleSheetOpenChange]);
6495
+ const openSheet = React39.useCallback(() => setSheetOpen(true), []);
6496
+ const goToChat = React39.useCallback(() => {
6315
6497
  setActivePage("chat");
6316
6498
  openSheet();
6317
6499
  }, [openSheet]);
6318
- const backToPreview = React38.useCallback(() => {
6319
- if (Platform7.OS !== "ios") {
6500
+ const backToPreview = React39.useCallback(() => {
6501
+ if (Platform9.OS !== "ios") {
6320
6502
  Keyboard5.dismiss();
6321
6503
  setActivePage("preview");
6322
6504
  return;
@@ -6333,11 +6515,11 @@ function StudioOverlay({
6333
6515
  const t = setTimeout(finalize, 350);
6334
6516
  Keyboard5.dismiss();
6335
6517
  }, []);
6336
- const startDraw = React38.useCallback(() => {
6518
+ const startDraw = React39.useCallback(() => {
6337
6519
  setDrawing(true);
6338
6520
  closeSheet();
6339
6521
  }, [closeSheet]);
6340
- const handleDrawCapture = React38.useCallback(
6522
+ const handleDrawCapture = React39.useCallback(
6341
6523
  (dataUrl) => {
6342
6524
  setChatAttachments((prev) => [...prev, dataUrl]);
6343
6525
  setDrawing(false);
@@ -6346,7 +6528,7 @@ function StudioOverlay({
6346
6528
  },
6347
6529
  [openSheet]
6348
6530
  );
6349
- const toggleSheet = React38.useCallback(async () => {
6531
+ const toggleSheet = React39.useCallback(async () => {
6350
6532
  if (!sheetOpen) {
6351
6533
  const shouldExitTest = Boolean(testingMrId) || isTesting;
6352
6534
  if (shouldExitTest) {
@@ -6358,7 +6540,7 @@ function StudioOverlay({
6358
6540
  closeSheet();
6359
6541
  }
6360
6542
  }, [closeSheet, isTesting, onRestoreBase, sheetOpen, testingMrId]);
6361
- const handleTestMr = React38.useCallback(
6543
+ const handleTestMr = React39.useCallback(
6362
6544
  async (mr) => {
6363
6545
  if (!onTestMr) return;
6364
6546
  await onTestMr(mr);
@@ -6368,7 +6550,7 @@ function StudioOverlay({
6368
6550
  );
6369
6551
  return /* @__PURE__ */ jsxs34(Fragment6, { children: [
6370
6552
  /* @__PURE__ */ jsx55(EdgeGlowFrame, { visible: isTesting, role: "accent", thickness: 40, intensity: 1 }),
6371
- /* @__PURE__ */ jsx55(StudioBottomSheet, { open: sheetOpen, onOpenChange: setSheetOpen, children: /* @__PURE__ */ jsx55(
6553
+ /* @__PURE__ */ jsx55(StudioBottomSheet, { open: sheetOpen, onOpenChange: handleSheetOpenChange, children: /* @__PURE__ */ jsx55(
6372
6554
  StudioSheetPager,
6373
6555
  {
6374
6556
  activePage,
@@ -6402,7 +6584,7 @@ function StudioOverlay({
6402
6584
  chat: /* @__PURE__ */ jsx55(
6403
6585
  ChatPanel,
6404
6586
  {
6405
- messages: chatMessages,
6587
+ messages: optimistic.messages,
6406
6588
  showTypingIndicator: chatShowTypingIndicator,
6407
6589
  loading: chatLoading,
6408
6590
  sendDisabled: chatSendDisabled,
@@ -6417,7 +6599,7 @@ function StudioOverlay({
6417
6599
  onClose: closeSheet,
6418
6600
  onNavigateHome,
6419
6601
  onStartDraw: startDraw,
6420
- onSend: onSendChat
6602
+ onSend: optimistic.onSend
6421
6603
  }
6422
6604
  )
6423
6605
  }
@@ -6476,16 +6658,16 @@ function ComergeStudio({
6476
6658
  onNavigateHome,
6477
6659
  style
6478
6660
  }) {
6479
- const [activeAppId, setActiveAppId] = React39.useState(appId);
6480
- const [runtimeAppId, setRuntimeAppId] = React39.useState(appId);
6481
- const [pendingRuntimeTargetAppId, setPendingRuntimeTargetAppId] = React39.useState(null);
6482
- const platform = React39.useMemo(() => RNPlatform.OS === "ios" ? "ios" : "android", []);
6483
- React39.useEffect(() => {
6661
+ const [activeAppId, setActiveAppId] = React40.useState(appId);
6662
+ const [runtimeAppId, setRuntimeAppId] = React40.useState(appId);
6663
+ const [pendingRuntimeTargetAppId, setPendingRuntimeTargetAppId] = React40.useState(null);
6664
+ const platform = React40.useMemo(() => RNPlatform.OS === "ios" ? "ios" : "android", []);
6665
+ React40.useEffect(() => {
6484
6666
  setActiveAppId(appId);
6485
6667
  setRuntimeAppId(appId);
6486
6668
  setPendingRuntimeTargetAppId(null);
6487
6669
  }, [appId]);
6488
- const captureTargetRef = React39.useRef(null);
6670
+ const captureTargetRef = React40.useRef(null);
6489
6671
  return /* @__PURE__ */ jsx56(StudioBootstrap, { apiKey, children: ({ userId }) => /* @__PURE__ */ jsx56(BottomSheetModalProvider, { children: /* @__PURE__ */ jsx56(
6490
6672
  ComergeStudioInner,
6491
6673
  {
@@ -6521,11 +6703,11 @@ function ComergeStudioInner({
6521
6703
  const { app, loading: appLoading } = useApp(activeAppId);
6522
6704
  const { app: runtimeAppFromHook } = useApp(runtimeAppId, { enabled: runtimeAppId !== activeAppId });
6523
6705
  const runtimeApp = runtimeAppId === activeAppId ? app : runtimeAppFromHook;
6524
- const sawEditingOnPendingTargetRef = React39.useRef(false);
6525
- React39.useEffect(() => {
6706
+ const sawEditingOnPendingTargetRef = React40.useRef(false);
6707
+ React40.useEffect(() => {
6526
6708
  sawEditingOnPendingTargetRef.current = false;
6527
6709
  }, [pendingRuntimeTargetAppId]);
6528
- React39.useEffect(() => {
6710
+ React40.useEffect(() => {
6529
6711
  if (!pendingRuntimeTargetAppId) return;
6530
6712
  if (activeAppId !== pendingRuntimeTargetAppId) return;
6531
6713
  if ((app == null ? void 0 : app.status) === "editing") {
@@ -6542,13 +6724,38 @@ function ComergeStudioInner({
6542
6724
  platform,
6543
6725
  canRequestLatest: (runtimeApp == null ? void 0 : runtimeApp.status) === "ready"
6544
6726
  });
6727
+ const sawEditingOnActiveAppRef = React40.useRef(false);
6728
+ const [showPostEditPreparing, setShowPostEditPreparing] = React40.useState(false);
6729
+ React40.useEffect(() => {
6730
+ sawEditingOnActiveAppRef.current = false;
6731
+ setShowPostEditPreparing(false);
6732
+ }, [activeAppId]);
6733
+ React40.useEffect(() => {
6734
+ if (!(app == null ? void 0 : app.id)) return;
6735
+ if (app.status === "editing") {
6736
+ sawEditingOnActiveAppRef.current = true;
6737
+ setShowPostEditPreparing(false);
6738
+ return;
6739
+ }
6740
+ if (app.status === "ready" && sawEditingOnActiveAppRef.current) {
6741
+ setShowPostEditPreparing(true);
6742
+ sawEditingOnActiveAppRef.current = false;
6743
+ }
6744
+ }, [app == null ? void 0 : app.id, app == null ? void 0 : app.status]);
6745
+ React40.useEffect(() => {
6746
+ if (!showPostEditPreparing) return;
6747
+ const stillProcessingBaseBundle = bundle.loading && bundle.loadingMode === "base" && !bundle.isTesting;
6748
+ if (!stillProcessingBaseBundle) {
6749
+ setShowPostEditPreparing(false);
6750
+ }
6751
+ }, [showPostEditPreparing, bundle.loading, bundle.loadingMode, bundle.isTesting]);
6545
6752
  const threadId = (app == null ? void 0 : app.threadId) ?? "";
6546
6753
  const thread = useThreadMessages(threadId);
6547
6754
  const mergeRequests = useMergeRequests({ appId: activeAppId });
6548
- const hasOpenOutgoingMr = React39.useMemo(() => {
6755
+ const hasOpenOutgoingMr = React40.useMemo(() => {
6549
6756
  return mergeRequests.lists.outgoing.some((mr) => mr.status === "open");
6550
6757
  }, [mergeRequests.lists.outgoing]);
6551
- const incomingReviewMrs = React39.useMemo(() => {
6758
+ const incomingReviewMrs = React40.useMemo(() => {
6552
6759
  if (!userId) return mergeRequests.lists.incoming;
6553
6760
  return mergeRequests.lists.incoming.filter((mr) => mr.createdBy !== userId);
6554
6761
  }, [mergeRequests.lists.incoming, userId]);
@@ -6570,9 +6777,9 @@ function ComergeStudioInner({
6570
6777
  uploadAttachments: uploader.uploadBase64Images
6571
6778
  });
6572
6779
  const chatSendDisabled = hasNoOutcomeAfterLastHuman(thread.raw);
6573
- const [processingMrId, setProcessingMrId] = React39.useState(null);
6574
- const [testingMrId, setTestingMrId] = React39.useState(null);
6575
- const chatShowTypingIndicator = React39.useMemo(() => {
6780
+ const [processingMrId, setProcessingMrId] = React40.useState(null);
6781
+ const [testingMrId, setTestingMrId] = React40.useState(null);
6782
+ const chatShowTypingIndicator = React40.useMemo(() => {
6576
6783
  var _a;
6577
6784
  if (!thread.raw || thread.raw.length === 0) return false;
6578
6785
  const last = thread.raw[thread.raw.length - 1];
@@ -6580,7 +6787,15 @@ function ComergeStudioInner({
6580
6787
  return payloadType !== "outcome";
6581
6788
  }, [thread.raw]);
6582
6789
  return /* @__PURE__ */ jsx56(View45, { style: [{ flex: 1 }, style], children: /* @__PURE__ */ jsxs35(View45, { ref: captureTargetRef, style: { flex: 1 }, collapsable: false, children: [
6583
- /* @__PURE__ */ jsx56(RuntimeRenderer, { appKey, bundlePath: bundle.bundlePath, renderToken: bundle.renderToken }),
6790
+ /* @__PURE__ */ jsx56(
6791
+ RuntimeRenderer,
6792
+ {
6793
+ appKey,
6794
+ bundlePath: bundle.bundlePath,
6795
+ forcePreparing: showPostEditPreparing,
6796
+ renderToken: bundle.renderToken
6797
+ }
6798
+ ),
6584
6799
  /* @__PURE__ */ jsx56(
6585
6800
  StudioOverlay,
6586
6801
  {