@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/dist/index.js +703 -611
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +459 -367
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/ChatComposer.tsx +3 -1
- package/src/components/chat/ChatMessageList.tsx +29 -10
- package/src/components/chat/ChatPage.tsx +25 -30
- package/src/components/comments/AppCommentsSheet.tsx +6 -1
- package/src/components/comments/useIosKeyboardSnapFix.ts +11 -3
- package/src/components/studio-sheet/StudioBottomSheet.tsx +25 -10
- package/src/studio/hooks/useApp.ts +10 -4
- package/src/studio/hooks/useForegroundSignal.ts +37 -0
- package/src/studio/hooks/useThreadMessages.ts +25 -5
- package/src/studio/ui/ChatPanel.tsx +9 -3
- package/src/studio/ui/StudioOverlay.tsx +8 -4
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
<
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
<
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
</
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
75
|
-
const
|
|
76
|
-
|
|
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
|
|
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="
|
|
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
|
-
|
|
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) =>
|
|
74
|
-
onUpdate: (m) => setRaw((prev) => prev
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
108
|
-
setSheetOpen(
|
|
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={
|
|
185
|
+
<StudioBottomSheet open={sheetOpen} onOpenChange={handleSheetOpenChange}>
|
|
182
186
|
<StudioSheetPager
|
|
183
187
|
activePage={activePage}
|
|
184
188
|
width={width}
|