fix/various fixes
This commit is contained in:
@@ -10,6 +10,10 @@ import {
|
||||
X,
|
||||
} from 'lucide-react-native';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type {
|
||||
NativeSyntheticEvent,
|
||||
TextInputContentSizeChangeEventData,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Alert,
|
||||
Animated,
|
||||
@@ -57,6 +61,10 @@ const CHAT_COLORS = {
|
||||
/** 后端用 [SPLIT] 分隔多条消息,需按块渲染为多个气泡 */
|
||||
const SPLIT_DELIMITER = '[SPLIT]';
|
||||
|
||||
/** 聊天输入框:单行内容高度与最多约 4 行(与 lineHeight 对齐) */
|
||||
const CHAT_INPUT_LINE_H = 22;
|
||||
const CHAT_INPUT_MAX_H = CHAT_INPUT_LINE_H * 4;
|
||||
|
||||
function splitContent(content: string): string[] {
|
||||
return content
|
||||
.split(SPLIT_DELIMITER)
|
||||
@@ -424,6 +432,7 @@ function ChatInputBar({
|
||||
tapToEndLabel,
|
||||
cancelRecordingLabel,
|
||||
disabled,
|
||||
textInputKey = 0,
|
||||
}: {
|
||||
value: string;
|
||||
onChangeText: (v: string) => void;
|
||||
@@ -446,8 +455,29 @@ function ChatInputBar({
|
||||
tapToEndLabel: string;
|
||||
cancelRecordingLabel: string;
|
||||
disabled?: boolean;
|
||||
/** 发送后递增,强制重建 TextInput,避免多行高度卡在 4 行 */
|
||||
textInputKey?: number;
|
||||
}) {
|
||||
const colors = useThemeColors();
|
||||
const [textHeight, setTextHeight] = useState(CHAT_INPUT_LINE_H);
|
||||
/** 空串时立即单行高度,避免仅依赖 state 时发送后仍沿用 4 行测量值 */
|
||||
const inputDisplayHeight = value === '' ? CHAT_INPUT_LINE_H : textHeight;
|
||||
|
||||
useEffect(() => {
|
||||
if (value === '') {
|
||||
setTextHeight(CHAT_INPUT_LINE_H);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const onInputContentSizeChange = useCallback(
|
||||
(e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
|
||||
const h = e.nativeEvent.contentSize.height;
|
||||
const next = Math.min(Math.max(h, CHAT_INPUT_LINE_H), CHAT_INPUT_MAX_H);
|
||||
setTextHeight(next);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const hasText = value.trim().length > 0;
|
||||
const showSend = inputMode === 'text' && hasText;
|
||||
|
||||
@@ -487,15 +517,18 @@ function ChatInputBar({
|
||||
{inputMode === 'text' ? (
|
||||
<View style={styles.inputCenter}>
|
||||
<TextInput
|
||||
key={`chat-input-${textInputKey}`}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={CHAT_COLORS.onSurfaceVariant}
|
||||
style={styles.textInput}
|
||||
style={[styles.textInput, { height: inputDisplayHeight }]}
|
||||
multiline
|
||||
scrollEnabled
|
||||
textAlignVertical="top"
|
||||
maxLength={2000}
|
||||
editable={!disabled}
|
||||
onContentSizeChange={onInputContentSizeChange}
|
||||
onSubmitEditing={onSend}
|
||||
returnKeyType="send"
|
||||
/>
|
||||
@@ -600,6 +633,7 @@ export default function ConversationScreen() {
|
||||
} = useRecorder(handleRecordingComplete);
|
||||
|
||||
const [input, setInput] = useState('');
|
||||
const [inputResetKey, setInputResetKey] = useState(0);
|
||||
const [inputMode, setInputMode] = useState<InputMode>('text');
|
||||
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
@@ -609,6 +643,30 @@ export default function ConversationScreen() {
|
||||
const onShow = (e: { endCoordinates: { height: number } }) => {
|
||||
setIsKeyboardVisible(true);
|
||||
setKeyboardHeight(e.endCoordinates.height);
|
||||
// #region agent log
|
||||
void fetch(
|
||||
'http://127.0.0.1:7446/ingest/e6437b8c-57a6-4b5a-9fdd-4a69aa1b3a6c',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Debug-Session-Id': 'a82a4c',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sessionId: 'a82a4c',
|
||||
hypothesisId: 'H3',
|
||||
location: 'ConversationScreen:keyboardShow',
|
||||
message: 'keyboard open; composer uses insetBottom 0 when text+kb',
|
||||
data: {
|
||||
kbH: e.endCoordinates.height,
|
||||
insetBottom: insets.bottom,
|
||||
os: Platform.OS,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
},
|
||||
).catch(() => {});
|
||||
// #endregion
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
listRef.current?.scrollToEnd({ animated: true });
|
||||
});
|
||||
@@ -617,13 +675,18 @@ export default function ConversationScreen() {
|
||||
setIsKeyboardVisible(false);
|
||||
setKeyboardHeight(0);
|
||||
};
|
||||
const subShow = Keyboard.addListener('keyboardDidShow', onShow);
|
||||
const subHide = Keyboard.addListener('keyboardDidHide', onHide);
|
||||
// iOS:Will* 与系统动画同步;KeyboardAvoidingView 在 iOS 上易与 safe area 叠出缝(见 RN #52626)
|
||||
const showEvt =
|
||||
Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||
const hideEvt =
|
||||
Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||
const subShow = Keyboard.addListener(showEvt, onShow);
|
||||
const subHide = Keyboard.addListener(hideEvt, onHide);
|
||||
return () => {
|
||||
subShow.remove();
|
||||
subHide.remove();
|
||||
};
|
||||
}, []);
|
||||
}, [insets.bottom]);
|
||||
|
||||
const flattenedData = flattenMessagesForList(messages ?? []);
|
||||
|
||||
@@ -642,6 +705,27 @@ export default function ConversationScreen() {
|
||||
if (!text) return;
|
||||
sendText(text);
|
||||
setInput('');
|
||||
setInputResetKey((k) => k + 1);
|
||||
// #region agent log
|
||||
void fetch(
|
||||
'http://127.0.0.1:7446/ingest/e6437b8c-57a6-4b5a-9fdd-4a69aa1b3a6c',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Debug-Session-Id': 'a82a4c',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sessionId: 'a82a4c',
|
||||
hypothesisId: 'H4',
|
||||
location: 'ConversationScreen:handleSend',
|
||||
message: 'cleared input + bumped textInputKey',
|
||||
data: { sentLen: text.length },
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
},
|
||||
).catch(() => {});
|
||||
// #endregion
|
||||
};
|
||||
|
||||
const connectionLabel =
|
||||
@@ -651,129 +735,142 @@ export default function ConversationScreen() {
|
||||
? t('connectionConnecting')
|
||||
: t('connectionDisconnected');
|
||||
|
||||
const keyboardOffset = Platform.OS === 'ios' ? insets.top + 56 : 0;
|
||||
const kavEnabled = inputMode === 'text' && isKeyboardVisible;
|
||||
const kavBehavior = Platform.OS === 'ios' ? 'padding' : 'height';
|
||||
/** iOS:用键盘高度直接顶起根布局,替代 KAV(避免与 safe area 叠出缝,见 RN #52626) */
|
||||
const keyboardLift =
|
||||
Platform.OS === 'ios' && inputMode === 'text' && isKeyboardVisible
|
||||
? keyboardHeight
|
||||
: 0;
|
||||
const androidKavOn =
|
||||
Platform.OS === 'android' && inputMode === 'text' && isKeyboardVisible;
|
||||
|
||||
const composerZeroBottomInset = isKeyboardVisible && inputMode === 'text';
|
||||
|
||||
const screen = (
|
||||
<View style={styles.column}>
|
||||
<ScreenHeader
|
||||
variant="chat"
|
||||
title={
|
||||
<View style={styles.headerTitleBlock}>
|
||||
<Text style={styles.headerTitle}>{tApp('name')}</Text>
|
||||
{playerStatus === 'playing' && (
|
||||
<Icon
|
||||
as={Volume2}
|
||||
size={18}
|
||||
color={CHAT_COLORS.primary}
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
styles.statusBadge,
|
||||
connectionState === 'connected' && styles.statusBadgeConnected,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.statusBadgeText,
|
||||
connectionState === 'connected' &&
|
||||
styles.statusBadgeTextConnected,
|
||||
]}
|
||||
>
|
||||
{connectionLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
backAccessibilityLabel={t('chatTitle')}
|
||||
/>
|
||||
|
||||
{/* Message list - flex 1, takes remaining space */}
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
data={flattenedData}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
keyExtractor={(item) => item.listKey}
|
||||
renderItem={({ item }) => (
|
||||
<MessageBubble
|
||||
item={item}
|
||||
agentName={t('agentName')}
|
||||
meLabel={t('me')}
|
||||
/>
|
||||
)}
|
||||
onContentSizeChange={() =>
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
listRef.current?.scrollToEnd({ animated: true });
|
||||
})
|
||||
}
|
||||
ListFooterComponent={
|
||||
streamingMessage ? (
|
||||
<StreamingBubbles
|
||||
streamingText={streamingMessage.text}
|
||||
isComplete={streamingMessage.isComplete}
|
||||
agentName={t('agentName')}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{/* WeChat-style input bar - 贴在底部,非浮空 */}
|
||||
<View
|
||||
style={[
|
||||
styles.inputBarWrapper,
|
||||
{
|
||||
paddingBottom: composerZeroBottomInset ? 0 : insets.bottom,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ChatInputBar
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
onSend={handleSend}
|
||||
textInputKey={inputResetKey}
|
||||
inputMode={inputMode}
|
||||
onInputModeToggle={() => {
|
||||
setInputMode((m) => {
|
||||
if (m === 'text') {
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
return m === 'text' ? 'voice' : 'text';
|
||||
});
|
||||
}}
|
||||
onAddPress={() => {}}
|
||||
onStartRecording={handleStartRecording}
|
||||
onStopRecording={() => void stopRecording()}
|
||||
onCancelRecording={() => void cancelRecording()}
|
||||
isRecording={isRecording}
|
||||
recordingDuration={recordingDuration}
|
||||
placeholder={t('inputPlaceholder')}
|
||||
placeholderVoice={t('inputPlaceholderVoice')}
|
||||
addMoreLabel={t('addMore')}
|
||||
sendLabel={t('send')}
|
||||
switchToVoiceLabel={t('switchToVoice')}
|
||||
switchToTextLabel={t('switchToText')}
|
||||
tapToStartLabel={t('tapToStartRecording')}
|
||||
tapToEndLabel={t('tapToEndRecording')}
|
||||
cancelRecordingLabel={t('cancelRecording')}
|
||||
disabled={connectionState !== 'connected'}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (Platform.OS === 'ios') {
|
||||
return (
|
||||
<View style={[styles.container, { paddingBottom: keyboardLift }]}>
|
||||
{screen}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={kavEnabled ? kavBehavior : undefined}
|
||||
keyboardVerticalOffset={keyboardOffset}
|
||||
behavior={androidKavOn ? 'height' : undefined}
|
||||
>
|
||||
<View style={styles.column}>
|
||||
<ScreenHeader
|
||||
variant="chat"
|
||||
title={
|
||||
<View style={styles.headerTitleBlock}>
|
||||
<Text style={styles.headerTitle}>{tApp('name')}</Text>
|
||||
{playerStatus === 'playing' && (
|
||||
<Icon
|
||||
as={Volume2}
|
||||
size={18}
|
||||
color={CHAT_COLORS.primary}
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
styles.statusBadge,
|
||||
connectionState === 'connected' &&
|
||||
styles.statusBadgeConnected,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.statusBadgeText,
|
||||
connectionState === 'connected' &&
|
||||
styles.statusBadgeTextConnected,
|
||||
]}
|
||||
>
|
||||
{connectionLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
backAccessibilityLabel={t('chatTitle')}
|
||||
/>
|
||||
|
||||
{/* Message list - flex 1, takes remaining space */}
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
style={styles.list}
|
||||
contentContainerStyle={[
|
||||
styles.listContent,
|
||||
inputMode === 'text' &&
|
||||
isKeyboardVisible && { paddingBottom: 12 + keyboardHeight },
|
||||
]}
|
||||
data={flattenedData}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
keyExtractor={(item) => item.listKey}
|
||||
renderItem={({ item }) => (
|
||||
<MessageBubble
|
||||
item={item}
|
||||
agentName={t('agentName')}
|
||||
meLabel={t('me')}
|
||||
/>
|
||||
)}
|
||||
onContentSizeChange={() =>
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
listRef.current?.scrollToEnd({ animated: true });
|
||||
})
|
||||
}
|
||||
ListFooterComponent={
|
||||
streamingMessage ? (
|
||||
<StreamingBubbles
|
||||
streamingText={streamingMessage.text}
|
||||
isComplete={streamingMessage.isComplete}
|
||||
agentName={t('agentName')}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{/* WeChat-style input bar - 贴在底部,非浮空 */}
|
||||
<View
|
||||
style={[
|
||||
styles.inputBarWrapper,
|
||||
{
|
||||
paddingBottom: insets.bottom,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ChatInputBar
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
onSend={handleSend}
|
||||
inputMode={inputMode}
|
||||
onInputModeToggle={() => {
|
||||
setInputMode((m) => {
|
||||
if (m === 'text') {
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
return m === 'text' ? 'voice' : 'text';
|
||||
});
|
||||
}}
|
||||
onAddPress={() => {}}
|
||||
onStartRecording={handleStartRecording}
|
||||
onStopRecording={() => void stopRecording()}
|
||||
onCancelRecording={() => void cancelRecording()}
|
||||
isRecording={isRecording}
|
||||
recordingDuration={recordingDuration}
|
||||
placeholder={t('inputPlaceholder')}
|
||||
placeholderVoice={t('inputPlaceholderVoice')}
|
||||
addMoreLabel={t('addMore')}
|
||||
sendLabel={t('send')}
|
||||
switchToVoiceLabel={t('switchToVoice')}
|
||||
switchToTextLabel={t('switchToText')}
|
||||
tapToStartLabel={t('tapToStartRecording')}
|
||||
tapToEndLabel={t('tapToEndRecording')}
|
||||
cancelRecordingLabel={t('cancelRecording')}
|
||||
disabled={connectionState !== 'connected'}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
{screen}
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
@@ -966,7 +1063,7 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: 'rgba(141, 140, 144, 0.14)',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 11,
|
||||
justifyContent: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
inputCenterFlex: {
|
||||
flex: 1,
|
||||
@@ -1037,11 +1134,11 @@ const styles = StyleSheet.create({
|
||||
} as const,
|
||||
textInput: {
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
lineHeight: CHAT_INPUT_LINE_H,
|
||||
color: CHAT_COLORS.onSurface,
|
||||
padding: 0,
|
||||
minHeight: 22, // 保持空内容时至少一行高度
|
||||
maxHeight: 88, // 4 lines * 22 lineHeight
|
||||
maxHeight: CHAT_INPUT_MAX_H,
|
||||
width: '100%',
|
||||
},
|
||||
sendButton: {
|
||||
height: 44,
|
||||
|
||||
Reference in New Issue
Block a user