@hienlh/ppm 0.10.1 → 0.10.3
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/CHANGELOG.md +12 -0
- package/dist/web/assets/chat-tab-lo46P4ZN.js +10 -0
- package/dist/web/assets/{code-editor-J1ScsQ5K.js → code-editor-DN0UiBvk.js} +2 -2
- package/dist/web/assets/{conflict-editor-DHZHw4Za.js → conflict-editor-C28xnWqp.js} +1 -1
- package/dist/web/assets/{database-viewer-DLcjXG7f.js → database-viewer-DDGq5efK.js} +1 -1
- package/dist/web/assets/{diff-viewer--rfhbBnw.js → diff-viewer-DBELqMy0.js} +1 -1
- package/dist/web/assets/{extension-webview-CYtw55sx.js → extension-webview--HUG0c_R.js} +1 -1
- package/dist/web/assets/{index-CLnTXXxE.js → index-BUxaCPPv.js} +2 -2
- package/dist/web/assets/index-Dq7PPmAk.css +2 -0
- package/dist/web/assets/{markdown-renderer-Co8Alvhg.js → markdown-renderer-CtsslbMO.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-CxnmKm6E.js → port-forwarding-tab-BszAda9U.js} +1 -1
- package/dist/web/assets/{postgres-viewer-a-QqP5jk.js → postgres-viewer-X2li3HfX.js} +1 -1
- package/dist/web/assets/{settings-tab-DU14rPtu.js → settings-tab-DR1HhS4C.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-xuoaJj1S.js → sqlite-viewer-BV0p6qnR.js} +1 -1
- package/dist/web/assets/{terminal-tab-BmRH_uhb.js → terminal-tab-BiRx6kvn.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/web/components/chat/message-input.tsx +121 -87
- package/src/web/components/chat/message-list.tsx +1 -1
- package/dist/web/assets/chat-tab-SBNMhk9B.js +0 -10
- package/dist/web/assets/index-CQJBmJiN.css +0 -2
|
@@ -74,7 +74,11 @@ export const MessageInput = memo(function MessageInput({
|
|
|
74
74
|
providerId,
|
|
75
75
|
onProviderChange,
|
|
76
76
|
}: MessageInputProps) {
|
|
77
|
-
|
|
77
|
+
// Uncontrolled textarea: value lives in DOM + ref, not React state.
|
|
78
|
+
// Only `hasText` state triggers re-renders (empty↔non-empty for send button).
|
|
79
|
+
// This eliminates React re-render on every keystroke — critical for Chromium on iPad.
|
|
80
|
+
const valueRef = useRef(initialValue ?? "");
|
|
81
|
+
const [hasText, setHasText] = useState(() => (initialValue ?? "").trim().length > 0);
|
|
78
82
|
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
|
|
79
83
|
const [modeSelectorOpen, setModeSelectorOpen] = useState(false);
|
|
80
84
|
const [pendingSend, setPendingSend] = useState(false);
|
|
@@ -88,6 +92,29 @@ export const MessageInput = memo(function MessageInput({
|
|
|
88
92
|
const slashSearchIdRef = useRef(0);
|
|
89
93
|
const fileItemsRef = useRef<FileNode[]>([]);
|
|
90
94
|
const resizeRafRef = useRef(0);
|
|
95
|
+
// Track picker open state to avoid unnecessary parent callbacks per keystroke
|
|
96
|
+
const slashPickerOpenRef = useRef(false);
|
|
97
|
+
const filePickerOpenRef = useRef(false);
|
|
98
|
+
// CSS field-sizing: content handles auto-resize natively (Safari 18.2+, Chrome 123+).
|
|
99
|
+
// Only fall back to JS scrollHeight resize when unsupported.
|
|
100
|
+
const needsJsResize = useRef(
|
|
101
|
+
typeof CSS === "undefined" || !CSS.supports("field-sizing", "content"),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
/** Write value to both textareas + ref + update hasText state */
|
|
105
|
+
const writeTextareas = useCallback((newValue: string) => {
|
|
106
|
+
valueRef.current = newValue;
|
|
107
|
+
if (textareaRef.current) textareaRef.current.value = newValue;
|
|
108
|
+
if (mobileTextareaRef.current) mobileTextareaRef.current.value = newValue;
|
|
109
|
+
setHasText(newValue.trim().length > 0);
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
/** Get the currently visible textarea */
|
|
113
|
+
const getVisibleTextarea = useCallback(() => {
|
|
114
|
+
return window.matchMedia("(min-width: 768px)").matches
|
|
115
|
+
? textareaRef.current
|
|
116
|
+
: mobileTextareaRef.current;
|
|
117
|
+
}, []);
|
|
91
118
|
|
|
92
119
|
// Voice input (Web Speech API)
|
|
93
120
|
const voice = useVoiceInput();
|
|
@@ -96,26 +123,26 @@ export const MessageInput = memo(function MessageInput({
|
|
|
96
123
|
const voiceResultCb = useCallback((text: string) => {
|
|
97
124
|
const prefix = preVoiceTextRef.current;
|
|
98
125
|
const newValue = prefix ? prefix + " " + text : text;
|
|
99
|
-
|
|
100
|
-
// Auto-resize textarea
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}, []);
|
|
126
|
+
writeTextareas(newValue);
|
|
127
|
+
// Auto-resize textarea (only when CSS field-sizing is unsupported)
|
|
128
|
+
if (needsJsResize.current) {
|
|
129
|
+
requestAnimationFrame(() => {
|
|
130
|
+
const ta = getVisibleTextarea();
|
|
131
|
+
if (ta) {
|
|
132
|
+
ta.style.height = "auto";
|
|
133
|
+
ta.style.height = Math.min(ta.scrollHeight, 160) + "px";
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}, [writeTextareas, getVisibleTextarea]);
|
|
111
138
|
const handleVoiceToggle = useCallback(() => {
|
|
112
139
|
if (voice.isListening) {
|
|
113
140
|
voice.stop();
|
|
114
141
|
} else {
|
|
115
|
-
preVoiceTextRef.current =
|
|
142
|
+
preVoiceTextRef.current = valueRef.current.trim();
|
|
116
143
|
voice.start(voiceResultCb);
|
|
117
144
|
}
|
|
118
|
-
}, [voice.isListening, voice.start, voice.stop,
|
|
145
|
+
}, [voice.isListening, voice.start, voice.stop, voiceResultCb]);
|
|
119
146
|
|
|
120
147
|
// Listen for global keyboard shortcut (Cmd+Shift+V) to toggle voice
|
|
121
148
|
useEffect(() => {
|
|
@@ -127,24 +154,19 @@ export const MessageInput = memo(function MessageInput({
|
|
|
127
154
|
// Apply initialValue when it changes (e.g. "Ask AI" from command palette)
|
|
128
155
|
useEffect(() => {
|
|
129
156
|
if (initialValue) {
|
|
130
|
-
|
|
157
|
+
writeTextareas(initialValue);
|
|
131
158
|
// Focus and move cursor to end
|
|
132
159
|
setTimeout(() => {
|
|
133
160
|
const ta = textareaRef.current;
|
|
134
161
|
if (ta) { ta.focus(); ta.selectionStart = ta.selectionEnd = ta.value.length; }
|
|
135
162
|
}, 50);
|
|
136
163
|
}
|
|
137
|
-
}, [initialValue]);
|
|
164
|
+
}, [initialValue]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
138
165
|
|
|
139
166
|
// Auto-focus on mount when requested
|
|
140
167
|
useEffect(() => {
|
|
141
168
|
if (!autoFocus) return;
|
|
142
|
-
setTimeout(() => {
|
|
143
|
-
const ta = window.matchMedia("(min-width: 768px)").matches
|
|
144
|
-
? textareaRef.current
|
|
145
|
-
: mobileTextareaRef.current;
|
|
146
|
-
ta?.focus();
|
|
147
|
-
}, 100);
|
|
169
|
+
setTimeout(() => { getVisibleTextarea()?.focus(); }, 100);
|
|
148
170
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
149
171
|
|
|
150
172
|
// Fetch slash items when projectName changes
|
|
@@ -197,43 +219,44 @@ export const MessageInput = memo(function MessageInput({
|
|
|
197
219
|
// Handle parent selecting a slash item
|
|
198
220
|
useEffect(() => {
|
|
199
221
|
if (!slashSelected) return;
|
|
200
|
-
const el =
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
const
|
|
222
|
+
const el = getVisibleTextarea();
|
|
223
|
+
if (!el) return;
|
|
224
|
+
const text = el.value;
|
|
225
|
+
const cursorPos = el.selectionStart;
|
|
226
|
+
const textBefore = text.slice(0, cursorPos);
|
|
227
|
+
const textAfter = text.slice(cursorPos);
|
|
204
228
|
// Find the /query pattern before cursor and replace it
|
|
205
229
|
const replaced = textBefore.replace(/(?:^|\s)\/\S*$/, (match) => {
|
|
206
230
|
const prefix = match.startsWith("/") ? "" : match[0]; // preserve whitespace
|
|
207
231
|
return `${prefix}/${slashSelected.name} `;
|
|
208
232
|
});
|
|
209
|
-
|
|
210
|
-
setValue(newValue);
|
|
233
|
+
writeTextareas(replaced + textAfter);
|
|
211
234
|
onSlashStateChange?.(false, "");
|
|
235
|
+
slashPickerOpenRef.current = false;
|
|
212
236
|
onFileStateChange?.(false, "");
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
237
|
+
filePickerOpenRef.current = false;
|
|
238
|
+
el.focus();
|
|
239
|
+
setTimeout(() => {
|
|
240
|
+
el.selectionStart = el.selectionEnd = replaced.length;
|
|
241
|
+
}, 0);
|
|
219
242
|
}, [slashSelected]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
220
243
|
|
|
221
244
|
// Handle parent selecting a file
|
|
222
245
|
useEffect(() => {
|
|
223
246
|
if (!fileSelected) return;
|
|
224
|
-
const el =
|
|
247
|
+
const el = getVisibleTextarea();
|
|
225
248
|
if (!el) return;
|
|
226
249
|
|
|
227
|
-
|
|
250
|
+
const text = el.value;
|
|
228
251
|
const cursorPos = el.selectionStart;
|
|
229
|
-
const textBefore =
|
|
230
|
-
const textAfter =
|
|
252
|
+
const textBefore = text.slice(0, cursorPos);
|
|
253
|
+
const textAfter = text.slice(cursorPos);
|
|
231
254
|
// Find the @ trigger before cursor
|
|
232
255
|
const atMatch = textBefore.match(/@(\S*)$/);
|
|
233
256
|
if (atMatch) {
|
|
234
257
|
const start = textBefore.length - atMatch[0].length;
|
|
235
258
|
const newText = textBefore.slice(0, start) + `@${fileSelected.path} ` + textAfter;
|
|
236
|
-
|
|
259
|
+
writeTextareas(newText);
|
|
237
260
|
const newCursorPos = start + fileSelected.path.length + 2; // +2 for @ and space
|
|
238
261
|
setTimeout(() => {
|
|
239
262
|
el.selectionStart = el.selectionEnd = newCursorPos;
|
|
@@ -241,14 +264,15 @@ export const MessageInput = memo(function MessageInput({
|
|
|
241
264
|
}, 0);
|
|
242
265
|
} else {
|
|
243
266
|
// Fallback: append at end
|
|
244
|
-
const newText =
|
|
245
|
-
|
|
267
|
+
const newText = text + `@${fileSelected.path} `;
|
|
268
|
+
writeTextareas(newText);
|
|
246
269
|
setTimeout(() => {
|
|
247
270
|
el.selectionStart = el.selectionEnd = newText.length;
|
|
248
271
|
el.focus();
|
|
249
272
|
}, 0);
|
|
250
273
|
}
|
|
251
274
|
onFileStateChange?.(false, "");
|
|
275
|
+
filePickerOpenRef.current = false;
|
|
252
276
|
}, [fileSelected]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
253
277
|
|
|
254
278
|
// Handle external files dropped on parent (ChatTab)
|
|
@@ -290,7 +314,9 @@ export const MessageInput = memo(function MessageInput({
|
|
|
290
314
|
for (const file of files) {
|
|
291
315
|
if (!isSupportedFile(file)) {
|
|
292
316
|
// Unsupported → insert file name as text
|
|
293
|
-
|
|
317
|
+
const cur = valueRef.current;
|
|
318
|
+
const sep = cur.length > 0 && !cur.endsWith(" ") ? " " : "";
|
|
319
|
+
writeTextareas(cur + sep + file.name);
|
|
294
320
|
continue;
|
|
295
321
|
}
|
|
296
322
|
|
|
@@ -322,7 +348,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
322
348
|
}
|
|
323
349
|
(mobileTextareaRef.current ?? textareaRef.current)?.focus();
|
|
324
350
|
},
|
|
325
|
-
[uploadFile],
|
|
351
|
+
[uploadFile, writeTextareas],
|
|
326
352
|
);
|
|
327
353
|
|
|
328
354
|
const removeAttachment = useCallback((id: string) => {
|
|
@@ -335,7 +361,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
335
361
|
|
|
336
362
|
/** Execute the actual send (called directly or after uploads complete) */
|
|
337
363
|
const executeSend = useCallback(() => {
|
|
338
|
-
const trimmed =
|
|
364
|
+
const trimmed = valueRef.current.trim();
|
|
339
365
|
const readyAttachments = attachments.filter((a) => a.status === "ready");
|
|
340
366
|
if (!trimmed && readyAttachments.length === 0) {
|
|
341
367
|
setPendingSend(false);
|
|
@@ -343,10 +369,12 @@ export const MessageInput = memo(function MessageInput({
|
|
|
343
369
|
}
|
|
344
370
|
|
|
345
371
|
onSlashStateChange?.(false, "");
|
|
372
|
+
slashPickerOpenRef.current = false;
|
|
346
373
|
onFileStateChange?.(false, "");
|
|
374
|
+
filePickerOpenRef.current = false;
|
|
347
375
|
if (voice.isListening) voice.stop();
|
|
348
376
|
onSend(trimmed, readyAttachments, isStreaming ? priority : undefined);
|
|
349
|
-
|
|
377
|
+
writeTextareas("");
|
|
350
378
|
// Revoke preview URLs
|
|
351
379
|
for (const att of attachments) {
|
|
352
380
|
if (att.previewUrl) URL.revokeObjectURL(att.previewUrl);
|
|
@@ -354,16 +382,18 @@ export const MessageInput = memo(function MessageInput({
|
|
|
354
382
|
setAttachments([]);
|
|
355
383
|
setPendingSend(false);
|
|
356
384
|
setPriority('next');
|
|
357
|
-
if (
|
|
358
|
-
|
|
359
|
-
|
|
385
|
+
if (needsJsResize.current) {
|
|
386
|
+
if (textareaRef.current) textareaRef.current.style.height = "auto";
|
|
387
|
+
if (mobileTextareaRef.current) mobileTextareaRef.current.style.height = "auto";
|
|
388
|
+
}
|
|
389
|
+
}, [attachments, onSend, onSlashStateChange, onFileStateChange, isStreaming, priority, writeTextareas]);
|
|
360
390
|
|
|
361
391
|
const handleSend = useCallback(() => {
|
|
362
392
|
if (disabled) return;
|
|
363
393
|
|
|
364
394
|
// If files are still uploading, queue the send for when they finish
|
|
365
395
|
if (attachments.some((a) => a.status === "uploading")) {
|
|
366
|
-
const trimmed =
|
|
396
|
+
const trimmed = valueRef.current.trim();
|
|
367
397
|
if (trimmed || attachments.some((a) => a.status !== "error")) {
|
|
368
398
|
setPendingSend(true);
|
|
369
399
|
}
|
|
@@ -371,7 +401,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
371
401
|
}
|
|
372
402
|
|
|
373
403
|
executeSend();
|
|
374
|
-
}, [
|
|
404
|
+
}, [attachments, disabled, executeSend]);
|
|
375
405
|
|
|
376
406
|
// Auto-send when queued and all uploads complete
|
|
377
407
|
useEffect(() => {
|
|
@@ -434,9 +464,9 @@ export const MessageInput = memo(function MessageInput({
|
|
|
434
464
|
// Cancel pending slash search if any
|
|
435
465
|
if (slashDebounceRef.current) { clearTimeout(slashDebounceRef.current); slashDebounceRef.current = undefined; }
|
|
436
466
|
if (slashRankedRef.current) slashRankedRef.current = false;
|
|
437
|
-
// Close pickers only if they were open (avoid unnecessary parent setState)
|
|
438
|
-
onSlashStateChange?.(false, "");
|
|
439
|
-
onFileStateChange?.(false, "");
|
|
467
|
+
// Close pickers only if they were actually open (avoid unnecessary parent setState)
|
|
468
|
+
if (slashPickerOpenRef.current) { onSlashStateChange?.(false, ""); slashPickerOpenRef.current = false; }
|
|
469
|
+
if (filePickerOpenRef.current) { onFileStateChange?.(false, ""); filePickerOpenRef.current = false; }
|
|
440
470
|
return;
|
|
441
471
|
}
|
|
442
472
|
|
|
@@ -446,7 +476,8 @@ export const MessageInput = memo(function MessageInput({
|
|
|
446
476
|
if (slashMatch && slashItemsRef.current.length > 0) {
|
|
447
477
|
const filter = slashMatch[1] ?? "";
|
|
448
478
|
onSlashStateChange?.(true, filter);
|
|
449
|
-
|
|
479
|
+
slashPickerOpenRef.current = true;
|
|
480
|
+
if (filePickerOpenRef.current) { onFileStateChange?.(false, ""); filePickerOpenRef.current = false; }
|
|
450
481
|
if (filter) fetchSlashSearch(filter);
|
|
451
482
|
return;
|
|
452
483
|
}
|
|
@@ -461,38 +492,45 @@ export const MessageInput = memo(function MessageInput({
|
|
|
461
492
|
const atMatch = textBefore.match(/@(\S*)$/);
|
|
462
493
|
if (atMatch && fileItemsRef.current.length > 0) {
|
|
463
494
|
onFileStateChange?.(true, atMatch[1] ?? "");
|
|
464
|
-
|
|
495
|
+
filePickerOpenRef.current = true;
|
|
496
|
+
if (slashPickerOpenRef.current) { onSlashStateChange?.(false, ""); slashPickerOpenRef.current = false; }
|
|
465
497
|
return;
|
|
466
498
|
}
|
|
467
499
|
}
|
|
468
500
|
|
|
469
|
-
// Nothing matched — close both pickers
|
|
470
|
-
onSlashStateChange?.(false, "");
|
|
471
|
-
onFileStateChange?.(false, "");
|
|
501
|
+
// Nothing matched — close both pickers (only if open)
|
|
502
|
+
if (slashPickerOpenRef.current) { onSlashStateChange?.(false, ""); slashPickerOpenRef.current = false; }
|
|
503
|
+
if (filePickerOpenRef.current) { onFileStateChange?.(false, ""); filePickerOpenRef.current = false; }
|
|
472
504
|
},
|
|
473
505
|
[onSlashStateChange, onFileStateChange, fetchSlashSearch],
|
|
474
506
|
);
|
|
475
507
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
508
|
+
/** Unified onChange for both textareas — updates ref, syncs other textarea, triggers picker */
|
|
509
|
+
const handleTextareaChange = useCallback(
|
|
510
|
+
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
511
|
+
const el = e.target;
|
|
512
|
+
const text = el.value;
|
|
513
|
+
valueRef.current = text;
|
|
514
|
+
// Sync the other textarea (handles viewport rotation edge case)
|
|
515
|
+
const other = el === textareaRef.current ? mobileTextareaRef.current : textareaRef.current;
|
|
516
|
+
if (other) other.value = text;
|
|
517
|
+
// Only trigger re-render on empty↔non-empty transition (for send button state)
|
|
518
|
+
setHasText(text.trim().length > 0);
|
|
519
|
+
// Update picker state (slash/file autocomplete)
|
|
520
|
+
updatePickerState(text, el.selectionStart);
|
|
521
|
+
// JS auto-resize fallback — only when CSS field-sizing: content is unsupported
|
|
522
|
+
if (needsJsResize.current) {
|
|
523
|
+
if (resizeRafRef.current) cancelAnimationFrame(resizeRafRef.current);
|
|
524
|
+
resizeRafRef.current = requestAnimationFrame(() => {
|
|
525
|
+
resizeRafRef.current = 0;
|
|
526
|
+
el.style.height = "auto";
|
|
527
|
+
el.style.height = Math.min(el.scrollHeight, el === mobileTextareaRef.current ? 80 : 160) + "px";
|
|
528
|
+
});
|
|
529
|
+
}
|
|
480
530
|
},
|
|
481
531
|
[updatePickerState],
|
|
482
532
|
);
|
|
483
533
|
|
|
484
|
-
const handleInput = useCallback((e?: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
485
|
-
const el = e?.target ?? textareaRef.current;
|
|
486
|
-
if (!el) return;
|
|
487
|
-
// Batch height recalculation to avoid sync layout reflow per keystroke
|
|
488
|
-
if (resizeRafRef.current) cancelAnimationFrame(resizeRafRef.current);
|
|
489
|
-
resizeRafRef.current = requestAnimationFrame(() => {
|
|
490
|
-
resizeRafRef.current = 0;
|
|
491
|
-
el.style.height = "auto";
|
|
492
|
-
el.style.height = Math.min(el.scrollHeight, 160) + "px";
|
|
493
|
-
});
|
|
494
|
-
}, []);
|
|
495
|
-
|
|
496
534
|
/** Handle paste — intercept images from clipboard */
|
|
497
535
|
const handlePaste = useCallback(
|
|
498
536
|
(e: ClipboardEvent<HTMLTextAreaElement>) => {
|
|
@@ -543,7 +581,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
543
581
|
[processFiles],
|
|
544
582
|
);
|
|
545
583
|
|
|
546
|
-
const hasContent =
|
|
584
|
+
const hasContent = hasText || attachments.some((a) => a.status !== "error");
|
|
547
585
|
const showCancel = isStreaming && !hasContent;
|
|
548
586
|
|
|
549
587
|
return (
|
|
@@ -555,11 +593,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
555
593
|
if (disabled) return;
|
|
556
594
|
// Only focus when clicking outside the textarea (e.g. padding area)
|
|
557
595
|
if (e.target instanceof HTMLTextAreaElement) return;
|
|
558
|
-
|
|
559
|
-
const ta = window.matchMedia("(min-width: 768px)").matches
|
|
560
|
-
? textareaRef.current
|
|
561
|
-
: mobileTextareaRef.current;
|
|
562
|
-
ta?.focus();
|
|
596
|
+
getVisibleTextarea()?.focus();
|
|
563
597
|
}}
|
|
564
598
|
>
|
|
565
599
|
{/* Attachment chips (inside container, aligned with input) */}
|
|
@@ -598,8 +632,8 @@ export const MessageInput = memo(function MessageInput({
|
|
|
598
632
|
</button>
|
|
599
633
|
<textarea
|
|
600
634
|
ref={mobileTextareaRef}
|
|
601
|
-
|
|
602
|
-
onChange={
|
|
635
|
+
defaultValue={initialValue ?? ""}
|
|
636
|
+
onChange={handleTextareaChange}
|
|
603
637
|
onKeyDown={handleKeyDown}
|
|
604
638
|
onPaste={handlePaste}
|
|
605
639
|
onDrop={handleDrop}
|
|
@@ -607,7 +641,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
607
641
|
placeholder={isStreaming ? "Follow-up..." : "Ask anything..."}
|
|
608
642
|
disabled={disabled}
|
|
609
643
|
rows={1}
|
|
610
|
-
className="flex-1 resize-none bg-transparent py-1.5 text-sm text-foreground placeholder:text-text-subtle focus:outline-none disabled:opacity-50 max-h-20"
|
|
644
|
+
className="flex-1 resize-none bg-transparent py-1.5 text-sm text-foreground placeholder:text-text-subtle focus:outline-none disabled:opacity-50 max-h-20 [field-sizing:content]"
|
|
611
645
|
/>
|
|
612
646
|
{voice.supported && (
|
|
613
647
|
<button
|
|
@@ -648,8 +682,8 @@ export const MessageInput = memo(function MessageInput({
|
|
|
648
682
|
<div className="hidden md:block">
|
|
649
683
|
<textarea
|
|
650
684
|
ref={textareaRef}
|
|
651
|
-
|
|
652
|
-
onChange={
|
|
685
|
+
defaultValue={initialValue ?? ""}
|
|
686
|
+
onChange={handleTextareaChange}
|
|
653
687
|
onKeyDown={handleKeyDown}
|
|
654
688
|
onPaste={handlePaste}
|
|
655
689
|
onDrop={handleDrop}
|
|
@@ -657,7 +691,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
657
691
|
placeholder={isStreaming ? "Follow-up or Stop..." : "Ask anything..."}
|
|
658
692
|
disabled={disabled}
|
|
659
693
|
rows={1}
|
|
660
|
-
className="w-full resize-none bg-transparent px-4 pt-3 pb-1 text-sm text-foreground placeholder:text-text-subtle focus:outline-none disabled:opacity-50 max-h-40"
|
|
694
|
+
className="w-full resize-none bg-transparent px-4 pt-3 pb-1 text-sm text-foreground placeholder:text-text-subtle focus:outline-none disabled:opacity-50 max-h-40 [field-sizing:content]"
|
|
661
695
|
/>
|
|
662
696
|
<div className="flex items-center justify-between px-3 pb-2">
|
|
663
697
|
<div className="flex items-center gap-1">
|
|
@@ -117,7 +117,7 @@ export function MessageList({
|
|
|
117
117
|
|
|
118
118
|
return (
|
|
119
119
|
<div className="relative flex-1 overflow-hidden flex flex-col min-h-0">
|
|
120
|
-
<StickToBottom className="flex-1 overflow-y-auto overflow-x-hidden" resize="smooth" initial="instant">
|
|
120
|
+
<StickToBottom className="flex-1 overflow-y-auto overflow-x-hidden [contain:strict]" resize="smooth" initial="instant">
|
|
121
121
|
<StickToBottom.Content className="p-4 space-y-4 select-none">
|
|
122
122
|
{hasMore && (
|
|
123
123
|
<button onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
|