@comergehq/studio 0.1.7 → 0.1.9

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.7",
3
+ "version": "0.1.9",
4
4
  "description": "Comerge studio",
5
5
  "main": "src/index.ts",
6
6
  "module": "dist/index.mjs",
@@ -21,6 +21,7 @@ export type ChatComposerProps = {
21
21
  onChangeValue?: (text: string) => void;
22
22
  placeholder?: string;
23
23
  disabled?: boolean;
24
+ sendDisabled?: boolean;
24
25
  sending?: boolean;
25
26
  autoFocus?: boolean;
26
27
  onSend: (text: string, attachments?: string[]) => void | Promise<void>;
@@ -90,6 +91,7 @@ export function ChatComposer({
90
91
  onChangeValue,
91
92
  placeholder = 'Describe the idea you want to build',
92
93
  disabled = false,
94
+ sendDisabled = false,
93
95
  sending = false,
94
96
  autoFocus = false,
95
97
  onSend,
@@ -111,7 +113,7 @@ export function ChatComposer({
111
113
  const hasText = text.trim().length > 0;
112
114
  const composerMinHeight = hasAttachments ? THUMBNAIL_HEIGHT + 44 + 24 : 44;
113
115
 
114
- const isButtonDisabled = sending || disabled;
116
+ const isButtonDisabled = sending || disabled || sendDisabled;
115
117
  const maxInputHeight = React.useMemo(() => Dimensions.get('window').height * 0.5, []);
116
118
  const shakeAnim = React.useRef(new Animated.Value(0)).current;
117
119
  const [sendPressed, setSendPressed] = React.useState(false);
@@ -1,5 +1,5 @@
1
1
  import * as React from 'react';
2
- import { View, type NativeScrollEvent, type NativeSyntheticEvent, type ViewStyle } from 'react-native';
2
+ import { Platform, View, type NativeScrollEvent, type NativeSyntheticEvent, type ViewStyle } from 'react-native';
3
3
  import { BottomSheetFlatList } from '@gorhom/bottom-sheet';
4
4
 
5
5
  import type { ChatMessage } from '../models/types';
@@ -16,6 +16,7 @@ export type ChatMessageListProps = {
16
16
  showTypingIndicator?: boolean;
17
17
  renderMessageContent?: ChatMessageBubbleProps['renderContent'];
18
18
  contentStyle?: ViewStyle;
19
+ bottomInset?: number;
19
20
  /**
20
21
  * Called when the user is near the bottom of the list.
21
22
  */
@@ -33,6 +34,7 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
33
34
  showTypingIndicator = false,
34
35
  renderMessageContent,
35
36
  contentStyle,
37
+ bottomInset = 0,
36
38
  onNearBottomChange,
37
39
  nearBottomThreshold = 200,
38
40
  },
@@ -55,7 +57,12 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
55
57
  const handleScroll = React.useCallback(
56
58
  (e: NativeSyntheticEvent<NativeScrollEvent>) => {
57
59
  const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
58
- const distanceFromBottom = Math.max(contentSize.height - (contentOffset.y + layoutMeasurement.height), 0);
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
+ );
59
66
  const isNear = distanceFromBottom <= nearBottomThreshold;
60
67
 
61
68
  if (nearBottomRef.current !== isNear) {
@@ -63,7 +70,7 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
63
70
  onNearBottomChange?.(isNear);
64
71
  }
65
72
  },
66
- [nearBottomThreshold, onNearBottomChange]
73
+ [bottomInset, nearBottomThreshold, onNearBottomChange]
67
74
  );
68
75
 
69
76
  // On first load, start at the bottom
@@ -99,11 +106,21 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
99
106
  return undefined;
100
107
  }, [showTypingIndicator, scrollToBottom]);
101
108
 
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
+
102
117
  return (
103
118
  <BottomSheetFlatList
104
119
  ref={listRef}
105
120
  data={messages}
106
121
  keyExtractor={(m: ChatMessage) => m.id}
122
+ keyboardDismissMode={Platform.OS === 'ios' ? 'interactive' : 'on-drag'}
123
+ keyboardShouldPersistTaps="handled"
107
124
  onScroll={handleScroll}
108
125
  scrollEventThrottle={16}
109
126
  showsVerticalScrollIndicator={false}
@@ -111,7 +128,7 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
111
128
  {
112
129
  paddingHorizontal: theme.spacing.lg,
113
130
  paddingTop: theme.spacing.sm,
114
- paddingBottom: theme.spacing.xl,
131
+ paddingBottom: theme.spacing.sm,
115
132
  },
116
133
  contentStyle,
117
134
  ]}
@@ -121,13 +138,15 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
121
138
  </View>
122
139
  )}
123
140
  ListFooterComponent={
124
- showTypingIndicator ? (
125
- <View style={{ marginTop: theme.spacing.sm, alignSelf: 'flex-start', paddingHorizontal: theme.spacing.lg }}>
126
- <TypingIndicator />
127
- </View>
128
- ) : null
141
+ <View>
142
+ {showTypingIndicator ? (
143
+ <View style={{ marginTop: theme.spacing.sm, alignSelf: 'flex-start', paddingHorizontal: theme.spacing.lg }}>
144
+ <TypingIndicator />
145
+ </View>
146
+ ) : null}
147
+ {bottomInset > 0 ? <View style={{ height: bottomInset }} /> : null}
148
+ </View>
129
149
  }
130
- maintainVisibleContentPosition={{ minIndexForVisible: 0, autoscrollToTopThreshold: nearBottomThreshold }}
131
150
  />
132
151
  );
133
152
  }
@@ -1,7 +1,6 @@
1
1
  import * as React from 'react';
2
2
  import { Keyboard, Platform, View, type ViewStyle } from 'react-native';
3
3
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
4
- import Animated, { useAnimatedKeyboard, useAnimatedStyle } from 'react-native-reanimated';
5
4
 
6
5
  import type { ChatMessage } from '../models/types';
7
6
  import { useTheme } from '../../theme';
@@ -42,11 +41,9 @@ export function ChatPage({
42
41
  const insets = useSafeAreaInsets();
43
42
  const [composerHeight, setComposerHeight] = React.useState(0);
44
43
  const [keyboardVisible, setKeyboardVisible] = React.useState(false);
45
- const animatedKeyboard = useAnimatedKeyboard();
46
44
 
47
45
  React.useEffect(() => {
48
46
  if (Platform.OS !== 'ios') return;
49
-
50
47
  const show = Keyboard.addListener('keyboardWillShow', () => setKeyboardVisible(true));
51
48
  const hide = Keyboard.addListener('keyboardWillHide', () => setKeyboardVisible(false));
52
49
  return () => {
@@ -56,11 +53,8 @@ export function ChatPage({
56
53
  }, []);
57
54
 
58
55
  const footerBottomPadding = Platform.OS === 'ios' ? (keyboardVisible ? 0 : insets.bottom) : insets.bottom + 10;
59
- const footerAnimatedStyle = useAnimatedStyle(() => {
60
- if (Platform.OS !== 'ios') return { paddingBottom: insets.bottom + 10 };
61
- return { paddingBottom: animatedKeyboard.height.value > 0 ? 0 : insets.bottom };
62
- });
63
56
  const overlayBottom = composerHeight + footerBottomPadding + theme.spacing.lg;
57
+ const bottomInset = composerHeight + footerBottomPadding + theme.spacing.xl;
64
58
 
65
59
  const resolvedOverlay = React.useMemo(() => {
66
60
  if (!overlay) return null;
@@ -79,35 +73,36 @@ export function ChatPage({
79
73
  </View>
80
74
  ) : null}
81
75
  <View style={{ flex: 1 }}>
82
- <ChatMessageList
83
- ref={listRef}
84
- messages={messages}
85
- showTypingIndicator={showTypingIndicator}
86
- renderMessageContent={renderMessageContent}
87
- onNearBottomChange={onNearBottomChange}
88
- contentStyle={{ paddingBottom: theme.spacing.xl + composerHeight + footerBottomPadding }}
89
- />
90
- {resolvedOverlay}
91
-
92
- <Animated.View
93
- style={[
94
- {
95
- position: 'absolute',
96
- left: 0,
97
- right: 0,
98
- bottom: 0,
99
- paddingHorizontal: theme.spacing.lg,
100
- paddingTop: theme.spacing.sm,
101
- },
102
- footerAnimatedStyle,
103
- ]}
76
+ <View
77
+ style={{ flex: 1 }}
78
+ >
79
+ <ChatMessageList
80
+ ref={listRef}
81
+ messages={messages}
82
+ showTypingIndicator={showTypingIndicator}
83
+ renderMessageContent={renderMessageContent}
84
+ onNearBottomChange={onNearBottomChange}
85
+ bottomInset={bottomInset}
86
+ />
87
+ {resolvedOverlay}
88
+ </View>
89
+ <View
90
+ style={{
91
+ position: 'absolute',
92
+ left: 0,
93
+ right: 0,
94
+ bottom: 0,
95
+ paddingHorizontal: theme.spacing.lg,
96
+ paddingTop: theme.spacing.sm,
97
+ paddingBottom: footerBottomPadding,
98
+ }}
104
99
  >
105
100
  <ChatComposer
106
101
  {...composer}
107
102
  attachments={composer.attachments ?? []}
108
103
  onLayout={({ height }) => setComposerHeight(height)}
109
104
  />
110
- </Animated.View>
105
+ </View>
111
106
  </View>
112
107
  </View>
113
108
  );
@@ -30,10 +30,14 @@ export function AppCommentsSheet({ appId, onClose, onCountChange, onPlayApp }: A
30
30
  const insets = useSafeAreaInsets();
31
31
  const sheetRef = React.useRef<BottomSheetModal | null>(null);
32
32
  const snapPoints = React.useMemo(() => ['50%', '90%'], []);
33
+ const currentIndexRef = React.useRef<number>(1);
33
34
 
34
35
  const { comments, loading, sending, error, create, refresh } = useAppComments(appId);
35
36
  const { app, loading: loadingApp } = useAppDetails(appId);
36
- const { keyboardVisible } = useIosKeyboardSnapFix(sheetRef);
37
+ const { keyboardVisible } = useIosKeyboardSnapFix(sheetRef, {
38
+ getCurrentIndex: () => currentIndexRef.current,
39
+ targetIndex: 1,
40
+ });
37
41
 
38
42
  React.useEffect(() => {
39
43
  if (appId) {
@@ -56,6 +60,7 @@ export function AppCommentsSheet({ appId, onClose, onCountChange, onPlayApp }: A
56
60
 
57
61
  const handleChange = React.useCallback(
58
62
  (index: number) => {
63
+ currentIndexRef.current = index;
59
64
  if (index === -1) onClose();
60
65
  },
61
66
  [onClose]
@@ -2,7 +2,10 @@ import * as React from 'react';
2
2
  import { Keyboard, Platform } from 'react-native';
3
3
  import type { BottomSheetModal } from '@gorhom/bottom-sheet';
4
4
 
5
- export function useIosKeyboardSnapFix(sheetRef: React.RefObject<BottomSheetModal | null>) {
5
+ export function useIosKeyboardSnapFix(
6
+ sheetRef: React.RefObject<BottomSheetModal | null>,
7
+ options?: { getCurrentIndex?: () => number | null; targetIndex?: number }
8
+ ) {
6
9
  const [keyboardVisible, setKeyboardVisible] = React.useState(false);
7
10
 
8
11
  React.useEffect(() => {
@@ -10,13 +13,18 @@ export function useIosKeyboardSnapFix(sheetRef: React.RefObject<BottomSheetModal
10
13
  const show = Keyboard.addListener('keyboardWillShow', () => setKeyboardVisible(true));
11
14
  const hide = Keyboard.addListener('keyboardWillHide', () => {
12
15
  setKeyboardVisible(false);
13
- setTimeout(() => sheetRef.current?.snapToIndex?.(1), 10);
16
+ const target = options?.targetIndex ?? 1;
17
+ const current = options?.getCurrentIndex?.() ?? null;
18
+ // Only "re-snap" if we're already at the target index; otherwise we can cause a jump.
19
+ if (current === target) {
20
+ setTimeout(() => sheetRef.current?.snapToIndex?.(target), 10);
21
+ }
14
22
  });
15
23
  return () => {
16
24
  show.remove();
17
25
  hide.remove();
18
26
  };
19
- }, [sheetRef]);
27
+ }, [options?.getCurrentIndex, options?.targetIndex, sheetRef]);
20
28
 
21
29
  return { keyboardVisible };
22
30
  }
@@ -1,5 +1,5 @@
1
1
  import * as React from 'react';
2
- import { Keyboard, Platform, View } from 'react-native';
2
+ import { AppState, Keyboard, Platform, View, type AppStateStatus } from 'react-native';
3
3
  import BottomSheet, { type BottomSheetBackgroundProps, type BottomSheetProps } from '@gorhom/bottom-sheet';
4
4
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
5
 
@@ -35,8 +35,7 @@ export type StudioBottomSheetProps = {
35
35
  children: React.ReactNode;
36
36
 
37
37
  /**
38
- * Additional BottomSheet props, for advanced tuning.
39
- * We intentionally do not expose everything as first-class props to keep SRP.
38
+ * Additional BottomSheet props
40
39
  */
41
40
  bottomSheetProps?: Omit<
42
41
  BottomSheetProps,
@@ -66,17 +65,32 @@ export function StudioBottomSheet({
66
65
  const insets = useSafeAreaInsets();
67
66
  const internalSheetRef = React.useRef<BottomSheet | null>(null);
68
67
  const resolvedSheetRef = sheetRef ?? internalSheetRef;
68
+ const currentIndexRef = React.useRef<number>(open ? snapPoints.length - 1 : -1);
69
+ const lastAppStateRef = React.useRef<AppStateStatus>(AppState.currentState);
69
70
 
71
+ // Workaround: @gorhom/bottom-sheet can occasionally render empty content after app resume.
70
72
  React.useEffect(() => {
71
- if (Platform.OS !== 'ios') return;
72
- const sub = Keyboard.addListener('keyboardDidHide', () => {
73
+ const sub = AppState.addEventListener('change', (state) => {
74
+ const prev = lastAppStateRef.current;
75
+ lastAppStateRef.current = state;
76
+
77
+ if (state === 'background' || state === 'inactive') {
78
+ Keyboard.dismiss();
79
+ return;
80
+ }
81
+
82
+ if (state !== 'active') return;
73
83
  const sheet = resolvedSheetRef.current;
74
- if (!sheet || !open) return;
75
- const targetIndex = snapPoints.length - 1;
76
- setTimeout(() => sheet.snapToIndex(targetIndex), 10);
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
+ }
77
91
  });
78
92
  return () => sub.remove();
79
- }, [open, resolvedSheetRef, snapPoints.length]);
93
+ }, [open, resolvedSheetRef]);
80
94
 
81
95
  React.useEffect(() => {
82
96
  const sheet = resolvedSheetRef.current;
@@ -92,6 +106,7 @@ export function StudioBottomSheet({
92
106
 
93
107
  const handleChange = React.useCallback(
94
108
  (index: number) => {
109
+ currentIndexRef.current = index;
95
110
  onOpenChange?.(index >= 0);
96
111
  },
97
112
  [onOpenChange]
@@ -103,7 +118,7 @@ export function StudioBottomSheet({
103
118
  index={open ? snapPoints.length - 1 : -1}
104
119
  snapPoints={snapPoints}
105
120
  enablePanDownToClose
106
- keyboardBehavior="extend"
121
+ keyboardBehavior="interactive"
107
122
  keyboardBlurBehavior="restore"
108
123
  android_keyboardInputMode="adjustResize"
109
124
  backgroundComponent={(props: BottomSheetBackgroundProps) => (
@@ -2,6 +2,7 @@ import * as React from 'react';
2
2
 
3
3
  import type { App } from '../../data/apps/types';
4
4
  import { appsRepository } from '../../data/apps/repository';
5
+ import { useForegroundSignal } from './useForegroundSignal';
5
6
 
6
7
  export type UseAppResult = {
7
8
  app: App | null;
@@ -23,6 +24,7 @@ export function useApp(appId: string, options?: UseAppOptions): UseAppResult {
23
24
  const [app, setApp] = React.useState<App | null>(null);
24
25
  const [loading, setLoading] = React.useState(false);
25
26
  const [error, setError] = React.useState<Error | null>(null);
27
+ const foregroundSignal = useForegroundSignal(enabled && Boolean(appId));
26
28
 
27
29
  const mergeApp = React.useCallback((prev: App | null, next: App): App => {
28
30
  // Realtime (Supabase) rows don't include "viewer-specific" fields like `isLiked`,
@@ -62,20 +64,24 @@ export function useApp(appId: string, options?: UseAppOptions): UseAppResult {
62
64
  if (!appId) return;
63
65
  const unsubscribe = appsRepository.subscribeApp(appId, {
64
66
  onInsert: (a) => {
65
- console.log('[useApp] onInsert', a);
66
67
  setApp((prev) => mergeApp(prev, a));
67
68
  },
68
69
  onUpdate: (a) => {
69
- console.log('[useApp] onUpdate', a);
70
70
  setApp((prev) => mergeApp(prev, a));
71
71
  },
72
72
  onDelete: () => {
73
- console.log('[useApp] onDelete');
74
73
  setApp(null);
75
74
  },
76
75
  });
77
76
  return unsubscribe;
78
- }, [appId, enabled, mergeApp]);
77
+ }, [appId, enabled, mergeApp, foregroundSignal]);
78
+
79
+ React.useEffect(() => {
80
+ if (!enabled) return;
81
+ if (!appId) return;
82
+ if (foregroundSignal <= 0) return;
83
+ void fetchOnce();
84
+ }, [appId, enabled, fetchOnce, foregroundSignal]);
79
85
 
80
86
  return { app, loading, error, refetch: fetchOnce };
81
87
  }
@@ -0,0 +1,37 @@
1
+ import * as React from 'react';
2
+ import { AppState, type AppStateStatus } from 'react-native';
3
+
4
+ import { getSupabaseClient } from '../../core/services/supabase';
5
+
6
+ export function useForegroundSignal(enabled: boolean = true): number {
7
+ const [signal, setSignal] = React.useState(0);
8
+ const lastStateRef = React.useRef<AppStateStatus>(AppState.currentState);
9
+
10
+ React.useEffect(() => {
11
+ if (!enabled) return;
12
+
13
+ const sub = AppState.addEventListener('change', (nextState) => {
14
+ const prevState = lastStateRef.current;
15
+ lastStateRef.current = nextState;
16
+
17
+ const didResume =
18
+ (prevState === 'background' || prevState === 'inactive') && nextState === 'active';
19
+ if (!didResume) return;
20
+
21
+ try {
22
+ const supabase = getSupabaseClient();
23
+ supabase?.realtime?.connect?.();
24
+ } catch {
25
+
26
+ }
27
+
28
+ setSignal((s) => s + 1);
29
+ });
30
+
31
+ return () => sub.remove();
32
+ }, [enabled]);
33
+
34
+ return signal;
35
+ }
36
+
37
+
@@ -3,6 +3,7 @@ import * as React from 'react';
3
3
  import type { Message } from '../../data/messages/types';
4
4
  import { messagesRepository } from '../../data/messages/repository';
5
5
  import type { ChatMessage } from '../../components/models/types';
6
+ import { useForegroundSignal } from './useForegroundSignal';
6
7
 
7
8
  export type UseThreadMessagesResult = {
8
9
  raw: Message[];
@@ -44,22 +45,35 @@ export function useThreadMessages(threadId: string): UseThreadMessagesResult {
44
45
  const [raw, setRaw] = React.useState<Message[]>([]);
45
46
  const [loading, setLoading] = React.useState(false);
46
47
  const [error, setError] = React.useState<Error | null>(null);
48
+ const activeRequestIdRef = React.useRef(0);
49
+ const foregroundSignal = useForegroundSignal(Boolean(threadId));
50
+
51
+ const upsertSorted = React.useCallback((prev: Message[], m: Message) => {
52
+ const next = prev.some((x) => x.id === m.id) ? prev.map((x) => (x.id === m.id ? m : x)) : [...prev, m];
53
+ // Keep ordering stable for the UI (chat scrolling is very sensitive to reorders).
54
+ next.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
55
+ return next;
56
+ }, []);
47
57
 
48
58
  const refetch = React.useCallback(async () => {
49
59
  if (!threadId) {
50
60
  setRaw([]);
51
61
  return;
52
62
  }
63
+ const requestId = ++activeRequestIdRef.current;
53
64
  setLoading(true);
54
65
  setError(null);
55
66
  try {
56
67
  const list = await messagesRepository.list(threadId);
57
- setRaw(list);
68
+ if (activeRequestIdRef.current !== requestId) return;
69
+ // Ensure stable ordering for downstream scrolling behavior.
70
+ setRaw([...list].sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt))));
58
71
  } catch (e) {
72
+ if (activeRequestIdRef.current !== requestId) return;
59
73
  setError(e instanceof Error ? e : new Error(String(e)));
60
74
  setRaw([]);
61
75
  } finally {
62
- setLoading(false);
76
+ if (activeRequestIdRef.current === requestId) setLoading(false);
63
77
  }
64
78
  }, [threadId]);
65
79
 
@@ -70,12 +84,18 @@ export function useThreadMessages(threadId: string): UseThreadMessagesResult {
70
84
  React.useEffect(() => {
71
85
  if (!threadId) return;
72
86
  const unsubscribe = messagesRepository.subscribeThread(threadId, {
73
- onInsert: (m) => setRaw((prev) => [...prev, m]),
74
- onUpdate: (m) => setRaw((prev) => prev.map((x) => (x.id === m.id ? m : x))),
87
+ onInsert: (m) => setRaw((prev) => upsertSorted(prev, m)),
88
+ onUpdate: (m) => setRaw((prev) => upsertSorted(prev, m)),
75
89
  onDelete: (m) => setRaw((prev) => prev.filter((x) => x.id !== m.id)),
76
90
  });
77
91
  return unsubscribe;
78
- }, [threadId]);
92
+ }, [threadId, upsertSorted, foregroundSignal]);
93
+
94
+ React.useEffect(() => {
95
+ if (!threadId) return;
96
+ if (foregroundSignal <= 0) return;
97
+ void refetch();
98
+ }, [foregroundSignal, refetch, threadId]);
79
99
 
80
100
  const messages = React.useMemo(() => raw.map(mapMessageToChatMessage), [raw]);
81
101
 
@@ -58,9 +58,12 @@ export function ChatPanel({
58
58
  const all = composerAttachments ?? attachments;
59
59
  await onSend(text, all.length > 0 ? all : undefined);
60
60
  onClearAttachments?.();
61
- requestAnimationFrame(() => listRef.current?.scrollToBottom({ animated: true }));
61
+ // Avoid double-scroll: ChatMessageList already auto-scrolls when the user is near bottom.
62
+ if (!nearBottom) {
63
+ requestAnimationFrame(() => listRef.current?.scrollToBottom({ animated: true }));
64
+ }
62
65
  },
63
- [attachments, onClearAttachments, onSend]
66
+ [attachments, nearBottom, onClearAttachments, onSend]
64
67
  );
65
68
 
66
69
  const handleScrollToBottom = React.useCallback(() => {
@@ -138,7 +141,10 @@ export function ChatPanel({
138
141
  </ScrollToBottomButton>
139
142
  }
140
143
  composer={{
141
- disabled: Boolean(loading) || Boolean(sendDisabled) || Boolean(forking),
144
+ // Keep the input editable even when sending is disallowed (e.g. agent still working),
145
+ // otherwise iOS will drop focus/keyboard and BottomSheet can get "stuck" with a keyboard-sized gap.
146
+ disabled: Boolean(loading) || Boolean(forking),
147
+ sendDisabled: Boolean(sendDisabled) || Boolean(loading) || Boolean(forking),
142
148
  sending: Boolean(sending),
143
149
  autoFocus: autoFocusComposer,
144
150
  onSend: handleSend,
@@ -104,11 +104,15 @@ export function StudioOverlay({
104
104
  [confirmMrId, incomingMergeRequests]
105
105
  );
106
106
 
107
- const closeSheet = React.useCallback(() => {
108
- setSheetOpen(false);
109
- Keyboard.dismiss();
107
+ const handleSheetOpenChange = React.useCallback((open: boolean) => {
108
+ setSheetOpen(open);
109
+ if (!open) Keyboard.dismiss();
110
110
  }, []);
111
111
 
112
+ const closeSheet = React.useCallback(() => {
113
+ handleSheetOpenChange(false);
114
+ }, [handleSheetOpenChange]);
115
+
112
116
  const openSheet = React.useCallback(() => setSheetOpen(true), []);
113
117
 
114
118
  const goToChat = React.useCallback(() => {
@@ -178,7 +182,7 @@ export function StudioOverlay({
178
182
  {/* Testing glow around runtime */}
179
183
  <EdgeGlowFrame visible={isTesting} role="accent" thickness={40} intensity={1} />
180
184
 
181
- <StudioBottomSheet open={sheetOpen} onOpenChange={setSheetOpen}>
185
+ <StudioBottomSheet open={sheetOpen} onOpenChange={handleSheetOpenChange}>
182
186
  <StudioSheetPager
183
187
  activePage={activePage}
184
188
  width={width}