Skip to content

Commit 56f1005

Browse files
committed
fix(app): improve composer autocomplete and draft persistence
1 parent 3ad4541 commit 56f1005

7 files changed

Lines changed: 290 additions & 260 deletions

File tree

apps/free/app/sources/components/AgentInput.tsx

Lines changed: 124 additions & 115 deletions
Large diffs are not rendered by default.

apps/free/app/sources/components/MultiTextInput.tsx

Lines changed: 18 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ import {
99
} from 'react-native';
1010
import { useUnistyles } from 'react-native-unistyles';
1111
import { Typography } from '@/constants/Typography';
12-
import { Logger } from '@saaskit-dev/agentbridge/telemetry';
13-
const logger = new Logger('app/components/MultiTextInput');
1412

1513
export type SupportedKey =
1614
| 'Enter'
@@ -28,16 +26,13 @@ export interface KeyPressEvent {
2826

2927
export type OnKeyPressCallback = (event: KeyPressEvent) => boolean;
3028

31-
export interface TextInputState {
32-
text: string;
33-
selection: {
34-
start: number;
35-
end: number;
36-
};
29+
export interface TextInputSelection {
30+
start: number;
31+
end: number;
3732
}
3833

3934
export interface MultiTextInputHandle {
40-
setTextAndSelection: (text: string, selection: { start: number; end: number }) => void;
35+
setTextAndSelection: (text: string, selection: TextInputSelection) => void;
4136
focus: () => void;
4237
blur: () => void;
4338
}
@@ -54,8 +49,8 @@ interface MultiTextInputProps {
5449
/** When false, the field does not accept focus or edits (e.g. while sending or aborting). */
5550
editable?: boolean;
5651
onKeyPress?: OnKeyPressCallback;
57-
onSelectionChange?: (selection: { start: number; end: number }) => void;
58-
onStateChange?: (state: TextInputState) => void;
52+
onSelectionChange?: (selection: TextInputSelection) => void;
53+
onCompositionStateChange?: (isComposing: boolean) => void;
5954
}
6055

6156
export const MultiTextInput = React.forwardRef<MultiTextInputHandle, MultiTextInputProps>(
@@ -68,14 +63,20 @@ export const MultiTextInput = React.forwardRef<MultiTextInputHandle, MultiTextIn
6863
editable = true,
6964
onKeyPress,
7065
onSelectionChange,
71-
onStateChange,
7266
} = props;
7367

7468
const { theme } = useUnistyles();
75-
// Track latest selection in a ref
76-
const selectionRef = React.useRef({ start: 0, end: 0 });
69+
const selectionRef = React.useRef<TextInputSelection>({ start: 0, end: 0 });
7770
const inputRef = React.useRef<TextInput>(null);
7871

72+
React.useEffect(() => {
73+
const max = value.length;
74+
const { start, end } = selectionRef.current;
75+
if (start > max || end > max) {
76+
selectionRef.current = { start: max, end: max };
77+
}
78+
}, [value]);
79+
7980
const handleKeyPress = React.useCallback(
8081
(e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
8182
if (!onKeyPress) return;
@@ -134,21 +135,9 @@ export const MultiTextInput = React.forwardRef<MultiTextInputHandle, MultiTextIn
134135
// Don't assume cursor position here — let onSelectionChange report the real position.
135136
// Previously this forced selection to text.length, which broke mid-text editing,
136137
// external keyboard word completions, and cursor repositioning.
137-
138-
logger.debug('📝 MultiTextInput.native: Text changed:', JSON.stringify({ text }));
139-
140138
onChangeText(text);
141-
142-
// Eagerly notify onStateChange with the fresh text and current selection.
143-
// handleSelectionChange uses `value` from its closure, which may be stale
144-
// when RN fires onSelectionChange in the same tick as onChangeText (before
145-
// the parent re-renders). By also reporting here we guarantee the consumer
146-
// sees the correct text immediately.
147-
if (onStateChange) {
148-
onStateChange({ text, selection: selectionRef.current });
149-
}
150139
},
151-
[onChangeText, onStateChange]
140+
[onChangeText]
152141
);
153142

154143
const handleSelectionChange = React.useCallback(
@@ -163,45 +152,31 @@ export const MultiTextInput = React.forwardRef<MultiTextInputHandle, MultiTextIn
163152
selection.end !== selectionRef.current.end
164153
) {
165154
selectionRef.current = selection;
166-
logger.debug('📍 MultiTextInput.native: Selection changed:', JSON.stringify(selection));
167155

168156
if (onSelectionChange) {
169157
onSelectionChange(selection);
170158
}
171-
if (onStateChange) {
172-
onStateChange({ text: value, selection });
173-
}
174159
}
175160
}
176161
},
177-
[value, onSelectionChange, onStateChange]
162+
[onSelectionChange]
178163
);
179164

180165
// Imperative handle for direct control
181166
React.useImperativeHandle(
182167
ref,
183168
() => ({
184169
setTextAndSelection: (text: string, selection: { start: number; end: number }) => {
185-
logger.debug(
186-
'🎯 MultiTextInput.native: setTextAndSelection:',
187-
JSON.stringify({ text, selection })
188-
);
189-
190170
if (inputRef.current) {
191171
// Use setNativeProps for direct manipulation
192172
inputRef.current.setNativeProps({
193173
text: text,
194174
selection: selection,
195175
});
196176

197-
// Update our ref
198177
selectionRef.current = selection;
199178

200-
// Notify through callbacks
201179
onChangeText(text);
202-
if (onStateChange) {
203-
onStateChange({ text, selection });
204-
}
205180
if (onSelectionChange) {
206181
onSelectionChange(selection);
207182
}
@@ -214,7 +189,7 @@ export const MultiTextInput = React.forwardRef<MultiTextInputHandle, MultiTextIn
214189
inputRef.current?.blur();
215190
},
216191
}),
217-
[onChangeText, onStateChange, onSelectionChange]
192+
[onChangeText, onSelectionChange]
218193
);
219194

220195
return (

apps/free/app/sources/components/MultiTextInput.web.tsx

Lines changed: 55 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,13 @@ export interface KeyPressEvent {
2020

2121
export type OnKeyPressCallback = (event: KeyPressEvent) => boolean;
2222

23-
export interface TextInputState {
24-
text: string;
25-
selection: {
26-
start: number;
27-
end: number;
28-
};
23+
export interface TextInputSelection {
24+
start: number;
25+
end: number;
2926
}
3027

3128
export interface MultiTextInputHandle {
32-
setTextAndSelection: (text: string, selection: { start: number; end: number }) => void;
29+
setTextAndSelection: (text: string, selection: TextInputSelection) => void;
3330
focus: () => void;
3431
blur: () => void;
3532
}
@@ -44,8 +41,8 @@ interface MultiTextInputProps {
4441
paddingLeft?: number;
4542
paddingRight?: number;
4643
onKeyPress?: OnKeyPressCallback;
47-
onSelectionChange?: (selection: { start: number; end: number }) => void;
48-
onStateChange?: (state: TextInputState) => void;
44+
onSelectionChange?: (selection: TextInputSelection) => void;
45+
onCompositionStateChange?: (isComposing: boolean) => void;
4946
}
5047

5148
export const MultiTextInput = React.forwardRef<MultiTextInputHandle, MultiTextInputProps>(
@@ -57,21 +54,48 @@ export const MultiTextInput = React.forwardRef<MultiTextInputHandle, MultiTextIn
5754
maxHeight = 120,
5855
onKeyPress,
5956
onSelectionChange,
60-
onStateChange,
57+
onCompositionStateChange,
6158
} = props;
6259

6360
const { theme } = useUnistyles();
6461
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
62+
const isComposingRef = React.useRef(false);
63+
const selectionRef = React.useRef<TextInputSelection>({ start: 0, end: 0 });
6564

6665
// Convert maxHeight to approximate maxRows (assuming ~24px line height)
6766
const maxRows = Math.floor(maxHeight / 24);
6867

68+
React.useEffect(() => {
69+
const max = value.length;
70+
const { start, end } = selectionRef.current;
71+
if (start > max || end > max) {
72+
selectionRef.current = { start: max, end: max };
73+
}
74+
}, [value]);
75+
76+
const emitSelectionChange = React.useCallback(
77+
(selection: TextInputSelection) => {
78+
if (
79+
selection.start === selectionRef.current.start &&
80+
selection.end === selectionRef.current.end
81+
) {
82+
return;
83+
}
84+
selectionRef.current = selection;
85+
onSelectionChange?.(selection);
86+
},
87+
[onSelectionChange]
88+
);
89+
6990
const handleKeyDown = React.useCallback(
7091
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
7192
if (!onKeyPress) return;
7293

7394
const isComposing =
74-
e.nativeEvent.isComposing || (e.nativeEvent as any).isComposing || e.keyCode === 229;
95+
isComposingRef.current ||
96+
e.nativeEvent.isComposing ||
97+
(e.nativeEvent as any).isComposing ||
98+
e.keyCode === 229;
7599
if (isComposing) {
76100
return;
77101
}
@@ -129,15 +153,9 @@ export const MultiTextInput = React.forwardRef<MultiTextInputHandle, MultiTextIn
129153
};
130154

131155
onChangeText(text);
132-
133-
if (onStateChange) {
134-
onStateChange({ text, selection });
135-
}
136-
if (onSelectionChange) {
137-
onSelectionChange(selection);
138-
}
156+
emitSelectionChange(selection);
139157
},
140-
[onChangeText, onStateChange, onSelectionChange]
158+
[emitSelectionChange, onChangeText]
141159
);
142160

143161
const handleSelect = React.useCallback(
@@ -147,43 +165,35 @@ export const MultiTextInput = React.forwardRef<MultiTextInputHandle, MultiTextIn
147165
start: target.selectionStart,
148166
end: target.selectionEnd,
149167
};
150-
151-
if (onSelectionChange) {
152-
onSelectionChange(selection);
153-
}
154-
if (onStateChange) {
155-
// Read text directly from the DOM element instead of the `value` prop.
156-
// A `select` event can fire right after `change` in the same tick;
157-
// the closure's `value` may still be the pre-change string, which
158-
// would overwrite the correct state that handleChange just set.
159-
onStateChange({ text: target.value, selection });
160-
}
168+
emitSelectionChange(selection);
161169
},
162-
[onSelectionChange, onStateChange]
170+
[emitSelectionChange]
163171
);
164172

173+
const handleCompositionStart = React.useCallback(() => {
174+
isComposingRef.current = true;
175+
onCompositionStateChange?.(true);
176+
}, [onCompositionStateChange]);
177+
178+
const handleCompositionEnd = React.useCallback(() => {
179+
isComposingRef.current = false;
180+
onCompositionStateChange?.(false);
181+
}, [onCompositionStateChange]);
182+
165183
// Imperative handle for direct control
166184
React.useImperativeHandle(
167185
ref,
168186
() => ({
169-
setTextAndSelection: (text: string, selection: { start: number; end: number }) => {
170-
if (textareaRef.current) {
187+
setTextAndSelection: (text: string, selection: TextInputSelection) => {
188+
if (textareaRef.current && !isComposingRef.current) {
171189
// Directly set value and selection on DOM element
172190
textareaRef.current.value = text;
173191
textareaRef.current.setSelectionRange(selection.start, selection.end);
174192

175193
// Trigger React's onChange by dispatching an input event
176194
const event = new Event('input', { bubbles: true });
177195
textareaRef.current.dispatchEvent(event);
178-
179-
// Also call callbacks directly for immediate update
180-
onChangeText(text);
181-
if (onStateChange) {
182-
onStateChange({ text, selection });
183-
}
184-
if (onSelectionChange) {
185-
onSelectionChange(selection);
186-
}
196+
emitSelectionChange(selection);
187197
}
188198
},
189199
focus: () => {
@@ -193,7 +203,7 @@ export const MultiTextInput = React.forwardRef<MultiTextInputHandle, MultiTextIn
193203
textareaRef.current?.blur();
194204
},
195205
}),
196-
[onChangeText, onStateChange, onSelectionChange]
206+
[emitSelectionChange]
197207
);
198208

199209
return (
@@ -222,6 +232,8 @@ export const MultiTextInput = React.forwardRef<MultiTextInputHandle, MultiTextIn
222232
onChange={handleChange}
223233
onSelect={handleSelect}
224234
onKeyDown={handleKeyDown}
235+
onCompositionStart={handleCompositionStart}
236+
onCompositionEnd={handleCompositionEnd}
225237
maxRows={maxRows}
226238
autoCapitalize="sentences"
227239
autoCorrect="on"

apps/free/app/sources/components/autocomplete/suggestions.ts

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -104,52 +104,20 @@ export async function getSuggestions(
104104
component: React.ComponentType;
105105
}[]
106106
> {
107-
logger.debug('💡 getSuggestions called with query:', JSON.stringify(query));
108-
109107
if (!query || query.length === 0) {
110-
logger.debug('💡 getSuggestions: Empty query, returning empty array');
111108
return [];
112109
}
113110

114111
// Check if it's a command (starts with /)
115112
if (query.startsWith('/')) {
116-
logger.debug('💡 getSuggestions: Command detected');
117-
const result = await getCommandSuggestions(sessionId, query);
118-
logger.debug(
119-
'💡 getSuggestions: Command suggestions:',
120-
JSON.stringify(
121-
result.map(r => ({
122-
key: r.key,
123-
text: r.text,
124-
component: '[Function]',
125-
})),
126-
null,
127-
2
128-
)
129-
);
130-
return result;
113+
return getCommandSuggestions(sessionId, query);
131114
}
132115

133116
// Check if it's a file mention (starts with @)
134117
if (query.startsWith('@')) {
135-
logger.debug('💡 getSuggestions: File mention detected');
136-
const result = await getFileMentionSuggestions(sessionId, query);
137-
logger.debug(
138-
'💡 getSuggestions: File suggestions:',
139-
JSON.stringify(
140-
result.map(r => ({
141-
key: r.key,
142-
text: r.text,
143-
component: '[Function]',
144-
})),
145-
null,
146-
2
147-
)
148-
);
149-
return result;
118+
return getFileMentionSuggestions(sessionId, query);
150119
}
151120

152121
// No suggestions for other queries
153-
logger.debug('💡 getSuggestions: No matching prefix, returning empty array');
154122
return [];
155123
}

0 commit comments

Comments
 (0)