@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/dist/index.js +675 -576
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +423 -324
- 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 +26 -9
- package/src/components/chat/ChatPage.tsx +2 -1
- package/src/components/comments/AppCommentsSheet.tsx +6 -1
- package/src/components/comments/useIosKeyboardSnapFix.ts +11 -3
- package/src/components/studio-sheet/StudioBottomSheet.tsx +33 -5
- 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/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);
|
|
@@ -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,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.
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
<
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
|
@@ -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
|
-
|
|
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=
|
|
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
|
-
|
|
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,
|