@comergehq/studio 0.1.12 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comergehq/studio",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "Comerge studio",
5
5
  "main": "src/index.ts",
6
6
  "module": "dist/index.mjs",
@@ -57,6 +57,7 @@
57
57
  "@gorhom/bottom-sheet": "*",
58
58
  "@comergehq/runtime": "^0.1.1",
59
59
  "expo": "*",
60
+ "expo-asset": "*",
60
61
  "expo-file-system": "*",
61
62
  "expo-haptics": "*",
62
63
  "expo-linear-gradient": "*",
@@ -23,7 +23,7 @@ import Animated, {
23
23
  import { isLiquidGlassSupported } from '@callstack/liquid-glass';
24
24
 
25
25
  import { DEFAULT_EDGE_PADDING, DEFAULT_OFFSET, DEFAULT_SIZE, ENTER_ROTATION_FROM_DEG, ENTER_SCALE_FROM, PULSE_DURATION_MS } from './constants';
26
- import type { FloatingDraggableButtonProps } from './types';
26
+ import type { BubbleProps } from './types';
27
27
  import { useTheme } from '../../theme';
28
28
  import { ResettableLiquidGlassView } from '../utils/ResettableLiquidGlassView';
29
29
 
@@ -53,7 +53,7 @@ function getFinalTranslateY(height: number, size: number, bottomOffset: number)
53
53
  return height - size - bottomOffset;
54
54
  }
55
55
 
56
- export function FloatingDraggableButton({
56
+ export function Bubble({
57
57
  onPress,
58
58
  size = DEFAULT_SIZE,
59
59
  disabled = false,
@@ -69,7 +69,7 @@ export function FloatingDraggableButton({
69
69
  testID,
70
70
  edgePadding = DEFAULT_EDGE_PADDING,
71
71
  backgroundColor,
72
- }: FloatingDraggableButtonProps) {
72
+ }: BubbleProps) {
73
73
  const theme = useTheme();
74
74
  const { width, height } = useWindowDimensions();
75
75
  const isDanger = variant === 'danger';
@@ -1,9 +1,9 @@
1
- import type { FloatingButtonOffset } from './types';
1
+ import type { BubbleOffset } from './types';
2
2
 
3
3
  export const DEFAULT_SIZE = 48;
4
4
  export const DEFAULT_EDGE_PADDING = 10;
5
5
 
6
- export const DEFAULT_OFFSET: Required<FloatingButtonOffset> = {
6
+ export const DEFAULT_OFFSET: Required<BubbleOffset> = {
7
7
  left: 20,
8
8
  bottom: 60,
9
9
  };
@@ -0,0 +1,4 @@
1
+ export { Bubble } from './Bubble';
2
+ export type { BubbleProps, BubbleOffset } from './types';
3
+
4
+
@@ -1,14 +1,14 @@
1
1
  import type * as React from 'react';
2
2
  import type { StyleProp, ViewStyle } from 'react-native';
3
3
 
4
- export type FloatingButtonOffset = {
4
+ export type BubbleOffset = {
5
5
  /** Distance from the left edge (in px). */
6
6
  left?: number;
7
7
  /** Distance from the bottom edge (in px). */
8
8
  bottom?: number;
9
9
  };
10
10
 
11
- export type FloatingDraggableButtonProps = {
11
+ export type BubbleProps = {
12
12
  /**
13
13
  * Whether the button should be shown. When toggled, the button animates in/out.
14
14
  * The component stays mounted to preserve its last drag position.
@@ -30,7 +30,7 @@ export type FloatingDraggableButtonProps = {
30
30
  * Initial placement when it animates in.
31
31
  * `left` is measured from the left edge; `bottom` from the bottom edge.
32
32
  */
33
- offset?: FloatingButtonOffset;
33
+ offset?: BubbleOffset;
34
34
 
35
35
  /** Accessible label for screen readers (kept as `ariaLabel` for compatibility). */
36
36
  ariaLabel?: string;
@@ -1,5 +1,5 @@
1
- export { FloatingDraggableButton } from './floating-draggable-button';
2
- export type { FloatingDraggableButtonProps, FloatingButtonOffset } from './floating-draggable-button';
1
+ export { Bubble } from './bubble';
2
+ export type { BubbleProps, BubbleOffset } from './bubble';
3
3
 
4
4
  export * from './icons/StudioIcons';
5
5
 
@@ -1,3 +1,3 @@
1
- export const BASE_URL = "https://comerge.ai";
1
+ export const BASE_URL = "https://api.comerge.ai";
2
2
 
3
3
 
@@ -3,7 +3,7 @@ import axios from "axios";
3
3
  import { BASE_URL } from "./baseUrl";
4
4
 
5
5
  const CLIENT_KEY_HEADER = "x-comerge-api-key";
6
- let clientApiKey: string | null = null;
6
+ let clientKey: string | null = null;
7
7
 
8
8
  export const publicApi = axios.create({
9
9
  baseURL: BASE_URL,
@@ -14,19 +14,19 @@ export const publicApi = axios.create({
14
14
  },
15
15
  });
16
16
 
17
- export function setClientApiKey(apiKey: string) {
18
- const trimmed = apiKey?.trim?.() ?? "";
17
+ export function setClientKey(clientKeyInput: string) {
18
+ const trimmed = clientKeyInput?.trim?.() ?? "";
19
19
  if (!trimmed) {
20
- throw new Error("comerge-studio: apiKey is required");
20
+ throw new Error("comerge-studio: clientKey is required");
21
21
  }
22
- clientApiKey = trimmed;
22
+ clientKey = trimmed;
23
23
  publicApi.defaults.headers.common[CLIENT_KEY_HEADER] = trimmed;
24
24
  }
25
25
 
26
26
  publicApi.interceptors.request.use((config) => {
27
- if (!clientApiKey) return config;
27
+ if (!clientKey) return config;
28
28
  config.headers = config.headers ?? {};
29
- (config.headers as any)[CLIENT_KEY_HEADER] = clientApiKey;
29
+ (config.headers as any)[CLIENT_KEY_HEADER] = clientKey;
30
30
  return config;
31
31
  });
32
32
 
@@ -7,6 +7,7 @@ import { StudioBootstrap } from './bootstrap/StudioBootstrap';
7
7
  import { useApp } from './hooks/useApp';
8
8
  import { useThreadMessages } from './hooks/useThreadMessages';
9
9
  import { useBundleManager } from './hooks/useBundleManager';
10
+ import type { EmbeddedBaseBundles } from './hooks/useBundleManager';
10
11
  import { useMergeRequests } from './hooks/useMergeRequests';
11
12
  import { useAttachmentUpload } from './hooks/useAttachmentUpload';
12
13
  import { useStudioActions } from './hooks/useStudioActions';
@@ -17,22 +18,24 @@ import { LiquidGlassResetProvider } from '../components/utils/liquidGlassReset';
17
18
 
18
19
  export type ComergeStudioProps = {
19
20
  appId: string;
20
- apiKey: string;
21
+ clientKey: string;
21
22
  appKey?: string;
22
23
  onNavigateHome?: () => void;
23
24
  style?: ViewStyle;
24
- showFloatingButton?: boolean;
25
+ showBubble?: boolean;
25
26
  studioControlOptions?: import('@comergehq/studio-control').StudioControlOptions;
27
+ embeddedBaseBundles?: EmbeddedBaseBundles;
26
28
  };
27
29
 
28
30
  export function ComergeStudio({
29
31
  appId,
30
- apiKey,
32
+ clientKey,
31
33
  appKey = 'MicroMain',
32
34
  onNavigateHome,
33
35
  style,
34
- showFloatingButton = true,
36
+ showBubble = true,
35
37
  studioControlOptions,
38
+ embeddedBaseBundles,
36
39
  }: ComergeStudioProps) {
37
40
  const [activeAppId, setActiveAppId] = React.useState(appId);
38
41
  const [runtimeAppId, setRuntimeAppId] = React.useState(appId);
@@ -48,7 +51,7 @@ export function ComergeStudio({
48
51
  const captureTargetRef = React.useRef<View | null>(null);
49
52
 
50
53
  return (
51
- <StudioBootstrap apiKey={apiKey}>
54
+ <StudioBootstrap clientKey={clientKey} fallback={<View style={{ flex: 1 }} />}>
52
55
  {({ userId }) => (
53
56
  <BottomSheetModalProvider>
54
57
  <LiquidGlassResetProvider resetTriggers={[appId, activeAppId, runtimeAppId]}>
@@ -65,8 +68,9 @@ export function ComergeStudio({
65
68
  onNavigateHome={onNavigateHome}
66
69
  captureTargetRef={captureTargetRef}
67
70
  style={style}
68
- showFloatingButton={showFloatingButton}
71
+ showBubble={showBubble}
69
72
  studioControlOptions={studioControlOptions}
73
+ embeddedBaseBundles={embeddedBaseBundles}
70
74
  />
71
75
  </LiquidGlassResetProvider>
72
76
  </BottomSheetModalProvider>
@@ -88,8 +92,9 @@ type InnerProps = {
88
92
  onNavigateHome?: () => void;
89
93
  captureTargetRef: React.RefObject<View | null>;
90
94
  style?: ViewStyle;
91
- showFloatingButton: boolean;
95
+ showBubble: boolean;
92
96
  studioControlOptions?: import('@comergehq/studio-control').StudioControlOptions;
97
+ embeddedBaseBundles?: EmbeddedBaseBundles;
93
98
  };
94
99
 
95
100
  function ComergeStudioInner({
@@ -105,8 +110,9 @@ function ComergeStudioInner({
105
110
  onNavigateHome,
106
111
  captureTargetRef,
107
112
  style,
108
- showFloatingButton,
113
+ showBubble,
109
114
  studioControlOptions,
115
+ embeddedBaseBundles,
110
116
  }: InnerProps) {
111
117
  const { app, loading: appLoading } = useApp(activeAppId);
112
118
  const { app: runtimeAppFromHook } = useApp(runtimeAppId, { enabled: runtimeAppId !== activeAppId });
@@ -136,6 +142,7 @@ function ComergeStudioInner({
136
142
  base: { appId: runtimeAppId, commitId: runtimeApp?.headCommitId ?? undefined },
137
143
  platform,
138
144
  canRequestLatest: runtimeApp?.status === 'ready',
145
+ embeddedBaseBundles,
139
146
  });
140
147
 
141
148
  const sawEditingOnActiveAppRef = React.useRef(false);
@@ -219,6 +226,7 @@ function ComergeStudioInner({
219
226
  bundlePath={bundle.bundlePath}
220
227
  forcePreparing={showPostEditPreparing}
221
228
  renderToken={bundle.renderToken}
229
+ allowInitialPreparing={!embeddedBaseBundles}
222
230
  />
223
231
 
224
232
  <StudioOverlay
@@ -276,7 +284,7 @@ function ComergeStudioInner({
276
284
  chatShowTypingIndicator={chatShowTypingIndicator}
277
285
  onSendChat={(text, attachments) => actions.sendEdit({ prompt: text, attachments })}
278
286
  onNavigateHome={onNavigateHome}
279
- showFloatingButton={showFloatingButton}
287
+ showBubble={showBubble}
280
288
  studioControlOptions={studioControlOptions}
281
289
  />
282
290
  </View>
@@ -16,8 +16,8 @@ export type StudioBootstrapProps = UseStudioBootstrapOptions & {
16
16
  renderError?: (error: Error) => React.ReactNode;
17
17
  };
18
18
 
19
- export function StudioBootstrap({ children, fallback, renderError, apiKey }: StudioBootstrapProps) {
20
- const { ready, error, userId } = useStudioBootstrap({ apiKey });
19
+ export function StudioBootstrap({ children, fallback, renderError, clientKey }: StudioBootstrapProps) {
20
+ const { ready, error, userId } = useStudioBootstrap({ clientKey });
21
21
 
22
22
  if (error) {
23
23
  return (
@@ -1,12 +1,13 @@
1
1
  import * as React from 'react';
2
2
 
3
- import { setClientApiKey } from '../../core/services/http/public';
3
+ import { setClientKey } from '../../core/services/http/public';
4
4
  import { ensureAuthenticatedSession, ensureAnonymousSession } from '../../core/services/supabase/auth';
5
5
  import { isSupabaseClientInjected, setSupabaseConfig } from '../../core/services/supabase/client';
6
- import { studioConfigRepository } from '../../data/public/studio-config/repository';
6
+ const SUPABASE_URL = 'https://xtfxwbckjpfmqubnsusu.supabase.co';
7
+ const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inh0Znh3YmNranBmbXF1Ym5zdXN1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjA2MDEyMzAsImV4cCI6MjA3NjE3NzIzMH0.dzWGAWrK4CvrmHVHzf8w7JlUZohdap0ZPnLZnABMV8s';
7
8
 
8
9
  export type UseStudioBootstrapOptions = {
9
- apiKey: string;
10
+ clientKey: string;
10
11
  };
11
12
 
12
13
  export type StudioBootstrapState = {
@@ -27,11 +28,10 @@ export function useStudioBootstrap(options: UseStudioBootstrapOptions): StudioBo
27
28
 
28
29
  (async () => {
29
30
  try {
30
- setClientApiKey(options.apiKey);
31
+ setClientKey(options.clientKey);
31
32
  const requireAuth = isSupabaseClientInjected();
32
33
  if (!requireAuth) {
33
- const cfg = await studioConfigRepository.get();
34
- setSupabaseConfig(cfg);
34
+ setSupabaseConfig({ url: SUPABASE_URL, anonKey: SUPABASE_ANON_KEY });
35
35
  }
36
36
  const { user } = requireAuth ? await ensureAuthenticatedSession() : await ensureAnonymousSession();
37
37
 
@@ -47,7 +47,7 @@ export function useStudioBootstrap(options: UseStudioBootstrapOptions): StudioBo
47
47
  return () => {
48
48
  cancelled = true;
49
49
  };
50
- }, [options.apiKey]);
50
+ }, [options.clientKey]);
51
51
 
52
52
  return state;
53
53
  }
@@ -1,5 +1,6 @@
1
1
  import * as React from 'react';
2
2
  import * as FileSystem from 'expo-file-system/legacy';
3
+ import { Asset } from 'expo-asset';
3
4
 
4
5
  import type { Platform as BundlePlatform, Bundle } from '../../data/apps/bundles/types';
5
6
  import { bundlesRepository } from '../../data/apps/bundles/repository';
@@ -60,6 +61,7 @@ export type UseBundleManagerParams = {
60
61
  * Test bundles (merge request previews) are NOT gated by this.
61
62
  */
62
63
  canRequestLatest?: boolean;
64
+ embeddedBaseBundles?: EmbeddedBaseBundles;
63
65
  };
64
66
 
65
67
  export type BundleLoadState = {
@@ -85,6 +87,16 @@ export type UseBundleManagerResult = BundleLoadState & {
85
87
  restoreBase: () => Promise<void>;
86
88
  };
87
89
 
90
+ export type EmbeddedBaseBundle = {
91
+ module: number;
92
+ meta?: BaseBundleMeta | null;
93
+ };
94
+
95
+ export type EmbeddedBaseBundles = {
96
+ ios?: EmbeddedBaseBundle;
97
+ android?: EmbeddedBaseBundle;
98
+ };
99
+
88
100
  function safeName(s: string) {
89
101
  return s.replace(/[^a-zA-Z0-9._-]/g, '_');
90
102
  }
@@ -183,6 +195,31 @@ async function deleteFileIfExists(fileUri: string) {
183
195
  }
184
196
  }
185
197
 
198
+ async function hydrateBaseFromEmbeddedAsset(
199
+ appId: string,
200
+ platform: BundlePlatform,
201
+ embedded: EmbeddedBaseBundle | undefined
202
+ ): Promise<{ bundlePath: string; meta?: BaseBundleMeta | null } | null> {
203
+ if (!embedded?.module) return null;
204
+ 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 };
208
+
209
+ const asset = Asset.fromModule(embedded.module);
210
+ await asset.downloadAsync();
211
+ const sourceUri = asset.localUri ?? asset.uri;
212
+ if (!sourceUri) return null;
213
+ const info = await FileSystem.getInfoAsync(sourceUri);
214
+ if (!info.exists) return null;
215
+
216
+ await deleteFileIfExists(targetUri);
217
+ await FileSystem.copyAsync({ from: sourceUri, to: targetUri });
218
+ const finalUri = await getExistingNonEmptyFileUri(targetUri);
219
+ if (!finalUri) return null;
220
+ return { bundlePath: finalUri, meta: embedded.meta ?? null };
221
+ }
222
+
186
223
  async function safeReplaceFileFromUrl(url: string, targetUri: string, tmpKey: string): Promise<string> {
187
224
  const tmpUri = toBundleFileUri(`tmp:${tmpKey}:${Date.now()}`);
188
225
  try {
@@ -275,6 +312,7 @@ export function useBundleManager({
275
312
  base,
276
313
  platform,
277
314
  canRequestLatest = true,
315
+ embeddedBaseBundles,
278
316
  }: UseBundleManagerParams): UseBundleManagerResult {
279
317
  const [bundlePath, setBundlePath] = React.useState<string | null>(null);
280
318
  const [renderToken, setRenderToken] = React.useState(0);
@@ -287,6 +325,9 @@ export function useBundleManager({
287
325
  const baseRef = React.useRef(base);
288
326
  baseRef.current = base;
289
327
 
328
+ const embeddedBaseBundlesRef = React.useRef<EmbeddedBaseBundles | undefined>(embeddedBaseBundles);
329
+ embeddedBaseBundlesRef.current = embeddedBaseBundles;
330
+
290
331
  // Monotonic operation ids to prevent stale async loads from overwriting newer ones.
291
332
  const baseOpIdRef = React.useRef(0);
292
333
  const testOpIdRef = React.useRef(0);
@@ -320,11 +361,23 @@ export function useBundleManager({
320
361
  await ensureDir(dir);
321
362
  const key = baseBundleKey(appId, platform);
322
363
  const uri = toBundleFileUri(key);
323
- const existing = await getExistingNonEmptyFileUri(uri);
364
+ let existing = await getExistingNonEmptyFileUri(uri);
365
+ let embeddedMeta: BaseBundleMeta | null = null;
366
+ if (!existing) {
367
+ const embedded = embeddedBaseBundlesRef.current?.[platform];
368
+ const hydrated = await hydrateBaseFromEmbeddedAsset(appId, platform, embedded);
369
+ if (hydrated?.bundlePath) {
370
+ existing = hydrated.bundlePath;
371
+ embeddedMeta = hydrated.meta ?? null;
372
+ if (embeddedMeta) {
373
+ await writeJsonFile(toBundleMetaFileUri(key), embeddedMeta);
374
+ }
375
+ }
376
+ }
324
377
  if (existing) {
325
378
  lastBaseBundlePathRef.current = existing;
326
379
  setBundlePath(existing);
327
- const meta = await readJsonFile<BaseBundleMeta>(toBundleMetaFileUri(key));
380
+ const meta = embeddedMeta ?? (await readJsonFile<BaseBundleMeta>(toBundleMetaFileUri(key)));
328
381
  if (meta?.fingerprint) {
329
382
  lastBaseFingerprintRef.current = meta.fingerprint;
330
383
  }
@@ -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
-