@comergehq/studio 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comergehq/studio",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Comerge studio",
5
5
  "main": "src/index.ts",
6
6
  "module": "dist/index.mjs",
@@ -9,12 +9,14 @@ import {
9
9
  View,
10
10
  type ViewStyle,
11
11
  } from 'react-native';
12
- import { LiquidGlassView, isLiquidGlassSupported } from '@callstack/liquid-glass';
12
+ import { isLiquidGlassSupported } from '@callstack/liquid-glass';
13
13
  import { Plus } from 'lucide-react-native';
14
14
 
15
15
  import { useTheme } from '../../theme';
16
16
  import { MultilineTextInput } from './MultilineTextInput';
17
17
  import { IconChevronRight, IconClose } from '../icons/StudioIcons';
18
+ import { withAlpha } from '../utils/color';
19
+ import { ResettableLiquidGlassView } from '../utils/ResettableLiquidGlassView';
18
20
 
19
21
  export type ChatComposerProps = {
20
22
  value?: string;
@@ -157,6 +159,7 @@ export function ChatComposer({
157
159
 
158
160
  const textareaBgColor = theme.scheme === 'dark' ? '#18181B' : '#F6F6F6';
159
161
  const placeholderTextColor = theme.scheme === 'dark' ? '#A1A1AA' : '#71717A';
162
+ const sendBg = withAlpha(theme.colors.primary, isButtonDisabled ? 0.6 : sendPressed ? 0.9 : 1);
160
163
 
161
164
  return (
162
165
  <View
@@ -165,7 +168,7 @@ export function ChatComposer({
165
168
  >
166
169
  <View style={{ flexDirection: 'row', alignItems: 'flex-end', gap: 8 }}>
167
170
  <Animated.View style={{ flex: 1, transform: [{ translateX: shakeAnim }] }}>
168
- <LiquidGlassView
171
+ <ResettableLiquidGlassView
169
172
  style={[
170
173
  // LiquidGlassView doesn't reliably auto-size to children; ensure enough height for the
171
174
  // thumbnail strip when attachments are present.
@@ -234,10 +237,10 @@ export function ChatComposer({
234
237
  lineHeight: 20,
235
238
  }}
236
239
  />
237
- </LiquidGlassView>
240
+ </ResettableLiquidGlassView>
238
241
  </Animated.View>
239
242
 
240
- <LiquidGlassView
243
+ <ResettableLiquidGlassView
241
244
  style={[{ borderRadius: 100 }, !isLiquidGlassSupported && { backgroundColor: textareaBgColor }]}
242
245
  interactive
243
246
  effect="clear"
@@ -248,8 +251,7 @@ export function ChatComposer({
248
251
  height: 44,
249
252
  borderRadius: 22,
250
253
  overflow: 'hidden',
251
- backgroundColor: theme.colors.primary,
252
- opacity: isButtonDisabled ? 0.6 : sendPressed ? 0.9 : 1,
254
+ backgroundColor: sendBg,
253
255
  }}
254
256
  >
255
257
  <Pressable
@@ -270,7 +272,7 @@ export function ChatComposer({
270
272
  )}
271
273
  </Pressable>
272
274
  </View>
273
- </LiquidGlassView>
275
+ </ResettableLiquidGlassView>
274
276
  </View>
275
277
  </View>
276
278
  );
@@ -46,10 +46,13 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
46
46
  const initialScrollDoneRef = React.useRef(false);
47
47
  const lastMessageIdRef = React.useRef<string | null>(null);
48
48
 
49
+ const data = React.useMemo(() => {
50
+ return [...messages].reverse();
51
+ }, [messages]);
52
+
49
53
  const scrollToBottom = React.useCallback((options?: { animated?: boolean }) => {
50
54
  const animated = options?.animated ?? true;
51
- // Scroll to visual bottom (latest messages) in a normal (non-inverted) list.
52
- listRef.current?.scrollToEnd({ animated });
55
+ listRef.current?.scrollToOffset({ offset: 0, animated });
53
56
  }, []);
54
57
 
55
58
  React.useImperativeHandle(ref, () => ({ scrollToBottom }), [scrollToBottom]);
@@ -57,12 +60,7 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
57
60
  const handleScroll = React.useCallback(
58
61
  (e: NativeSyntheticEvent<NativeScrollEvent>) => {
59
62
  const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
60
- // Treat "bottom" as the end of actual messages (excluding the intentional footer spacer),
61
- // so "near bottom" still means "near the latest message", not "deep into empty space".
62
- const distanceFromBottom = Math.max(
63
- contentSize.height - Math.max(bottomInset, 0) - (contentOffset.y + layoutMeasurement.height),
64
- 0
65
- );
63
+ const distanceFromBottom = Math.max(contentOffset.y - Math.max(bottomInset, 0), 0);
66
64
  const isNear = distanceFromBottom <= nearBottomThreshold;
67
65
 
68
66
  if (nearBottomRef.current !== isNear) {
@@ -73,16 +71,6 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
73
71
  [bottomInset, nearBottomThreshold, onNearBottomChange]
74
72
  );
75
73
 
76
- // On first load, start at the bottom
77
- React.useEffect(() => {
78
- if (initialScrollDoneRef.current) return;
79
- if (messages.length === 0) return;
80
-
81
- initialScrollDoneRef.current = true;
82
- lastMessageIdRef.current = messages[messages.length - 1]?.id ?? null;
83
- const id = requestAnimationFrame(() => scrollToBottom({ animated: false }));
84
- return () => cancelAnimationFrame(id);
85
- }, [messages, scrollToBottom]);
86
74
 
87
75
  // When new messages arrive, keep the user pinned to the bottom only if they already were near it.
88
76
  React.useEffect(() => {
@@ -106,38 +94,36 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
106
94
  return undefined;
107
95
  }, [showTypingIndicator, scrollToBottom]);
108
96
 
109
- // When the bottom inset grows/shrinks (e.g. composer height changes), keep pinned users at bottom.
110
- React.useEffect(() => {
111
- if (!initialScrollDoneRef.current) return;
112
- if (!nearBottomRef.current) return;
113
- const id = requestAnimationFrame(() => scrollToBottom({ animated: false }));
114
- return () => cancelAnimationFrame(id);
115
- }, [bottomInset, scrollToBottom]);
116
-
117
97
  return (
118
98
  <BottomSheetFlatList
119
99
  ref={listRef}
120
- data={messages}
100
+ inverted
101
+ data={data}
121
102
  keyExtractor={(m: ChatMessage) => m.id}
122
- keyboardDismissMode={Platform.OS === 'ios' ? 'interactive' : 'on-drag'}
123
103
  keyboardShouldPersistTaps="handled"
124
104
  onScroll={handleScroll}
125
105
  scrollEventThrottle={16}
126
106
  showsVerticalScrollIndicator={false}
107
+ onContentSizeChange={() => {
108
+ if (initialScrollDoneRef.current) return;
109
+ initialScrollDoneRef.current = true;
110
+ lastMessageIdRef.current = messages.length > 0 ? messages[messages.length - 1]!.id : null;
111
+ nearBottomRef.current = true;
112
+ onNearBottomChange?.(true);
113
+ requestAnimationFrame(() => scrollToBottom({ animated: false }));
114
+ }}
127
115
  contentContainerStyle={[
128
116
  {
129
117
  paddingHorizontal: theme.spacing.lg,
130
- paddingTop: theme.spacing.sm,
131
- paddingBottom: theme.spacing.sm,
118
+ paddingVertical: theme.spacing.sm,
132
119
  },
133
120
  contentStyle,
134
121
  ]}
135
- renderItem={({ item, index }: { item: ChatMessage; index: number }) => (
136
- <View style={{ marginTop: index === 0 ? 0 : theme.spacing.sm }}>
137
- <ChatMessageBubble message={item} renderContent={renderMessageContent} />
138
- </View>
122
+ ItemSeparatorComponent={() => <View style={{ height: theme.spacing.sm }} />}
123
+ renderItem={({ item }: { item: ChatMessage }) => (
124
+ <ChatMessageBubble message={item} renderContent={renderMessageContent} />
139
125
  )}
140
- ListFooterComponent={
126
+ ListHeaderComponent={
141
127
  <View>
142
128
  {showTypingIndicator ? (
143
129
  <View style={{ marginTop: theme.spacing.sm, alignSelf: 'flex-start', paddingHorizontal: theme.spacing.lg }}>
@@ -21,6 +21,7 @@ export type ChatPageProps = {
21
21
  */
22
22
  overlay?: React.ReactNode;
23
23
  style?: ViewStyle;
24
+ composerHorizontalPadding?: number;
24
25
  onNearBottomChange?: ChatMessageListProps['onNearBottomChange'];
25
26
  listRef?: React.RefObject<ChatMessageListRef | null>;
26
27
  };
@@ -34,6 +35,7 @@ export function ChatPage({
34
35
  composer,
35
36
  overlay,
36
37
  style,
38
+ composerHorizontalPadding,
37
39
  onNearBottomChange,
38
40
  listRef,
39
41
  }: ChatPageProps) {
@@ -92,7 +94,7 @@ export function ChatPage({
92
94
  left: 0,
93
95
  right: 0,
94
96
  bottom: 0,
95
- paddingHorizontal: theme.spacing.lg,
97
+ paddingHorizontal: composerHorizontalPadding ?? theme.spacing.md,
96
98
  paddingTop: theme.spacing.sm,
97
99
  paddingBottom: footerBottomPadding,
98
100
  }}
@@ -6,7 +6,7 @@ import {
6
6
  BottomSheetScrollView,
7
7
  } from '@gorhom/bottom-sheet';
8
8
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
9
- import { LiquidGlassView, isLiquidGlassSupported } from '@callstack/liquid-glass';
9
+ import { isLiquidGlassSupported } from '@callstack/liquid-glass';
10
10
  import { Play } from 'lucide-react-native';
11
11
 
12
12
  import { useTheme } from '../../theme';
@@ -17,6 +17,7 @@ import { CommentRow } from './CommentRow';
17
17
  import { useAppComments } from './useAppComments';
18
18
  import { useAppDetails } from './useAppDetails';
19
19
  import { useIosKeyboardSnapFix } from './useIosKeyboardSnapFix';
20
+ import { ResettableLiquidGlassView } from '../utils/ResettableLiquidGlassView';
20
21
 
21
22
  export type AppCommentsSheetProps = {
22
23
  appId: string | null;
@@ -117,7 +118,7 @@ export function AppCommentsSheet({ appId, onClose, onCountChange, onPlayApp }: A
117
118
  {loadingApp ? 'Loading...' : app?.name || 'Comments'}
118
119
  </Text>
119
120
 
120
- <LiquidGlassView
121
+ <ResettableLiquidGlassView
121
122
  style={[
122
123
  { borderRadius: 24 },
123
124
  !isLiquidGlassSupported && { backgroundColor: theme.scheme === 'dark' ? '#18181B' : '#F6F6F6' },
@@ -130,10 +131,9 @@ export function AppCommentsSheet({ appId, onClose, onCountChange, onPlayApp }: A
130
131
  width: 32,
131
132
  height: 32,
132
133
  borderRadius: 999,
133
- backgroundColor: theme.colors.primary,
134
+ backgroundColor: withAlpha(theme.colors.primary, appId ? 1 : 0.5),
134
135
  alignItems: 'center',
135
136
  justifyContent: 'center',
136
- opacity: appId ? 1 : 0.5,
137
137
  }}
138
138
  >
139
139
  <Pressable
@@ -147,13 +147,13 @@ export function AppCommentsSheet({ appId, onClose, onCountChange, onPlayApp }: A
147
147
  alignItems: 'center',
148
148
  justifyContent: 'center',
149
149
  },
150
- pressed ? { opacity: 0.85 } : null,
150
+ pressed ? { transform: [{ scale: 0.96 }] } : null,
151
151
  ]}
152
152
  >
153
153
  <Play size={16} color={theme.colors.onPrimary} />
154
154
  </Pressable>
155
155
  </View>
156
- </LiquidGlassView>
156
+ </ResettableLiquidGlassView>
157
157
  </View>
158
158
 
159
159
  <BottomSheetScrollView
@@ -20,11 +20,12 @@ import Animated, {
20
20
  withSpring,
21
21
  withTiming,
22
22
  } from 'react-native-reanimated';
23
- import { LiquidGlassView, isLiquidGlassSupported } from '@callstack/liquid-glass';
23
+ import { isLiquidGlassSupported } from '@callstack/liquid-glass';
24
24
 
25
- import { DEFAULT_EDGE_PADDING, DEFAULT_OFFSET, DEFAULT_SIZE, ENTER_ROTATION_FROM_DEG, ENTER_SCALE_FROM, HIDDEN_OPACITY, PULSE_DURATION_MS } from './constants';
25
+ import { DEFAULT_EDGE_PADDING, DEFAULT_OFFSET, DEFAULT_SIZE, ENTER_ROTATION_FROM_DEG, ENTER_SCALE_FROM, PULSE_DURATION_MS } from './constants';
26
26
  import type { FloatingDraggableButtonProps } from './types';
27
27
  import { useTheme } from '../../theme';
28
+ import { ResettableLiquidGlassView } from '../utils/ResettableLiquidGlassView';
28
29
 
29
30
  const HIDDEN_OFFSET_X = 20;
30
31
 
@@ -35,9 +36,6 @@ const SPRING_ROTATION_IN = { damping: 15, stiffness: 80 } as const;
35
36
  const SPRING_ROTATION_GRAB = { damping: 20 } as const;
36
37
  const SPRING_SCALE_GRAB = { damping: 15, stiffness: 200 } as const;
37
38
 
38
- const TIMING_OPACITY_IN = { duration: 300, easing: Easing.out(Easing.ease) } as const;
39
- const TIMING_OPACITY_OUT = { duration: 250, easing: Easing.in(Easing.ease) } as const;
40
-
41
39
  function clamp(value: number, min: number, max: number) {
42
40
  'worklet';
43
41
  return Math.max(min, Math.min(max, value));
@@ -91,7 +89,6 @@ export function FloatingDraggableButton({
91
89
  const translateY = useSharedValue(getHiddenTranslateY(height));
92
90
  const scale = useSharedValue(ENTER_SCALE_FROM);
93
91
  const rotation = useSharedValue(ENTER_ROTATION_FROM_DEG);
94
- const opacity = useSharedValue(1);
95
92
  const borderPulse = useSharedValue(0);
96
93
  const startPos = useRef({ x: 0, y: 0 });
97
94
  const isAnimatingOut = useRef(false);
@@ -99,26 +96,16 @@ export function FloatingDraggableButton({
99
96
  const animateToHidden = useCallback(
100
97
  (options?: { onFinish?: () => void }) => {
101
98
  // Animate back to starting position (reverse of entrance)
102
- translateX.value = withSpring(getHiddenTranslateX(size), SPRING_POSITION);
99
+ const finish = options?.onFinish;
100
+
101
+ translateX.value = withSpring(getHiddenTranslateX(size), SPRING_POSITION, (finished?: boolean) => {
102
+ if (finished && finish) runOnJS(finish)();
103
+ });
103
104
  translateY.value = withSpring(getHiddenTranslateY(height), SPRING_POSITION);
104
105
  scale.value = withSpring(ENTER_SCALE_FROM, SPRING_SCALE_IN);
105
106
  rotation.value = withSpring(ENTER_ROTATION_FROM_DEG, SPRING_ROTATION_IN);
106
-
107
- const finish = options?.onFinish;
108
- if (!finish) {
109
- opacity.value = withTiming(HIDDEN_OPACITY, TIMING_OPACITY_OUT);
110
- return;
111
- }
112
-
113
- opacity.value = withTiming(
114
- HIDDEN_OPACITY as unknown as number,
115
- TIMING_OPACITY_OUT,
116
- (finished?: boolean) => {
117
- if (finished) runOnJS(finish)();
118
- }
119
- );
120
107
  },
121
- [height, opacity, rotation, scale, size, translateX, translateY]
108
+ [height, rotation, scale, size, translateX, translateY]
122
109
  );
123
110
 
124
111
  const animateOut = useCallback(() => {
@@ -163,8 +150,7 @@ export function FloatingDraggableButton({
163
150
  withSpring(1, SPRING_SCALE_OUT)
164
151
  );
165
152
  rotation.value = withSpring(0, SPRING_ROTATION_IN);
166
- opacity.value = withTiming(1, TIMING_OPACITY_IN);
167
- }, [height, offset.bottom, offset.left, opacity, rotation, scale, size, translateX, translateY]);
153
+ }, [height, offset.bottom, offset.left, rotation, scale, size, translateX, translateY]);
168
154
 
169
155
  // Initial animation on mount
170
156
  useEffect(() => {
@@ -228,7 +214,6 @@ export function FloatingDraggableButton({
228
214
  { scale: scale.value },
229
215
  { rotate: `${rotation.value}deg` },
230
216
  ],
231
- opacity: opacity.value,
232
217
  }));
233
218
 
234
219
  const borderAnimatedStyle = useAnimatedStyle(() => {
@@ -257,7 +242,7 @@ export function FloatingDraggableButton({
257
242
  accessibilityLabel={ariaLabel}
258
243
  >
259
244
  <Animated.View style={[{ width: size, height: size, borderRadius: size / 2 }, borderAnimatedStyle]}>
260
- <LiquidGlassView
245
+ <ResettableLiquidGlassView
261
246
  style={[{ flex: 1, borderRadius: size / 2 }, !isLiquidGlassSupported && { backgroundColor: fallbackBgColor }]}
262
247
  interactive
263
248
  effect="clear"
@@ -271,7 +256,7 @@ export function FloatingDraggableButton({
271
256
  >
272
257
  {children ?? <View />}
273
258
  </Pressable>
274
- </LiquidGlassView>
259
+ </ResettableLiquidGlassView>
275
260
  </Animated.View>
276
261
 
277
262
  {badgeCount > 0 && (
@@ -1,11 +1,12 @@
1
1
  import * as React from 'react';
2
2
  import { Pressable, View, type ViewStyle } from 'react-native';
3
- import { LiquidGlassView, isLiquidGlassSupported } from '@callstack/liquid-glass';
3
+ import { isLiquidGlassSupported } from '@callstack/liquid-glass';
4
4
  import { Heart, MessageCircle } from 'lucide-react-native';
5
5
 
6
6
  import { useTheme } from '../../theme';
7
7
  import { Text } from '../primitives/Text';
8
8
  import { MergeIcon } from '../icons/MergeIcon';
9
+ import { ResettableLiquidGlassView } from '../utils/ResettableLiquidGlassView';
9
10
 
10
11
  export type StatsBarProps = {
11
12
  likeCount: number;
@@ -41,7 +42,7 @@ export function StatsBar({
41
42
  style,
42
43
  ]}
43
44
  >
44
- <LiquidGlassView
45
+ <ResettableLiquidGlassView
45
46
  style={[
46
47
  { borderRadius: 100, overflow: 'hidden' },
47
48
  fixedWidth ? { width: fixedWidth } : undefined,
@@ -101,7 +102,7 @@ export function StatsBar({
101
102
  </Text>
102
103
  </View>
103
104
  </View>
104
- </LiquidGlassView>
105
+ </ResettableLiquidGlassView>
105
106
  </View>
106
107
  );
107
108
  }
@@ -55,7 +55,7 @@ export type StudioBottomSheetProps = {
55
55
  export function StudioBottomSheet({
56
56
  open,
57
57
  onOpenChange,
58
- snapPoints = ['80%', '100%'],
58
+ snapPoints = ['100%'],
59
59
  sheetRef,
60
60
  background,
61
61
  children,
@@ -117,9 +117,9 @@ export function StudioBottomSheet({
117
117
  ref={resolvedSheetRef}
118
118
  index={open ? snapPoints.length - 1 : -1}
119
119
  snapPoints={snapPoints}
120
+ enableDynamicSizing={false}
120
121
  enablePanDownToClose
121
- keyboardBehavior="interactive"
122
- keyboardBlurBehavior="restore"
122
+ enableContentPanningGesture={false}
123
123
  android_keyboardInputMode="adjustResize"
124
124
  backgroundComponent={(props: BottomSheetBackgroundProps) => (
125
125
  <StudioSheetBackground {...props} renderBackground={background?.renderBackground} />
@@ -1,9 +1,10 @@
1
1
  import * as React from 'react';
2
2
  import { Platform, View, type ViewStyle } from 'react-native';
3
3
  import type { BottomSheetBackgroundProps } from '@gorhom/bottom-sheet';
4
- import { LiquidGlassView, isLiquidGlassSupported } from '@callstack/liquid-glass';
4
+ import { isLiquidGlassSupported } from '@callstack/liquid-glass';
5
5
 
6
6
  import { useTheme } from '../../theme';
7
+ import { ResettableLiquidGlassView } from '../utils/ResettableLiquidGlassView';
7
8
 
8
9
  export type StudioSheetBackgroundProps = BottomSheetBackgroundProps & {
9
10
  /**
@@ -35,7 +36,7 @@ export function StudioSheetBackground({
35
36
 
36
37
  return (
37
38
  <>
38
- <LiquidGlassView
39
+ <ResettableLiquidGlassView
39
40
  style={[containerStyle, !isLiquidGlassSupported && { backgroundColor: fallbackBgColor }]}
40
41
  effect="regular"
41
42
  />
@@ -1,8 +1,10 @@
1
1
  import * as React from 'react';
2
2
  import { Pressable, View, type ViewStyle } from 'react-native';
3
- import { LiquidGlassView, isLiquidGlassSupported } from '@callstack/liquid-glass';
3
+ import { isLiquidGlassSupported } from '@callstack/liquid-glass';
4
4
 
5
5
  import { useTheme } from '../../theme';
6
+ import { withAlpha } from '../utils/color';
7
+ import { ResettableLiquidGlassView } from '../utils/ResettableLiquidGlassView';
6
8
 
7
9
  export type StudioSheetHeaderIconButtonProps = {
8
10
  onPress: () => void;
@@ -38,11 +40,12 @@ export function StudioSheetHeaderIconButton({
38
40
  const glassInnerBg = intent === 'danger' ? theme.colors.danger : theme.colors.primary;
39
41
 
40
42
  const resolvedOpacity = disabled ? 0.6 : pressed ? 0.9 : 1;
43
+ const glassBg = withAlpha(glassInnerBg, resolvedOpacity);
41
44
 
42
45
  return (
43
46
  <View style={style}>
44
47
  {appearance === 'glass' ? (
45
- <LiquidGlassView
48
+ <ResettableLiquidGlassView
46
49
  style={[{ borderRadius: 100 }, !isLiquidGlassSupported && { backgroundColor: glassFallbackBg }]}
47
50
  interactive
48
51
  effect="clear"
@@ -54,8 +57,7 @@ export function StudioSheetHeaderIconButton({
54
57
  borderRadius: 100,
55
58
  alignItems: 'center',
56
59
  justifyContent: 'center',
57
- backgroundColor: glassInnerBg,
58
- opacity: resolvedOpacity,
60
+ backgroundColor: glassBg,
59
61
  }}
60
62
  >
61
63
  <Pressable
@@ -73,7 +75,7 @@ export function StudioSheetHeaderIconButton({
73
75
  {children}
74
76
  </Pressable>
75
77
  </View>
76
- </LiquidGlassView>
78
+ </ResettableLiquidGlassView>
77
79
  ) : (
78
80
  <View
79
81
  style={{
@@ -0,0 +1,28 @@
1
+ import * as React from 'react';
2
+ import { LiquidGlassView } from '@callstack/liquid-glass';
3
+
4
+ import { useLiquidGlassResetToken } from './liquidGlassReset';
5
+
6
+ export type ResettableLiquidGlassViewProps = React.ComponentProps<typeof LiquidGlassView>;
7
+
8
+ export function ResettableLiquidGlassView({ children, ...props }: ResettableLiquidGlassViewProps) {
9
+ const token = useLiquidGlassResetToken();
10
+ const [layoutBootKey, setLayoutBootKey] = React.useState(0);
11
+ const sawNonZeroLayoutRef = React.useRef(false);
12
+
13
+ const onLayout: NonNullable<ResettableLiquidGlassViewProps['onLayout']> = (e) => {
14
+ props.onLayout?.(e);
15
+ const { width, height } = e.nativeEvent.layout;
16
+ if (width > 0 && height > 0 && !sawNonZeroLayoutRef.current) {
17
+ sawNonZeroLayoutRef.current = true;
18
+ setLayoutBootKey((k) => k + 1);
19
+ }
20
+ };
21
+
22
+ return (
23
+ <LiquidGlassView key={`liquid-glass-${token}-${layoutBootKey}`} {...props} onLayout={onLayout}>
24
+ {children}
25
+ </LiquidGlassView>
26
+ );
27
+ }
28
+
@@ -0,0 +1,36 @@
1
+ import * as React from 'react';
2
+ import { AppState, type AppStateStatus, Platform } from 'react-native';
3
+
4
+ const LiquidGlassResetContext = React.createContext(0);
5
+
6
+ export function LiquidGlassResetProvider({
7
+ children,
8
+ resetTriggers = [],
9
+ }: {
10
+ children: React.ReactNode;
11
+ resetTriggers?: React.DependencyList;
12
+ }) {
13
+ const [token, setToken] = React.useState(0);
14
+
15
+ React.useEffect(() => {
16
+ if (Platform.OS !== 'ios') return;
17
+
18
+ const onChange = (state: AppStateStatus) => {
19
+ if (state === 'active') setToken((t) => t + 1);
20
+ };
21
+
22
+ const sub = AppState.addEventListener('change', onChange);
23
+ return () => sub.remove();
24
+ }, []);
25
+
26
+ React.useEffect(() => {
27
+ setToken((t) => t + 1);
28
+ }, resetTriggers);
29
+
30
+ return <LiquidGlassResetContext.Provider value={token}>{children}</LiquidGlassResetContext.Provider>;
31
+ }
32
+
33
+ export function useLiquidGlassResetToken() {
34
+ return React.useContext(LiquidGlassResetContext);
35
+ }
36
+
@@ -13,6 +13,7 @@ import { useStudioActions } from './hooks/useStudioActions';
13
13
  import { hasNoOutcomeAfterLastHuman } from './lib/chat';
14
14
  import { RuntimeRenderer } from './ui/RuntimeRenderer';
15
15
  import { StudioOverlay } from './ui/StudioOverlay';
16
+ import { LiquidGlassResetProvider } from '../components/utils/liquidGlassReset';
16
17
 
17
18
  export type ComergeStudioProps = {
18
19
  appId: string;
@@ -46,20 +47,22 @@ export function ComergeStudio({
46
47
  <StudioBootstrap apiKey={apiKey}>
47
48
  {({ userId }) => (
48
49
  <BottomSheetModalProvider>
49
- <ComergeStudioInner
50
- userId={userId}
51
- activeAppId={activeAppId}
52
- setActiveAppId={setActiveAppId}
53
- runtimeAppId={runtimeAppId}
54
- setRuntimeAppId={setRuntimeAppId}
55
- pendingRuntimeTargetAppId={pendingRuntimeTargetAppId}
56
- setPendingRuntimeTargetAppId={setPendingRuntimeTargetAppId}
57
- appKey={appKey}
58
- platform={platform}
59
- onNavigateHome={onNavigateHome}
60
- captureTargetRef={captureTargetRef}
61
- style={style}
62
- />
50
+ <LiquidGlassResetProvider resetTriggers={[appId, activeAppId, runtimeAppId]}>
51
+ <ComergeStudioInner
52
+ userId={userId}
53
+ activeAppId={activeAppId}
54
+ setActiveAppId={setActiveAppId}
55
+ runtimeAppId={runtimeAppId}
56
+ setRuntimeAppId={setRuntimeAppId}
57
+ pendingRuntimeTargetAppId={pendingRuntimeTargetAppId}
58
+ setPendingRuntimeTargetAppId={setPendingRuntimeTargetAppId}
59
+ appKey={appKey}
60
+ platform={platform}
61
+ onNavigateHome={onNavigateHome}
62
+ captureTargetRef={captureTargetRef}
63
+ style={style}
64
+ />
65
+ </LiquidGlassResetProvider>
63
66
  </BottomSheetModalProvider>
64
67
  )}
65
68
  </StudioBootstrap>
@@ -125,6 +128,34 @@ function ComergeStudioInner({
125
128
  canRequestLatest: runtimeApp?.status === 'ready',
126
129
  });
127
130
 
131
+ const sawEditingOnActiveAppRef = React.useRef(false);
132
+ const [showPostEditPreparing, setShowPostEditPreparing] = React.useState(false);
133
+ React.useEffect(() => {
134
+ sawEditingOnActiveAppRef.current = false;
135
+ setShowPostEditPreparing(false);
136
+ }, [activeAppId]);
137
+
138
+ React.useEffect(() => {
139
+ if (!app?.id) return;
140
+ if (app.status === 'editing') {
141
+ sawEditingOnActiveAppRef.current = true;
142
+ setShowPostEditPreparing(false);
143
+ return;
144
+ }
145
+ if (app.status === 'ready' && sawEditingOnActiveAppRef.current) {
146
+ setShowPostEditPreparing(true);
147
+ sawEditingOnActiveAppRef.current = false;
148
+ }
149
+ }, [app?.id, app?.status]);
150
+
151
+ React.useEffect(() => {
152
+ if (!showPostEditPreparing) return;
153
+ const stillProcessingBaseBundle = bundle.loading && bundle.loadingMode === 'base' && !bundle.isTesting;
154
+ if (!stillProcessingBaseBundle) {
155
+ setShowPostEditPreparing(false);
156
+ }
157
+ }, [showPostEditPreparing, bundle.loading, bundle.loadingMode, bundle.isTesting]);
158
+
128
159
  const threadId = app?.threadId ?? '';
129
160
  const thread = useThreadMessages(threadId);
130
161
 
@@ -173,7 +204,12 @@ function ComergeStudioInner({
173
204
  return (
174
205
  <View style={[{ flex: 1 }, style]}>
175
206
  <View ref={captureTargetRef} style={{ flex: 1 }} collapsable={false}>
176
- <RuntimeRenderer appKey={appKey} bundlePath={bundle.bundlePath} renderToken={bundle.renderToken} />
207
+ <RuntimeRenderer
208
+ appKey={appKey}
209
+ bundlePath={bundle.bundlePath}
210
+ forcePreparing={showPostEditPreparing}
211
+ renderToken={bundle.renderToken}
212
+ />
177
213
 
178
214
  <StudioOverlay
179
215
  captureTargetRef={captureTargetRef}