@comergehq/studio 0.1.12 → 0.1.15

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,7 +1,9 @@
1
1
  import * as React from 'react';
2
2
  import * as FileSystem from 'expo-file-system/legacy';
3
+ import { Asset } from 'expo-asset';
4
+ import { unzip } from 'react-native-zip-archive';
3
5
 
4
- import type { Platform as BundlePlatform, Bundle } from '../../data/apps/bundles/types';
6
+ import type { Platform as BundlePlatform, Bundle, BundleAsset } from '../../data/apps/bundles/types';
5
7
  import { bundlesRepository } from '../../data/apps/bundles/repository';
6
8
 
7
9
  function sleep(ms: number): Promise<void> {
@@ -60,6 +62,7 @@ export type UseBundleManagerParams = {
60
62
  * Test bundles (merge request previews) are NOT gated by this.
61
63
  */
62
64
  canRequestLatest?: boolean;
65
+ embeddedBaseBundles?: EmbeddedBaseBundles;
63
66
  };
64
67
 
65
68
  export type BundleLoadState = {
@@ -85,6 +88,23 @@ export type UseBundleManagerResult = BundleLoadState & {
85
88
  restoreBase: () => Promise<void>;
86
89
  };
87
90
 
91
+ export type EmbeddedBaseBundle = {
92
+ module: number;
93
+ meta?: BaseBundleMeta | null;
94
+ assetsModule?: number;
95
+ assetsMeta?: EmbeddedAssetsMeta | null;
96
+ };
97
+
98
+ export type EmbeddedBaseBundles = {
99
+ ios?: EmbeddedBaseBundle;
100
+ android?: EmbeddedBaseBundle;
101
+ };
102
+
103
+ type EmbeddedAssetsMeta = {
104
+ checksumSha256: string | null;
105
+ size: number | null;
106
+ };
107
+
88
108
  function safeName(s: string) {
89
109
  return s.replace(/[^a-zA-Z0-9._-]/g, '_');
90
110
  }
@@ -102,6 +122,11 @@ async function ensureDir(path: string) {
102
122
  await FileSystem.makeDirectoryAsync(path, { intermediates: true });
103
123
  }
104
124
 
125
+ async function ensureBundleDir(key: string) {
126
+ await ensureDir(bundlesCacheDir());
127
+ await ensureDir(bundleDir(key));
128
+ }
129
+
105
130
  function baseBundleKey(appId: string, platform: BundlePlatform): string {
106
131
  return `base:${appId}:${platform}`;
107
132
  }
@@ -110,16 +135,37 @@ function testBundleKey(appId: string, commitId: string | null | undefined, platf
110
135
  return `test:${appId}:${commitId ?? 'head'}:${platform}:${bundleId}`;
111
136
  }
112
137
 
113
- function toBundleFileUri(key: string): string {
138
+ function legacyBundleFileUri(key: string): string {
114
139
  const dir = bundlesCacheDir();
115
140
  return `${dir}${safeName(key)}.jsbundle`;
116
141
  }
117
142
 
118
- function toBundleMetaFileUri(key: string): string {
143
+ function legacyBundleMetaFileUri(key: string): string {
119
144
  const dir = bundlesCacheDir();
120
145
  return `${dir}${safeName(key)}.meta.json`;
121
146
  }
122
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
+
123
169
  type BaseBundleMeta = {
124
170
  fingerprint: string;
125
171
  bundleId: string;
@@ -144,7 +190,7 @@ async function writeJsonFile(fileUri: string, value: unknown): Promise<void> {
144
190
  try {
145
191
  await (FileSystem as any).writeAsStringAsync(fileUri, JSON.stringify(value));
146
192
  } catch {
147
-
193
+
148
194
  }
149
195
  }
150
196
 
@@ -158,6 +204,15 @@ async function getExistingNonEmptyFileUri(fileUri: string): Promise<string | nul
158
204
  }
159
205
  }
160
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
+
161
216
  async function downloadIfMissing(url: string, fileUri: string): Promise<string> {
162
217
  const existing = await getExistingNonEmptyFileUri(fileUri);
163
218
  if (existing) return existing;
@@ -177,14 +232,103 @@ async function deleteFileIfExists(fileUri: string) {
177
232
  try {
178
233
  const info = await FileSystem.getInfoAsync(fileUri);
179
234
  if (!info.exists) return;
180
- await FileSystem.deleteAsync(fileUri).catch(() => {});
235
+ await FileSystem.deleteAsync(fileUri).catch(() => { });
236
+ } catch {
237
+
238
+ }
239
+ }
240
+
241
+ async function hydrateBaseFromEmbeddedAsset(
242
+ appId: string,
243
+ platform: BundlePlatform,
244
+ embedded: EmbeddedBaseBundle | undefined
245
+ ): Promise<{ bundlePath: string; meta?: BaseBundleMeta | null } | null> {
246
+ if (!embedded?.module) return null;
247
+ const key = baseBundleKey(appId, platform);
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);
254
+
255
+ const asset = Asset.fromModule(embedded.module);
256
+ await asset.downloadAsync();
257
+ const sourceUri = asset.localUri ?? asset.uri;
258
+ if (!sourceUri) return null;
259
+ const info = await FileSystem.getInfoAsync(sourceUri);
260
+ if (!info.exists) return null;
261
+
262
+ await deleteFileIfExists(targetUri);
263
+ await FileSystem.copyAsync({ from: sourceUri, to: targetUri });
264
+ const finalUri = await getExistingNonEmptyFileUri(targetUri);
265
+ if (!finalUri) return null;
266
+ return { bundlePath: finalUri, meta: embedded.meta ?? null };
267
+ }
268
+
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(() => { });
181
310
  } catch {
182
-
183
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;
184
321
  }
185
322
 
186
- async function safeReplaceFileFromUrl(url: string, targetUri: string, tmpKey: string): Promise<string> {
187
- const tmpUri = toBundleFileUri(`tmp:${tmpKey}:${Date.now()}`);
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));
188
332
  try {
189
333
  await withRetry(
190
334
  async () => {
@@ -207,6 +351,107 @@ async function safeReplaceFileFromUrl(url: string, targetUri: string, tmpKey: st
207
351
  }
208
352
  }
209
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
+
210
455
  async function pollBundle(appId: string, bundleId: string, opts: { timeoutMs: number; intervalMs: number }): Promise<Bundle> {
211
456
  const start = Date.now();
212
457
  while (true) {
@@ -254,27 +499,48 @@ async function resolveBundlePath(
254
499
  throw new Error('Bundle build failed.');
255
500
  }
256
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
+
257
511
  const signed = await withRetry(
258
512
  async () => {
259
513
  return await bundlesRepository.getSignedDownloadUrl(appId, finalBundle.id, { redirect: false });
260
514
  },
261
515
  { attempts: 3, baseDelayMs: 500, maxDelayMs: 4000 }
262
516
  );
517
+ const key =
518
+ mode === 'base'
519
+ ? baseBundleKey(appId, platform)
520
+ : testBundleKey(appId, commitId, platform, finalBundle.id);
521
+ await ensureBundleDir(key);
263
522
  const bundlePath =
264
523
  mode === 'base'
265
524
  ? await safeReplaceFileFromUrl(
266
- signed.url,
267
- toBundleFileUri(baseBundleKey(appId, platform)),
268
- `${appId}:${commitId ?? 'head'}:${platform}:${finalBundle.id}`
269
- )
270
- : await downloadIfMissing(signed.url, toBundleFileUri(testBundleKey(appId, commitId, platform, finalBundle.id)));
271
- 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 };
272
537
  }
273
538
 
274
539
  export function useBundleManager({
275
540
  base,
276
541
  platform,
277
542
  canRequestLatest = true,
543
+ embeddedBaseBundles,
278
544
  }: UseBundleManagerParams): UseBundleManagerResult {
279
545
  const [bundlePath, setBundlePath] = React.useState<string | null>(null);
280
546
  const [renderToken, setRenderToken] = React.useState(0);
@@ -287,6 +553,9 @@ export function useBundleManager({
287
553
  const baseRef = React.useRef(base);
288
554
  baseRef.current = base;
289
555
 
556
+ const embeddedBaseBundlesRef = React.useRef<EmbeddedBaseBundles | undefined>(embeddedBaseBundles);
557
+ embeddedBaseBundlesRef.current = embeddedBaseBundles;
558
+
290
559
  // Monotonic operation ids to prevent stale async loads from overwriting newer ones.
291
560
  const baseOpIdRef = React.useRef(0);
292
561
  const testOpIdRef = React.useRef(0);
@@ -319,12 +588,38 @@ export function useBundleManager({
319
588
  const dir = bundlesCacheDir();
320
589
  await ensureDir(dir);
321
590
  const key = baseBundleKey(appId, platform);
322
- const uri = toBundleFileUri(key);
323
- const existing = await getExistingNonEmptyFileUri(uri);
591
+ let existing = await getExistingBundleFileUri(key, platform);
592
+ let embeddedMeta: BaseBundleMeta | null = null;
593
+ if (!existing) {
594
+ const embedded = embeddedBaseBundlesRef.current?.[platform];
595
+ const hydrated = await hydrateBaseFromEmbeddedAsset(appId, platform, embedded);
596
+ if (hydrated?.bundlePath) {
597
+ existing = hydrated.bundlePath;
598
+ embeddedMeta = hydrated.meta ?? null;
599
+ if (embeddedMeta) {
600
+ await ensureBundleDir(key);
601
+ await writeJsonFile(toBundleMetaFileUri(key), embeddedMeta);
602
+ await writeJsonFile(legacyBundleMetaFileUri(key), embeddedMeta);
603
+ }
604
+ }
605
+ }
324
606
  if (existing) {
325
607
  lastBaseBundlePathRef.current = existing;
326
608
  setBundlePath(existing);
327
- const meta = 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
+ }
328
623
  if (meta?.fingerprint) {
329
624
  lastBaseFingerprintRef.current = meta.fingerprint;
330
625
  }
@@ -334,7 +629,7 @@ export function useBundleManager({
334
629
  }
335
630
  }
336
631
  } catch {
337
-
632
+
338
633
  }
339
634
  },
340
635
  [platform]
@@ -408,7 +703,16 @@ export function useBundleManager({
408
703
  lastBaseFingerprintRef.current = fingerprint;
409
704
  hasCompletedFirstNetworkBaseLoadRef.current = true;
410
705
  initialHydratedBaseFromDiskRef.current = false;
411
- 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), {
412
716
  fingerprint,
413
717
  bundleId: bundle.id,
414
718
  checksumSha256: bundle.checksumSha256 ?? null,
@@ -13,6 +13,10 @@ export type RuntimeRendererProps = {
13
13
  * Used to avoid briefly rendering an outdated bundle during post-edit base refresh.
14
14
  */
15
15
  forcePreparing?: boolean;
16
+ /**
17
+ * When false, suppress "Preparing app…" on the very first load.
18
+ */
19
+ allowInitialPreparing?: boolean;
16
20
  /**
17
21
  * Used to force a runtime remount even when bundlePath stays constant
18
22
  * (e.g. base bundle replaced in-place).
@@ -21,8 +25,27 @@ export type RuntimeRendererProps = {
21
25
  style?: ViewStyle;
22
26
  };
23
27
 
24
- export function RuntimeRenderer({ appKey, bundlePath, forcePreparing, renderToken, style }: RuntimeRendererProps) {
28
+ export function RuntimeRenderer({
29
+ appKey,
30
+ bundlePath,
31
+ forcePreparing,
32
+ renderToken,
33
+ style,
34
+ allowInitialPreparing = true,
35
+ }: RuntimeRendererProps) {
36
+ const [hasRenderedOnce, setHasRenderedOnce] = React.useState(false);
37
+
38
+ React.useEffect(() => {
39
+ if (bundlePath) {
40
+ setHasRenderedOnce(true);
41
+ }
42
+ }, [bundlePath]);
43
+
25
44
  if (!bundlePath || forcePreparing) {
45
+ if (!hasRenderedOnce && !forcePreparing && !allowInitialPreparing) {
46
+ return <View style={[{ flex: 1 }, style]} />;
47
+ }
48
+
26
49
  return (
27
50
  <View style={[{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 }, style]}>
28
51
  <Text variant="bodyMuted">Preparing app…</Text>
@@ -5,7 +5,7 @@ import type { App } from '../../data/apps/types';
5
5
  import type { MergeRequest } from '../../data/merge-requests/types';
6
6
  import { StudioBottomSheet } from '../../components/studio-sheet/StudioBottomSheet';
7
7
  import { StudioSheetPager } from '../../components/studio-sheet/StudioSheetPager';
8
- import { FloatingDraggableButton } from '../../components/floating-draggable-button/FloatingDraggableButton';
8
+ import { Bubble } from '../../components/bubble/Bubble';
9
9
  import { EdgeGlowFrame } from '../../components/overlays/EdgeGlowFrame';
10
10
  import { DrawModeOverlay } from '../../components/draw/DrawModeOverlay';
11
11
  import { AppCommentsSheet } from '../../components/comments/AppCommentsSheet';
@@ -61,7 +61,7 @@ export type StudioOverlayProps = {
61
61
 
62
62
  // Navigation callbacks
63
63
  onNavigateHome?: () => void;
64
- showFloatingButton: boolean;
64
+ showBubble: boolean;
65
65
  studioControlOptions?: StudioControlOptions;
66
66
  };
67
67
 
@@ -94,7 +94,7 @@ export function StudioOverlay({
94
94
  chatShowTypingIndicator,
95
95
  onSendChat,
96
96
  onNavigateHome,
97
- showFloatingButton,
97
+ showBubble,
98
98
  studioControlOptions,
99
99
  }: StudioOverlayProps) {
100
100
  const theme = useTheme();
@@ -270,8 +270,8 @@ export function StudioOverlay({
270
270
  />
271
271
  </StudioBottomSheet>
272
272
 
273
- {showFloatingButton && (
274
- <FloatingDraggableButton
273
+ {showBubble && (
274
+ <Bubble
275
275
  visible={!sheetOpen && !drawing}
276
276
  ariaLabel={sheetOpen ? 'Hide studio' : 'Show studio'}
277
277
  badgeCount={incomingMergeRequests.length}
@@ -281,7 +281,7 @@ export function StudioOverlay({
281
281
  <View style={{ width: 28, height: 28, alignItems: 'center', justifyContent: 'center' }}>
282
282
  <MergeIcon width={24} height={24} color={theme.colors.floatingContent} />
283
283
  </View>
284
- </FloatingDraggableButton>
284
+ </Bubble>
285
285
  )}
286
286
 
287
287
  <DrawModeOverlay
@@ -1,4 +0,0 @@
1
- export { FloatingDraggableButton } from './FloatingDraggableButton';
2
- export type { FloatingDraggableButtonProps, FloatingButtonOffset } from './types';
3
-
4
-