@hienlh/ppm 0.10.1 → 0.10.2

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.
@@ -74,7 +74,11 @@ export const MessageInput = memo(function MessageInput({
74
74
  providerId,
75
75
  onProviderChange,
76
76
  }: MessageInputProps) {
77
- const [value, setValue] = useState(initialValue ?? "");
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,24 @@ 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
+
99
+ /** Write value to both textareas + ref + update hasText state */
100
+ const writeTextareas = useCallback((newValue: string) => {
101
+ valueRef.current = newValue;
102
+ if (textareaRef.current) textareaRef.current.value = newValue;
103
+ if (mobileTextareaRef.current) mobileTextareaRef.current.value = newValue;
104
+ setHasText(newValue.trim().length > 0);
105
+ }, []);
106
+
107
+ /** Get the currently visible textarea */
108
+ const getVisibleTextarea = useCallback(() => {
109
+ return window.matchMedia("(min-width: 768px)").matches
110
+ ? textareaRef.current
111
+ : mobileTextareaRef.current;
112
+ }, []);
91
113
 
92
114
  // Voice input (Web Speech API)
93
115
  const voice = useVoiceInput();
@@ -96,26 +118,24 @@ export const MessageInput = memo(function MessageInput({
96
118
  const voiceResultCb = useCallback((text: string) => {
97
119
  const prefix = preVoiceTextRef.current;
98
120
  const newValue = prefix ? prefix + " " + text : text;
99
- setValue(newValue);
121
+ writeTextareas(newValue);
100
122
  // Auto-resize textarea
101
123
  requestAnimationFrame(() => {
102
- const ta = window.matchMedia("(min-width: 768px)").matches
103
- ? textareaRef.current
104
- : mobileTextareaRef.current;
124
+ const ta = getVisibleTextarea();
105
125
  if (ta) {
106
126
  ta.style.height = "auto";
107
127
  ta.style.height = Math.min(ta.scrollHeight, 160) + "px";
108
128
  }
109
129
  });
110
- }, []);
130
+ }, [writeTextareas, getVisibleTextarea]);
111
131
  const handleVoiceToggle = useCallback(() => {
112
132
  if (voice.isListening) {
113
133
  voice.stop();
114
134
  } else {
115
- preVoiceTextRef.current = value.trim();
135
+ preVoiceTextRef.current = valueRef.current.trim();
116
136
  voice.start(voiceResultCb);
117
137
  }
118
- }, [voice.isListening, voice.start, voice.stop, value, voiceResultCb]);
138
+ }, [voice.isListening, voice.start, voice.stop, voiceResultCb]);
119
139
 
120
140
  // Listen for global keyboard shortcut (Cmd+Shift+V) to toggle voice
121
141
  useEffect(() => {
@@ -127,24 +147,19 @@ export const MessageInput = memo(function MessageInput({
127
147
  // Apply initialValue when it changes (e.g. "Ask AI" from command palette)
128
148
  useEffect(() => {
129
149
  if (initialValue) {
130
- setValue(initialValue);
150
+ writeTextareas(initialValue);
131
151
  // Focus and move cursor to end
132
152
  setTimeout(() => {
133
153
  const ta = textareaRef.current;
134
154
  if (ta) { ta.focus(); ta.selectionStart = ta.selectionEnd = ta.value.length; }
135
155
  }, 50);
136
156
  }
137
- }, [initialValue]);
157
+ }, [initialValue]); // eslint-disable-line react-hooks/exhaustive-deps
138
158
 
139
159
  // Auto-focus on mount when requested
140
160
  useEffect(() => {
141
161
  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);
162
+ setTimeout(() => { getVisibleTextarea()?.focus(); }, 100);
148
163
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
149
164
 
150
165
  // Fetch slash items when projectName changes
@@ -197,43 +212,44 @@ export const MessageInput = memo(function MessageInput({
197
212
  // Handle parent selecting a slash item
198
213
  useEffect(() => {
199
214
  if (!slashSelected) return;
200
- const el = textareaRef.current;
201
- const cursorPos = el?.selectionStart ?? value.length;
202
- const textBefore = value.slice(0, cursorPos);
203
- const textAfter = value.slice(cursorPos);
215
+ const el = getVisibleTextarea();
216
+ if (!el) return;
217
+ const text = el.value;
218
+ const cursorPos = el.selectionStart;
219
+ const textBefore = text.slice(0, cursorPos);
220
+ const textAfter = text.slice(cursorPos);
204
221
  // Find the /query pattern before cursor and replace it
205
222
  const replaced = textBefore.replace(/(?:^|\s)\/\S*$/, (match) => {
206
223
  const prefix = match.startsWith("/") ? "" : match[0]; // preserve whitespace
207
224
  return `${prefix}/${slashSelected.name} `;
208
225
  });
209
- const newValue = replaced + textAfter;
210
- setValue(newValue);
226
+ writeTextareas(replaced + textAfter);
211
227
  onSlashStateChange?.(false, "");
228
+ slashPickerOpenRef.current = false;
212
229
  onFileStateChange?.(false, "");
213
- if (el) {
214
- el.focus();
215
- setTimeout(() => {
216
- el.selectionStart = el.selectionEnd = replaced.length;
217
- }, 0);
218
- }
230
+ filePickerOpenRef.current = false;
231
+ el.focus();
232
+ setTimeout(() => {
233
+ el.selectionStart = el.selectionEnd = replaced.length;
234
+ }, 0);
219
235
  }, [slashSelected]); // eslint-disable-line react-hooks/exhaustive-deps
220
236
 
221
237
  // Handle parent selecting a file
222
238
  useEffect(() => {
223
239
  if (!fileSelected) return;
224
- const el = textareaRef.current;
240
+ const el = getVisibleTextarea();
225
241
  if (!el) return;
226
242
 
227
- // Replace the @query with @path
243
+ const text = el.value;
228
244
  const cursorPos = el.selectionStart;
229
- const textBefore = value.slice(0, cursorPos);
230
- const textAfter = value.slice(cursorPos);
245
+ const textBefore = text.slice(0, cursorPos);
246
+ const textAfter = text.slice(cursorPos);
231
247
  // Find the @ trigger before cursor
232
248
  const atMatch = textBefore.match(/@(\S*)$/);
233
249
  if (atMatch) {
234
250
  const start = textBefore.length - atMatch[0].length;
235
251
  const newText = textBefore.slice(0, start) + `@${fileSelected.path} ` + textAfter;
236
- setValue(newText);
252
+ writeTextareas(newText);
237
253
  const newCursorPos = start + fileSelected.path.length + 2; // +2 for @ and space
238
254
  setTimeout(() => {
239
255
  el.selectionStart = el.selectionEnd = newCursorPos;
@@ -241,14 +257,15 @@ export const MessageInput = memo(function MessageInput({
241
257
  }, 0);
242
258
  } else {
243
259
  // Fallback: append at end
244
- const newText = value + `@${fileSelected.path} `;
245
- setValue(newText);
260
+ const newText = text + `@${fileSelected.path} `;
261
+ writeTextareas(newText);
246
262
  setTimeout(() => {
247
263
  el.selectionStart = el.selectionEnd = newText.length;
248
264
  el.focus();
249
265
  }, 0);
250
266
  }
251
267
  onFileStateChange?.(false, "");
268
+ filePickerOpenRef.current = false;
252
269
  }, [fileSelected]); // eslint-disable-line react-hooks/exhaustive-deps
253
270
 
254
271
  // Handle external files dropped on parent (ChatTab)
@@ -290,7 +307,9 @@ export const MessageInput = memo(function MessageInput({
290
307
  for (const file of files) {
291
308
  if (!isSupportedFile(file)) {
292
309
  // Unsupported → insert file name as text
293
- setValue((prev) => prev + (prev.length > 0 && !prev.endsWith(" ") ? " " : "") + file.name);
310
+ const cur = valueRef.current;
311
+ const sep = cur.length > 0 && !cur.endsWith(" ") ? " " : "";
312
+ writeTextareas(cur + sep + file.name);
294
313
  continue;
295
314
  }
296
315
 
@@ -322,7 +341,7 @@ export const MessageInput = memo(function MessageInput({
322
341
  }
323
342
  (mobileTextareaRef.current ?? textareaRef.current)?.focus();
324
343
  },
325
- [uploadFile],
344
+ [uploadFile, writeTextareas],
326
345
  );
327
346
 
328
347
  const removeAttachment = useCallback((id: string) => {
@@ -335,7 +354,7 @@ export const MessageInput = memo(function MessageInput({
335
354
 
336
355
  /** Execute the actual send (called directly or after uploads complete) */
337
356
  const executeSend = useCallback(() => {
338
- const trimmed = value.trim();
357
+ const trimmed = valueRef.current.trim();
339
358
  const readyAttachments = attachments.filter((a) => a.status === "ready");
340
359
  if (!trimmed && readyAttachments.length === 0) {
341
360
  setPendingSend(false);
@@ -343,10 +362,12 @@ export const MessageInput = memo(function MessageInput({
343
362
  }
344
363
 
345
364
  onSlashStateChange?.(false, "");
365
+ slashPickerOpenRef.current = false;
346
366
  onFileStateChange?.(false, "");
367
+ filePickerOpenRef.current = false;
347
368
  if (voice.isListening) voice.stop();
348
369
  onSend(trimmed, readyAttachments, isStreaming ? priority : undefined);
349
- setValue("");
370
+ writeTextareas("");
350
371
  // Revoke preview URLs
351
372
  for (const att of attachments) {
352
373
  if (att.previewUrl) URL.revokeObjectURL(att.previewUrl);
@@ -356,14 +377,14 @@ export const MessageInput = memo(function MessageInput({
356
377
  setPriority('next');
357
378
  if (textareaRef.current) textareaRef.current.style.height = "auto";
358
379
  if (mobileTextareaRef.current) mobileTextareaRef.current.style.height = "auto";
359
- }, [value, attachments, onSend, onSlashStateChange, onFileStateChange, isStreaming, priority]);
380
+ }, [attachments, onSend, onSlashStateChange, onFileStateChange, isStreaming, priority, writeTextareas]);
360
381
 
361
382
  const handleSend = useCallback(() => {
362
383
  if (disabled) return;
363
384
 
364
385
  // If files are still uploading, queue the send for when they finish
365
386
  if (attachments.some((a) => a.status === "uploading")) {
366
- const trimmed = value.trim();
387
+ const trimmed = valueRef.current.trim();
367
388
  if (trimmed || attachments.some((a) => a.status !== "error")) {
368
389
  setPendingSend(true);
369
390
  }
@@ -371,7 +392,7 @@ export const MessageInput = memo(function MessageInput({
371
392
  }
372
393
 
373
394
  executeSend();
374
- }, [value, attachments, disabled, executeSend]);
395
+ }, [attachments, disabled, executeSend]);
375
396
 
376
397
  // Auto-send when queued and all uploads complete
377
398
  useEffect(() => {
@@ -434,9 +455,9 @@ export const MessageInput = memo(function MessageInput({
434
455
  // Cancel pending slash search if any
435
456
  if (slashDebounceRef.current) { clearTimeout(slashDebounceRef.current); slashDebounceRef.current = undefined; }
436
457
  if (slashRankedRef.current) slashRankedRef.current = false;
437
- // Close pickers only if they were open (avoid unnecessary parent setState)
438
- onSlashStateChange?.(false, "");
439
- onFileStateChange?.(false, "");
458
+ // Close pickers only if they were actually open (avoid unnecessary parent setState)
459
+ if (slashPickerOpenRef.current) { onSlashStateChange?.(false, ""); slashPickerOpenRef.current = false; }
460
+ if (filePickerOpenRef.current) { onFileStateChange?.(false, ""); filePickerOpenRef.current = false; }
440
461
  return;
441
462
  }
442
463
 
@@ -446,7 +467,8 @@ export const MessageInput = memo(function MessageInput({
446
467
  if (slashMatch && slashItemsRef.current.length > 0) {
447
468
  const filter = slashMatch[1] ?? "";
448
469
  onSlashStateChange?.(true, filter);
449
- onFileStateChange?.(false, "");
470
+ slashPickerOpenRef.current = true;
471
+ if (filePickerOpenRef.current) { onFileStateChange?.(false, ""); filePickerOpenRef.current = false; }
450
472
  if (filter) fetchSlashSearch(filter);
451
473
  return;
452
474
  }
@@ -461,38 +483,43 @@ export const MessageInput = memo(function MessageInput({
461
483
  const atMatch = textBefore.match(/@(\S*)$/);
462
484
  if (atMatch && fileItemsRef.current.length > 0) {
463
485
  onFileStateChange?.(true, atMatch[1] ?? "");
464
- onSlashStateChange?.(false, "");
486
+ filePickerOpenRef.current = true;
487
+ if (slashPickerOpenRef.current) { onSlashStateChange?.(false, ""); slashPickerOpenRef.current = false; }
465
488
  return;
466
489
  }
467
490
  }
468
491
 
469
- // Nothing matched — close both pickers
470
- onSlashStateChange?.(false, "");
471
- onFileStateChange?.(false, "");
492
+ // Nothing matched — close both pickers (only if open)
493
+ if (slashPickerOpenRef.current) { onSlashStateChange?.(false, ""); slashPickerOpenRef.current = false; }
494
+ if (filePickerOpenRef.current) { onFileStateChange?.(false, ""); filePickerOpenRef.current = false; }
472
495
  },
473
496
  [onSlashStateChange, onFileStateChange, fetchSlashSearch],
474
497
  );
475
498
 
476
- const handleChange = useCallback(
477
- (text: string, cursorPos: number) => {
478
- setValue(text);
479
- updatePickerState(text, cursorPos);
499
+ /** Unified onChange for both textareas — updates ref, syncs other textarea, triggers picker */
500
+ const handleTextareaChange = useCallback(
501
+ (e: React.ChangeEvent<HTMLTextAreaElement>) => {
502
+ const el = e.target;
503
+ const text = el.value;
504
+ valueRef.current = text;
505
+ // Sync the other textarea (handles viewport rotation edge case)
506
+ const other = el === textareaRef.current ? mobileTextareaRef.current : textareaRef.current;
507
+ if (other) other.value = text;
508
+ // Only trigger re-render on empty↔non-empty transition (for send button state)
509
+ setHasText(text.trim().length > 0);
510
+ // Update picker state (slash/file autocomplete)
511
+ updatePickerState(text, el.selectionStart);
512
+ // rAF-debounced resize
513
+ if (resizeRafRef.current) cancelAnimationFrame(resizeRafRef.current);
514
+ resizeRafRef.current = requestAnimationFrame(() => {
515
+ resizeRafRef.current = 0;
516
+ el.style.height = "auto";
517
+ el.style.height = Math.min(el.scrollHeight, el === mobileTextareaRef.current ? 80 : 160) + "px";
518
+ });
480
519
  },
481
520
  [updatePickerState],
482
521
  );
483
522
 
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
523
  /** Handle paste — intercept images from clipboard */
497
524
  const handlePaste = useCallback(
498
525
  (e: ClipboardEvent<HTMLTextAreaElement>) => {
@@ -543,7 +570,7 @@ export const MessageInput = memo(function MessageInput({
543
570
  [processFiles],
544
571
  );
545
572
 
546
- const hasContent = value.trim().length > 0 || attachments.some((a) => a.status !== "error");
573
+ const hasContent = hasText || attachments.some((a) => a.status !== "error");
547
574
  const showCancel = isStreaming && !hasContent;
548
575
 
549
576
  return (
@@ -555,11 +582,7 @@ export const MessageInput = memo(function MessageInput({
555
582
  if (disabled) return;
556
583
  // Only focus when clicking outside the textarea (e.g. padding area)
557
584
  if (e.target instanceof HTMLTextAreaElement) return;
558
- // Pick the visible textarea based on viewport width
559
- const ta = window.matchMedia("(min-width: 768px)").matches
560
- ? textareaRef.current
561
- : mobileTextareaRef.current;
562
- ta?.focus();
585
+ getVisibleTextarea()?.focus();
563
586
  }}
564
587
  >
565
588
  {/* Attachment chips (inside container, aligned with input) */}
@@ -598,8 +621,8 @@ export const MessageInput = memo(function MessageInput({
598
621
  </button>
599
622
  <textarea
600
623
  ref={mobileTextareaRef}
601
- value={value}
602
- onChange={(e) => { handleChange(e.target.value, e.target.selectionStart); handleInput(e); }}
624
+ defaultValue={initialValue ?? ""}
625
+ onChange={handleTextareaChange}
603
626
  onKeyDown={handleKeyDown}
604
627
  onPaste={handlePaste}
605
628
  onDrop={handleDrop}
@@ -648,8 +671,8 @@ export const MessageInput = memo(function MessageInput({
648
671
  <div className="hidden md:block">
649
672
  <textarea
650
673
  ref={textareaRef}
651
- value={value}
652
- onChange={(e) => { handleChange(e.target.value, e.target.selectionStart); handleInput(e); }}
674
+ defaultValue={initialValue ?? ""}
675
+ onChange={handleTextareaChange}
653
676
  onKeyDown={handleKeyDown}
654
677
  onPaste={handlePaste}
655
678
  onDrop={handleDrop}