@comergehq/studio 0.1.6 → 0.1.8

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.6",
3
+ "version": "0.1.8",
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);
@@ -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,6 +106,14 @@ 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}
@@ -111,7 +126,7 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
111
126
  {
112
127
  paddingHorizontal: theme.spacing.lg,
113
128
  paddingTop: theme.spacing.sm,
114
- paddingBottom: theme.spacing.xl,
129
+ paddingBottom: theme.spacing.sm,
115
130
  },
116
131
  contentStyle,
117
132
  ]}
@@ -121,13 +136,15 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
121
136
  </View>
122
137
  )}
123
138
  ListFooterComponent={
124
- showTypingIndicator ? (
125
- <View style={{ marginTop: theme.spacing.sm, alignSelf: 'flex-start', paddingHorizontal: theme.spacing.lg }}>
126
- <TypingIndicator />
127
- </View>
128
- ) : null
139
+ <View>
140
+ {showTypingIndicator ? (
141
+ <View style={{ marginTop: theme.spacing.sm, alignSelf: 'flex-start', paddingHorizontal: theme.spacing.lg }}>
142
+ <TypingIndicator />
143
+ </View>
144
+ ) : null}
145
+ {bottomInset > 0 ? <View style={{ height: bottomInset }} /> : null}
146
+ </View>
129
147
  }
130
- maintainVisibleContentPosition={{ minIndexForVisible: 0, autoscrollToTopThreshold: nearBottomThreshold }}
131
148
  />
132
149
  );
133
150
  }
@@ -61,6 +61,7 @@ export function ChatPage({
61
61
  return { paddingBottom: animatedKeyboard.height.value > 0 ? 0 : insets.bottom };
62
62
  });
63
63
  const overlayBottom = composerHeight + footerBottomPadding + theme.spacing.lg;
64
+ const bottomInset = composerHeight + footerBottomPadding + theme.spacing.xl;
64
65
 
65
66
  const resolvedOverlay = React.useMemo(() => {
66
67
  if (!overlay) return null;
@@ -85,7 +86,7 @@ export function ChatPage({
85
86
  showTypingIndicator={showTypingIndicator}
86
87
  renderMessageContent={renderMessageContent}
87
88
  onNearBottomChange={onNearBottomChange}
88
- contentStyle={{ paddingBottom: theme.spacing.xl + composerHeight + footerBottomPadding }}
89
+ bottomInset={bottomInset}
89
90
  />
90
91
  {resolvedOverlay}
91
92
 
@@ -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
 
@@ -66,16 +66,43 @@ export function StudioBottomSheet({
66
66
  const insets = useSafeAreaInsets();
67
67
  const internalSheetRef = React.useRef<BottomSheet | null>(null);
68
68
  const resolvedSheetRef = sheetRef ?? internalSheetRef;
69
+ const currentIndexRef = React.useRef<number>(open ? snapPoints.length - 1 : -1);
70
+ const lastAppStateRef = React.useRef<AppStateStatus>(AppState.currentState);
71
+
72
+ // Workaround: @gorhom/bottom-sheet can occasionally render empty content after app resume.
73
+ React.useEffect(() => {
74
+ const sub = AppState.addEventListener('change', (state) => {
75
+ const prev = lastAppStateRef.current;
76
+ lastAppStateRef.current = state;
77
+
78
+ if (state === 'background' || state === 'inactive') {
79
+ Keyboard.dismiss();
80
+ return;
81
+ }
82
+
83
+ if (state !== 'active') return;
84
+ const sheet = resolvedSheetRef.current;
85
+ if (!sheet) return;
86
+ const idx = currentIndexRef.current;
87
+ if (open && idx >= 0) {
88
+ Keyboard.dismiss();
89
+ requestAnimationFrame(() => sheet.snapToIndex(idx));
90
+ setTimeout(() => sheet.snapToIndex(idx), 120);
91
+ }
92
+ });
93
+ return () => sub.remove();
94
+ }, [open, resolvedSheetRef]);
69
95
 
70
- // Gorhom BottomSheet `index` is not reliably "fully controlled" across versions.
71
- // Ensure the visual sheet actually opens/closes when `open` changes (e.g. via header X button).
72
96
  React.useEffect(() => {
73
97
  if (Platform.OS !== 'ios') return;
74
98
  const sub = Keyboard.addListener('keyboardDidHide', () => {
75
99
  const sheet = resolvedSheetRef.current;
76
100
  if (!sheet || !open) return;
77
101
  const targetIndex = snapPoints.length - 1;
78
- setTimeout(() => sheet.snapToIndex(targetIndex), 10);
102
+ // Only "re-snap" if we're already at the highest snap point.
103
+ if (currentIndexRef.current === targetIndex) {
104
+ setTimeout(() => sheet.snapToIndex(targetIndex), 10);
105
+ }
79
106
  });
80
107
  return () => sub.remove();
81
108
  }, [open, resolvedSheetRef, snapPoints.length]);
@@ -94,6 +121,7 @@ export function StudioBottomSheet({
94
121
 
95
122
  const handleChange = React.useCallback(
96
123
  (index: number) => {
124
+ currentIndexRef.current = index;
97
125
  onOpenChange?.(index >= 0);
98
126
  },
99
127
  [onOpenChange]
@@ -105,7 +133,7 @@ export function StudioBottomSheet({
105
133
  index={open ? snapPoints.length - 1 : -1}
106
134
  snapPoints={snapPoints}
107
135
  enablePanDownToClose
108
- keyboardBehavior={Platform.OS === 'ios' ? 'interactive' : 'extend'}
136
+ keyboardBehavior="interactive"
109
137
  keyboardBlurBehavior="restore"
110
138
  android_keyboardInputMode="adjustResize"
111
139
  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,