@comergehq/studio 0.1.22 → 0.1.24

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.
Files changed (32) hide show
  1. package/dist/index.d.mts +3 -1
  2. package/dist/index.d.ts +3 -1
  3. package/dist/index.js +697 -312
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +724 -336
  6. package/dist/index.mjs.map +1 -1
  7. package/package.json +1 -1
  8. package/src/components/bubble/Bubble.tsx +11 -5
  9. package/src/components/bubble/types.ts +2 -0
  10. package/src/components/chat/ChatComposer.tsx +4 -21
  11. package/src/components/chat/ChatMessageBubble.tsx +33 -2
  12. package/src/components/chat/ChatMessageList.tsx +12 -1
  13. package/src/components/chat/ChatPage.tsx +8 -14
  14. package/src/components/merge-requests/ReviewMergeRequestCard.tsx +1 -1
  15. package/src/components/primitives/MarkdownText.tsx +134 -35
  16. package/src/components/studio-sheet/StudioBottomSheet.tsx +26 -29
  17. package/src/core/services/http/index.ts +64 -1
  18. package/src/core/services/supabase/realtimeManager.ts +55 -1
  19. package/src/data/agent/types.ts +1 -0
  20. package/src/data/apps/bundles/remote.ts +4 -3
  21. package/src/data/users/types.ts +1 -1
  22. package/src/index.ts +1 -0
  23. package/src/studio/ComergeStudio.tsx +6 -2
  24. package/src/studio/hooks/useApp.ts +24 -6
  25. package/src/studio/hooks/useBundleManager.ts +12 -1
  26. package/src/studio/hooks/useForegroundSignal.ts +2 -4
  27. package/src/studio/hooks/useMergeRequests.ts +6 -1
  28. package/src/studio/hooks/useOptimisticChatMessages.ts +55 -3
  29. package/src/studio/hooks/useStudioActions.ts +60 -6
  30. package/src/studio/hooks/useThreadMessages.ts +26 -5
  31. package/src/studio/ui/ChatPanel.tsx +6 -3
  32. package/src/studio/ui/StudioOverlay.tsx +7 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comergehq/studio",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Comerge studio",
5
5
  "main": "src/index.ts",
6
6
  "module": "dist/index.mjs",
@@ -26,6 +26,7 @@ import { DEFAULT_EDGE_PADDING, DEFAULT_OFFSET, DEFAULT_SIZE, ENTER_ROTATION_FROM
26
26
  import type { BubbleProps } from './types';
27
27
  import { useTheme } from '../../theme';
28
28
  import { ResettableLiquidGlassView } from '../utils/ResettableLiquidGlassView';
29
+ import { withAlpha } from '../utils/color';
29
30
 
30
31
  const HIDDEN_OFFSET_X = 20;
31
32
 
@@ -59,6 +60,7 @@ export function Bubble({
59
60
  disabled = false,
60
61
  ariaLabel,
61
62
  isLoading = false,
63
+ loadingBorderTone = 'default',
62
64
  visible = true,
63
65
  badgeCount = 0,
64
66
  offset = DEFAULT_OFFSET,
@@ -84,6 +86,10 @@ export function Bubble({
84
86
  if (isDanger) return 'rgba(239, 68, 68, 0.9)';
85
87
  return theme.scheme === 'dark' ? 'rgba(0, 0, 0, 0.6)' : 'rgba(255, 255, 255, 0.6)';
86
88
  }, [backgroundColor, isDanger, theme.scheme]);
89
+ const warningRingColors = useMemo(
90
+ () => [withAlpha(theme.colors.warning, 0.35), withAlpha(theme.colors.warning, 1)] as const,
91
+ [theme.colors.warning]
92
+ );
87
93
 
88
94
  const translateX = useSharedValue(getHiddenTranslateX(size));
89
95
  const translateY = useSharedValue(getHiddenTranslateY(height));
@@ -118,11 +124,8 @@ export function Bubble({
118
124
  // noop
119
125
  }
120
126
 
121
- animateToHidden({
122
- onFinish: () => {
123
- onPressRef.current?.();
124
- },
125
- });
127
+ onPressRef.current?.();
128
+ animateToHidden();
126
129
  }, [animateToHidden]);
127
130
 
128
131
  useEffect(() => {
@@ -217,11 +220,14 @@ export function Bubble({
217
220
  }));
218
221
 
219
222
  const borderAnimatedStyle = useAnimatedStyle(() => {
223
+ const isWarning = loadingBorderTone === 'warning';
220
224
  const borderColor = interpolateColor(
221
225
  borderPulse.value,
222
226
  [0, 1],
223
227
  isDanger
224
228
  ? ['rgba(239,68,68,0.4)', 'rgba(239,68,68,1)']
229
+ : isWarning
230
+ ? warningRingColors
225
231
  : theme.scheme === 'dark'
226
232
  ? ['rgba(255,255,255,0.2)', 'rgba(255,255,255,0.9)']
227
233
  : ['rgba(55,0,179,0.2)', 'rgba(55,0,179,0.9)']
@@ -40,6 +40,8 @@ export type BubbleProps = {
40
40
 
41
41
  /** When true, renders a pulsing border ring. */
42
42
  isLoading?: boolean;
43
+ /** Visual tone for the pulsing loading ring. */
44
+ loadingBorderTone?: 'default' | 'warning';
43
45
 
44
46
  variant?: 'default' | 'danger';
45
47
 
@@ -9,7 +9,7 @@ import {
9
9
  View,
10
10
  type ViewStyle,
11
11
  } from 'react-native';
12
- import { isLiquidGlassSupported } from '@callstack/liquid-glass';
12
+ import { isLiquidGlassSupported, LiquidGlassView } from '@callstack/liquid-glass';
13
13
  import { Plus } from 'lucide-react-native';
14
14
 
15
15
  import { useTheme } from '../../theme';
@@ -25,7 +25,6 @@ export type ChatComposerProps = {
25
25
  disabled?: boolean;
26
26
  sendDisabled?: boolean;
27
27
  sending?: boolean;
28
- autoFocus?: boolean;
29
28
  onSend: (text: string, attachments?: string[]) => void | Promise<void>;
30
29
  attachments?: string[];
31
30
  onRemoveAttachment?: (index: number) => void;
@@ -95,7 +94,6 @@ export function ChatComposer({
95
94
  disabled = false,
96
95
  sendDisabled = false,
97
96
  sending = false,
98
- autoFocus = false,
99
97
  onSend,
100
98
  attachments = [],
101
99
  onRemoveAttachment,
@@ -119,20 +117,6 @@ export function ChatComposer({
119
117
  const maxInputHeight = React.useMemo(() => Dimensions.get('window').height * 0.5, []);
120
118
  const shakeAnim = React.useRef(new Animated.Value(0)).current;
121
119
  const [sendPressed, setSendPressed] = React.useState(false);
122
- const inputRef = React.useRef<import('react-native').TextInput | null>(null);
123
- const prevAutoFocusRef = React.useRef(false);
124
-
125
- React.useEffect(() => {
126
- const shouldFocus = autoFocus && !prevAutoFocusRef.current && !disabled && !sending;
127
- prevAutoFocusRef.current = autoFocus;
128
- if (!shouldFocus) return;
129
-
130
- // Temporary workaround: Bottom sheets can take a moment to open
131
- const t = setTimeout(() => {
132
- inputRef.current?.focus();
133
- }, 75);
134
- return () => clearTimeout(t);
135
- }, [autoFocus, disabled, sending]);
136
120
 
137
121
  const triggerShake = React.useCallback(() => {
138
122
  shakeAnim.setValue(0);
@@ -168,7 +152,7 @@ export function ChatComposer({
168
152
  >
169
153
  <View style={{ flexDirection: 'row', alignItems: 'flex-end', gap: 8 }}>
170
154
  <Animated.View style={{ flex: 1, transform: [{ translateX: shakeAnim }] }}>
171
- <ResettableLiquidGlassView
155
+ <LiquidGlassView
172
156
  style={[
173
157
  // LiquidGlassView doesn't reliably auto-size to children; ensure enough height for the
174
158
  // thumbnail strip when attachments are present.
@@ -219,13 +203,12 @@ export function ChatComposer({
219
203
  ) : null}
220
204
 
221
205
  <MultilineTextInput
222
- ref={inputRef}
223
206
  value={text}
224
207
  onChangeText={setText}
225
208
  placeholder={placeholder}
226
209
  editable={!disabled && !sending}
227
210
  useBottomSheetTextInput={useBottomSheetTextInput}
228
- autoFocus={autoFocus}
211
+ autoFocus={false}
229
212
  placeholderTextColor={placeholderTextColor}
230
213
  scrollEnabled
231
214
  style={{
@@ -237,7 +220,7 @@ export function ChatComposer({
237
220
  lineHeight: 20,
238
221
  }}
239
222
  />
240
- </ResettableLiquidGlassView>
223
+ </LiquidGlassView>
241
224
  </Animated.View>
242
225
 
243
226
  <ResettableLiquidGlassView
@@ -1,11 +1,13 @@
1
1
  import * as React from 'react';
2
2
  import { View, type ViewStyle } from 'react-native';
3
- import { CheckCheck, GitMerge } from 'lucide-react-native';
3
+ import { CheckCheck, GitMerge, RotateCcw } from 'lucide-react-native';
4
4
 
5
5
  import type { ChatMessage } from '../models/types';
6
6
  import { useTheme } from '../../theme';
7
+ import { Button } from '../primitives/Button';
7
8
  import { MarkdownText } from '../primitives/MarkdownText';
8
9
  import { Surface } from '../primitives/Surface';
10
+ import { Text } from '../primitives/Text';
9
11
 
10
12
  export type ChatMessageBubbleProps = {
11
13
  message: ChatMessage;
@@ -13,10 +15,13 @@ export type ChatMessageBubbleProps = {
13
15
  * Optional custom renderer for message content (e.g. markdown).
14
16
  */
15
17
  renderContent?: (message: ChatMessage) => React.ReactNode;
18
+ isLast?: boolean;
19
+ retrying?: boolean;
20
+ onRetry?: () => void;
16
21
  style?: ViewStyle;
17
22
  };
18
23
 
19
- export function ChatMessageBubble({ message, renderContent, style }: ChatMessageBubbleProps) {
24
+ export function ChatMessageBubble({ message, renderContent, isLast, retrying, onRetry, style }: ChatMessageBubbleProps) {
20
25
  const theme = useTheme();
21
26
  const metaEvent = message.meta?.event ?? null;
22
27
  const metaStatus = message.meta?.status ?? null;
@@ -36,6 +41,8 @@ export function ChatMessageBubble({ message, renderContent, style }: ChatMessage
36
41
 
37
42
  const bodyColor =
38
43
  metaStatus === 'success' ? theme.colors.success : metaStatus === 'error' ? theme.colors.danger : undefined;
44
+ const showRetry = Boolean(onRetry) && isLast && metaStatus === 'error';
45
+ const retryLabel = retrying ? 'Retrying...' : 'Retry';
39
46
 
40
47
  return (
41
48
  <View style={[align, style]}>
@@ -65,6 +72,30 @@ export function ChatMessageBubble({ message, renderContent, style }: ChatMessage
65
72
  </View>
66
73
  </View>
67
74
  </Surface>
75
+ {showRetry ? (
76
+ <View style={{ marginTop: theme.spacing.xs, alignSelf: align.alignSelf }}>
77
+ <Button
78
+ variant="ghost"
79
+ size="sm"
80
+ onPress={onRetry}
81
+ disabled={retrying}
82
+ style={{ borderColor: theme.colors.danger }}
83
+ accessibilityLabel="Retry send"
84
+ >
85
+ <View style={{ flexDirection: 'row', alignItems: 'center' }}>
86
+ {!retrying ? <RotateCcw size={14} color={theme.colors.danger} /> : null}
87
+ <Text
88
+ variant="caption"
89
+ color={theme.colors.danger}
90
+ style={{ marginLeft: retrying ? 0 : theme.spacing.xs }}
91
+ numberOfLines={1}
92
+ >
93
+ {retryLabel}
94
+ </Text>
95
+ </View>
96
+ </Button>
97
+ </View>
98
+ ) : null}
68
99
  </View>
69
100
  );
70
101
  }
@@ -15,6 +15,8 @@ export type ChatMessageListProps = {
15
15
  messages: ChatMessage[];
16
16
  showTypingIndicator?: boolean;
17
17
  renderMessageContent?: ChatMessageBubbleProps['renderContent'];
18
+ onRetryMessage?: (messageId: string) => void;
19
+ isRetryingMessage?: (messageId: string) => boolean;
18
20
  contentStyle?: ViewStyle;
19
21
  bottomInset?: number;
20
22
  /**
@@ -33,6 +35,8 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
33
35
  messages,
34
36
  showTypingIndicator = false,
35
37
  renderMessageContent,
38
+ onRetryMessage,
39
+ isRetryingMessage,
36
40
  contentStyle,
37
41
  bottomInset = 0,
38
42
  onNearBottomChange,
@@ -49,6 +53,7 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
49
53
  const data = React.useMemo(() => {
50
54
  return [...messages].reverse();
51
55
  }, [messages]);
56
+ const lastMessageId = messages.length > 0 ? messages[messages.length - 1]!.id : null;
52
57
 
53
58
  const scrollToBottom = React.useCallback((options?: { animated?: boolean }) => {
54
59
  const animated = options?.animated ?? true;
@@ -121,7 +126,13 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
121
126
  ]}
122
127
  ItemSeparatorComponent={() => <View style={{ height: theme.spacing.sm }} />}
123
128
  renderItem={({ item }: { item: ChatMessage }) => (
124
- <ChatMessageBubble message={item} renderContent={renderMessageContent} />
129
+ <ChatMessageBubble
130
+ message={item}
131
+ renderContent={renderMessageContent}
132
+ isLast={Boolean(lastMessageId && item.id === lastMessageId)}
133
+ retrying={isRetryingMessage?.(item.id) ?? false}
134
+ onRetry={onRetryMessage ? () => onRetryMessage(item.id) : undefined}
135
+ />
125
136
  )}
126
137
  ListHeaderComponent={
127
138
  <View>
@@ -1,5 +1,5 @@
1
1
  import * as React from 'react';
2
- import { Keyboard, Platform, View, type ViewStyle } from 'react-native';
2
+ import { Platform, View, type ViewStyle } from 'react-native';
3
3
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
4
4
 
5
5
  import type { ChatMessage } from '../models/types';
@@ -12,6 +12,8 @@ export type ChatPageProps = {
12
12
  messages: ChatMessage[];
13
13
  showTypingIndicator?: boolean;
14
14
  renderMessageContent?: ChatMessageListProps['renderMessageContent'];
15
+ onRetryMessage?: ChatMessageListProps['onRetryMessage'];
16
+ isRetryingMessage?: ChatMessageListProps['isRetryingMessage'];
15
17
  topBanner?: React.ReactNode;
16
18
  composerTop?: React.ReactNode;
17
19
  composer: Omit<ChatComposerProps, 'attachments'> & {
@@ -32,6 +34,8 @@ export function ChatPage({
32
34
  messages,
33
35
  showTypingIndicator,
34
36
  renderMessageContent,
37
+ onRetryMessage,
38
+ isRetryingMessage,
35
39
  topBanner,
36
40
  composerTop,
37
41
  composer,
@@ -45,19 +49,7 @@ export function ChatPage({
45
49
  const insets = useSafeAreaInsets();
46
50
  const [composerHeight, setComposerHeight] = React.useState(0);
47
51
  const [composerTopHeight, setComposerTopHeight] = React.useState(0);
48
- const [keyboardVisible, setKeyboardVisible] = React.useState(false);
49
-
50
- React.useEffect(() => {
51
- if (Platform.OS !== 'ios') return;
52
- const show = Keyboard.addListener('keyboardWillShow', () => setKeyboardVisible(true));
53
- const hide = Keyboard.addListener('keyboardWillHide', () => setKeyboardVisible(false));
54
- return () => {
55
- show.remove();
56
- hide.remove();
57
- };
58
- }, []);
59
-
60
- const footerBottomPadding = Platform.OS === 'ios' ? (keyboardVisible ? 0 : insets.bottom) : insets.bottom + 10;
52
+ const footerBottomPadding = Platform.OS === 'ios' ? insets.bottom : insets.bottom + 10;
61
53
  const totalComposerHeight = composerHeight + composerTopHeight;
62
54
  const overlayBottom = totalComposerHeight + footerBottomPadding + theme.spacing.lg;
63
55
  const bottomInset = totalComposerHeight + footerBottomPadding + theme.spacing.xl;
@@ -92,6 +84,8 @@ export function ChatPage({
92
84
  messages={messages}
93
85
  showTypingIndicator={showTypingIndicator}
94
86
  renderMessageContent={renderMessageContent}
87
+ onRetryMessage={onRetryMessage}
88
+ isRetryingMessage={isRetryingMessage}
95
89
  onNearBottomChange={onNearBottomChange}
96
90
  bottomInset={bottomInset}
97
91
  />
@@ -109,7 +109,7 @@ export function ReviewMergeRequestCard({
109
109
 
110
110
  <Text style={{ color: theme.colors.textMuted, fontSize: 12, lineHeight: 16, marginBottom: 12 }}>
111
111
  {creator
112
- ? `${creator.approvedOpenedMergeRequests} approved merge${creator.approvedOpenedMergeRequests !== 1 ? 's' : ''}`
112
+ ? `${creator.approvedOrMergedMergeRequests} approved merge${creator.approvedOrMergedMergeRequests !== 1 ? 's' : ''}`
113
113
  : 'Loading stats...'}
114
114
  </Text>
115
115
 
@@ -1,8 +1,9 @@
1
- import { Platform, View, type ViewStyle } from 'react-native';
1
+ import { Platform, Pressable, Text, View, type ViewStyle } from 'react-native';
2
2
 
3
3
  import Markdown from 'react-native-markdown-display';
4
4
 
5
5
  import { useTheme } from '../../theme';
6
+ import { useEffect, useRef, useState } from 'react';
6
7
 
7
8
  export type MarkdownTextVariant = 'chat' | 'mergeRequest';
8
9
 
@@ -16,9 +17,47 @@ export type MarkdownTextProps = {
16
17
  style?: ViewStyle;
17
18
  };
18
19
 
20
+ function copyMarkdownToClipboard(markdown: string) {
21
+ if (!markdown) {
22
+ return;
23
+ }
24
+
25
+ const navigatorClipboard = globalThis?.navigator?.clipboard;
26
+ if (navigatorClipboard?.writeText) {
27
+ void navigatorClipboard.writeText(markdown);
28
+ return;
29
+ }
30
+
31
+ try {
32
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
33
+ const expoClipboard = require('expo-clipboard');
34
+ if (expoClipboard?.setStringAsync) {
35
+ void expoClipboard.setStringAsync(markdown);
36
+ return;
37
+ }
38
+ } catch {
39
+ // optional dependency; fall through
40
+ }
41
+
42
+ try {
43
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
44
+ const rnClipboard = require('@react-native-clipboard/clipboard');
45
+ if (rnClipboard?.setString) {
46
+ rnClipboard.setString(markdown);
47
+ }
48
+ } catch {
49
+ // optional dependency; nothing else to try
50
+ }
51
+ }
52
+
19
53
  export function MarkdownText({ markdown, variant = 'chat', bodyColor, style }: MarkdownTextProps) {
20
54
  const theme = useTheme();
21
55
  const isDark = theme.scheme === 'dark';
56
+ const [showCopied, setShowCopied] = useState(false);
57
+ const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | null>(null);
58
+ const [tooltipWidth, setTooltipWidth] = useState(0);
59
+ const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
60
+ const containerRef = useRef<View>(null);
22
61
 
23
62
  const baseBodyColor = variant === 'mergeRequest' ? theme.colors.textMuted : theme.colors.text;
24
63
  const linkColor =
@@ -31,41 +70,101 @@ export function MarkdownText({ markdown, variant = 'chat', bodyColor, style }: M
31
70
  const paragraphBottom = variant === 'mergeRequest' ? 8 : 6;
32
71
  const baseLineHeight = variant === 'mergeRequest' ? 22 : 20;
33
72
 
73
+ useEffect(() => {
74
+ return () => {
75
+ if (hideTimerRef.current) {
76
+ clearTimeout(hideTimerRef.current);
77
+ }
78
+ };
79
+ }, []);
80
+
81
+ const handleLongPress = (event: {
82
+ nativeEvent: { locationX: number; locationY: number; pageX: number; pageY: number };
83
+ }) => {
84
+ const { locationX, locationY, pageX, pageY } = event.nativeEvent;
85
+
86
+ if (containerRef.current?.measureInWindow) {
87
+ containerRef.current.measureInWindow((x, y) => {
88
+ setTooltipPosition({ x: pageX - x, y: pageY - y });
89
+ });
90
+ } else {
91
+ setTooltipPosition({ x: locationX, y: locationY });
92
+ }
93
+ copyMarkdownToClipboard(markdown);
94
+ setShowCopied(true);
95
+
96
+ if (hideTimerRef.current) {
97
+ clearTimeout(hideTimerRef.current);
98
+ }
99
+
100
+ hideTimerRef.current = setTimeout(() => {
101
+ setShowCopied(false);
102
+ }, 1200);
103
+ };
104
+
34
105
  return (
35
- <View style={style}>
36
- <Markdown
37
- style={{
38
- body: { color: bodyColor ?? baseBodyColor, fontSize: 14, lineHeight: baseLineHeight },
39
- paragraph: { marginTop: 0, marginBottom: paragraphBottom },
40
- link: { color: linkColor, fontWeight: linkWeight },
41
- code_inline: {
42
- backgroundColor: codeBgColor,
43
- color: codeTextColor,
44
- paddingHorizontal: variant === 'mergeRequest' ? 6 : 4,
45
- paddingVertical: variant === 'mergeRequest' ? 2 : 0,
46
- borderRadius: variant === 'mergeRequest' ? 6 : 4,
47
- fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
48
- fontSize: 13,
49
- },
50
- code_block: {
51
- backgroundColor: codeBgColor,
52
- color: codeTextColor,
53
- padding: variant === 'mergeRequest' ? 12 : 8,
54
- borderRadius: variant === 'mergeRequest' ? 8 : 6,
55
- marginVertical: variant === 'mergeRequest' ? 8 : 0,
56
- },
57
- fence: {
58
- backgroundColor: codeBgColor,
59
- color: codeTextColor,
60
- padding: variant === 'mergeRequest' ? 12 : 8,
61
- borderRadius: variant === 'mergeRequest' ? 8 : 6,
62
- marginVertical: variant === 'mergeRequest' ? 8 : 0,
63
- },
64
- }}
65
- >
66
- {markdown}
67
- </Markdown>
68
- </View>
106
+ <Pressable style={style} onLongPress={handleLongPress}>
107
+ <View ref={containerRef} style={{ position: 'relative' }}>
108
+ <Markdown
109
+ style={{
110
+ body: { color: bodyColor ?? baseBodyColor, fontSize: 14, lineHeight: baseLineHeight },
111
+ paragraph: { marginTop: 0, marginBottom: paragraphBottom },
112
+ link: { color: linkColor, fontWeight: linkWeight },
113
+ code_inline: {
114
+ backgroundColor: codeBgColor,
115
+ color: codeTextColor,
116
+ paddingHorizontal: variant === 'mergeRequest' ? 6 : 4,
117
+ paddingVertical: variant === 'mergeRequest' ? 2 : 0,
118
+ borderRadius: variant === 'mergeRequest' ? 6 : 4,
119
+ fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
120
+ fontSize: 13,
121
+ },
122
+ code_block: {
123
+ backgroundColor: codeBgColor,
124
+ color: codeTextColor,
125
+ padding: variant === 'mergeRequest' ? 12 : 8,
126
+ borderRadius: variant === 'mergeRequest' ? 8 : 6,
127
+ marginVertical: variant === 'mergeRequest' ? 8 : 0,
128
+ },
129
+ fence: {
130
+ backgroundColor: codeBgColor,
131
+ color: codeTextColor,
132
+ padding: variant === 'mergeRequest' ? 12 : 8,
133
+ borderRadius: variant === 'mergeRequest' ? 8 : 6,
134
+ marginVertical: variant === 'mergeRequest' ? 8 : 0,
135
+ },
136
+ }}
137
+ >
138
+ {markdown}
139
+ </Markdown>
140
+ {showCopied && tooltipPosition ? (
141
+ <View
142
+ pointerEvents="none"
143
+ style={{
144
+ position: 'absolute',
145
+ left: tooltipPosition.x,
146
+ top: tooltipPosition.y - theme.spacing.lg - 32,
147
+ backgroundColor: theme.colors.success,
148
+ borderRadius: theme.radii.pill,
149
+ paddingHorizontal: theme.spacing.sm,
150
+ paddingVertical: theme.spacing.xs,
151
+ transform: [{ translateX: tooltipWidth ? -tooltipWidth / 2 : 0 }],
152
+ }}
153
+ onLayout={(event) => setTooltipWidth(event.nativeEvent.layout.width)}
154
+ >
155
+ <Text
156
+ style={{
157
+ color: theme.colors.onSuccess,
158
+ fontSize: theme.typography.fontSize.xs,
159
+ fontWeight: theme.typography.fontWeight.medium,
160
+ }}
161
+ >
162
+ Copied
163
+ </Text>
164
+ </View>
165
+ ) : null}
166
+ </View>
167
+ </Pressable>
69
168
  );
70
169
  }
71
170
 
@@ -1,6 +1,10 @@
1
1
  import * as React from 'react';
2
- import { AppState, Keyboard, Platform, View, type AppStateStatus } from 'react-native';
3
- import BottomSheet, { type BottomSheetBackgroundProps, type BottomSheetProps } from '@gorhom/bottom-sheet';
2
+ import { AppState, Keyboard, View, type AppStateStatus } from 'react-native';
3
+ import {
4
+ BottomSheetModal,
5
+ type BottomSheetBackgroundProps,
6
+ type BottomSheetModalProps,
7
+ } from '@gorhom/bottom-sheet';
4
8
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
9
 
6
10
  import { useTheme } from '../../theme';
@@ -20,9 +24,9 @@ export type StudioBottomSheetProps = {
20
24
  snapPoints?: StudioSheetSnapPoints;
21
25
 
22
26
  /**
23
- * Optional ref forwarding to control the BottomSheet imperatively.
27
+ * Optional ref forwarding to control the BottomSheetModal imperatively.
24
28
  */
25
- sheetRef?: React.RefObject<BottomSheet | null>;
29
+ sheetRef?: React.RefObject<BottomSheetModal | null>;
26
30
 
27
31
  /**
28
32
  * Provide a custom background renderer (e.g. BlurView).
@@ -35,10 +39,10 @@ export type StudioBottomSheetProps = {
35
39
  children: React.ReactNode;
36
40
 
37
41
  /**
38
- * Additional BottomSheet props
42
+ * Additional BottomSheetModal props
39
43
  */
40
44
  bottomSheetProps?: Omit<
41
- BottomSheetProps,
45
+ BottomSheetModalProps,
42
46
  | 'ref'
43
47
  | 'index'
44
48
  | 'snapPoints'
@@ -48,6 +52,7 @@ export type StudioBottomSheetProps = {
48
52
  | 'bottomInset'
49
53
  | 'handleIndicatorStyle'
50
54
  | 'onChange'
55
+ | 'onDismiss'
51
56
  | 'children'
52
57
  >;
53
58
  };
@@ -63,31 +68,21 @@ export function StudioBottomSheet({
63
68
  }: StudioBottomSheetProps) {
64
69
  const theme = useTheme();
65
70
  const insets = useSafeAreaInsets();
66
- const internalSheetRef = React.useRef<BottomSheet | null>(null);
71
+ const internalSheetRef = React.useRef<BottomSheetModal | null>(null);
67
72
  const resolvedSheetRef = sheetRef ?? internalSheetRef;
68
- const currentIndexRef = React.useRef<number>(open ? snapPoints.length - 1 : -1);
73
+ const resolvedSnapPoints = React.useMemo<(string | number)[]>(() => [...snapPoints], [snapPoints]);
74
+ const currentIndexRef = React.useRef<number>(open ? resolvedSnapPoints.length - 1 : -1);
69
75
  const lastAppStateRef = React.useRef<AppStateStatus>(AppState.currentState);
70
76
 
71
- // Workaround: @gorhom/bottom-sheet can occasionally render empty content after app resume.
77
+ // Workaround: @gorhom/bottom-sheet can occasionally render empty content after app resume if the keyboard is open.
72
78
  React.useEffect(() => {
73
79
  const sub = AppState.addEventListener('change', (state) => {
74
- const prev = lastAppStateRef.current;
75
80
  lastAppStateRef.current = state;
76
81
 
77
82
  if (state === 'background' || state === 'inactive') {
78
83
  Keyboard.dismiss();
79
84
  return;
80
85
  }
81
-
82
- if (state !== 'active') return;
83
- const sheet = resolvedSheetRef.current;
84
- if (!sheet) return;
85
- const idx = currentIndexRef.current;
86
- if (open && idx >= 0) {
87
- Keyboard.dismiss();
88
- requestAnimationFrame(() => sheet.snapToIndex(idx));
89
- setTimeout(() => sheet.snapToIndex(idx), 120);
90
- }
91
86
  });
92
87
  return () => sub.remove();
93
88
  }, [open, resolvedSheetRef]);
@@ -97,12 +92,11 @@ export function StudioBottomSheet({
97
92
  if (!sheet) return;
98
93
 
99
94
  if (open) {
100
- // Open to the highest snap point by default.
101
- sheet.snapToIndex(snapPoints.length - 1);
95
+ sheet.present();
102
96
  } else {
103
- sheet.close();
97
+ sheet.dismiss();
104
98
  }
105
- }, [open, resolvedSheetRef, snapPoints.length]);
99
+ }, [open, resolvedSheetRef, resolvedSnapPoints.length]);
106
100
 
107
101
  const handleChange = React.useCallback(
108
102
  (index: number) => {
@@ -113,13 +107,15 @@ export function StudioBottomSheet({
113
107
  );
114
108
 
115
109
  return (
116
- <BottomSheet
110
+ <BottomSheetModal
117
111
  ref={resolvedSheetRef}
118
- index={open ? snapPoints.length - 1 : -1}
119
- snapPoints={snapPoints}
112
+ index={resolvedSnapPoints.length - 1}
113
+ snapPoints={resolvedSnapPoints}
120
114
  enableDynamicSizing={false}
121
115
  enablePanDownToClose
122
116
  enableContentPanningGesture={false}
117
+ keyboardBehavior="interactive"
118
+ keyboardBlurBehavior="restore"
123
119
  android_keyboardInputMode="adjustResize"
124
120
  backgroundComponent={(props: BottomSheetBackgroundProps) => (
125
121
  <StudioSheetBackground {...props} renderBackground={background?.renderBackground} />
@@ -127,11 +123,12 @@ export function StudioBottomSheet({
127
123
  topInset={insets.top}
128
124
  bottomInset={0}
129
125
  handleIndicatorStyle={{ backgroundColor: theme.colors.handleIndicator }}
130
- onChange={handleChange}
131
126
  {...bottomSheetProps}
127
+ onChange={handleChange}
128
+ onDismiss={() => onOpenChange?.(false)}
132
129
  >
133
130
  <View style={{ flex: 1, overflow: 'hidden' }}>{children}</View>
134
- </BottomSheet>
131
+ </BottomSheetModal>
135
132
  );
136
133
  }
137
134